Labyrinth Initial framework and basic animation.

This commit is contained in:
Brian Fertig 2026-06-06 16:00:49 -06:00
parent ab84b32f1d
commit 8f1e3faaec
15 changed files with 1153 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

View File

@ -0,0 +1,110 @@
// Labyrinth — heuristic opponent. No Phaser, no timers. It plans a whole turn
// at once: try every legal (rotation × insertion) of the spare tile, and for
// each resulting board find the move that best advances the player's current
// target. Greedy 1-turn lookahead with a skill model for human-like pacing and
// the occasional blunder.
import {
legalSlots, withSpareRot, applyInsertion, reachableFrom, findTreasure, currentTarget,
} from './LabyrinthLogic.js';
const SKILL_PROFILES = {
1: { topN: 6, blunder: 0.40, noise: 3.0, delay: [800, 1300] },
2: { topN: 4, blunder: 0.24, noise: 1.8, delay: [700, 1150] },
3: { topN: 3, blunder: 0.12, noise: 1.0, delay: [620, 1020] },
4: { topN: 2, blunder: 0.04, noise: 0.4, delay: [520, 900] },
// topN 2 + light noise keeps play strong while breaking the rare deterministic
// cycle two perfect players could otherwise fall into.
5: { topN: 2, blunder: 0.00, noise: 0.4, delay: [440, 820] },
};
function profileFor(skill) { return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; }
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
const manhattan = (a, br, bc) => Math.abs(a.r - br) + Math.abs(a.c - bc);
const WIN_SCORE = 1e6;
const REACH_BONUS = 1000; // landing on the target this turn beats any approach
// Evaluate one insertion result for the given seat: returns the best move it
// affords and a score (higher is better).
function evalBoard(state, seat) {
const p = state.players[seat];
const reach = reachableFrom(state, p.r, p.c);
const collected = currentTarget(p) == null;
// The cell we're trying to reach: our current treasure's tile, or — once the
// stack is empty — our home corner to win.
let goal;
if (collected) {
goal = { r: p.home.r, c: p.home.c };
} else {
const loc = findTreasure(state, currentTarget(p));
if (!loc) {
// Target rode out onto the spare — unreachable this turn. Sit tight near
// the centre so we keep options open.
return { score: -50, dest: { r: p.r, c: p.c } };
}
goal = loc;
}
// On the goal already / can reach it → take it.
const onGoal = reach.find((q) => q.r === goal.r && q.c === goal.c);
if (onGoal) {
const base = collected ? WIN_SCORE : REACH_BONUS;
return { score: base, dest: { r: goal.r, c: goal.c } };
}
// Otherwise close the gap: pick the reachable cell nearest the goal.
let best = { r: p.r, c: p.c }, bestD = Infinity;
for (const q of reach) {
const d = manhattan(q, goal.r, goal.c);
if (d < bestD) { bestD = d; best = q; }
}
return { score: -bestD, dest: best };
}
// Plan a full turn. Returns { rot, slotId, dest:{r,c} } or null if no legal
// insertion exists (shouldn't happen).
export function chooseAction(state, skill = 3) {
const seat = state.current;
const prof = profileFor(skill);
const combos = [];
for (const slot of legalSlots(state)) {
for (let rot = 0; rot < 4; rot++) {
const after = applyInsertion(withSpareRot(state, rot), slot.id);
const { score, dest } = evalBoard(after, seat);
combos.push({ rot, slotId: slot.id, dest, score });
}
}
if (combos.length === 0) return null;
combos.sort((a, b) => b.score - a.score);
// No capture/win available this turn — every option is just "get closer".
// Greedy 1-turn play can lock two perfect players into a fixed point here, so
// explore the better half at random to guarantee progress over time. Capture
// and win turns (score ≥ 0) always fall through to the sharp logic below.
if (combos[0].score < 0) {
const pool = combos.slice(0, Math.max(1, Math.ceil(combos.length / 2)));
return pool[Math.floor(Math.random() * pool.length)];
}
if (Math.random() < prof.blunder) {
// Pick a merely-decent combo from the top third instead of the best.
const pool = combos.slice(0, Math.max(1, Math.ceil(combos.length / 3)));
return pool[Math.floor(Math.random() * pool.length)];
}
const pool = combos.slice(0, Math.min(prof.topN, combos.length));
let best = pool[0], bestV = -Infinity;
for (const c of pool) {
const v = c.score + (prof.noise ? (Math.random() * 2 - 1) * prof.noise : 0);
if (v > bestV) { bestV = v; best = c; }
}
return best;
}

View File

@ -0,0 +1,171 @@
// Labyrinth — static data. No Phaser, no game state: just the maze tile
// vocabulary, the fixed-tile skeleton, the movable-tile bag, the treasure
// catalog, player home corners, and spritesheet frame helpers. Everything
// dynamic (the shuffled board, spare tile, pawns, targets) lives in
// LabyrinthLogic.js.
// 7×7 board. The 49 cells hold 48 movable + the fixed skeleton; one extra
// "spare" tile is always held off-board and pushed in each turn.
export const GRID = 7;
// ── Directions ────────────────────────────────────────────────────────────────
// Side indices around a tile, clockwise from the top. A clockwise rotation by
// `r` quarter-turns maps side s → (s + r) % 4.
export const N = 0, E = 1, S = 2, W = 3;
export const DIRS = [N, E, S, W];
export const OPPOSITE = { [N]: S, [E]: W, [S]: N, [W]: E };
// Row/col delta when stepping out of a tile through a given side.
export const DELTA = {
[N]: { dr: -1, dc: 0 },
[E]: { dr: 0, dc: 1 },
[S]: { dr: 1, dc: 0 },
[W]: { dr: 0, dc: -1 },
};
// Base (unrotated) open sides per tile shape.
// I (straight) — corridor N↔S
// L (corner) — corridor N↔E
// T (junction) — open E,S,W (closed N)
export const BASE_SIDES = {
I: [N, S],
L: [N, E],
T: [E, S, W],
};
// The set of open sides for a tile of `type` rotated `rot` quarter-turns.
export function openSides(type, rot) {
return BASE_SIDES[type].map((s) => (s + rot) % 4);
}
// Does a tile of (type,rot) have an opening on `side`?
export function isOpen(type, rot, side) {
return openSides(type, rot).includes(side);
}
// ── Treasures ────────────────────────────────────────────────────────────────
// 24 treasures. The array index IS the spritesheet frame for both the on-tile
// overlay (labyrinth-treasures, 100×100) and the held card (labyrinth-cards,
// 270×390). 12 creatures (011) then 12 objects (1223).
export const TREASURES = [
{ key: 'dragon', name: 'Dragon' },
{ key: 'genie', name: 'Genie' },
{ key: 'ghost', name: 'Ghost' },
{ key: 'goblin', name: 'Goblin' },
{ key: 'witch', name: 'Witch' },
{ key: 'skeleton', name: 'Skeleton' },
{ key: 'bat', name: 'Bat' },
{ key: 'owl', name: 'Owl' },
{ key: 'spider', name: 'Spider' },
{ key: 'lizard', name: 'Lizard' },
{ key: 'beetle', name: 'Beetle' },
{ key: 'rat', name: 'Rat' },
{ key: 'crown', name: 'Crown' },
{ key: 'ring', name: 'Ring' },
{ key: 'keys', name: 'Keys' },
{ key: 'chest', name: 'Treasure Chest' },
{ key: 'gem', name: 'Gem' },
{ key: 'coins', name: 'Gold Coins' },
{ key: 'candelabra', name: 'Candelabra' },
{ key: 'grimoire', name: 'Grimoire' },
{ key: 'sword', name: 'Sword' },
{ key: 'helmet', name: 'Helmet' },
{ key: 'map', name: 'Map Scroll' },
{ key: 'chalice', name: 'Chalice' },
];
export const TREASURE_COUNT = TREASURES.length; // 24
// ── Fixed skeleton ──────────────────────────────────────────────────────────
// The 16 tiles anchored at even row/col never move. Corners are L-tiles opening
// inward; the rest are T-junctions whose closed (flat) side faces the board
// edge. The 12 non-corner fixed tiles each carry a treasure (frames 011).
export const FIXED = [
// four corners (no treasure)
{ r: 0, c: 0, type: 'L', rot: 1 }, // opens E,S
{ r: 0, c: 6, type: 'L', rot: 2 }, // opens S,W
{ r: 6, c: 0, type: 'L', rot: 0 }, // opens N,E
{ r: 6, c: 6, type: 'L', rot: 3 }, // opens N,W
// top edge — closed N
{ r: 0, c: 2, type: 'T', rot: 0, treasure: 0 },
{ r: 0, c: 4, type: 'T', rot: 0, treasure: 1 },
// bottom edge — closed S
{ r: 6, c: 2, type: 'T', rot: 2, treasure: 2 },
{ r: 6, c: 4, type: 'T', rot: 2, treasure: 3 },
// left edge — closed W
{ r: 2, c: 0, type: 'T', rot: 3, treasure: 4 },
{ r: 4, c: 0, type: 'T', rot: 3, treasure: 5 },
// right edge — closed E
{ r: 2, c: 6, type: 'T', rot: 1, treasure: 6 },
{ r: 4, c: 6, type: 'T', rot: 1, treasure: 7 },
// inner four — point inward
{ r: 2, c: 2, type: 'T', rot: 3, treasure: 8 }, // open N,E,S
{ r: 2, c: 4, type: 'T', rot: 1, treasure: 9 }, // open N,S,W
{ r: 4, c: 2, type: 'T', rot: 3, treasure: 10 },
{ r: 4, c: 4, type: 'T', rot: 1, treasure: 11 },
];
export function isFixed(r, c) {
return r % 2 === 0 && c % 2 === 0;
}
// ── Movable tile bag ──────────────────────────────────────────────────────────
// 34 tiles (33 fill the open cells, 1 is the starting spare). Treasures 1223
// ride on twelve of them (the six T's + six L's, matching the classic deck);
// straights carry none. Rotations are randomized at deal time in the logic.
export function buildMovableBag() {
const bag = [];
const push = (type, treasure = null) => bag.push({ type, treasure });
// 6 T-junctions, all treasured (1217)
for (let i = 0; i < 6; i++) push('T', 12 + i);
// 16 corners — six treasured (1823), ten plain
for (let i = 0; i < 6; i++) push('L', 18 + i);
for (let i = 0; i < 10; i++) push('L', null);
// 12 straights, no treasure
for (let i = 0; i < 12; i++) push('I', null);
return bag; // length 34
}
// ── Players ────────────────────────────────────────────────────────────────
// Home corners in seat order. Two-player games use opposite corners (seats 0 &
// 1 are diagonal), three/four fill in the remaining corners.
export const HOME_CORNERS = [
{ r: 0, c: 0 },
{ r: 6, c: 6 },
{ r: 0, c: 6 },
{ r: 6, c: 0 },
];
export const PLAYER_COLORS = [0xd0473a, 0x4a90d9, 0x49a25a, 0xe2b53c];
export const PLAYER_COLOR_HEX = ['#d0473a', '#4a90d9', '#49a25a', '#e2b53c'];
// ── Insertion slots ──────────────────────────────────────────────────────────
// The 12 places the spare can be pushed in: the shiftable rows/cols (1,3,5) on
// each of the four edges. `side` names where the spare enters; the row/col it
// affects is `index`. Pushing into a slot ejects the tile at the far end.
export const SHIFT_LINES = [1, 3, 5];
export const SLOTS = (() => {
const out = [];
for (const i of SHIFT_LINES) out.push({ id: `T${i}`, side: 'top', index: i });
for (const i of SHIFT_LINES) out.push({ id: `B${i}`, side: 'bottom', index: i });
for (const i of SHIFT_LINES) out.push({ id: `L${i}`, side: 'left', index: i });
for (const i of SHIFT_LINES) out.push({ id: `R${i}`, side: 'right', index: i });
return out;
})();
export const OPPOSITE_SIDE = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
// The slot whose push would directly reverse the given one (same line, opposite
// edge). Returns a slot id or null.
export function reverseSlotId(slot) {
if (!slot) return null;
const side = OPPOSITE_SIDE[slot.side];
return `${side === 'top' ? 'T' : side === 'bottom' ? 'B' : side === 'left' ? 'L' : 'R'}${slot.index}`;
}
// ── Spritesheet frame helpers ──────────────────────────────────────────────
// Tile background: frame 0 = movable, 1 = fixed/anchored.
export function tileFrame(fixed) { return fixed ? 1 : 0; }
// Treasure overlay & card share the treasure index as their frame.
export function treasureFrame(idx) { return idx; }
export function cardFrame(idx) { return idx; }

View File

@ -0,0 +1,602 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import {
GRID, N, E, S, W, openSides, SLOTS, TREASURES, tileFrame,
} from './LabyrinthData.js';
import {
createInitialState, rotateSpare, withSpareRot, applyInsertion, applyMove,
reachableFrom, currentTarget, targetsRemaining, allCollected, isGameOver,
} from './LabyrinthLogic.js';
import { chooseAction, nextThinkDelay } from './LabyrinthAI.js';
// ── Layout ───────────────────────────────────────────────────────────────────
const TILE = 112, GAP = 4, PITCH = TILE + GAP;
const BOARD_W = GRID * PITCH - GAP; // 808
const BX0 = 150, BY0 = 150; // board top-left
const RAIL_X = BX0 + BOARD_W + 70; // ~1028
const RAIL_W = GAME_WIDTH - RAIL_X - 30;
const DEPTH = { bg: 0, board: 5, tile: 10, corridor: 11, treasure: 12, home: 14, pawn: 20, ui: 40, popup: 60, banner: 90 };
export default class LabyrinthGame extends Phaser.Scene {
constructor() { super('LabyrinthGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.humanSeat = 0;
this.gs = null;
this.busy = false;
this.dyn = [];
this.portraits = [];
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
this.hasTiles = this.textures.exists('labyrinth-tiles');
this.hasTreasures = this.textures.exists('labyrinth-treasures');
this.hasCards = this.textures.exists('labyrinth-cards');
this.buildBackground();
const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length));
this.skillBySeat = {};
const names = [];
for (let seat = 0; seat < playerCount; seat++) {
if (seat === this.humanSeat) {
names.push(auth.user?.username ?? 'You');
this.skillBySeat[seat] = 5;
} else {
const opp = this.opponents[seat - 1];
names.push(opp?.name ?? `Player ${seat + 1}`);
this.skillBySeat[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3));
}
}
this.gs = createInitialState({ playerCount, names });
this.buildPortraits();
this.render();
this.advance();
}
// ── static chrome ────────────────────────────────────────────────────────────
buildBackground() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
} else {
const g = this.add.graphics().setDepth(DEPTH.bg);
g.fillGradientStyle(0x14110b, 0x14110b, 0x070503, 0x070503, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
}
this.add.text(GAME_WIDTH / 2, 24, 'Labyrinth', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
new Button(this, GAME_WIDTH - 96, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 140, height: 42, fontSize: 18 }).setDepth(DEPTH.ui);
}
// Player portraits (created once, positioned per rail row).
buildPortraits() {
const n = this.gs.players.length;
const r = 30;
this.gs.players.forEach((p, idx) => {
const { x, y } = this.playerRowAnchor(idx, n);
if (idx === this.humanSeat) {
this.portraits[idx] = createPlayerPortrait(this, x, y, r, DEPTH.ui + 1, 'LabyrinthGame');
} else {
this.portraits[idx] = createOpponentPortrait(this, this.opponents[idx - 1], x, y, r, DEPTH.ui + 1);
}
});
}
playerRowAnchor(idx, n) {
const top = 470, bottom = GAME_HEIGHT - 70;
const rowH = (bottom - top) / n;
return { x: RAIL_X + 46, y: top + idx * rowH + rowH / 2, rowH, rowTop: top + idx * rowH };
}
// ── render ─────────────────────────────────────────────────────────────────
reg(o) { this.dyn.push(o); return o; }
clearDyn() { for (const o of this.dyn) { try { o.destroy(); } catch { /* */ } } this.dyn = []; }
tileCenter(r, c) { return { x: BX0 + c * PITCH + TILE / 2, y: BY0 + r * PITCH + TILE / 2 }; }
spareCenterXY() { return { x: RAIL_X + RAIL_W - 130, y: 150 }; }
render() {
this.clearDyn();
this.drawBoardFrame();
this.drawTiles();
this.drawHomes();
this.drawReachable();
this.drawPawns();
this.drawArrows();
this.drawSpare();
this.drawRail();
this.drawStatus();
}
drawBoardFrame() {
const g = this.reg(this.add.graphics().setDepth(DEPTH.board));
g.fillStyle(0x000000, 0.5).fillRoundedRect(BX0 - 16, BY0 - 16, BOARD_W + 32, BOARD_W + 32, 16);
g.lineStyle(2, COLORS.accent, 0.5).strokeRoundedRect(BX0 - 16, BY0 - 16, BOARD_W + 32, BOARD_W + 32, 16);
}
drawTiles() {
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const { x, y } = this.tileCenter(r, c);
const fixed = r % 2 === 0 && c % 2 === 0;
this.drawTileArt(x, y, this.gs.board[r][c], fixed, TILE, DEPTH.tile);
}
}
}
// Draws a maze tile: user-supplied background, code-drawn bordered-white
// corridors, then the treasure overlay. Scales with `size`.
drawTileArt(cx, cy, tile, fixed, size, depth) {
const half = size / 2;
const cw = size * 0.36;
const B = Math.max(2, Math.round(size * 0.028));
// 1) background
if (this.hasTiles) {
this.reg(this.add.image(cx, cy, 'labyrinth-tiles', tileFrame(fixed))
.setDisplaySize(size, size).setDepth(depth));
} else {
this.reg(this.add.graphics().setDepth(depth))
.fillStyle(fixed ? 0x33402b : 0x3f5235, 1)
.fillRoundedRect(cx - half, cy - half, size, size, 6);
}
// 2) corridors (dark border underlay, then white on top)
const white = [{ x: -cw / 2, y: -cw / 2, w: cw, h: cw }];
const dark = [{ x: -cw / 2 - B, y: -cw / 2 - B, w: cw + 2 * B, h: cw + 2 * B }];
for (const side of openSides(tile.type, tile.rot)) {
if (side === N) { white.push({ x: -cw / 2, y: -half, w: cw, h: half }); dark.push({ x: -cw / 2 - B, y: -half, w: cw + 2 * B, h: half }); }
if (side === S) { white.push({ x: -cw / 2, y: 0, w: cw, h: half }); dark.push({ x: -cw / 2 - B, y: 0, w: cw + 2 * B, h: half }); }
if (side === W) { white.push({ x: -half, y: -cw / 2, w: half, h: cw }); dark.push({ x: -half, y: -cw / 2 - B, w: half, h: cw + 2 * B }); }
if (side === E) { white.push({ x: 0, y: -cw / 2, w: half, h: cw }); dark.push({ x: 0, y: -cw / 2 - B, w: half, h: cw + 2 * B }); }
}
const g = this.reg(this.add.graphics().setDepth(depth + 1));
g.fillStyle(0x241d12, 1);
for (const rr of dark) g.fillRect(cx + rr.x, cy + rr.y, rr.w, rr.h);
g.fillStyle(0xf3ead2, 1);
for (const rr of white) g.fillRect(cx + rr.x, cy + rr.y, rr.w, rr.h);
// 3) treasure overlay
if (tile.treasure != null) {
const ts = size * 0.52;
if (this.hasTreasures) {
this.reg(this.add.image(cx, cy, 'labyrinth-treasures', tile.treasure)
.setDisplaySize(ts, ts).setDepth(depth + 2));
} else {
const tg = this.reg(this.add.graphics().setDepth(depth + 2));
tg.fillStyle(0x000000, 0.5).fillCircle(cx, cy, ts * 0.4);
tg.fillStyle(COLORS.gold, 1).fillCircle(cx, cy, ts * 0.34);
this.reg(this.add.text(cx, cy, TREASURES[tile.treasure].name.charAt(0), {
fontFamily: 'Righteous', fontSize: `${Math.round(size * 0.22)}px`, color: '#1a1208',
}).setOrigin(0.5).setDepth(depth + 3));
}
}
}
// Creates a tweeneable Phaser Container with tile art centered at (0,0).
createAnimTile(tile, fixed, size) {
const cont = this.add.container(0, 0);
const half = size / 2, cw = size * 0.36;
const B = Math.max(2, Math.round(size * 0.028));
if (this.hasTiles) {
cont.add(this.add.image(0, 0, 'labyrinth-tiles', tileFrame(fixed)).setDisplaySize(size, size));
} else {
const bg = this.add.graphics();
bg.fillStyle(fixed ? 0x33402b : 0x3f5235, 1).fillRoundedRect(-half, -half, size, size, 6);
cont.add(bg);
}
const white = [{ x: -cw / 2, y: -cw / 2, w: cw, h: cw }];
const dark = [{ x: -cw / 2 - B, y: -cw / 2 - B, w: cw + 2 * B, h: cw + 2 * B }];
for (const side of openSides(tile.type, tile.rot)) {
if (side === N) { white.push({ x: -cw/2, y: -half, w: cw, h: half }); dark.push({ x: -cw/2-B, y: -half, w: cw+2*B, h: half }); }
if (side === S) { white.push({ x: -cw/2, y: 0, w: cw, h: half }); dark.push({ x: -cw/2-B, y: 0, w: cw+2*B, h: half }); }
if (side === W) { white.push({ x: -half, y: -cw/2, w: half, h: cw }); dark.push({ x: -half, y: -cw/2-B, w: half, h: cw+2*B }); }
if (side === E) { white.push({ x: 0, y: -cw/2, w: half, h: cw }); dark.push({ x: 0, y: -cw/2-B, w: half, h: cw+2*B }); }
}
const g = this.add.graphics();
g.fillStyle(0x241d12, 1); for (const r of dark) g.fillRect(r.x, r.y, r.w, r.h);
g.fillStyle(0xf3ead2, 1); for (const r of white) g.fillRect(r.x, r.y, r.w, r.h);
cont.add(g);
if (tile.treasure != null) {
const ts = size * 0.52;
if (this.hasTreasures) {
cont.add(this.add.image(0, 0, 'labyrinth-treasures', tile.treasure).setDisplaySize(ts, ts));
} else {
const tg = this.add.graphics();
tg.fillStyle(0x000000, 0.5).fillCircle(0, 0, ts * 0.4);
tg.fillStyle(COLORS.gold, 1).fillCircle(0, 0, ts * 0.34);
cont.add(tg);
cont.add(this.add.text(0, 0, TREASURES[tile.treasure].name.charAt(0), {
fontFamily: 'Righteous', fontSize: `${Math.round(size * 0.22)}px`, color: '#1a1208',
}).setOrigin(0.5));
}
}
return cont;
}
// 3-phase insertion animation: spare flies to edge → row/col slides → ejected tile flies to spare area.
animateInsertion(slotId, onComplete) {
const slot = SLOTS.find(sl => sl.id === slotId);
const { side, index } = slot;
const isVert = side === 'top' || side === 'bottom';
const oldSpare = this.gs.spare;
const oldLine = [];
for (let i = 0; i < GRID; i++)
oldLine.push(isVert ? this.gs.board[i][index] : this.gs.board[index][i]);
this.gs = applyInsertion(this.gs, slotId);
const { x: spareCX, y: spareCY } = this.spareCenterXY();
const ANIM_D = 85;
const animObjs = [];
let entryX, entryY, exitX, exitY;
if (side === 'top') { entryX = this.tileCenter(0, index).x; entryY = BY0 - TILE / 2; exitX = entryX; exitY = BY0 + BOARD_W + TILE / 2; }
if (side === 'bottom') { entryX = this.tileCenter(GRID-1, index).x; entryY = BY0 + BOARD_W + TILE / 2; exitX = entryX; exitY = BY0 - TILE / 2; }
if (side === 'left') { entryY = this.tileCenter(index, 0 ).y; entryX = BX0 - TILE / 2; exitY = entryY; exitX = BX0 + BOARD_W + TILE / 2; }
if (side === 'right') { entryY = this.tileCenter(index, GRID-1 ).y; entryX = BX0 + BOARD_W + TILE / 2; exitY = entryY; exitX = BX0 - TILE / 2; }
const ejectIdx = (side === 'top' || side === 'left') ? 6 : 0;
const spareDest =
side === 'top' ? this.tileCenter(0, index) :
side === 'bottom' ? this.tileCenter(GRID-1, index) :
side === 'left' ? this.tileCenter(index, 0 ) :
this.tileCenter(index, GRID-1 );
// Phase 1: spare flies to entry edge
const spareCont = this.createAnimTile(oldSpare, false, TILE);
spareCont.setPosition(spareCX, spareCY).setScale(116 / TILE).setDepth(ANIM_D);
animObjs.push(spareCont);
this.tweens.add({
targets: spareCont, x: entryX, y: entryY, scaleX: 1, scaleY: 1,
duration: 500, ease: 'Cubic.easeIn',
onComplete: () => {
this.time.delayedCall(600, () => {
// Phase 2: all line tiles + spare slide simultaneously
const lineConts = [];
for (let i = 0; i < GRID; i++) {
const startPos = isVert ? this.tileCenter(i, index) : this.tileCenter(index, i);
const cont = this.createAnimTile(oldLine[i], false, TILE);
cont.setPosition(startPos.x, startPos.y).setDepth(ANIM_D);
animObjs.push(cont);
lineConts.push({ cont, i });
}
let done = 0;
const total = GRID + 1;
const onSlid = () => { if (++done < total) return; phase3(); }; // eslint-disable-line no-use-before-define
this.tweens.add({ targets: spareCont, x: spareDest.x, y: spareDest.y, duration: 1200, ease: 'Linear', onComplete: onSlid });
for (const { cont, i } of lineConts) {
let dx, dy;
if (i === ejectIdx) { dx = exitX; dy = exitY; }
else {
const dest =
side === 'top' ? this.tileCenter(i + 1, index) :
side === 'bottom' ? this.tileCenter(i - 1, index) :
side === 'left' ? this.tileCenter(index, i + 1) :
this.tileCenter(index, i - 1);
dx = dest.x; dy = dest.y;
}
this.tweens.add({ targets: cont, x: dx, y: dy, duration: 1200, ease: 'Linear', onComplete: onSlid });
}
// Phase 3: ejected tile flies to spare display area
const phase3 = () => {
this.time.delayedCall(600, () => {
const ejectCont = lineConts[ejectIdx].cont;
this.tweens.add({
targets: ejectCont, x: spareCX, y: spareCY, scaleX: 116 / TILE, scaleY: 116 / TILE,
duration: 600, ease: 'Cubic.easeOut',
onComplete: () => {
for (const o of animObjs) o.destroy();
onComplete();
},
});
});
};
});
},
});
}
drawHomes() {
this.gs.players.forEach((p) => {
const { x, y } = this.tileCenter(p.home.r, p.home.c);
const g = this.reg(this.add.graphics().setDepth(DEPTH.home));
g.lineStyle(4, p.color, 0.95).strokeRoundedRect(x - TILE / 2 + 3, y - TILE / 2 + 3, TILE - 6, TILE - 6, 8);
// corner flag dot
g.fillStyle(p.color, 1).fillCircle(x - TILE / 2 + 14, y - TILE / 2 + 14, 7);
g.lineStyle(2, 0xffffff, 0.9).strokeCircle(x - TILE / 2 + 14, y - TILE / 2 + 14, 7);
});
}
// Highlight + click targets for the human's move step.
drawReachable() {
if (!this.isHumanMove()) return;
const p = this.gs.players[this.humanSeat];
const cells = reachableFrom(this.gs, p.r, p.c);
for (const q of cells) {
const { x, y } = this.tileCenter(q.r, q.c);
const isHere = q.r === p.r && q.c === p.c;
const g = this.reg(this.add.graphics().setDepth(DEPTH.home + 1));
g.fillStyle(0x57c46a, isHere ? 0.10 : 0.22).fillRoundedRect(x - TILE / 2, y - TILE / 2, TILE, TILE, 8);
g.lineStyle(3, 0x57c46a, 0.9).strokeRoundedRect(x - TILE / 2 + 2, y - TILE / 2 + 2, TILE - 4, TILE - 4, 8);
const z = this.reg(this.add.zone(x, y, TILE, TILE).setInteractive({ useHandCursor: true }).setDepth(DEPTH.pawn + 1));
z.on('pointerdown', () => this.onMove(q.r, q.c));
}
}
drawPawns() {
const cells = {};
this.gs.players.forEach((p) => { const k = p.r * GRID + p.c; (cells[k] ??= []).push(p); });
for (const k of Object.keys(cells)) {
const list = cells[k];
const r = Math.floor(k / GRID), c = k % GRID;
const { x, y } = this.tileCenter(r, c);
list.forEach((p, i) => {
const off = this.pawnOffset(i, list.length);
const px = x + off.x, py = y + off.y;
const g = this.reg(this.add.graphics().setDepth(DEPTH.pawn));
g.fillStyle(0x000000, 0.4).fillCircle(px, py + 3, 14);
g.fillStyle(p.color, 1).fillCircle(px, py, 13);
const ring = p.seat === this.gs.current && !isGameOver(this.gs);
g.lineStyle(ring ? 4 : 2, ring ? 0xffffff : 0x1a1208, ring ? 1 : 0.7).strokeCircle(px, py, 13);
});
}
}
pawnOffset(i, n) {
if (n === 1) return { x: 0, y: 0 };
const ang = (i / n) * Math.PI * 2 - Math.PI / 2;
return { x: Math.cos(ang) * 16, y: Math.sin(ang) * 16 };
}
// ── insertion arrows ─────────────────────────────────────────────────────────
drawArrows() {
const human = this.isHumanInsert();
const blocked = this.gs.blockedSlotId;
for (const slot of SLOTS) {
const pos = this.arrowPos(slot);
const isBlocked = slot.id === blocked;
const active = human && !isBlocked;
const color = isBlocked ? COLORS.muted : (active ? COLORS.gold : COLORS.accent);
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
g.fillStyle(color, isBlocked ? 0.25 : 0.9);
this.fillTriangle(g, pos.x, pos.y, pos.dir, 16);
if (active) {
const z = this.reg(this.add.zone(pos.x, pos.y, 44, 44).setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 1));
z.on('pointerover', () => { g.clear(); g.fillStyle(COLORS.text, 1); this.fillTriangle(g, pos.x, pos.y, pos.dir, 19); });
z.on('pointerout', () => { g.clear(); g.fillStyle(COLORS.gold, 0.9); this.fillTriangle(g, pos.x, pos.y, pos.dir, 16); });
z.on('pointerdown', () => this.onInsert(slot.id));
}
}
}
arrowPos(slot) {
const off = 26;
if (slot.side === 'top') { return { x: this.tileCenter(0, slot.index).x, y: BY0 - off, dir: 'down' }; }
if (slot.side === 'bottom') { return { x: this.tileCenter(GRID - 1, slot.index).x, y: BY0 + BOARD_W + off, dir: 'up' }; }
if (slot.side === 'left') { return { x: BX0 - off, y: this.tileCenter(slot.index, 0).y, dir: 'right' }; }
return { x: BX0 + BOARD_W + off, y: this.tileCenter(slot.index, GRID - 1).y, dir: 'left' };
}
fillTriangle(g, x, y, dir, s) {
if (dir === 'down') g.fillTriangle(x - s, y - s, x + s, y - s, x, y + s);
if (dir === 'up') g.fillTriangle(x - s, y + s, x + s, y + s, x, y - s);
if (dir === 'right') g.fillTriangle(x - s, y - s, x - s, y + s, x + s, y);
if (dir === 'left') g.fillTriangle(x + s, y - s, x + s, y + s, x - s, y);
}
// ── spare tile preview + rotate controls ──────────────────────────────────────
drawSpare() {
const cx = RAIL_X + RAIL_W - 130, cy = 150;
const size = 116;
this.reg(this.add.text(cx, cy - size / 2 - 28, 'Extra tile', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.drawTileArt(cx, cy, this.gs.spare, false, size, DEPTH.ui + 2);
if (this.isHumanInsert()) {
this.spareBtn(cx - 78, cy, '↺', () => this.onRotate(-1));
this.spareBtn(cx + 78, cy, '↻', () => this.onRotate(1));
this.reg(this.add.text(cx, cy + size / 2 + 22, 'Rotate, then tap an arrow', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
}
}
spareBtn(x, y, glyph, fn) {
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
g.fillStyle(COLORS.panel, 1).fillCircle(x, y, 22);
g.lineStyle(2, COLORS.accent, 1).strokeCircle(x, y, 22);
this.reg(this.add.text(x, y, glyph, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
const z = this.reg(this.add.zone(x, y, 48, 48).setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 3));
z.on('pointerdown', fn);
}
// ── right rail: target card + player panels ───────────────────────────────────
drawRail() {
// Human's current treasure card.
const human = this.gs.players[this.humanSeat];
const t = currentTarget(human);
const cardW = 150, cardH = 217, cardX = RAIL_X + 90, cardY = 175;
this.reg(this.add.text(cardX, cardY - cardH / 2 - 26, 'Your treasure', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
g.fillStyle(0x14110b, 1).fillRoundedRect(cardX - cardW / 2, cardY - cardH / 2, cardW, cardH, 10);
if (t == null) {
this.reg(this.add.text(cardX, cardY, 'All found!\nReturn home', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex, align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
} else if (this.hasCards) {
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff).fillRoundedRect(cardX - cardW / 2, cardY - cardH / 2, cardW, cardH, 10);
this.dyn.push({ destroy: () => maskG.destroy() });
this.reg(this.add.image(cardX, cardY, 'labyrinth-cards', t)
.setDisplaySize(cardW, cardH).setMask(maskG.createGeometryMask()).setDepth(DEPTH.ui + 1));
} else {
if (this.hasTreasures) {
this.reg(this.add.image(cardX, cardY - 18, 'labyrinth-treasures', t).setDisplaySize(96, 96).setDepth(DEPTH.ui + 1));
} else {
this.reg(this.add.graphics().setDepth(DEPTH.ui + 1)).fillStyle(COLORS.gold, 1).fillCircle(cardX, cardY - 18, 38);
}
this.reg(this.add.text(cardX, cardY + 70, TREASURES[t].name, {
fontFamily: 'Righteous', fontSize: '17px', color: COLORS.textHex, align: 'center',
wordWrap: { width: cardW - 12 },
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
}
this.reg(this.add.graphics().setDepth(DEPTH.ui + 2))
.lineStyle(2, COLORS.accent, 0.9).strokeRoundedRect(cardX - cardW / 2, cardY - cardH / 2, cardW, cardH, 10);
this.reg(this.add.text(cardX, cardY + cardH / 2 + 18,
`${targetsRemaining(human)} treasure${targetsRemaining(human) === 1 ? '' : 's'} left`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
// Player panels (portraits live above, drawn once).
const n = this.gs.players.length;
this.gs.players.forEach((p, idx) => {
const a = this.playerRowAnchor(idx, n);
const isCurrent = idx === this.gs.current && !isGameOver(this.gs);
const px = RAIL_X, pw = RAIL_W, py = a.rowTop + 6, ph = a.rowH - 12;
const g2 = this.reg(this.add.graphics().setDepth(DEPTH.ui));
g2.fillStyle(0x000000, 0.55).fillRoundedRect(px, py, pw, ph, 10);
g2.lineStyle(isCurrent ? 3 : 1, isCurrent ? COLORS.gold : p.color, isCurrent ? 1 : 0.6)
.strokeRoundedRect(px, py, pw, ph, 10);
// color swatch
g2.fillStyle(p.color, 1).fillCircle(px + 18, py + 18, 8);
const youTag = idx === this.humanSeat ? ' (you)' : '';
this.reg(this.add.text(px + 92, py + 12, `${p.name}${youTag}`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).setDepth(DEPTH.ui + 1));
const rem = targetsRemaining(p);
const found = p.targets.length - rem;
this.reg(this.add.text(px + 92, py + 44,
allCollected(p) ? 'Heading home' : `Found ${found} / ${p.targets.length}`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setDepth(DEPTH.ui + 1));
// remaining pips
for (let i = 0; i < p.targets.length; i++) {
const dot = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
dot.fillStyle(i < found ? COLORS.gold : 0x4a4336, 1)
.fillCircle(px + pw - 18 - i * 16, py + ph - 16, 5);
}
});
}
drawStatus() {
let msg;
if (isGameOver(this.gs)) msg = `${this.gs.players[this.gs.winner].name} wins!`;
else if (!this.isHumanTurn()) msg = `${this.pname(this.gs.current)} is thinking…`;
else if (this.gs.phase === 'insert') msg = 'Your turn — rotate the extra tile, then push it into an arrow.';
else msg = 'Now move along the corridors to a highlighted tile (or tap your tile to stay).';
this.reg(this.add.text(BX0 - 4, BY0 + BOARD_W + 44, msg, {
fontFamily: '"Julius Sans One"', fontSize: '20px',
color: isGameOver(this.gs) ? COLORS.goldHex : COLORS.textHex,
}).setDepth(DEPTH.ui));
}
// ── helpers ─────────────────────────────────────────────────────────────────
pname(seat) { return this.gs.players[seat]?.name ?? `Player ${seat + 1}`; }
isHumanTurn() { return !this.busy && this.gs.current === this.humanSeat && !isGameOver(this.gs); }
isHumanInsert() { return this.isHumanTurn() && this.gs.phase === 'insert'; }
isHumanMove() { return this.isHumanTurn() && this.gs.phase === 'move'; }
// ── human actions ─────────────────────────────────────────────────────────────
onRotate(dir) {
if (!this.isHumanInsert()) return;
this.gs = rotateSpare(this.gs, dir);
playSound(this, SFX.PIECE_CLICK);
this.render();
}
onInsert(slotId) {
if (!this.isHumanInsert()) return;
this.busy = true;
playSound(this, SFX.PIECE_CLICK);
this.animateInsertion(slotId, () => { this.busy = false; this.render(); });
}
onMove(r, c) {
if (!this.isHumanMove()) return;
const next = applyMove(this.gs, r, c);
this.gs = next;
playSound(this, SFX.PIECE_CLICK);
this.advance();
}
// ── turn driver ───────────────────────────────────────────────────────────────
advance() {
this.render();
if (isGameOver(this.gs)) { this.busy = false; this.showWinner(); return; }
if (this.gs.current === this.humanSeat) { this.busy = false; return; }
this.aiTurn();
}
aiTurn() {
this.busy = true;
const skill = this.skillBySeat[this.gs.current];
this.time.delayedCall(nextThinkDelay(skill), () => {
const act = chooseAction(this.gs, skill);
if (!act) { this.busy = false; this.advance(); return; }
this.gs = withSpareRot(this.gs, act.rot);
this.render();
this.time.delayedCall(380, () => {
playSound(this, SFX.PIECE_CLICK);
this.animateInsertion(act.slotId, () => {
this.render();
this.time.delayedCall(480, () => {
this.gs = applyMove(this.gs, act.dest.r, act.dest.c);
playSound(this, SFX.PIECE_CLICK);
this.busy = false;
this.advance();
});
});
});
});
}
showWinner() {
const w = this.gs.players[this.gs.winner];
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
.setDepth(DEPTH.banner);
const panel = this.add.container(GAME_WIDTH / 2, GAME_HEIGHT / 2).setDepth(DEPTH.banner + 1);
const g = this.add.graphics();
g.fillStyle(0x14110b, 0.96).fillRoundedRect(-260, -110, 520, 220, 16);
g.lineStyle(3, w.color, 1).strokeRoundedRect(-260, -110, 520, 220, 16);
panel.add(g);
panel.add(this.add.text(0, -40, `${w.name} wins!`, {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(0.5));
panel.add(this.add.text(0, 8, 'Collected every treasure and returned home.', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5));
const btn = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 70, 'Back to menu',
() => this.scene.start('GameMenu'), { width: 240, height: 50 }).setDepth(DEPTH.banner + 2);
this._endObjs = [overlay, panel, btn];
}
}

View File

@ -0,0 +1,258 @@
// Labyrinth — pure game engine. No Phaser, no rendering, no timers. Every
// mutator deep-clones the state and returns the next one, so the scene and the
// AI can freely look ahead. A turn is two steps: INSERT the spare tile (after
// optionally rotating it), then MOVE your pawn along connected corridors.
import {
GRID, DELTA, OPPOSITE, openSides, isOpen,
FIXED, isFixed, buildMovableBag, TREASURES, TREASURE_COUNT,
HOME_CORNERS, PLAYER_COLORS, PLAYER_COLOR_HEX,
SLOTS, reverseSlotId,
} from './LabyrinthData.js';
// ── tiny seedable RNG (deterministic when a seed is supplied) ────────────────
function makeRng(seed) {
if (seed == null) return Math.random;
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rng) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
const keyOf = (r, c) => r * GRID + c;
// ── clone ────────────────────────────────────────────────────────────────────
function cloneTile(t) { return t ? { type: t.type, rot: t.rot, treasure: t.treasure } : t; }
export function cloneState(s) {
return {
board: s.board.map((row) => row.map(cloneTile)),
spare: cloneTile(s.spare),
players: s.players.map((p) => ({ ...p, home: { ...p.home }, targets: [...p.targets] })),
current: s.current,
phase: s.phase,
lastSlotId: s.lastSlotId,
blockedSlotId: s.blockedSlotId,
winner: s.winner,
playerCount: s.playerCount,
};
}
// ── setup ────────────────────────────────────────────────────────────────────
export function createInitialState({ playerCount = 4, names = [], seed = null } = {}) {
const rng = makeRng(seed);
const n = Math.max(2, Math.min(4, playerCount));
// Empty board, then stamp the fixed skeleton.
const board = Array.from({ length: GRID }, () => new Array(GRID).fill(null));
for (const f of FIXED) {
board[f.r][f.c] = { type: f.type, rot: f.rot, treasure: f.treasure ?? null };
}
// Shuffle the movable bag, give each a random rotation, and fill the open
// cells in reading order; the leftover tile is the starting spare.
const bag = shuffle(buildMovableBag(), rng).map((t) => ({
type: t.type, rot: Math.floor(rng() * 4), treasure: t.treasure,
}));
let bi = 0;
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
if (isFixed(r, c)) continue;
board[r][c] = bag[bi++];
}
}
const spare = bag[bi++];
// Deal the 24 treasures evenly as ordered, hidden target stacks.
const deck = shuffle(Array.from({ length: TREASURE_COUNT }, (_, i) => i), rng);
const per = Math.floor(TREASURE_COUNT / n);
const players = [];
for (let seat = 0; seat < n; seat++) {
const home = HOME_CORNERS[seat];
players.push({
seat,
name: names[seat] ?? `Player ${seat + 1}`,
color: PLAYER_COLORS[seat],
colorHex: PLAYER_COLOR_HEX[seat],
home: { ...home },
r: home.r, c: home.c,
targets: deck.slice(seat * per, seat * per + per),
targetIdx: 0,
});
}
return {
board, spare, players,
current: 0,
phase: 'insert',
lastSlotId: null,
blockedSlotId: null,
winner: null,
playerCount: n,
};
}
// ── queries ──────────────────────────────────────────────────────────────────
export function currentPlayer(state) { return state.players[state.current]; }
export function currentTarget(p) { return p.targetIdx < p.targets.length ? p.targets[p.targetIdx] : null; }
export function allCollected(p) { return p.targetIdx >= p.targets.length; }
export function targetsRemaining(p) { return p.targets.length - p.targetIdx; }
export function isGameOver(state) { return state.phase === 'over'; }
export function winner(state) { return state.winner; }
// Slots that are legal this turn (every slot except the one that would directly
// reverse the previous insertion).
export function legalSlots(state) {
return SLOTS.filter((sl) => sl.id !== state.blockedSlotId);
}
// Where a treasure currently sits on the board, or null if it's on the spare.
export function findTreasure(state, idx) {
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
if (state.board[r][c].treasure === idx) return { r, c };
}
}
return null;
}
// All cells reachable from (sr,sc) along connected corridors, including the
// start. Two adjacent tiles connect when each has an opening on their shared
// side.
export function reachableFrom(state, sr, sc) {
const b = state.board;
const seen = new Set([keyOf(sr, sc)]);
const out = [{ r: sr, c: sc }];
const stack = [{ r: sr, c: sc }];
while (stack.length) {
const { r, c } = stack.pop();
const t = b[r][c];
for (const side of openSides(t.type, t.rot)) {
const { dr, dc } = DELTA[side];
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue;
const nt = b[nr][nc];
if (!isOpen(nt.type, nt.rot, OPPOSITE[side])) continue;
const k = keyOf(nr, nc);
if (seen.has(k)) continue;
seen.add(k);
out.push({ r: nr, c: nc });
stack.push({ r: nr, c: nc });
}
}
return out;
}
export function isReachable(state, sr, sc, tr, tc) {
return reachableFrom(state, sr, sc).some((q) => q.r === tr && q.c === tc);
}
// ── mutators ─────────────────────────────────────────────────────────────────
export function rotateSpare(state, dir = 1) {
const s = cloneState(state);
if (s.phase !== 'insert') return s;
s.spare.rot = (s.spare.rot + (dir > 0 ? 1 : 3)) % 4;
return s;
}
export function withSpareRot(state, rot) {
const s = cloneState(state);
s.spare.rot = ((rot % 4) + 4) % 4;
return s;
}
// Push the spare into a slot: slide the affected row/column, wrap any pawn that
// rides off the far edge back onto the newly-inserted tile, and turn the
// ejected far tile into the new spare. Mutates `s` in place.
function shiftLine(s, slot) {
const b = s.board;
const spare = s.spare;
let ejected;
const last = GRID - 1;
if (slot.side === 'top' || slot.side === 'bottom') {
const c = slot.index;
const col = b.map((row) => row[c]);
if (slot.side === 'top') {
ejected = col[last];
const nc = [spare, ...col.slice(0, last)];
for (let r = 0; r < GRID; r++) b[r][c] = nc[r];
for (const p of s.players) if (p.c === c) p.r = p.r === last ? 0 : p.r + 1;
} else {
ejected = col[0];
const nc = [...col.slice(1), spare];
for (let r = 0; r < GRID; r++) b[r][c] = nc[r];
for (const p of s.players) if (p.c === c) p.r = p.r === 0 ? last : p.r - 1;
}
} else {
const r = slot.index;
const row = b[r];
if (slot.side === 'left') {
ejected = row[last];
b[r] = [spare, ...row.slice(0, last)];
for (const p of s.players) if (p.r === r) p.c = p.c === last ? 0 : p.c + 1;
} else {
ejected = row[0];
b[r] = [...row.slice(1), spare];
for (const p of s.players) if (p.r === r) p.c = p.c === 0 ? last : p.c - 1;
}
}
s.spare = ejected;
}
export function applyInsertion(state, slotId) {
const s = cloneState(state);
if (s.phase !== 'insert') return s;
if (!legalSlots(s).some((sl) => sl.id === slotId)) return s;
const slot = SLOTS.find((sl) => sl.id === slotId);
shiftLine(s, slot);
s.lastSlotId = slotId;
s.blockedSlotId = reverseSlotId(slot); // next player can't shove it straight back
s.phase = 'move';
return s;
}
// Claim the player's current target if standing on its tile, advancing their
// hidden stack. Mutates the player.
function claimIfPossible(s, p) {
const target = currentTarget(p);
if (target == null) return false;
if (s.board[p.r][p.c].treasure === target) { p.targetIdx++; return true; }
return false;
}
export function applyMove(state, r, c) {
const s = cloneState(state);
if (s.phase !== 'move') return s;
const p = s.players[s.current];
if (!isReachable(s, p.r, p.c, r, c)) return s; // illegal — ignore
p.r = r; p.c = c;
claimIfPossible(s, p);
if (allCollected(p) && p.r === p.home.r && p.c === p.home.c) {
s.phase = 'over';
s.winner = p.seat;
return s;
}
s.current = (s.current + 1) % s.players.length;
s.phase = 'insert';
return s;
}
// Uniform entry point used by the AI driver. `action` is one of:
// { type:'insert', slotId, rot? } { type:'move', r, c } { type:'rotate', dir }
export function applyAction(state, action) {
if (action.type === 'insert') {
const s = action.rot != null ? withSpareRot(state, action.rot) : state;
return applyInsertion(s, action.slotId);
}
if (action.type === 'move') return applyMove(state, action.r, action.c);
if (action.type === 'rotate') return rotateSpare(state, action.dir);
return state;
}

View File

@ -53,6 +53,7 @@ import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js'
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
import SplendorGame from './games/splendor/SplendorGame.js';
import TectonicGame from './games/tectonic/TectonicGame.js';
import LabyrinthGame from './games/labyrinth/LabyrinthGame.js';
const config = {
type: Phaser.AUTO,
@ -119,6 +120,7 @@ const config = {
SolitaireTourGame,
SplendorGame,
TectonicGame,
LabyrinthGame,
],
};

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -384,7 +384,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill.
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland'].includes(this.gameDef.slug)) {
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div');

View File

@ -112,6 +112,13 @@ export default class PreloadScene extends Phaser.Scene {
this.load.spritesheet('oldmaid-cards', '/assets/images/oldmaid-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('tab-icons', '/assets/images/tab-icons.png', { frameWidth: 128, frameHeight: 128 });
this.load.spritesheet('game-icons', '/assets/images/game-icons.png', { frameWidth: 44, frameHeight: 44 });
// Labyrinth. Tile backgrounds: frame 0 = movable, 1 = fixed (corridors are
// drawn in code). Treasure overlays & cards share the treasure index as
// their frame. All optional — the scene draws vector fallbacks when absent.
this.load.spritesheet('labyrinth-tiles', '/assets/images/labyrinth-tiles.png', { frameWidth: 200, frameHeight: 200 });
this.load.spritesheet('labyrinth-treasures', '/assets/images/labyrinth-treasures.png', { frameWidth: 100, frameHeight: 100 });
this.load.spritesheet('labyrinth-cards', '/assets/images/labyrinth-cards.png', { frameWidth: 270, frameHeight: 390 });
}
async create() {

View File

@ -68,3 +68,4 @@ registerGame({ slug: 'tectonic', name: 'Tectonic', category: 'w
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 });
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 });
registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 43 });