Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
306 lines
16 KiB
JavaScript
306 lines
16 KiB
JavaScript
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 > 127) return [...errors, '底板须为 1–127 格'];
|
||
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)) > 6) {
|
||
errors.push('坐标必须为半径 6 内的轴向整数坐标'); 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 > 11) errors.push('颜色必须为 0–11');
|
||
}
|
||
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 search edge costs one turn, but only successful removals spend the move budget.
|
||
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, minimumActions = minimumMoves + node.rotations;
|
||
return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, minimumActions, 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, minimumActions: null, rotations: 0, actions: [], order: [], waves: first.waves, stuck: first.remaining.map(t => t.id), states: visited, exhausted: visited >= maxStates };
|
||
}
|
||
|
||
// Count the paths that successful pieces really travel in the reference plan.
|
||
// Decorative bends in unused cells do not count as route-reading practice.
|
||
export function solutionPathUsage(level, actions) {
|
||
let board = level, remaining = [...level.tiles], straightRun = 0;
|
||
const paths = [];
|
||
for (const action of actions) {
|
||
if (action.type === 'rotate') { board = rotateBoard(board, action.direction); continue; }
|
||
const tile = remaining.find(t => t.id === action.id);
|
||
if (!tile) continue;
|
||
const route = trace(board, tile, remaining);
|
||
if (route.status !== 'exit') continue;
|
||
const bends = route.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length;
|
||
straightRun = bends ? 0 : straightRun + 1;
|
||
paths.push({ id: tile.id, bends, cells: route.steps.length, straightRun });
|
||
remaining = remaining.filter(t => t.id !== tile.id);
|
||
}
|
||
const curvedRemovals = paths.filter(p => p.bends > 0).length;
|
||
return {
|
||
removals: paths.length,
|
||
curvedRemovals,
|
||
straightRemovals: paths.length - curvedRemovals,
|
||
curvedRatio: paths.length ? curvedRemovals / paths.length : 0,
|
||
singleBendRemovals: paths.filter(p => p.bends === 1).length,
|
||
multiBendRemovals: paths.filter(p => p.bends > 1).length,
|
||
totalBends: paths.reduce((sum, p) => sum + p.bends, 0),
|
||
maxBends: Math.max(0, ...paths.map(p => p.bends)),
|
||
maxConsecutiveStraight: Math.max(0, ...paths.map(p => p.straightRun)),
|
||
paths: paths.map(({ straightRun, ...path }) => path),
|
||
};
|
||
}
|
||
|
||
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 routeTurns = routes.map(p => p.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length);
|
||
const totalTurns = routeTurns.reduce((sum, turns) => sum + turns, 0);
|
||
const meanTurns = n ? totalTurns / n : 0, maxTurns = Math.max(0, ...routeTurns);
|
||
const occupiedRoutes = level.tiles.map(t => trace(level, t));
|
||
const free = occupiedRoutes.filter(p => p.status === 'exit').length, freeRatio = n ? free / n : 0;
|
||
const sweptOnlyBlockers = occupiedRoutes.filter((p, i) => p.collision && !routes[i].blockers.includes(p.blocker)).length;
|
||
const dependencyRatio = n > 1 ? Math.max(0, solution.waves.length - 1) / (n - 1) : 0;
|
||
// Cognitive difficulty measures route reading and dependencies, never board area,
|
||
// piece count or a penalty for experimenting. Workload is reported separately.
|
||
const parts = {
|
||
bends: Math.min(1, meanTurns / 4) * 22,
|
||
longestRoute: Math.min(1, maxTurns / 8) * 12,
|
||
dependencies: Math.min(1, dependencyRatio) * 18,
|
||
blocked: (n ? 1 - freeRatio : 0) * 28,
|
||
sweptBlockers: (n ? sweptOnlyBlockers / n : 0) * 12,
|
||
rotation: Math.min(1, solution.rotations / 3) * 8,
|
||
};
|
||
const score = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
|
||
const workload = {
|
||
removals: n,
|
||
rotations: solution.rotations,
|
||
minimumActions: solution.minimumActions,
|
||
routeCells: routes.reduce((sum, p) => sum + p.steps.length, 0),
|
||
// A pacing heuristic for comparing authored levels, not measured playtest time.
|
||
estimatedSeconds: Math.round(n * 3 + totalTurns * 0.75 + solution.rotations * 4),
|
||
};
|
||
return { score, parts, meanTurns, maxTurns, free, freeRatio, sweptOnlyBlockers, dependencyRatio, workload, pathUsage: solutionPathUsage(level, solution.actions), 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++;
|
||
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();
|
||
if (route.status === 'exit') { this.tiles = this.tiles.filter(t => t.id !== id); this.moves--; }
|
||
else this.mistakes++;
|
||
return { ...route, tile: clone(tile) };
|
||
}
|
||
undo() {
|
||
const previous = this.history.pop();
|
||
if (!previous) return false;
|
||
Object.assign(this, previous); return true;
|
||
}
|
||
}
|