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
+43 -29
View File
@@ -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 {} }
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;
const GATE_OVERSHOOT = 0.12, GATE_HALF = 0.16, APRON_WIDTH = 1.9;
const APRON_WIDTH = 1.9;
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' });
worker.onmessage = ({ data }) => {
@@ -56,7 +56,7 @@ function loadLevel(next) {
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
$('level-title').textContent = `第 ${index + 1} 关`;
$('level-name').textContent = game.level.name;
notice(game.level.lesson); sync(); diagnose();
notice(game.level.lesson); resize(); diagnose();
}
function waitingLocation(plane, state = game.state) {
return waitingPose(plane, state.planes, boardRadius(game.level.cells));
@@ -79,7 +79,7 @@ function approachHints() {
const route = routes.find(r => r.side === plane.side && r.color === plane.color);
if (!route) continue; // no entrance / no connected runway from this edge
const hit = sweepPath(route.points, blockers.filter(b => b.id !== plane.id));
hints.push({ plane, from: planeLocation(plane), points: [planeLocation(plane), ...route.points], blocker: hit ? hit.blocker : null });
hints.push({ plane, from: planeLocation(plane), points: route.points, blocker: hit ? hit.blocker : null });
}
return hints;
}
@@ -130,14 +130,14 @@ function perform(action) {
if (result.status === 'land') {
const start = waitingLocation(plane, before);
// Exterior approach stays outside the board and joins the selected boundary port.
const mouth = result.points[0], n = PORTS[plane.side], len = Math.hypot(n.x,n.y);
const mouth = result.points[0], n = PORTS[result.exitDir ?? plane.side], len = Math.hypot(n.x,n.y);
const approach = { x: mouth.x + n.x / len * 1.3, y: mouth.y + n.y / len * 1.3 };
points = [start, approach, ...result.points]; kind = 'land'; sound('land');
notice('停靠成功。留意新飞机是否挡住其他通路。');
} else if (result.status === 'exit') {
points = [...result.points]; kind = 'exit'; sound('exit');
if (plane.kind === 'arrival') {
const last = points.at(-1), n = PORTS[result.side], len = Math.hypot(n.x,n.y);
const last = points.at(-1), n = PORTS[result.exitDir ?? result.side], len = Math.hypot(n.x,n.y);
points.push({ x:last.x+n.x/len*1.3, y:last.y+n.y/len*1.3 }, waitingLocation(updated));
notice(`来机已起飞,将在${SIDES[result.side]}停机坪等待。`);
} else notice('飞机已离场,空出的通路可以用于接机。');
@@ -230,6 +230,18 @@ function hull(points){
for(const q of sorted.reverse()){while(upper.length>=2&&cross(upper.at(-2),upper.at(-1),q)<=0)upper.pop();upper.push(q);}
return [...lower.slice(0,-1),...upper.slice(0,-1)];
}
function insideHull(poly,x,y){for(let i=0;i<poly.length;i++){const a=poly[i],b=poly[(i+1)%poly.length];if((b.x-a.x)*(y-a.y)-(b.y-a.y)*(x-a.x)<-1e-9)return false;}return true;}
function clipToHull(points,poly){
const out=[];
for(let i=0;i<points.length;i++){
const p=points[i],inn=insideHull(poly,p.x,p.y),prev=i?points[i-1]:null,prevIn=prev&&insideHull(poly,prev.x,prev.y);
if(inn){
if(prev&&!prevIn){const dx=p.x-prev.x,dy=p.y-prev.y,l=Math.hypot(dx,dy)||1,t=rimCrossing(poly,p,{x:-dx/l,y:-dy/l});if(t!==null)out.push({x:p.x-dx/l*t,y:p.y-dy/l*t});}
out.push(p);
}else if(prevIn){const dx=p.x-prev.x,dy=p.y-prev.y,l=Math.hypot(dx,dy)||1,t=rimCrossing(poly,prev,{x:dx/l,y:dy/l});if(t!==null)out.push({x:prev.x+dx/l*t,y:prev.y+dy/l*t});}
}
return out;
}
function aircraft(plane,where,alpha=1){
ctx.save();ctx.translate(where.x,where.y);ctx.globalAlpha=alpha;
const color=pigments[plane.color].color;
@@ -274,14 +286,14 @@ function draw(now){
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 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)=>{
// A boundary runway reaches the tray outline instead of stopping inside the brick,
// but never leaves the board: the round cap is pulled back by half the stroke.
const rimEnd=(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};
if(t===null)return null;
const reach=Math.max(0,t-trackWidth/2);
return {x:point.x+ux*reach,y:point.y+uy*reach};
};
const gates=[];
for(const cell of board.cells){
const p=position(cell.q,cell.r);
for(let d=0;d<6;d++){
@@ -291,13 +303,11 @@ 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 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;});
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);}}
if(exterior(cell,d)){const g=rimEnd(points[0],d);if(g)points.unshift(g);}
if(exterior(cell,b)){const g=rimEnd(points.at(-1),b);if(g)points.push(g);}
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();
// Stands, like the aircraft, never turn with the base.
for(const stand of game.level.stands){
@@ -355,22 +365,24 @@ 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);
}
// Runway read: when a waiting plane's edge connects to a same-colour stand, draw the
// whole channel from the plane, across the tray edge, to the stand. The highlight is a
// neutral hint colour (never the plane colour: the runway does not carry the match),
// 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.
// Runway read: when a waiting plane's edge connects to a same-colour stand, light the
// channel in a neutral hint colour from the plane, across the apron, onto the board
// path. Board runways themselves stay inside the tray. A ripple at the plane, a ring
// at the opening, and an inbound arrow still guide; a blocker ripples red.
if (!motions.length && !rotation && !document.querySelector('dialog[open]')) {
const hints = approachHints(), T = now / 1000;
for (const h of hints) {
const pts = h.points;
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;
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();
const onboard = clipToHull(h.points, hullPts);
const pts = onboard.length ? [h.from, ...onboard] : [h.from, ...h.points];
if (pts.length > 1) {
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;
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 ring = onboard[0] || along(pts, .08);
ctx.save(); ctx.globalAlpha = .9; ctx.beginPath(); ctx.arc(ring.x, ring.y, .28, 0, Math.PI * 2);
ctx.strokeStyle = materials.hint; ctx.lineWidth = .10; ctx.stroke(); ctx.restore();
}
for (const phase of [T % 1, (T + .5) % 1]) {
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);
@@ -395,7 +407,9 @@ function draw(now){
}
function resize(){
const rect=$('airspace').getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);
const span=(game?apronDistance(boardRadius(game.level.cells)):6)*2+1.8;
const radius=game?boardRadius(game.level.cells):2;
const stacks=game?Math.max(1,...[0,1,2,3,4,5].map(s=>game.level.arrivals.filter(a=>a.side===s).length)):1;
const span=(apronDistance(radius)+(stacks-1)*1.12+1.8)*2;
view={width:rect.width,height:rect.height,size:Math.min(rect.width/span,rect.height/span),ox:rect.width/2,oy:rect.height/2};
canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr);if(game)sync();
}