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 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 }) => { 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); } 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 = []; 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; 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, 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); 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') { 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); 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 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'); $('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=indexi?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=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 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; const face=roundedHex(0,0,.90,.16); polygon(face,color); polygon(face,null,'#FFFFFF',.09); ctx.save();ctx.rotate(where.angle); 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(); 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(); } // 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); 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); 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(); 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); 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)drawStand(stand); if(feedback&&nowm.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); 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); } // 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 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 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); 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(); } } } } 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(){ 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')); // 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 || dragSpin) return; const rect = $('airspace').getBoundingClientRect(); 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('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('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); $('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);