removed src_old
This commit is contained in:
@@ -1,346 +0,0 @@
|
|||||||
import type { PositionTag, Coord, CellTag, MazeCell, MazeData } from "./types";
|
|
||||||
|
|
||||||
export class MazeGenerator {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
maze: MazeCell[][];
|
|
||||||
totalSteps: number = 0;
|
|
||||||
|
|
||||||
constructor(userCols: number, userRows: number) {
|
|
||||||
const cellsWide = Math.max(1, Math.floor(userCols / 2));
|
|
||||||
const cellsHigh = Math.max(1, Math.floor(userRows / 2));
|
|
||||||
|
|
||||||
this.width = cellsWide;
|
|
||||||
this.height = cellsHigh;
|
|
||||||
|
|
||||||
this.cols = 2 * this.width + 1;
|
|
||||||
this.rows = 2 * this.height + 1;
|
|
||||||
|
|
||||||
this.maze = this.initArray<MazeCell>([]);
|
|
||||||
|
|
||||||
this.maze.forEach((row, r) => {
|
|
||||||
row.forEach((_cell, c) => {
|
|
||||||
switch (r) {
|
|
||||||
case 0:
|
|
||||||
case this.rows - 1:
|
|
||||||
this.maze[r][c] = ["wall"];
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
if ((r % 2) === 1) {
|
|
||||||
if ((c === 0) || (c === this.cols - 1)) {
|
|
||||||
this.maze[r][c] = ["wall"];
|
|
||||||
}
|
|
||||||
} else if (c % 2 === 0) {
|
|
||||||
this.maze[r][c] = ["wall"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (r === 0) {
|
|
||||||
const doorPos = this.posToSpace(this.rand(1, this.width));
|
|
||||||
this.maze[r][doorPos] = ["door", "exit"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (r === this.rows - 1) {
|
|
||||||
const doorPos = this.posToSpace(this.rand(1, this.width));
|
|
||||||
this.maze[r][doorPos] = ["door", "entrance"];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.partition(1, this.height - 1, 1, this.width - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private initArray<T = unknown>(value?: T): T[][] {
|
|
||||||
return new Array(this.rows)
|
|
||||||
.fill(null)
|
|
||||||
.map(() => new Array(this.cols).fill(value as T));
|
|
||||||
}
|
|
||||||
|
|
||||||
private rand(min: number, max: number): number {
|
|
||||||
return min + Math.floor(Math.random() * (1 + max - min));
|
|
||||||
}
|
|
||||||
|
|
||||||
private posToSpace(x: number): number {
|
|
||||||
return 2 * (x - 1) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private posToWall(x: number): number {
|
|
||||||
return 2 * x;
|
|
||||||
}
|
|
||||||
|
|
||||||
private inBounds(r: number, c: number): boolean {
|
|
||||||
if (
|
|
||||||
typeof this.maze[r] === "undefined" ||
|
|
||||||
typeof this.maze[r]?.[c] === "undefined"
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private shuffle<T>(array: T[]): T[] {
|
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[array[i], array[j]] = [array[j], array[i]];
|
|
||||||
}
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
private partition(r1: number, r2: number, c1: number, c2: number): void {
|
|
||||||
let horiz: number, vert: number;
|
|
||||||
let x: number, y: number, start: number, end: number;
|
|
||||||
|
|
||||||
if (r2 < r1 || c2 < c1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (r1 === r2) {
|
|
||||||
horiz = r1;
|
|
||||||
} else {
|
|
||||||
x = r1 + 1;
|
|
||||||
y = r2 - 1;
|
|
||||||
start = Math.round(x + (y - x) / 4);
|
|
||||||
end = Math.round(x + (y - x) * 3 / 4);
|
|
||||||
horiz = this.rand(start, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c1 === c2) {
|
|
||||||
vert = c1;
|
|
||||||
} else {
|
|
||||||
x = c1 + 1;
|
|
||||||
y = c2 - 1;
|
|
||||||
start = Math.round(x + (y - x) / 3);
|
|
||||||
end = Math.round(x + (y - x) * 2 / 3);
|
|
||||||
vert = this.rand(start, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = this.posToWall(r1) - 1; i <= this.posToWall(r2) + 1; i++) {
|
|
||||||
for (let j = this.posToWall(c1) - 1; j <= this.posToWall(c2) + 1; j++) {
|
|
||||||
if (i === this.posToWall(horiz) || j === this.posToWall(vert)) {
|
|
||||||
this.maze[i][j] = ["wall"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gaps = this.shuffle([true, true, true, false]);
|
|
||||||
|
|
||||||
if (gaps[0]) {
|
|
||||||
const gapPosition = this.rand(c1, vert);
|
|
||||||
this.maze[this.posToWall(horiz)][this.posToSpace(gapPosition)] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gaps[1]) {
|
|
||||||
const gapPosition = this.rand(vert + 1, c2 + 1);
|
|
||||||
this.maze[this.posToWall(horiz)][this.posToSpace(gapPosition)] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gaps[2]) {
|
|
||||||
const gapPosition = this.rand(r1, horiz);
|
|
||||||
this.maze[this.posToSpace(gapPosition)][this.posToWall(vert)] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gaps[3]) {
|
|
||||||
const gapPosition = this.rand(horiz + 1, r2 + 1);
|
|
||||||
this.maze[this.posToSpace(gapPosition)][this.posToWall(vert)] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.partition(r1, horiz - 1, c1, vert - 1);
|
|
||||||
this.partition(horiz + 1, r2, c1, vert - 1);
|
|
||||||
this.partition(r1, horiz - 1, vert + 1, c2);
|
|
||||||
this.partition(horiz + 1, r2, vert + 1, c2);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isGap(...cells: Array<[number, number]>): boolean {
|
|
||||||
return cells.every(([row, col]) => {
|
|
||||||
const cell = this.maze[row][col];
|
|
||||||
if (cell.length > 0) {
|
|
||||||
if (!cell.includes("door")) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private countSteps(
|
|
||||||
array: (number | undefined)[][],
|
|
||||||
r: number,
|
|
||||||
c: number,
|
|
||||||
val: number,
|
|
||||||
stop: CellTag
|
|
||||||
): boolean {
|
|
||||||
if (!this.inBounds(r, c)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array[r][c] !== undefined && (array[r][c] as number) <= val) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isGap([r, c])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
array[r][c] = val;
|
|
||||||
|
|
||||||
if (this.maze[r][c].includes(stop)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.countSteps(array, r - 1, c, val + 1, stop);
|
|
||||||
this.countSteps(array, r, c + 1, val + 1, stop);
|
|
||||||
this.countSteps(array, r + 1, c, val + 1, stop);
|
|
||||||
this.countSteps(array, r, c - 1, val + 1, stop);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getKeyLocation(): Coord {
|
|
||||||
const fromEntrance = this.initArray<number | undefined>();
|
|
||||||
const fromExit = this.initArray<number | undefined>();
|
|
||||||
|
|
||||||
this.totalSteps = -1;
|
|
||||||
|
|
||||||
for (let j = 1; j < this.cols - 1; j++) {
|
|
||||||
if (this.maze[this.rows - 1][j].includes("entrance")) {
|
|
||||||
this.countSteps(fromEntrance, this.rows - 1, j, 0, "exit");
|
|
||||||
}
|
|
||||||
if (this.maze[0][j].includes("exit")) {
|
|
||||||
this.countSteps(fromExit, 0, j, 0, "entrance");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fc = -1,
|
|
||||||
fr = -1;
|
|
||||||
|
|
||||||
this.maze.forEach((_row, r) => {
|
|
||||||
_row.forEach((_cell, c) => {
|
|
||||||
if (typeof fromEntrance[r]?.[c] === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const stepCount =
|
|
||||||
(fromEntrance[r][c] ?? Infinity) + (fromExit[r][c] ?? Infinity);
|
|
||||||
if (stepCount > this.totalSteps) {
|
|
||||||
fr = r;
|
|
||||||
fc = c;
|
|
||||||
this.totalSteps = stepCount;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [fr, fc];
|
|
||||||
}
|
|
||||||
|
|
||||||
placeKey(): void {
|
|
||||||
const [fr, fc] = this.getKeyLocation();
|
|
||||||
this.maze[fr][fc] = ["key"];
|
|
||||||
}
|
|
||||||
|
|
||||||
shortestPath(
|
|
||||||
fromTag: PositionTag,
|
|
||||||
toTag: PositionTag,
|
|
||||||
currentPosition?: Coord
|
|
||||||
): Coord[] {
|
|
||||||
let start: Coord | undefined;
|
|
||||||
let end: Coord | undefined;
|
|
||||||
|
|
||||||
if (fromTag === "hero" && currentPosition) {
|
|
||||||
start = currentPosition;
|
|
||||||
} else {
|
|
||||||
for (let r = 0; r < this.rows; r++) {
|
|
||||||
for (let c = 0; c < this.cols; c++) {
|
|
||||||
if (this.maze[r][c].includes(fromTag)) start = [r, c];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let r = 0; r < this.rows; r++) {
|
|
||||||
for (let c = 0; c < this.cols; c++) {
|
|
||||||
if (this.maze[r][c].includes(toTag)) end = [r, c];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!start || !end) return [];
|
|
||||||
|
|
||||||
const visited = this.initArray<boolean>(false);
|
|
||||||
const prev = this.initArray<Coord | null>(null);
|
|
||||||
const queue: Coord[] = [[...start]];
|
|
||||||
visited[start[0]][start[1]] = true;
|
|
||||||
|
|
||||||
const dirs: Coord[] = [
|
|
||||||
[-1, 0],
|
|
||||||
[1, 0],
|
|
||||||
[0, -1],
|
|
||||||
[0, 1],
|
|
||||||
];
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const [r, c] = queue.shift() as Coord;
|
|
||||||
|
|
||||||
if (r === end[0] && c === end[1]) {
|
|
||||||
const path: Coord[] = [];
|
|
||||||
let curr: Coord | null = end;
|
|
||||||
while (curr) {
|
|
||||||
path.unshift(curr);
|
|
||||||
curr = prev[curr[0]][curr[1]];
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [dr, dc] of dirs) {
|
|
||||||
const nr = r + dr,
|
|
||||||
nc = c + dc;
|
|
||||||
if (!this.inBounds(nr, nc)) continue;
|
|
||||||
if (visited[nr][nc]) continue;
|
|
||||||
if (this.maze[nr][nc].includes("wall")) continue;
|
|
||||||
|
|
||||||
visited[nr][nc] = true;
|
|
||||||
prev[nr][nc] = [r, c];
|
|
||||||
queue.push([nr, nc]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
shortestPathToKey(currentPosition?: Coord): Coord[] {
|
|
||||||
return this.shortestPath(
|
|
||||||
currentPosition ? "hero" : "entrance",
|
|
||||||
"key",
|
|
||||||
currentPosition
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
shortestPathToExit(currentPosition?: Coord): Coord[] {
|
|
||||||
return this.shortestPath(
|
|
||||||
currentPosition ? "hero" : "entrance",
|
|
||||||
"exit",
|
|
||||||
currentPosition
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getDistance(
|
|
||||||
fromTag: PositionTag,
|
|
||||||
toTag: PositionTag,
|
|
||||||
currentPosition?: Coord
|
|
||||||
): number {
|
|
||||||
const path = this.shortestPath(fromTag, toTag, currentPosition);
|
|
||||||
return path.length ? path.length - 1 : Infinity;
|
|
||||||
}
|
|
||||||
|
|
||||||
getData(): MazeData {
|
|
||||||
return {
|
|
||||||
maze: this.maze,
|
|
||||||
width: this.width,
|
|
||||||
height: this.height,
|
|
||||||
cols: this.cols,
|
|
||||||
rows: this.rows,
|
|
||||||
totalSteps: this.totalSteps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(): MazeData {
|
|
||||||
return this.getData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import type { MazeData } from "./types";
|
|
||||||
|
|
||||||
export class MazeRenderer {
|
|
||||||
private mazeData: MazeData;
|
|
||||||
private numbered: boolean;
|
|
||||||
private parentDiv?: HTMLElement;
|
|
||||||
|
|
||||||
constructor(mazeData: MazeData, numbered: boolean = false) {
|
|
||||||
this.mazeData = mazeData;
|
|
||||||
this.numbered = numbered;
|
|
||||||
}
|
|
||||||
|
|
||||||
display(parentDiv: HTMLElement | null): boolean {
|
|
||||||
this.parentDiv = parentDiv ?? undefined;
|
|
||||||
|
|
||||||
if (!this.parentDiv) {
|
|
||||||
console.error("Cannot initialise maze - parent element not provided.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (this.parentDiv.firstChild) {
|
|
||||||
this.parentDiv.removeChild(this.parentDiv.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
container.id = "maze";
|
|
||||||
container.dataset.steps = String(this.mazeData.totalSteps);
|
|
||||||
|
|
||||||
const makeCell = (cls = "", text = "", attrs?: Record<string, string>) => {
|
|
||||||
const d = document.createElement("div");
|
|
||||||
d.className = cls;
|
|
||||||
if (text) d.textContent = text;
|
|
||||||
if (attrs) for (const k in attrs) d.setAttribute(k, attrs[k]);
|
|
||||||
return d;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rowLabel = (r: number) => (r > 0 && r < this.mazeData.rows ? String(r) : "");
|
|
||||||
const colLabel = (c: number) => (c > 0 && c < this.mazeData.cols ? String(c) : "");
|
|
||||||
|
|
||||||
if (this.numbered) {
|
|
||||||
const topStrip = document.createElement("div");
|
|
||||||
topStrip.className = "number-strip number-strip-top";
|
|
||||||
topStrip.appendChild(makeCell("number corner", ""));
|
|
||||||
for (let c = 0; c < this.mazeData.cols; c++) {
|
|
||||||
topStrip.appendChild(makeCell("number col", colLabel(c), { "data-c": String(c) }));
|
|
||||||
}
|
|
||||||
topStrip.appendChild(makeCell("number corner", ""));
|
|
||||||
container.appendChild(topStrip);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mazeData.maze.forEach((row, r) => {
|
|
||||||
const rowDiv = document.createElement("div");
|
|
||||||
rowDiv.className = "maze-row";
|
|
||||||
rowDiv.setAttribute("data-r", String(r));
|
|
||||||
|
|
||||||
if (this.numbered) {
|
|
||||||
rowDiv.appendChild(makeCell("number row", rowLabel(r), { "data-r": String(r) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
row.forEach((cell, c) => {
|
|
||||||
const cellDiv = document.createElement("div");
|
|
||||||
if (cell) cellDiv.className = cell.join(" ");
|
|
||||||
cellDiv.setAttribute("data-r", String(r));
|
|
||||||
cellDiv.setAttribute("data-c", String(c));
|
|
||||||
rowDiv.appendChild(cellDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.numbered) {
|
|
||||||
rowDiv.appendChild(makeCell("number row", rowLabel(r), { "data-r": String(r) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
container.appendChild(rowDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.numbered) {
|
|
||||||
const bottomStrip = document.createElement("div");
|
|
||||||
bottomStrip.className = "number-strip number-strip-bottom";
|
|
||||||
bottomStrip.appendChild(makeCell("number corner", ""));
|
|
||||||
for (let c = 0; c < this.mazeData.cols; c++) {
|
|
||||||
bottomStrip.appendChild(makeCell("number col", colLabel(c), { "data-c": String(c) }));
|
|
||||||
}
|
|
||||||
bottomStrip.appendChild(makeCell("number corner", ""));
|
|
||||||
container.appendChild(bottomStrip);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.parentDiv.appendChild(container);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMazeEl(container: HTMLElement): HTMLElement | null {
|
|
||||||
return container.querySelector("#maze");
|
|
||||||
}
|
|
||||||
|
|
||||||
getCellSelector(r: number, c: number): string {
|
|
||||||
return `.maze-row > div[data-r="${r}"][data-c="${c}"]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCellEl(container: HTMLElement, r: number, c: number): HTMLElement | null {
|
|
||||||
const maze = this.getMazeEl(container);
|
|
||||||
if (!maze) return null;
|
|
||||||
return maze.querySelector(this.getCellSelector(r, c)) as HTMLElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
addCellClass(container: HTMLElement, r: number, c: number, cls: string): void {
|
|
||||||
this.getCellEl(container, r, c)?.classList.add(cls);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeCellClass(container: HTMLElement, r: number, c: number, cls: string): void {
|
|
||||||
this.getCellEl(container, r, c)?.classList.remove(cls);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleCellClass(container: HTMLElement, r: number, c: number, cls: string): void {
|
|
||||||
this.getCellEl(container, r, c)?.classList.toggle(cls);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,6 +0,0 @@
|
|||||||
import './game.css';
|
|
||||||
export { MazeGenerator } from "./MazeGenerator";
|
|
||||||
export { MazeRenderer } from "./MazeRenderer";
|
|
||||||
export * from './useGame';
|
|
||||||
export * from './types';
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { useScore } from "./useScore";
|
|
||||||
import { useGame } from "./useGame";
|
|
||||||
export type UseGameType = ReturnType<typeof useGame>;
|
|
||||||
export type UseScoreType = ReturnType<typeof useScore>;
|
|
||||||
|
|
||||||
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 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 = {
|
|
||||||
gameStatus: GameStatus;
|
|
||||||
setGameStatus: (status: GameStatus) => void;
|
|
||||||
stopGame: () => void;
|
|
||||||
|
|
||||||
// Optional — only Maze-type games provide these
|
|
||||||
mazeManager?: {
|
|
||||||
mazeInstanceRef?: { current?: any };
|
|
||||||
};
|
|
||||||
playerManager?: {
|
|
||||||
playerPos?: { r: number; c: number } | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UseScoreParams = {
|
|
||||||
gameContext: GameScoreContext;
|
|
||||||
keyManager?: KeyLogProvider;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PositionTag = "entrance" | "exit" | "key" | "hero";
|
|
||||||
|
|
||||||
export type Coord = [number, number];
|
|
||||||
|
|
||||||
export type CellTag = "wall" | "door" | PositionTag;
|
|
||||||
|
|
||||||
export type MazeCell = CellTag[];
|
|
||||||
|
|
||||||
export interface MazeData {
|
|
||||||
maze: MazeCell[][];
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
totalSteps: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './useGame';
|
|
||||||
export * from './useKeyBindings';
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -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>;
|
|
||||||
|
|
||||||
@@ -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>;
|
|
||||||
@@ -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>;
|
|
||||||
@@ -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>;
|
|
||||||
@@ -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>;
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
export * from './useScore';
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user