Compare commits

..

5 Commits

Author SHA1 Message Date
79479f681d feat: cache atlas and update nuxt version to win on compilation time
All checks were successful
Build and Deploy / build (push) Successful in 5m11s
Build and Deploy / deploy (push) Successful in 2s
2026-10-07 14:31:05 +02:00
822a66a20f refactor: add ignoreWhenModalOpen parameter to useKeyDown 2026-10-07 14:01:13 +02:00
f1ee6922b1 fix: hide help buttons on screen too small to click on physical buttons 2026-10-07 13:58:03 +02:00
06b96fe5ba fix: 2048 modal bug 2026-10-07 13:31:33 +02:00
1dc342d5c1 feat: update cv.pdf
All checks were successful
Build and Deploy / build (push) Successful in 2m12s
Build and Deploy / deploy (push) Successful in 1s
2026-09-28 22:24:17 +02:00
12 changed files with 8763 additions and 11135 deletions

View File

@@ -95,11 +95,9 @@ const handleTouchEnd = (event: TouchEvent) => {
); );
}; };
const ndsScale = computed(() => { const ndsScale = computed(() =>
const scaleX = (windowSize.width.value - 40) / 235; getNDS2DScale(windowSize.width.value, windowSize.height.value),
const scaleY = (windowSize.height.value - 40) / 431; );
return Math.min(scaleX, scaleY);
});
const gallery = useGalleryStore(); const gallery = useGalleryStore();
const TOP_SCREEN_OFFSET = 170; const TOP_SCREEN_OFFSET = 170;

View File

@@ -151,12 +151,15 @@ onClick((x, y) => {
} }
}); });
useKeyDown(({ key }) => { useKeyDown(
if (isAnimating.value) return; ({ key }) => {
if (key === "NDS_X") { if (isAnimating.value) return;
handleVisitAll(); if (key === "NDS_X") {
} handleVisitAll();
}); }
},
{ ignoreWhenModalOpen: true },
);
onRender((ctx) => { onRender((ctx) => {
// slash divider // slash divider

View File

@@ -141,7 +141,7 @@ const cellX = (col: number) => BORDER_SIZE + col * (TILE_SIZE + BORDER_SIZE);
const cellY = (row: number) => BORDER_SIZE + row * (TILE_SIZE + BORDER_SIZE); const cellY = (row: number) => BORDER_SIZE + row * (TILE_SIZE + BORDER_SIZE);
let tiles: VisualTile[] = []; let tiles: VisualTile[] = [];
let animating = false; let moveTimeline: gsap.core.Timeline | null = null;
const showRestartModal = () => { const showRestartModal = () => {
confirmationModal.open({ confirmationModal.open({
@@ -415,14 +415,14 @@ if (board.every((r) => r.every((c) => c === 0))) {
} }
const slide = (rowDir: number, colDir: number) => { const slide = (rowDir: number, colDir: number) => {
if (isDead() && !confirmationModal.isOpen) { if (isDead()) {
showRestartModal(); showRestartModal();
return; return;
} }
if (animating) { if (moveTimeline) {
gsap.globalTimeline.clear(); moveTimeline.kill();
animating = false; moveTimeline = null;
buildTilesFromBoard(); buildTilesFromBoard();
} }
@@ -553,14 +553,14 @@ const slide = (rowDir: number, colDir: number) => {
} }
tiles = animTiles; tiles = animTiles;
animating = true;
const tl = gsap.timeline({ const tl = gsap.timeline({
onComplete: () => { onComplete: () => {
animating = false; moveTimeline = null;
buildTilesFromBoard(); buildTilesFromBoard();
}, },
}); });
moveTimeline = tl;
for (const { tile, toX, toY } of tweens) { for (const { tile, toX, toY } of tweens) {
tl.to(tile, { x: toX, y: toY, duration: ANIM_DURATION, ease: "none" }, 0); tl.to(tile, { x: toX, y: toY, duration: ANIM_DURATION, ease: "none" }, 0);
@@ -600,27 +600,30 @@ const slide = (rowDir: number, colDir: number) => {
} }
}; };
useKeyDown(({ key }) => { useKeyDown(
if (isAnimating.value) return; ({ key }) => {
switch (key) { if (isAnimating.value) return;
case "NDS_UP": switch (key) {
case "NDS_SWIPE_UP": case "NDS_UP":
slide(-1, 0); case "NDS_SWIPE_UP":
break; slide(-1, 0);
case "NDS_DOWN": break;
case "NDS_SWIPE_DOWN": case "NDS_DOWN":
slide(1, 0); case "NDS_SWIPE_DOWN":
break; slide(1, 0);
case "NDS_LEFT": break;
case "NDS_SWIPE_LEFT": case "NDS_LEFT":
slide(0, -1); case "NDS_SWIPE_LEFT":
break; slide(0, -1);
case "NDS_RIGHT": break;
case "NDS_SWIPE_RIGHT": case "NDS_RIGHT":
slide(0, 1); case "NDS_SWIPE_RIGHT":
break; slide(0, 1);
} break;
}); }
},
{ ignoreWhenModalOpen: true },
);
</script> </script>
<template> <template>

View File

@@ -6,11 +6,17 @@ export type KeyDownCallback = (params: {
repeated: boolean; repeated: boolean;
}) => void; }) => void;
export const useKeyDown = (callback: KeyDownCallback) => { export const useKeyDown = (
callback: KeyDownCallback,
// the confirmation modal's own buttons rely on useKeyDown, so this is opt-in
options: { ignoreWhenModalOpen?: boolean } = {},
) => {
const app = useAppStore(); const app = useAppStore();
const confirmationModal = useConfirmationModal();
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (app.lagModalOpen) return; if (app.lagModalOpen) return;
if (options.ignoreWhenModalOpen && confirmationModal.isOpen) return;
const ndsButton = mapCodeToNDS(event.code); const ndsButton = mapCodeToNDS(event.code);
if ( if (

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { useWindowSize } from "@vueuse/core";
import gsap from "gsap"; import gsap from "gsap";
import { LazyLagModal } from "#components"; import { LazyLagModal } from "#components";
@@ -30,20 +29,6 @@ const bottomScreenCanvas = computed(() => bottomScreen.value?.canvas ?? null);
const isTouchDevice = () => const isTouchDevice = () =>
"ontouchstart" in window || navigator.maxTouchPoints > 0; "ontouchstart" in window || navigator.maxTouchPoints > 0;
const windowSize = useWindowSize();
watch(
[windowSize.width, windowSize.height],
([width, height]) => {
if (width / height > 614 / 667) {
if (app.booted) app.allowHints();
} else {
app.disallowHints();
}
},
{ immediate: true },
);
const helpButton = useTemplateRef("helpButton"); const helpButton = useTemplateRef("helpButton");
let helpAnimation: gsap.core.Timeline | null = null; let helpAnimation: gsap.core.Timeline | null = null;
@@ -105,11 +90,16 @@ const hideHelpLabels = () => {
}); });
}; };
// help button is removed when hints are disallowed, no need to animate it out
watch( watch(
() => app.hintsAllowed, () => app.hintsAllowed,
(allowed) => { (allowed) => {
if (!allowed) hideHelpLabels(); if (allowed) return;
helpAnimation?.kill();
helpAnimation = null;
app.hintsVisible = false;
}, },
{ immediate: true },
); );
onMounted(async () => { onMounted(async () => {
@@ -129,9 +119,7 @@ onMounted(async () => {
}); });
} }
const scaleX = (window.innerWidth - 40) / 235; const scale = getNDS2DScale(window.innerWidth, window.innerHeight);
const scaleY = (window.innerHeight - 40) / 431;
const scale = Math.min(scaleX, scaleY);
if (app.settings.renderingMode === "2d" && scale < 1) return; if (app.settings.renderingMode === "2d" && scale < 1) return;

View File

@@ -1,4 +1,5 @@
import { z } from "zod/mini"; import { z } from "zod/mini";
import { useWindowSize } from "@vueuse/core";
import type * as THREE from "three"; import type * as THREE from "three";
export const LOGICAL_WIDTH = 256; export const LOGICAL_WIDTH = 256;
@@ -18,6 +19,24 @@ export const APP_COLORS = [
const STORAGE_ID = "nds-settings"; const STORAGE_ID = "nds-settings";
// 2d nds is 414x431, scaled so the screens fit, it overflows horizontally on narrow viewports
export const getNDS2DScale = (width: number, height: number) =>
Math.min((width - 40) / 235, (height - 40) / 431);
// 3d: fixed vertical fov, so buttons clip at a fixed aspect ratio (measured: 810px wide at 865px tall)
const HINTS_MIN_ASPECT_RATIO_3D = 810 / 865;
// 2d: horizontal span of the physical buttons (A button is ~14px from the edge)
const NDS2D_BUTTONS_SPAN = 386;
const screenFitsHints = (
width: number,
height: number,
renderingMode: "3d" | "2d",
) =>
renderingMode === "3d"
? width / height > HINTS_MIN_ASPECT_RATIO_3D
: NDS2D_BUTTONS_SPAN * getNDS2DScale(width, height) <= width;
const settingsSchema = z.object({ const settingsSchema = z.object({
color: z.object({ color: z.object({
col: z.number(), col: z.number(),
@@ -55,7 +74,9 @@ export const useAppStore = defineStore("app", {
visitedGallery: false, visitedGallery: false,
camera: null as THREE.Camera | null, camera: null as THREE.Camera | null,
hintsVisible: false, hintsVisible: false,
hintsAllowed: false, // hints wanted by the current app state (intro done, not in gallery)
hintsRequested: false,
windowSize: useWindowSize(),
lagDetected: false, lagDetected: false,
lagModalOpen: false, lagModalOpen: false,
}; };
@@ -96,12 +117,11 @@ export const useAppStore = defineStore("app", {
}, },
allowHints() { allowHints() {
this.hintsAllowed = true; this.hintsRequested = true;
}, },
disallowHints() { disallowHints() {
this.hintsAllowed = false; this.hintsRequested = false;
this.hintsVisible = false;
}, },
showHints() { showHints() {
@@ -128,5 +148,14 @@ export const useAppStore = defineStore("app", {
row: state.settings.color.row, row: state.settings.color.row,
hex: APP_COLORS[state.settings.color.row]![state.settings.color.col]!, hex: APP_COLORS[state.settings.color.row]![state.settings.color.col]!,
}), }),
// hints wanted and screen large enough
hintsAllowed: (state) =>
state.hintsRequested &&
screenFitsHints(
state.windowSize.width,
state.windowSize.height,
state.settings.renderingMode,
),
}, },
}); });

View File

@@ -67,9 +67,7 @@ export const useIntroStore = defineStore("intro", {
) )
.call(() => { .call(() => {
const app = useAppStore(); const app = useAppStore();
if (window.innerWidth / window.innerHeight > 614 / 667) { app.allowHints();
app.allowHints();
}
}); });
}, },

View File

@@ -1,10 +1,14 @@
import { defineNuxtModule, useLogger } from "@nuxt/kit"; import { defineNuxtModule, useLogger } from "@nuxt/kit";
import { readdir, readFile, writeFile } from "fs/promises"; import { mkdir, readdir, readFile, writeFile } from "fs/promises";
import { join, relative, parse } from "path"; import { dirname, join, relative, parse } from "path";
import { existsSync, watch } from "fs"; import { existsSync, watch } from "fs";
import { execSync } from "child_process"; import { execFile } from "child_process";
import { createHash } from "crypto";
import { promisify } from "util";
import sharp from "sharp"; import sharp from "sharp";
const execFileAsync = promisify(execFile);
type AtlasRect = { type AtlasRect = {
x: number; x: number;
y: number; y: number;
@@ -79,6 +83,7 @@ export default defineNuxtModule({
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");
const atlasHashFile = join(rootDir, "node_modules/.cache/asset-generator/atlas-hash");
const loadImages = async ( const loadImages = async (
paths: string[], paths: string[],
@@ -88,10 +93,8 @@ export default defineNuxtModule({
await Promise.all( await Promise.all(
paths.map(async (path) => { paths.map(async (path) => {
try { try {
const [metadata, buffer] = await Promise.all([ const buffer = await readFile(path);
sharp(path).metadata(), const metadata = await sharp(buffer).metadata();
sharp(path).png().toBuffer(),
]);
if (metadata.width && metadata.height) { if (metadata.width && metadata.height) {
images.set(path, { images.set(path, {
@@ -256,7 +259,10 @@ ${sp} }`;
const generateAssets = async () => { const generateAssets = async () => {
try { try {
const imagePaths = await scanByExt(imagesDir, IMAGE_EXTENSIONS); // sorted so packing (and therefore the atlas hash) is deterministic
const imagePaths = (
await scanByExt(imagesDir, IMAGE_EXTENSIONS)
).sort();
const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS); const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS);
const audioPaths = await scanByExt(audioDir, AUDIO_EXTENSIONS); const audioPaths = await scanByExt(audioDir, AUDIO_EXTENSIONS);
@@ -266,43 +272,61 @@ ${sp} }`;
let atlasHash = ""; let atlasHash = "";
let atlasWidth = 0; let atlasWidth = 0;
let atlasHeight = 0; let atlasHeight = 0;
let atlasCached = false;
if (imagePaths.length > 0) { if (imagePaths.length > 0) {
const imageMap = await loadImages(imagePaths); const imageMap = await loadImages(imagePaths);
const images = Array.from(imageMap.values()); const images = imagePaths.flatMap((path) => imageMap.get(path) ?? []);
const { rects, width, height } = packImages(images); const { rects, width, height } = packImages(images);
atlasWidth = width; atlasWidth = width;
atlasHeight = height; atlasHeight = height;
const atlasBuffer = await sharp({
create: {
width,
height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(
images.map((img) => {
const rect = rects.get(img.path)!;
return { input: img.buffer, left: rect.x, top: rect.y };
}),
)
.webp({ lossless: true })
.toBuffer();
await writeFile(atlasOutputPath, atlasBuffer);
execSync(
`magick "${atlasOutputPath}" -define webp:lossless=true -define webp:method=6 "${atlasOutputPath}"`,
{ stdio: "pipe" },
);
atlasHash = Math.random().toString(36).slice(2, 10);
const imageTree = buildImageTree(imagePaths, rects); const imageTree = buildImageTree(imagePaths, rects);
imageCode = generateImageCode(imageTree); imageCode = generateImageCode(imageTree);
// imageCode covers names and layout, buffers cover pixels
const hash = createHash("sha256").update(imageCode);
for (const img of images) hash.update(img.buffer);
atlasHash = hash.digest("hex").slice(0, 8);
const cachedHash = await readFile(atlasHashFile, "utf-8").catch(
() => null,
);
atlasCached = cachedHash === atlasHash && existsSync(atlasOutputPath);
if (!atlasCached) {
const atlasBuffer = await sharp({
create: {
width,
height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(
images.map((img) => {
const rect = rects.get(img.path)!;
return { input: img.buffer, left: rect.x, top: rect.y };
}),
)
.webp({ lossless: true })
.toBuffer();
await writeFile(atlasOutputPath, atlasBuffer);
await execFileAsync("magick", [
atlasOutputPath,
"-define",
"webp:lossless=true",
"-define",
"webp:method=6",
atlasOutputPath,
]);
await mkdir(dirname(atlasHashFile), { recursive: true });
await writeFile(atlasHashFile, atlasHash, "utf-8");
}
} }
const modelTree = buildModelTree(modelPaths); const modelTree = buildModelTree(modelPaths);
@@ -319,9 +343,16 @@ ${sp} }`;
.replace("{{MODELS}}", modelCode) .replace("{{MODELS}}", modelCode)
.replace("{{AUDIO}}", audioCode); .replace("{{AUDIO}}", audioCode);
await writeFile(outputFile, code, "utf-8"); // rewriting an identical file would still invalidate it in Vite
const previousCode = await readFile(outputFile, "utf-8").catch(
() => null,
);
if (previousCode !== code) {
await writeFile(outputFile, code, "utf-8");
}
logger.success( logger.success(
`Generated assets with ${imagePaths.length} images (${atlasWidth}x${atlasHeight}), ${modelPaths.length} models and ${audioPaths.length} audio files`, `Generated assets with ${imagePaths.length} images (${atlasWidth}x${atlasHeight}${atlasCached ? ", cached" : ""}), ${modelPaths.length} models and ${audioPaths.length} audio files`,
); );
} catch (error) { } catch (error) {
logger.error("Error generating assets:", error); logger.error("Error generating assets:", error);

View File

@@ -106,6 +106,13 @@ export default defineNuxtConfig({
redirectOn: "root", redirectOn: "root",
}, },
}, },
ui: {
// JetBrains Mono is loaded from bunny.net in app.css
fonts: false,
experimental: {
componentDetection: true,
},
},
image: { image: {
quality: 80, quality: 80,
format: ["webp"], format: ["webp"],

View File

@@ -20,29 +20,29 @@
"exifr": "^7.1.3", "exifr": "^7.1.3",
"gsap": "3.13.0", "gsap": "3.13.0",
"pinia": "3.0.4", "pinia": "3.0.4",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.3.3",
"three": "^0.182.0", "three": "^0.182.0",
"vue": "3.5.25", "vue": "3.5.43",
"vue-router": "4.6.3", "vue-router": "5.3.1",
"zod": "^4.2.1" "zod": "^4.2.1"
}, },
"packageManager": "pnpm@10.30.2", "packageManager": "pnpm@10.30.2",
"devDependencies": { "devDependencies": {
"@nuxt/content": "3.8.2", "@nuxt/content": "3.16.1",
"@nuxt/eslint": "1.10.0", "@nuxt/eslint": "1.17.0",
"@nuxt/image": "2.0.0", "@nuxt/image": "2.1.0",
"@nuxt/kit": "4.2.1", "@nuxt/kit": "4.6.0",
"@nuxt/ui": "4.3.0", "@nuxt/ui": "4.11.3",
"@nuxtjs/i18n": "10.2.1", "@nuxtjs/i18n": "10.6.0",
"@nuxtjs/mdc": "0.18.4", "@nuxtjs/mdc": "0.23.2",
"@pinia/nuxt": "0.11.3", "@pinia/nuxt": "0.11.3",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/three": "^0.182.0", "@types/three": "^0.182.0",
"better-sqlite3": "12.4.1", "better-sqlite3": "12.11.1",
"eslint": "9.39.1", "eslint": "9.39.1",
"nuxt": "4.2.1", "nuxt": "4.6.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"sharp": "^0.34.5", "sharp": "^0.35.5",
"typescript": "5.9.3" "typescript": "5.9.3"
} }
} }

19603
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.