597 lines
35 KiB
JavaScript
597 lines
35 KiB
JavaScript
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<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 (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');
|
||
$('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 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&&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);
|
||
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);
|