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:
+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 { requestReward } from './rewards.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 {} }
|
||||
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 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;
|
||||
const worker = new Worker(new URL('./solver-worker.js', import.meta.url), { type: 'module' });
|
||||
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
|
||||
// and to draw the approach guide; the player still performs the landing.
|
||||
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 blockers = game.state.planes.filter(p => (p.state === 'ground' || p.state === 'parked'))
|
||||
.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);
|
||||
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.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;
|
||||
}
|
||||
@@ -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);
|
||||
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 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();
|
||||
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(0).map(q=>({x:q.x,y:q.y+.10})),materials.shadow);
|
||||
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 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)=>{
|
||||
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){
|
||||
const p=position(cell.q,cell.r);
|
||||
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 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);}}
|
||||
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){
|
||||
@@ -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);
|
||||
}
|
||||
// Approach guide (tutorial levels): when a waiting plane's edge connects to a
|
||||
// same-colour stand, draw the coloured runway (even past a blocker), a looping
|
||||
// ripple at the plane, and an arrow gliding toward the stand. A blocker 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, 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.
|
||||
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, 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;
|
||||
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]) {
|
||||
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.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);
|
||||
if (blocker) {
|
||||
|
||||
Reference in New Issue
Block a user