feat(nds): add audio in all menus

This commit is contained in:
2026-02-25 14:52:48 +01:00
parent 61aec3da2e
commit bbe20150ed
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":
selectedOption = "yes";
if (selectedOption !== "yes") {
selectedOption = "yes";
assets.audio.pkmnSelector.play();
}
break;
case "NDS_DOWN":
selectedOption = "no";
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,