diff --git a/src/useGame.ts b/src/useGame.ts index 0a9f3d8..8b5d835 100644 --- a/src/useGame.ts +++ b/src/useGame.ts @@ -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 = { diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts index 76f5648..4ed145f 100644 --- a/src/useGameStatus/useGameStatus.ts +++ b/src/useGameStatus/useGameStatus.ts @@ -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('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'); } diff --git a/src/useHero/index.ts b/src/useHero/index.ts index 19743ad..87e29b7 100644 --- a/src/useHero/index.ts +++ b/src/useHero/index.ts @@ -1 +1,2 @@ +export * from './useHero'; export * from './useHeroRender'; diff --git a/src/useHero/useHero.ts b/src/useHero/useHero.ts new file mode 100644 index 0000000..c2f5649 --- /dev/null +++ b/src/useHero/useHero.ts @@ -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(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( + `.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( + `.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; diff --git a/src/useHero/useHeroRender.ts b/src/useHero/useHeroRender.ts index 75a26c3..d59877d 100644 --- a/src/useHero/useHeroRender.ts +++ b/src/useHero/useHeroRender.ts @@ -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;