working on refactor
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './useGame';
|
||||
export * from './useKeyBindings';
|
||||
@@ -0,0 +1,70 @@
|
||||
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 { render } = mazeManager;
|
||||
const { movePlayer } = playerManager;
|
||||
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
|
||||
const { containerRef } = mazeManager;
|
||||
|
||||
|
||||
// 🎮 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 = {
|
||||
containerRef,
|
||||
gameStatus,
|
||||
mazeManager,
|
||||
playerManager,
|
||||
render,
|
||||
startGame,
|
||||
togglePause,
|
||||
movePlayer: (dr: number, dc: number, count: number = 1) =>
|
||||
movePlayer(dr, dc, count, gameStatus, setGameStatus),
|
||||
stopGame,
|
||||
|
||||
// key bindings
|
||||
...keyBindings,
|
||||
|
||||
// scoring
|
||||
scoreManager,
|
||||
};
|
||||
|
||||
if (typeof platformHook === 'function') {
|
||||
platformHook(fullGameManager);
|
||||
}
|
||||
|
||||
return fullGameManager;
|
||||
}
|
||||
|
||||
export type { GameStatus } from "../types";
|
||||
@@ -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>;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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>;
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,45 @@
|
||||
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>;
|
||||
@@ -0,0 +1,202 @@
|
||||
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>;
|
||||
Reference in New Issue
Block a user