feat(airport): draw the entry channel to the tray edge
Board-edge runways reach the tray outline with a threshold gate, fixed apron slabs mark each side, and a connected side lights the whole plane-to-stand channel in the neutral hint colour.
This commit is contained in:
@@ -169,6 +169,13 @@
|
|||||||
|
|
||||||
彩色飞机和机位是主要阅读对象,装饰不得遮挡轨道、机头、机位和碰撞边界。正顶视飞机保留可感知的六角占位边界。通路不着匹配色。
|
彩色飞机和机位是主要阅读对象,装饰不得遮挡轨道、机头、机位和碰撞边界。正顶视飞机保留可感知的六角占位边界。通路不着匹配色。
|
||||||
|
|
||||||
|
盘边跑道与停机坪航线(2026-09-21):
|
||||||
|
|
||||||
|
- 每条板边轨道不再在糖砖内收口,而是沿端口法线延伸到托盘外缘,并在开口处画一道垂直门槛,让"开到盘边的出口"和"中途断掉的线"一眼可分。
|
||||||
|
- 托盘外、六个侧的法线方向画固定浅色停机坪航线,等待机停在其上;航线不随底盘旋转,跑道与门槛随底盘旋转。
|
||||||
|
- 当某架来机的当前侧在当前朝向下接得上同色机位时,用中性强调色(马卡龙 `hint`,不是飞机或机位颜色)把"飞机 → 盘边开口 → 机位"整条通道点亮,并在开口处加一圈标记、保留进场箭头;被挡时阻挡物仍用 `blocked` 标红。常显跑道负责说明"这里有没有开口",高亮负责说明"这架能不能进"。
|
||||||
|
- 停机坪机位仍按其原有平边法线摆放(与入口法线差半步),不做位移;错位由高亮通道跨越,不靠移动机位。
|
||||||
|
|
||||||
### 5.2 颜色与身份
|
### 5.2 颜色与身份
|
||||||
|
|
||||||
机位具有彩色边框和专用停靠标识;已停靠来机还需独立完成标记,不只凭朝内朝外区分。本稿默认使用小型 P 标记,正式素材沿用统一图标语言。
|
机位具有彩色边框和专用停靠标识;已停靠来机还需独立完成标记,不只凭朝内朝外区分。本稿默认使用小型 P 标记,正式素材沿用统一图标语言。
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
点击地面飞机起飞,点击场外停机坪上的飞机降落;左右转每次 60°。成功、失败与旋转都消耗一次。灰色轨道不分颜色;来机匹配彩色专用机位。P 标记表示已停好的来机。它再次起飞后,停到实际出口那条边的停机坪,仍需重新停好。
|
点击地面飞机起飞,点击场外停机坪上的飞机降落;左右转每次 60°。成功、失败与旋转都消耗一次。灰色轨道不分颜色;来机匹配彩色专用机位。P 标记表示已停好的来机。它再次起飞后,停到实际出口那条边的停机坪,仍需重新停好。
|
||||||
|
|
||||||
|
板边轨道一直画到托盘外缘并带一道门槛,托盘外的浅色停机坪航线固定不动。当等待机在当前朝向下接得上同色机位时,整条"飞机 → 盘边开口 → 机位"的通道用中性强调色点亮;旋转时通道会亮灭,据此判断能否进场。
|
||||||
|
|
||||||
## 关卡证据
|
## 关卡证据
|
||||||
|
|
||||||
所有数据冻结在 `levels.js`。`solution` 是最优免费解,`experience` 是制作意图回放;第三关后者演示来机阻挡及再次起飞换侧。
|
所有数据冻结在 `levels.js`。`solution` 是最优免费解,`experience` 是制作意图回放;第三关后者演示来机阻挡及再次起飞换侧。
|
||||||
|
|||||||
+40
-12
@@ -1,4 +1,4 @@
|
|||||||
import { AirportGame, SIDES, boardAt, catalogs, parkedCount, copy, boardRadius, waitingPose, apronDistance } from './core.js';
|
import { AirportGame, SIDES, boardAt, catalogs, parkedCount, copy, boardRadius, waitingPose, apronDistance, trayHull, rimCrossing, bayNormal, TRAY_PAD } from './core.js';
|
||||||
import { LEVELS } from './levels.js';
|
import { LEVELS } from './levels.js';
|
||||||
import { requestReward } from './rewards.js';
|
import { requestReward } from './rewards.js';
|
||||||
import { position, PORTS, hexPoints, trackPoints, sweepPath } from '../../src/path-math.js';
|
import { position, PORTS, hexPoints, trackPoints, sweepPath } from '../../src/path-math.js';
|
||||||
@@ -22,7 +22,7 @@ try { const value = JSON.parse(localStorage.getItem(STORAGE)); if (value && Arra
|
|||||||
function persist() { try { localStorage.setItem(STORAGE, JSON.stringify(saved)); } catch {} }
|
function persist() { try { localStorage.setItem(STORAGE, JSON.stringify(saved)); } catch {} }
|
||||||
let index = Number.isInteger(saved.last) && saved.last >= 0 && saved.last < LEVELS.length ? saved.last : 0;
|
let index = Number.isInteger(saved.last) && saved.last >= 0 && saved.last < LEVELS.length ? saved.last : 0;
|
||||||
let game, motions = [], rotation = null, feedback = null, queue = [], requestId = 0, diagnosis = null, generation = 0;
|
let game, motions = [], rotation = null, feedback = null, queue = [], requestId = 0, diagnosis = null, generation = 0;
|
||||||
const HINT_LEVELS = 10; // tutorial levels auto-bring in planes and show the approach hint
|
const GATE_OVERSHOOT = 0.12, GATE_HALF = 0.16, APRON_WIDTH = 1.9;
|
||||||
let view = { width: 390, height: 460, size: 28, ox: 195, oy: 230 }, audioContext;
|
let view = { width: 390, height: 460, size: 28, ox: 195, oy: 230 }, audioContext;
|
||||||
const worker = new Worker(new URL('./solver-worker.js', import.meta.url), { type: 'module' });
|
const worker = new Worker(new URL('./solver-worker.js', import.meta.url), { type: 'module' });
|
||||||
worker.onmessage = ({ data }) => {
|
worker.onmessage = ({ data }) => {
|
||||||
@@ -69,7 +69,7 @@ function screen(point) { return { x: view.ox + point.x * view.size, y: view.oy +
|
|||||||
// exists (whether or not a plane currently blocks it). Used to prompt the player
|
// exists (whether or not a plane currently blocks it). Used to prompt the player
|
||||||
// and to draw the approach guide; the player still performs the landing.
|
// and to draw the approach guide; the player still performs the landing.
|
||||||
function approachHints() {
|
function approachHints() {
|
||||||
if (index >= HINT_LEVELS || game.won || game.deadlocked) return [];
|
if (game.won || game.deadlocked) return [];
|
||||||
const routes = catalogs(game.level)[game.state.orientation].arrivals;
|
const routes = catalogs(game.level)[game.state.orientation].arrivals;
|
||||||
const blockers = game.state.planes.filter(p => (p.state === 'ground' || p.state === 'parked'))
|
const blockers = game.state.planes.filter(p => (p.state === 'ground' || p.state === 'parked'))
|
||||||
.map(p => ({ ...position(p.q, p.r), id: p.id }));
|
.map(p => ({ ...position(p.q, p.r), id: p.id }));
|
||||||
@@ -79,7 +79,7 @@ function approachHints() {
|
|||||||
const route = routes.find(r => r.side === plane.side && r.color === plane.color);
|
const route = routes.find(r => r.side === plane.side && r.color === plane.color);
|
||||||
if (!route) continue; // no entrance / no connected runway from this edge
|
if (!route) continue; // no entrance / no connected runway from this edge
|
||||||
const hit = sweepPath(route.points, blockers.filter(b => b.id !== plane.id));
|
const hit = sweepPath(route.points, blockers.filter(b => b.id !== plane.id));
|
||||||
hints.push({ plane, from: planeLocation(plane), points: [planeLocation(plane), ...route.points.slice(1)], color: pigments[plane.color].color, blocker: hit ? hit.blocker : null });
|
hints.push({ plane, from: planeLocation(plane), points: [planeLocation(plane), ...route.points], blocker: hit ? hit.blocker : null });
|
||||||
}
|
}
|
||||||
return hints;
|
return hints;
|
||||||
}
|
}
|
||||||
@@ -251,9 +251,20 @@ function draw(now){
|
|||||||
const dpr=Math.min(devicePixelRatio||1,2);ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,view.width,view.height);
|
const dpr=Math.min(devicePixelRatio||1,2);ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,view.width,view.height);
|
||||||
ctx.translate(view.ox,view.oy);ctx.scale(view.size,view.size);
|
ctx.translate(view.ox,view.oy);ctx.scale(view.size,view.size);
|
||||||
const board=rotation?rotation.old:catalogs(game.level)[game.state.orientation].board;
|
const board=rotation?rotation.old:catalogs(game.level)[game.state.orientation].board;
|
||||||
|
const hullPts=trayHull(board.cells);
|
||||||
|
// Fixed apron slabs sit under everything: waiting planes park on a soft lane outside
|
||||||
|
// the tray, on each side's flat-side normal, so a plane's side is readable before any
|
||||||
|
// hint appears. They never turn with the base.
|
||||||
|
const radius=boardRadius(game.level.cells),apronR=apronDistance(radius);
|
||||||
|
for(let side=0;side<6;side++){
|
||||||
|
const n=bayNormal(side),count=Math.max(1,game.level.arrivals.filter(a=>a.side===side).length);
|
||||||
|
const inner=rimCrossing(hullPts,{x:0,y:0},n)+0.20,outer=apronR+(count-1)*1.12+0.85;
|
||||||
|
if(!(outer>inner))continue;
|
||||||
|
line([{x:n.x*inner,y:n.y*inner},{x:n.x*outer,y:n.y*outer}],materials.shadowSoft,APRON_WIDTH);
|
||||||
|
}
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if(rotation){const p=Math.min(1,(now-rotation.start)/rotation.duration),u=p*p*(3-2*p);ctx.rotate(rotation.direction*Math.PI/3*u);}
|
if(rotation){const p=Math.min(1,(now-rotation.start)/rotation.duration),u=p*p*(3-2*p);ctx.rotate(rotation.direction*Math.PI/3*u);}
|
||||||
const tray=grow=>roundedPoly(hull(board.cells.flatMap(c=>{const p=position(c.q,c.r);return hexPoints(p.x,p.y,1.34+grow);})),.9);
|
const tray=grow=>roundedPoly(hull(board.cells.flatMap(c=>{const p=position(c.q,c.r);return hexPoints(p.x,p.y,TRAY_PAD+grow);})),.9);
|
||||||
polygon(tray(.06).map(q=>({x:q.x,y:q.y+.20})),materials.shadowSoft);
|
polygon(tray(.06).map(q=>({x:q.x,y:q.y+.20})),materials.shadowSoft);
|
||||||
polygon(tray(0).map(q=>({x:q.x,y:q.y+.10})),materials.shadow);
|
polygon(tray(0).map(q=>({x:q.x,y:q.y+.10})),materials.shadow);
|
||||||
polygon(tray(0),materials.tray);
|
polygon(tray(0),materials.tray);
|
||||||
@@ -263,6 +274,14 @@ function draw(now){
|
|||||||
const cellKeys=new Set(board.cells.map(c=>key(c.q,c.r)));
|
const cellKeys=new Set(board.cells.map(c=>key(c.q,c.r)));
|
||||||
const trackWidth=.11,portRadius=Math.hypot(PORTS[0].x,PORTS[0].y),visualRadius=Math.max(0,.90*FLAT-trackWidth/2-.01);
|
const trackWidth=.11,portRadius=Math.hypot(PORTS[0].x,PORTS[0].y),visualRadius=Math.max(0,.90*FLAT-trackWidth/2-.01);
|
||||||
const exterior=(cell,d)=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
|
const exterior=(cell,d)=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
|
||||||
|
// A boundary runway then reaches the tray outline instead of stopping inside the brick,
|
||||||
|
// so an off-board plane can be read as joining one specific opening. The reach rotates
|
||||||
|
// with the base, exactly like the track it belongs to.
|
||||||
|
const gateReach=(point,dir)=>{
|
||||||
|
const n=PORTS[dir],l=Math.hypot(n.x,n.y),ux=n.x/l,uy=n.y/l,t=rimCrossing(hullPts,point,{x:ux,y:uy});
|
||||||
|
return t===null?null:{x:point.x+ux*(t+GATE_OVERSHOOT),y:point.y+uy*(t+GATE_OVERSHOOT),ux,uy,dir};
|
||||||
|
};
|
||||||
|
const gates=[];
|
||||||
for(const cell of board.cells){
|
for(const cell of board.cells){
|
||||||
const p=position(cell.q,cell.r);
|
const p=position(cell.q,cell.r);
|
||||||
for(let d=0;d<6;d++){
|
for(let d=0;d<6;d++){
|
||||||
@@ -272,9 +291,13 @@ function draw(now){
|
|||||||
const dx0=exterior(cell,d)?(points[0].x-p.x)*shrink:0,dy0=exterior(cell,d)?(points[0].y-p.y)*shrink:0;
|
const dx0=exterior(cell,d)?(points[0].x-p.x)*shrink:0,dy0=exterior(cell,d)?(points[0].y-p.y)*shrink:0;
|
||||||
const dx1=exterior(cell,b)?(last.x-p.x)*shrink:0,dy1=exterior(cell,b)?(last.y-p.y)*shrink:0;
|
const dx1=exterior(cell,b)?(last.x-p.x)*shrink:0,dy1=exterior(cell,b)?(last.y-p.y)*shrink:0;
|
||||||
points.forEach((q,i)=>{const t=i/(points.length-1),u=1-t;q.x-=u*u*u*dx0+t*t*t*dx1;q.y-=u*u*u*dy0+t*t*t*dy1;});
|
points.forEach((q,i)=>{const t=i/(points.length-1),u=1-t;q.x-=u*u*u*dx0+t*t*t*dx1;q.y-=u*u*u*dy0+t*t*t*dy1;});
|
||||||
|
if(exterior(cell,d)){const g=gateReach(points[0],d);if(g){points.unshift(g);gates.push(g);}}
|
||||||
|
if(exterior(cell,b)){const g=gateReach(points.at(-1),b);if(g){points.push(g);gates.push(g);}}
|
||||||
line(points,materials.track,trackWidth);
|
line(points,materials.track,trackWidth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A perpendicular threshold at each opening, so a runway end reads as a gate.
|
||||||
|
for(const g of gates)line([{x:g.x-g.uy*GATE_HALF,y:g.y+g.ux*GATE_HALF},{x:g.x+g.uy*GATE_HALF,y:g.y-g.ux*GATE_HALF}],materials.track,trackWidth);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
// Stands, like the aircraft, never turn with the base.
|
// Stands, like the aircraft, never turn with the base.
|
||||||
for(const stand of game.level.stands){
|
for(const stand of game.level.stands){
|
||||||
@@ -332,21 +355,26 @@ function draw(now){
|
|||||||
line([{x:x-k,y:y-k},{x:x+k,y:y+k}],materials.blocked,.11);
|
line([{x:x-k,y:y-k},{x:x+k,y:y+k}],materials.blocked,.11);
|
||||||
line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,.11);
|
line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,.11);
|
||||||
}
|
}
|
||||||
// Approach guide (tutorial levels): when a waiting plane's edge connects to a
|
// Runway read: when a waiting plane's edge connects to a same-colour stand, draw the
|
||||||
// same-colour stand, draw the coloured runway (even past a blocker), a looping
|
// whole channel from the plane, across the tray edge, to the stand. The highlight is a
|
||||||
// ripple at the plane, and an arrow gliding toward the stand. A blocker ripples
|
// neutral hint colour (never the plane colour: the runway does not carry the match),
|
||||||
// red. This only guides; the player still performs the landing.
|
// a ripple at the plane and a ring at the opening, and an arrow gliding inbound. A
|
||||||
|
// blocker still ripples red. This only guides; the player still performs the landing.
|
||||||
if (!motions.length && !rotation && !document.querySelector('dialog[open]')) {
|
if (!motions.length && !rotation && !document.querySelector('dialog[open]')) {
|
||||||
const hints = approachHints(), T = now / 1000;
|
const hints = approachHints(), T = now / 1000;
|
||||||
for (const h of hints) {
|
for (const h of hints) {
|
||||||
const pts = h.points;
|
const pts = h.points;
|
||||||
line(pts, h.color, .08);
|
line(pts, materials.hintGlow, .19);
|
||||||
|
line(pts, materials.hint, .085);
|
||||||
const at = along(pts, (T * 0.6) % 1), dir = at.angle - Math.PI / 2, s = .26;
|
const at = along(pts, (T * 0.6) % 1), dir = at.angle - Math.PI / 2, s = .26;
|
||||||
polygon([{ x: at.x + Math.cos(dir) * s, y: at.y + Math.sin(dir) * s }, { x: at.x + Math.cos(dir + 2.4) * s, y: at.y + Math.sin(dir + 2.4) * s }, { x: at.x + Math.cos(dir - 2.4) * s, y: at.y + Math.sin(dir - 2.4) * s }], h.color);
|
polygon([{ x: at.x + Math.cos(dir) * s, y: at.y + Math.sin(dir) * s }, { x: at.x + Math.cos(dir + 2.4) * s, y: at.y + Math.sin(dir + 2.4) * s }, { x: at.x + Math.cos(dir - 2.4) * s, y: at.y + Math.sin(dir - 2.4) * s }], world.ink);
|
||||||
|
const mouth = pts[1] || h.from;
|
||||||
|
ctx.save(); ctx.globalAlpha = .9; ctx.beginPath(); ctx.arc(mouth.x, mouth.y, .34, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = materials.hint; ctx.lineWidth = .10; ctx.stroke(); ctx.restore();
|
||||||
for (const phase of [T % 1, (T + .5) % 1]) {
|
for (const phase of [T % 1, (T + .5) % 1]) {
|
||||||
ctx.save(); ctx.globalAlpha = (1 - phase) * .8;
|
ctx.save(); ctx.globalAlpha = (1 - phase) * .8;
|
||||||
ctx.beginPath(); ctx.arc(h.from.x, h.from.y, .5 + phase * 1.2, 0, Math.PI * 2);
|
ctx.beginPath(); ctx.arc(h.from.x, h.from.y, .5 + phase * 1.2, 0, Math.PI * 2);
|
||||||
ctx.strokeStyle = h.color; ctx.lineWidth = .10; ctx.stroke(); ctx.restore();
|
ctx.strokeStyle = materials.hint; ctx.lineWidth = .10; ctx.stroke(); ctx.restore();
|
||||||
}
|
}
|
||||||
const blocker = h.blocker && game.state.planes.find(p => p.id === h.blocker);
|
const blocker = h.blocker && game.state.planes.find(p => p.id === h.blocker);
|
||||||
if (blocker) {
|
if (blocker) {
|
||||||
|
|||||||
+38
-6
@@ -1,6 +1,6 @@
|
|||||||
// Airport rules. Rendering never supplies occupancy or chooses a destination.
|
// 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 { DIRS, key, opposite, rotateBoard, trace, validateLevel as validateBase, canRotate } from '../../src/core.js';
|
||||||
import { PORTS, position, pathPoints, sweepPath } from '../../src/path-math.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.
|
// Flat-top edges in screen space. Compass names hid that waiting bays sit on the six sides.
|
||||||
export const SIDES = ['上方', '右上方', '右下方', '下方', '左下方', '左上方'];
|
export const SIDES = ['上方', '右上方', '右下方', '下方', '左下方', '左上方'];
|
||||||
@@ -10,18 +10,50 @@ export function boardRadius(cells) {
|
|||||||
export function apronDistance(radius) {
|
export function apronDistance(radius) {
|
||||||
return Math.sqrt(3) * (radius + 0.5) + 1.15;
|
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) {
|
export function waitingPose(plane, planes, radius) {
|
||||||
const group = planes.filter(p => p.kind === 'arrival' && p.state === 'waiting' && p.side === plane.side);
|
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 at = Math.max(0, group.findIndex(p => p.id === plane.id));
|
||||||
const offset = group.length ? at * 1.12 : 0;
|
const offset = group.length ? at * 1.12 : 0;
|
||||||
const n = PORTS[plane.side], length = Math.hypot(n.x, n.y), x = n.x / length, y = n.y / length;
|
const { x: bx, y: by } = bayNormal(plane.side);
|
||||||
// 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.
|
|
||||||
const c = Math.cos(Math.PI / 6), s = Math.sin(Math.PI / 6);
|
|
||||||
const bx = x * c - y * s, by = x * s + y * c;
|
|
||||||
const d = apronDistance(radius);
|
const d = apronDistance(radius);
|
||||||
return { x: bx * (d + offset), y: by * (d + offset), angle: Math.atan2(-by, -bx) + Math.PI / 2 };
|
return { x: bx * (d + offset), y: by * (d + offset), angle: Math.atan2(-by, -bx) + Math.PI / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
export const copy = value => structuredClone(value);
|
||||||
const onBoard = plane => plane.state === 'ground' || plane.state === 'parked';
|
const onBoard = plane => plane.state === 'ground' || plane.state === 'parked';
|
||||||
const catalogCache = new WeakMap();
|
const catalogCache = new WeakMap();
|
||||||
|
|||||||
+61
-4
@@ -1,8 +1,8 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { hexagon, trace, opposite } from '../src/core.js';
|
import { hexagon, trace, opposite, DIRS } from '../src/core.js';
|
||||||
import { pathPoints, sweepPath, position, PORTS } from '../src/path-math.js';
|
import { pathPoints, sweepPath, position, PORTS, hexPoints } from '../src/path-math.js';
|
||||||
import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose } from '../labs/airport/core.js';
|
import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose, boardAt, trayHull, rimCrossing, bayNormal, TRAY_PAD } from '../labs/airport/core.js';
|
||||||
import { LEVELS } from '../labs/airport/levels.js';
|
import { LEVELS } from '../labs/airport/levels.js';
|
||||||
import { requestReward } from '../labs/airport/rewards.js';
|
import { requestReward } from '../labs/airport/rewards.js';
|
||||||
|
|
||||||
@@ -27,7 +27,6 @@ test('frozen levels have unique routes at every side/orientation and exact budge
|
|||||||
assert.ok(all.minimumActions>level.moveLimit,'clearing all must cost more than free budget');
|
assert.ok(all.minimumActions>level.moveLimit,'clearing all must cost more than free budget');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const bayNormal=side=>{const n=PORTS[side],l=Math.hypot(n.x,n.y),x=n.x/l,y=n.y/l;const c=Math.cos(Math.PI/6),s=Math.sin(Math.PI/6);return{x:x*c-y*s,y:x*s+y*c};};
|
|
||||||
test('waiting aprons sit on the board outline flat sides, outside every cell',()=>{
|
test('waiting aprons sit on the board outline flat sides, outside every cell',()=>{
|
||||||
assert.deepEqual(SIDES,['上方','右上方','右下方','下方','左下方','左上方']);
|
assert.deepEqual(SIDES,['上方','右上方','右下方','下方','左下方','左上方']);
|
||||||
assert.equal(boardRadius(hexagon(2)),2);
|
assert.equal(boardRadius(hexagon(2)),2);
|
||||||
@@ -207,3 +206,61 @@ test('reward completion cannot revive a restarted session or accept concurrent c
|
|||||||
assert.equal((await requestReward(game,async()=>({completed:true,id:'double'}))).status,'unavailable');
|
assert.equal((await requestReward(game,async()=>({completed:true,id:'double'}))).status,'unavailable');
|
||||||
current=false;resolve({completed:true,id:'old'});assert.equal((await pending).status,'stale');assert.equal(game.moves,10);
|
current=false;resolve({completed:true,id:'old'});assert.equal((await pending).status,'stale');assert.equal(game.moves,10);
|
||||||
});
|
});
|
||||||
|
const FACE_NORMALS=[0,1,2].map(k=>({x:Math.cos(Math.PI/6+k*Math.PI/3),y:Math.sin(Math.PI/6+k*Math.PI/3)}));
|
||||||
|
const insideFace=(x,y,cell,radius=.9)=>{const p=position(cell.q,cell.r),dx=x-p.x,dy=y-p.y;return FACE_NORMALS.every(n=>Math.abs(dx*n.x+dy*n.y)<=radius*Math.cos(Math.PI/6)+1e-9);};
|
||||||
|
const EDGE_ENDPOINT_RADIUS=.90*Math.cos(Math.PI/6)-.11/2-.01;
|
||||||
|
const insideHull=(hull,x,y)=>{for(let i=0;i<hull.length;i++){const a=hull[i],b=hull[(i+1)%hull.length];if((b.x-a.x)*(y-a.y)-(b.y-a.y)*(x-a.x)<-1e-9)return false;}return true;};
|
||||||
|
test('the tray outline is convex and contains every brick',()=>{
|
||||||
|
for(const level of LEVELS){
|
||||||
|
const hull=trayHull(level.cells);
|
||||||
|
assert.ok(hull.length>=6,`${level.id} hull`);
|
||||||
|
const cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
|
||||||
|
for(let i=0;i<hull.length;i++)assert.ok(cross(hull[i],hull[(i+1)%hull.length],hull[(i+2)%hull.length])>0,`${level.id} hull must stay convex`);
|
||||||
|
for(const cell of level.cells){
|
||||||
|
const p=position(cell.q,cell.r);
|
||||||
|
for(const v of hexPoints(p.x,p.y,.90))assert.ok(insideHull(hull,v.x,v.y),`${level.id} hull must enclose brick ${cell.q},${cell.r}`);
|
||||||
|
assert.ok(TRAY_PAD>.90,`${level.id} tray pad must clear the brick face`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('every board-edge runway reaches the tray outline without crossing another brick',()=>{
|
||||||
|
for(const level of LEVELS)for(let o=0;o<6;o++){
|
||||||
|
const board=boardAt(level,o),hull=trayHull(board.cells),keys=new Set(board.cells.map(c=>`${c.q},${c.r}`));
|
||||||
|
for(const cell of board.cells){
|
||||||
|
const p=position(cell.q,cell.r);
|
||||||
|
for(let d=0;d<6;d++){
|
||||||
|
if(d>=cell.tracks[d])continue;
|
||||||
|
const b=cell.tracks[d];
|
||||||
|
for(const port of [d,b]){
|
||||||
|
if(keys.has(`${cell.q+DIRS[port][0]},${cell.r+DIRS[port][1]}`))continue; // shares an edge with a brick
|
||||||
|
const n=PORTS[port],l=Math.hypot(n.x,n.y),ux=n.x/l,uy=n.y/l;
|
||||||
|
const end={x:p.x+ux*EDGE_ENDPOINT_RADIUS,y:p.y+uy*EDGE_ENDPOINT_RADIUS};
|
||||||
|
const t=rimCrossing(hull,end,{x:ux,y:uy});
|
||||||
|
assert.ok(t!==null&&t>0,`${level.id} o${o} ${cell.q},${cell.r} port ${port} must reach the tray outline`);
|
||||||
|
for(let s=1;s<24;s++){
|
||||||
|
const at={x:end.x+ux*t*s/24,y:end.y+uy*t*s/24};
|
||||||
|
for(const c of board.cells){
|
||||||
|
if(c.q===cell.q&&c.r===cell.r)continue;
|
||||||
|
assert.ok(!insideFace(at.x,at.y,c),`${level.id} o${o} runway ${cell.q},${cell.r} port ${port} must not graze brick ${c.q},${c.r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('apron slabs start outside the tray and stack along each side normal',()=>{
|
||||||
|
for(const level of LEVELS){
|
||||||
|
const radius=boardRadius(level.cells),hull=trayHull(level.cells);
|
||||||
|
for(let side=0;side<6;side++){
|
||||||
|
const n=bayNormal(side),rim=rimCrossing(hull,{x:0,y:0},n);
|
||||||
|
assert.ok(rim!==null&&rim>0,`${level.id} side ${side} slab needs a rim`);
|
||||||
|
for(const arrival of level.arrivals.filter(a=>a.side===side)){
|
||||||
|
const pose=waitingPose({...arrival,kind:'arrival',state:'waiting'},level.arrivals.map(a=>({...a,kind:'arrival',state:'waiting'})),radius);
|
||||||
|
// The plane sits on the slab axis, beyond the tray: the lane reaches its bay.
|
||||||
|
assert.ok(pose.x*n.x+pose.y*n.y>rim,`${level.id} side ${side} bay must sit outside the tray`);
|
||||||
|
assert.ok(Math.abs(pose.x*n.y-pose.y*n.x)<1e-9,`${level.id} side ${side} bay must stay on the slab axis`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user