Files
HexTrails/src/core.js
T

260 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { position, PORTS, pathPoints, sweepPath } from './path-math.js';
export { position } from './path-math.js';
// Engine-independent model. Coordinates: flat-top axial hexagons, y points down.
export const DIRS = [[0, -1], [1, -1], [1, 0], [0, 1], [-1, 1], [-1, 0]];
export const key = (q, r) => `${q},${r}`;
export const clone = value => JSON.parse(JSON.stringify(value));
export const opposite = d => (d + 3) % 6;
export function hexagon(radius) {
const cells = [];
for (let q = -radius; q <= radius; q++) {
for (let r = Math.max(-radius, -q - radius); r <= Math.min(radius, -q + radius); r++) {
cells.push({ q, r, tracks: [3, 4, 5, 0, 1, 2] });
}
}
return cells;
}
export function random(seed) {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function randomTracks(rng) {
const ports = [0, 1, 2, 3, 4, 5], result = [];
while (ports.length) {
const a = ports.shift(), b = ports.splice(Math.floor(rng() * ports.length), 1)[0];
result[a] = b;
result[b] = a;
}
return result;
}
export function rotateTracks(tracks) {
const next = [];
tracks.forEach((p, i) => { next[(i + 1) % 6] = (p + 1) % 6; });
return next;
}
export function validateLevel(input) {
const errors = [];
if (!input || typeof input !== 'object') return ['关卡必须是 JSON 对象'];
if (input.rotationEnabled !== undefined && typeof input.rotationEnabled !== 'boolean') errors.push('rotationEnabled 必须是布尔值');
if (input.version !== 1) errors.push('version 必须为 1');
if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 60) errors.push('名称须为 1–60 个字符');
if (!Array.isArray(input.cells) || input.cells.length < 1 || input.cells.length > 61) return [...errors, '底板须为 1–61 格'];
if (!Array.isArray(input.tiles) || input.tiles.length > input.cells.length) return [...errors, '棋子数量不能超过底板'];
const coords = new Set();
for (const c of input.cells) {
if (!c || !Number.isInteger(c.q) || !Number.isInteger(c.r) || Math.max(Math.abs(c.q), Math.abs(c.r), Math.abs(c.q + c.r)) > 4) {
errors.push('坐标必须为半径 4 内的轴向整数坐标'); continue;
}
const k = key(c.q, c.r);
if (coords.has(k)) errors.push(`底板重复:${k}`);
coords.add(k);
if (!Array.isArray(c.tracks) || c.tracks.length !== 6 || c.tracks.some((v, i) => !Number.isInteger(v) || v < 0 || v > 5 || v === i || c.tracks[v] !== i)) errors.push(`轨道 ${k} 必须两两配对六个端口`);
}
const occupied = new Set(), ids = new Set();
for (const t of input.tiles) {
if (!t || !coords.has(key(t.q, t.r))) { errors.push('棋子必须位于底板上'); continue; }
const k = key(t.q, t.r);
if (occupied.has(k)) errors.push(`棋子重叠:${k}`);
occupied.add(k);
if (typeof t.id !== 'string' || !t.id || ids.has(t.id)) errors.push('棋子 id 须为唯一非空字符串');
ids.add(t.id);
if (!Number.isInteger(t.dir) || t.dir < 0 || t.dir > 5) errors.push('方向必须为 0–5');
if (!Number.isInteger(t.color) || t.color < 0 || t.color > 3) errors.push('颜色必须为 0–3');
}
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('步数须为 1–999');
return [...new Set(errors)];
}
// At a crossing, tracks do not connect. Entering an occupied cell blocks movement.
export function trace(level, tile, tiles = level.tiles, ignoreOccupancy = false) {
const finish = route => {
if (ignoreOccupancy) return route;
const obstacles = tiles.filter(t => t.id !== tile.id).map(t => ({ ...position(t.q,t.r), id:t.id }));
const points=pathPoints(route);
// Continue the exit ray beyond holes/irregular edges, so escape animation cannot pass through a farther piece.
if(route.status==='exit'){const last=points[points.length-1],port=PORTS[route.steps[route.steps.length-1].exit];points.push({x:last.x+port.x*24,y:last.y+port.y*24});}
const collision = sweepPath(points, obstacles);
if (!collision) return route;
return { ...route, status:'blocked', blocker:collision.blocker, collision:true, collisionPoint:collision.point, travelPoints:collision.points };
};
const board = new Map(level.cells.map(c => [key(c.q, c.r), c]));
const occupied = new Map(tiles.filter(t => t.id !== tile.id).map(t => [key(t.q, t.r), t]));
let q = tile.q, r = tile.r, exit = tile.dir;
const steps = [{ q, r, enter: null, exit }], visited = new Set([`${q},${r},${exit}`]);
const blockers = new Set();
for (let n = 0; n < level.cells.length * 6 + 1; n++) {
const [dq, dr] = DIRS[exit]; q += dq; r += dr;
const cell = board.get(key(q, r));
if (!cell) return finish({ status: 'exit', steps, blockers: [...blockers], end: { q, r } });
const occupant = occupied.get(key(q, r));
if (occupant) {
blockers.add(occupant.id);
if (!ignoreOccupancy) return finish({ status: 'blocked', steps, blocker: occupant.id, blockers: [...blockers], end: { q, r } });
}
const enter = opposite(exit); exit = cell.tracks[enter];
steps.push({ q, r, enter, exit });
const state = `${q},${r},${exit}`;
if (visited.has(state)) return finish({ status: 'loop', steps, blockers: [...blockers] });
visited.add(state);
}
return finish({ status: 'loop', steps, blockers: [...blockers] });
}
export function available(level, tiles = level.tiles) {
return tiles.filter(t => trace(level, t, tiles).status === 'exit');
}
export function solve(level) {
let remaining = [...level.tiles];
const order = [], waves = [];
while (remaining.length) {
const free = available(level, remaining);
if (!free.length) break;
const ids = new Set(free.map(t => t.id));
waves.push(free.map(t => t.id)); order.push(...ids);
remaining = remaining.filter(t => !ids.has(t.id));
}
return { solvable: remaining.length === 0 && level.tiles.length > 0 && level.moveLimit >= level.tiles.length, order, waves, stuck: remaining.map(t => t.id) };
}
export function rotateBoard(level, turns = 1) {
let cells = clone(level.cells);
const count = ((turns % 6) + 6) % 6;
for (let i = 0; i < count; i++) {
cells = cells.map(c => ({ ...c, q: -c.r || 0, r: c.q + c.r || 0, tracks: rotateTracks(c.tracks) }));
}
return { ...level, cells };
}
export function canRotate(level) {
const coords = new Set(level.cells.map(c => key(c.q, c.r)));
return rotateBoard(level).cells.every(c => coords.has(key(c.q, c.r)));
}
// Rotation search: removing an available piece is always safe (occupancy only decreases).
// Exhaust available pieces at each orientation, then breadth-first search CW/CCW turns.
// Each edge costs one turn; total solution cost is tile count + shortest turn count.
export function solveRotations(level, maxStates = 20000) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const rotating = canRotate(level) && level.rotationEnabled !== false;
function drain(orientation, remaining, actions, waves) {
let pieces = remaining;
const nextActions = [...actions], nextWaves = [...waves];
while (pieces.length) {
const free = available(boards[orientation], pieces);
if (!free.length) break;
const ids = new Set(free.map(t => t.id));
nextActions.push(...free.map(t => ({ type: 'tap', id: t.id })));
nextWaves.push([...ids]);
pieces = pieces.filter(t => !ids.has(t.id));
}
return { orientation, remaining: pieces, actions: nextActions, waves: nextWaves };
}
const first = drain(0, [...level.tiles], [], []);
const queue = [{ ...first, rotations: 0 }], seen = new Set();
let visited = 0;
for (let head = 0; head < queue.length && visited < maxStates; head++) {
const node = queue[head];
const signature = node.orientation + ':' + JSON.stringify(node.remaining.map(t => t.id).sort());
if (seen.has(signature)) continue;
seen.add(signature); visited++;
if (!node.remaining.length && level.tiles.length) {
const minimumMoves = level.tiles.length + node.rotations;
return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, rotations: node.rotations, actions: node.actions, order: node.actions.filter(a => a.type === 'tap').map(a => a.id), waves: node.waves, stuck: [], states: visited, exhausted: false };
}
if (!rotating) continue;
for (const direction of [1, -1]) {
const orientation = (node.orientation + direction + 6) % 6;
const child = drain(orientation, node.remaining, [...node.actions, { type: 'rotate', direction }], node.waves);
const signature = child.orientation + ':' + JSON.stringify(child.remaining.map(t => t.id).sort());
if (!seen.has(signature)) queue.push({ ...child, rotations: node.rotations + 1 });
}
}
return { solvable: false, reachable: false, minimumMoves: null, rotations: 0, actions: [], order: [], waves: first.waves, stuck: first.remaining.map(t => t.id), states: visited, exhausted: visited >= maxStates };
}
export function analyze(level) {
const solution = solveRotations(level), n = level.tiles.length;
const routes = level.tiles.map(t => trace(level, t, level.tiles, true));
const meanTurns = n ? routes.reduce((sum, p) => sum + p.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length, 0) / n : 0;
const free = available(level).length;
const parts = {
scale: Math.min(1, n / 37) * 15,
turns: Math.min(1, meanTurns / 5) * 20,
depth: Math.min(1, Math.max(0, solution.waves.length - 1) / 9) * 20,
blocked: (n ? 1 - free / n : 0) * 15,
rotation: Math.min(1, solution.rotations / 4) * 20,
precision: (n ? Math.min(1, Math.max(0, 1 - (level.moveLimit - (solution.minimumMoves ?? n)) / Math.max(1, n * 0.4))) : 0) * 10,
};
const score = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
return { score, parts, meanTurns, free, depth: solution.waves.length, ...solution, label: score < 30 ? '轻松' : score < 50 ? '进阶' : score < 70 ? '烧脑' : '挑战' };
}
export function generateLevel(seed, radius = 3, target = 25, complexity = 0.7) {
const rng = random(seed);
const level = { version: 1, id: `seed-${seed}`, name: '新生轨迹', seed, cells: hexagon(radius), tiles: [], moveLimit: target + 4 };
level.cells.forEach(c => { c.tracks = randomTracks(rng); });
// Reverse construction: each inserted tile can leave before every earlier tile.
for (let i = 0; i < target; i++) {
const occupied = new Set(level.tiles.map(t => key(t.q, t.r))), candidates = [];
for (const c of level.cells) {
if (occupied.has(key(c.q, c.r))) continue;
for (let dir = 0; dir < 6; dir++) {
const t = { id: `t${i}`, q: c.q, r: c.r, dir, color: Math.floor(rng() * 4) };
const p = trace(level, t, [...level.tiles, t]);
if (p.status === 'exit') candidates.push({ t, weight: rng() + p.steps.length * complexity });
}
}
if (!candidates.length) break;
candidates.sort((a, b) => b.weight - a.weight);
const pick = Math.floor(rng() * Math.min(6, candidates.length));
level.tiles.push(candidates[pick].t);
}
level.moveLimit = level.tiles.length + Math.round(level.tiles.length * (1 - complexity) * 0.4) + 1;
return level;
}
export class Game {
constructor(level) {
this.initialLevel = clone(level); this.level = clone(level); this.tiles = clone(level.tiles);
this.moves = level.moveLimit; this.history = []; this.mistakes = 0; this.orientation = 0; this.rotations = 0;
}
get won() { return this.tiles.length === 0; }
get lost() {
if (this.won) return false;
if (this.moves <= 0) return true;
// Lack of immediate exits is not failure: the board may be rotated to open a route.
if (canRotate(this.level) && this.level.rotationEnabled !== false) return false;
return available(this.level, this.tiles).length === 0;
}
snapshot() {
this.history.push({ level: clone(this.level), tiles: clone(this.tiles), moves: this.moves, mistakes: this.mistakes, orientation: this.orientation, rotations: this.rotations });
}
rotate(direction = 1) {
if (direction !== 1 && direction !== -1) return { status: 'invalid-direction' };
if (this.won || this.lost) return { status: 'finished' };
if (!canRotate(this.level) || this.level.rotationEnabled === false) return { status: 'rotation-disabled' };
this.snapshot();
this.level = rotateBoard(this.level, direction);
this.orientation = (this.orientation + direction + 6) % 6;
this.rotations++; this.moves--;
return { status: 'rotated', direction, orientation: this.orientation };
}
tap(id) {
if (this.won || this.lost) return { status: 'finished' };
const tile = this.tiles.find(t => t.id === id);
if (!tile) return { status: 'missing' };
const route = trace(this.level, tile, this.tiles);
this.snapshot(); this.moves--;
if (route.status === 'exit') this.tiles = this.tiles.filter(t => t.id !== id);
else this.mistakes++;
return { ...route, tile: clone(tile) };
}
undo() {
const previous = this.history.pop();
if (!previous) return false;
Object.assign(this, previous); return true;
}
}