Maze: Pac-Man-style dots, ghosts, and continuous movement

Full redesign of the Maze template (same templateType: "maze" — plain
text column, no migration needed): dots (*) to eat, power pellets (O)
that frighten ghosts, ghost spawns (G, 0-4), and continuous glide
movement with buffered turns instead of one-cell-per-keypress. Win
condition confirmed with the user explicitly: collect every dot and
pellet, then reach the exit — kept the exit tile rather than switching
to classic Pac-Man's "no exit, board-clear wins," since this app's
maze identity is built around it.

Ghosts chase via BFS pathfinding (reusing the same BFS shape the
maze's own reachability check already used, just retargeted at the
live player position each tick) and flee via simple greedy
distance-maximizing when frightened. The reachability check itself
generalized from "is the exit reachable" to "is every dot, pellet, and
the exit reachable" — same guarantee, now covering the actual win
condition instead of just the exit. Verified precisely: a maze with a
reachable exit but one walled-off dot is correctly rejected.

Continuous movement is tick-based, not real physics: a repeating timer
steps the player in the buffered direction (or continues straight) if
open, tweening the sprite's pixel position between cells for a glide
feel rather than snapping. Same 3-lives-on-capture, coarse-grained
resume (position + collected keys + lives, ghosts reset to spawn on
reload) as the other templates' resume behavior.

The one existing maze game on production validated cleanly against
the new schema unchanged (lives defaults to 3, its dots were already
reachable) — no data migration needed this time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-01 03:35:31 +00:00
1 parent be53908471
commit c035650063
4 files changed
+490 -156

No files matched your search

+30 -14
View File
@@ -23,13 +23,14 @@ export function MazeBuilder({
const [description, setDescription] = useState(initialDescription ?? "");
const [gridText, setGridText] = useState((initialDefinition?.grid ?? DEFAULT_MAZE_DEFINITION.grid).join("\n"));
const [timeLimitSec, setTimeLimitSec] = useState(initialDefinition?.timeLimitSec ?? 0);
const [lives, setLives] = useState(initialDefinition?.lives ?? DEFAULT_MAZE_DEFINITION.lives);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function handleSave() {
setError(null);
const grid = gridText.split("\n").map((row) => row.trimEnd()).filter((row) => row.length > 0);
const definition: MazeDefinition = { grid, timeLimitSec };
const definition: MazeDefinition = { grid, timeLimitSec, lives };
const parsed = mazeDefinitionSchema.safeParse(definition);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте лабиринт — что-то заполнено неверно");
@@ -88,24 +89,39 @@ export function MazeBuilder({
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="w-48 text-sm">
<span className="mb-1 block font-medium text-text-muted">Лимит времени, сек (0 — без лимита)</span>
<input
type="number"
min={0}
max={600}
value={timeLimitSec}
onChange={(e) => setTimeLimitSec(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Лимит времени, сек (0 — без лимита)</span>
<input
type="number"
min={0}
max={600}
value={timeLimitSec}
onChange={(e) => setTimeLimitSec(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Жизни</span>
<input
type="number"
min={1}
max={9}
value={lives}
onChange={(e) => setLives(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
</div>
</div>
<div className="card flex flex-col gap-2 p-4">
<span className="text-sm font-medium text-text-muted">
Лабиринт — каждая строка ряд. <code className="text-text-faint">#</code> стена,{" "}
Лабиринт в стиле Pac-Man — каждая строка ряд. <code className="text-text-faint">#</code> стена,{" "}
<code className="text-text-faint">.</code> пол, <code className="text-text-faint">S</code> старт (один),{" "}
<code className="text-text-faint">E</code> выход, <code className="text-text-faint">*</code> предмет.
<code className="text-text-faint">E</code> выход, <code className="text-text-faint">*</code> точка,{" "}
<code className="text-text-faint">O</code> усиливающая точка (пугает привидений),{" "}
<code className="text-text-faint">G</code> призрак (до 4). Нужно собрать все точки и дойти до выхода.
</span>
<textarea
value={gridText}
+1 -1
View File
@@ -7,7 +7,7 @@ const SYSTEM_PROMPTS: Record<TemplateType, string> = {
quiz: "Ты создаёшь вопросы для викторины в формате «Кто хочет стать миллионером?» — 15-уровневая денежная лестница с нарастающей сложностью. Каждый вопрос должен использовать ТОЛЬКО реальные, проверяемые факты (даты, столицы, авторов, научные факты и т.п.) — ничего не выдумывай и не искажай. У каждого вопроса ровно 4 варианта ответа и один правильный. Поле band (1-5) — сложность: 1 — очень лёгкий вопрос, 5 — очень сложный. Обязательно должен быть хотя бы один вопрос на каждый band от 1 до 5. Категория (category) должна отражать тему вопроса.",
clicker:
"Ты создаёшь RPG-кликер в духе Clicker Heroes для игрового движка: игрок бьёт монстра по клику, монстр имеет HP, каждые 5 уровней — босс с таймером. Придумай тему (theme), название босса через bossEmoji (один эмодзи), пул из 5-10 эмодзи обычных монстров (monsterEmojis, каждый — один эмодзи-символ) и 4-8 героев (heroes) по описанию пользователя — герои покупаются многократно (уровни), у каждого либо бонус к урону за клик (clickDamageBonus), либо к урону в секунду (dpsBonus), либо оба понемногу. Более поздние герои в списке должны быть дороже (baseCost) и сильнее.",
maze: "Ты создаёшь лабиринт для простого игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Ровно один символ S (старт), хотя бы один E (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.",
maze: "Ты создаёшь лабиринт в стиле Pac-Man для игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Символы: # стена, . пол, S старт (ровно один), E выход (хотя бы один), * обычная точка, O усиливающая точка (пугает привидений на время), G призрак-респавн (0-4 штук). Расставь точки * почти на каждой проходимой клетке коридоров (как в настоящем Pac-Man), несколько O в дальних углах. Победа — собрать все * и O, затем дойти до E, поэтому каждая точка, усиливающая точка и выход обязательно должны быть достижимы от старта по коридорам без стен.",
};
/** Thrown when no Anthropic key is configured — callers map this to a clean "not configured" response. */
+64 -19
View File
@@ -1,24 +1,26 @@
import { z } from "zod";
const MAZE_CHARS = new Set(["#", ".", "S", "E", "*"]);
const MAZE_CHARS = new Set(["#", ".", "S", "E", "*", "O", "G"]);
const MAX_GHOSTS = 4;
/** BFS from S to any E, treating '#' as the only impassable tile. */
function isExitReachable(grid: string[]): boolean {
// Pac-Man-flavored timing/scoring — fixed, not hand-authored (same
// "curve is fixed" precedent as the quiz money ladder / clicker HP curve).
export const PLAYER_STEP_MS = 180;
export const GHOST_STEP_MS = 220; // slightly slower than the player — keeps it beatable.
export const FRIGHTEN_DURATION_MS = 7000;
export const GHOST_RESPAWN_DELAY_MS = 3000;
export const DOT_SCORE = 10;
export const PELLET_SCORE = 50;
export const GHOST_EAT_SCORE = 200;
/** BFS from S — used both to find the exit and to prove every collectible tile is reachable. '#' is the only impassable tile. */
function reachableFrom(grid: string[], start: [number, number]): Set<string> {
const rows = grid.length;
const cols = grid[0]?.length ?? 0;
let start: [number, number] | null = null;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === "S") start = [r, c];
}
}
if (!start) return false;
const visited = new Set<string>([`${start[0]},${start[1]}`]);
const queue: [number, number][] = [start];
while (queue.length > 0) {
const [r, c] = queue.shift()!;
if (grid[r][c] === "E") return true;
for (const [dr, dc] of [
[-1, 0],
[1, 0],
@@ -34,22 +36,47 @@ function isExitReachable(grid: string[]): boolean {
queue.push([nr, nc]);
}
}
return false;
return visited;
}
function findStart(grid: string[]): [number, number] | null {
for (let r = 0; r < grid.length; r++) {
const c = grid[r].indexOf("S");
if (c !== -1) return [r, c];
}
return null;
}
/** Every dot, power pellet, and the exit must be reachable — win condition requires collecting them all. */
function allCollectiblesReachable(grid: string[]): boolean {
const start = findStart(grid);
if (!start) return false;
const reachable = reachableFrom(grid, start);
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[r].length; c++) {
const ch = grid[r][c];
if ((ch === "*" || ch === "O" || ch === "E") && !reachable.has(`${r},${c}`)) return false;
}
}
return true;
}
export const mazeDefinitionSchema = z
.object({
// Each string is one row. '#' wall, '.' floor, 'S' start, 'E' exit, '*' item.
// Each string is one row. '#' wall, '.' floor, 'S' start, 'E' exit,
// '*' dot, 'O' power pellet, 'G' ghost spawn (0-4 of them).
grid: z.array(z.string().min(1)).min(3).max(30),
// 0 = no limit.
timeLimitSec: z.number().int().min(0).max(600).default(0),
lives: z.number().int().min(1).max(9).default(3),
})
.refine((def) => def.grid.every((row) => row.length === def.grid[0].length), {
message: "All grid rows must be the same length",
path: ["grid"],
})
.refine((def) => def.grid.every((row) => [...row].every((c) => MAZE_CHARS.has(c))), {
message: "Grid may only contain the characters # . S E *",
message: "Grid may only contain the characters # . S E * O G",
path: ["grid"],
})
.refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "S").length === 1, {
@@ -60,23 +87,41 @@ export const mazeDefinitionSchema = z
message: "Grid must contain at least one E (exit)",
path: ["grid"],
})
.refine((def) => isExitReachable(def.grid), {
message: "Exit (E) must be reachable from the start (S)",
.refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "G").length <= MAX_GHOSTS, {
message: `Grid may contain at most ${MAX_GHOSTS} G (ghost spawns)`,
path: ["grid"],
})
.refine((def) => allCollectiblesReachable(def.grid), {
message: "Every dot (*), power pellet (O), and the exit (E) must be reachable from the start (S)",
path: ["grid"],
});
export type MazeDefinition = z.infer<typeof mazeDefinitionSchema>;
export const DEFAULT_MAZE_DEFINITION: MazeDefinition = {
grid: ["#######", "#S.*..#", "#.###.#", "#...*.#", "#.###.#", "#....E#", "#######"],
grid: [
"#############",
"#O****#****O#",
"#*###*#*###*#",
"#*#*******#*#",
"#*#*#####*#*#",
"#****GGG****#",
"#*#*#####*#*#",
"#*#*******#*#",
"#*###*#*###*#",
"#S****#****E#",
"#############",
],
timeLimitSec: 0,
lives: 3,
};
export const mazeProgressSchema = z.object({
playerRow: z.number().int().min(0),
playerCol: z.number().int().min(0),
collectedItemKeys: z.array(z.string()).default([]),
collectedKeys: z.array(z.string()).default([]),
remainingMs: z.number().int().min(0),
lives: z.number().int().min(0),
});
export type MazeProgress = z.infer<typeof mazeProgressSchema>;
+395 -122
View File
@@ -1,34 +1,96 @@
import Phaser from "phaser";
import {
PLAYER_STEP_MS,
GHOST_STEP_MS,
FRIGHTEN_DURATION_MS,
GHOST_RESPAWN_DELAY_MS,
DOT_SCORE,
PELLET_SCORE,
GHOST_EAT_SCORE,
} from "../maze";
import type { MazeDefinition, MazeProgress, GameResult } from "../types";
const COLORS = {
bg: 0x0a0b14,
wall: 0x292d4a,
floor: 0x1b1e36,
wall: 0x2121de,
floor: 0x0f1030,
exit: 0x0d9488,
item: 0xfbbf24,
player: 0x4f46e5,
dot: 0xfde68a,
pellet: 0xfde68a,
player: 0xfbe200,
ghostFrightened: 0x2b4bde,
ghostColors: [0xff0000, 0xffb8ff, 0x00ffff, 0xffb851],
};
interface Cell {
row: number;
col: number;
}
type Dir = { dr: number; dc: number };
const DIRS: Record<string, Dir> = {
up: { dr: -1, dc: 0 },
down: { dr: 1, dc: 0 },
left: { dr: 0, dc: -1 },
right: { dr: 0, dc: 1 },
};
interface Ghost {
row: number;
col: number;
homeRow: number;
homeCol: number;
color: number;
frightenedUntil?: number;
eatenUntil?: number;
container: Phaser.GameObjects.Container;
body: Phaser.GameObjects.Arc;
}
const GHOST_RADIUS = 12;
const DOT_RADIUS = 3;
const PELLET_RADIUS = 7;
const PLAYER_RADIUS = 13;
export class MazeScene extends Phaser.Scene {
private definition!: MazeDefinition;
private onFinish?: (result: GameResult) => void;
private onProgress?: (state: MazeProgress) => void;
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
private playerPos = { row: 0, col: 0 };
private items = new Set<string>();
private itemSprites = new Map<string, Phaser.GameObjects.Rectangle>();
private player!: Phaser.GameObjects.Arc;
private grid: string[] = [];
private rows = 0;
private cols = 0;
private cellSize = 32;
private offsetX = 0;
private offsetY = 0;
private totalItems = 0;
private collectedItemKeys: string[] = [];
private scoreText!: Phaser.GameObjects.Text;
private timerText?: Phaser.GameObjects.Text;
private startPos: Cell = { row: 0, col: 0 };
private playerPos: Cell = { row: 0, col: 0 };
private desiredDir: Dir | null = null;
private currentDir: Dir | null = null;
private player!: Phaser.GameObjects.Arc;
private dots = new Set<string>();
private pellets = new Set<string>();
private dotSprites = new Map<string, Phaser.GameObjects.Arc>();
private pelletSprites = new Map<string, Phaser.GameObjects.Arc>();
private collectedKeys = new Set<string>();
private totalCollectibles = 0;
private ghosts: Ghost[] = [];
private score = 0;
private lives = 3;
private remainingMs = 0;
private finished = false;
private livesText!: Phaser.GameObjects.Text;
private scoreText!: Phaser.GameObjects.Text;
private dotsText!: Phaser.GameObjects.Text;
private timerText?: Phaser.GameObjects.Text;
private frightenBarBg!: Phaser.GameObjects.Rectangle;
private frightenBarFill!: Phaser.GameObjects.Rectangle;
private frightenEndsAt = 0;
constructor() {
super("MazeScene");
}
@@ -38,76 +100,87 @@ export class MazeScene extends Phaser.Scene {
this.onFinish = this.registry.get("onFinish");
this.onProgress = this.registry.get("onProgress");
this.finished = false;
this.desiredDir = null;
this.currentDir = null;
this.cameras.main.setBackgroundColor(COLORS.bg);
const initialState = this.registry.get("initialState") as MazeProgress | undefined;
const alreadyCollected = new Set(initialState?.collectedItemKeys ?? []);
this.collectedItemKeys = [...alreadyCollected];
this.collectedKeys = new Set(initialState?.collectedKeys ?? []);
this.lives = initialState?.lives ?? this.definition.lives;
this.score = 0;
const grid = this.definition.grid;
const rows = grid.length;
const cols = grid[0].length;
this.cellSize = Math.max(12, Math.min(44, Math.floor(880 / cols), Math.floor(460 / rows)));
this.offsetX = 480 - (cols * this.cellSize) / 2;
this.offsetY = 110;
this.grid = this.definition.grid;
this.rows = this.grid.length;
this.cols = this.grid[0].length;
this.cellSize = Math.max(10, Math.min(34, Math.floor(900 / this.cols), Math.floor(420 / this.rows)));
this.offsetX = 480 - (this.cols * this.cellSize) / 2;
this.offsetY = 130;
this.items = new Set();
this.itemSprites = new Map();
this.totalItems = 0;
this.dots = new Set();
this.pellets = new Set();
this.dotSprites = new Map();
this.pelletSprites = new Map();
this.ghosts = [];
this.totalCollectibles = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ch = grid[r][c];
const x = this.offsetX + c * this.cellSize + this.cellSize / 2;
const y = this.offsetY + r * this.cellSize + this.cellSize / 2;
let ghostIndex = 0;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const ch = this.grid[r][c];
const { x, y } = this.cellToPixel({ row: r, col: c });
const key = `${r},${c}`;
if (ch === "#") {
this.add.rectangle(x, y, this.cellSize - 1, this.cellSize - 1, COLORS.wall);
this.add.rectangle(x, y, this.cellSize, this.cellSize, COLORS.wall);
continue;
}
this.add.rectangle(x, y, this.cellSize - 1, this.cellSize - 1, ch === "E" ? COLORS.exit : COLORS.floor);
if (ch === "S") this.playerPos = { row: r, col: c };
this.add.rectangle(x, y, this.cellSize, this.cellSize, ch === "E" ? COLORS.exit : COLORS.floor);
if (ch === "S") this.startPos = { row: r, col: c };
if (ch === "*") {
this.totalItems += 1;
const key = `${r},${c}`;
if (alreadyCollected.has(key)) continue;
this.items.add(key);
const item = this.add.rectangle(x, y, this.cellSize * 0.4, this.cellSize * 0.4, COLORS.item);
this.itemSprites.set(key, item);
this.totalCollectibles += 1;
if (!this.collectedKeys.has(key)) {
this.dots.add(key);
this.dotSprites.set(key, this.add.circle(x, y, DOT_RADIUS, COLORS.dot));
}
} else if (ch === "O") {
this.totalCollectibles += 1;
if (!this.collectedKeys.has(key)) {
this.pellets.add(key);
const pellet = this.add.circle(x, y, PELLET_RADIUS, COLORS.pellet);
this.tweens.add({ targets: pellet, scale: 1.3, duration: 500, yoyo: true, repeat: -1 });
this.pelletSprites.set(key, pellet);
}
} else if (ch === "G") {
this.ghosts.push(this.createGhost(r, c, ghostIndex));
ghostIndex += 1;
}
}
}
// Resume position only if it's still a valid, non-wall cell — the
// definition may have changed since this progress was saved.
if (
initialState &&
initialState.playerRow >= 0 &&
initialState.playerRow < rows &&
initialState.playerCol >= 0 &&
initialState.playerCol < cols &&
grid[initialState.playerRow][initialState.playerCol] !== "#"
) {
this.playerPos = { row: initialState.playerRow, col: initialState.playerCol };
}
this.playerPos = this.resolvePlayerStart(initialState);
const playerPx = this.cellToPixel(this.playerPos);
this.player = this.add.circle(playerPx.x, playerPx.y, PLAYER_RADIUS, COLORS.player).setDepth(3);
this.tweens.add({ targets: this.player, scale: 0.82, duration: 140, yoyo: true, repeat: -1 });
const px = this.offsetX + this.playerPos.col * this.cellSize + this.cellSize / 2;
const py = this.offsetY + this.playerPos.row * this.cellSize + this.cellSize / 2;
this.player = this.add.circle(px, py, this.cellSize * 0.35, COLORS.player);
this.livesText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" });
this.scoreText = this.add.text(20, 38, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#a3a8c6" });
this.dotsText = this.add
.text(940, 15, "", { fontFamily: "sans-serif", fontSize: "15px", color: "#eef0fb" })
.setOrigin(1, 0);
this.scoreText = this.add
.text(480, 40, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#eef0fb" })
.setOrigin(0.5, 0);
this.frightenBarBg = this.add.rectangle(480, 95, 500, 8, 0x292d4a).setVisible(false);
this.frightenBarFill = this.add
.rectangle(480 - 250, 95, 500, 8, 0x60a5fa)
.setOrigin(0, 0.5)
.setVisible(false);
if (this.definition.timeLimitSec > 0) {
const savedRemaining = initialState?.remainingMs;
this.remainingMs =
savedRemaining !== undefined
? Math.min(Math.max(savedRemaining, 0), this.definition.timeLimitSec * 1000)
: this.definition.timeLimitSec * 1000;
this.remainingMs = initialState?.remainingMs ?? this.definition.timeLimitSec * 1000;
this.timerText = this.add
.text(480, 66, "", { fontFamily: "sans-serif", fontSize: "14px", color: "#a3a8c6" })
.text(480, 15, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#a3a8c6" })
.setOrigin(0.5, 0);
this.time.addEvent({
delay: 100,
@@ -116,86 +189,286 @@ export class MazeScene extends Phaser.Scene {
if (this.finished) return;
this.remainingMs -= 100;
if (this.remainingMs <= 0) {
this.remainingMs = 0;
this.finish(false, "Время вышло");
this.finish(false, this.score, "Время вышло.");
return;
}
this.updateHud();
},
});
}
this.cursors = this.input.keyboard!.createCursorKeys();
this.input.keyboard!.on("keydown-LEFT", () => (this.desiredDir = DIRS.left));
this.input.keyboard!.on("keydown-RIGHT", () => (this.desiredDir = DIRS.right));
this.input.keyboard!.on("keydown-UP", () => (this.desiredDir = DIRS.up));
this.input.keyboard!.on("keydown-DOWN", () => (this.desiredDir = DIRS.down));
this.time.addEvent({ delay: PLAYER_STEP_MS, loop: true, callback: () => this.playerTick() });
this.time.addEvent({ delay: GHOST_STEP_MS, loop: true, callback: () => this.ghostTick() });
this.updateHud();
this.reportProgress();
}
update() {
private createGhost(row: number, col: number, index: number): Ghost {
const color = COLORS.ghostColors[index % COLORS.ghostColors.length];
const { x, y } = this.cellToPixel({ row, col });
const body = this.add.circle(0, 0, GHOST_RADIUS, color);
const eye1 = this.add.circle(-5, -4, 3, 0xffffff);
const eye2 = this.add.circle(5, -4, 3, 0xffffff);
const pupil1 = this.add.circle(-5, -4, 1.3, 0x0a0b14);
const pupil2 = this.add.circle(5, -4, 1.3, 0x0a0b14);
const container = this.add.container(x, y, [body, eye1, pupil1, eye2, pupil2]).setDepth(2);
return { row, col, homeRow: row, homeCol: col, color, container, body };
}
private resolvePlayerStart(initialState?: MazeProgress): Cell {
if (
initialState &&
initialState.playerRow >= 0 &&
initialState.playerRow < this.rows &&
initialState.playerCol >= 0 &&
initialState.playerCol < this.cols &&
this.grid[initialState.playerRow][initialState.playerCol] !== "#"
) {
return { row: initialState.playerRow, col: initialState.playerCol };
}
return this.startPos;
}
private cellToPixel(cell: Cell): { x: number; y: number } {
return {
x: this.offsetX + cell.col * this.cellSize + this.cellSize / 2,
y: this.offsetY + cell.row * this.cellSize + this.cellSize / 2,
};
}
private isWalkable(row: number, col: number): boolean {
if (row < 0 || row >= this.rows || col < 0 || col >= this.cols) return false;
return this.grid[row][col] !== "#";
}
private tryMove(from: Cell, dir: Dir): Cell | null {
const nr = from.row + dir.dr;
const nc = from.col + dir.dc;
return this.isWalkable(nr, nc) ? { row: nr, col: nc } : null;
}
private snapTo(obj: { x: number; y: number }, cell: Cell) {
const { x, y } = this.cellToPixel(cell);
obj.x = x;
obj.y = y;
}
private tweenTo(obj: Phaser.GameObjects.Container | Phaser.GameObjects.Arc, cell: Cell, duration: number) {
const { x, y } = this.cellToPixel(cell);
this.tweens.add({ targets: obj, x, y, duration, ease: "Linear" });
}
private playerTick() {
if (this.finished) return;
const grid = this.definition.grid;
let { row, col } = this.playerPos;
let moved = false;
if (Phaser.Input.Keyboard.JustDown(this.cursors.left)) {
col -= 1;
moved = true;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.right)) {
col += 1;
moved = true;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) {
row -= 1;
moved = true;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.down)) {
row += 1;
moved = true;
}
if (!moved) return;
if (row < 0 || row >= grid.length || col < 0 || col >= grid[0].length) return;
if (grid[row][col] === "#") return;
this.playerPos = { row, col };
this.player.x = this.offsetX + col * this.cellSize + this.cellSize / 2;
this.player.y = this.offsetY + row * this.cellSize + this.cellSize / 2;
const key = `${row},${col}`;
if (this.items.has(key)) {
this.items.delete(key);
this.itemSprites.get(key)?.destroy();
this.itemSprites.delete(key);
this.collectedItemKeys.push(key);
this.updateHud();
}
if (grid[row][col] === "E") {
this.finish(true);
let next = this.desiredDir ? this.tryMove(this.playerPos, this.desiredDir) : null;
if (next) {
this.currentDir = this.desiredDir;
} else {
this.updateHud();
next = this.currentDir ? this.tryMove(this.playerPos, this.currentDir) : null;
}
if (!next) return;
this.playerPos = next;
this.tweenTo(this.player, next, PLAYER_STEP_MS * 0.9);
this.handlePickup(next);
this.checkCollisions();
if (!this.finished) this.reportProgress();
}
private handlePickup(cell: Cell) {
const key = `${cell.row},${cell.col}`;
if (this.dots.has(key)) {
this.dots.delete(key);
this.dotSprites.get(key)?.destroy();
this.dotSprites.delete(key);
this.collectedKeys.add(key);
this.score += DOT_SCORE;
} else if (this.pellets.has(key)) {
this.pellets.delete(key);
this.pelletSprites.get(key)?.destroy();
this.pelletSprites.delete(key);
this.collectedKeys.add(key);
this.score += PELLET_SCORE;
this.frightenAllGhosts();
}
this.updateHud();
if (this.dots.size === 0 && this.pellets.size === 0 && this.grid[cell.row][cell.col] === "E") {
this.finish(true, this.score, `Лабиринт пройден! Очков: ${this.score}`);
}
}
private frightenAllGhosts() {
const until = this.time.now + FRIGHTEN_DURATION_MS;
this.frightenEndsAt = until;
this.ghosts.forEach((g) => {
if (!g.eatenUntil || g.eatenUntil < this.time.now) g.frightenedUntil = until;
});
this.frightenBarBg.setVisible(true);
this.frightenBarFill.setVisible(true);
}
private ghostTick() {
if (this.finished) return;
const now = this.time.now;
this.ghosts.forEach((g) => {
if (g.eatenUntil !== undefined) {
if (g.eatenUntil > now) return;
g.eatenUntil = undefined;
g.row = g.homeRow;
g.col = g.homeCol;
g.body.setFillStyle(g.color);
g.container.setVisible(true);
this.snapTo(g.container, g);
return;
}
const frightened = g.frightenedUntil !== undefined && g.frightenedUntil > now;
if (g.frightenedUntil !== undefined && g.frightenedUntil <= now) g.frightenedUntil = undefined;
g.body.setFillStyle(frightened ? COLORS.ghostFrightened : g.color);
const next = frightened ? this.fleeStep(g) : this.chaseStep(g);
if (next) {
g.row = next.row;
g.col = next.col;
this.tweenTo(g.container, next, GHOST_STEP_MS * 0.9);
}
});
this.updateFrightenBar(now);
this.checkCollisions();
}
private bfsPath(from: Cell, to: Cell): Cell[] | null {
const key = (p: Cell) => `${p.row},${p.col}`;
const visited = new Set<string>([key(from)]);
const queue: Cell[][] = [[from]];
while (queue.length > 0) {
const path = queue.shift()!;
const cur = path[path.length - 1];
if (cur.row === to.row && cur.col === to.col) return path;
for (const d of Object.values(DIRS)) {
const nr = cur.row + d.dr;
const nc = cur.col + d.dc;
if (!this.isWalkable(nr, nc)) continue;
const k = `${nr},${nc}`;
if (visited.has(k)) continue;
visited.add(k);
queue.push([...path, { row: nr, col: nc }]);
}
}
return null;
}
private chaseStep(g: Ghost): Cell | null {
const path = this.bfsPath({ row: g.row, col: g.col }, this.playerPos);
return path && path.length > 1 ? path[1] : null;
}
private fleeStep(g: Ghost): Cell | null {
let best: Cell | null = null;
let bestDist = -1;
for (const d of Object.values(DIRS)) {
const nr = g.row + d.dr;
const nc = g.col + d.dc;
if (!this.isWalkable(nr, nc)) continue;
const dist = Math.abs(nr - this.playerPos.row) + Math.abs(nc - this.playerPos.col);
if (dist > bestDist) {
bestDist = dist;
best = { row: nr, col: nc };
}
}
return best;
}
private checkCollisions() {
if (this.finished) return;
const now = this.time.now;
for (const g of this.ghosts) {
if (g.eatenUntil !== undefined) continue;
if (g.row !== this.playerPos.row || g.col !== this.playerPos.col) continue;
const frightened = g.frightenedUntil !== undefined && g.frightenedUntil > now;
if (frightened) {
this.score += GHOST_EAT_SCORE;
g.frightenedUntil = undefined;
g.eatenUntil = now + GHOST_RESPAWN_DELAY_MS;
g.container.setVisible(false);
this.updateHud();
} else {
this.loseLife();
}
break;
}
}
private loseLife() {
this.lives -= 1;
this.updateHud();
if (this.lives <= 0) {
this.finish(false, this.score, `Вас поймали. Очков: ${this.score}`);
return;
}
this.playerPos = { ...this.startPos };
this.snapTo(this.player, this.playerPos);
this.desiredDir = null;
this.currentDir = null;
this.ghosts.forEach((g) => {
g.row = g.homeRow;
g.col = g.homeCol;
g.frightenedUntil = undefined;
g.eatenUntil = undefined;
g.body.setFillStyle(g.color);
g.container.setVisible(true);
this.snapTo(g.container, g);
});
this.reportProgress();
}
private updateFrightenBar(now: number) {
const active = this.ghosts.some((g) => g.frightenedUntil !== undefined && g.frightenedUntil > now);
if (!active) {
this.frightenBarBg.setVisible(false);
this.frightenBarFill.setVisible(false);
return;
}
const pct = Math.max(0, (this.frightenEndsAt - now) / FRIGHTEN_DURATION_MS);
this.frightenBarFill.setScale(pct, 1);
}
private updateHud() {
const collected = this.totalItems - this.items.size;
this.scoreText.setText(`Собрано: ${collected}/${this.totalItems}`);
this.livesText.setText(`Жизни: ${"❤".repeat(Math.max(0, this.lives))}`);
this.scoreText.setText(`Очки: ${this.score}`);
this.dotsText.setText(`Осталось: ${this.dots.size + this.pellets.size}`);
if (this.timerText) {
this.timerText.setText(`Осталось: ${Math.ceil(this.remainingMs / 1000)} сек`);
}
if (!this.finished) {
this.onProgress?.({
playerRow: this.playerPos.row,
playerCol: this.playerPos.col,
collectedItemKeys: this.collectedItemKeys,
remainingMs: this.remainingMs,
});
this.timerText.setText(`Время: ${Math.max(0, Math.ceil(this.remainingMs / 1000))} сек`);
}
}
private finish(won: boolean, message?: string) {
this.finished = true;
const collected = this.totalItems - this.items.size;
this.onFinish?.({
won,
score: collected,
message: message ?? `Собрано: ${collected}/${this.totalItems}`,
private reportProgress() {
this.onProgress?.({
playerRow: this.playerPos.row,
playerCol: this.playerPos.col,
collectedKeys: Array.from(this.collectedKeys),
remainingMs: this.remainingMs,
lives: this.lives,
});
}
private finish(won: boolean, score: number, message: string) {
this.finished = true;
this.onFinish?.({ won, score, message });
}
}