proper hero renderer and positioning

This commit is contained in:
2025-10-19 02:39:18 +01:00
parent 310355104a
commit ef635f6112
5 changed files with 232 additions and 12 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
import type { GameManager, GameOptions } from './types';
import { useBoard } from './useBoard';
import { useCursor } from './useCursor';
import { useHeroRender } from './useHero';
import { useHero } from './useHero';
import { useKeyBindings } from './useKeyBindings';
import { useGameStatus } from './useGameStatus';
@@ -14,7 +14,7 @@ export function useGame(options: GameOptions, platformHook?: unknown): GameManag
const { gameStatus, startGame, togglePause, quitGame } = gameStatusManager;
useHeroRender({ gameStatus, board, cursor });
const hero = useHero(board, gameStatus);
const keyManager = useKeyBindings({ cursor, gameStatus });
const gameManager = {
+19 -5
View File
@@ -1,12 +1,23 @@
import { useState } from 'react';
import type { GameStatus } from '../types';
import { useState, useCallback } from 'react';
import type { GameStatus, BoardManager, Coord } from '../types';
export function useGameStatus() {
export function useGameStatus(board?: BoardManager, setHeroPos?: (pos: Coord) => void) {
const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
function startGame() {
const startGame = useCallback(() => {
if (board && setHeroPos) {
const m = board.mazeRef.current;
if (!m.length) return;
for (let c = 0; c < m[0].length; c++) {
if (m[m.length - 1][c].includes("entrance")) {
setHeroPos({ row: m.length - 1, col: c });
break;
}
}
}
setGameStatus('started');
}
}, [board, setHeroPos]);
function togglePause(pause?: boolean) {
if (typeof pause === 'boolean') {
@@ -17,6 +28,9 @@ export function useGameStatus() {
}
function quitGame() {
if (setHeroPos) {
setHeroPos({ row: 0, col: 0 });
}
setGameStatus('waiting');
}
+1
View File
@@ -1 +1,2 @@
export * from './useHero';
export * from './useHeroRender';
+204
View File
@@ -0,0 +1,204 @@
import { useRef, useState, useCallback, useEffect } from "react";
import type { BoardManager, GameStatus, Coord } from "../types";
import { useHeroRender } from "./useHeroRender";
export function useHero(board: BoardManager, gameStatus: GameStatus) {
const { mazeRef, containerRef } = board;
const [heroPos, setHeroPos] = useState<Coord | null>(null);
const animatingRef = useRef(false);
useEffect(() => {
if (gameStatus === 'started' && !heroPos) {
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")) {
setHeroPos({ row: m.length - 1, col: c });
break;
}
}
}
}, [gameStatus, heroPos, mazeRef]);
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]);
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]);
const flashInvalid = useCallback(() => {
if (!heroPos) return;
const el = getCellEl(heroPos.row, heroPos.col);
if (!el) return;
el.classList.add("invalid-move");
setTimeout(() => el.classList.remove("invalid-move"), 180);
}, [heroPos, getCellEl]);
useEffect(() => {
const onInvalid = () => flashInvalid();
window.addEventListener("maze-invalid", onInvalid);
return () => window.removeEventListener("maze-invalid", onInvalid);
}, [flashInvalid]);
const moveHero = useCallback(
(
dr: number,
dc: number,
steps: number | undefined,
gameStatus: GameStatus,
setGameStatus: (s: GameStatus) => void
) => {
const m = mazeRef.current;
if (!heroPos || !m.length) return;
if (gameStatus !== "started" && gameStatus !== "hasKey") return;
if (animatingRef.current) return;
const n = Math.max(1, steps ?? 1);
const r0 = heroPos.row;
const c0 = heroPos.col;
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();
return;
}
if (n > 1) {
animatingRef.current = true;
let i = 0;
let rNow = r0;
let cNow = c0;
let cancelled = false;
let rafId: number | null = null;
const TOTAL_MS = 64;
const framesTarget = Math.max(1, Math.round(TOTAL_MS / 16));
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;
let processed = 0;
while (processed < stepsPerFrame && i < n) {
i += 1;
rNow += dr;
cNow += dc;
leaveTrail(rNow, cNow);
processed += 1;
}
setHeroPos({ row: rNow, col: cNow });
window.dispatchEvent(new CustomEvent("maze-logic-tick", { detail: { t: performance.now() } }));
if (i < n) {
rafId = requestAnimationFrame(tick);
} else {
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;
}
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");
}
setHeroPos({ row: newR, col: newC });
},
[heroPos, mazeRef, containerRef, flashInvalid, leaveTrail]
);
const hero = {
heroPos,
setHeroPos,
moveHero,
};
console.log('calling useHeroRender with', { gameStatus, board, hero });
useHeroRender({ gameStatus, board, hero });
return hero;
}
export type UseHeroType = ReturnType<typeof useHero>;
+6 -5
View File
@@ -1,17 +1,18 @@
import { useEffect } from "react";
import type { BoardManager, Cursor, GameStatus } from "../types";
import type { BoardManager, GameStatus } from "../types";
import type { UseHeroType } from "./useHero";
type UseHeroRenderParams = {
gameStatus: GameStatus;
board: BoardManager;
cursor: Cursor;
hero: UseHeroType;
relative?: boolean;
};
export function useHeroRender(params: UseHeroRenderParams) {
const { gameStatus, board, cursor, relative = true } = params;
const { gameStatus, board, hero, relative = true } = params;
const { containerRef } = board;
const playerPos = cursor.position();
const playerPos = hero.heroPos;
const applyRelativeNumbers = (mazeDiv: HTMLElement, R: number, C: number) => {
const rows = Number(mazeDiv.dataset.rows || 0);
@@ -48,7 +49,7 @@ export function useHeroRender(params: UseHeroRenderParams) {
console.log('gameStatus', gameStatus);
const container = containerRef.current;
if (!['started', 'has-key'].includes(gameStatus)) return;
if (!container) return;
if (!container || !playerPos) return;
const mazeDiv = container.querySelector("#maze") as HTMLElement | null;
if (!mazeDiv) return;