feat(airport): add inbound dispatch prototype and landing effects
This commit is contained in:
+142
-37
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user