diff --git a/package.json b/package.json index fbded5a..7492ed0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "start": "next start", "typecheck": "tsc --noEmit", "check": "biome check", - "check:fix": "biome check --write --unsafe" + "check:fix": "biome check --write --unsafe", + "gen:logo": "node scripts/make-logo-data.mjs", + "validate:mazes": "node scripts/validate-mazes.mjs" }, "dependencies": { "@polinetwork/backend": "^0.16.4", diff --git a/public/polinetwork_logo.png b/public/polinetwork_logo.png new file mode 100644 index 0000000..a160a96 Binary files /dev/null and b/public/polinetwork_logo.png differ diff --git a/scripts/make-logo-data.mjs b/scripts/make-logo-data.mjs new file mode 100644 index 0000000..d0c6dd2 --- /dev/null +++ b/scripts/make-logo-data.mjs @@ -0,0 +1,35 @@ +import { execFileSync } from "node:child_process" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(__dirname, "..") +const sourceLogo = path.join(projectRoot, "public/polinetwork_logo.png") +const outFile = path.join(projectRoot, "src/components/cfu-game/logoData.ts") + +const tmpDir = mkdtempSync(path.join(tmpdir(), "cfu-logo-")) +const resized = path.join(tmpDir, "logo256.png") + +try { + execFileSync("sips", ["-Z", "256", sourceLogo, "--out", resized], { + stdio: "pipe", + }) + + const buffer = readFileSync(resized) + const base64 = buffer.toString("base64") + const dataUri = `data:image/png;base64,${base64}` + + const contents = `// Generated by scripts/make-logo-data.mjs — do not edit by hand. +// Source: public/polinetwork_logo.png, resized to 256px via \`pnpm gen:logo\`. +export const POLINETWORK_LOGO_DATA_URI = "${dataUri}" +` + + writeFileSync(outFile, contents, "utf8") + + const kb = (buffer.length / 1024).toFixed(1) + console.log(`Logo base64 generato: ${outFile} (${kb} KB sorgente PNG)`) +} finally { + rmSync(tmpDir, { recursive: true, force: true }) +} diff --git a/scripts/validate-mazes.mjs b/scripts/validate-mazes.mjs new file mode 100644 index 0000000..8e0c22c --- /dev/null +++ b/scripts/validate-mazes.mjs @@ -0,0 +1,199 @@ +// Verifica gli invarianti dei labirinti: se questo script fallisce, il gioco +// può diventare impossibile da completare o rompere il conteggio dei 180 CFU. +// Va eseguito ad ogni modifica di src/components/cfu-game/mazes.ts. +import { readFileSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const mazesFile = path.join(__dirname, "../src/components/cfu-game/mazes.ts") + +const CFU_VALUES = { ".": 1, ":": 3, o: 6, O: 9, "@": 12 } +const EXPECTED_TOTAL_PER_MAZE = 60 +const EXPECTED_GRAND_TOTAL = 180 + +function extractMazeBlocks(source) { + // Estrae ogni array `[...]_ROWS = [ ... ];` dal file TypeScript senza + // dover compilare il progetto: sufficiente per dati puramente statici. + const blocks = [] + const regex = /const\s+(\w+_ROWS)\s*=\s*\[([\s\S]*?)\]/g + let match + while ((match = regex.exec(source))) { + const [, name, body] = match + const rows = [...body.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\"/g, '"')) + blocks.push({ name, rows }) + } + return blocks +} + +function floodFillReachable(rows) { + const h = rows.length + const w = rows[0].length + const cellAt = (r, c) => rows[r][c] + let start = null + for (let r = 0; r < h; r++) { + for (let c = 0; c < w; c++) { + if (cellAt(r, c) === "P") start = [r, c] + } + } + if (!start) return { start: null, reached: new Set() } + + const key = (r, c) => `${r},${c}` + const reached = new Set([key(...start)]) + const queue = [start] + + // Coppie di tunnel sulla stessa riga: uscire da un lato porta all'altro. + const tunnelRowToCols = new Map() + for (let r = 0; r < h; r++) { + for (let c = 0; c < w; c++) { + if (cellAt(r, c) === "T") { + if (!tunnelRowToCols.has(r)) tunnelRowToCols.set(r, []) + tunnelRowToCols.get(r).push(c) + } + } + } + + while (queue.length) { + const [r, c] = queue.pop() + const neighbors = [ + [r - 1, c], + [r + 1, c], + [r, c - 1], + [r, c + 1], + ] + if (cellAt(r, c) === "T") { + const cols = tunnelRowToCols.get(r) ?? [] + for (const otherCol of cols) { + if (otherCol !== c) neighbors.push([r, otherCol]) + } + } + for (const [nr, nc] of neighbors) { + if (nr < 0 || nr >= h || nc < 0 || nc >= w) continue + const ch = rows[nr][nc] + if (ch === "#" || ch === undefined) continue + const k = key(nr, nc) + if (!reached.has(k)) { + reached.add(k) + queue.push([nr, nc]) + } + } + } + return { start, reached } +} + +function validateMaze(name, rows) { + const errors = [] + const width = rows[0]?.length ?? 0 + + if (rows.length === 0) { + return [`${name}: nessuna riga trovata`] + } + rows.forEach((row, i) => { + if (row.length !== width) { + errors.push(`${name}: riga ${i} ha lunghezza ${row.length}, attesa ${width}`) + } + }) + + const h = rows.length + const w = width + for (let c = 0; c < w; c++) { + if (rows[0][c] !== "#" || rows[h - 1][c] !== "#") { + errors.push(`${name}: bordo superiore/inferiore non chiuso in colonna ${c}`) + } + } + for (let r = 0; r < h; r++) { + const left = rows[r][0] + const right = rows[r][w - 1] + const leftOk = left === "#" || left === "T" + const rightOk = right === "#" || right === "T" + if (!leftOk || !rightOk) { + errors.push(`${name}: bordo laterale non chiuso in riga ${r}`) + } + } + + let cfuTotal = 0 + const counts = {} + let playerSpawns = 0 + let ghostSpawns = 0 + let coffeeCount = 0 + const tunnelRows = new Map() + + for (let r = 0; r < h; r++) { + for (let c = 0; c < w; c++) { + const ch = rows[r][c] + if (CFU_VALUES[ch]) { + cfuTotal += CFU_VALUES[ch] + counts[ch] = (counts[ch] ?? 0) + 1 + } + if (ch === "P") playerSpawns++ + if (ch === "G") ghostSpawns++ + if (ch === "C") coffeeCount++ + if (ch === "T") { + tunnelRows.set(r, (tunnelRows.get(r) ?? 0) + 1) + } + } + } + + if (cfuTotal !== EXPECTED_TOTAL_PER_MAZE) { + errors.push(`${name}: totale CFU = ${cfuTotal}, atteso ${EXPECTED_TOTAL_PER_MAZE} (dettaglio: ${JSON.stringify(counts)})`) + } + if (playerSpawns !== 1) { + errors.push(`${name}: spawn giocatore (P) = ${playerSpawns}, atteso 1`) + } + if (ghostSpawns < 1) { + errors.push(`${name}: nessuno spawn nemico (G) trovato`) + } + if (coffeeCount !== 2) { + errors.push(`${name}: caffè (C) = ${coffeeCount}, attesi 2`) + } + for (const [row, count] of tunnelRows) { + if (count !== 2) { + errors.push(`${name}: riga ${row} ha ${count} tunnel (T), atteso 0 o 2 per riga`) + } + } + + if (playerSpawns === 1) { + const { reached } = floodFillReachable(rows) + for (let r = 0; r < h; r++) { + for (let c = 0; c < w; c++) { + const ch = rows[r][c] + const isPelletOrSpecial = CFU_VALUES[ch] || ch === "C" || ch === "G" || ch === "T" + if (isPelletOrSpecial && !reached.has(`${r},${c}`)) { + errors.push(`${name}: cella '${ch}' in (${r},${c}) non raggiungibile dallo spawn P`) + } + } + } + } + + return { errors, cfuTotal } +} + +const source = readFileSync(mazesFile, "utf8") +const blocks = extractMazeBlocks(source) + +if (blocks.length === 0) { + console.error(`Nessun labirinto trovato in ${mazesFile}`) + process.exit(1) +} + +let allErrors = [] +let grandTotal = 0 + +for (const { name, rows } of blocks) { + const { errors, cfuTotal } = validateMaze(name, rows) + allErrors.push(...errors) + grandTotal += cfuTotal ?? 0 + console.log(`${name}: ${cfuTotal} CFU, ${errors.length} errori`) +} + +if (grandTotal !== EXPECTED_GRAND_TOTAL) { + allErrors.push(`TOTALE su tutti i labirinti = ${grandTotal}, atteso ${EXPECTED_GRAND_TOTAL}`) +} + +if (allErrors.length > 0) { + console.error("\nErrori di validazione:") + for (const err of allErrors) console.error(` - ${err}`) + process.exit(1) +} + +console.log(`\nOK — ${blocks.length} labirinti validi, totale ${grandTotal} CFU.`) diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx new file mode 100644 index 0000000..f7cf9ae --- /dev/null +++ b/src/app/game/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next" +import CFUGame from "@/components/cfu-game/CFUGame" +import { Hero } from "@/components/ui/hero" + +export const metadata: Metadata = { + title: "PoliPac", + description: "Mangia i CFU, arriva a 180 e laureati! Un mini-gioco stile Pac-Man targato PoliNetwork.", +} + +export default function GamePage() { + return ( +
+ + + +
+ ) +} diff --git a/src/components/cfu-game/CFUGame.module.css b/src/components/cfu-game/CFUGame.module.css new file mode 100644 index 0000000..a0c4c84 --- /dev/null +++ b/src/components/cfu-game/CFUGame.module.css @@ -0,0 +1,170 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + width: 100%; + max-width: 640px; + margin: 0 auto; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--color-text-primary); + user-select: none; +} + +.hud { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.hudRow { + display: flex; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.9rem; + flex-wrap: wrap; +} + +.hudLabel { + white-space: nowrap; +} + +.progressTrack { + position: relative; + width: 100%; + height: 1.4rem; + background: var(--color-background); + border-radius: 999px; + overflow: hidden; + border: 1px solid var(--color-blue-primary); +} + +.progressFill { + height: 100%; + background: linear-gradient(90deg, var(--color-blue-secondary), var(--color-blue-primary)); + transition: width 0.25s ease-out; +} + +.progressText { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.goldenBanner { + text-align: center; + font-size: 0.85rem; + font-weight: 600; + color: #ffd66b; +} + +.goldenWarning { + animation: blink 0.4s steps(1) infinite; +} + +@keyframes blink { + 50% { + opacity: 0.3; + } +} + +.canvasArea { + position: relative; + max-width: 100%; + line-height: 0; +} + +.canvas { + display: block; + max-width: 100%; + border-radius: var(--radius-rectangles); + background: #050b18; + touch-action: none; + outline: none; +} + +.canvas:focus-visible { + box-shadow: 0 0 0 3px var(--color-blue-primary); +} + +.overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 1rem; + text-align: center; + background: rgba(5, 11, 24, 0.85); + border-radius: var(--radius-rectangles); + font-size: 1rem; + color: var(--color-text-accent-darkbg); +} + +.dpad { + display: grid; + grid-template-columns: repeat(3, 3rem); + grid-template-rows: repeat(2, 3rem); + gap: 0.25rem; + justify-content: center; +} + +.dpadUp { + grid-column: 2; + grid-row: 1; +} + +.dpadLeft { + grid-column: 1; + grid-row: 2; +} + +.dpadRight { + grid-column: 3; + grid-row: 2; +} + +.dpadDown { + grid-column: 2; + grid-row: 2; +} + +.dpad button { + border: 1px solid var(--color-blue-primary); + border-radius: var(--radius-images); + background: var(--color-background); + color: var(--color-text-primary); + font-size: 1.1rem; + touch-action: manipulation; +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.75rem; + opacity: 0.8; + justify-content: center; +} + +.legendDot { + display: inline-block; + width: 0.6rem; + height: 0.6rem; + border-radius: 999px; + margin-right: 0.25rem; +} + +@media (prefers-reduced-motion: reduce) { + .goldenWarning { + animation: none; + } +} diff --git a/src/components/cfu-game/CFUGame.tsx b/src/components/cfu-game/CFUGame.tsx new file mode 100644 index 0000000..060b1af --- /dev/null +++ b/src/components/cfu-game/CFUGame.tsx @@ -0,0 +1,446 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { Button } from "@/components/ui/button" +import styles from "./CFUGame.module.css" +import { + advanceToNextYear, + createInitialState, + GOLDEN_WARNING_MS, + respawnAfterDeath, + setPaused, + setPlayerDirection, + startGame, + stepEngine, + TILE, +} from "./engine" +import { POLINETWORK_LOGO_DATA_URI } from "./logoData" +import { MAZES } from "./mazes" +import { renderFrame } from "./render" +import type { CFUGameStats, Direction, EngineState, GamePhase } from "./types" + +export type CFUGameProps = { + className?: string + targetCFU?: number + logoSrc?: string + onLevelComplete?: (year: number, cfu: number) => void + onGraduate?: (stats: CFUGameStats) => void +} + +type HudSnapshot = { + phase: GamePhase + cfu: number + score: number + livesLeft: number + yearIndex: number + goldenActive: boolean + goldenWarning: boolean + paused: boolean +} + +const FIXED_STEP_SEC = 1 / 120 +const MAX_FRAME_SEC = 0.25 +const SWIPE_THRESHOLD = 24 +const RESPAWN_DELAY_MS = 900 + +function snapshotHud(state: EngineState): HudSnapshot { + return { + phase: state.phase, + cfu: state.cfu, + score: state.score, + livesLeft: state.livesLeft, + yearIndex: state.yearIndex, + goldenActive: state.elapsedMs < state.goldenSessionUntilMs, + goldenWarning: + state.elapsedMs < state.goldenSessionUntilMs && state.goldenSessionUntilMs - state.elapsedMs < GOLDEN_WARNING_MS, + paused: state.pausedReason !== null, + } +} + +export default function CFUGame({ className, targetCFU = 180, logoSrc, onLevelComplete, onGraduate }: CFUGameProps) { + const wrapperRef = useRef(null) + const canvasRef = useRef(null) + const stateRef = useRef(createInitialState()) + const logoImgRef = useRef(null) + const rafRef = useRef(null) + const lastTimeRef = useRef(null) + const accumulatorRef = useRef(0) + const respawnTimeoutRef = useRef | null>(null) + const graduateNotifiedRef = useRef(false) + + const [hud, setHud] = useState(() => snapshotHud(stateRef.current)) + const [scale, setScale] = useState(1) + const [isTouch, setIsTouch] = useState(false) + const [reducedMotion, setReducedMotion] = useState(false) + const [logoReady, setLogoReady] = useState(false) + + const gridWidth = (MAZES[0]?.rows[0]?.length ?? 0) * TILE + const gridHeight = (MAZES[0]?.rows.length ?? 0) * TILE + + // Caricamento immagine logo — solo lato client, mai a livello di modulo. + useEffect(() => { + const img = new Image() + img.src = logoSrc ?? POLINETWORK_LOGO_DATA_URI + img + .decode() + .then(() => { + logoImgRef.current = img + setLogoReady(true) + }) + .catch(() => { + logoImgRef.current = img + setLogoReady(true) + }) + }, [logoSrc]) + + useEffect(() => { + const mq = window.matchMedia("(pointer: coarse)") + const mqMotion = window.matchMedia("(prefers-reduced-motion: reduce)") + setIsTouch(mq.matches) + setReducedMotion(mqMotion.matches) + const onTouchChange = () => setIsTouch(mq.matches) + const onMotionChange = () => setReducedMotion(mqMotion.matches) + mq.addEventListener("change", onTouchChange) + mqMotion.addEventListener("change", onMotionChange) + return () => { + mq.removeEventListener("change", onTouchChange) + mqMotion.removeEventListener("change", onMotionChange) + } + }, []) + + // Canvas responsive: risoluzione interna fissa in devicePixelRatio, + // dimensione visualizzata adattata via ResizeObserver. + useEffect(() => { + const canvas = canvasRef.current + const wrapper = wrapperRef.current + if (!canvas || !wrapper) return + + const dpr = window.devicePixelRatio || 1 + canvas.width = gridWidth * dpr + canvas.height = gridHeight * dpr + const ctx = canvas.getContext("2d") + ctx?.scale(dpr, dpr) + + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + const availableWidth = entry.contentRect.width + const nextScale = Math.min(1, availableWidth / gridWidth) + setScale(nextScale > 0 ? nextScale : 1) + }) + observer.observe(wrapper) + return () => observer.disconnect() + }, [gridWidth, gridHeight]) + + const restartGame = useCallback(() => { + stateRef.current = createInitialState() + startGame(stateRef.current) + graduateNotifiedRef.current = false + setHud(snapshotHud(stateRef.current)) + }, []) + + const beginFromIntro = useCallback(() => { + startGame(stateRef.current) + setHud(snapshotHud(stateRef.current)) + }, []) + + const continueToNextYear = useCallback(() => { + const finishedYear = stateRef.current.yearIndex + onLevelComplete?.(finishedYear + 1, stateRef.current.cfu) + stateRef.current = advanceToNextYear(stateRef.current) + setHud(snapshotHud(stateRef.current)) + }, [onLevelComplete]) + + const togglePause = useCallback(() => { + const state = stateRef.current + if (state.phase !== "playing") return + setPaused(state, state.pausedReason === "user" ? null : "user") + setHud(snapshotHud(state)) + }, []) + + // Loop principale a timestep fisso. + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext("2d") + if (!canvas || !ctx) return + + let frameCount = 0 + + const tick = (timeMs: number) => { + rafRef.current = requestAnimationFrame(tick) + if (lastTimeRef.current == null) lastTimeRef.current = timeMs + const frameSec = Math.min(MAX_FRAME_SEC, (timeMs - lastTimeRef.current) / 1000) + lastTimeRef.current = timeMs + accumulatorRef.current += frameSec + + const state = stateRef.current + while (accumulatorRef.current >= FIXED_STEP_SEC) { + stepEngine(state, FIXED_STEP_SEC) + accumulatorRef.current -= FIXED_STEP_SEC + } + + renderFrame(ctx, state, logoImgRef.current, timeMs / 1000, { reducedMotion }) + + frameCount++ + if (frameCount % 6 === 0) { + setHud((prev) => { + const next = snapshotHud(state) + if ( + prev.phase === next.phase && + prev.cfu === next.cfu && + prev.score === next.score && + prev.livesLeft === next.livesLeft && + prev.yearIndex === next.yearIndex && + prev.goldenActive === next.goldenActive && + prev.goldenWarning === next.goldenWarning && + prev.paused === next.paused + ) { + return prev + } + return next + }) + } + } + + rafRef.current = requestAnimationFrame(tick) + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current) + lastTimeRef.current = null + } + }, [reducedMotion]) + + // Gestione della fase "dying": pausa breve, poi respawn automatico. + useEffect(() => { + if (hud.phase === "dying") { + respawnTimeoutRef.current = setTimeout(() => { + stateRef.current = respawnAfterDeath(stateRef.current) + setHud(snapshotHud(stateRef.current)) + }, RESPAWN_DELAY_MS) + } + return () => { + if (respawnTimeoutRef.current) clearTimeout(respawnTimeoutRef.current) + } + }, [hud.phase]) + + // Notifica di laurea, una sola volta per partita. + useEffect(() => { + if (hud.phase === "graduated" && !graduateNotifiedRef.current) { + graduateNotifiedRef.current = true + onGraduate?.({ + cfu: stateRef.current.cfu, + score: stateRef.current.score, + timeMs: stateRef.current.elapsedMs, + }) + } + }, [hud.phase, onGraduate]) + + // Pausa automatica quando la scheda/finestra perde visibilità. + useEffect(() => { + const onVisibility = () => { + const state = stateRef.current + if (document.hidden) { + if (state.pausedReason === null) setPaused(state, "hidden") + } else if (state.pausedReason === "hidden") { + setPaused(state, null) + } + setHud(snapshotHud(state)) + } + document.addEventListener("visibilitychange", onVisibility) + window.addEventListener("blur", onVisibility) + return () => { + document.removeEventListener("visibilitychange", onVisibility) + window.removeEventListener("blur", onVisibility) + } + }, []) + + // Tastiera: attiva su tutta la pagina (non serve cliccare sul riquadro), + // ma si disattiva se l'utente sta scrivendo in un campo editabile altrove. + useEffect(() => { + const isEditableTarget = (el: Element | null) => { + if (!el) return false + if (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true + return (el as HTMLElement).isContentEditable + } + + const keyToDir: Record = { + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", + w: "up", + s: "down", + a: "left", + d: "right", + W: "up", + S: "down", + A: "left", + D: "right", + } + + const onKeyDown = (e: KeyboardEvent) => { + if (isEditableTarget(document.activeElement)) return + const dir = keyToDir[e.key] + if (dir) { + e.preventDefault() + setPlayerDirection(stateRef.current, dir) + return + } + if (e.key === " " || e.key === "p" || e.key === "P") { + e.preventDefault() + togglePause() + } + } + + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [togglePause]) + + // Swipe touch sul canvas. + const touchStartRef = useRef<{ x: number; y: number } | null>(null) + const onPointerDown = useCallback((e: React.PointerEvent) => { + touchStartRef.current = { x: e.clientX, y: e.clientY } + canvasRef.current?.focus() + }, []) + const onPointerUp = useCallback((e: React.PointerEvent) => { + const start = touchStartRef.current + touchStartRef.current = null + if (!start) return + const dx = e.clientX - start.x + const dy = e.clientY - start.y + if (Math.abs(dx) < SWIPE_THRESHOLD && Math.abs(dy) < SWIPE_THRESHOLD) return + const dir: Direction = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up" + setPlayerDirection(stateRef.current, dir) + }, []) + + const pressDpad = useCallback((dir: Direction) => { + setPlayerDirection(stateRef.current, dir) + canvasRef.current?.focus() + }, []) + + const maze = MAZES[hud.yearIndex] + const displayWidth = Math.round(gridWidth * scale) + const displayHeight = Math.round(gridHeight * scale) + + return ( +
canvasRef.current?.focus()} + > +
+
+ {maze?.title ?? ""} + Appelli: {"❤️".repeat(Math.max(0, hud.livesLeft))} + Punti: {hud.score} +
+
+
+ + {hud.cfu} / {targetCFU} CFU + +
+ {hud.goldenActive && ( +
+ ☕ Sessione d'oro! +
+ )} +
+ +
+ + + {!logoReady &&
Caricamento…
} + + {hud.paused && hud.phase === "playing" && ( +
+

Pausa

+ +
+ )} + + {hud.phase === "intro" && ( +
+

Mangia i CFU e arriva a 180 per laurearti!

+ +
+ )} + + {hud.phase === "levelComplete" && ( +
+

+ Anno superato! {hud.cfu} / {targetCFU} CFU +

+ +
+ )} + + {hud.phase === "gameOver" && ( +
+

Fuori corso…

+ +
+ )} + + {hud.phase === "graduated" && ( +
+

+ 🎓 Dottore! {hud.cfu} CFU — punteggio {hud.score} +

+ +
+ )} +
+ + {isTouch && hud.phase === "playing" && ( +
+ + + + +
+ )} + +
+ + 1 CFU + + + 3 CFU + + + 6 CFU + + + 12 CFU + + ☕ sessione d'oro +
+
+ ) +} diff --git a/src/components/cfu-game/engine.ts b/src/components/cfu-game/engine.ts new file mode 100644 index 0000000..de6320a --- /dev/null +++ b/src/components/cfu-game/engine.ts @@ -0,0 +1,389 @@ +import { MAZES } from "./mazes" +import { PALETTE } from "./palette" +import { type CellType, CFU_VALUE_BY_CELL, type Direction, type EngineState, type GhostState, type Vec2 } from "./types" + +export const TILE = 24 // px logici per tile a scala 1 +const PLAYER_SPEED = 6.2 // tile/sec +const GOLDEN_SESSION_MS = 7000 +const GOLDEN_WARNING_MS = 2000 +const STARTING_LIVES = 3 +const GHOST_COLORS = [PALETTE.red, PALETTE.green, PALETTE.blueSecondary, PALETTE.blueTertiary] + +let popupIdCounter = 0 + +function cloneGrid(rows: string[]): CellType[][] { + return rows.map((row) => row.split("") as CellType[]) +} + +function findAll(grid: CellType[][], type: CellType): Vec2[] { + const found: Vec2[] = [] + for (let y = 0; y < grid.length; y++) { + const row = grid[y] + if (!row) continue + for (let x = 0; x < row.length; x++) { + if (row[x] === type) found.push({ x, y }) + } + } + return found +} + +function cellAt(grid: CellType[][], x: number, y: number): CellType { + const row = grid[y] + if (!row) return "#" + return row[x] ?? "#" +} + +function isWalkable(grid: CellType[][], x: number, y: number): boolean { + return cellAt(grid, x, y) !== "#" +} + +function sumCfuInGrid(grid: CellType[][]): number { + let total = 0 + for (const row of grid) { + for (const cell of row) { + total += CFU_VALUE_BY_CELL[cell] ?? 0 + } + } + return total +} + +const DIR_VECTORS: Record, Vec2> = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, +} + +const DIR_ANGLES: Record = { + right: 0, + down: Math.PI / 2, + left: Math.PI, + up: -Math.PI / 2, + none: 0, +} + +export function dirAngle(dir: Direction): number { + return DIR_ANGLES[dir] +} + +function isAligned(pos: Vec2): boolean { + const eps = 0.02 + return Math.abs(pos.x - Math.round(pos.x)) < eps && Math.abs(pos.y - Math.round(pos.y)) < eps +} + +function isTunnelWrap(grid: CellType[][], tileY: number, nx: number, ny: number): boolean { + if (ny !== tileY) return false + const row = grid[tileY] + if (!row) return false + const width = row.length + if (nx < 0) return row[0] === "T" + if (nx >= width) return row[width - 1] === "T" + return false +} + +function canMove(grid: CellType[][], tileX: number, tileY: number, dir: Direction): boolean { + if (dir === "none") return false + const v = DIR_VECTORS[dir] + const nx = tileX + v.x + const ny = tileY + v.y + // Le celle "T" sui bordi laterali sono tunnel: uscire dalla griglia in + // quel punto è un movimento valido, non un muro (isWalkable fallirebbe + // perché la cella successiva è fuori dai limiti dell'array). + if (isTunnelWrap(grid, tileY, nx, ny)) return true + return isWalkable(grid, nx, ny) +} + +function wrapTunnel(grid: CellType[][], pos: Vec2): Vec2 { + const width = grid[0]?.length ?? 0 + const tileY = Math.round(pos.y) + const row = grid[tileY] + if (!row) return pos + if (row[0] === "T" && pos.x < 0) { + return { x: width - 1, y: pos.y } + } + if (row[width - 1] === "T" && pos.x > width - 1) { + return { x: 0, y: pos.y } + } + return pos +} + +export function createLevelState( + yearIndex: number, + carry: { cfu: number; score: number; livesLeft: number } +): EngineState { + const maze = MAZES[yearIndex] + if (!maze) throw new Error(`Anno inesistente: ${yearIndex}`) + const grid = cloneGrid(maze.rows) + + const playerSpawn = findAll(grid, "P")[0] ?? { x: 1, y: 1 } + const ghostSpawns = findAll(grid, "G") + + const ghosts: GhostState[] = ghostSpawns.slice(0, maze.ghostCount).map((spawn, i) => ({ + id: i, + pos: { x: spawn.x, y: spawn.y }, + dir: "left", + color: GHOST_COLORS[i % GHOST_COLORS.length] ?? PALETTE.red, + vulnerable: false, + vulnerableUntilMs: 0, + spawn, + })) + + // P e G sono solo marker di spawn: la tile sottostante è pavimento vuoto. + grid[playerSpawn.y]![playerSpawn.x] = " " + for (const spawn of ghostSpawns) { + grid[spawn.y]![spawn.x] = " " + } + + return { + phase: "playing", + yearIndex, + grid, + totalCfuInLevel: sumCfuInGrid(grid), + cfu: carry.cfu, + score: carry.score, + livesLeft: carry.livesLeft, + player: { + pos: { x: playerSpawn.x, y: playerSpawn.y }, + dir: "none", + nextDir: "none", + speedTilesPerSec: PLAYER_SPEED, + mouthAngle: 0, + moving: false, + }, + ghosts, + goldenSessionUntilMs: 0, + popups: [], + elapsedMs: 0, + pausedReason: null, + } +} + +export function createInitialState(): EngineState { + const state = createLevelState(0, { cfu: 0, score: 0, livesLeft: STARTING_LIVES }) + state.phase = "intro" + return state +} + +export function startGame(state: EngineState) { + state.phase = "playing" +} + +function resetPositionsAfterDeath(state: EngineState) { + const maze = MAZES[state.yearIndex]! + const spawn = findAll( + // il grid corrente ha già " " al posto di P, quindi ricalcoliamo dallo schema originale + cloneGrid(maze.rows), + "P" + )[0] ?? { x: 1, y: 1 } + state.player.pos = { x: spawn.x, y: spawn.y } + state.player.dir = "none" + state.player.nextDir = "none" + state.player.moving = false + for (const ghost of state.ghosts) { + ghost.pos = { x: ghost.spawn.x, y: ghost.spawn.y } + ghost.vulnerable = false + ghost.vulnerableUntilMs = 0 + } + state.goldenSessionUntilMs = 0 +} + +function pushPopup(state: EngineState, text: string, x: number, y: number) { + state.popups.push({ + id: popupIdCounter++, + text, + x, + y, + createdAtMs: state.elapsedMs, + }) + // tieni al più le ultime popup per non far crescere l'array all'infinito + if (state.popups.length > 8) state.popups.shift() +} + +function moveGhost(state: EngineState, ghost: GhostState, dtSec: number) { + const maze = MAZES[state.yearIndex]! + const speed = state.player.speedTilesPerSec * maze.ghostSpeedFactor + + // Come in movePlayer: la direzione va decisa e validata solo nell'istante + // esatto dello snap, non ricontrollata ogni tick con una posizione ancora + // frazionaria (Math.round vicino a x.5 è ambiguo e bloccherebbe il nemico + // a metà cella). + if (isAligned(ghost.pos)) { + const tileX = Math.round(ghost.pos.x) + const tileY = Math.round(ghost.pos.y) + ghost.pos.x = tileX + ghost.pos.y = tileY + + const validDirs = (Object.keys(DIR_VECTORS) as Direction[]).filter((d) => canMove(state.grid, tileX, tileY, d)) + const reverse: Record = { + up: "down", + down: "up", + left: "right", + right: "left", + } + const nonReverse = validDirs.filter((d) => d !== reverse[ghost.dir]) + const options = nonReverse.length > 0 ? nonReverse : validDirs + + if (options.length > 0) { + const toPlayer = { + x: state.player.pos.x - tileX, + y: state.player.pos.y - tileY, + } + const chaseChance = ghost.vulnerable ? 0 : maze.ghostChaseChance + const shouldChase = Math.random() < (ghost.vulnerable ? 1 : chaseChance) + + let chosen: Direction + if (shouldChase) { + const sign = ghost.vulnerable ? -1 : 1 + chosen = options.reduce((best, d) => { + const v = DIR_VECTORS[d as Exclude] + const bestV = DIR_VECTORS[best as Exclude] + const score = sign * (v.x * toPlayer.x + v.y * toPlayer.y) + const bestScore = sign * (bestV.x * toPlayer.x + bestV.y * toPlayer.y) + return score > bestScore ? d : best + }, options[0]!) + } else { + chosen = options[Math.floor(Math.random() * options.length)]! + } + ghost.dir = chosen + } + } + + if (ghost.dir !== "none") { + const v = DIR_VECTORS[ghost.dir as Exclude] + ghost.pos.x += v.x * speed * dtSec + ghost.pos.y += v.y * speed * dtSec + } + ghost.pos = wrapTunnel(state.grid, ghost.pos) + + if (ghost.vulnerable && state.elapsedMs > ghost.vulnerableUntilMs) { + ghost.vulnerable = false + } +} + +function movePlayer(state: EngineState, dtSec: number) { + const p = state.player + + // Il muro davanti va validato SOLO nell'istante esatto dello snap, usando + // le coordinate intere di quel momento: ricontrollare canMove ogni tick con + // Math.round() di una posizione ancora frazionaria è ambiguo vicino a x.5 + // e può bloccare il giocatore a metà cella pensando di aver già raggiunto + // quella successiva. + if (isAligned(p.pos)) { + const tileX = Math.round(p.pos.x) + const tileY = Math.round(p.pos.y) + p.pos.x = tileX + p.pos.y = tileY + consumeCellAt(state, tileX, tileY) + if (p.nextDir !== "none" && canMove(state.grid, tileX, tileY, p.nextDir)) { + p.dir = p.nextDir + } else if (p.dir !== "none" && !canMove(state.grid, tileX, tileY, p.dir)) { + p.dir = "none" + } + } + + if (p.dir !== "none") { + const v = DIR_VECTORS[p.dir as Exclude] + p.pos.x += v.x * p.speedTilesPerSec * dtSec + p.pos.y += v.y * p.speedTilesPerSec * dtSec + p.moving = true + } else { + p.moving = false + } + p.pos = wrapTunnel(state.grid, p.pos) +} + +function consumeCellAt(state: EngineState, tileX: number, tileY: number) { + const cell = cellAt(state.grid, tileX, tileY) + const maze = MAZES[state.yearIndex]! + const cfuValue = CFU_VALUE_BY_CELL[cell] + + if (cfuValue) { + state.grid[tileY]![tileX] = " " + state.cfu += cfuValue + state.score += cfuValue * 10 + const examName = maze.examNames.find((e) => e.cell === cell)?.name + pushPopup(state, examName ? `${examName} +${cfuValue} CFU` : `+${cfuValue} CFU`, tileX, tileY) + } else if (cell === "C") { + state.grid[tileY]![tileX] = " " + state.goldenSessionUntilMs = state.elapsedMs + GOLDEN_SESSION_MS + state.score += 50 + for (const ghost of state.ghosts) { + ghost.vulnerable = true + ghost.vulnerableUntilMs = state.goldenSessionUntilMs + } + pushPopup(state, "☕ sessione d'oro!", tileX, tileY) + } +} + +function handleGhostCollisions(state: EngineState) { + for (const ghost of state.ghosts) { + const dx = ghost.pos.x - state.player.pos.x + const dy = ghost.pos.y - state.player.pos.y + const dist2 = dx * dx + dy * dy + if (dist2 < 0.35 * 0.35) { + if (ghost.vulnerable) { + ghost.pos = { x: ghost.spawn.x, y: ghost.spawn.y } + ghost.vulnerable = false + state.score += 200 + pushPopup(state, "+200", ghost.spawn.x, ghost.spawn.y) + } else { + state.phase = "dying" + state.livesLeft -= 1 + return + } + } + } +} + +export function stepEngine(state: EngineState, dtSec: number): EngineState { + if (state.phase !== "playing" || state.pausedReason) return state + + state.elapsedMs += dtSec * 1000 + + const targetMouth = state.player.moving ? 0.32 : 0.02 + const speed = 10 * dtSec + state.player.mouthAngle += (targetMouth - state.player.mouthAngle) * Math.min(1, speed) + + movePlayer(state, dtSec) + for (const ghost of state.ghosts) moveGhost(state, ghost, dtSec) + handleGhostCollisions(state) + + state.popups = state.popups.filter((p) => state.elapsedMs - p.createdAtMs < 1200) + + if (state.livesLeft <= 0) { + state.phase = "gameOver" + return state + } + + const remaining = sumCfuInGrid(state.grid) + if (remaining === 0 && state.phase === "playing") { + state.phase = state.cfu >= 180 ? "graduated" : "levelComplete" + } + + return state +} + +export function respawnAfterDeath(state: EngineState): EngineState { + resetPositionsAfterDeath(state) + state.phase = "playing" + return state +} + +export function advanceToNextYear(state: EngineState): EngineState { + const nextYearIndex = state.yearIndex + 1 + return createLevelState(nextYearIndex, { + cfu: state.cfu, + score: state.score, + livesLeft: state.livesLeft, + }) +} + +export function setPlayerDirection(state: EngineState, dir: Direction) { + state.player.nextDir = dir +} + +export function setPaused(state: EngineState, reason: "user" | "hidden" | null) { + state.pausedReason = reason +} + +export { GOLDEN_WARNING_MS, STARTING_LIVES } diff --git a/src/components/cfu-game/logoData.ts b/src/components/cfu-game/logoData.ts new file mode 100644 index 0000000..635b700 --- /dev/null +++ b/src/components/cfu-game/logoData.ts @@ -0,0 +1,4 @@ +// Generated by scripts/make-logo-data.mjs — do not edit by hand. +// Source: polinetwork_logo.png, resized to 256px via `npm run gen:logo`. +export const POLINETWORK_LOGO_DATA_URI = + "data:image/png;base64,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" diff --git a/src/components/cfu-game/mazes.ts b/src/components/cfu-game/mazes.ts new file mode 100644 index 0000000..30bd198 --- /dev/null +++ b/src/components/cfu-game/mazes.ts @@ -0,0 +1,95 @@ +import type { MazeDefinition } from "./types" + +// Ogni labirinto vale esattamente 60 CFU (1×@=12 + 2×o=12 + 4×:=12 + 24×.=24), +// così che 3 anni completati facciano esattamente 180 CFU = laurea. +// Verificato da `npm run validate:mazes` ad ogni modifica. + +const YEAR_1_ROWS = [ + "###############", + "#: .#G @ : o G#", + "# # # # # ### #", + "#. .#o : . . .#", + "# ### ####### #", + "#. .#C : . .#.#", + "# # ##### # # #", + "T. . . . . .#.T", + "# # ####### # #", + "# . . . . .# #", + "# # ##### ### #", + "# .# #", + "# # ### ##### #", + "# # # # #", + "# # # # # # # #", + "# P C #", + "###############", +] + +const YEAR_2_ROWS = [ + "###############", + "#. C o @ G G G#", + "# ### ####### #", + "#. . : . . .#o#", + "# # ### # # # #", + "#. .#. :#.#: :#", + "# ### ### #####", + "T. .#.#. .#. .T", + "### # # # # # #", + "#. .#. . . .#.#", + "# # ####### # #", + "# # #", + "####### # # # #", + "# #", + "# # # # ### # #", + "# #P C #", + "###############", +] + +const YEAR_3_ROWS = [ + "###############", + "#@ : o G o G G#", + "# # ##### # # #", + "#: . . . : : C#", + "### # #########", + "#. .#. . . . .#", + "# # # # # ### #", + "T. .#.#. . . .T", + "# # # # # ### #", + "#. .#. . . .#", + "# ### ### # ###", + "#. # # #", + "### ### # # # #", + "# # # #", + "# ### ### ### #", + "# P C #", + "###############", +] + +export const MAZES: MazeDefinition[] = [ + { + year: 1, + title: "1° anno", + rows: YEAR_1_ROWS, + examNames: [{ cell: "@", name: "Analisi Matematica I" }], + ghostCount: 2, + ghostChaseChance: 0.5, + ghostSpeedFactor: 0.85, + }, + { + year: 2, + title: "2° anno", + rows: YEAR_2_ROWS, + examNames: [{ cell: "@", name: "Fisica II" }], + ghostCount: 3, + ghostChaseChance: 0.6, + ghostSpeedFactor: 0.88, + }, + { + year: 3, + title: "3° anno", + rows: YEAR_3_ROWS, + examNames: [{ cell: "@", name: "Tesi di Laurea" }], + ghostCount: 3, + ghostChaseChance: 0.7, + ghostSpeedFactor: 0.9, + }, +] diff --git a/src/components/cfu-game/palette.ts b/src/components/cfu-game/palette.ts new file mode 100644 index 0000000..aa281b2 --- /dev/null +++ b/src/components/cfu-game/palette.ts @@ -0,0 +1,12 @@ +// Canvas 2D non può leggere le custom property CSS: questi valori replicano +// i design token del sito (src/styles/figma.css, tema chiaro) per il disegno +// su , dove serve una stringa colore risolta. +export const PALETTE = { + bluePrimary: "oklch(70.7% 0.165 254.624)", // --color-blue-primary (blue-400) + blueSecondary: "oklch(50% 0.134 242.749)", // --color-blue-secondary (sky-700) + blueTertiary: "oklch(82.8% 0.111 230.318)", // --color-blue-tertiary (sky-300) + red: "oklch(58.6% 0.253 17.585)", // --color-red (rose-600) + green: "oklch(76.5% 0.177 163.223)", // --color-green (emerald-400) + grey: "oklch(86.9% 0.022 252.894)", // --color-grey (slate-300) + textPrimary: "oklch(12.9% 0.042 264.695)", // --color-text-primary (slate-950) +} diff --git a/src/components/cfu-game/render.ts b/src/components/cfu-game/render.ts new file mode 100644 index 0000000..d6e54bf --- /dev/null +++ b/src/components/cfu-game/render.ts @@ -0,0 +1,217 @@ +import { dirAngle, TILE } from "./engine" +import { PALETTE } from "./palette" +import type { EngineState } from "./types" +import { CFU_VALUE_BY_CELL } from "./types" + +const WALL_COLOR = PALETTE.bluePrimary +const BG_COLOR = "#050b18" +const PELLET_COLORS: Record = { + ".": PALETTE.blueTertiary, + ":": PALETTE.blueSecondary, + o: PALETTE.green, + O: PALETTE.red, + "@": PALETTE.red, +} + +export type RenderOptions = { + reducedMotion: boolean +} + +function drawWalls(ctx: CanvasRenderingContext2D, state: EngineState) { + ctx.strokeStyle = WALL_COLOR + ctx.lineWidth = TILE * 0.28 + ctx.lineCap = "round" + ctx.lineJoin = "round" + + const grid = state.grid + for (let y = 0; y < grid.length; y++) { + const row = grid[y] + if (!row) continue + for (let x = 0; x < row.length; x++) { + if (row[x] !== "#") continue + const cx = x * TILE + TILE / 2 + const cy = y * TILE + TILE / 2 + // Disegna solo i segmenti verso vicini non-muro, per un effetto "tubo" + // continuo invece di quadrati pieni. + const right = row[x + 1] + const down = grid[y + 1]?.[x] + if (right === "#") { + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.lineTo(cx + TILE, cy) + ctx.stroke() + } + if (down === "#") { + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.lineTo(cx, cy + TILE) + ctx.stroke() + } + } + } +} + +function drawPellets(ctx: CanvasRenderingContext2D, state: EngineState, tSec: number) { + const grid = state.grid + for (let y = 0; y < grid.length; y++) { + const row = grid[y] + if (!row) continue + for (let x = 0; x < row.length; x++) { + const cell = row[x] + if (!cell) continue + const cx = x * TILE + TILE / 2 + const cy = y * TILE + TILE / 2 + + if (cell === "C") { + const pulse = 1 + 0.15 * Math.sin(tSec * 6) + ctx.font = `${TILE * 0.9 * pulse}px system-ui, sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText("☕", cx, cy + 1) + continue + } + + const color = PELLET_COLORS[cell] + if (!color) continue + + const isBig = cell === "@" || cell === "O" + const showValue = cell !== "." && cell !== ":" + const baseRadius = cell === "." ? TILE * 0.09 : cell === ":" ? TILE * 0.13 : TILE * 0.22 + const radius = isBig ? baseRadius * (1 + 0.08 * Math.sin(tSec * 4)) : baseRadius + + ctx.fillStyle = color + ctx.beginPath() + ctx.arc(cx, cy, radius, 0, Math.PI * 2) + ctx.fill() + + if (showValue) { + const value = CFU_VALUE_BY_CELL[cell] + ctx.fillStyle = PALETTE.textPrimary + ctx.font = `bold ${TILE * 0.34}px system-ui, sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText(String(value), cx, cy + 1) + } + } + } +} + +function drawGhost( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + color: string, + vulnerable: boolean, + dirX: number, + dirY: number +) { + const r = TILE * 0.42 + ctx.fillStyle = vulnerable ? PALETTE.grey : color + ctx.beginPath() + ctx.arc(x, y - r * 0.1, r, Math.PI, 0) + ctx.lineTo(x + r, y + r * 0.6) + for (let i = 0; i < 4; i++) { + const wx = x + r - (i * (2 * r)) / 4 + ctx.quadraticCurveTo(wx - r / 4, y + (i % 2 === 0 ? r * 1.05 : r * 0.5), wx - r / 2, y + r * 0.6) + } + ctx.lineTo(x - r, y - r * 0.1) + ctx.closePath() + ctx.fill() + + const eyeOffsetX = r * 0.35 + const eyeOffsetY = -r * 0.15 + const pupilShiftX = dirX * r * 0.18 + const pupilShiftY = dirY * r * 0.18 + + for (const side of [-1, 1]) { + ctx.fillStyle = "#fff" + ctx.beginPath() + ctx.arc(x + side * eyeOffsetX, y + eyeOffsetY, r * 0.22, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = vulnerable ? "#fff" : PALETTE.textPrimary + ctx.beginPath() + ctx.arc(x + side * eyeOffsetX + pupilShiftX, y + eyeOffsetY + pupilShiftY, r * 0.11, 0, Math.PI * 2) + ctx.fill() + } +} + +function drawPlayer( + ctx: CanvasRenderingContext2D, + state: EngineState, + logoImg: HTMLImageElement | null, + _tSec: number, + reducedMotion: boolean +) { + const { pos, dir, mouthAngle } = state.player + const cx = pos.x * TILE + TILE / 2 + const cy = pos.y * TILE + TILE / 2 + const r = TILE * 0.52 + const angle = dirAngle(dir === "none" ? "right" : dir) + const mouth = reducedMotion ? mouthAngle * 0.5 : mouthAngle + + ctx.save() + ctx.beginPath() + if (mouth > 0.03) { + ctx.moveTo(cx, cy) + ctx.arc(cx, cy, r, angle + mouth, angle - mouth + Math.PI * 2) + ctx.closePath() + } else { + ctx.arc(cx, cy, r, 0, Math.PI * 2) + } + ctx.clip() + + if (logoImg) { + ctx.drawImage(logoImg, cx - r, cy - r, r * 2, r * 2) + } else { + ctx.fillStyle = PALETTE.bluePrimary + ctx.fillRect(cx - r, cy - r, r * 2, r * 2) + } + ctx.restore() +} + +function drawPopups(ctx: CanvasRenderingContext2D, state: EngineState) { + for (const popup of state.popups) { + const age = state.elapsedMs - popup.createdAtMs + const progress = Math.min(1, age / 1200) + const cx = popup.x * TILE + TILE / 2 + const cy = popup.y * TILE + TILE / 2 - progress * TILE * 1.4 + const alpha = 1 - progress + + ctx.globalAlpha = alpha + ctx.fillStyle = "#ffffff" + ctx.font = `bold ${TILE * 0.42}px system-ui, sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText(popup.text, cx, cy) + ctx.globalAlpha = 1 + } +} + +export function renderFrame( + ctx: CanvasRenderingContext2D, + state: EngineState, + logoImg: HTMLImageElement | null, + tSec: number, + options: RenderOptions +) { + const width = (state.grid[0]?.length ?? 0) * TILE + const height = state.grid.length * TILE + + ctx.fillStyle = BG_COLOR + ctx.fillRect(0, 0, width, height) + + drawWalls(ctx, state) + drawPellets(ctx, state, tSec) + + const golden = state.elapsedMs < state.goldenSessionUntilMs + for (const ghost of state.ghosts) { + const gx = ghost.pos.x * TILE + TILE / 2 + const gy = ghost.pos.y * TILE + TILE / 2 + const angle = dirAngle(ghost.dir) + const flashing = golden && state.goldenSessionUntilMs - state.elapsedMs < 2000 && Math.floor(tSec * 6) % 2 === 0 + drawGhost(ctx, gx, gy, flashing ? "#ffffff" : ghost.color, ghost.vulnerable, Math.cos(angle), Math.sin(angle)) + } + + drawPlayer(ctx, state, logoImg, tSec, options.reducedMotion) + drawPopups(ctx, state) +} diff --git a/src/components/cfu-game/types.ts b/src/components/cfu-game/types.ts new file mode 100644 index 0000000..548d5dc --- /dev/null +++ b/src/components/cfu-game/types.ts @@ -0,0 +1,89 @@ +export type Direction = "up" | "down" | "left" | "right" | "none" + +export type CellType = + | "#" // wall + | " " // empty floor (already eaten / walkable) + | "." // 1 CFU + | ":" // 3 CFU + | "o" // 6 CFU + | "O" // 9 CFU + | "@" // 12 CFU + | "C" // caffè power-up + | "P" // player spawn + | "G" // ghost spawn + | "-" // ghost house door + | "T" // tunnel + +export const CFU_VALUE_BY_CELL: Partial> = { + ".": 1, + ":": 3, + o: 6, + O: 9, + "@": 12, +} + +export type Vec2 = { x: number; y: number } + +export type ExamName = { + cell: "@" | "O" | "o" + name: string +} + +export type MazeDefinition = { + year: 1 | 2 | 3 + title: string + rows: string[] + examNames: ExamName[] + ghostCount: number + ghostChaseChance: number // 0..1 + ghostSpeedFactor: number // relative to player speed, < 1 +} + +export type GhostState = { + id: number + pos: Vec2 // tile-space, fractional + dir: Direction + color: string + vulnerable: boolean + vulnerableUntilMs: number + spawn: Vec2 +} + +export type FloatingPopup = { + id: number + text: string + x: number + y: number + createdAtMs: number +} + +export type GamePhase = "intro" | "playing" | "dying" | "levelComplete" | "graduated" | "gameOver" + +export type EngineState = { + phase: GamePhase + yearIndex: number // 0,1,2 -> anno 1,2,3 + grid: CellType[][] + totalCfuInLevel: number + cfu: number // 0..180 cumulative across levels + score: number + livesLeft: number // "appelli" + player: { + pos: Vec2 + dir: Direction + nextDir: Direction + speedTilesPerSec: number + mouthAngle: number + moving: boolean + } + ghosts: GhostState[] + goldenSessionUntilMs: number + popups: FloatingPopup[] + elapsedMs: number + pausedReason: "user" | "hidden" | null +} + +export type CFUGameStats = { + cfu: number + score: number + timeMs: number +} diff --git a/src/components/header/logo.tsx b/src/components/header/logo.tsx index 0c9043e..7fb551a 100644 --- a/src/components/header/logo.tsx +++ b/src/components/header/logo.tsx @@ -1,9 +1,38 @@ +"use client" + import Image from "next/image" import Link from "next/link" +import { useRouter } from "next/navigation" +import { useRef } from "react" + +const EASTER_EGG_CLICKS = 3 +const EASTER_EGG_WINDOW_MS = 800 + +export const Logo = () => { + const router = useRouter() + const clickCountRef = useRef(0) + const resetTimeoutRef = useRef | null>(null) + + const handleClick = (e: React.MouseEvent) => { + clickCountRef.current += 1 + + if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current) + resetTimeoutRef.current = setTimeout(() => { + clickCountRef.current = 0 + }, EASTER_EGG_WINDOW_MS) + + if (clickCountRef.current >= EASTER_EGG_CLICKS) { + e.preventDefault() + clickCountRef.current = 0 + clearTimeout(resetTimeoutRef.current) + router.push("/game") + } + } -export const Logo = () => ( - - PoliNetwork Logo -

PoliNetwork

- -) + return ( + + PoliNetwork Logo +

PoliNetwork

+ + ) +}