working on refactor

This commit is contained in:
2025-10-17 03:07:07 +01:00
parent acd4ec2053
commit ee58ab25fb
27 changed files with 688 additions and 55 deletions
-232
View File
@@ -1,232 +0,0 @@
/* =========================================================
🔧 Controls (tweak these)
========================================================= */
:root {
--maze-cell-size: 24px;
--maze-number-font-size: 14px;
}
/* =========================================================
Container & Maze Base
========================================================= */
#maze_container {
position: relative;
margin: 1em auto;
width: fit-content;
}
#maze {
position: relative;
background-color: var(--card, #1e2030);
background-image:
radial-gradient(circle at 0% 0%, transparent 50%, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%),
radial-gradient(circle at center, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%, transparent 50%),
radial-gradient(circle at 100% 100%, transparent 50%, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%),
radial-gradient(circle at 0% 100%, transparent 50%, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%),
radial-gradient(circle at 100% 0%, transparent 50%, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 50%);
background-size: 8em 8em;
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);
}
/* =========================================================
Layout primitives
========================================================= */
#maze .maze-row,
#maze .number-strip {
display: flex; /* rows of equal-width cells */
}
/* Every grid box (maze cells + number cells) shares the same size */
#maze .maze-row > div,
#maze .number {
width: var(--maze-cell-size);
height: var(--maze-cell-size);
display: flex;
align-items: center;
justify-content: center;
}
/* Number cells (rows/cols) */
#maze .number {
color: var(--muted-foreground, #828bb8);
font-weight: 600;
font-family: "Fira Code", monospace;
font-size: var(--maze-number-font-size);
line-height: 1;
opacity: 0.85;
user-select: none;
}
#maze .number.corner { /* top-left/top-right/bottom corners */
width: var(--maze-cell-size);
}
/* Relative highlight (your tweak) */
#maze.is-relative .number.is-active {
background: var(--accent, #ffc777);
border-radius: 2px;
font-size: 1.5rem;
color: var(--accent-foreground, #1b1d2b);
}
/* =========================================================
Maze cells visuals (⚠️ scoped to real cells only)
========================================================= */
/* Base cell box */
#maze .maze-row > div[data-r][data-c] {
position: relative;
transition: background-color 0.2s ease;
}
/* 🟢 Corridors (non-wall/door/sentinel) */
#maze .maze-row > div[data-r][data-c]:not(.wall):not(.door):not(.sentinel) {
background-color: var(--muted, #2f334d);
background-image:
linear-gradient(135deg, color-mix(in srgb, var(--primary, #82aaff) 8%, transparent) 25%, transparent 25%),
linear-gradient(225deg, color-mix(in srgb, var(--primary, #82aaff) 6%, transparent) 25%, transparent 25%),
linear-gradient(45deg, color-mix(in srgb, var(--primary, #82aaff) 4%, transparent) 25%, transparent 25%),
linear-gradient(315deg, color-mix(in srgb, var(--primary, #82aaff) 3%, transparent) 25%, transparent 25%);
background-size: var(--maze-cell-size) var(--maze-cell-size);
box-shadow: inset 0 0 3px rgba(27,29,43,0.4);
}
/* 🧱 Walls and exits */
#maze .maze-row > div[data-r][data-c].wall,
#maze .maze-row > div[data-r][data-c].nubbin.wall,
#maze .maze-row > div[data-r][data-c].door.exit {
background-color: var(--background, #222436);
background-image:
linear-gradient(45deg, rgba(27,29,43,0.5) 45%, transparent 55%),
linear-gradient(to bottom, rgba(27,29,43,0.4) 45%, transparent 55%);
background-size: calc(var(--maze-cell-size) * 0.5) calc(var(--maze-cell-size) * 0.5);
box-shadow: inset 0 0 3px rgba(27,29,43,0.6);
}
/* 🔒 Door (exit) — FIXED BACKGROUND */
#maze .maze-row > div[data-r][data-c].door.exit {
background: radial-gradient(circle at center, var(--chart-4, #4fd6be) 10%, var(--card, #1e2030) 90%) !important;
box-shadow:
0 0 6px color-mix(in srgb, var(--chart-4, #4fd6be) 50%, transparent),
inset 0 0 4px color-mix(in srgb, var(--chart-4, #4fd6be) 30%, transparent);
border-radius: 3px;
z-index: 2;
}
#maze .maze-row > div[data-r][data-c].door.exit::after {
content: "\1F6AA";
color: var(--chart-4, #4fd6be);
text-shadow: 0 0 6px color-mix(in srgb, var(--chart-4, #4fd6be) 60%, transparent);
position: absolute;
top: -4px;
left: -2px;
z-index: 3;
}
/* 💎 Decorations (nubbins) */
#maze .maze-row > div[data-r][data-c]::after {
position: absolute;
left: -3px;
top: -4px;
text-align: center;
text-shadow: 0 0 3px rgba(0,0,0,0.6);
font-size: calc(var(--maze-cell-size) * 0.75); /* scale with cell */
z-index: 10;
}
#maze .maze-row > div[data-r][data-c].nubbin::after { content: "\1F33C"; }
#maze .maze-row > div[data-r][data-c].nubbin:nth-of-type(3n)::after { content: "\1F344"; }
#maze .maze-row > div[data-r][data-c].nubbin:nth-of-type(5n)::after { content: "\1F33B"; }
#maze .maze-row > div[data-r][data-c].nubbin:nth-of-type(7n)::after { content: "\1F48E"; }
#maze .maze-row > div[data-r][data-c].nubbin.wall::after { content: ""; }
/* 🧍 Hero */
#maze .maze-row > div[data-r][data-c].hero {
background-color: var(--primary, #82aaff);
border-radius: 50%;
box-shadow: 0 0 8px color-mix(in srgb, var(--primary, #82aaff) 70%, transparent);
}
#maze.face-right .maze-row > div[data-r][data-c].hero::after { transform: scale(-1, 1); }
#maze .maze-row > div[data-r][data-c].hero::after { content: "\1F6B6" !important; }
/* 🐍 Sentinels */
#maze .maze-row > div[data-r][data-c].sentinel.wall { background: transparent; }
#maze .maze-row > div[data-r][data-c].sentinel.wall::after {
content: "\1F40A";
color: var(--muted-foreground, #828bb8);
}
#maze .maze-row > div[data-r][data-c].sentinel.wall:nth-of-type(3n)::after { content: "\1F40D"; }
/* 🗝️ Key */
#maze .maze-row > div[data-r][data-c].key::after {
content: "\1F511";
color: var(--accent, #ffc777);
text-shadow: 0 0 6px color-mix(in srgb, var(--accent, #ffc777) 80%, transparent);
}
/* =========================================================
Score / message bar (unchanged)
========================================================= */
#maze_output {
display: flex;
margin: 0.5em auto;
}
#maze_score,
#maze_message {
font-family: "Fira Code", monospace;
font-weight: 600;
font-size: 1em;
color: var(--foreground, #c8d3f5);
}
#maze_score {
flex: 1;
text-align: left;
}
#maze_score::before {
content: "Score: ";
color: var(--muted-foreground, #828bb8);
}
#maze_score.has-key::after {
content: "\00a0\1F511";
color: var(--accent, #ffc777);
}
#maze_message {
flex: 3;
text-align: right;
}
/* Subtle step trail effect for counted moves */
#maze .maze-row > div[data-r][data-c].trail {
box-shadow:
inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 60%, transparent),
0 0 6px color-mix(in srgb, var(--primary, #82aaff) 35%, transparent);
animation: maze-trail-fade 16ms ease-out;
}
/* ===== Invalid move "red mist" around hero ===== */
#maze .maze-row > div[data-r][data-c].invalid-move {
animation: maze-invalid-pulse 180ms ease-out;
box-shadow:
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent);
}
@keyframes maze-invalid-pulse {
0% {
transform: scale(1.02);
box-shadow:
0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent),
0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent);
}
100% {
transform: none;
box-shadow: none;
}
}
@keyframes maze-trail-fade {
from { background-color: color-mix(in srgb, var(--primary, #82aaff) 25%, transparent); }
to { background-color: transparent; }
}
-57
View File
@@ -1,57 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react";
/**
* useTimer – a low-level timer that updates with requestAnimationFrame.
*
* @returns { timeValue, startTimer, stopTimer, resetTimer }
*/
export function useTimer() {
const [timeValue, setTimeValue] = useState(0);
const startTimeRef = useRef<number | null>(null);
const frameRef = useRef<number | null>(null);
const runningRef = useRef(false);
// 🕒 Start timer
const startTimer = useCallback(() => {
if (runningRef.current) return; runningRef.current = true;
startTimeRef.current = performance.now();
const tick = () => {
if (!runningRef.current) return;
const now = performance.now();
const elapsed = now - (startTimeRef.current ?? now);
setTimeValue(elapsed);
frameRef.current = requestAnimationFrame(tick);
};
frameRef.current = requestAnimationFrame(tick);
}, []);
// ⏹ Stop/pause timer
const stopTimer = useCallback(() => {
runningRef.current = false;
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
}, []);
// 🔄 Reset timer
const resetTimer = useCallback(() => {
stopTimer();
setTimeValue(0);
startTimeRef.current = null;
}, [stopTimer]);
// 🧹 Cleanup on unmount
useEffect(() => {
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, []);
return { timeValue, startTimer, stopTimer, resetTimer };
}
export type UseTimerReturn = ReturnType<typeof useTimer>;
+1 -5
View File
@@ -1,6 +1,2 @@
import './game.css';
export { MazeGenerator } from "./MazeGenerator";
export { MazeRenderer } from "./MazeRenderer";
export * from './useGame';
export * from './types';
export * from './useGame';
+31 -31
View File
@@ -1,43 +1,38 @@
import { useScore } from "./useScore";
import { useGame } from "./useGame";
export type UseGameType = ReturnType<typeof useGame>;
export type UseScoreType = ReturnType<typeof useScore>;
import type { RefObject } from 'react';
export * from './useGame';
export * from './useBoard';
export * from './useCursor';
export type GamePhase = "idle" | "playing";
export type PlayStatus = "started" | "hasKey" | "game-over" | "game-won";
export type GameStatus = "waiting" | "started" | "hasKey" | "game-over" | "game-won";
export type GameStatus = 'waiting' | 'started' | 'hasKey' | 'paused' | 'game-over' | 'game-won'
export const isPlaying = (status: GameStatus): status is PlayStatus =>
status === "started" || status === "hasKey" || status === "game-over" || status === "game-won";
export const getGamePhase = (status: GameStatus): GamePhase =>
status === "waiting" ? "idle" : "playing";
export type KeyLogEntry = { key: string; timestamp: number };
export type KeyLogProvider = { keyLog: KeyLogEntry[] };
export type GameScoreContext = {
export type GameManager = { //return type of useGame
containerRef: RefObject<HTMLDivElement | null>;
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
stopGame: () => void;
renderBoard: () => void;
startGame: () => void;
togglePause: (pause?: boolean) => void;
quitGame: () => void;
// Optional — only Maze-type games provide these
mazeManager?: {
mazeInstanceRef?: { current?: any };
};
playerManager?: {
playerPos?: { r: number; c: number } | null;
};
cursor: Cursor;
};
export type UseScoreParams = {
gameContext: GameScoreContext;
keyManager?: KeyLogProvider;
};
export type Cursor = { //return type of useCursor (currently useHero)
position: () => Coord;
mode: () => CursorMode;
move: (dCols: number, dRows: number, count: number) => void;
moveLeft: (count?: number) => void;
moveRight: (count?: number) => void;
moveUp: (count?: number) => void;
moveDown: (count?: number) => void;
}
export type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line';
export type PositionTag = "entrance" | "exit" | "key" | "hero";
export type Coord = [number, number];
export type Coord = { row: number, col: number };
export type CellTag = "wall" | "door" | PositionTag;
@@ -52,3 +47,8 @@ export interface MazeData {
totalSteps: number;
}
export type GameOptions = {
rows: number;
cols: number;
};
@@ -1,4 +1,4 @@
import type { PositionTag, Coord, CellTag, MazeCell, MazeData } from "./types";
import type { PositionTag, Coord, CellTag, MazeCell, MazeData } from "../types";
export class MazeGenerator {
width: number;
@@ -344,3 +344,4 @@ export class MazeGenerator {
return this.getData();
}
}
@@ -1,4 +1,4 @@
import type { MazeData } from "./types";
import type { MazeData } from "../types";
export class MazeRenderer {
private mazeData: MazeData;
@@ -113,3 +113,4 @@ export class MazeRenderer {
this.getCellEl(container, r, c)?.classList.toggle(cls);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './useBoard';
+46
View File
@@ -0,0 +1,46 @@
import { useRef } from "react";
import type { RefObject } from 'react';
import { MazeGenerator } from "./MazeGenerator";
import { MazeRenderer } from "./MazeRenderer";
import type { MazeCell } from "../types";
export function useBoard(cols: number, rows: number) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mazeRef = useRef<MazeCell[][]>([]);
const mazeInstanceRef = useRef<MazeGenerator | null>(null);
const rendererRef = useRef<MazeRenderer | null>(null);
function renderBoard() {
const container = containerRef.current;
if (!container) return;
container.innerHTML = "";
const generator = new MazeGenerator(cols, rows);
generator.placeKey();
const renderer = new MazeRenderer(generator.getData(), true);
renderer.display(container);
mazeRef.current = generator.maze;
mazeInstanceRef.current = generator;
rendererRef.current = renderer;
}
return {
containerRef,
mazeRef,
mazeInstanceRef,
rendererRef,
renderBoard,
};
}
export type BoardManager = {
containerRef: RefObject<HTMLDivElement | null>
mazeRef: RefObject<MazeCell[][]>
mazeInstanceRef: RefObject<MazeGenerator | null>
rendererRef: RefObject<MazeRenderer | null>
renderBoard: () => void
}
+1
View File
@@ -0,0 +1 @@
export * from './useCursor';
+37
View File
@@ -0,0 +1,37 @@
import { useRef } from 'react'
import type { Cursor, CursorMode, Coord, BoardManager } from '../types'
export function useCursor(board: BoardManager): Cursor {
const position = useRef<Coord>({ row: 0, col: 0 })
const mode = useRef<CursorMode>('normal')
function move(dCols: number, dRows: number, count: number = 1) {
}
function moveLeft(count = 1) {
// TODO: implement move left
}
function moveRight(count = 1) {
// TODO: implement move right
}
function moveUp(count = 1) {
// TODO: implement move up
}
function moveDown(count = 1) {
// TODO: implement move down
}
return {
position: () => position.current,
mode: () => mode.current,
move,
moveLeft,
moveRight,
moveUp,
moveDown
}
}
+45
View File
@@ -0,0 +1,45 @@
import { useState } from 'react';
import type { GameManager, GameOptions, GameStatus } from './types';
import { useBoard } from './useBoard';
import { useCursor } from './useCursor';
export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
const { cols, rows } = options;
const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
const boardManager = useBoard(cols, rows);
const { containerRef, renderBoard } = boardManager;
const cursor = useCursor(boardManager);
function startGame() {
setGameStatus('started');
}
function togglePause(pause?: boolean) {
if (typeof pause === 'boolean') {
setGameStatus(pause ? 'paused' : 'started');
return;
}
setGameStatus(prevStatus => prevStatus === 'paused' ? 'started' : 'paused');
}
function quitGame() {
setGameStatus('waiting');
}
const gameManager = {
containerRef,
gameStatus,
renderBoard,
startGame,
togglePause,
quitGame,
cursor,
}
if (typeof platformHook === 'function') {
platformHook(gameManager);
}
return gameManager;
}
-2
View File
@@ -1,2 +0,0 @@
export * from './useGame';
export * from './useKeyBindings';
-86
View File
@@ -1,86 +0,0 @@
import { useEffect } from "react";
import { useMaze } from "./useMaze";
import { usePlayer } from "./usePlayer";
import { useGameStatus } from "./useGameStatus";
import { useHeroRender } from "./useHeroRender";
import { useScore } from "../useScore";
import type { UseScoreType } from '../types'
import { useKeyBindings, type UseKeyBindingsType } from "./useKeyBindings";
export function useGame(cols: number, rows: number, platformHook?: unknown) {
const mazeManager = useMaze(cols, rows);
const playerManager = usePlayer(mazeManager);
const gameManager = useGameStatus(mazeManager, playerManager);
useHeroRender({ mazeManager, playerManager });
const { initMaze } = mazeManager;
const { movePlayer } = playerManager;
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
const { containerRef } = mazeManager;
// Initialize maze on mount
useEffect(() => {
initMaze();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startNewGame = (autoStart = false) => {
if (autoStart) {
initMaze(true, startGame);
} else {
initMaze();
}
};
// 🎮 key bindings integrated
const keyBindings: UseKeyBindingsType = useKeyBindings({
gameManager: {
movePlayer: (dr: number, dc: number, count: number = 1) =>
movePlayer(dr, dc, count, gameStatus, setGameStatus),
gameStatus,
mazeManager,
playerManager,
} as any, // minimal shape needed by useKeyBindings
});
// 🏆 scoring integrated — pass the full context it needs (incl. maze/player)
const scoreManager: UseScoreType = useScore({
gameContext: {
gameStatus,
setGameStatus,
stopGame,
mazeManager, // <-- provide for useScorePaths
playerManager, // <-- provide for step counting
},
keyManager: { keyLog: keyBindings.keyLog }, // minimal info; avoids type cycles
});
const fullGameManager = {
// core game
containerRef,
gameStatus,
setGameStatus,
mazeManager,
playerManager,
startGame,
initGame: startNewGame,
movePlayer: (dr: number, dc: number, count: number = 1) =>
movePlayer(dr, dc, count, gameStatus, setGameStatus),
stopGame,
reset: () => initMaze(),
// key bindings
...keyBindings,
// scoring
scoreManager,
};
if (typeof platformHook === 'function') {
platformHook(fullGameManager);
}
return fullGameManager;
}
export type { GameStatus } from "../types";
-55
View File
@@ -1,55 +0,0 @@
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>;
-81
View File
@@ -1,81 +0,0 @@
import { useEffect } from "react";
import type { UseMazeType } from "./useMaze";
import type { UsePlayerType } from "./usePlayer";
type UseHeroRenderParams = {
mazeManager: UseMazeType;
playerManager: UsePlayerType;
relative?: boolean;
};
export function useHeroRender(params: UseHeroRenderParams) {
const { mazeManager, playerManager, relative = true } = params;
const { containerRef } = mazeManager;
const { playerPos } = playerManager;
const applyRelativeNumbers = (mazeDiv: HTMLElement, R: number, C: number) => {
// counts provided by caller
const rows = Number(mazeDiv.dataset.rows || 0);
const cols = Number(mazeDiv.dataset.cols || 0);
// rows: borders blank; active row shows its ABSOLUTE number (r), others show |r-R|
const rowNums = mazeDiv.querySelectorAll<HTMLElement>(".number.row[data-r]");
rowNums.forEach((el) => {
const r = Number(el.getAttribute("data-r"));
if (r <= 0 || r >= rows) {
el.textContent = "";
el.classList.remove("is-active");
return;
}
el.textContent = r === R ? String(r) : String(Math.abs(r - R));
el.classList.toggle("is-active", r === R);
});
// cols (top & bottom): borders blank; active col shows ABSOLUTE number (c), others show |c-C|
const colNums = mazeDiv.querySelectorAll<HTMLElement>(".number.col[data-c]");
colNums.forEach((el) => {
const c = Number(el.getAttribute("data-c"));
if (c <= 0 || c >= cols) {
el.textContent = "";
el.classList.remove("is-active");
return;
}
el.textContent = c === C ? String(c) : String(Math.abs(c - C));
el.classList.toggle("is-active", c === C);
});
mazeDiv.classList.add("is-relative");
};
useEffect(() => {
if (!playerPos) return;
const container = containerRef.current;
if (!container) return;
const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
if (!mazeDiv) return;
// Remove any previous hero marker
mazeDiv.querySelectorAll(".hero").forEach((el) => el.classList.remove("hero"));
// Place hero (select exact cell by coordinates)
const selector = `.maze-row > div[data-r="${playerPos.r}"][data-c="${playerPos.c}"]`;
const cell = mazeDiv.querySelector(selector) as HTMLElement | null;
if (cell) cell.classList.add("hero");
// Relative numbering (absolute on the active line/col)
if (relative) {
// Provide counts so helper can know borders
mazeDiv.dataset.rows = String(mazeDiv.querySelectorAll(".maze-row").length);
mazeDiv.dataset.cols = String(
mazeDiv
.querySelector(".maze-row > div[data-r][data-c]")
?.parentElement?.querySelectorAll(':scope > div[data-r][data-c]').length || 0
);
applyRelativeNumbers(mazeDiv, playerPos.r, playerPos.c);
}
}, [playerPos, containerRef]);
}
export type UseHeroRenderType = ReturnType<typeof useHeroRender>;
-259
View File
@@ -1,259 +0,0 @@
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>;
-45
View File
@@ -1,45 +0,0 @@
import { useRef, useCallback } from "react";
import { MazeGenerator } from "../MazeGenerator";
import { MazeRenderer } from "../MazeRenderer";
import type { MazeCell } from "../types";
export function useMaze(cols: number, rows: number) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mazeRef = useRef<MazeCell[][]>([]);
const mazeInstanceRef = useRef<MazeGenerator | null>(null);
const rendererRef = useRef<MazeRenderer | null>(null);
const initMaze = useCallback(
(autoStart = false, startCallback?: () => void) => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = "";
const generator = new MazeGenerator(cols, rows);
generator.placeKey();
const renderer = new MazeRenderer(generator.getData(), true);
renderer.display(container);
mazeRef.current = generator.maze;
mazeInstanceRef.current = generator;
rendererRef.current = renderer;
if (autoStart && startCallback) {
setTimeout(() => startCallback(), 0);
}
},
[cols, rows]
);
return {
containerRef,
mazeRef,
mazeInstanceRef,
rendererRef,
initMaze,
};
}
export type UseMazeType = ReturnType<typeof useMaze>;
-202
View File
@@ -1,202 +0,0 @@
import { useRef, useState, useCallback, useEffect } from "react";
import type { UseMazeType } from "./useMaze";
import type { GameStatus } from "./useGame";
export function usePlayer(mazeManager: UseMazeType) {
const { mazeRef, containerRef } = mazeManager;
const [playerPos, setPlayerPos] = useState<{ r: number; c: number } | null>(null);
// animation gate so we don't accept new motions mid-anim
const animatingRef = useRef(false);
// Helper: get the maze cell element by [r,c] via data attributes
const getCellEl = useCallback((r: number, c: number): HTMLElement | null => {
const container = containerRef.current;
if (!container) return null;
const mazeDiv = container.querySelector("#maze");
if (!mazeDiv) return null;
const selector = `.maze-row > div[data-r="${r}"][data-c="${c}"]`;
return mazeDiv.querySelector(selector) as HTMLElement | null;
}, [containerRef]);
// Visual: briefly highlight a visited cell
const leaveTrail = useCallback((r: number, c: number) => {
const el = getCellEl(r, c);
if (!el) return;
el.classList.add("trail");
setTimeout(() => el.classList.remove("trail"), 220);
}, [getCellEl]);
// Visual: flash an invalid move on the hero's current cell
const flashInvalid = useCallback(() => {
if (!playerPos) return;
const el = getCellEl(playerPos.r, playerPos.c);
if (!el) return;
el.classList.add("invalid-move");
setTimeout(() => el.classList.remove("invalid-move"), 180);
}, [playerPos, getCellEl]);
// Allow other modules (e.g., keybindings) to signal an invalid attempt
useEffect(() => {
const onInvalid = () => flashInvalid();
window.addEventListener("maze-invalid", onInvalid);
return () => window.removeEventListener("maze-invalid", onInvalid);
}, [flashInvalid]);
/**
* movePlayer — supports counted motions with an all-or-nothing rule.
* If steps > 1 and the path is valid, animates quickly (many steps per frame),
* then applies interactions (key/exit) on the final tile.
*/
const movePlayer = useCallback(
(
dr: number,
dc: number,
steps: number | undefined,
gameStatus: GameStatus,
setGameStatus: (s: GameStatus) => void
) => {
const m = mazeRef.current;
if (!playerPos || !m.length) return;
if (gameStatus !== "started" && gameStatus !== "hasKey") return;
if (animatingRef.current) return; // ignore input mid-animation
const n = Math.max(1, steps ?? 1);
// ----- Preflight: simulate n steps; abort if any step is invalid -----
const r0 = playerPos.r;
const c0 = playerPos.c;
let r = r0;
let c = c0;
let valid = true;
for (let i = 1; i <= n; i++) {
r += dr;
c += dc;
if (!m[r] || !m[r][c] || m[r][c].includes("wall")) {
valid = false;
break;
}
if (m[r][c].includes("exit") && gameStatus !== "hasKey") {
valid = false;
break;
}
}
if (!valid) {
flashInvalid(); // ❌ all-or-nothing: show feedback and do nothing
return;
}
// ----- Animated multi-step (RAF, fast: multiple steps per frame, short duration) -----
if (n > 1) {
animatingRef.current = true;
let i = 0;
let rNow = r0;
let cNow = c0;
let cancelled = false;
let rafId: number | null = null;
// Target total duration (tweak): snappy/fast
const TOTAL_MS = 64; // 48 or 32 for ultra-fast
const framesTarget = Math.max(1, Math.round(TOTAL_MS / 16)); // ~60fps frame estimate
const stepsPerFrame = Math.max(1, Math.ceil(n / framesTarget));
const cancel = () => {
cancelled = true;
if (rafId !== null) cancelAnimationFrame(rafId);
window.removeEventListener("keydown", onEsc);
animatingRef.current = false;
};
const onEsc = (ev: KeyboardEvent) => {
if (ev.key === "Escape") cancel();
};
window.addEventListener("keydown", onEsc);
const tick = () => {
if (cancelled) return;
// churn through multiple steps in one frame
let processed = 0;
while (processed < stepsPerFrame && i < n) {
i += 1;
rNow += dr;
cNow += dc;
leaveTrail(rNow, cNow); // visual only
processed += 1;
}
// move hero to the latest stepped cell once per frame
setPlayerPos({ r: rNow, c: cNow });
window.dispatchEvent(new CustomEvent("maze-logic-tick", { detail: { t: performance.now() } }));
if (i < n) {
rafId = requestAnimationFrame(tick);
} else {
// final-tile interactions
if (m[rNow][cNow].includes("key")) {
setGameStatus("hasKey");
m[rNow][cNow] = [];
const container = containerRef.current;
const mazeDiv = container?.querySelector("#maze");
const keyCell = mazeDiv?.querySelector<HTMLElement>(
`.maze-row > div[data-r="${rNow}"][data-c="${cNow}"]`
);
keyCell?.classList.remove("key");
}
if (m[rNow][cNow].includes("exit")) {
setGameStatus("game-won");
}
window.removeEventListener("keydown", onEsc);
animatingRef.current = false;
}
};
rafId = requestAnimationFrame(tick);
return;
}
// ----- Single-step (no animation) -----
const newR = r0 + dr;
const newC = c0 + dc;
if (!m[newR] || !m[newR][newC]) {
flashInvalid();
return;
}
if (m[newR][newC].includes("wall")) {
flashInvalid();
return;
}
if (m[newR][newC].includes("exit") && gameStatus !== "hasKey") {
flashInvalid();
return;
}
if (m[newR][newC].includes("key")) {
setGameStatus("hasKey");
m[newR][newC] = [];
const container = containerRef.current;
const mazeDiv = container?.querySelector("#maze");
const cell = mazeDiv?.querySelector<HTMLElement>(
`.maze-row > div[data-r="${newR}"][data-c="${newC}"]`
);
cell?.classList.remove("key");
}
if (m[newR][newC].includes("exit")) {
setGameStatus("game-won");
}
setPlayerPos({ r: newR, c: newC });
},
[playerPos, mazeRef, containerRef, flashInvalid, leaveTrail]
);
return {
playerPos,
setPlayerPos,
movePlayer,
};
}
export type UsePlayerType = ReturnType<typeof usePlayer>;
-85
View File
@@ -1,85 +0,0 @@
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
@@ -1 +0,0 @@
export * from './useScore';
-143
View File
@@ -1,143 +0,0 @@
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,
};
}
-58
View File
@@ -1,58 +0,0 @@
import { useEffect, useState } from "react";
import type { GameStatus } from "../types";
// explicit so as not to break with circular deps, but should be UseGameType
type UseScorePathsParams = {
gameManager: {
gameStatus: GameStatus;
mazeManager: {
mazeInstanceRef: { current: any };
};
playerManager: {
playerPos: { r: number; c: number } | null;
};
};
};
export function useScorePaths({ gameManager }: UseScorePathsParams) {
const { gameStatus } = gameManager;
const { mazeInstanceRef } = gameManager.mazeManager;
const { playerPos } = gameManager.playerManager;
const [distances, setDistances] = useState({
entranceToKey: Infinity,
entranceToExit: Infinity,
heroToKey: Infinity,
heroToExit: Infinity,
});
// 🧭 1️⃣ Calculate static distances (entrance-based) when game starts
useEffect(() => {
if (gameStatus !== "started") return;
const maze = mazeInstanceRef.current;
if (!maze) return;
setDistances((prev) => ({
...prev,
entranceToKey: maze.getDistance("entrance", "key"),
entranceToExit: maze.getDistance("entrance", "exit"),
}));
}, [gameStatus, mazeInstanceRef]);
// 🚶 2️⃣ Update dynamic distances (hero-based) on every move
useEffect(() => {
const maze = mazeInstanceRef.current;
if (!maze || !playerPos) return;
setDistances((prev) => ({
...prev,
heroToKey: maze.getDistance("hero", "key", [playerPos.r, playerPos.c]),
heroToExit: maze.getDistance("hero", "exit", [playerPos.r, playerPos.c]),
}));
}, [playerPos, mazeInstanceRef]);
return distances;
}
export type UseScorePathsReturn = ReturnType<typeof useScorePaths>;
-38
View File
@@ -1,38 +0,0 @@
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]);
}