import { AirportGame, SIDES, boardAt, catalogs, parkedCount, copy, boardRadius, waitingPose, apronDistance } 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; const HINT_LEVELS = 10; // tutorial levels auto-bring in planes and show the approach hint 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); sync(); 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 (index >= HINT_LEVELS || 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: [planeLocation(plane), ...route.points.slice(1)], color: pigments[plane.color].color, 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[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.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;ip.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 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; 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); 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])); 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;}); 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&&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); } 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); } // 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. 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); 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); 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(); } 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=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 span=(game?apronDistance(boardRadius(game.level.cells)):6)*2+1.8; 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);