working on unified API and major refactoring

This commit is contained in:
2025-10-19 01:48:20 +01:00
parent 8f62c6c2b5
commit 4db86e1cfe
14 changed files with 1306 additions and 54 deletions
+232
View File
@@ -0,0 +1,232 @@
/* =========================================================
🔧 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; }
}
+2
View File
@@ -1,2 +1,4 @@
export * from './types';
export * from './useGame';
export * from './useGameStatus';
export * from './useKeyBindings';
+48 -12
View File
@@ -1,11 +1,12 @@
import type { RefObject } from 'react';
import { MazeRenderer, MazeGenerator } from "./useBoard";
export * from './useGame';
export * from './useBoard';
export * from './useCursor';
export type GameStatus = 'waiting' | 'started' | 'hasKey' | 'paused' | 'game-over' | 'game-won'
export type GameManager = { //return type of useGame
export type GameManager = {
containerRef: RefObject<HTMLDivElement | null>;
gameStatus: GameStatus;
renderBoard: () => void;
@@ -14,21 +15,18 @@ export type GameManager = { //return type of useGame
quitGame: () => void;
cursor: Cursor;
keyManager: GameKeyManager;
};
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 BoardManager = {
containerRef: RefObject<HTMLDivElement | null>
mazeRef: RefObject<MazeCell[][]>
mazeInstanceRef: RefObject<MazeGenerator | null>
rendererRef: RefObject<MazeRenderer | null>
renderBoard: () => void
}
export type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line';
export type PositionTag = "entrance" | "exit" | "key" | "hero";
@@ -52,3 +50,41 @@ export type GameOptions = {
rows: number;
cols: number;
};
export type KeyLogEntry = { key: string; timestamp: number };
export type GameKeyManager = {
keyLog: KeyLogEntry[];
clearLog: () => void;
getLog: () => KeyLogEntry[];
};
export type GameStatusManager = {
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
startGame: () => void;
togglePause: (pause?: boolean) => void;
quitGame: () => void;
};
//Cursor Stuff
export type CursorMode = 'normal' | 'insert' | 'replace' | 'visual' | 'visual-line';
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;
moveToStart: () => void;
moveToEnd: () => void;
moveToTop: () => void;
moveToBottom: () => void;
repeatLastMotion: () => void;
resetCount: () => void;
getCount: () => string;
setCount: (digit: string) => void;
setLastKey: (key: string) => void;
getLastKey: () => string;
}
+2
View File
@@ -1 +1,3 @@
export * from './useBoard';
export * from './MazeGenerator';
export * from './MazeRenderer';
+8 -10
View File
@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useEffect, useRef } from "react";
import type { RefObject } from 'react';
import { MazeGenerator } from "./MazeGenerator";
import { MazeRenderer } from "./MazeRenderer";
@@ -10,6 +10,13 @@ export function useBoard(cols: number, rows: number) {
const mazeInstanceRef = useRef<MazeGenerator | null>(null);
const rendererRef = useRef<MazeRenderer | null>(null);
useEffect(() => {
if (containerRef.current) {
renderBoard();
}
}, [containerRef]);
function renderBoard() {
const container = containerRef.current;
if (!container) return;
@@ -35,12 +42,3 @@ export function useBoard(cols: number, rows: number) {
renderBoard,
};
}
export type BoardManager = {
containerRef: RefObject<HTMLDivElement | null>
mazeRef: RefObject<MazeCell[][]>
mazeInstanceRef: RefObject<MazeGenerator | null>
rendererRef: RefObject<MazeRenderer | null>
renderBoard: () => void
}
+202 -7
View File
@@ -1,27 +1,212 @@
import { useRef } from 'react'
import type { Cursor, CursorMode, Coord, BoardManager } from '../types'
import type { CursorMode, Coord, BoardManager } from '../types'
export function useCursor(board: BoardManager): Cursor {
type Motion = { dr: number; dc: number; steps: number };
export function useCursor(board: BoardManager) {
const position = useRef<Coord>({ row: 0, col: 0 })
const mode = useRef<CursorMode>('normal')
const countRef = useRef<string>("");
const lastMotionRef = useRef<Motion | null>(null);
const lastKeyRef = useRef<string>("");
function findAnchorTarget(dir: "left" | "right") {
const m = board.mazeRef.current;
const pos = position.current;
if (!m.length) return null;
const colsCount = m[0]?.length ?? 0;
const r = pos.row;
const c = pos.col;
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;
}
}
function findColumnAnchorTarget(dir: "top" | "bottom") {
const m = board.mazeRef.current;
const pos = position.current;
if (!m.length) return null;
const rowsCount = m.length;
const r = pos.row;
const c = pos.col;
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;
}
}
function move(dCols: number, dRows: number, count: number = 1) {
const m = board.mazeRef.current;
if (!m.length) return;
const currentPos = position.current;
const n = Math.max(1, count);
// Preflight: simulate n steps; abort if any step is invalid
let r = currentPos.row;
let c = currentPos.col;
let valid = true;
for (let i = 1; i <= n; i++) {
r += dRows;
c += dCols;
if (!m[r] || !m[r][c] || m[r][c].includes("wall")) {
valid = false;
break;
}
}
if (!valid) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
// Update position
position.current = { row: r, col: c };
lastMotionRef.current = { dr: dRows, dc: dCols, steps: n };
}
function moveLeft(count = 1) {
// TODO: implement move left
move(-1, 0, count);
}
function moveRight(count = 1) {
// TODO: implement move right
move(1, 0, count);
}
function moveUp(count = 1) {
// TODO: implement move up
move(0, -1, count);
}
function moveDown(count = 1) {
// TODO: implement move down
move(0, 1, count);
}
function moveToStart() {
const anchor = findAnchorTarget("left");
if (!anchor) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
const pos = position.current;
const dr = 0;
const dc = anchor.cTarget > pos.col ? 1 : -1;
const steps = Math.abs(anchor.cTarget - pos.col);
move(dr, dc, steps);
}
function moveToEnd() {
const anchor = findAnchorTarget("right");
if (!anchor) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
const pos = position.current;
const dr = 0;
const dc = anchor.cTarget > pos.col ? 1 : -1;
const steps = Math.abs(anchor.cTarget - pos.col);
move(dr, dc, steps);
}
function moveToTop() {
const anchor = findColumnAnchorTarget("top");
if (!anchor) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
const pos = position.current;
const dr = anchor.rTarget > pos.row ? 1 : -1;
const dc = 0;
const steps = Math.abs(anchor.rTarget - pos.row);
move(dr, dc, steps);
}
function moveToBottom() {
const anchor = findColumnAnchorTarget("bottom");
if (!anchor) {
window.dispatchEvent(new Event("maze-invalid"));
return;
}
const pos = position.current;
const dr = anchor.rTarget > pos.row ? 1 : -1;
const dc = 0;
const steps = Math.abs(anchor.rTarget - pos.row);
move(dr, dc, steps);
}
function repeatLastMotion() {
const last = lastMotionRef.current;
if (!last) return;
const steps = Math.max(1, parseInt(countRef.current || String(last.steps), 10));
resetCount();
move(last.dc, last.dr, steps);
}
function resetCount() {
countRef.current = "";
}
function getCount() {
return countRef.current;
}
function setCount(digit: string) {
countRef.current += digit;
}
function setLastKey(key: string) {
lastKeyRef.current = key;
}
function getLastKey() {
return lastKeyRef.current;
}
return {
@@ -31,7 +216,17 @@ export function useCursor(board: BoardManager): Cursor {
moveLeft,
moveRight,
moveUp,
moveDown
moveDown,
moveToStart,
moveToEnd,
moveToTop,
moveToBottom,
repeatLastMotion,
resetCount,
getCount,
setCount,
setLastKey,
getLastKey,
}
}
+7 -18
View File
@@ -1,31 +1,19 @@
import { useState } from 'react';
import type { GameManager, GameOptions, GameStatus } from './types';
import type { GameManager, GameOptions } from './types';
import { useBoard } from './useBoard';
import { useCursor } from './useCursor';
import { useKeyBindings } from './useKeyBindings';
import { useGameStatus } from './useGameStatus';
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);
const gameStatusManager = useGameStatus();
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 { gameStatus, startGame, togglePause, quitGame } = gameStatusManager;
const keyManager = useKeyBindings({ cursor, gameStatus });
const gameManager = {
containerRef,
@@ -35,6 +23,7 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag
togglePause,
quitGame,
cursor,
keyManager,
}
if (typeof platformHook === 'function') {
+1
View File
@@ -0,0 +1 @@
export * from './useGameStatus';
+31
View File
@@ -0,0 +1,31 @@
import { useState } from 'react';
import type { GameStatus } from '../types';
export function useGameStatus() {
const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
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');
}
return {
gameStatus,
setGameStatus,
startGame,
togglePause,
quitGame,
};
}
+1
View File
@@ -0,0 +1 @@
export * from './useKeyBindings';
+137
View File
@@ -0,0 +1,137 @@
import type { GameStatus, KeyLogEntry } from "../types";
import { useEffect, useRef, useState } from "react";
export type CursorBindingContext = {
moveLeft: (count?: number) => void;
moveRight: (count?: number) => void;
moveUp: (count?: number) => void;
moveDown: (count?: number) => void;
moveToStart: () => void;
moveToEnd: () => void;
moveToTop: () => void;
moveToBottom: () => void;
repeatLastMotion: () => void;
resetCount: () => void;
getCount: () => string;
setCount: (digit: string) => void;
setLastKey: (key: string) => void;
getLastKey: () => string;
};
type UseKeyBindingsParams = {
cursor: CursorBindingContext;
gameStatus: GameStatus;
};
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 = ({ cursor, gameStatus }: UseKeyBindingsParams) => {
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
const logRef = useRef<KeyLogEntry[]>([]);
const clearLog = () => {
logRef.current = [];
setKeyLog([]);
};
const recordKey = (key: string) => {
const entry = { key, timestamp: performance.now() };
logRef.current.push(entry);
setKeyLog([...logRef.current]);
};
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" && cursor.getCount() === "") {
recordKey("0");
cursor.resetCount();
cursor.moveToStart();
return;
}
const isDigit = e.key >= "0" && e.key <= "9";
if (isDigit) {
if (e.key === "0" && cursor.getCount() === "") return;
recordKey(e.key);
cursor.setCount(e.key);
return;
}
if (e.key === ".") {
recordKey(".");
cursor.repeatLastMotion();
return;
}
if (e.key === "^" || e.key === "$") {
recordKey(e.key);
cursor.resetCount();
if (e.key === "^") {
cursor.moveToStart();
} else {
cursor.moveToEnd();
}
return;
}
if (e.key === "g" || e.key === "G") {
if (e.key === "g") {
if (cursor.getLastKey() === "g") {
recordKey(e.key);
cursor.resetCount();
cursor.setLastKey("");
cursor.moveToTop();
return;
} else {
recordKey(e.key);
cursor.setLastKey("g");
return;
}
} else {
recordKey(e.key);
cursor.resetCount();
cursor.setLastKey("");
cursor.moveToBottom();
return;
}
}
if (!RELEVANT_KEYS.has(e.key)) return;
recordKey(e.key);
const count = Math.max(1, parseInt(cursor.getCount() || "1", 10));
cursor.resetCount();
switch (e.key) {
case "h":
case "H": cursor.moveLeft(count); break;
case "j":
case "J": cursor.moveDown(count); break;
case "k":
case "K": cursor.moveUp(count); break;
case "l":
case "L": cursor.moveRight(count); break;
default: return;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [cursor, gameStatus]);
return {
keyLog,
clearLog,
getLog: () => logRef.current,
};
};