board consumed in example

This commit is contained in:
2025-10-16 12:25:52 +01:00
commit 892e85ecf5
38 changed files with 1856 additions and 0 deletions
Executable
+33
View File
@@ -0,0 +1,33 @@
/* =========================================================
🔧 Controls (tweak these)
========================================================= */
:root {
--snake-cell-size: 16px;
}
/* =========================================================
Container & Board Base
========================================================= */
#snake-board {
position: relative;
background-color: var(--card, #1e2030);
border-radius: 0.5rem;
box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 5%, transparent);
padding: 4px;
}
/* =========================================================
Snake Board Layout
========================================================= */
.snake-row {
display: flex;
}
.snake-cell {
width: var(--snake-cell-size);
height: var(--snake-cell-size);
background-color: var(--muted, #2f334d);
border: 1px solid var(--border, #3b3f5c);
box-sizing: border-box;
}
Executable
+3
View File
@@ -0,0 +1,3 @@
export * from './useGame';
export * from './types';
Executable
+18
View File
@@ -0,0 +1,18 @@
import { useGame } from "./useGame";
export type UseGameType = ReturnType<typeof useGame>;
export type GamePhase = "idle" | "playing";
export type PlayStatus = "started" | "game-over" | "game-won";
export type GameStatus = "waiting" | "started" | "game-over" | "game-won";
export const isPlaying = (status: GameStatus): status is PlayStatus =>
status === "started" || status === "game-over" || status === "game-won";
export const getGamePhase = (status: GameStatus): GamePhase =>
status === "waiting" ? "idle" : "playing";
export type Direction = "up" | "down" | "left" | "right";
export type Position = { r: number; c: number };
export type SnakeBody = Position[];
+2
View File
@@ -0,0 +1,2 @@
export * from './useGame';
export * from './useKeyBindings';
View File
View File
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from "react";
import { useRenderer } from "./useRenderer";
export function useGame(cols: number, rows: number, platformHook?: unknown) {
const rendererManager = useRenderer();
const { containerRef, renderBoard } = rendererManager;
useEffect(() => {
renderBoard(cols, rows);
}, [cols, rows]);
const fullGameManager = {
containerRef,
renderBoard,
};
if (typeof platformHook === "function") {
platformHook(fullGameManager);
}
return fullGameManager;
}
export type { GameStatus } from "../types";
+55
View File
@@ -0,0 +1,55 @@
import { useCallback, useState, useMemo } from "react";
import type { UseMazeType } from "./useMaze";
import type { UsePlayerType } from "./usePlayer";
import type { GameStatus } from "../types";
import { getGamePhase, isPlaying } from "../types";
export type { GameStatus } from "../types";
export function useGameStatus(mazeManager: UseMazeType, playerManager: UsePlayerType) {
const { mazeRef, containerRef } = mazeManager;
const { setPlayerPos } = playerManager;
const [gameStatus, setGameStatus] = useState<GameStatus>("waiting");
const gamePhase = useMemo(() => getGamePhase(gameStatus), [gameStatus]);
const playStatus = useMemo(
() => (isPlaying(gameStatus) ? gameStatus : null),
[gameStatus]
);
// 🕹 Start the game (place hero)
const startGame = useCallback(() => {
const m = mazeRef.current;
if (!m.length) return;
for (let c = 0; c < m[0].length; c++) {
if (m[m.length - 1][c].includes("entrance")) {
setPlayerPos({ r: m.length - 1, c });
break;
}
}
setGameStatus("started");
}, [mazeRef, setPlayerPos]);
// 🧍 Stop / exit game
const stopGame = useCallback(() => {
setPlayerPos(null);
setGameStatus("waiting");
const container = containerRef.current;
container?.querySelector("#maze")?.classList.remove("finished");
container?.querySelectorAll(".hero").forEach((el) => el.classList.remove("hero"));
}, [setPlayerPos, containerRef]);
return {
gameStatus,
gamePhase,
playStatus,
setGameStatus,
startGame,
stopGame,
};
}
export type UseGameStatusType = ReturnType<typeof useGameStatus>;
+259
View File
@@ -0,0 +1,259 @@
import type { GameStatus } from "../types";
import { useEffect, useRef, useState } from "react";
import type { KeyLogEntry } from "../types";
export type GameBindingContext = {
movePlayer: (dr: number, dc: number, count?: number) => void;
gameStatus: GameStatus;
mazeManager: any;
playerManager: any;
};
type UseMazeKeyBindingsParams = {
gameManager: GameBindingContext;
};
type Motion = { dr: number; dc: number; steps: number };
const RELEVANT_KEYS = new Set([
"h", "H", "j", "J", "k", "K", "l", "L", ".", "^", "$", "g", "G", "0",
"1", "2", "3", "4", "5", "6", "7", "8", "9"
]);
export const useKeyBindings = ({ gameManager }: UseMazeKeyBindingsParams) => {
const { movePlayer, gameStatus } = gameManager;
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
const logRef = useRef<KeyLogEntry[]>([]);
const countRef = useRef<string>("");
const lastMotionRef = useRef<Motion | null>(null);
const lastKeyRef = useRef<string>("");
const relevantKeys = RELEVANT_KEYS;
const clearLog = () => {
logRef.current = [];
setKeyLog([]);
};
const recordKey = (key: string) => {
const entry = { key, timestamp: performance.now() };
logRef.current.push(entry);
setKeyLog([...logRef.current]);
};
const resetCount = () => { countRef.current = ""; };
const findAnchorTarget = (dir: "left" | "right") => {
const m = gameManager.mazeManager.mazeRef.current;
const pos = gameManager.playerManager.playerPos;
if (!m.length || !pos) return null;
const colsCount = m[0]?.length ?? 0;
const r = pos.r;
const c = pos.c;
if (!m[r] || !m[r][c] || m[r][c].includes("wall")) return null;
const step = dir === "left" ? -1 : 1;
let cc = c;
while (true) {
const next = cc + step;
if (next < 0 || next >= colsCount) return null;
if (m[r][next].includes("wall")) {
const wallIdx = next;
const expectedWallIdx = dir === "left" ? 0 : colsCount - 1;
const expectedCellIdx = dir === "left" ? 1 : colsCount - 2;
if (wallIdx !== expectedWallIdx) return null;
if (cc !== expectedCellIdx) return null;
if (cc === c) return null;
return { r, cTarget: cc };
}
cc = next;
}
};
const findColumnAnchorTarget = (dir: "top" | "bottom") => {
const m = gameManager.mazeManager.mazeRef.current;
const pos = gameManager.playerManager.playerPos;
if (!m.length || !pos) return null;
const rowsCount = m.length;
const r = pos.r;
const c = pos.c;
if (!m[r] || !m[r][c] || m[r][c].includes("wall")) return null;
const step = dir === "top" ? -1 : 1;
let rr = r;
while (true) {
const next = rr + step;
if (next < 0 || next >= rowsCount) return null;
if (m[next][c].includes("wall")) {
const wallIdx = next;
const expectedWallIdx = dir === "top" ? 0 : rowsCount - 1;
const expectedCellIdx = dir === "top" ? 1 : rowsCount - 2;
if (wallIdx !== expectedWallIdx) return null;
if (rr !== expectedCellIdx) return null;
if (rr === r) return null;
return { rTarget: rr, c };
}
rr = next;
}
};
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (["waiting", "game-over", "game-won"].includes(gameStatus)) return;
// "0" behaves like Vim: beginning of line
if (e.key === "0" && countRef.current === "") {
recordKey("0");
resetCount();
const anchor = findAnchorTarget("left");
if (!anchor) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
const pos = gameManager.playerManager.playerPos!;
const dr = 0;
const dc = anchor.cTarget > pos.c ? 1 : -1;
const steps = Math.abs(anchor.cTarget - pos.c);
movePlayer(dr, dc, steps);
lastMotionRef.current = { dr, dc, steps };
return;
}
const isDigit = e.key >= "0" && e.key <= "9";
if (isDigit) {
if (e.key === "0" && countRef.current === "") return;
recordKey(e.key);
countRef.current += e.key;
return;
}
if (e.key === ".") {
recordKey(".");
const last = lastMotionRef.current;
if (!last) return;
const steps = Math.max(1, parseInt(countRef.current || String(last.steps), 10));
resetCount();
movePlayer(last.dr, last.dc, steps);
lastMotionRef.current = { dr: last.dr, dc: last.dc, steps };
return;
}
if (e.key === "^" || e.key === "$") {
recordKey(e.key);
resetCount();
const dir = e.key === "^" ? "left" : "right";
const anchor = findAnchorTarget(dir);
if (!anchor) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
const pos = gameManager.playerManager.playerPos!;
const dr = 0;
const dc = anchor.cTarget > pos.c ? 1 : -1;
const steps = Math.abs(anchor.cTarget - pos.c);
movePlayer(dr, dc, steps);
lastMotionRef.current = { dr, dc, steps };
return;
}
if (e.key === "g" || e.key === "G") {
if (e.key === "g") {
if (lastKeyRef.current === "g") {
recordKey(e.key);
resetCount();
lastKeyRef.current = "";
const anchor = findColumnAnchorTarget("top");
if (!anchor) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
const pos = gameManager.playerManager.playerPos!;
const dr = anchor.rTarget > pos.r ? 1 : -1;
const dc = 0;
const steps = Math.abs(anchor.rTarget - pos.r);
movePlayer(dr, dc, steps);
lastMotionRef.current = { dr, dc, steps };
return;
} else {
recordKey(e.key);
lastKeyRef.current = "g";
return;
}
} else {
recordKey(e.key);
resetCount();
lastKeyRef.current = "";
const anchor = findColumnAnchorTarget("bottom");
if (!anchor) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
const pos = gameManager.playerManager.playerPos!;
const dr = anchor.rTarget > pos.r ? 1 : -1;
const dc = 0;
const steps = Math.abs(anchor.rTarget - pos.r);
movePlayer(dr, dc, steps);
lastMotionRef.current = { dr, dc, steps };
return;
}
}
if (!relevantKeys.has(e.key)) return;
recordKey(e.key);
let dr = 0, dc = 0;
switch (e.key) {
case "h":
case "H": dr = 0; dc = -1; break;
case "j":
case "J": dr = 1; dc = 0; break;
case "k":
case "K": dr = -1; dc = 0; break;
case "l":
case "L": dr = 0; dc = 1; break;
default: return;
}
const steps = Math.max(1, parseInt(countRef.current || "1", 10));
resetCount();
movePlayer(dr, dc, steps);
lastMotionRef.current = { dr, dc, steps };
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [movePlayer, gameStatus, gameManager]);
return {
keyLog,
clearLog,
resetCount,
getLog: () => logRef.current,
};
};
export type UseKeyBindingsType = ReturnType<typeof useKeyBindings>;
+46
View File
@@ -0,0 +1,46 @@
import { useRef } from "react";
export type BoardData = {
cols: number;
rows: number;
};
export function useRenderer() {
const containerRef = useRef<HTMLDivElement | null>(null);
const renderBoard = (cols: number, rows: number) => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = "";
const board = document.createElement("div");
board.id = "snake-board";
board.className = "snake-board";
for (let r = 0; r < rows; r++) {
const rowDiv = document.createElement("div");
rowDiv.className = "snake-row";
rowDiv.setAttribute("data-r", String(r));
for (let c = 0; c < cols; c++) {
const cellDiv = document.createElement("div");
cellDiv.className = "snake-cell";
cellDiv.setAttribute("data-r", String(r));
cellDiv.setAttribute("data-c", String(c));
rowDiv.appendChild(cellDiv);
}
board.appendChild(rowDiv);
}
container.appendChild(board);
};
return {
containerRef,
renderBoard,
};
}
export type UseRendererType = ReturnType<typeof useRenderer>;
View File
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from "react";
export type UseTimerReturn = {
timeValue: number;
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
};
export type UseTimerOptions = {
/**
* Interval (in ms) between updates. Defaults to 100ms.
*/
interval?: number;
};
export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
const { interval = 100 } = options;
const [timeValue, setTimeValue] = useState(0);
const intervalIdRef = useRef<ReturnType<typeof setInterval> | null>(null);
const offsetRef = useRef(0);
const startTimeRef = useRef<number | null>(null);
const update = useCallback(() => {
if (startTimeRef.current === null) {
return;
}
const now = performance.now();
setTimeValue(offsetRef.current + (now - startTimeRef.current));
}, []);
const startTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
return;
}
if (startTimeRef.current === null) {
startTimeRef.current = performance.now();
}
update();
intervalIdRef.current = setInterval(update, Math.max(16, interval));
}, [interval, update]);
const stopTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
if (startTimeRef.current !== null) {
update();
offsetRef.current += performance.now() - startTimeRef.current;
startTimeRef.current = null;
}
}, [update]);
const resetTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
startTimeRef.current = null;
offsetRef.current = 0;
setTimeValue(0);
}, []);
useEffect(() => {
return () => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
}
};
}, []);
return {
timeValue,
startTimer,
stopTimer,
resetTimer,
};
}
+1
View File
@@ -0,0 +1 @@
export * from './useScore';
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from "react";
import { useTimer } from "../hooks/useTimer";
import { useScoreTime } from "./useScoreTime";
import { useScorePaths } from "./useScorePaths";
import type { KeyLogProvider, GameScoreContext } from "../types";
type UseScoreParams = {
gameContext: GameScoreContext;
keyManager?: KeyLogProvider;
};
export function useScore({ gameContext, keyManager }: UseScoreParams) {
const timer = useTimer();
const { gameStatus, setGameStatus } = gameContext;
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
// ✅ useScorePaths always returns distances under these names
const {
entranceToKey = 0,
entranceToExit = 0,
heroToKey = 0,
heroToExit = 0,
} = gameContext.mazeManager
? useScorePaths({ gameManager: gameContext as any })
: {};
// ✅ use unified naming for internal logic
const distToKey = entranceToKey || heroToKey;
const distToExit = entranceToExit || heroToExit;
const [finalScore, setFinalScore] = useState<number | null>(null);
const optimalRef = useRef<number>(0);
const keystrokes = keyManager ? keyManager.keyLog.length : 0;
// 1️⃣ Manage visible wall-clock timer
useScoreTime({ gameStatus, timer });
// 2️⃣ Compute static optimal path when maze (re)starts
useEffect(() => {
const maze = gameContext.mazeManager?.mazeInstanceRef?.current;
if (!maze) return;
const k = maze.getDistance("entrance", "key");
const e = maze.getDistance("key", "exit");
const total = k + e;
if (Number.isFinite(total) && total > 0) {
optimalRef.current = total;
}
}, [gameStatus, gameContext.mazeManager?.mazeInstanceRef]);
const optimalSteps = optimalRef.current;
// 3️⃣ Efficiency (keystrokes vs optimal steps)
const efficiency =
optimalSteps > 0 ? Math.round((keystrokes / optimalSteps) * 100) : 0;
// 4️⃣ Logic time (internal motion time)
const [logicFirst, setLogicFirst] = useState<number | null>(null);
const [logicLast, setLogicLast] = useState<number | null>(null);
useEffect(() => {
if (gameStatus === "started") {
setLogicFirst(null);
setLogicLast(null);
setPlayerSteps(0);
prevPosRef.current = null;
}
}, [gameStatus]);
useEffect(() => {
const onTick = (e: Event) => {
const t =
(e as CustomEvent<{ t?: number }>).detail?.t ?? performance.now();
setLogicFirst((prev) => prev ?? t);
setLogicLast(t);
};
window.addEventListener("maze-logic-tick", onTick as EventListener);
return () =>
window.removeEventListener("maze-logic-tick", onTick as EventListener);
}, []);
// 5️⃣ Actual player steps
const [playerSteps, setPlayerSteps] = useState(0);
const prevPosRef = useRef<{ r: number; c: number } | null>(null);
useEffect(() => {
const pos = gameContext.playerManager?.playerPos;
if (!pos) {
prevPosRef.current = null;
return;
}
if (!["started", "hasKey"].includes(gameStatus)) {
prevPosRef.current = pos;
return;
}
const prev = prevPosRef.current;
prevPosRef.current = pos;
if (!prev) return;
const delta = Math.abs(pos.r - prev.r) + Math.abs(pos.c - prev.c);
if (delta > 0) setPlayerSteps((s) => s + delta);
}, [gameStatus, gameContext.playerManager?.playerPos]);
// 6️⃣ Auto game-over on inefficiency
useEffect(() => {
if (gameStatus === "started" && efficiency > 150) {
setGameStatus("game-over");
}
}, [efficiency, gameStatus, setGameStatus]);
// 7️⃣ Final score on win
useEffect(() => {
if (gameStatus !== "game-won") return;
const logicMs =
logicFirst != null && logicLast != null
? Math.max(1, logicLast - logicFirst)
: timeValue;
const optimalMs = optimalSteps * 200;
const ratio = Math.max(0.1, optimalMs / logicMs);
const score = Math.min(100000, Math.round(ratio * 100000));
setFinalScore(score);
}, [gameStatus, logicFirst, logicLast, timeValue, optimalSteps, playerSteps]);
// ✅ Return clean, typed object
return {
timeValue,
startTimer,
stopTimer,
resetTimer,
distToKey,
distToExit,
keystrokes,
optimalSteps,
efficiency,
finalScore,
};
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useRef } from "react";
import type { UseTimerReturn } from "./hooks/useTimer";
import type { GameStatus } from "../types";
type UseScoreTimeParams = {
gameStatus: GameStatus;
timer: UseTimerReturn;
};
export function useScoreTime({ gameStatus, timer }: UseScoreTimeParams) {
const prevStatusRef = useRef<GameStatus | null>(null);
const { startTimer, stopTimer, resetTimer } = timer;
useEffect(() => {
// const prevStatus = prevStatusRef.current;
prevStatusRef.current = gameStatus;
switch (gameStatus) {
case "started":
// Always reset and start a fresh timer when a new game begins
resetTimer();
startTimer();
break;
case "hasKey":
// Keep running
break;
case "game-over":
case "game-won":
case "waiting":
// Stop timer when game finishes or is exited
stopTimer();
break;
}
}, [gameStatus, startTimer, stopTimer, resetTimer]);
}