Files
HexTrails/labs/inbound/core.js
T

200 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
}));
}