import { z } from "zod/mini"; import { useWindowSize } from "@vueuse/core"; import type * as THREE from "three"; export const LOGICAL_WIDTH = 256; export const LOGICAL_HEIGHT = 192; export const SCREEN_SCALE = 2; export const SCREEN_WIDTH = LOGICAL_WIDTH * SCREEN_SCALE; export const SCREEN_HEIGHT = LOGICAL_HEIGHT * SCREEN_SCALE; export const APP_COLORS = [ ["#61829a", "#ba4900", "#fb0018", "#fb8afb"], ["#fb9200", "#f3e300", "#aafb00", "#00fb00"], ["#00a238", "#49db8a", "#30baf3", "#0059f3"], ["#000092", "#8a00d3", "#d300eb", "#fb0092"], ]; 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({ color: z.object({ col: z.number(), row: z.number(), }), renderingMode: z.enum(["3d", "2d"]), volume: z.number(), }); type Settings = z.infer; const defaultSettings = (): Settings => ({ color: { col: 0, row: 0 }, renderingMode: "3d", volume: 0.5, }); export const useAppStore = defineStore("app", { state: () => { let settings: Settings; const stored = localStorage.getItem(STORAGE_ID); try { settings = settingsSchema.parse(JSON.parse(stored ?? "")); } catch { settings = defaultSettings(); } return { ready: false, booted: false, userHasInteracted: false, settings, previousScreen: "home" as AppScreen, screen: "home" as AppScreen, visitedGallery: false, camera: null as THREE.Camera | null, hintsVisible: false, // hints wanted by the current app state (intro done, not in gallery) hintsRequested: false, windowSize: useWindowSize(), lagDetected: false, lagModalOpen: false, }; }, actions: { setColor(col: number, row: number) { this.settings.color = { col, row }; }, setRenderingMode(mode: Settings["renderingMode"]) { this.ready = mode === "2d"; this.settings.renderingMode = mode; this.lagDetected = false; if (mode === "2d") this.camera = null; this.save(); }, navigateTo(screen: AppScreen) { this.previousScreen = this.screen; this.screen = screen; const achievements = useAchievementsStore(); switch (screen) { case "projects": achievements.unlock("projects_visit"); break; case "gallery": this.visitedGallery = true; break; case "contact": achievements.unlock("contact_visit"); break; } }, allowHints() { this.hintsRequested = true; }, disallowHints() { this.hintsRequested = false; }, showHints() { if (!this.hintsAllowed) return; this.hintsVisible = true; }, hideHints() { this.hintsVisible = false; }, setCamera(camera: THREE.Camera) { this.camera = camera; }, save() { localStorage.setItem(STORAGE_ID, JSON.stringify(this.settings)); }, }, getters: { color: (state) => ({ col: state.settings.color.col, row: state.settings.color.row, 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, ), }, });