diff --git a/public/assets/images/labyrinth-cards.png b/public/assets/images/labyrinth-cards.png new file mode 100644 index 0000000..1423e59 Binary files /dev/null and b/public/assets/images/labyrinth-cards.png differ diff --git a/public/assets/images/labyrinth-cards.psd b/public/assets/images/labyrinth-cards.psd new file mode 100644 index 0000000..2623a4a Binary files /dev/null and b/public/assets/images/labyrinth-cards.psd differ diff --git a/public/assets/images/labyrinth-tiles.png b/public/assets/images/labyrinth-tiles.png new file mode 100644 index 0000000..17b2004 Binary files /dev/null and b/public/assets/images/labyrinth-tiles.png differ diff --git a/public/assets/images/labyrinth-tiles.psd b/public/assets/images/labyrinth-tiles.psd new file mode 100644 index 0000000..d3632f0 Binary files /dev/null and b/public/assets/images/labyrinth-tiles.psd differ diff --git a/public/assets/images/labyrinth-treasures.png b/public/assets/images/labyrinth-treasures.png new file mode 100644 index 0000000..ac70219 Binary files /dev/null and b/public/assets/images/labyrinth-treasures.png differ diff --git a/public/assets/images/labyrinth-treasures.psd b/public/assets/images/labyrinth-treasures.psd new file mode 100644 index 0000000..1e57d59 Binary files /dev/null and b/public/assets/images/labyrinth-treasures.psd differ diff --git a/public/src/games/labyrinth/LabyrinthAI.js b/public/src/games/labyrinth/LabyrinthAI.js new file mode 100644 index 0000000..bc51255 --- /dev/null +++ b/public/src/games/labyrinth/LabyrinthAI.js @@ -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; +} diff --git a/public/src/games/labyrinth/LabyrinthData.js b/public/src/games/labyrinth/LabyrinthData.js new file mode 100644 index 0000000..6bae0b9 --- /dev/null +++ b/public/src/games/labyrinth/LabyrinthData.js @@ -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 (0–11) then 12 objects (12–23). +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 0–11). +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 12–23 +// 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 (12–17) + for (let i = 0; i < 6; i++) push('T', 12 + i); + // 16 corners — six treasured (18–23), 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; } diff --git a/public/src/games/labyrinth/LabyrinthGame.js b/public/src/games/labyrinth/LabyrinthGame.js new file mode 100644 index 0000000..99523ab --- /dev/null +++ b/public/src/games/labyrinth/LabyrinthGame.js @@ -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]; + } +} diff --git a/public/src/games/labyrinth/LabyrinthLogic.js b/public/src/games/labyrinth/LabyrinthLogic.js new file mode 100644 index 0000000..2d8fa3e --- /dev/null +++ b/public/src/games/labyrinth/LabyrinthLogic.js @@ -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; +} diff --git a/public/src/main.js b/public/src/main.js index 88598d8..dd1f492 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index e6c4a7a..e9a5e9a 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index fbffbd5..95b47b5 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -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 1–5 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'); diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 031677b..ffc1833 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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() { diff --git a/server/games/registry.js b/server/games/registry.js index ff24bf6..220b7e4 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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 });