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:
toshiocn
2026-09-21 14:25:09 +08:00
parent 6e72b95ad8
commit 1be0e9caf1
18 changed files with 5229 additions and 43 deletions
+64
View File
@@ -0,0 +1,64 @@
# 六角机场实验室
独立的起降调度原型。前三关是 19 格教学;视觉使用原工程马卡龙方案。不覆盖主线关卡或存档。
## 运行
在项目根目录运行 `npm run dev`,打开 [六角机场](http://localhost:4173/labs/airport/index.html)。静态打包包含本实验目录。设计规则见 [设计文档](../../docs/airport-design.md)。
点击地面飞机起飞,点击场外停机坪上的飞机降落;左右转每次 60°。成功、失败与旋转都消耗一次。灰色轨道不分颜色;来机匹配彩色专用机位。P 标记表示已停好的来机。它再次起飞后,停到实际出口那条边的停机坪,仍需重新停好。
## 关卡证据
所有数据冻结在 `levels.js`。`solution` 是最优免费解,`experience` 是制作意图回放;第三关后者演示来机阻挡及再次起飞换侧。
| 关卡 | 格子 | 最少操作 | 初始额度 | 体验回放 | 先清全盘再接机最少操作 | 最优解保留原有飞机 |
|---|---:|---:|---:|---:|---:|---:|
| 留一点空间 | 19 | 5 | 8 | 5 | 10 | 3 |
| 同色,不同方向 | 19 | 7 | 10 | 7 | 11 | 4 |
| 让路与换侧 | 19 | 6 | 13 | 11 | 16 | 6 |
| 更大的停机坪 | 37 | 8 | 12 | 8 | 18 | 6 |
| 绕一圈再接入 | 37 | 11 | 15 | 11 | 17 | 3 |
| 空港全景 | 61 | 10 | 14 | 10 | 20 | 8 |
前三关为教学。第 4、5 关是半径 3 的练习关,地面 8 架原有飞机。第 6 关把棋盘放大到半径 4(61 格),地面原有飞机增至 13 架,用来承载更多初始棋子,而不是加新规则。最优性通过统一动作成本 BFS 的完整找到最短路径证明;状态上限或深度上限只返回 unknown。第三关的 11 步体验回放保留 5 架原有飞机,与最优解保留 6 架不同。
每关通过空棋盘、六朝向、六边、每色完整结果的无歧义校验。入口侧按边界端口向外法线归类,名称依次为上方、右上方、右下方、下方、左下方、左上方;角部不同端口各有唯一归属。来机画在对应边外侧的停机坪上,不使用正北等罗盘悬停点。
```sh
npm run test:airport
npm run airport:verify
npm run airport:generate
npm run airport:practice
npm run build:airport
```
教学三关生成器使用固定种子 9172026。练习关生成器使用 9172027,追加两个 37 格关与一个 61 格关,不改写前三关。不会在玩家进入页面时随机生成。
## 结构
- `core.js`:纯规则、状态变换、碰撞、原子预占、求解和关卡校验。
- `app.js` / `index.html` / `style.css`:Canvas 棋盘、可访问按钮、六侧等待区和动画。
- `solver-worker.js`:异步检查局面,未完成搜索不冒充已证明死局。
- `rewards.js`:广告适配边界、会话隔离和幂等奖励。
- `levels.js`:冻结关卡、最优解与体验解。
规则层 `transition` 供无动画搜索使用;`AirportGame.begin/finish` 管理动画期间操作锁。降落立即占位,来机起飞立即取消完成状态并更新出口侧,动画完成前该飞机不能再次点击。配置的轨道和机位在运行时固定;编辑它们后须调用 `invalidateCatalogs`,`validateAirportLevel` 已自行刷新缓存。
## 广告与存储边界
尚未接入真实广告 SDK。默认界面明确显示“测试补充”,每次 +5 次,每关最多 3 次,仅用来验证奖励流程。
宿主可在页面模块运行前提供 `window.airportAdProvider = async ({levelId, steps}) => ({id, completed})`。只有真实激励广告完成回调才能返回 `completed: true`;`id` 必须是唯一奖励凭据。取消返回 `completed: false`;失败拒绝 Promise。生产宿主负责验证凭据,本地幂等防重复不替代服务端或 SDK 的奖励校验。
广告期间切关或重来后,旧回调不能给新局加步数;确认死局不提供加步数入口。搜索未知时不保证加步数能解围。
唯一持久化键是 `hex-airport-v1`,保存本实验已完成关卡、上次关卡和音效偏好,不保存正在进行的一局,也不读写原作进度。没有撤销、主动提示、拖动旋转或缩放;原型只保留明确按钮操作。
## 验证范围
自动测试覆盖免费回放、最优与全清成本、六侧唯一性、反向入位、首个阻挡、邻格擦碰、入位末段碰撞、连点抢位、失败扣步、换侧返回、最后一步通关、搜索未知与死局区分、奖励取消/失败/重复/过期回调。
浏览器验证使用真实页面按钮回放三个关卡,另检查短屏布局、失败反馈、补充次数和模态操作。自动测试与模型回放不能证明玩家理解度或广告效果;尚需真人试玩。
完整执行记录与已有仓库问题见 [验收报告](verification.md)。
+431
View File
@@ -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);
+248
View File
@@ -0,0 +1,248 @@
// Airport rules. Rendering never supplies occupancy or chooses a destination.
import { DIRS, key, opposite, rotateBoard, trace, validateLevel as validateBase, canRotate } from '../../src/core.js';
import { PORTS, position, pathPoints, sweepPath } from '../../src/path-math.js';
// Flat-top edges in screen space. Compass names hid that waiting bays sit on the six sides.
export const SIDES = ['上方', '右上方', '右下方', '下方', '左下方', '左上方'];
export function boardRadius(cells) {
return Math.max(0, ...cells.map(c => (Math.abs(c.q) + Math.abs(c.r) + Math.abs(c.q + c.r)) / 2));
}
export function apronDistance(radius) {
return Math.sqrt(3) * (radius + 0.5) + 1.15;
}
export function waitingPose(plane, planes, radius) {
const group = planes.filter(p => p.kind === 'arrival' && p.state === 'waiting' && p.side === plane.side);
const at = Math.max(0, group.findIndex(p => p.id === plane.id));
const offset = group.length ? at * 1.12 : 0;
const n = PORTS[plane.side], length = Math.hypot(n.x, n.y), x = n.x / length, y = n.y / length;
// The cell cluster's outline is pointy-top: its six wide flat sides sit half a
// step (30°) from the port normals, so bays rest on those sides, never on a point.
const c = Math.cos(Math.PI / 6), s = Math.sin(Math.PI / 6);
const bx = x * c - y * s, by = x * s + y * c;
const d = apronDistance(radius);
return { x: bx * (d + offset), y: by * (d + offset), angle: Math.atan2(-by, -bx) + Math.PI / 2 };
}
export const copy = value => structuredClone(value);
const onBoard = plane => plane.state === 'ground' || plane.state === 'parked';
const catalogCache = new WeakMap();
// Runtime layouts are fixed. Editors must invalidate after changing cells/stands.
export function invalidateCatalogs(level) { catalogCache.delete(level); }
export function createState(level) {
return {
orientation: 0,
planes: [
...level.originals.map(p => ({ ...p, kind: 'original', state: 'ground' })),
...level.arrivals.map(p => ({ ...p, kind: 'arrival', state: 'waiting' })),
],
};
}
export function isWon(state) {
const arrivals = state.planes.filter(p => p.kind === 'arrival');
return arrivals.length > 0 && arrivals.every(p => p.state === 'parked');
}
export function parkedCount(state) { return state.planes.filter(p => p.state === 'parked').length; }
export function boardAt(level, orientation) { return rotateBoard({ cells: level.cells }, orientation); }
export function departureGeometry(board, plane) {
const route = trace(board, plane, [], true);
if (route.status !== 'exit') return { ...route, points: pathPoints(route) };
const side = route.steps.at(-1).exit;
return { ...route, side, points: pathPoints(route) };
}
export function catalogs(level) {
if (catalogCache.has(level)) return catalogCache.get(level);
const result = Array.from({ length: 6 }, (_, orientation) => {
const board = boardAt(level, orientation), arrivals = [];
for (const stand of level.stands) for (let dir = 0; dir < 6; dir++) {
const departure = departureGeometry(board, { ...stand, dir, id: `stand:${stand.id}` });
if (departure.status !== 'exit') continue;
arrivals.push({
status: 'land', standId: stand.id, color: stand.color, q: stand.q, r: stand.r,
dir: opposite(dir), side: departure.side,
points: [...departure.points].reverse(), steps: [...departure.steps].reverse(),
signature: `${stand.id}:${dir}:${departure.side}`,
});
}
return { board, arrivals, departures: new Map() };
});
catalogCache.set(level, result);
return result;
}
function obstacles(state, excludeId) {
return state.planes.filter(p => onBoard(p) && p.id !== excludeId)
.map(p => ({ ...position(p.q, p.r), id: p.id }));
}
export function checkLanding(level, state, planeId) {
const plane = state.planes.find(p => p.id === planeId);
if (!plane || plane.state !== 'waiting') return { status: 'invalid' };
const routes = catalogs(level)[state.orientation].arrivals
.filter(r => r.side === plane.side && r.color === plane.color);
if (!routes.length) return { status: 'no-route' };
const occupied = new Set(state.planes.filter(onBoard).map(p => key(p.q, p.r)));
const free = routes.filter(r => !occupied.has(key(r.q, r.r)));
if (!free.length) return { status: 'no-stand' };
const blockers = obstacles(state), legal = [], blocked = [];
for (const route of free) {
const hit = sweepPath(route.points, blockers);
if (!hit) legal.push(route);
else blocked.push({ ...route, status: 'blocked', blocker: hit.blocker, collisionPoint: hit.point, travelPoints: hit.points });
}
if (legal.length > 1) return { status: 'ambiguous', candidates: legal };
if (legal.length === 1) return { ...legal[0], plane: { ...plane } };
// Production levels pass the empty-board uniqueness validator. Never silently
// pick one diagnostic route on an invalid, multiply-connected fixture.
if (blocked.length !== 1) return { status: 'ambiguous', candidates: blocked };
return { ...blocked[0], plane: { ...plane } };
}
export function checkDeparture(level, state, planeId) {
const plane = state.planes.find(p => p.id === planeId);
if (!plane || !onBoard(plane)) return { status: 'invalid' };
const catalog = catalogs(level)[state.orientation];
const cacheKey = `${plane.q},${plane.r},${plane.dir}`;
if (!catalog.departures.has(cacheKey)) catalog.departures.set(cacheKey, departureGeometry(catalog.board, plane));
const route = catalog.departures.get(cacheKey);
const points = route.points;
// Preserve the original long exit-ray collision check, without drawing a
// 24-unit flight across the screen.
const swept = route.status === 'exit'
? [...points, { x: points.at(-1).x + PORTS[route.side].x * 24, y: points.at(-1).y + PORTS[route.side].y * 24 }]
: points;
const hit = sweepPath(swept, obstacles(state, planeId));
if (hit) return { ...route, status: 'blocked', plane: { ...plane }, blocker: hit.blocker, collisionPoint: hit.point, travelPoints: hit.points };
return { ...route, plane: { ...plane } };
}
export function transition(level, state, action) {
if (action.type === 'rotate') {
if (action.direction !== 1 && action.direction !== -1) return { result: { status: 'invalid' }, state };
return { result: { status: 'rotated', direction: action.direction }, state: { ...state, orientation: (state.orientation + action.direction + 6) % 6 } };
}
if (action.type !== 'tap') return { result: { status: 'invalid' }, state };
const plane = state.planes.find(p => p.id === action.id);
const result = plane?.state === 'waiting' ? checkLanding(level, state, action.id) : checkDeparture(level, state, action.id);
if (result.status !== 'land' && result.status !== 'exit') return { result, state };
const updated = { ...plane };
if (result.status === 'land') Object.assign(updated, { state: 'parked', q: result.q, r: result.r, dir: result.dir, standId: result.standId });
else if (plane.kind === 'original') updated.state = 'gone';
else {
updated.state = 'waiting'; updated.side = result.side;
delete updated.q; delete updated.r; delete updated.dir; delete updated.standId;
}
return { result, state: { ...state, planes: state.planes.map(p => p.id === plane.id ? updated : p) } };
}
export class AirportGame {
constructor(level) {
this.level = level; this.state = createState(level); this.moves = level.moveLimit;
this.busy = new Set(); this.rotating = false; this.receipts = new Set(); this.rewardCount = 0;
this.actions = []; this.deadlocked = false;
}
get won() { return isWon(this.state); }
begin(action) {
if (this.won || this.deadlocked) return { status: 'finished' };
if (this.rotating || (action.type === 'rotate' && this.busy.size) || this.busy.has(action.id)) return { status: 'busy' };
const tapped = this.state.planes.find(p => p.id === action.id);
// Bringing a plane in from the apron is free: the budget is spent on turning
// the base and clearing the board, not on the landing itself.
const free = action.type === 'tap' && tapped?.state === 'waiting';
if (!free && this.moves <= 0) return { status: 'out-of-moves' };
const { result, state } = transition(this.level, this.state, action);
if (result.status === 'invalid' || result.status === 'ambiguous') return result;
if (!free) this.moves--;
this.state = state; this.actions.push({ ...action, status: result.status });
if (action.type === 'rotate') this.rotating = true;
else this.busy.add(action.id);
return result;
}
finish(id) { if (id === 'rotation') this.rotating = false; else this.busy.delete(id); }
grantReward(receipt) {
if (receipt?.completed !== true || typeof receipt.id !== 'string' || !receipt.id || this.receipts.has(receipt.id)
|| this.won || this.deadlocked || this.rewardCount >= this.level.reward.maxClaims) return false;
this.receipts.add(receipt.id); this.rewardCount++; this.moves += this.level.reward.steps; return true;
}
}
export function uniquenessIssues(level) {
const issues = [];
for (let orientation = 0; orientation < 6; orientation++) {
const groups = new Map();
for (const route of catalogs(level)[orientation].arrivals) {
const group = `${route.side}:${route.color}`;
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(route.signature);
}
for (const [group, results] of groups) if (results.length > 1) issues.push({ orientation, group, results });
}
return issues;
}
export function validateAirportLevel(level) {
if (!level || !Array.isArray(level.originals) || !Array.isArray(level.arrivals) || !Array.isArray(level.stands)) return ['缺少飞机或停机位数组'];
const errors = validateBase({ version: 1, name: level.name, cells: level.cells, tiles: level.originals, moveLimit: level.moveLimit });
if (errors.length) return errors;
if (!canRotate(level)) errors.push('底盘必须支持六重旋转');
if (!level.arrivals.length) errors.push('至少需要一架来机');
const ids = new Set(level.originals.map(p => p.id)), cells = new Set(level.cells.map(c => key(c.q,c.r))), places = new Set(), standIds = new Set();
const counts = new Map();
for (const p of level.arrivals) {
if (!p || typeof p.id !== 'string' || !p.id || ids.has(p.id)) { errors.push('飞机身份必须唯一'); continue; }
ids.add(p.id);
if (!Number.isInteger(p.side) || p.side < 0 || p.side > 5 || !Number.isInteger(p.color) || p.color < 0 || p.color > 11) errors.push('来机侧或颜色无效');
counts.set(p.color, (counts.get(p.color) || 0) + 1);
}
for (const s of level.stands) {
if (!s || !s.id || standIds.has(s.id) || !cells.has(key(s.q,s.r)) || places.has(key(s.q,s.r))) { errors.push('停机位身份或位置无效'); continue; }
standIds.add(s.id); places.add(key(s.q,s.r));
if (!Number.isInteger(s.color) || s.color < 0 || s.color > 11) errors.push('停机位颜色无效');
counts.set(s.color, (counts.get(s.color) || 0) - 1);
}
if ([...counts.values()].some(n => n !== 0)) errors.push('同色来机与停机位数量必须相等');
if (!Number.isInteger(level.reward?.steps) || level.reward.steps < 1 || !Number.isInteger(level.reward?.maxClaims) || level.reward.maxClaims < 1) errors.push('奖励配置无效');
invalidateCatalogs(level);
if (!errors.length && uniquenessIssues(level).length) errors.push('存在多个合法停靠结果');
return errors;
}
function stateKey(state) {
return state.orientation + '|' + state.planes.map(p => p.state === 'waiting' ? `w${p.side}` : p.state === 'gone' ? 'x' : `${p.q},${p.r},${p.dir}`).join(';');
}
// Uniform-cost (one per action) BFS. Failed taps cannot improve a solution.
// State limits yield "unknown", never an invented deadlock or optimum.
export function solveAirport(level, initial = createState(level), { maxStates = 60000, maxDepth = Infinity, clearFirst = false } = {}) {
const nodes = [{ state: initial, parent: -1, action: null, depth: 0 }], seen = new Set([stateKey(initial)]);
let depthLimited = false;
for (let head = 0; head < nodes.length; head++) {
const node = nodes[head];
if (isWon(node.state)) {
const actions = []; let i = head;
while (nodes[i].parent >= 0) { actions.push(nodes[i].action); i = nodes[i].parent; }
return { status: 'solved', actions: actions.reverse(), minimumActions: node.depth, states: seen.size,
remainingOriginals: node.state.planes.filter(p => p.kind === 'original' && p.state === 'ground').length };
}
if (head >= maxStates) return { status: 'unknown', states: seen.size, reason: 'state-limit' };
if (node.depth >= maxDepth) { depthLimited = true; continue; }
const originalsRemain = node.state.planes.some(p => p.kind === 'original' && p.state === 'ground');
const actions = [{ type: 'rotate', direction: -1 }, { type: 'rotate', direction: 1 },
...node.state.planes.filter(p => p.state !== 'gone' && !(clearFirst && originalsRemain && p.kind === 'arrival'))
.map(p => ({ type: 'tap', id: p.id }))];
for (const action of actions) {
const next = transition(level, node.state, action);
if (!['land','exit','rotated'].includes(next.result.status)) continue;
const signature = stateKey(next.state);
if (seen.has(signature)) continue;
if (seen.size >= maxStates) return { status: 'unknown', states: seen.size, reason: 'state-limit' };
seen.add(signature); nodes.push({ state: next.state, parent: head, action, depth: node.depth + 1 });
}
}
return { status: depthLimited ? 'unknown' : 'deadlock', states: seen.size, reason: depthLimited ? 'depth-limit' : 'exhausted' };
}
export function replay(level, actions) {
const game = new AirportGame(level), events = [];
for (const action of actions) {
const event = game.begin(action); events.push(event);
if (!['land','exit','rotated'].includes(event.status)) throw new Error(`回放失败: ${JSON.stringify(action)} ${event.status}`);
game.finish(action.type === 'rotate' ? 'rotation' : action.id);
}
return { state: game.state, won: game.won, moves: game.moves, events };
}
+36
View File
@@ -0,0 +1,36 @@
<!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="airport">
<header class="topbar">
<button class="icon-button" id="levels" aria-label="选择关卡"><canvas data-icon="grid" width="48" height="48"></canvas></button>
<div><p class="eyebrow">六角机场</p><h1 id="level-title">第 1 关</h1></div>
<button class="icon-button" id="settings" aria-label="设置与玩法"><canvas data-icon="gear" width="48" height="48"></canvas></button>
</header>
<section class="status" aria-label="关卡状态">
<div><span class="caption">剩余操作</span><strong id="moves">—</strong></div>
<div class="mission"><span class="caption">接机进度</span><strong id="progress">0 / 1</strong><div class="progress-track"><i id="progress-bar"></i></div></div>
</section>
<div class="notice" id="notice" role="status" aria-live="polite">清开通路,把来机接入同色停机位。</div>
<section class="airspace" id="airspace" aria-label="机场棋盘与六边停机坪">
<canvas id="board" aria-label="六角轨道、停机位与飞机"></canvas>
<div id="plane-controls" aria-label="可操作飞机"></div>
</section>
<div class="rotation-controls">
<button id="left" class="rotate"><canvas data-icon="left" width="56" height="56"></canvas><span>左转</span><small>1 次</small></button>
<span class="rotation-note">只转轨道<br>飞机与机位保持不动</span>
<button id="right" class="rotate"><canvas data-icon="right" width="56" height="56"></canvas><span>右转</span><small>1 次</small></button>
</div>
<footer><button id="restart" class="restart"><canvas data-icon="restart" width="28" height="28"></canvas>重来</button><span id="level-name">留一点空间</span></footer>
</main>
<dialog id="menu"><button class="close" data-close aria-label="关闭">×</button><h2>选择关卡</h2><p>接齐来机即可过关,无需清空棋盘。</p><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><li>每次起降尝试和旋转都消耗一次操作。</li></ol><p>标有 P 的是已经停靠的来机。所有来机同时停好,才算完成。</p><button id="sound" class="secondary">声音:开</button><p class="small">独立实验版 · 马卡龙视觉 · 进度单独保存</p></dialog>
<dialog id="result"><h2 id="result-title">全部到位</h2><p id="result-copy"></p><div class="dialog-actions"><button id="next" class="primary">下一关</button><button id="reward" class="primary" hidden></button><button id="retry" class="secondary">重新挑战</button></div><p id="reward-note" class="small" hidden>本实验版尚未接入真实广告,测试补充只用于验证奖励流程。</p></dialog>
<script type="module" src="./app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
// The host adapter must return a completed receipt only after its rewarded-ad
// success callback. No ad SDK or network service is bundled with this prototype.
export async function requestReward(game, provider, isCurrent = () => true) {
if (game.rewardPending || game.won || game.deadlocked || !isCurrent()) return { status: 'unavailable' };
game.rewardPending = true;
try {
const receipt = await provider({ levelId: game.level.id, steps: game.level.reward.steps });
if (!isCurrent()) return { status: 'stale' };
return { status: game.grantReward(receipt) ? 'granted' : 'cancelled' };
} catch { return { status: 'failed' }; }
finally { game.rewardPending = false; }
}
+5
View File
@@ -0,0 +1,5 @@
import { solveAirport } from './core.js';
self.onmessage = ({ data }) => {
try { self.postMessage({ request: data.request, result: solveAirport(data.level, data.state, { maxStates: 60000 }) }); }
catch (error) { self.postMessage({ request: data.request, result: { status: 'unknown', reason: String(error) } }); }
};
+9
View File
@@ -0,0 +1,9 @@
@font-face{font-family:'Resource Han Rounded';font-weight:500;font-display:swap;src:url(../../assets/fonts/ResourceHanRoundedCN-Medium.subset.woff2) format('woff2')}
@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,'Arial Rounded MT Bold','Nunito','PingFang SC','Microsoft YaHei',sans-serif;color:#5A3A44;background:#F5EFE7;font-synthesis:none;--primary:#E07416;--surface:#FFFFFF;--ink:#5A3A44;--muted:#A98590}
*{box-sizing:border-box}html,body{background:#F5EFE7}body{margin:0;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom);min-height:100svh}button{font:inherit;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation}button:disabled{opacity:.45;cursor:default}button:focus-visible{outline:3px solid #5A3A44;outline-offset:3px}button:active:not(:disabled){transform:scale(.96)}[hidden]{display:none!important}
.airport{max-width:520px;margin:auto;padding:20px 18px 16px}.topbar{display:flex;justify-content:space-between;align-items:center;text-align:center}.eyebrow{font-size:12px;letter-spacing:.2em;margin:0 0 3px;font-weight:700;color:#A98590}h1{font-size:28px;line-height:1.2;margin:0;color:#5A3A44}.icon-button{width:48px;height:48px;border:0;border-radius:50%;background:#FFFFFF;box-shadow:0 4px 14px rgba(110,70,82,.14);display:grid;place-items:center}.icon-button canvas{width:29px;height:29px}.status{display:flex;justify-content:center;align-items:center;gap:38px;margin:20px 0 8px}.status>div{min-width:100px}.caption{display:block;font-size:12px;color:#A98590;margin-bottom:2px}.status strong{font-size:32px;font-variant-numeric:tabular-nums;color:#5A3A44}.mission strong{font-size:24px}.progress-track{height:5px;width:100px;background:#F1E7E0;border-radius:5px;margin-top:6px;overflow:hidden}.progress-track i{display:block;width:0;height:100%;background:#E07416;transition:width .25s}.notice{min-height:52px;display:flex;align-items:center;justify-content:center;text-align:center;padding:6px 12px;font-size:13px;line-height:1.55;border-radius:18px;color:#7A5560;background:#FFFFFF;box-shadow:0 4px 14px rgba(110,70,82,.08)}.notice.warning{background:#FBE3CB;color:#A44825;box-shadow:none}.airspace{position:relative;width:calc(100% + 24px);margin:0 -12px;height:420px;user-select:none;-webkit-user-select:none;touch-action:manipulation}#board{display:block;width:100%;height:100%;touch-action:none}#plane-controls{position:absolute;inset:0;pointer-events:none}.plane-hit{position:absolute;transform:translate(-50%,-50%);min-width:44px;min-height:44px;background:transparent;border:0;border-radius:50%;pointer-events:auto;padding:0;color:transparent}.plane-hit:active:not(:disabled){transform:translate(-50%,-50%) scale(.96)}.plane-hit:focus-visible{outline:3px solid #E07416}.plane-hit.queued{outline:2px solid #E07416}.rotation-controls{display:flex;justify-content:center;align-items:center;gap:24px;margin-top:0}.rotate{border:0;background:transparent;min-width:80px;min-height:92px;display:flex;align-items:center;flex-direction:column;font-weight:700;padding:4px;color:#7A5560}.rotate canvas{width:50px;height:50px}.rotate small{font-size:11px;font-weight:400;margin-top:2px;color:#A98590}.rotation-note{text-align:center;font-size:11px;line-height:1.8;color:#A98590}footer{display:flex;justify-content:space-between;align-items:center;padding:14px 8px 0;margin-top:8px;font-size:12px}footer>span{font-weight:700}.restart{background:#E07416;color:#fff;border:0;border-radius:50px;padding:8px 16px;min-height:44px;display:flex;align-items:center;gap:7px;box-shadow:0 4px 14px rgba(224,116,22,.28)}.restart canvas{width:26px;height:26px}
dialog{border:0;border-radius:24px;background:var(--surface);color:var(--ink);padding:28px;max-width:calc(100vw - 32px);width:390px;max-height:90svh;overflow:auto;box-shadow:0 18px 40px rgba(90,58,68,.18)}dialog::backdrop{background:rgba(62,46,53,.5);backdrop-filter:blur(5px)}h2{font-size:24px;margin:0 0 14px}dialog p,dialog li{font-size:14px;line-height:1.75;color:#7A5560}dialog ol{padding-left:21px}.close{position:absolute;right:12px;top:8px;border:0;background:#FFFFFF;box-shadow:0 4px 14px rgba(110,70,82,.14);font-size:28px;width:44px;height:44px;border-radius:50%}.primary,.secondary{border:0;border-radius:50px;min-height:48px;padding:12px 18px;font-weight:700}.primary{background:#E07416;color:#fff}.secondary{background:#FBF6F0;color:#5A3A44}.dialog-actions{display:grid;gap:10px;margin-top:22px}.small{font-size:11px!important;color:#A98590}.level-option{width:100%;text-align:left;margin:8px 0;border:0;border-radius:16px;background:#FBF6F0;padding:14px;font-size:14px}.level-option strong{display:block;margin-bottom:5px}.level-option small{display:block;font-size:12px;line-height:1.6;color:#7A5560}.level-option.current{background:#FBE3CB;box-shadow:inset 0 0 0 2px #E07416}
@media(max-height:760px){.airport{padding-top:12px}.status{margin-top:12px}.airspace{height:370px}.rotation-controls{gap:15px}.rotate{min-height:78px}.rotate canvas{width:40px;height:40px}footer{padding-top:8px;margin-top:4px}.notice{min-height:44px}}
@media(min-width:700px){.airport{margin:18px auto;padding:26px 26px 20px}.airspace{height:490px}}
@media(prefers-reduced-motion:reduce){*{transition:none!important}button:active:not(:disabled){transform:none}.plane-hit:active:not(:disabled){transform:translate(-50%,-50%)}}
+45
View File
@@ -0,0 +1,45 @@
# 六角机场原型验收 · 2026-09-17
## 规则与关卡
- `node --test tests/airport.test.js`:17 项通过(含贴边停机坪几何)。
- `node scripts/verify-airport.mjs`:五关开局免费可解,完整回放通过;每关每色在六朝向、六边均无多结果。
- 最优操作 / 初始预算 / 全清后接机最优操作:第 1 关 5 / 8 / 10;第 2 关 7 / 10 / 11;第 3 关 6 / 13 / 16;第 4 关 8 / 12 / 18;第 5 关 11 / 15 / 17;第 6 关 10 / 14 / 20。
- 最优解保留原有飞机分别为 3、4、6、6、3、8 架。第 3 关另有 11 步换侧体验解,保留 5 架。
- 第 1–3 关 19 格教学;第 4–5 关 37 格、各 8 架地面飞机;第 6 关 61 格、13 架地面飞机。
- 体验解检查确实存在“已停靠来机挡住另一来机 → 起飞到新侧 → 重新接入”的过程。
## 浏览器
- 2026-09-17 贴边改版:页面无控制台错误。来机标签为「可可飞机 in1,上方停机坪待降落」。色名来自马卡龙色库。背景 `#F5EFE7`,字体 Resource Han Rounded。六关半径 2/2/2/3/3/4。本轮自动化主机在完整按钮回放中途断开;规则回放以测试与 `airport:verify` 为准。
- 2026-09-17 放大档位:第 6 关「空港全景」加载后棋盘生成 15 个可点飞机(13 架地面 + 2 架来机),两架来机分别显示在左下方与下方停机坪,剩余操作 14,进度 0 / 2,无控制台错误。截图主机未绘制,改用页面 DOM 与 ARIA 标签核对。
在本地 WebView 中通过页面实际按钮执行,未直接修改游戏状态:
- 第 1 关按 5 步最优解通关,剩余 3 次,保留 3 架原有飞机。
- 第 2 关按 7 步最优解通关,剩余 3 次,保留 4 架原有飞机。
- 第 3 关按 11 步体验解通关,蓝色来机再次起飞后显示东南侧等待,最终剩余 2 次,保留 5 架原有飞机。
- 第 1 关旋转两次后点击受阻来机:次数 6 → 5,提示路线受阻,模型与画面指向第一个阻挡 out4。
- 连续 8 次失败尝试耗尽额度,显示仍有可行路线;“测试补充 +5 次”把额度从 0 加到 5 并保留棋盘。
- 浏览器控制台无错误;此任务测试上下文仅创建 `hex-airport-v1` 存储键。
- 390×844 无横向或纵向溢出。360×640 保留至少 44px 飞机触摸区,无横向溢出,页面允许纵向滚动访问底部控件。
- `npm run build:airport` 通过;使用 `--dist` 在独立端口提供打包页面,再次通过按钮完成第 1 关,确认模块、图集、Worker 和样例均包含在产物中。
截图做过视觉检查;本报告不声称进行了真人体验、原生 Cocos 或实际广告 SDK 验收。
## 打包与已有仓库问题
机场专用验证与静态复制流程:`npm run build:airport`。它校验全部关卡,再使用项目既有静态复制器输出 `dist/`,不改写历史关卡。完整 `npm run build` 还包含旧版本历史档案的原始字节校验。
本次全量 `node --test`:214 项,212 项通过,2 项失败,均指向开始工作前已有的环境/工作区状态:
1. `tests/campaign-versions.test.js` 的历史档案 SHA 校验失败。v0.1、v0.2、v0.3 工作副本是 CRLF;逐个确认 Git HEAD 原始字节及工作副本仅转换为 LF 后的字节均匹配 provenance 中记录的 SHA。未改写历史文件或降低校验标准。
2. `tests/design-packages.test.js` 的本地链接检查失败:`design/README.md` 引用的根目录 `AGENTS.md` 已被删除。初始 `git status` 已显示该删除,本次保留,未自行恢复。
`npm run build` 在同一个 v0.1 原始字节校验处停止。机场静态打包不依赖重新生成这些历史档案。以上问题与新机场测试的通过情况分开报告,不能将全量测试说成全部通过。
## 当前限制
- 飞机为原型 Canvas 造型,沿用已接受的 UI 图标图集;尚非最终美术资产。
- 奖励接口和幂等逻辑已实现,默认补充明确标注为测试奖励;没有接入真实广告或凭据验证服务。
- 不提供玩家撤销、主动提示、拖动旋转、缩放;只保留本轮批准的首轮按钮交互。
- 只验证三个冻结样例,未证明大规模关卡的丰富度、玩家理解度或广告收益。