Files
HexTrails/labs/airport/core.js
T
toshiocn 0e67c4c188 feat(airport): draw the entry channel to the tray edge
Board-edge runways reach the tray outline with a threshold gate, fixed apron slabs mark each side, and a connected side lights the whole plane-to-stand channel in the neutral hint colour.
2026-09-21 14:38:54 +08:00

281 lines
16 KiB
JavaScript

// Airport rules. Rendering never supplies occupancy or chooses a destination.
import { DIRS, key, opposite, rotateBoard, trace, validateLevel as validateBase, canRotate } from '../../src/core.js';
import { PORTS, position, pathPoints, sweepPath, hexPoints } from '../../src/path-math.js';
// Flat-top edges in screen space. Compass names hid that waiting bays sit on the six sides.
export const SIDES = ['上方', '右上方', '右下方', '下方', '左下方', '左上方'];
export function boardRadius(cells) {
return Math.max(0, ...cells.map(c => (Math.abs(c.q) + Math.abs(c.r) + Math.abs(c.q + c.r)) / 2));
}
export function apronDistance(radius) {
return Math.sqrt(3) * (radius + 0.5) + 1.15;
}
// The cell cluster's outline is pointy-top: its six wide flat sides sit half a
// step (30°) from the port normals, so bays rest on those sides, never on a point.
export function bayNormal(side) {
const n = PORTS[side], length = Math.hypot(n.x, n.y), x = n.x / length, y = n.y / length;
const c = Math.cos(Math.PI / 6), s = Math.sin(Math.PI / 6);
return { x: x * c - y * s, y: x * s + y * c };
}
export function waitingPose(plane, planes, radius) {
const group = planes.filter(p => p.kind === 'arrival' && p.state === 'waiting' && p.side === plane.side);
const at = Math.max(0, group.findIndex(p => p.id === plane.id));
const offset = group.length ? at * 1.12 : 0;
const { x: bx, y: by } = bayNormal(plane.side);
const d = apronDistance(radius);
return { x: bx * (d + offset), y: by * (d + offset), angle: Math.atan2(-by, -bx) + Math.PI / 2 };
}
// White tray outline in board-local units, shared by drawing, the edge-runway
// reach and the runway-read tests. A convex hull, so ray crossings are unambiguous.
export const TRAY_PAD = 1.34;
const hullCache = new WeakMap();
export function trayHull(cells) {
if (hullCache.has(cells)) return hullCache.get(cells);
const points = cells.flatMap(c => { const p = position(c.q, c.r); return hexPoints(p.x, p.y, TRAY_PAD); });
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y), lower = [], upper = [];
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
for (const q of sorted) { while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), q) <= 0) lower.pop(); lower.push(q); }
for (const q of sorted.reverse()) { while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), q) <= 0) upper.pop(); upper.push(q); }
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
hullCache.set(cells, hull);
return hull;
}
// Distance from `from` (inside the hull) to the tray outline along unit `dir`.
export function rimCrossing(hull, from, dir) {
let exit = null;
for (let i = 0; i < hull.length; i++) {
const a = hull[i], b = hull[(i + 1) % hull.length];
const ex = b.x - a.x, ey = b.y - a.y, den = dir.x * ey - dir.y * ex;
if (Math.abs(den) < 1e-12) continue;
const wx = a.x - from.x, wy = a.y - from.y;
const t = (wx * ey - wy * ex) / den, u = (wx * dir.y - wy * dir.x) / den;
if (t > 0 && u >= -1e-9 && u <= 1 + 1e-9 && (exit === null || t > exit)) exit = t;
}
return exit;
}
export const copy = value => structuredClone(value);
const onBoard = plane => plane.state === 'ground' || plane.state === 'parked';
const catalogCache = new WeakMap();
// Runtime layouts are fixed. Editors must invalidate after changing cells/stands.
export function invalidateCatalogs(level) { catalogCache.delete(level); }
export function createState(level) {
return {
orientation: 0,
planes: [
...level.originals.map(p => ({ ...p, kind: 'original', state: 'ground' })),
...level.arrivals.map(p => ({ ...p, kind: 'arrival', state: 'waiting' })),
],
};
}
export function isWon(state) {
const arrivals = state.planes.filter(p => p.kind === 'arrival');
return arrivals.length > 0 && arrivals.every(p => p.state === 'parked');
}
export function parkedCount(state) { return state.planes.filter(p => p.state === 'parked').length; }
export function boardAt(level, orientation) { return rotateBoard({ cells: level.cells }, orientation); }
export function departureGeometry(board, plane) {
const route = trace(board, plane, [], true);
if (route.status !== 'exit') return { ...route, points: pathPoints(route) };
const side = route.steps.at(-1).exit;
return { ...route, side, points: pathPoints(route) };
}
export function catalogs(level) {
if (catalogCache.has(level)) return catalogCache.get(level);
const result = Array.from({ length: 6 }, (_, orientation) => {
const board = boardAt(level, orientation), arrivals = [];
for (const stand of level.stands) for (let dir = 0; dir < 6; dir++) {
const departure = departureGeometry(board, { ...stand, dir, id: `stand:${stand.id}` });
if (departure.status !== 'exit') continue;
arrivals.push({
status: 'land', standId: stand.id, color: stand.color, q: stand.q, r: stand.r,
dir: opposite(dir), side: departure.side,
points: [...departure.points].reverse(), steps: [...departure.steps].reverse(),
signature: `${stand.id}:${dir}:${departure.side}`,
});
}
return { board, arrivals, departures: new Map() };
});
catalogCache.set(level, result);
return result;
}
function obstacles(state, excludeId) {
return state.planes.filter(p => onBoard(p) && p.id !== excludeId)
.map(p => ({ ...position(p.q, p.r), id: p.id }));
}
export function checkLanding(level, state, planeId) {
const plane = state.planes.find(p => p.id === planeId);
if (!plane || plane.state !== 'waiting') return { status: 'invalid' };
const routes = catalogs(level)[state.orientation].arrivals
.filter(r => r.side === plane.side && r.color === plane.color);
if (!routes.length) return { status: 'no-route' };
const occupied = new Set(state.planes.filter(onBoard).map(p => key(p.q, p.r)));
const free = routes.filter(r => !occupied.has(key(r.q, r.r)));
if (!free.length) return { status: 'no-stand' };
const blockers = obstacles(state), legal = [], blocked = [];
for (const route of free) {
const hit = sweepPath(route.points, blockers);
if (!hit) legal.push(route);
else blocked.push({ ...route, status: 'blocked', blocker: hit.blocker, collisionPoint: hit.point, travelPoints: hit.points });
}
if (legal.length > 1) return { status: 'ambiguous', candidates: legal };
if (legal.length === 1) return { ...legal[0], plane: { ...plane } };
// Production levels pass the empty-board uniqueness validator. Never silently
// pick one diagnostic route on an invalid, multiply-connected fixture.
if (blocked.length !== 1) return { status: 'ambiguous', candidates: blocked };
return { ...blocked[0], plane: { ...plane } };
}
export function checkDeparture(level, state, planeId) {
const plane = state.planes.find(p => p.id === planeId);
if (!plane || !onBoard(plane)) return { status: 'invalid' };
const catalog = catalogs(level)[state.orientation];
const cacheKey = `${plane.q},${plane.r},${plane.dir}`;
if (!catalog.departures.has(cacheKey)) catalog.departures.set(cacheKey, departureGeometry(catalog.board, plane));
const route = catalog.departures.get(cacheKey);
const points = route.points;
// Preserve the original long exit-ray collision check, without drawing a
// 24-unit flight across the screen.
const swept = route.status === 'exit'
? [...points, { x: points.at(-1).x + PORTS[route.side].x * 24, y: points.at(-1).y + PORTS[route.side].y * 24 }]
: points;
const hit = sweepPath(swept, obstacles(state, planeId));
if (hit) return { ...route, status: 'blocked', plane: { ...plane }, blocker: hit.blocker, collisionPoint: hit.point, travelPoints: hit.points };
return { ...route, plane: { ...plane } };
}
export function transition(level, state, action) {
if (action.type === 'rotate') {
if (action.direction !== 1 && action.direction !== -1) return { result: { status: 'invalid' }, state };
return { result: { status: 'rotated', direction: action.direction }, state: { ...state, orientation: (state.orientation + action.direction + 6) % 6 } };
}
if (action.type !== 'tap') return { result: { status: 'invalid' }, state };
const plane = state.planes.find(p => p.id === action.id);
const result = plane?.state === 'waiting' ? checkLanding(level, state, action.id) : checkDeparture(level, state, action.id);
if (result.status !== 'land' && result.status !== 'exit') return { result, state };
const updated = { ...plane };
if (result.status === 'land') Object.assign(updated, { state: 'parked', q: result.q, r: result.r, dir: result.dir, standId: result.standId });
else if (plane.kind === 'original') updated.state = 'gone';
else {
updated.state = 'waiting'; updated.side = result.side;
delete updated.q; delete updated.r; delete updated.dir; delete updated.standId;
}
return { result, state: { ...state, planes: state.planes.map(p => p.id === plane.id ? updated : p) } };
}
export class AirportGame {
constructor(level) {
this.level = level; this.state = createState(level); this.moves = level.moveLimit;
this.busy = new Set(); this.rotating = false; this.receipts = new Set(); this.rewardCount = 0;
this.actions = []; this.deadlocked = false;
}
get won() { return isWon(this.state); }
begin(action) {
if (this.won || this.deadlocked) return { status: 'finished' };
if (this.rotating || (action.type === 'rotate' && this.busy.size) || this.busy.has(action.id)) return { status: 'busy' };
const tapped = this.state.planes.find(p => p.id === action.id);
// Bringing a plane in from the apron is free: the budget is spent on turning
// the base and clearing the board, not on the landing itself.
const free = action.type === 'tap' && tapped?.state === 'waiting';
if (!free && this.moves <= 0) return { status: 'out-of-moves' };
const { result, state } = transition(this.level, this.state, action);
if (result.status === 'invalid' || result.status === 'ambiguous') return result;
if (!free) this.moves--;
this.state = state; this.actions.push({ ...action, status: result.status });
if (action.type === 'rotate') this.rotating = true;
else this.busy.add(action.id);
return result;
}
finish(id) { if (id === 'rotation') this.rotating = false; else this.busy.delete(id); }
grantReward(receipt) {
if (receipt?.completed !== true || typeof receipt.id !== 'string' || !receipt.id || this.receipts.has(receipt.id)
|| this.won || this.deadlocked || this.rewardCount >= this.level.reward.maxClaims) return false;
this.receipts.add(receipt.id); this.rewardCount++; this.moves += this.level.reward.steps; return true;
}
}
export function uniquenessIssues(level) {
const issues = [];
for (let orientation = 0; orientation < 6; orientation++) {
const groups = new Map();
for (const route of catalogs(level)[orientation].arrivals) {
const group = `${route.side}:${route.color}`;
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(route.signature);
}
for (const [group, results] of groups) if (results.length > 1) issues.push({ orientation, group, results });
}
return issues;
}
export function validateAirportLevel(level) {
if (!level || !Array.isArray(level.originals) || !Array.isArray(level.arrivals) || !Array.isArray(level.stands)) return ['缺少飞机或停机位数组'];
const errors = validateBase({ version: 1, name: level.name, cells: level.cells, tiles: level.originals, moveLimit: level.moveLimit });
if (errors.length) return errors;
if (!canRotate(level)) errors.push('底盘必须支持六重旋转');
if (!level.arrivals.length) errors.push('至少需要一架来机');
const ids = new Set(level.originals.map(p => p.id)), cells = new Set(level.cells.map(c => key(c.q,c.r))), places = new Set(), standIds = new Set();
const counts = new Map();
for (const p of level.arrivals) {
if (!p || typeof p.id !== 'string' || !p.id || ids.has(p.id)) { errors.push('飞机身份必须唯一'); continue; }
ids.add(p.id);
if (!Number.isInteger(p.side) || p.side < 0 || p.side > 5 || !Number.isInteger(p.color) || p.color < 0 || p.color > 11) errors.push('来机侧或颜色无效');
counts.set(p.color, (counts.get(p.color) || 0) + 1);
}
for (const s of level.stands) {
if (!s || !s.id || standIds.has(s.id) || !cells.has(key(s.q,s.r)) || places.has(key(s.q,s.r))) { errors.push('停机位身份或位置无效'); continue; }
standIds.add(s.id); places.add(key(s.q,s.r));
if (!Number.isInteger(s.color) || s.color < 0 || s.color > 11) errors.push('停机位颜色无效');
counts.set(s.color, (counts.get(s.color) || 0) - 1);
}
if ([...counts.values()].some(n => n !== 0)) errors.push('同色来机与停机位数量必须相等');
if (!Number.isInteger(level.reward?.steps) || level.reward.steps < 1 || !Number.isInteger(level.reward?.maxClaims) || level.reward.maxClaims < 1) errors.push('奖励配置无效');
invalidateCatalogs(level);
if (!errors.length && uniquenessIssues(level).length) errors.push('存在多个合法停靠结果');
return errors;
}
function stateKey(state) {
return state.orientation + '|' + state.planes.map(p => p.state === 'waiting' ? `w${p.side}` : p.state === 'gone' ? 'x' : `${p.q},${p.r},${p.dir}`).join(';');
}
// Uniform-cost (one per action) BFS. Failed taps cannot improve a solution.
// State limits yield "unknown", never an invented deadlock or optimum.
export function solveAirport(level, initial = createState(level), { maxStates = 60000, maxDepth = Infinity, clearFirst = false } = {}) {
const nodes = [{ state: initial, parent: -1, action: null, depth: 0 }], seen = new Set([stateKey(initial)]);
let depthLimited = false;
for (let head = 0; head < nodes.length; head++) {
const node = nodes[head];
if (isWon(node.state)) {
const actions = []; let i = head;
while (nodes[i].parent >= 0) { actions.push(nodes[i].action); i = nodes[i].parent; }
return { status: 'solved', actions: actions.reverse(), minimumActions: node.depth, states: seen.size,
remainingOriginals: node.state.planes.filter(p => p.kind === 'original' && p.state === 'ground').length };
}
if (head >= maxStates) return { status: 'unknown', states: seen.size, reason: 'state-limit' };
if (node.depth >= maxDepth) { depthLimited = true; continue; }
const originalsRemain = node.state.planes.some(p => p.kind === 'original' && p.state === 'ground');
const actions = [{ type: 'rotate', direction: -1 }, { type: 'rotate', direction: 1 },
...node.state.planes.filter(p => p.state !== 'gone' && !(clearFirst && originalsRemain && p.kind === 'arrival'))
.map(p => ({ type: 'tap', id: p.id }))];
for (const action of actions) {
const next = transition(level, node.state, action);
if (!['land','exit','rotated'].includes(next.result.status)) continue;
const signature = stateKey(next.state);
if (seen.has(signature)) continue;
if (seen.size >= maxStates) return { status: 'unknown', states: seen.size, reason: 'state-limit' };
seen.add(signature); nodes.push({ state: next.state, parent: head, action, depth: node.depth + 1 });
}
}
return { status: depthLimited ? 'unknown' : 'deadlock', states: seen.size, reason: depthLimited ? 'depth-limit' : 'exhausted' };
}
export function replay(level, actions) {
const game = new AirportGame(level), events = [];
for (const action of actions) {
const event = game.begin(action); events.push(event);
if (!['land','exit','rotated'].includes(event.status)) throw new Error(`回放失败: ${JSON.stringify(action)} ${event.status}`);
game.finish(action.type === 'rotate' ? 'rotation' : action.id);
}
return { state: game.state, won: game.won, moves: game.moves, events };
}