feat(airport): add inbound dispatch prototype and landing effects

This commit is contained in:
toshiocn
2026-10-26 16:11:11 +08:00
parent f40efb8ba9
commit 10962babe3
10 changed files with 1581 additions and 39 deletions
+199
View File
@@ -0,0 +1,199 @@
// Inbound dial. A turn moves a notch on a 6-mark ring. It does not slide tracks
// under aircraft, and it does not call rotateBoard. Mouths are fixed world
// objects: open iff mouth.tick === dial.
import { opposite, trace } from '../../src/core.js';
import { pathPoints, position, sweepPath, PORTS } from '../../src/path-math.js';
import { visualSide, SIDES } from '../airport/core.js';
export { SIDES };
export const COLORS = {
0: { name: '草莓', color: '#EE5398' },
8: { name: '可可', color: '#B5806A' },
};
const boardOf = level => ({ cells: level.cells });
const clone = value => structuredClone(value);
function departure(level, q, r, dir) {
const route = trace(boardOf(level), { id: 'probe', q, r, dir }, [], true);
if (route.status !== 'exit') return { status: route.status, route, points: pathPoints(route) };
const last = route.steps.at(-1);
return { status: 'exit', route, points: pathPoints(route), side: visualSide(last.q, last.r, last.exit), exitDir: last.exit };
}
export function catalog(level) {
return level.mouths.map(mouth => {
const stand = level.stands.find(item => item.id === mouth.standId);
const inbound = departure(level, stand.q, stand.r, mouth.departDir);
const parkedDir = opposite(mouth.departDir);
const outbound = departure(level, stand.q, stand.r, parkedDir);
return {
...mouth,
stand,
side: inbound.side,
sideName: SIDES[inbound.side],
parkedDir,
exitSide: outbound.side,
exitSideName: SIDES[outbound.side],
arrivalPoints: [...inbound.points].reverse(),
departPoints: outbound.points,
cells: inbound.route.steps.length,
};
});
}
export function createState(level) {
return {
dial: level.dial,
stars: 0,
planes: level.arrivals.map(plane => ({
id: plane.id, color: plane.color, side: plane.side, state: 'waiting',
q: null, r: null, dir: null, standId: null, mouthId: null,
})),
};
}
const parked = state => state.planes.filter(plane => plane.state === 'parked');
const obstacles = planes => planes.map(plane => ({ ...position(plane.q, plane.r), id: plane.id }));
export function checkLanding(level, state, planeId) {
const plane = state.planes.find(item => item.id === planeId);
if (!plane || plane.state !== 'waiting') return { status: 'invalid' };
const mouths = catalog(level).filter(mouth => mouth.tick === state.dial && mouth.side === plane.side && mouth.stand.color === plane.color);
if (!mouths.length) return { status: 'no-route', plane };
const taken = new Set(parked(state).map(item => `${item.q},${item.r}`));
const free = mouths.filter(mouth => !taken.has(`${mouth.stand.q},${mouth.stand.r}`));
if (!free.length) return { status: 'no-stand', plane };
const blocks = obstacles(parked(state));
const legal = [], blocked = [];
for (const mouth of free) {
const hit = sweepPath(mouth.arrivalPoints, blocks);
if (!hit) legal.push(mouth);
else blocked.push({ mouth, blocker: hit.blocker, point: hit.point, travel: hit.points });
}
if (legal.length > 1) return { status: 'ambiguous', plane, candidates: legal };
if (legal.length === 1) return { status: 'land', plane, mouth: legal[0] };
if (blocked.length === 1) return { status: 'blocked', plane, ...blocked[0] };
return { status: 'ambiguous', plane, candidates: blocked };
}
export function checkRelocate(level, state, planeId) {
const plane = state.planes.find(item => item.id === planeId);
if (!plane || plane.state !== 'parked') return { status: 'invalid' };
const outbound = departure(level, plane.q, plane.r, plane.dir);
if (outbound.status !== 'exit') return { status: 'no-route', plane };
const end = outbound.points.at(-1), port = PORTS[outbound.exitDir];
const swept = [...outbound.points, { x: end.x + port.x * 4, y: end.y + port.y * 4 }];
const hit = sweepPath(swept, obstacles(parked(state).filter(item => item.id !== plane.id)));
if (hit) return { status: 'blocked', plane, blocker: hit.blocker, point: hit.point, travel: hit.points, side: outbound.side };
return { status: 'relocate', plane, side: outbound.side, sideName: SIDES[outbound.side], points: outbound.points };
}
function applyLanding(state, check) {
const next = clone(state);
const plane = next.planes.find(item => item.id === check.plane.id);
plane.state = 'parked';
plane.q = check.mouth.stand.q;
plane.r = check.mouth.stand.r;
plane.dir = check.mouth.parkedDir;
plane.standId = check.mouth.standId;
plane.mouthId = check.mouth.id;
plane.side = null;
next.stars += 1;
return next;
}
function applyRelocate(state, check) {
const next = clone(state);
const plane = next.planes.find(item => item.id === check.plane.id);
plane.state = 'waiting';
plane.side = check.side;
plane.q = plane.r = plane.dir = plane.standId = plane.mouthId = null;
next.stars += 1;
return next;
}
export function transition(level, state, action) {
if (action.type === 'confirm') {
const dial = action.dial, steps = action.steps;
if (!Number.isInteger(dial) || dial < 0 || dial > 5 || !Number.isInteger(steps) || steps < 1) return { commit: false, result: { status: 'invalid' } };
const next = clone(state);
next.dial = dial;
next.stars += steps;
return { commit: true, state: next, result: { status: 'confirmed', dial, steps } };
}
if (action.type === 'land') {
const check = checkLanding(level, state, action.id);
if (check.status !== 'land') return { commit: false, result: check };
return { commit: true, state: applyLanding(state, check), result: check };
}
if (action.type === 'relocate') {
const check = checkRelocate(level, state, action.id);
if (check.status !== 'relocate') return { commit: false, result: check };
return { commit: true, state: applyRelocate(state, check), result: check };
}
return { commit: false, result: { status: 'invalid' } };
}
export function isWon(state) {
return state.planes.length > 0 && state.planes.every(plane => plane.state === 'parked');
}
export class Inbound {
constructor(level) {
this.level = level;
this.state = createState(level);
this.history = [];
}
get won() { return isWon(this.state); }
act(action) {
if (action.type === 'undo') {
if (!this.history.length) return { status: 'invalid' };
this.state = this.history.pop();
return { status: 'undone' };
}
const outcome = transition(this.level, this.state, action);
if (!outcome.commit) return outcome.result;
this.history.push(clone(this.state));
this.state = outcome.state;
return { ...outcome.result, won: this.won, stars: this.state.stars };
}
}
export function validateInbound(level) {
const issues = [];
const mouths = catalog(level);
for (const mouth of mouths) {
if (mouth.side === undefined) issues.push(`${mouth.id} 没有出口`);
if (mouth.cells < 1 || mouth.cells > 5) issues.push(`${mouth.id} 路径 ${mouth.cells} 格,超出 1–5`);
}
for (let dial = 0; dial < 6; dial++) {
const groups = new Map();
for (const mouth of mouths.filter(item => item.tick === dial)) {
const group = `${mouth.side}:${mouth.stand.color}`;
groups.set(group, (groups.get(group) || 0) + 1);
}
for (const [group, count] of groups) if (count > 1) issues.push(`朝向 ${dial} ${group} 有 ${count} 个口`);
}
const seen = new Set();
for (const mouth of mouths) {
const signature = `${mouth.side}:${mouth.tick}:${mouth.standId}`;
if (seen.has(signature)) issues.push(`重复口 ${signature}`);
seen.add(signature);
}
return issues;
}
// What a reader can count before touching preview. Independent of dial mutation.
export function reading(level, dial) {
return catalog(level).map(mouth => ({
id: mouth.id,
side: mouth.sideName,
tick: mouth.tick,
open: mouth.tick === dial,
right: (mouth.tick - dial + 6) % 6,
left: (dial - mouth.tick + 6) % 6,
exitSide: mouth.exitSideName,
cells: mouth.cells,
}));
}