Entry side is the 60-degree wedge of the port, not the cell-edge direction. Levels regenerated; hint line joins the waiting plane to the on-board path.
295 lines
16 KiB
JavaScript
295 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';
|
|
|
|
// Six visual flats of the pointy-top outline (bay normals at -60/0/60/120/180/-120).
|
|
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 };
|
|
}
|
|
// A boundary port belongs to the visual flat whose outward normal is nearest the
|
|
// port's position, in 60° wedges centred on the bay normals. Ties fall on the
|
|
// clockwise edge so a corner never belongs to two sides.
|
|
export function visualSideFromPoint(x, y) {
|
|
// 60° wedges starting at the top (-90°): that boundary belongs to side 0.
|
|
let t = Math.atan2(y, x) + Math.PI / 2;
|
|
t = ((t % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
|
return Math.min(5, Math.floor(t / (Math.PI / 3) + 1e-12));
|
|
}
|
|
export function visualSide(q, r, portDir) {
|
|
const p = position(q, r), n = PORTS[portDir];
|
|
return visualSideFromPoint(p.x + n.x, p.y + n.y);
|
|
}
|
|
|
|
// 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 last = route.steps.at(-1), exitDir = last.exit;
|
|
return { ...route, exitDir, side: visualSide(last.q, last.r, exitDir), 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, exitDir: departure.exitDir,
|
|
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 out = route.exitDir ?? route.side;
|
|
const swept = route.status === 'exit'
|
|
? [...points, { x: points.at(-1).x + PORTS[out].x * 24, y: points.at(-1).y + PORTS[out].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 };
|
|
}
|