feat(nds): add audio in all menus

This commit is contained in:
2026-02-25 14:52:48 +01:00
parent 4af6de5329
commit 858082e151
45 changed files with 240 additions and 117 deletions

View File

@@ -1,58 +1,14 @@
<script setup lang="ts">
const { onRender } = useScreen();
const { assets } = useAssets();
const app = useAppStore();
const store = useHomeStore();
const { assets } = useAssets();
const tickClock = useClockTick();
const CENTER_X = 63;
const CENTER_Y = 95;
function drawLine(
ctx: CanvasRenderingContext2D,
x0: number,
y0: number,
x1: number,
y1: number,
width: number,
) {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
const drawThickPixel = (x: number, y: number) => {
const isVertical = dy > dx;
if (width === 1) {
ctx.fillRect(x, y, 1, 1);
} else if (isVertical) {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x - offset, y, width, 1);
} else {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x, y - offset, 1, width);
}
};
while (true) {
drawThickPixel(x0, y0);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
}
onRender((ctx) => {
ctx.globalAlpha = store.isIntro
? store.intro.topScreenOpacity
@@ -66,7 +22,9 @@ onRender((ctx) => {
: store.isOutro && store.outro.animateTop
? store.outro.stage2Opacity
: 1;
const now = new Date();
tickClock(now);
const renderHand = (
value: number,

View File

@@ -58,6 +58,8 @@ const BIG_CIRCLE_DURATION = 0.09;
const POST_DURATION = 0.07;
const startButtonAnimation = (type: ButtonType) => {
assets.audio.pkmnButton.play();
const anim: ButtonAnimation = {
type,
position: BUTTONS[type].position,

View File

@@ -53,13 +53,20 @@ useKeyDown(({ key }) => {
switch (key) {
case "NDS_UP":
if (selectedOption !== "yes") {
selectedOption = "yes";
assets.audio.pkmnSelector.play();
}
break;
case "NDS_DOWN":
if (selectedOption !== "no") {
selectedOption = "no";
assets.audio.pkmnSelector.play();
}
break;
case "NDS_A":
case "NDS_START":
assets.audio.pkmnSelector.play();
setTimeout(() => {
if (selectedOption === "yes") store.visitProject();
store.showConfirmationPopup = false;
@@ -90,12 +97,14 @@ onClick((x, y) => {
const ACTIVATION_DELAY = 50;
if (rectContains([198, 105, 50, 14], [x, y])) {
assets.audio.pkmnSelector.play();
selectedOption = "yes";
setTimeout(() => {
store.visitProject();
store.showConfirmationPopup = false;
}, ACTIVATION_DELAY);
} else if (rectContains([198, 121, 50, 14], [x, y])) {
assets.audio.pkmnSelector.play();
selectedOption = "no";
setTimeout(() => (store.showConfirmationPopup = false), ACTIVATION_DELAY);
}

View File

@@ -131,6 +131,7 @@ const handleReset = () => {
const handleVisitAll = () => {
if (isAnimating.value) return;
assets.audio.menuOpen.play();
achievementsScreen.animateFadeToBlackIntro();
};

View File

@@ -71,11 +71,17 @@ const { select, selected, pressed, selectorPosition } = useButtonNavigation({
onActivate: (buttonName) => {
if (isSubMenu(buttonName)) {
store.openSubMenu(buttonName);
} else if (buttonName === "touchScreen") {
store.openSubMenu("touchScreenTapTap");
} else {
if (!store.menuExpanded) {
assets.audio.settingsMenuOpen.play();
} else {
assets.audio.tinyClick.play(0.8);
}
if (buttonName === "options") select("optionsLanguage");
if (buttonName === "clock") select("clockAchievements");
if (buttonName === "user") select("userUserName");
if (buttonName === "touchScreen") store.openSubMenu("touchScreenTapTap");
}
},
navigation: {
@@ -156,6 +162,21 @@ const { select, selected, pressed, selectorPosition } = useButtonNavigation({
}
return true;
},
onNavigate: (buttonName) => {
if (isMainMenu(buttonName)) {
if (store.menuExpanded) {
assets.audio.settingsMenuClose.play();
} else {
assets.audio.tinyClick.play(0.8);
}
} else {
if (!store.menuExpanded) {
assets.audio.settingsMenuOpen.play();
} else {
assets.audio.tinyClick.play(0.8);
}
}
},
disabled: computed(
() =>
store.currentSubMenu !== null ||
@@ -256,12 +277,17 @@ const handleActivateA = () => {
if (isSubMenu(selected.value)) {
store.openSubMenu(selected.value);
} else if (selected.value === "touchScreen") {
store.openSubMenu("touchScreenTapTap");
} else {
if (!store.menuExpanded) {
assets.audio.settingsMenuOpen.play();
} else {
assets.audio.tinyClick.play(0.8);
}
if (selected.value === "options") select("optionsLanguage");
if (selected.value === "clock") select("clockAchievements");
if (selected.value === "user") select("userUserName");
if (selected.value === "touchScreen")
store.openSubMenu("touchScreenTapTap");
}
};
@@ -270,6 +296,7 @@ const handleActivateB = () => {
return;
if (isSubmenuSelected.value) {
assets.audio.settingsMenuClose.play();
select(getParentMenu(selected.value));
} else {
store.animateOutro();

View File

@@ -17,7 +17,7 @@ const handleActivateB = () => {
onClosed: async (choice) => {
if (choice === "A") {
await animateOutro();
store.closeSubMenu();
store.closeSubMenu(true);
}
},
keepButtonsDown: (choice) => choice === "A",
@@ -460,6 +460,8 @@ const slide = (rowDir: number, colDir: number) => {
);
if (!changed) return;
assets.audio.type.play(0.35);
if (board.some((r) => r.some((c) => c >= 512))) {
achievements.unlock("2048_score_512");
}
@@ -534,11 +536,20 @@ const slide = (rowDir: number, colDir: number) => {
}
}
if (mergePairs.length > 0) {
const highestMerge = Math.max(...mergePairs.map((p) => p.mergedValue));
const boardMax = Math.max(0, ...beforeTiles.map((t) => t.value));
if (highestMerge > boardMax) {
assets.audio.duplicate.play(0.35);
}
}
const spawned = spawnTile();
saveState();
if (isDead()) {
buildTilesFromBoard();
assets.audio.invalid.play();
showRestartModal();
return;
}

View File

@@ -181,7 +181,7 @@ const handleActivateA = () => {
),
onClosed: async () => {
await animateOutro();
store.closeSubMenu();
store.closeSubMenu(true);
},
keepButtonsDown: true,
timeout: 2000,

View File

@@ -120,7 +120,7 @@ const handleActivateA = () => {
: $t("settings.options.renderingMode.confirmation2d"),
onClosed: async () => {
await animateOutro();
store.closeSubMenu();
store.closeSubMenu(true);
},
keepButtonsDown: true,
timeout: 2000,

View File

@@ -6,6 +6,7 @@ const app = useAppStore();
const store = useSettingsStore();
const achievements = useAchievementsStore();
const confirmationModal = useConfirmationModal();
const { assets } = useAssets();
const { onRender, onClick } = useScreen();
@@ -161,7 +162,7 @@ const handleActivateB = () => {
onClosed: async (choice) => {
if (choice === "A") {
await animateOutro();
store.closeSubMenu();
store.closeSubMenu(true);
} else {
state.value = "playing";
}
@@ -190,6 +191,7 @@ const handleActivateA = async () => {
},
});
} else if (state.value === "waiting") {
assets.audio.menuConfirmed.play();
await gsap.to(animation, {
areaOpacity: 0,
duration: AREA_FADE_DURATION,
@@ -264,7 +266,7 @@ const showDeathScreen = () => {
resetGame();
} else {
await animateOutro();
store.closeSubMenu();
store.closeSubMenu(true);
}
},
keepButtonsDown: (choice) => choice === "B",
@@ -286,6 +288,7 @@ onClick((mx, my) => {
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= circle.radius) {
assets.audio.type.play(0.35);
rings.push({
x: circle.x,
y: circle.y,
@@ -336,7 +339,10 @@ onRender((ctx, deltaTime) => {
lives--;
if (lives <= 0) {
state.value = "ended";
assets.audio.invalid.play();
showDeathScreen();
} else {
assets.audio.eraser.play();
}
return false;
}

View File

@@ -88,7 +88,7 @@ const handleActivateA = () => {
text,
onClosed: async () => {
await animateOutro();
store.closeSubMenu();
store.closeSubMenu(true);
},
keepButtonsDown: true,
timeout: 2000,

View File

@@ -156,16 +156,16 @@ useKeyDown(({ key }) => {
switch (key) {
case "NDS_UP":
if (selectedRow > 0) select(selectedCol, selectedRow - 1);
if (selectedRow > 0) { assets.audio.tinyClick.play(0.8); select(selectedCol, selectedRow - 1); }
break;
case "NDS_RIGHT":
if (selectedCol < GRID_SIZE - 1) select(selectedCol + 1, selectedRow);
if (selectedCol < GRID_SIZE - 1) { assets.audio.tinyClick.play(0.8); select(selectedCol + 1, selectedRow); }
break;
case "NDS_DOWN":
if (selectedRow < GRID_SIZE - 1) select(selectedCol, selectedRow + 1);
if (selectedRow < GRID_SIZE - 1) { assets.audio.tinyClick.play(0.8); select(selectedCol, selectedRow + 1); }
break;
case "NDS_LEFT":
if (selectedCol > 0) select(selectedCol - 1, selectedRow);
if (selectedCol > 0) { assets.audio.tinyClick.play(0.8); select(selectedCol - 1, selectedRow); }
break;
}
});
@@ -186,6 +186,7 @@ onClick((x, y) => {
rectContains([0, 0, GRID_SIZE - 1, GRID_SIZE - 1], [col, row]) &&
rectContains([0, 0, CELL_SIZE + 1, CELL_SIZE + 1], [cellLocalX, cellLocalY])
) {
assets.audio.tinyClick.play(0.8);
select(col, row);
}
});
@@ -288,7 +289,7 @@ const handleActivateA = () => {
text: $t("settings.user.color.confirmation"),
onClosed: async () => {
await animateOutro();
store.closeSubMenu();
store.closeSubMenu(true);
},
keepButtonsDown: true,
timeout: 2000,

View File

@@ -120,7 +120,7 @@ const handleActivateB = async () => {
onClosed: async (choice) => {
if (choice === "A") {
await animateOutro();
store.closeSubMenu();
store.closeSubMenu(true);
} else {
state.value = "alive";
}
@@ -160,6 +160,7 @@ const handleActivateA = () => {
}
case "waiting": {
atlas.audio.menuConfirmed.play();
spawn();
break;
}
@@ -207,6 +208,8 @@ const eat = () => {
food.copy(randomFoodPos());
score += 1;
atlas.audio.duplicate.play(0.35);
if (score === 40) {
achievements.unlock("snake_score_25");
}
@@ -214,6 +217,7 @@ const eat = () => {
const die = () => {
state.value = "dead";
atlas.audio.invalid.play();
};
const spawn = () => {
@@ -351,6 +355,7 @@ useKeyDown(({ key }) => {
if (newDirection.clone().dot(direction) === 0) {
nextDirection.copy(newDirection);
atlas.audio.type.play(0.35);
}
});

View File

@@ -1,64 +1,21 @@
<script setup lang="ts">
const { onRender } = useScreen();
const { assets } = useAssets();
const app = useAppStore();
const store = useSettingsStore();
const { assets } = useAssets();
const tickClock = useClockTick();
const CENTER_X = 63;
const CENTER_Y = 95;
function drawLine(
ctx: CanvasRenderingContext2D,
x0: number,
y0: number,
x1: number,
y1: number,
width: number,
) {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
const drawThickPixel = (x: number, y: number) => {
const isVertical = dy > dx;
if (width === 1) {
ctx.fillRect(x, y, 1, 1);
} else if (isVertical) {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x - offset, y, width, 1);
} else {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x, y - offset, 1, width);
}
};
while (true) {
drawThickPixel(x0, y0);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
}
onRender((ctx) => {
ctx.translate(0, -16 + store.notificationYOffset / 3);
assets.images.home.topScreen.clock.draw(ctx, 13, 45);
const now = new Date();
tickClock(now);
const renderHand = (
value: number,

View File

@@ -7,14 +7,18 @@ type Rect = [number, number, number, number];
let atlasImage: HTMLImageElement | null = null;
const modelCache = new Map<string, THREE.Group>();
const createAudio = (path: string) => ({
play: () => {
if (!import.meta.client) return;
const audio = new Audio(path);
const createAudio = (path: string) => {
const source = import.meta.client ? new Audio(path) : null;
return {
play: (volume = 1) => {
if (!source) return;
const audio = source.cloneNode() as HTMLAudioElement;
audio.volume = volume;
audio.addEventListener("ended", () => audio.remove(), { once: true });
audio.play().catch(() => {});
},
});
};
};
const loaded = ref(0);
const total = ref({{TOTAL}});
@@ -104,6 +108,7 @@ type ModelTree = {
export type AudioEntry = ReturnType<typeof createAudio>;
type AudioTree = {
[key: string]: AudioEntry | AudioTree;
};

View File

@@ -5,6 +5,7 @@ export const useButtonNavigation = <T extends Record<string, Rect>>({
initialButton,
canClickButton,
onActivate,
onNavigate,
navigation,
disabled,
selectorAnimation,
@@ -13,6 +14,7 @@ export const useButtonNavigation = <T extends Record<string, Rect>>({
initialButton: keyof T;
canClickButton?: (buttonName: keyof T) => boolean;
onActivate?: (buttonName: keyof T) => void;
onNavigate?: (buttonName: keyof T) => void;
navigation: Record<
keyof T,
{
@@ -231,6 +233,12 @@ export const useButtonNavigation = <T extends Record<string, Rect>>({
if (selectedButton.value === buttonName) {
onActivate?.(buttonName);
} else {
if (onNavigate) {
onNavigate(buttonName);
} else {
const { assets } = useAssets();
assets.audio.tinyClick.play(0.8);
}
const path = findPath(graph, selectedButton.value, buttonName);
if (
@@ -356,6 +364,12 @@ export const useButtonNavigation = <T extends Record<string, Rect>>({
}
if (targetButton) {
if (onNavigate) {
onNavigate(targetButton);
} else {
const { assets } = useAssets();
assets.audio.tinyClick.play(0.8);
}
const path = findPath(graph, selectedButton.value, targetButton);
animateToButton(targetButton, path);
}

View File

@@ -0,0 +1,12 @@
let lastSecond = -1;
export const useClockTick = () => {
const { assets } = useAssets();
return (now: Date) => {
const s = now.getSeconds();
if (s === lastSecond) return;
lastSecond = s;
assets.audio.clockTick.play(s === 0 ? 1 : 0.7);
};
};

View File

@@ -63,6 +63,9 @@ export const useAchievementsStore = defineStore("achievements", () => {
storage.value.unlocked.push(name);
const { assets } = useAssets();
assets.audio.messageReceived.play(0.5);
if (storage.value.unlocked.length === ACHIEVEMENTS.length) {
confetti.spawn();
} else {

View File

@@ -137,6 +137,9 @@ export const useAchievementsScreen = defineStore("achievementsScreen", {
this.isIntro = false;
this.isOutro = true;
const { assets } = useAssets();
assets.audio.menuConfirmed.play();
gsap
.timeline()
.fromTo(

View File

@@ -45,6 +45,18 @@ export const useConfirmationModal = defineStore("confirmationModal", {
this.isClosing = false;
this.isOpen = true;
const { assets } = useAssets();
if (
options.aLabel !== undefined ||
options.bLabel !== undefined ||
options.onActivateA !== undefined ||
options.onActivateB !== undefined
) {
assets.audio.menuOpen.play();
} else {
assets.audio.menuConfirmed.play();
}
gsap
.timeline()
// standard buttons down
@@ -83,6 +95,20 @@ export const useConfirmationModal = defineStore("confirmationModal", {
this.isClosing = true;
if (
this.aLabel !== null ||
this.bLabel !== null ||
this.onActivateA !== null ||
this.onActivateB !== null
) {
const { assets } = useAssets();
if (choice === "A") {
assets.audio.menuConfirmed.play();
} else {
assets.audio.menuError.play();
}
}
const keepButtonsDown =
typeof this.keepButtonsDown === "function"
? this.keepButtonsDown(choice)

View File

@@ -77,6 +77,9 @@ export const useContactStore = defineStore("contact", {
pushNotification(content: string) {
this.notifications.push(content);
const { assets } = useAssets();
assets.audio.messageSent.play();
gsap.fromTo(
this,
{ notificationsYOffset: 20 },
@@ -92,6 +95,9 @@ export const useContactStore = defineStore("contact", {
animateOutro() {
this.isOutro = true;
const { assets } = useAssets();
assets.audio.menuConfirmed.play();
const timeline = gsap.timeline({
onComplete: () => {
setTimeout(() => {

View File

@@ -113,6 +113,9 @@ export const useCreditsStore = defineStore("credits", {
this.isIntro = false;
this.isOutro = true;
const { assets } = useAssets();
assets.audio.menuConfirmed.play();
gsap
.timeline()
.fromTo(

View File

@@ -102,6 +102,9 @@ export const useHomeStore = defineStore("home", {
},
animateOutro(to: AppScreen) {
const { assets } = useAssets();
assets.audio.menuOpen.play();
if (to === "achievements") {
const achievementsScreen = useAchievementsScreen();
achievementsScreen.animateFadeToBlackIntro();

View File

@@ -32,6 +32,15 @@ export const useIntroStore = defineStore("intro", {
this.isIntro = false;
},
})
.call(
() => {
const now = new Date();
const isBirthday = now.getMonth() === 3 && now.getDate() === 25;
(isBirthday ? assets.audio.birthdayStartup : assets.audio.startUp).play();
},
undefined,
delay,
)
.to(
this.intro,
{
@@ -59,6 +68,9 @@ export const useIntroStore = defineStore("intro", {
animateOutro() {
this.isOutro = true;
const { assets } = useAssets();
assets.audio.tinyClick.play(0.8);
gsap
.timeline()
.to(this.outro, {

View File

@@ -68,6 +68,9 @@ export const useSettingsStore = defineStore("settings", {
},
async openSubMenu(submenu: SettingsSubMenu) {
const { assets } = useAssets();
assets.audio.menuOpen.play();
await gsap
.timeline()
.to(this.submenuTransition, {
@@ -99,7 +102,12 @@ export const useSettingsStore = defineStore("settings", {
}
},
async closeSubMenu() {
async closeSubMenu(silent = false) {
if (!silent) {
const { assets } = useAssets();
assets.audio.menuError.play();
}
await gsap
.timeline()
.to(this, {
@@ -148,6 +156,8 @@ export const useSettingsStore = defineStore("settings", {
animateIntro() {
this.isIntro = true;
const { assets } = useAssets();
gsap
.timeline()
// bars
@@ -178,6 +188,7 @@ export const useSettingsStore = defineStore("settings", {
{ 1: 0, duration: 0.1, ease: "none" },
0.2,
)
.call(() => assets.audio.settingsMenuIntro.play(), undefined, 0.2)
.fromTo(
this.menuOffsets,
{ 2: -48 },
@@ -198,8 +209,11 @@ export const useSettingsStore = defineStore("settings", {
animateOutro() {
this.isOutro = true;
const { assets } = useAssets();
gsap
.timeline()
.call(() => assets.audio.settingsMenuOutro.play())
// title notification
.fromTo(
this,

View File

@@ -1,3 +1,48 @@
export const drawLine = (
ctx: CanvasRenderingContext2D,
x0: number,
y0: number,
x1: number,
y1: number,
width: number,
) => {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
const drawThickPixel = (x: number, y: number) => {
const isVertical = dy > dx;
if (width === 1) {
ctx.fillRect(x, y, 1, 1);
} else if (isVertical) {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x - offset, y, width, 1);
} else {
const offset = Math.floor((width - 1) / 2);
ctx.fillRect(x, y - offset, 1, width);
}
};
while (true) {
drawThickPixel(x0, y0);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
};
export const fillTextCentered = (
ctx: CanvasRenderingContext2D,
text: string,

View File

@@ -76,7 +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 audioDir = join(rootDir, "public/nds/audio");
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");
@@ -234,7 +234,7 @@ ${sp} }`;
node = node[key] as AudioTree;
}
node[toCamelCase(fileName)] = `/nds/audios/${relative(audioDir, path)}`;
node[toCamelCase(fileName)] = `/nds/audio/${relative(audioDir, path)}`;
}
return tree;

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/nds/audio/eraser.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/nds/audio/type.mp3 Normal file

Binary file not shown.