Labyrinth Initial framework and basic animation.
This commit is contained in:
parent
ab84b32f1d
commit
8f1e3faaec
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.
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,7 @@ import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js'
|
||||||
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
|
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
|
||||||
import SplendorGame from './games/splendor/SplendorGame.js';
|
import SplendorGame from './games/splendor/SplendorGame.js';
|
||||||
import TectonicGame from './games/tectonic/TectonicGame.js';
|
import TectonicGame from './games/tectonic/TectonicGame.js';
|
||||||
|
import LabyrinthGame from './games/labyrinth/LabyrinthGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -119,6 +120,7 @@ const config = {
|
||||||
SolitaireTourGame,
|
SolitaireTourGame,
|
||||||
SplendorGame,
|
SplendorGame,
|
||||||
TectonicGame,
|
TectonicGame,
|
||||||
|
LabyrinthGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
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]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -384,7 +384,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
|
|
||||||
// Skill control: pips always show the level; the +/- buttons appear only
|
// 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.
|
// 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';
|
bio.style.webkitLineClamp = '1';
|
||||||
|
|
||||||
const skillRow = document.createElement('div');
|
const skillRow = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -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('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('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 });
|
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() {
|
async create() {
|
||||||
|
|
|
||||||
|
|
@ -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: '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: '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: '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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue