feat(audio): audio system
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user