feat(airport): bind landings to the six visual flats

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.
This commit is contained in:
toshiocn
2026-09-21 17:55:06 +08:00
parent 0e67c4c188
commit c99716b1d5
8 changed files with 1157 additions and 1128 deletions
+20 -6
View File
@@ -2,8 +2,8 @@
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';
// Flat-top edges in screen space. Compass names hid that waiting bays sit on the six sides.
export const SIDES = ['上方', '右上方', '右下方', '下方', '左下方', '左上方'];
// 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));
}
@@ -25,6 +25,19 @@ export function waitingPose(plane, planes, radius) {
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.
@@ -79,8 +92,8 @@ export function boardAt(level, orientation) { return rotateBoard({ cells: level.
export function departureGeometry(board, plane) {
const route = trace(board, plane, [], true);
if (route.status !== 'exit') return { ...route, points: pathPoints(route) };
const side = route.steps.at(-1).exit;
return { ...route, side, 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);
@@ -91,7 +104,7 @@ export function catalogs(level) {
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,
dir: opposite(dir), side: departure.side, exitDir: departure.exitDir,
points: [...departure.points].reverse(), steps: [...departure.steps].reverse(),
signature: `${stand.id}:${dir}:${departure.side}`,
});
@@ -138,8 +151,9 @@ export function checkDeparture(level, state, planeId) {
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[route.side].x * 24, y: points.at(-1).y + PORTS[route.side].y * 24 }]
? [...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 };