feat(airport): add inbound dispatch prototype and landing effects

This commit is contained in:
toshiocn
2026-10-26 16:11:11 +08:00
parent f40efb8ba9
commit 10962babe3
10 changed files with 1581 additions and 39 deletions
+142 -37
View File
@@ -22,6 +22,9 @@ try { const value = JSON.parse(localStorage.getItem(STORAGE)); if (value && Arra
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 }) => {
@@ -49,9 +52,52 @@ function sound(name) {
} 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 = []; rotation = null; feedback = null; queue = []; diagnosis = null;
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;
@@ -110,7 +156,7 @@ function sync() {
}
for (const button of [...controls.children]) if (!live.has(button.dataset.plane)) button.remove();
}
function perform(action) {
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);
@@ -121,7 +167,8 @@ function perform(action) {
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 };
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);
@@ -181,6 +228,7 @@ function settle() {
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');
@@ -264,6 +312,19 @@ function drawApronFans(hull,outerR){
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;
@@ -271,7 +332,7 @@ function aircraft(plane,where,alpha=1){
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]];
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();
@@ -281,6 +342,22 @@ function aircraft(plane,where,alpha=1){
}
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);
@@ -292,8 +369,15 @@ function draw(now){
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();
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 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);
@@ -328,11 +412,7 @@ function draw(now){
}
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);
}
for(const stand of game.level.stands)drawStand(stand);
if(feedback&&now<feedback.until){
const pts=feedback.travelPoints||feedback.points;
if(pts?.length){
@@ -371,9 +451,13 @@ function draw(now){
}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);
} 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);
}
@@ -384,14 +468,15 @@ function draw(now){
line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,.11);
}
// Runway read: when a waiting plane's edge connects to a same-colour stand, light the
// channel in a neutral hint colour from the plane, across the apron, onto the board
// path. Board runways themselves stay inside the tray. A ripple at the plane, a ring
// at the opening, and an inbound arrow still guide; a blocker ripples red.
if (!motions.length && !rotation && !document.querySelector('dialog[open]')) {
// 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 pts = onboard.length ? [h.from, ...onboard] : [h.from, ...h.points];
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);
@@ -417,10 +502,12 @@ function draw(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();}
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(){
@@ -432,26 +519,44 @@ function resize(){
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;
// 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) return;
if (document.querySelector('dialog[open]') || game.won || game.deadlocked || game.moves <= 0 || game.rotating || dragSpin) return;
const rect = $('airspace').getBoundingClientRect();
dragPointer = { id: event.pointerId, x: event.clientX - rect.left - view.ox, y: event.clientY - rect.top - view.oy };
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('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('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('pointercancel', () => { dragPointer = null; });
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);
+386
View File
@@ -0,0 +1,386 @@
import { hexPoints, position, trackPoints } from '../../src/path-math.js';
import { apronDistance, bayNormal, boardRadius, trayHull, waitingPose } from '../airport/core.js';
import { checkRelocate, COLORS, Inbound, SIDES, catalog } from './core.js';
import { LEVELS } from './levels.js';
const $ = id => document.getElementById(id);
const canvas = $('board'), ctx = canvas.getContext('2d');
const view = { dpr: 1, w: 1, h: 1, ox: 0, oy: 0, size: 1 };
let index = Math.max(0, Math.min(LEVELS.length - 1, (Number(new URLSearchParams(location.search).get('level')) || 1) - 1));
let game, mouths, preview, previewSteps, selected, anim, feedback, poses;
function load(next) {
index = (next + LEVELS.length) % LEVELS.length;
game = new Inbound(LEVELS[index]);
mouths = catalog(LEVELS[index]);
preview = game.state.dial;
previewSteps = 0;
selected = null;
anim = feedback = null;
history.replaceState(null, '', `?level=${index + 1}`);
sync();
draw();
}
function sync() {
const level = LEVELS[index];
$('level-title').textContent = `第 ${index + 1} 关 · ${level.name}`;
$('notice').textContent = level.lesson;
$('stars').textContent = String(game.state.stars);
$('standard').textContent = `/ ${level.standard}`;
const done = game.state.planes.filter(plane => plane.state === 'parked').length;
$('progress').textContent = `${done} / ${game.state.planes.length}`;
$('confirm').disabled = previewSteps === 0 || !!anim;
$('confirm').textContent = previewSteps === 0 ? '确认这一转' : `确认 ${Math.abs(previewSteps)} 格`;
$('undo').disabled = !game.history.length || !!anim;
$('left').disabled = $('right').disabled = !!anim;
const plane = game.state.planes.find(item => item.id === selected);
$('relocate').hidden = !plane || plane.state !== 'parked' || !!anim;
$('preview-note').textContent = previewSteps
? '这是预览。确认才计星。进场仍按已经确认的缺口。'
: '缺口和刻度差几格,转之前就能数。预览不计星。';
}
function resize() {
const rect = canvas.getBoundingClientRect();
view.dpr = Math.min(devicePixelRatio || 1, 2);
view.w = Math.max(1, rect.width);
view.h = Math.max(1, rect.height);
canvas.width = Math.round(view.w * view.dpr);
canvas.height = Math.round(view.h * view.dpr);
draw();
}
function worldPoint(event) {
const rect = canvas.getBoundingClientRect();
return { x: (event.clientX - rect.left - view.ox) / view.size, y: (event.clientY - rect.top - view.oy) / view.size };
}
function poseOf(plane) {
if (anim?.planeId === plane.id) return anim.pose;
if (plane.state === 'waiting') {
const shaped = game.state.planes.map(item => ({ ...item, kind: 'arrival' }));
return waitingPose({ ...plane, kind: 'arrival' }, shaped, boardRadius(LEVELS[index].cells));
}
const at = position(plane.q, plane.r);
return { x: at.x, y: at.y, angle: plane.dir * Math.PI / 3 };
}
function line(points, color, width) {
if (!points || points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (const point of points.slice(1)) ctx.lineTo(point.x, point.y);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
function circle(x, y, r, fill, stroke) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 0.06; ctx.stroke(); }
}
function label(text, x, y, size, color) {
ctx.fillStyle = color;
ctx.font = `700 ${size}px 'Resource Han Rounded','PingFang SC',sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y);
}
function sample(points, t) {
const lengths = [0];
for (let i = 1; i < points.length; i++) lengths.push(lengths.at(-1) + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y));
const target = lengths.at(-1) * t, at = lengths.findIndex(length => length >= target);
const i = Math.max(1, at);
const span = lengths[i] - lengths[i - 1] || 1;
const u = (target - lengths[i - 1]) / span;
const a = points[i - 1], b = points[i];
return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u, angle: Math.atan2(b.x - a.x, -(b.y - a.y)) };
}
function draw() {
const level = LEVELS[index];
ctx.setTransform(view.dpr, 0, 0, view.dpr, 0, 0);
ctx.clearRect(0, 0, view.w, view.h);
const radius = boardRadius(level.cells);
const reach = apronDistance(radius) + 1.15;
view.size = Math.min(view.w, view.h) / (reach * 2);
view.ox = view.w / 2;
view.oy = view.h / 2;
ctx.translate(view.ox, view.oy);
ctx.scale(view.size, view.size);
const hull = trayHull(level.cells);
let hullR = 0;
for (const point of hull) hullR = Math.max(hullR, Math.hypot(point.x, point.y));
const ringR = hullR + 0.55;
ctx.beginPath();
hull.forEach((point, i) => i ? ctx.lineTo(point.x, point.y + 0.12) : ctx.moveTo(point.x, point.y + 0.12));
ctx.closePath();
ctx.fillStyle = '#E6DDD6';
ctx.fill();
ctx.beginPath();
hull.forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
ctx.closePath();
ctx.fillStyle = '#FFFFFF';
ctx.fill();
for (const cell of level.cells) {
const at = position(cell.q, cell.r);
const pts = hexPoints(at.x, at.y, 0.9);
ctx.beginPath();
pts.forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
ctx.closePath();
ctx.fillStyle = '#F8E7DC';
ctx.fill();
}
for (const cell of level.cells) {
const at = position(cell.q, cell.r);
for (let dir = 0; dir < 6; dir++) {
const other = cell.tracks[dir];
if (dir >= other) continue;
line(trackPoints(at.x, at.y, 0.92, dir, other), '#96707B', 0.09);
}
}
for (let side = 0; side < 6; side++) {
const normal = bayNormal(side);
const distance = apronDistance(radius) + 0.72;
label(SIDES[side], normal.x * distance, normal.y * distance, 0.32, '#A98590');
}
for (const mouth of mouths) {
const color = COLORS[mouth.stand.color].color;
const previewOpen = mouth.tick === preview;
const committedOpen = mouth.tick === game.state.dial;
line(mouth.arrivalPoints, previewOpen ? '#F7C948' : 'rgba(150,112,123,.35)', previewOpen ? 0.16 : 0.07);
const gate = mouth.arrivalPoints[0];
circle(gate.x, gate.y, committedOpen ? 0.22 : 0.16, previewOpen ? color : '#FFFFFF', color);
}
ctx.beginPath();
ctx.arc(0, 0, ringR, 0, Math.PI * 2);
ctx.strokeStyle = '#E6DDD6';
ctx.lineWidth = 0.08;
ctx.stroke();
for (let seat = 0; seat < 6; seat++) {
const angle = -Math.PI / 2 + seat * Math.PI / 3;
circle(Math.cos(angle) * ringR, Math.sin(angle) * ringR, 0.1, '#F5EFE7', '#E6DDD6');
}
const groups = new Map();
for (const mouth of mouths) {
if (!groups.has(mouth.tick)) groups.set(mouth.tick, []);
groups.get(mouth.tick).push(mouth);
}
for (const [tick, group] of groups) {
const angle = -Math.PI / 2 + tick * Math.PI / 3;
group.forEach((mouth, slot) => {
const shift = (slot - (group.length - 1) / 2) * 0.28;
const x = Math.cos(angle) * ringR + Math.cos(angle + Math.PI / 2) * shift;
const y = Math.sin(angle) * ringR + Math.sin(angle + Math.PI / 2) * shift;
const gate = mouth.arrivalPoints[0];
line([{ x, y }, gate], COLORS[mouth.stand.color].color, 0.035);
circle(x, y, 0.28, COLORS[mouth.stand.color].color, '#FFFFFF');
});
}
const notchAngle = -Math.PI / 2 + preview * Math.PI / 3;
const nx = Math.cos(notchAngle) * (ringR + 0.42), ny = Math.sin(notchAngle) * (ringR + 0.42);
ctx.save();
ctx.translate(nx, ny);
ctx.rotate(notchAngle + Math.PI / 2);
ctx.beginPath();
ctx.moveTo(0, -0.38);
ctx.lineTo(0.26, 0.22);
ctx.lineTo(-0.26, 0.22);
ctx.closePath();
ctx.fillStyle = previewSteps ? '#E07416' : '#5A3A44';
ctx.fill();
ctx.restore();
if (preview !== game.state.dial) {
const held = -Math.PI / 2 + game.state.dial * Math.PI / 3;
circle(Math.cos(held) * ringR, Math.sin(held) * ringR, 0.12, '#5A3A44');
}
for (const stand of level.stands) {
const at = position(stand.q, stand.r), color = COLORS[stand.color].color;
const occupied = game.state.planes.some(plane => plane.state === 'parked' && plane.q === stand.q && plane.r === stand.r && anim?.planeId !== plane.id);
if (occupied) continue;
ctx.beginPath();
hexPoints(at.x, at.y, 0.62).forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
circle(at.x, at.y, 0.36, '#FFFFFF');
label(COLORS[stand.color].name[0], at.x, at.y, 0.34, color);
}
if (feedback && performance.now() < feedback.until) {
line(feedback.travel, '#F0663A', 0.14);
const tip = feedback.travel.at(-1);
line([{ x: tip.x - 0.16, y: tip.y - 0.16 }, { x: tip.x + 0.16, y: tip.y + 0.16 }], '#F0663A', 0.08);
line([{ x: tip.x - 0.16, y: tip.y + 0.16 }, { x: tip.x + 0.16, y: tip.y - 0.16 }], '#F0663A', 0.08);
}
poses = [];
for (const plane of game.state.planes) {
if (plane.state === 'parked' && anim?.planeId !== plane.id) {
const ray = checkRelocate(level, game.state, plane.id);
if (ray.points) line(ray.points, plane.id === selected ? COLORS[plane.color].color : 'rgba(90,58,68,.35)', plane.id === selected ? 0.1 : 0.05);
}
const pose = poseOf(plane);
poses.push({ id: plane.id, ...pose });
drawPlane(plane, pose);
}
}
function drawPlane(plane, pose) {
const color = COLORS[plane.color].color;
ctx.save();
ctx.translate(pose.x, pose.y);
ctx.rotate(pose.angle || 0);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, -0.62);
ctx.lineTo(0.16, -0.2);
ctx.lineTo(0.55, 0.05);
ctx.lineTo(0.16, 0.12);
ctx.lineTo(0.12, 0.42);
ctx.lineTo(0, 0.28);
ctx.lineTo(-0.12, 0.42);
ctx.lineTo(-0.16, 0.12);
ctx.lineTo(-0.55, 0.05);
ctx.lineTo(-0.16, -0.2);
ctx.closePath();
ctx.fill();
ctx.restore();
if (plane.state === 'parked' && anim?.planeId !== plane.id) {
circle(pose.x + 0.42, pose.y + 0.36, 0.18, '#E07416');
label('P', pose.x + 0.42, pose.y + 0.36, 0.22, '#FFFFFF');
}
if (plane.id === selected) circle(pose.x, pose.y, 0.78, null, '#5A3A44');
}
function frame(now) {
if (!anim) return;
const t = Math.min(1, (now - anim.start) / anim.duration);
const eased = t * t * (3 - 2 * t);
anim.pose = sample(anim.points, eased);
draw();
if (t < 1) requestAnimationFrame(frame);
else finishAnim();
}
function finishAnim() {
anim = null;
sync();
draw();
if (game.won) openResult();
}
function play(points, planeId) {
anim = { points, planeId, start: performance.now(), duration: 720, pose: points[0] };
sync();
requestAnimationFrame(frame);
}
function fail(result, planeId) {
selected = planeId;
if (result.travel) feedback = { travel: result.travel, until: performance.now() + 900 };
$('notice').textContent = result.status === 'blocked' ? '被挡住了。只标第一个阻挡,不计星。' : '这条边现在没有开着的同色口。';
draw();
}
function onPlane(id) {
if (anim || game.won) return;
const plane = game.state.planes.find(item => item.id === id);
if (plane.state === 'parked') {
selected = id;
const ray = checkRelocate(LEVELS[index], game.state, id);
sync();
$('notice').textContent = ray.sideName ? `调边会落到${ray.sideName}。这条射线不随转改变。` : LEVELS[index].lesson;
draw();
return;
}
if (previewSteps) {
sync();
$('notice').textContent = '先确认这一转,或转回原位。预览不能进场。';
return;
}
const result = game.act({ type: 'land', id });
if (result.status !== 'land') { fail(result, id); return; }
selected = null;
const from = waitingPose({ ...plane, kind: 'arrival', state: 'waiting' }, [{ ...plane, kind: 'arrival', state: 'waiting' }], boardRadius(LEVELS[index].cells));
play([from, ...result.mouth.arrivalPoints], id);
sync();
}
function turn(direction) {
if (anim) return;
preview = (preview + direction + 6) % 6;
previewSteps += direction;
if (previewSteps === 0) preview = game.state.dial;
sync();
draw();
}
function confirm() {
if (!previewSteps || anim) return;
game.act({ type: 'confirm', dial: preview, steps: Math.abs(previewSteps) });
previewSteps = 0;
preview = game.state.dial;
sync();
draw();
}
function relocate() {
if (anim || !selected) return;
const before = game.state.planes.find(plane => plane.id === selected);
const result = game.act({ type: 'relocate', id: selected });
if (result.status !== 'relocate') { fail(result, selected); return; }
const end = waitingPose({ id: before.id, kind: 'arrival', state: 'waiting', side: result.side }, [{ id: before.id, kind: 'arrival', state: 'waiting', side: result.side }], boardRadius(LEVELS[index].cells));
selected = null;
play([...result.points, end], before.id);
sync();
}
function openResult() {
const level = LEVELS[index];
$('result-copy').textContent = `提交 ${game.state.stars} / 标准 ${level.standard}。${level.lesson}`;
$('next').hidden = index === LEVELS.length - 1;
$('result').showModal();
}
$('left').onclick = () => turn(-1);
$('right').onclick = () => turn(1);
$('confirm').onclick = confirm;
$('undo').onclick = () => { game.act({ type: 'undo' }); preview = game.state.dial; previewSteps = 0; selected = null; sync(); draw(); };
$('restart').onclick = () => load(index);
$('relocate').onclick = relocate;
$('levels').onclick = () => $('menu').showModal();
$('help-open').onclick = () => $('help').showModal();
$('next').onclick = () => { $('result').close(); load(index + 1); };
$('retry').onclick = () => { $('result').close(); load(index); };
document.querySelectorAll('[data-close]').forEach(button => button.onclick = () => button.closest('dialog').close());
const list = $('level-list');
LEVELS.forEach((level, i) => {
const button = document.createElement('button');
button.textContent = `${i + 1} ${level.name}`;
button.onclick = () => { $('menu').close(); load(i); };
list.append(button);
});
canvas.addEventListener('pointerdown', event => {
const at = worldPoint(event);
const hit = poses?.find(pose => Math.hypot(pose.x - at.x, pose.y - at.y) < 0.9);
if (hit) onPlane(hit.id);
});
new ResizeObserver(resize).observe(canvas);
load(index);
+199
View File
@@ -0,0 +1,199 @@
// Inbound dial. A turn moves a notch on a 6-mark ring. It does not slide tracks
// under aircraft, and it does not call rotateBoard. Mouths are fixed world
// objects: open iff mouth.tick === dial.
import { opposite, trace } from '../../src/core.js';
import { pathPoints, position, sweepPath, PORTS } from '../../src/path-math.js';
import { visualSide, SIDES } from '../airport/core.js';
export { SIDES };
export const COLORS = {
0: { name: '草莓', color: '#EE5398' },
8: { name: '可可', color: '#B5806A' },
};
const boardOf = level => ({ cells: level.cells });
const clone = value => structuredClone(value);
function departure(level, q, r, dir) {
const route = trace(boardOf(level), { id: 'probe', q, r, dir }, [], true);
if (route.status !== 'exit') return { status: route.status, route, points: pathPoints(route) };
const last = route.steps.at(-1);
return { status: 'exit', route, points: pathPoints(route), side: visualSide(last.q, last.r, last.exit), exitDir: last.exit };
}
export function catalog(level) {
return level.mouths.map(mouth => {
const stand = level.stands.find(item => item.id === mouth.standId);
const inbound = departure(level, stand.q, stand.r, mouth.departDir);
const parkedDir = opposite(mouth.departDir);
const outbound = departure(level, stand.q, stand.r, parkedDir);
return {
...mouth,
stand,
side: inbound.side,
sideName: SIDES[inbound.side],
parkedDir,
exitSide: outbound.side,
exitSideName: SIDES[outbound.side],
arrivalPoints: [...inbound.points].reverse(),
departPoints: outbound.points,
cells: inbound.route.steps.length,
};
});
}
export function createState(level) {
return {
dial: level.dial,
stars: 0,
planes: level.arrivals.map(plane => ({
id: plane.id, color: plane.color, side: plane.side, state: 'waiting',
q: null, r: null, dir: null, standId: null, mouthId: null,
})),
};
}
const parked = state => state.planes.filter(plane => plane.state === 'parked');
const obstacles = planes => planes.map(plane => ({ ...position(plane.q, plane.r), id: plane.id }));
export function checkLanding(level, state, planeId) {
const plane = state.planes.find(item => item.id === planeId);
if (!plane || plane.state !== 'waiting') return { status: 'invalid' };
const mouths = catalog(level).filter(mouth => mouth.tick === state.dial && mouth.side === plane.side && mouth.stand.color === plane.color);
if (!mouths.length) return { status: 'no-route', plane };
const taken = new Set(parked(state).map(item => `${item.q},${item.r}`));
const free = mouths.filter(mouth => !taken.has(`${mouth.stand.q},${mouth.stand.r}`));
if (!free.length) return { status: 'no-stand', plane };
const blocks = obstacles(parked(state));
const legal = [], blocked = [];
for (const mouth of free) {
const hit = sweepPath(mouth.arrivalPoints, blocks);
if (!hit) legal.push(mouth);
else blocked.push({ mouth, blocker: hit.blocker, point: hit.point, travel: hit.points });
}
if (legal.length > 1) return { status: 'ambiguous', plane, candidates: legal };
if (legal.length === 1) return { status: 'land', plane, mouth: legal[0] };
if (blocked.length === 1) return { status: 'blocked', plane, ...blocked[0] };
return { status: 'ambiguous', plane, candidates: blocked };
}
export function checkRelocate(level, state, planeId) {
const plane = state.planes.find(item => item.id === planeId);
if (!plane || plane.state !== 'parked') return { status: 'invalid' };
const outbound = departure(level, plane.q, plane.r, plane.dir);
if (outbound.status !== 'exit') return { status: 'no-route', plane };
const end = outbound.points.at(-1), port = PORTS[outbound.exitDir];
const swept = [...outbound.points, { x: end.x + port.x * 4, y: end.y + port.y * 4 }];
const hit = sweepPath(swept, obstacles(parked(state).filter(item => item.id !== plane.id)));
if (hit) return { status: 'blocked', plane, blocker: hit.blocker, point: hit.point, travel: hit.points, side: outbound.side };
return { status: 'relocate', plane, side: outbound.side, sideName: SIDES[outbound.side], points: outbound.points };
}
function applyLanding(state, check) {
const next = clone(state);
const plane = next.planes.find(item => item.id === check.plane.id);
plane.state = 'parked';
plane.q = check.mouth.stand.q;
plane.r = check.mouth.stand.r;
plane.dir = check.mouth.parkedDir;
plane.standId = check.mouth.standId;
plane.mouthId = check.mouth.id;
plane.side = null;
next.stars += 1;
return next;
}
function applyRelocate(state, check) {
const next = clone(state);
const plane = next.planes.find(item => item.id === check.plane.id);
plane.state = 'waiting';
plane.side = check.side;
plane.q = plane.r = plane.dir = plane.standId = plane.mouthId = null;
next.stars += 1;
return next;
}
export function transition(level, state, action) {
if (action.type === 'confirm') {
const dial = action.dial, steps = action.steps;
if (!Number.isInteger(dial) || dial < 0 || dial > 5 || !Number.isInteger(steps) || steps < 1) return { commit: false, result: { status: 'invalid' } };
const next = clone(state);
next.dial = dial;
next.stars += steps;
return { commit: true, state: next, result: { status: 'confirmed', dial, steps } };
}
if (action.type === 'land') {
const check = checkLanding(level, state, action.id);
if (check.status !== 'land') return { commit: false, result: check };
return { commit: true, state: applyLanding(state, check), result: check };
}
if (action.type === 'relocate') {
const check = checkRelocate(level, state, action.id);
if (check.status !== 'relocate') return { commit: false, result: check };
return { commit: true, state: applyRelocate(state, check), result: check };
}
return { commit: false, result: { status: 'invalid' } };
}
export function isWon(state) {
return state.planes.length > 0 && state.planes.every(plane => plane.state === 'parked');
}
export class Inbound {
constructor(level) {
this.level = level;
this.state = createState(level);
this.history = [];
}
get won() { return isWon(this.state); }
act(action) {
if (action.type === 'undo') {
if (!this.history.length) return { status: 'invalid' };
this.state = this.history.pop();
return { status: 'undone' };
}
const outcome = transition(this.level, this.state, action);
if (!outcome.commit) return outcome.result;
this.history.push(clone(this.state));
this.state = outcome.state;
return { ...outcome.result, won: this.won, stars: this.state.stars };
}
}
export function validateInbound(level) {
const issues = [];
const mouths = catalog(level);
for (const mouth of mouths) {
if (mouth.side === undefined) issues.push(`${mouth.id} 没有出口`);
if (mouth.cells < 1 || mouth.cells > 5) issues.push(`${mouth.id} 路径 ${mouth.cells} 格,超出 1–5`);
}
for (let dial = 0; dial < 6; dial++) {
const groups = new Map();
for (const mouth of mouths.filter(item => item.tick === dial)) {
const group = `${mouth.side}:${mouth.stand.color}`;
groups.set(group, (groups.get(group) || 0) + 1);
}
for (const [group, count] of groups) if (count > 1) issues.push(`朝向 ${dial} ${group} 有 ${count} 个口`);
}
const seen = new Set();
for (const mouth of mouths) {
const signature = `${mouth.side}:${mouth.tick}:${mouth.standId}`;
if (seen.has(signature)) issues.push(`重复口 ${signature}`);
seen.add(signature);
}
return issues;
}
// What a reader can count before touching preview. Independent of dial mutation.
export function reading(level, dial) {
return catalog(level).map(mouth => ({
id: mouth.id,
side: mouth.sideName,
tick: mouth.tick,
open: mouth.tick === dial,
right: (mouth.tick - dial + 6) % 6,
left: (dial - mouth.tick + 6) % 6,
exitSide: mouth.exitSideName,
cells: mouth.cells,
}));
}
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#F5EFE7">
<title>进场调度 · 刻度环试玩</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<main class="inbound">
<header class="topbar">
<button class="icon-button" id="levels" aria-label="选择关卡">关</button>
<div><p class="eyebrow">进场调度 · 试玩</p><h1 id="level-title">第 1 关</h1></div>
<button class="icon-button" id="help-open" aria-label="怎么读刻度">?</button>
</header>
<section class="status" aria-label="进度">
<div><span>提交</span><strong id="stars">0</strong><small id="standard">/ 1</small></div>
<div><span>停好</span><strong id="progress">0 / 1</strong></div>
</section>
<p class="notice" id="notice" role="status"></p>
<section class="airspace" id="airspace">
<canvas id="board" aria-label="固定机场、进场口和刻度环"></canvas>
</section>
<div class="controls">
<button id="left" class="turn">左转一格</button>
<button id="confirm" class="confirm" disabled>确认这一转</button>
<button id="right" class="turn">右转一格</button>
</div>
<p class="preview-note" id="preview-note">缺口和刻度差几格,转之前就能数。预览不计星。</p>
<footer>
<button id="undo">撤销</button>
<button id="relocate" hidden>调边</button>
<button id="restart">重来</button>
</footer>
</main>
<dialog id="menu">
<button class="close" data-close aria-label="关闭">×</button>
<h2>六关竖切</h2>
<div id="level-list"></div>
</dialog>
<dialog id="help">
<button class="close" data-close aria-label="关闭">×</button>
<h2>刻度环</h2>
<ol>
<li>棋盘、轨道、飞机都不转。转的是环上的缺口。</li>
<li>每个口固定在一条边上。环上同色的点是它的刻度。</li>
<li>缺口盖住刻度,这个口才开。差几格,转之前数。</li>
<li>左右转是预览,不计星。确认才写入,跨几格计几星。</li>
<li>点场外飞机进场。点已停飞机,再按调边。调边落到已经画出的出口边,转不改变它。</li>
</ol>
</dialog>
<dialog id="result">
<h2>全部到位</h2>
<p id="result-copy"></p>
<div class="dialog-actions">
<button id="next" class="confirm">下一关</button>
<button id="retry" class="turn">再打一次</button>
</div>
</dialog>
<script type="module" src="./app.js"></script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
import { hexagon } from '../../src/core.js';
const board = () => hexagon(2);
// Mouths are fixed. departDir is the heading whose reverse is the arrival.
// Parked heading is the opposite, so 调边 follows a different ray.
export const LEVELS = [
{
id: 'in-1', name: '口已经开了', standard: 1, dial: 0,
lesson: '这个口已经开了,点进来就停好。',
cells: board(),
stands: [{ id: 'cocoa', q: 1, r: -1, color: 8 }],
mouths: [{ id: 'cocoa-right', standId: 'cocoa', departDir: 2, tick: 0 }],
arrivals: [{ id: 'cocoa', color: 8, side: 1 }],
solution: [{ type: 'land', id: 'cocoa' }],
},
{
id: 'in-2', name: '转一下才通', standard: 2, dial: 0,
lesson: '我转这一格,是为了让缺口盖住这个口。',
cells: board(),
stands: [{ id: 'berry', q: 1, r: -1, color: 0 }],
mouths: [{ id: 'berry-up', standId: 'berry', departDir: 0, tick: 1 }],
arrivals: [{ id: 'berry', color: 0, side: 0 }],
solution: [{ type: 'confirm', dial: 1, steps: 1 }, { type: 'land', id: 'berry' }],
},
{
id: 'in-3', name: '一转两口', standard: 2, dial: 0,
lesson: '这一转打开了我的口,也关掉了对面。',
cells: board(),
stands: [{ id: 'cocoa', q: 0, r: 0, color: 8 }],
mouths: [
{ id: 'cocoa-right', standId: 'cocoa', departDir: 1, tick: 0 },
{ id: 'cocoa-left', standId: 'cocoa', departDir: 4, tick: 5 },
],
arrivals: [{ id: 'cocoa', color: 8, side: 4 }],
solution: [{ type: 'confirm', dial: 5, steps: 1 }, { type: 'land', id: 'cocoa' }],
},
{
id: 'in-4', name: '谁先停', standard: 2, dial: 0,
lesson: '先停那架会挡住另一架,所以要换顺序。',
cells: board(),
stands: [
{ id: 'cocoa', q: 0, r: -1, color: 8 },
{ id: 'berry', q: 1, r: -2, color: 0 },
],
mouths: [
{ id: 'cocoa-up', standId: 'cocoa', departDir: 1, tick: 0 },
{ id: 'berry-right', standId: 'berry', departDir: 2, tick: 0 },
],
arrivals: [
{ id: 'cocoa', color: 8, side: 0 },
{ id: 'berry', color: 0, side: 1 },
],
solution: [{ type: 'land', id: 'cocoa' }, { type: 'land', id: 'berry' }],
},
{
id: 'in-5', name: '两架各自进', standard: 3, dial: 0,
lesson: '我转是为了打开这两个口,但还是要一架一架点进来。',
cells: board(),
stands: [
{ id: 'berry', q: 1, r: -1, color: 0 },
{ id: 'cocoa', q: 0, r: 0, color: 8 },
],
mouths: [
{ id: 'berry-right', standId: 'berry', departDir: 2, tick: 1 },
{ id: 'cocoa-left', standId: 'cocoa', departDir: 4, tick: 1 },
],
arrivals: [
{ id: 'berry', color: 0, side: 1 },
{ id: 'cocoa', color: 8, side: 4 },
],
solution: [
{ type: 'confirm', dial: 1, steps: 1 },
{ type: 'land', id: 'berry' },
{ type: 'land', id: 'cocoa' },
],
},
{
id: 'in-6', name: '先停是为了换边', standard: 5, dial: 0,
lesson: '我先停是为了换到那条边,再转开口进来。',
cells: board(),
stands: [
{ id: 'berry', q: 1, r: -1, color: 0 },
{ id: 'cocoa', q: 0, r: -1, color: 8 },
],
mouths: [
{ id: 'berry-up', standId: 'berry', departDir: 0, tick: 0 },
{ id: 'berry-down', standId: 'berry', departDir: 3, tick: 1 },
{ id: 'cocoa-right', standId: 'cocoa', departDir: 2, tick: 1 },
],
arrivals: [
{ id: 'berry', color: 0, side: 0 },
{ id: 'cocoa', color: 8, side: 1 },
],
solution: [
{ type: 'land', id: 'berry' },
{ type: 'relocate', id: 'berry' },
{ type: 'confirm', dial: 1, steps: 1 },
{ type: 'land', id: 'cocoa' },
{ type: 'land', id: 'berry' },
],
},
];
+22
View File
@@ -0,0 +1,22 @@
@font-face{font-family:'Resource Han Rounded';font-weight:700;font-display:swap;src:url(../../assets/fonts/ResourceHanRoundedCN-Bold.subset.woff2) format('woff2')}
:root{font-family:'Resource Han Rounded',ui-rounded,'PingFang SC','Microsoft YaHei',sans-serif;color:#5A3A44;background:#F5EFE7;font-synthesis:none}
*{box-sizing:border-box}html,body{background:#F5EFE7}body{margin:0;min-height:100svh}button{font:inherit;color:inherit;cursor:pointer;border:0;background:transparent}button:disabled{opacity:.4;cursor:default}button:focus-visible{outline:3px solid #5A3A44;outline-offset:3px}[hidden]{display:none!important}
.inbound{max-width:520px;margin:auto;padding:16px 16px 20px;min-height:100svh;display:flex;flex-direction:column}
.topbar{display:flex;align-items:center;justify-content:space-between;text-align:center}
.eyebrow{margin:0;font-size:12px;letter-spacing:.16em;color:#A98590;font-weight:700}h1{margin:0;font-size:26px;line-height:1.2}
.icon-button{width:44px;height:44px;border-radius:50%;background:#fff;box-shadow:0 4px 14px rgba(110,70,82,.14);font-weight:700}
.status{display:flex;justify-content:space-between;margin:14px 0 8px;font-size:13px;color:#A98590}
.status div{display:flex;align-items:baseline;gap:4px}.status strong{color:#5A3A44;font-size:28px;line-height:1}#standard{font-size:16px;color:#A98590}
.notice{min-height:44px;margin:0 0 8px;font-size:15px;line-height:1.45}
.airspace{flex:1;min-height:420px;position:relative}canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none}
.controls{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:8px;margin-top:8px}
.turn,.confirm,#undo,#restart,#relocate,#next,#retry{min-height:48px;border-radius:999px;padding:0 12px;font-weight:700}
.turn{background:#fff;box-shadow:0 3px 0 #E6DDD6}.confirm{background:#E07416;color:#fff;box-shadow:0 3px 0 #C96A0E}
.preview-note{margin:8px 0;text-align:center;font-size:12px;color:#A98590;min-height:18px}
footer{display:flex;gap:8px;justify-content:center}footer button{background:#fff;box-shadow:0 3px 0 #E6DDD6;padding:0 18px}
#relocate{background:#5A3A44;color:#fff}
dialog{border:0;border-radius:24px;background:#fff;color:#5A3A44;padding:24px;width:min(390px,calc(100vw - 32px));box-shadow:0 18px 40px rgba(90,58,68,.18)}dialog::backdrop{background:rgba(62,46,53,.5)}dialog h2{margin:0 0 12px}dialog li,dialog p{font-size:14px;line-height:1.7;color:#7A5560}
.close{position:absolute;right:12px;top:8px;width:36px;height:36px;border-radius:50%;background:#fff;box-shadow:0 4px 14px rgba(110,70,82,.14)}
#level-list{display:grid;gap:8px}#level-list button{text-align:left;background:#F5EFE7;border-radius:14px;padding:12px 14px;min-height:48px}
.dialog-actions{display:flex;gap:8px;margin-top:16px}.dialog-actions button{flex:1}
@media(max-height:760px){.airspace{min-height:300px}h1{font-size:22px}}