295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
// 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);
|
|
}
|
|
|
|
// BFS shortest path from (sr,sc) to (tr,tc) along connected corridors.
|
|
// Returns the path as [{r,c}…] including both endpoints, or [{r:sr,c:sc}] if
|
|
// unreachable. The caller must ensure (tr,tc) is actually reachable.
|
|
export function pathTo(state, sr, sc, tr, tc) {
|
|
if (sr === tr && sc === tc) return [{ r: sr, c: sc }];
|
|
const b = state.board;
|
|
const prev = new Map();
|
|
const seen = new Set([keyOf(sr, sc)]);
|
|
const queue = [{ r: sr, c: sc }];
|
|
let found = false;
|
|
outer: while (queue.length) {
|
|
const { r, c } = queue.shift();
|
|
for (const side of openSides(b[r][c].type, b[r][c].rot)) {
|
|
const { dr, dc } = DELTA[side];
|
|
const nr = r + dr, nc = c + dc;
|
|
if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue;
|
|
if (!isOpen(b[nr][nc].type, b[nr][nc].rot, OPPOSITE[side])) continue;
|
|
const k = keyOf(nr, nc);
|
|
if (seen.has(k)) continue;
|
|
seen.add(k); prev.set(k, { r, c });
|
|
if (nr === tr && nc === tc) { found = true; break outer; }
|
|
queue.push({ r: nr, c: nc });
|
|
}
|
|
}
|
|
if (!found) return [{ r: sr, c: sc }];
|
|
const path = [];
|
|
let pos = { r: tr, c: tc };
|
|
for (;;) {
|
|
path.unshift(pos);
|
|
if (pos.r === sr && pos.c === sc) break;
|
|
pos = prev.get(keyOf(pos.r, pos.c));
|
|
if (!pos) break;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
// ── 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;
|
|
}
|