Files
vanta-relay-20260727-095415/game-engine.js
T

214 lines
5.5 KiB
JavaScript

const WALL = '#';
const START = '@';
const GOAL = 'X';
const PAD = /^[a-z]$/;
const GATE = /^[A-Z]$/;
function copyPoint(point) {
return { x: point.x, y: point.y };
}
function pointKey(point) {
return `${point.x},${point.y}`;
}
function samePoint(left, right) {
return left.x === right.x && left.y === right.y;
}
function isPad(tile) {
return PAD.test(tile);
}
function isGate(tile) {
return tile !== GOAL && GATE.test(tile);
}
function isParsedLevel(value) {
return Boolean(value && Array.isArray(value.grid) && value.start && value.goal);
}
export function validateLevel(lines) {
if (!Array.isArray(lines) || lines.length < 3 || lines.some((row) => typeof row !== 'string')) {
throw new Error('A relay level needs at least three string rows.');
}
const width = lines[0].length;
if (width < 3 || lines.some((row) => row.length !== width)) {
throw new Error('Every relay row must use the same width.');
}
let starts = 0;
let goals = 0;
const pads = new Set();
const gates = new Set();
for (const row of lines) {
for (const tile of row) {
if (tile === START) starts += 1;
if (tile === GOAL) goals += 1;
if (isPad(tile)) pads.add(tile.toUpperCase());
if (isGate(tile)) gates.add(tile);
}
}
if (starts !== 1 || goals !== 1) {
throw new Error('Each relay needs exactly one start and one extraction point.');
}
if (pads.size !== gates.size || [...pads].some((id) => !gates.has(id))) {
throw new Error('Signal pads and gates must be paired one-to-one.');
}
}
export function parseLevel(lines) {
validateLevel(lines);
const pads = new Map();
const gates = new Map();
let start;
let goal;
lines.forEach((row, y) => {
[...row].forEach((tile, x) => {
const position = { x, y };
if (tile === START) start = position;
if (tile === GOAL) goal = position;
if (isPad(tile)) pads.set(tile, position);
if (isGate(tile)) gates.set(tile, position);
});
});
return {
grid: lines.map((row) => [...row]),
width: lines[0].length,
height: lines.length,
start,
goal,
pads,
gates,
};
}
function resolveLevel(source) {
return isParsedLevel(source) ? source : parseLevel(source);
}
function padAt(level, position) {
for (const [id, pad] of level.pads) {
if (samePoint(pad, position)) return id;
}
return null;
}
function activeGateSet(state) {
const occupiedByEcho = state.echoes.map((echo) => echo.position);
const active = new Set();
for (const [padId, position] of state.level.pads) {
if (occupiedByEcho.some((entity) => samePoint(entity, position))) {
active.add(padId.toUpperCase());
}
}
return active;
}
export function getActiveGateIds(state) {
return [...activeGateSet(state)].sort();
}
function tileAt(level, position) {
if (position.x < 0 || position.y < 0 || position.x >= level.width || position.y >= level.height) {
return null;
}
return level.grid[position.y][position.x];
}
function eventState(state, lastEvent) {
return { ...state, player: copyPoint(state.player), echoes: state.echoes.map((echo) => ({ ...echo, position: copyPoint(echo.position) })), lastEvent };
}
export function createGame(source, { maxEchoes = 3 } = {}) {
const level = resolveLevel(source);
if (!Number.isInteger(maxEchoes) || maxEchoes < 1) {
throw new Error('Relay capacity must be a positive integer.');
}
return {
level,
player: copyPoint(level.start),
echoes: [],
maxEchoes,
moves: 0,
status: 'playing',
lastEvent: 'ready',
};
}
const VECTORS = {
U: { x: 0, y: -1 },
D: { x: 0, y: 1 },
L: { x: -1, y: 0 },
R: { x: 1, y: 0 },
};
export function move(state, command) {
if (state.status !== 'playing') return eventState(state, 'complete');
const vector = VECTORS[command];
if (!vector) return eventState(state, 'invalid');
const target = { x: state.player.x + vector.x, y: state.player.y + vector.y };
const tile = tileAt(state.level, target);
if (tile === null) return eventState(state, 'boundary');
if (tile === WALL) return eventState(state, 'wall');
if (isGate(tile) && !activeGateSet(state).has(tile)) return eventState(state, 'sealed');
const next = {
...state,
player: target,
echoes: state.echoes.map((echo) => ({ ...echo, position: copyPoint(echo.position) })),
moves: state.moves + 1,
lastEvent: tile === GOAL ? 'extract' : 'move',
status: tile === GOAL ? 'won' : 'playing',
};
return next;
}
export function anchor(state) {
if (state.status !== 'playing') return eventState(state, 'complete');
const padId = padAt(state.level, state.player);
if (!padId || state.echoes.length >= state.maxEchoes) return eventState(state, 'anchor-required');
return {
...state,
player: copyPoint(state.level.start),
echoes: [
...state.echoes.map((echo) => ({ ...echo, position: copyPoint(echo.position) })),
{ id: `E${state.echoes.length + 1}`, padId: padId.toUpperCase(), position: copyPoint(state.player) },
],
moves: state.moves + 1,
lastEvent: 'anchor',
};
}
export function removeLastEcho(state) {
if (state.echoes.length === 0) return eventState(state, 'nothing-to-undo');
return {
...state,
player: copyPoint(state.level.start),
echoes: state.echoes.slice(0, -1).map((echo) => ({ ...echo, position: copyPoint(echo.position) })),
status: 'playing',
lastEvent: 'undo',
};
}
export function reset(state) {
return createGame(state.level, { maxEchoes: state.maxEchoes });
}
export function positionKey(position) {
return pointKey(position);
}