// 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 }; }