feat(audio): audio system
This commit is contained in:
@@ -2,13 +2,18 @@
|
||||
const app = useAppStore();
|
||||
const { isReady } = useAssets();
|
||||
const { t } = useI18n();
|
||||
|
||||
const onClick = () => {
|
||||
if (!isReady.value) return;
|
||||
app.userHasInteracted = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 bg-[#181818] flex flex-col items-center justify-center gap-4"
|
||||
:class="{ 'cursor-pointer': isReady }"
|
||||
@click="isReady && (app.userHasInteracted = true)"
|
||||
@click="onClick"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
|
||||
@@ -7,6 +7,18 @@ type Rect = [number, number, number, number];
|
||||
let atlasImage: HTMLImageElement | null = null;
|
||||
const modelCache = new Map<string, THREE.Group>();
|
||||
|
||||
const createAudio = (path: string) => {
|
||||
const audio = import.meta.client ? new Audio(path) : null;
|
||||
|
||||
return {
|
||||
play: () => {
|
||||
if (!audio) return;
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const loaded = ref(0);
|
||||
const total = ref({{TOTAL}});
|
||||
const isReady = computed(() => loaded.value === total.value);
|
||||
@@ -93,9 +105,16 @@ type ModelTree = {
|
||||
[key: string]: THREE.Group | ModelTree;
|
||||
};
|
||||
|
||||
export type AudioEntry = ReturnType<typeof createAudio>;
|
||||
|
||||
type AudioTree = {
|
||||
[key: string]: AudioEntry | AudioTree;
|
||||
};
|
||||
|
||||
const assets = {
|
||||
images: {{IMAGES}} as const satisfies ImageTree,
|
||||
models: {{MODELS}} as const satisfies ModelTree,
|
||||
audio: {{AUDIO}} as const satisfies AudioTree,
|
||||
};
|
||||
|
||||
type Assets = typeof assets;
|
||||
|
||||
@@ -21,6 +21,10 @@ type ModelTree = {
|
||||
[key: string]: string | ModelTree;
|
||||
};
|
||||
|
||||
type AudioTree = {
|
||||
[key: string]: string | AudioTree;
|
||||
};
|
||||
|
||||
type ImageData = {
|
||||
path: string;
|
||||
buffer: Buffer;
|
||||
@@ -30,6 +34,7 @@ type ImageData = {
|
||||
|
||||
const IMAGE_EXTENSIONS = [".png", ".webp"];
|
||||
const MODEL_EXTENSIONS = [".glb"];
|
||||
const AUDIO_EXTENSIONS = [".mp3", ".ogg", ".wav"];
|
||||
const MAX_WIDTH = 2048;
|
||||
|
||||
const toCamelCase = (str: string) => {
|
||||
@@ -71,6 +76,7 @@ export default defineNuxtModule({
|
||||
const rootDir = nuxt.options.rootDir;
|
||||
const imagesDir = join(rootDir, "app/assets/nds/images");
|
||||
const modelsDir = join(rootDir, "public/nds/models");
|
||||
const audioDir = join(rootDir, "public/nds/audios");
|
||||
const templateFile = join(rootDir, "app/composables/useAssets.ts.in");
|
||||
const outputFile = join(rootDir, "app/composables/useAssets.ts");
|
||||
const atlasOutputPath = join(rootDir, "public/nds/atlas.webp");
|
||||
@@ -214,10 +220,46 @@ ${sp} }`;
|
||||
return `{\n${lines.join(",\n")},\n${sp}}`;
|
||||
};
|
||||
|
||||
const buildAudioTree = (audioPaths: string[]): AudioTree => {
|
||||
const tree: AudioTree = {};
|
||||
|
||||
for (const path of audioPaths) {
|
||||
const parts = relative(audioDir, path).split("/");
|
||||
const fileName = parse(parts.at(-1)!).name;
|
||||
|
||||
let node = tree;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const key = toCamelCase(part);
|
||||
node[key] ??= {};
|
||||
node = node[key] as AudioTree;
|
||||
}
|
||||
|
||||
node[toCamelCase(fileName)] = `/nds/audios/${relative(audioDir, path)}`;
|
||||
}
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
const generateAudioCode = (tree: AudioTree, indent = 0): string => {
|
||||
const entries = Object.entries(tree);
|
||||
if (!entries.length) return "{}";
|
||||
|
||||
const sp = " ".repeat(indent);
|
||||
const lines = entries.map(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
return `${sp} ${key}: createAudio("${value}")`;
|
||||
}
|
||||
return `${sp} ${key}: ${generateAudioCode(value, indent + 1)}`;
|
||||
});
|
||||
|
||||
return `{\n${lines.join(",\n")}\n${sp}}`;
|
||||
};
|
||||
|
||||
const generateAssets = async () => {
|
||||
try {
|
||||
const imagePaths = await scanByExt(imagesDir, IMAGE_EXTENSIONS);
|
||||
const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS);
|
||||
const audioPaths = await scanByExt(audioDir, AUDIO_EXTENSIONS);
|
||||
|
||||
const totalAssets = (imagePaths.length > 0 ? 1 : 0) + modelPaths.length;
|
||||
|
||||
@@ -271,16 +313,20 @@ ${sp} }`;
|
||||
const modelTree = buildModelTree(modelPaths);
|
||||
const modelCode = generateModelCode(modelTree);
|
||||
|
||||
const audioTree = buildAudioTree(audioPaths);
|
||||
const audioCode = generateAudioCode(audioTree);
|
||||
|
||||
const template = await readFile(templateFile, "utf-8");
|
||||
const code = template
|
||||
.replace("{{TOTAL}}", totalAssets.toString())
|
||||
.replace("{{ATLAS_HASH}}", atlasHash)
|
||||
.replace("{{IMAGES}}", imageCode)
|
||||
.replace("{{MODELS}}", modelCode);
|
||||
.replace("{{MODELS}}", modelCode)
|
||||
.replace("{{AUDIO}}", audioCode);
|
||||
|
||||
await writeFile(outputFile, code, "utf-8");
|
||||
logger.success(
|
||||
`Generated assets with ${imagePaths.length} images (${atlasWidth}x${atlasHeight}) and ${modelPaths.length} models`,
|
||||
`Generated assets with ${imagePaths.length} images (${atlasWidth}x${atlasHeight}), ${modelPaths.length} models and ${audioPaths.length} audio files`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error generating assets:", error);
|
||||
@@ -308,6 +354,7 @@ ${sp} }`;
|
||||
|
||||
nuxt.hook("ready", () => {
|
||||
watchAndRegenerate(imagesDir, IMAGE_EXTENSIONS, "image");
|
||||
watchAndRegenerate(audioDir, AUDIO_EXTENSIONS, "audio");
|
||||
watchAndRegenerate(modelsDir, MODEL_EXTENSIONS, "model");
|
||||
watch(templateFile, () => {
|
||||
logger.info("Template file changed");
|
||||
|
||||
Reference in New Issue
Block a user