Files
HexTrails/labs/airport/app.js
T

492 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AirportGame, SIDES, boardAt, catalogs, parkedCount, copy, boardRadius, waitingPose, apronDistance, trayHull, rimCrossing, 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';
import { DIRS, key } from '../../src/core.js';
import { roundedHex } from '../../src/tile-shape.js';
import { travelProgress } from '../../src/exit-animation.js';
import { collisionPhase } from '../../src/collision-animation.js';
import { artDirection, directionTheme } from '../../src/art-directions.js';
import { ICONS } from '../../src/ui-skin.js';
const direction = artDirection('macaron');
const world = directionTheme(direction, 'lagoon');
const pigments = direction.pigments;
const materials = world.materials;
const $ = id => document.getElementById(id), canvas = $('board'), ctx = canvas.getContext('2d');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const STORAGE = 'hex-airport-v1';
let saved = { completed: [], last: 0, sound: true };
try { const value = JSON.parse(localStorage.getItem(STORAGE)); if (value && Array.isArray(value.completed)) saved = { ...saved, ...value }; } 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 game, motions = [], rotation = null, feedback = null, queue = [], requestId = 0, diagnosis = null, generation = 0;
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 }) => {
if (data.request !== requestId) return;
diagnosis = data.result;
if (diagnosis.status === 'deadlock') game.deadlocked = true;
settle();
};
worker.onerror = () => { diagnosis = { status: 'unknown' }; settle(); };
function diagnose() {
diagnosis = null;
worker.postMessage({ request: ++requestId, level: game.level, state: copy(game.state) });
}
function sound(name) {
if (!saved.sound) return;
try {
audioContext ??= new (window.AudioContext || window.webkitAudioContext)();
audioContext.resume().catch(() => {});
const frequencies = { land: [440, 660], exit: [660, 880], rotated: [330, 440], hit: [145], win: [523, 659, 784] }[name] || [480];
frequencies.forEach((frequency, i) => {
const oscillator = audioContext.createOscillator(), gain = audioContext.createGain(), t = audioContext.currentTime + i * .09;
oscillator.frequency.value = frequency; gain.gain.setValueAtTime(.001, t); gain.gain.linearRampToValueAtTime(.045, t + .01); gain.gain.exponentialRampToValueAtTime(.001, t + .18);
oscillator.connect(gain); gain.connect(audioContext.destination); oscillator.start(t); oscillator.stop(t + .2);
});
} catch {}
}
function notice(message, warning = false) { $('notice').textContent = message; $('notice').classList.toggle('warning', warning); }
function loadLevel(next) {
generation++; requestId++; index = next; saved.last = index; persist();
game = new AirportGame(LEVELS[index]); motions = []; rotation = 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;
notice(game.level.lesson); resize(); diagnose();
}
function waitingLocation(plane, state = game.state) {
return waitingPose(plane, state.planes, boardRadius(game.level.cells));
}
function planeLocation(plane, state = game.state) {
return plane.state === 'waiting' ? waitingLocation(plane, state) : { ...position(plane.q, plane.r), angle: plane.dir * Math.PI / 3 };
}
function screen(point) { return { x: view.ox + point.x * view.size, y: view.oy + point.y * view.size }; }
// A waiting plane whose edge actually connects to a same-colour stand: the runway
// 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 (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 }));
const hints = [];
for (const plane of game.state.planes) {
if (plane.state !== 'waiting') continue;
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: route.points, blocker: hit ? hit.blocker : null });
}
return hints;
}
function sync() {
$('airspace').setAttribute('aria-busy', String(game.busy.size > 0 || game.rotating));
$('moves').textContent = game.moves;
const parked = parkedCount(game.state), total = game.level.arrivals.length;
$('progress').textContent = `${parked} / ${total}`;
$('progress-bar').style.width = `${100 * parked / total}%`;
$('left').disabled = $('right').disabled = game.busy.size > 0 || game.rotating || game.moves <= 0 || game.won || game.deadlocked;
$('sound').textContent = `声音:${saved.sound ? '开' : '关'}`;
const controls = $('plane-controls');
const live = new Set();
for (const plane of game.state.planes) {
if (plane.state === 'gone') continue;
live.add(plane.id);
let button = controls.querySelector(`[data-plane="${plane.id}"]`);
if (!button) {
button = document.createElement('button'); button.type = 'button'; button.className = 'plane-hit'; button.dataset.plane = plane.id;
button.addEventListener('click', () => perform({ type: 'tap', id: plane.id })); controls.append(button);
}
const label = plane.state === 'waiting' ? `${SIDES[plane.side]}停机坪待降落` : plane.kind === 'arrival' ? '已停靠,可再次起飞' : '地面待起飞';
button.setAttribute('aria-label', `${pigments[plane.color].name}飞机 ${plane.id},${label}`);
const p = screen(planeLocation(plane));
button.style.left = `${p.x}px`; button.style.top = `${p.y}px`;
button.style.width = button.style.height = `${Math.max(44, view.size * 1.65)}px`;
button.disabled = game.busy.has(plane.id) || game.won || game.deadlocked || (game.moves <= 0 && plane.state !== 'waiting');
button.classList.toggle('queued', queue.some(a => a.id === plane.id));
}
for (const button of [...controls.children]) if (!live.has(button.dataset.plane)) button.remove();
}
function perform(action) {
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);
sync(); return;
}
const before = copy(game.state), result = game.begin(action);
if (['busy', 'invalid', 'finished', 'out-of-moves'].includes(result.status)) return;
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 };
sound('rotated'); notice('轨道已调整,观察飞机所在侧的连接。');
} else {
const plane = before.planes.find(p => p.id === action.id), updated = game.state.planes.find(p => p.id === action.id);
let points, kind;
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[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.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('飞机已离场,空出的通路可以用于接机。');
} else {
sound('hit'); kind = plane.state === 'waiting' ? 'shake' : 'bump';
points = result.travelPoints || result.points || [planeLocation(plane)];
if (kind === 'bump' && result.status !== 'blocked') points = [planeLocation(plane)];
feedback = { ...result, until: now + 4500 };
const messages = { 'blocked':'路线受阻 · 先移开高亮的飞机', 'no-stand':'没有可用的跑道', 'no-route':'没有可供行进的路线', 'loop':'没有可供离场的路线' };
notice(messages[result.status] || '没有可供行进的路线', true);
}
const length = pathLength(points);
motions.push({ id: plane.id, plane, updated, kind, points, blocker: result.blocker, start: now, duration: kind === 'shake' ? (reduced ? 100 : 420) : kind === 'bump' ? 800 : motionTiming(length, kind) });
}
sync(); diagnose();
}
function pathLength(points) { let length=0; for(let i=1;i<points.length;i++)length+=Math.hypot(points[i].x-points[i-1].x,points[i].y-points[i-1].y); return length; }
// Descent reads slower than taxi-out; both keep one cruising speed per unit and
// accelerate only past the knee, like the original exit timing.
function motionTiming(length, kind) {
const msPerUnit = kind === 'land' ? 150 : 100, knee = 2600, maxMs = 3600, ramp = 90;
const linear = length * msPerUnit;
const base = linear <= knee ? linear : Math.min(maxMs, knee + (linear - knee) * (maxMs - knee) / Math.max(1, linear - knee) * (1 - Math.exp(-(linear - knee) / Math.max(1, maxMs - knee))));
return Math.max(kind === 'land' ? 560 : 320, base + Math.min(ramp, base / 3));
}
function along(points, progress) {
if (points.length < 2) return { ...points[0], angle: 0 };
let remaining = pathLength(points) * Math.min(1, Math.max(0, progress));
for(let i=1;i<points.length;i++){
const a=points[i-1],b=points[i],length=Math.hypot(b.x-a.x,b.y-a.y);
if(remaining<=length||i===points.length-1){const t=length?Math.min(1,remaining/length):0;return{x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,angle:Math.atan2(b.y-a.y,b.x-a.x)+Math.PI/2};}
remaining-=length;
}
}
function show(dialog) { queue=[]; sync(); if(!dialog.open)dialog.showModal(); }
function settle() {
if (motions.length || rotation) return;
sync();
// Prompt the player when a waiting plane's runway is connected (blocked or not).
if (!game.won && !game.deadlocked && approachHints().length) {
const guide = '来机已连通:点击它接入同色机位。';
if ($('notice').textContent !== guide && !$('notice').classList.contains('warning')) notice(guide);
}
if (game.won) {
if (!$('result').open) {
if (!saved.completed.includes(game.level.id)) saved.completed.push(game.level.id);
persist(); sound('win');
$('result-title').textContent='全部到位';
const remaining=game.state.planes.filter(p=>p.kind==='original'&&p.state==='ground').length;
$('result-copy').textContent=`全部 ${game.level.arrivals.length} 架来机已停好,剩余 ${game.moves} 次操作。场内还保留了 ${remaining} 架原有飞机。`;
$('next').hidden=false;$('next').textContent=index<LEVELS.length-1?'下一关':'回到第一关';
$('reward').hidden=true;$('reward-note').hidden=true;show($('result'));
}
} else if (game.moves <= 0 || game.deadlocked) {
$('next').hidden=true;
$('result-title').textContent=game.deadlocked?'当前调度已无法完成':'操作次数已用完';
$('result-copy').textContent=game.deadlocked?'增加次数也无法解开当前局面。重新安排起降顺序,再试一次。':!diagnosis?'正在检查当前局面…':diagnosis.status==='solved'?'当前局面仍有可行路线,可以补充操作次数继续。':'尚未确定当前局面是否可完成,补充次数不保证能解围。';
const available=!game.deadlocked&&game.rewardCount<game.level.reward.maxClaims;
if(!game.deadlocked&&!available)$('result-copy').textContent='本关的补充次数已用完,可以重新安排调度再挑战。';
$('reward').hidden=!available;$('reward').disabled=!diagnosis||game.rewardPending;
$('reward').textContent=window.airportAdProvider?`观看广告 · +${game.level.reward.steps} 次`:`测试补充 · +${game.level.reward.steps} 次`;
$('reward-note').hidden=!available||!!window.airportAdProvider;
show($('result'));
}
}
// Drawing uses exactly the same normalized paths as collision and replay.
function polygon(points, fill, stroke, width=.04) {
ctx.beginPath();points.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();
if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=width;ctx.stroke();}
}
function line(points,color,width=.06){if(!points?.length)return;ctx.beginPath();points.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.strokeStyle=color;ctx.lineWidth=width;ctx.lineCap='round';ctx.lineJoin='round';ctx.stroke();}
function label(text,x,y,size,color=world.ink){ctx.fillStyle=color;ctx.font=`700 ${size}px "Resource Han Rounded","Microsoft YaHei",sans-serif`;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(text,x,y);}
function roundedPoly(poly,radius){
return poly.flatMap((v,i)=>{
const prev=poly[(i+poly.length-1)%poly.length],next=poly[(i+1)%poly.length];
const ka=Math.min(.5,radius/Math.hypot(prev.x-v.x,prev.y-v.y)),kb=Math.min(.5,radius/Math.hypot(next.x-v.x,next.y-v.y));
const a={x:v.x+(prev.x-v.x)*ka,y:v.y+(prev.y-v.y)*ka},b={x:v.x+(next.x-v.x)*kb,y:v.y+(next.y-v.y)*kb};
return Array.from({length:6},(_,j)=>{const t=j/5,u=1-t;return {x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y};});
});
}
function ringTint(cell){
const rings=materials.rings||[materials.board];
return rings[(Math.abs(cell.q)+Math.abs(cell.r)+Math.abs(cell.q+cell.r))/2%rings.length];
}
const FLAT=Math.cos(Math.PI/6);
function hull(points){
const sorted=[...points].sort((a,b)=>a.x-b.x||a.y-b.y),lower=[],upper=[],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);}
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 apronSector(hull,side,outerR,steps=16,inset=.035){
const a0=-Math.PI/2+side*Math.PI/3+inset,a1=-Math.PI/2+(side+1)*Math.PI/3-inset,pts=[];
for(let i=0;i<=steps;i++){
const a=a0+(a1-a0)*i/steps,dir={x:Math.cos(a),y:Math.sin(a)};
const t=rimCrossing(hull,{x:0,y:0},dir),r=(t??0)+.08;
pts.push({x:dir.x*r,y:dir.y*r});
}
for(let i=steps;i>=0;i--){
const a=a0+(a1-a0)*i/steps;
pts.push({x:Math.cos(a)*outerR,y:Math.sin(a)*outerR});
}
return pts;
}
function rgba(hex,a){const n=hex.replace('#','');return `rgba(${parseInt(n.slice(0,2),16)},${parseInt(n.slice(2,4),16)},${parseInt(n.slice(4,6),16)},${a})`;}
function drawApronFans(hull,outerR){
const fill='#d1d1d1';
for(let side=0;side<6;side++){
polygon(apronSector(hull,side,outerR),rgba(fill,.2));
const inset=.035,a0=-Math.PI/2+side*Math.PI/3+inset,a1=-Math.PI/2+(side+1)*Math.PI/3-inset;
ctx.beginPath();ctx.arc(0,0,outerR,a0,a1);
ctx.strokeStyle=rgba(fill,.16);ctx.lineWidth=.055;ctx.lineCap='round';ctx.stroke();
}
}
function aircraft(plane,where,alpha=1){
ctx.save();ctx.translate(where.x,where.y);ctx.globalAlpha=alpha;
const color=pigments[plane.color].color;
const face=roundedHex(0,0,.90,.16);
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]];
polygon(silhouette.map(([x,y])=>({x,y})),'#FFFFFF');
line([{x:0,y:-.46},{x:0,y:-.28}],color,.07);
ctx.restore();
label(String.fromCharCode(65+plane.color),-.50,-.46,.24,world.ink);
if(plane.state==='parked'){
ctx.beginPath();ctx.arc(.52,.50,.22,0,Math.PI*2);ctx.fillStyle=direction.accent;ctx.fill();label('P',.52,.50,.26,'#FFFFFF');
}
ctx.restore();
}
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);
// Six equal apron fans, fixed around the board centre. They replace the old
// strip runways so each visual flat (the ABCDEF wedges) is a readable sector.
const radius=boardRadius(game.level.cells),apronR=apronDistance(radius);
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);
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,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);
for(const cell of board.cells){const p=position(cell.q,cell.r);polygon(roundedHex(p.x,p.y,.90,.16),ringTint(cell));}
// Runways stay one piece with the sugar bricks: an end that joins a neighbouring
// brick keeps its port radius, while a board-edge end is pulled inside the flat side.
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 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});
if(t===null)return null;
const reach=Math.max(0,t-trackWidth/2);
return {x:point.x+ux*reach,y:point.y+uy*reach};
};
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],points=trackPoints(p.x,p.y,1,d,b),shrink=1-visualRadius/portRadius;
const last=points[points.length-1];
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=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);
}
}
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);
}
if(feedback&&now<feedback.until){
const pts=feedback.travelPoints||feedback.points;
if(pts?.length){
// Macaron blocked reading: a chunky blocked-colour line ending in an orange cross.
line(pts,materials.blocked,.126);
const tip=pts.at(-1),k=.13;
line([{x:tip.x-k,y:tip.y-k},{x:tip.x+k,y:tip.y+k}],materials.blocked,.11);
line([{x:tip.x-k,y:tip.y+k},{x:tip.x+k,y:tip.y-k}],materials.blocked,.11);
}
}
const moving=new Set(motions.map(m=>m.id));
const kicks=new Map(), impacts=[];
for(const motion of motions)if(motion.kind==='bump'&&motion.blocker){
const phase=collisionPhase(Math.min(1,(now-motion.start)/motion.duration),reduced);
const blocker=game.state.planes.find(p=>p.id===motion.blocker);
if(!blocker)continue;
const contact=motion.points.at(-1),target=planeLocation(blocker);
if(phase.kick>0){
const angle=Math.atan2(target.y-contact.y,target.x-contact.x);
kicks.set(blocker.id,{x:Math.cos(angle)*phase.kick*.32,y:Math.sin(angle)*phase.kick*.32});
}
if(phase.impact>0)impacts.push({contact,target,impact:phase.impact});
}
// Macaron blocked reading: the blocked cell blushes under the impact.
for(const {target} of impacts)polygon(roundedHex(target.x,target.y,.90,.16),materials.blush);
for(const plane of game.state.planes)if(plane.state!=='gone'&&!moving.has(plane.id)){
const where=planeLocation(plane),kick=kicks.get(plane.id);
if(kick){where.x+=kick.x;where.y+=kick.y;}
aircraft(plane,where);
}
for(const motion of motions){
const progress=Math.min(1,(now-motion.start)/motion.duration);
let where,alpha=1;
if(motion.kind==='shake'){
where=planeLocation(motion.plane);where.x+=reduced?0:Math.sin(progress*36)*.11*(1-progress);
}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);
}
aircraft({...motion.plane,state:motion.kind==='land'&&progress>.96?'parked':motion.plane.state==='parked'?'ground':motion.plane.state},where,alpha);
}
// Macaron blocked reading: an orange cross grows at the contact while the impact holds.
for(const {contact,target,impact} of impacts){
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2,k=.30*impact;
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, 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 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);
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) {
const bp = position(blocker.q, blocker.r);
for (const phase of [(T * 1.3) % 1, (T * 1.3 + .5) % 1]) {
ctx.save(); ctx.globalAlpha = (1 - phase) * .85;
ctx.beginPath(); ctx.arc(bp.x, bp.y, .5 + phase * 1.2, 0, Math.PI * 2);
ctx.strokeStyle = materials.blocked; ctx.lineWidth = .12; ctx.stroke(); ctx.restore();
}
}
}
}
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();}
requestAnimationFrame(draw);
}
function resize(){
const rect=$('airspace').getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);
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();
}
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;
canvas.addEventListener('pointerdown', event => {
if (document.querySelector('dialog[open]') || game.won || game.deadlocked || game.moves <= 0 || game.rotating) return;
const rect = $('airspace').getBoundingClientRect();
dragPointer = { id: event.pointerId, x: event.clientX - rect.left - view.ox, y: event.clientY - rect.top - view.oy };
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('pointercancel', () => { dragPointer = null; });
$('left').onclick=()=>perform({type:'rotate',direction:-1});$('right').onclick=()=>perform({type:'rotate',direction:1});
$('restart').onclick=$('retry').onclick=()=>loadLevel(index);
$('next').onclick=()=>loadLevel((index+1)%LEVELS.length);
$('settings').onclick=()=>show($('help'));
$('sound').onclick=()=>{saved.sound=!saved.sound;persist();sync();};
$('levels').onclick=()=>{
$('level-list').replaceChildren(...LEVELS.map((level,i)=>{
const button=document.createElement('button');button.className=`level-option${i===index?' current':''}`;
const title=document.createElement('strong');title.textContent=`${i+1} · ${level.name}${saved.completed.includes(level.id)?' ✓':''}`;
const caption=document.createElement('small');caption.textContent=level.lesson;
button.append(title,caption);button.onclick=()=>loadLevel(i);return button;
}));show($('menu'));
};
for(const button of document.querySelectorAll('[data-close]'))button.onclick=()=>{button.closest('dialog').close();settle();};
$('result').addEventListener('cancel',event=>event.preventDefault());
$('reward').onclick=async()=>{
const current=game,session=generation;$('reward').disabled=true;
const provider=window.airportAdProvider|| (async()=>({id:crypto.randomUUID(),completed:true}));
const outcome=await requestReward(current,provider,()=>game===current&&generation===session);
if(game!==current||generation!==session)return;
if(outcome.status==='granted'){$('result').close();notice(`已补充 ${game.level.reward.steps} 次操作。`);sync();diagnose();}
else{$('result-copy').textContent=outcome.status==='failed'?'未能获得奖励,请稍后重试。':'未完成奖励,操作次数保持不变。';$('reward').disabled=false;}
};
// Reuse the accepted UI atlas rather than substituting a new icon family.
const atlas=new Image();atlas.src='../../assets/ui-icons-generated.png';atlas.onload=()=>{
for(const target of document.querySelectorAll('[data-icon]')){
const f=ICONS[target.dataset.icon],g=target.getContext('2d');
g.clearRect(0,0,target.width,target.height);
g.fillStyle=target.closest('.restart,.primary')?'#FFFFFF':'#5A3A44';
g.fillRect(0,0,target.width,target.height);
g.globalCompositeOperation='destination-in';
g.drawImage(atlas,f.x,f.y,f.w,f.h,0,0,target.width,target.height);
g.globalCompositeOperation='source-over';
}
};
loadLevel(index);resize();requestAnimationFrame(draw);