feat(audio): audio system

This commit is contained in:
2026-02-24 17:34:01 +01:00
parent 8a67577d36
commit ef00bd06bb
3 changed files with 74 additions and 3 deletions

View File

@@ -2,13 +2,18 @@
const app = useAppStore(); const app = useAppStore();
const { isReady } = useAssets(); const { isReady } = useAssets();
const { t } = useI18n(); const { t } = useI18n();
const onClick = () => {
if (!isReady.value) return;
app.userHasInteracted = true;
};
</script> </script>
<template> <template>
<div <div
class="fixed inset-0 bg-[#181818] flex flex-col items-center justify-center gap-4" class="fixed inset-0 bg-[#181818] flex flex-col items-center justify-center gap-4"
:class="{ 'cursor-pointer': isReady }" :class="{ 'cursor-pointer': isReady }"
@click="isReady && (app.userHasInteracted = true)" @click="onClick"
> >
<svg <svg
viewBox="0 0 512 512" viewBox="0 0 512 512"

View File

@@ -7,6 +7,18 @@ type Rect = [number, number, number, number];
let atlasImage: HTMLImageElement | null = null; let atlasImage: HTMLImageElement | null = null;
const modelCache = new Map<string, THREE.Group>(); 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 loaded = ref(0);
const total = ref({{TOTAL}}); const total = ref({{TOTAL}});
const isReady = computed(() => loaded.value === total.value); const isReady = computed(() => loaded.value === total.value);
@@ -93,9 +105,16 @@ type ModelTree = {
[key: string]: THREE.Group | ModelTree; [key: string]: THREE.Group | ModelTree;
}; };
export type AudioEntry = ReturnType<typeof createAudio>;
type AudioTree = {
[key: string]: AudioEntry | AudioTree;
};
const assets = { const assets = {
images: {{IMAGES}} as const satisfies ImageTree, images: {{IMAGES}} as const satisfies ImageTree,
models: {{MODELS}} as const satisfies ModelTree, models: {{MODELS}} as const satisfies ModelTree,
audio: {{AUDIO}} as const satisfies AudioTree,
}; };
type Assets = typeof assets; type Assets = typeof assets;

View File

@@ -21,6 +21,10 @@ type ModelTree = {
[key: string]: string | ModelTree; [key: string]: string | ModelTree;
}; };
type AudioTree = {
[key: string]: string | AudioTree;
};
type ImageData = { type ImageData = {
path: string; path: string;
buffer: Buffer; buffer: Buffer;
@@ -30,6 +34,7 @@ type ImageData = {
const IMAGE_EXTENSIONS = [".png", ".webp"]; const IMAGE_EXTENSIONS = [".png", ".webp"];
const MODEL_EXTENSIONS = [".glb"]; const MODEL_EXTENSIONS = [".glb"];
const AUDIO_EXTENSIONS = [".mp3", ".ogg", ".wav"];
const MAX_WIDTH = 2048; const MAX_WIDTH = 2048;
const toCamelCase = (str: string) => { const toCamelCase = (str: string) => {
@@ -71,6 +76,7 @@ export default defineNuxtModule({
const rootDir = nuxt.options.rootDir; const rootDir = nuxt.options.rootDir;
const imagesDir = join(rootDir, "app/assets/nds/images"); const imagesDir = join(rootDir, "app/assets/nds/images");
const modelsDir = join(rootDir, "public/nds/models"); const modelsDir = join(rootDir, "public/nds/models");
const audioDir = join(rootDir, "public/nds/audios");
const templateFile = join(rootDir, "app/composables/useAssets.ts.in"); const templateFile = join(rootDir, "app/composables/useAssets.ts.in");
const outputFile = join(rootDir, "app/composables/useAssets.ts"); const outputFile = join(rootDir, "app/composables/useAssets.ts");
const atlasOutputPath = join(rootDir, "public/nds/atlas.webp"); const atlasOutputPath = join(rootDir, "public/nds/atlas.webp");
@@ -214,10 +220,46 @@ ${sp} }`;
return `{\n${lines.join(",\n")},\n${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 () => { const generateAssets = async () => {
try { try {
const imagePaths = await scanByExt(imagesDir, IMAGE_EXTENSIONS); const imagePaths = await scanByExt(imagesDir, IMAGE_EXTENSIONS);
const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS); const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS);
const audioPaths = await scanByExt(audioDir, AUDIO_EXTENSIONS);
const totalAssets = (imagePaths.length > 0 ? 1 : 0) + modelPaths.length; const totalAssets = (imagePaths.length > 0 ? 1 : 0) + modelPaths.length;
@@ -271,16 +313,20 @@ ${sp} }`;
const modelTree = buildModelTree(modelPaths); const modelTree = buildModelTree(modelPaths);
const modelCode = generateModelCode(modelTree); const modelCode = generateModelCode(modelTree);
const audioTree = buildAudioTree(audioPaths);
const audioCode = generateAudioCode(audioTree);
const template = await readFile(templateFile, "utf-8"); const template = await readFile(templateFile, "utf-8");
const code = template const code = template
.replace("{{TOTAL}}", totalAssets.toString()) .replace("{{TOTAL}}", totalAssets.toString())
.replace("{{ATLAS_HASH}}", atlasHash) .replace("{{ATLAS_HASH}}", atlasHash)
.replace("{{IMAGES}}", imageCode) .replace("{{IMAGES}}", imageCode)
.replace("{{MODELS}}", modelCode); .replace("{{MODELS}}", modelCode)
.replace("{{AUDIO}}", audioCode);
await writeFile(outputFile, code, "utf-8"); await writeFile(outputFile, code, "utf-8");
logger.success( 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) { } catch (error) {
logger.error("Error generating assets:", error); logger.error("Error generating assets:", error);
@@ -308,6 +354,7 @@ ${sp} }`;
nuxt.hook("ready", () => { nuxt.hook("ready", () => {
watchAndRegenerate(imagesDir, IMAGE_EXTENSIONS, "image"); watchAndRegenerate(imagesDir, IMAGE_EXTENSIONS, "image");
watchAndRegenerate(audioDir, AUDIO_EXTENSIONS, "audio");
watchAndRegenerate(modelsDir, MODEL_EXTENSIONS, "model"); watchAndRegenerate(modelsDir, MODEL_EXTENSIONS, "model");
watch(templateFile, () => { watch(templateFile, () => {
logger.info("Template file changed"); logger.info("Template file changed");