fertig-classic-games/src/games/labyrinth/LabyrinthLogic.js

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;
}