feat(airport): add inbound dispatch prototype and landing effects
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user