feat(airport): add the Hex Airport dispatch lab as a baseline snapshot
Six-side aprons, frozen levels, solver, rewards and verify scripts; the edge-runway readability work continues from here.
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
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;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 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&&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);
|
||||
}
|
||||
// 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<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 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);
|
||||
Reference in New Issue
Block a user