feat(airport): add inbound dispatch prototype and landing effects

This commit is contained in:
toshiocn
2026-10-26 16:11:11 +08:00
parent f40efb8ba9
commit 10962babe3
10 changed files with 1581 additions and 39 deletions
+142 -37
View File
@@ -22,6 +22,9 @@ 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;
let dragSpin = null;
const STEP=Math.PI/3, SNAP=22*Math.PI/180, FLICK_MS=240, FLICK_ANG=.18;
function wrapDelta(a){while(a>Math.PI)a-=Math.PI*2;while(a<-Math.PI)a+=Math.PI*2;return a;}
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 }) => {
@@ -49,9 +52,52 @@ function sound(name) {
} catch {}
}
function notice(message, warning = false) { $('notice').textContent = message; $('notice').classList.toggle('warning', warning); }
let cheers = [];
// A short candy burst around the stand that just accepted a plane. Pieces stay
// outside the hex so they do not cover the nose or the bay mark. Windows with
// animation effects off still gets the burst: skipping it hid the landing.
function cheer(at, color) {
const colors = ['#EE5398', '#E5680C', '#4C9E5A', '#578BF0', '#937DE4', '#D06E93', '#FFFFFF', pigments[color].color];
cheers.push({
x: at.x, y: at.y, start: performance.now(), duration: 1100,
bits: Array.from({ length: 16 }, (_, i) => ({
angle: -Math.PI / 2 + (i / 16) * Math.PI * 2 + (i % 2 ? .1 : -.06),
reach: .1 + (i % 3) * .04,
spin: (i % 2 ? 1 : -1) * (.55 + (i % 3) * .15),
len: i % 4 === 3 ? 0 : .26 + (i % 3) * .04,
color: colors[i % colors.length],
})),
});
}
function drawCheers(now) {
const before = cheers.length;
cheers = cheers.filter(c => now - c.start < c.duration);
const unit = Math.max(view.size, 1);
const stroke = Math.max(.16, 4.5 / unit);
const dot = Math.max(.09, 3 / unit);
for (const c of cheers) {
const t = (now - c.start) / c.duration;
const fade = t < .05 ? t / .05 : Math.max(0, 1 - (t - .7) / .3);
for (const bit of c.bits) {
const ease = 1 - Math.pow(1 - t, 2);
const dist = 1.14 + ease * bit.reach;
const curl = bit.spin * ease * .14;
const x = c.x + Math.cos(bit.angle + curl) * dist;
const y = c.y + Math.sin(bit.angle + curl) * dist + ease * ease * .05;
ctx.save();
ctx.translate(x, y);
ctx.rotate(bit.angle + t * bit.spin);
ctx.globalAlpha = fade;
if (bit.len) line([{ x: -bit.len / 2, y: 0 }, { x: bit.len / 2, y: 0 }], bit.color, stroke);
else { ctx.beginPath(); ctx.arc(0, 0, dot, 0, Math.PI * 2); ctx.fillStyle = bit.color; ctx.fill(); }
ctx.restore();
}
}
return before > 0 && !cheers.length;
}
function loadLevel(next) {
generation++; requestId++; index = next; saved.last = index; persist();
game = new AirportGame(LEVELS[index]); motions = []; rotation = null; feedback = null; queue = []; diagnosis = null;
game = new AirportGame(LEVELS[index]); motions = []; cheers = []; rotation = null; dragSpin = null; feedback = null; queue = []; diagnosis = null;
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
$('level-title').textContent = `第 ${index + 1} 关`;
$('level-name').textContent = game.level.name;
@@ -110,7 +156,7 @@ function sync() {
}
for (const button of [...controls.children]) if (!live.has(button.dataset.plane)) button.remove();
}
function perform(action) {
function perform(action, opts = {}) {
if (document.querySelector('dialog[open]')) return;
if (game.rotating && action.type === 'tap') {
if (!queue.some(a => a.id === action.id) && !game.busy.has(action.id)) queue.push(action);
@@ -121,7 +167,8 @@ function perform(action) {
if (result.status === 'ambiguous') { notice('此布局有多个停靠结果,已暂停操作。', true); return; }
feedback = null; const now = performance.now();
if (result.status === 'rotated') {
rotation = { old: boardAt(game.level, before.orientation), direction: action.direction, start: now, duration: 240 };
if (opts.skipSpin) game.finish('rotation');
else rotation = { old: boardAt(game.level, before.orientation), direction: action.direction, start: now, duration: 240 };
sound('rotated'); notice('轨道已调整,观察飞机所在侧的连接。');
} else {
const plane = before.planes.find(p => p.id === action.id), updated = game.state.planes.find(p => p.id === action.id);
@@ -181,6 +228,7 @@ function settle() {
if ($('notice').textContent !== guide && !$('notice').classList.contains('warning')) notice(guide);
}
if (game.won) {
if (cheers.some(c => performance.now() - c.start < c.duration)) return;
if (!$('result').open) {
if (!saved.completed.includes(game.level.id)) saved.completed.push(game.level.id);
persist(); sound('win');
@@ -264,6 +312,19 @@ function drawApronFans(hull,outerR){
ctx.strokeStyle=rgba(fill,.16);ctx.lineWidth=.055;ctx.lineCap='round';ctx.stroke();
}
}
function drawSpinArc(hull,angle){
if(!angle||Math.abs(angle)<.02)return;
let r=0;for(const v of hull)r=Math.max(r,Math.hypot(v.x,v.y));
r+=.18;
const a0=-Math.PI/2,commit=Math.abs(angle)>=SNAP;
ctx.beginPath();ctx.arc(0,0,r,a0,a0+angle,angle<0);
ctx.strokeStyle=commit?direction.accent:rgba(materials.track,.55);
ctx.lineWidth=commit?.12:.09;ctx.lineCap='round';ctx.stroke();
}
const NOSE=.70;
function nosePoint(where){
return {x:where.x+Math.sin(where.angle)*NOSE,y:where.y-Math.cos(where.angle)*NOSE};
}
function aircraft(plane,where,alpha=1){
ctx.save();ctx.translate(where.x,where.y);ctx.globalAlpha=alpha;
const color=pigments[plane.color].color;
@@ -271,7 +332,7 @@ function aircraft(plane,where,alpha=1){
polygon(face,color);
polygon(face,null,'#FFFFFF',.09);
ctx.save();ctx.rotate(where.angle);
const silhouette=[[0,-.70],[.11,-.50],[.11,-.12],[.58,.20],[.58,.32],[.12,.13],[.11,.44],[.28,.60],[.28,.68],[0,.56],[-.28,.68],[-.28,.60],[-.11,.44],[-.12,.13],[-.58,.32],[-.58,.20],[-.11,-.12],[-.11,-.50]];
const silhouette=[[0,-NOSE],[.11,-.50],[.11,-.12],[.58,.20],[.58,.32],[.12,.13],[.11,.44],[.28,.60],[.28,.68],[0,.56],[-.28,.68],[-.28,.60],[-.11,.44],[-.12,.13],[-.58,.32],[-.58,.20],[-.11,-.12],[-.11,-.50]];
polygon(silhouette.map(([x,y])=>({x,y})),'#FFFFFF');
line([{x:0,y:-.46},{x:0,y:-.28}],color,.07);
ctx.restore();
@@ -281,6 +342,22 @@ function aircraft(plane,where,alpha=1){
}
ctx.restore();
}
// Stand icon. 'bay' is the trial; set STAND_MARK to 'plain' to restore the white pad.
const STAND_MARK='bay';
function drawStand(stand){
const p=position(stand.q,stand.r),color=pigments[stand.color].color,letter=String.fromCharCode(65+stand.color);
if(STAND_MARK==='plain'){
polygon(roundedHex(p.x,p.y,.70,.16),'#FFFFFF',color,.08);
label('P',p.x,p.y-.06,.52,color);label(letter,p.x,p.y+.38,.22,world.ink);
return;
}
// Inverted aircraft: a candy ring around a white face. Outer edge matches the
// old stroke's reach, so the runway gutter is unchanged. The face stays white
// so the P keeps its contrast on every pigment, including honey.
polygon(roundedHex(p.x,p.y,.74,.16),color);
polygon(roundedHex(p.x,p.y,.56,.20),'#FFFFFF');
label('P',p.x,p.y-.06,.46,color);label(letter,p.x,p.y+.30,.22,world.ink);
}
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);
@@ -292,8 +369,15 @@ function draw(now){
const stacks=Math.max(1,...[0,1,2,3,4,5].map(s=>game.level.arrivals.filter(a=>a.side===s).length));
const outerR=apronR+(stacks-1)*1.12+.85;
drawApronFans(hullPts,outerR);
if(dragSpin)drawSpinArc(hullPts,dragSpin.angle);
if(dragSpin?.settling){
const s=dragSpin.settling,p=Math.min(1,(now-s.start)/s.duration),u=p*p*(3-2*p);
dragSpin.angle=s.from+(s.to-s.from)*u;
if(p>=1){const dir=s.commitDir;dragSpin=null;if(dir)perform({type:'rotate',direction:dir},{skipSpin:true});}
}
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 boardSpin=rotation?(Math.min(1,(now-rotation.start)/rotation.duration)**2*(3-2*Math.min(1,(now-rotation.start)/rotation.duration)))*rotation.direction*STEP:(dragSpin?.angle||0);
if(boardSpin)ctx.rotate(boardSpin);
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);
@@ -328,11 +412,7 @@ function draw(now){
}
ctx.restore();
// Stands, like the aircraft, never turn with the base.
for(const stand of game.level.stands){
const p=position(stand.q,stand.r),color=pigments[stand.color].color;
polygon(roundedHex(p.x,p.y,.70,.16),'#FFFFFF',color,.08);
label('P',p.x,p.y-.06,.52,color);label(String.fromCharCode(65+stand.color),p.x,p.y+.38,.22,world.ink);
}
for(const stand of game.level.stands)drawStand(stand);
if(feedback&&now<feedback.until){
const pts=feedback.travelPoints||feedback.points;
if(pts?.length){
@@ -371,9 +451,13 @@ function draw(now){
}else if(motion.kind==='bump'){
where=along(motion.points,collisionPhase(progress,reduced).distance);
if(motion.points.length===1)where.angle=motion.plane.dir*Math.PI/3;
}else{
where=along(motion.points,travelProgress(progress,motion.duration));
if(motion.kind==='exit'&&motion.plane.kind==='original')alpha=1-Math.max(0,(progress-.8)/.2);
} else {
where = along(motion.points, travelProgress(progress, motion.duration));
if (motion.kind === 'exit' && motion.plane.kind === 'original') alpha = 1 - Math.max(0, (progress - .8) / .2);
if (motion.kind === 'land' && progress >= .96 && !motion.cheered) {
motion.cheered = true;
cheer(position(motion.updated.q, motion.updated.r), motion.plane.color);
}
}
aircraft({...motion.plane,state:motion.kind==='land'&&progress>.96?'parked':motion.plane.state==='parked'?'ground':motion.plane.state},where,alpha);
}
@@ -384,14 +468,15 @@ function draw(now){
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, 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]')) {
// channel from the nose, across the apron, onto the board path. The ripple stays on
// the plane. Board runways stay inside the tray. A ring at the opening and an inbound
// arrow still guide; a blocker ripples red.
if (!motions.length && !rotation && !dragSpin && !document.querySelector('dialog[open]')) {
const hints = approachHints(), T = now / 1000;
for (const h of hints) {
const onboard = clipToHull(h.points, hullPts);
const pts = onboard.length ? [h.from, ...onboard] : [h.from, ...h.points];
const nose = nosePoint(h.from);
const pts = onboard.length ? [nose, ...onboard] : [nose, ...h.points];
if (pts.length > 1) {
line(pts, materials.hintGlow, .19);
line(pts, materials.hint, .085);
@@ -417,10 +502,12 @@ function draw(now){
}
}
}
let changed=false;
motions=motions.filter(m=>{if(now-m.start<m.duration)return true;game.finish(m.id);changed=true;return false;});
if(rotation&&now-rotation.start>=rotation.duration){rotation=null;game.finish('rotation');changed=true;const pending=queue;queue=[];for(const action of pending)perform(action);}
if(changed){sync();settle();}
const cheerDone = drawCheers(now);
let changed = false;
motions = motions.filter(m => { if (now - m.start < m.duration) return true; game.finish(m.id); changed = true; return false; });
if (rotation && now - rotation.start >= rotation.duration) { rotation = null; game.finish('rotation'); changed = true; const pending = queue; queue = []; for (const action of pending) perform(action); }
if (changed) { sync(); settle(); }
else if (cheerDone && game.won && !$('result').open) settle();
requestAnimationFrame(draw);
}
function resize(){
@@ -432,26 +519,44 @@ function resize(){
canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr);if(game)sync();
}
new ResizeObserver(resize).observe($('airspace'));
// Swiping anywhere outside the board centre turns the base, like dragging the
// rim in the original. The gesture never selects a route, so it stays direct.
let dragPointer = null;
// Rim drag: follow the finger within ±60°. A quick flick still turns one sector.
// Release past SNAP commits that sector; otherwise the board eases back.
canvas.addEventListener('pointerdown', event => {
if (document.querySelector('dialog[open]') || game.won || game.deadlocked || game.moves <= 0 || game.rotating) return;
if (document.querySelector('dialog[open]') || game.won || game.deadlocked || game.moves <= 0 || game.rotating || dragSpin) return;
const rect = $('airspace').getBoundingClientRect();
dragPointer = { id: event.pointerId, x: event.clientX - rect.left - view.ox, y: event.clientY - rect.top - view.oy };
const x = event.clientX - rect.left - view.ox, y = event.clientY - rect.top - view.oy;
if (Math.hypot(x, y) <= Math.min(view.width, view.height) * 0.28) return;
dragSpin = { id: event.pointerId, x, y, startAngle: Math.atan2(y, x), angle: 0, t0: performance.now(), analog: false, settling: null };
canvas.setPointerCapture?.(event.pointerId);
});
canvas.addEventListener('pointerup', event => {
if (!dragPointer || event.pointerId !== dragPointer.id) { dragPointer = null; return; }
const rect = $('airspace').getBoundingClientRect(), start = dragPointer;
dragPointer = null;
if (Math.hypot(start.x, start.y) <= Math.min(view.width, view.height) * 0.28) return;
let delta = Math.atan2(event.clientY - rect.top - view.oy, event.clientX - rect.left - view.ox) - Math.atan2(start.y, start.x);
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
if (Math.abs(delta) > 0.18) perform({ type: 'rotate', direction: delta > 0 ? 1 : -1 });
canvas.addEventListener('pointermove', event => {
if (!dragSpin || event.pointerId !== dragSpin.id || dragSpin.settling || rotation) return;
const rect = $('airspace').getBoundingClientRect();
let delta = wrapDelta(Math.atan2(event.clientY - rect.top - view.oy, event.clientX - rect.left - view.ox) - dragSpin.startAngle);
delta = Math.max(-STEP, Math.min(STEP, delta));
if (Math.abs(delta) > .04) dragSpin.analog = true;
if (dragSpin.analog) dragSpin.angle = delta;
});
canvas.addEventListener('pointercancel', () => { dragPointer = null; });
canvas.addEventListener('pointerup', event => {
if (!dragSpin || event.pointerId !== dragSpin.id) { dragSpin = null; return; }
if (dragSpin.settling) return;
const rect = $('airspace').getBoundingClientRect(), dt = performance.now() - dragSpin.t0;
let delta = wrapDelta(Math.atan2(event.clientY - rect.top - view.oy, event.clientX - rect.left - view.ox) - dragSpin.startAngle);
const flick = dt < FLICK_MS && Math.abs(delta) > FLICK_ANG;
if (flick) {
dragSpin = null;
perform({ type: 'rotate', direction: delta > 0 ? 1 : -1 });
return;
}
const angle = dragSpin.analog ? dragSpin.angle : 0;
if (Math.abs(angle) >= SNAP) {
const dir = angle > 0 ? 1 : -1;
dragSpin.settling = { from: angle, to: dir * STEP, start: performance.now(), duration: reduced ? 80 : 180, commitDir: dir };
} else if (Math.abs(angle) > .01) {
dragSpin.settling = { from: angle, to: 0, start: performance.now(), duration: reduced ? 80 : 180, commitDir: 0 };
} else dragSpin = null;
});
canvas.addEventListener('pointercancel', () => { dragSpin = null; });
$('left').onclick=()=>perform({type:'rotate',direction:-1});$('right').onclick=()=>perform({type:'rotate',direction:1});
$('restart').onclick=$('retry').onclick=()=>loadLevel(index);