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:
@@ -1,42 +0,0 @@
|
|||||||
# Repository Guidelines
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
|
||||||
|
|
||||||
HEX TRAILS is a dependency-free JavaScript hexagonal track puzzle. `index.html` and `style.css` define the responsive browser interface. Keep gameplay, solving, generation, validation, and difficulty scoring in `src/core.js`; coordinate conversion and curve sampling belong in `src/geometry.js`. `src/renderer.js` exposes engine-independent drawing commands plus the Canvas adapter. Browser controls and persistence live in `src/app.js`.
|
|
||||||
|
|
||||||
`levels/campaign.json` and generated `src/levels.js` contain the campaign. `tests/` holds Node tests, `scripts/` provides development utilities, and `docs/` explains design and portability. `cocos/HexTrailsBoard.ts` is the native engine adapter example.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
|
||||||
|
|
||||||
Use Node.js 20 or newer. No dependency installation is required.
|
|
||||||
|
|
||||||
- `npm run dev`: serve the application at `http://localhost:4173`.
|
|
||||||
- `npm test`: run the Node built-in test suite.
|
|
||||||
- `npm run build`: create a static deployment in `dist/`.
|
|
||||||
- `npm run levels`: regenerate both campaign data files using deterministic seeds.
|
|
||||||
|
|
||||||
Open the application over HTTP because it uses ES modules. Do not commit `dist/` or `node_modules/`.
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
|
||||||
|
|
||||||
Use ES modules, two-space indentation, camelCase functions, and PascalCase classes. No formatter or linter is configured. Follow surrounding conventions and avoid unrelated formatting changes. Keep DOM and storage APIs out of the model and geometry modules. Keep gameplay rendering portable: polygons, lines, circles, and PNG atlas sprites. Store generated art and prompts in `assets/`; source rectangles live in `src/sprites.js`. `src/path-math.js` shares geometry between rendering and swept collision detection.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
Name tests `tests/*.test.js`. Cover rule changes with meaningful fixtures, particularly board rotation, swept collisions along curves, blockers, loops, undo, and malformed imports. Campaign changes must validate and solve completely. Test responsive gameplay and editor flows after interface changes. No coverage percentage is enforced.
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
|
||||||
|
|
||||||
No Git history was available when this project was created. Use focused, imperative commit subjects. Describe behavior changes, link relevant issues, and include test results and screenshots for visual changes. State native-engine verification limits explicitly.
|
|
||||||
|
|
||||||
## Agent-Specific Instructions
|
|
||||||
|
|
||||||
Before calling MCP tools, read `/Users/wukaibing/.grok-go/agents-guide.md` for version-specific GrokGo instructions.
|
|
||||||
|
|
||||||
### Level-production scope
|
|
||||||
|
|
||||||
The user has explicitly limited this work to level creation: board layouts, track connections, pieces, difficulty, and level ordering. Use the game's existing interface and interaction logic. Do not independently add controls, hide controls, change layouts or themes, or introduce teaching-specific interface behavior. Any future interface or interaction change requires an explicit user request. Preserve existing campaign numbering and saved progress when integrating level data.
|
|
||||||
|
|
||||||
### Design-system entry point
|
|
||||||
|
|
||||||
For user-requested design/UI work, start at [design/README.md](design/README.md) and [design/AGENTS.md](design/AGENTS.md), then read [design/shared.md](design/shared.md) and the selected `design/directions/<id>/design.md`. [design/manifest.json](design/manifest.json) lists all five stable IDs, canonical documents, previews, and generated tokens. Each design has five themes; design IDs are separate from theme IDs and campaign versions. Authored design.md files must not be overwritten by generators. This entry point does not expand the scope of level-only tasks.
|
|
||||||
@@ -150,3 +150,7 @@ Cocos 示例复用规则、碰撞、渲染命令和图集;HTML 控件、文件
|
|||||||
## 玩法变种 Lab
|
## 玩法变种 Lab
|
||||||
|
|
||||||
[打开独立实验室](http://localhost:4173/labs/variants/index.html):分色归站、机关换轨、接力停靠,各 5 个可解关卡,支持旋转、撤销、提示和重来。仅使用实验室数据,不写入主线存档。规则和验证范围见 [Lab 说明](labs/variants/README.md)。
|
[打开独立实验室](http://localhost:4173/labs/variants/index.html):分色归站、机关换轨、接力停靠,各 5 个可解关卡,支持旋转、撤销、提示和重来。仅使用实验室数据,不写入主线存档。规则和验证范围见 [Lab 说明](labs/variants/README.md)。
|
||||||
|
|
||||||
|
## 六角机场 Lab
|
||||||
|
|
||||||
|
[打开六角机场](http://localhost:4173/labs/airport/index.html):点击起降、旋转轨道,把贴在六条边停机坪上的来机接入同色专用机位。视觉使用第 07 套马卡龙。前三关为 19 格教学,均可在初始额度内完成并保留原有飞机;来机再次起飞后停到实际出口边。轨道不分颜色,失败尝试与旋转也计步。独立进度,不覆盖原主线。见 [游戏设计文档](docs/airport-design.md)、[运行与验收说明](labs/airport/README.md)。运行 `npm run test:airport` 和 `npm run airport:verify` 验证;当前补充次数是明确标注的测试奖励,尚未接入真实广告。
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# 六角机场:起降调度游戏设计文档
|
||||||
|
|
||||||
|
**版本:** v0.1 · 规则基线与原型验收版
|
||||||
|
**日期:** 2026-09-17
|
||||||
|
**项目名:** 六角机场(暂定)
|
||||||
|
**状态:** 本轮讨论确认的规则基线。实现与测试结果见 [机场实验室](../labs/airport/README.md),不以自动测试代替真人体验验证。
|
||||||
|
|
||||||
|
## 1. 游戏定位与设计目标
|
||||||
|
|
||||||
|
### 1.1 游戏概述
|
||||||
|
|
||||||
|
一款基于 HexTrails 六角轨道玩法的竖屏机场调度解谜游戏。
|
||||||
|
|
||||||
|
玩家点击地面飞机使其起飞,清理必要的通路;旋转六角底盘,改变轨道连接;点击棋盘外等待的飞机,使其从所在侧进入,停到预设的同色停机位。
|
||||||
|
|
||||||
|
**在有限操作次数内,让本关全部来机同时完成停靠,即可过关。棋盘上可以保留原有飞机,不要求清空棋盘。**
|
||||||
|
|
||||||
|
### 1.2 核心体验
|
||||||
|
|
||||||
|
玩家判断哪些原有飞机必须先起飞、哪些可以保留,怎样旋转连接来机所在侧,哪架来机应先停,以及是否值得让已停靠来机再次起飞、换侧进入。
|
||||||
|
|
||||||
|
操作保持直接:点击飞机尝试起降,点击左右旋转按钮改变连接。不手动选路线、不拖动飞机、不增加降落确认步骤。
|
||||||
|
|
||||||
|
### 1.3 设计依据与优先级
|
||||||
|
|
||||||
|
- [原作说明](../README.md)。
|
||||||
|
- [原作设计规范](../Design.md)。
|
||||||
|
- [关卡 38—39 录像分析](../../_handoff/gameplay-recording-analysis.md)。
|
||||||
|
- 2026-09-16 至 2026-09-17 本轮机场玩法讨论的最新结论。
|
||||||
|
|
||||||
|
新玩法明确修改的规则优先于原作;其余视觉、轨道、碰撞及交互规范尽量继承。录像用于理解原作体验,不作为新玩法成立的证据,也不据此推导新玩法的平均通关时长。
|
||||||
|
|
||||||
|
### 1.4 与原作的区别
|
||||||
|
|
||||||
|
| 项目 | 原作 | 新玩法 |
|
||||||
|
|---|---|---|
|
||||||
|
| 胜利目标 | 清空棋盘 | 全部来机同时停靠 |
|
||||||
|
| 棋盘棋子 | 带箭头的六角棋子 | 保留六角占位的飞机 |
|
||||||
|
| 颜色 | 外观区分 | 来机与专用机位匹配 |
|
||||||
|
| 轨道颜色 | 统一表现 | 仍然统一,不参与匹配 |
|
||||||
|
| 占位 | 随离场减少 | 起飞减少,降落增加 |
|
||||||
|
| 场外元素 | 无待接入棋子 | 六侧等待的来机 |
|
||||||
|
| 旋转、失败尝试 | 免费 | 消耗操作次数 |
|
||||||
|
| 来机再次起飞 | 无 | 在实际出口侧等待 |
|
||||||
|
| 广告点 | 不继承旧规则 | 激励广告增加操作额度 |
|
||||||
|
|
||||||
|
## 2. 游戏对象与空间规则
|
||||||
|
|
||||||
|
### 2.1 棋盘与轨道
|
||||||
|
|
||||||
|
保留六角棋盘、格内轨道配对、弯道转向和交叉不换轨。旋转以 60° 为单位,只有底盘与轨道旋转,飞机位置和机头方向、停机位位置和颜色、外围六个贴边停机坪均保持固定。
|
||||||
|
|
||||||
|
轨道没有颜色匹配规则。是否能经过取决于连接关系和碰撞判定。
|
||||||
|
|
||||||
|
### 2.2 原有飞机
|
||||||
|
|
||||||
|
原有飞机指开局已经位于棋盘上的飞机。从机头方向进入轨道,成功起飞后永久离场,不计入接机完成数量,颜色不限制起飞路径。离场只释放占位,不把普通格变成停机位。
|
||||||
|
|
||||||
|
不强制所有原有飞机朝外;机头方向是关卡配置的一部分。
|
||||||
|
|
||||||
|
### 2.3 来机
|
||||||
|
|
||||||
|
来机具有固定身份、颜色和当前等待侧。等待时不自行绕盘换侧;点击只检查当前所在侧的入口。成功停靠后占位并可能阻挡其他飞机。
|
||||||
|
|
||||||
|
来机再次起飞后,转到实际离场出口所属侧等待,仍属于未完成目标,必须重新停好。**来机身份不会因为起降变成原有飞机。**
|
||||||
|
|
||||||
|
### 2.4 专用停机位
|
||||||
|
|
||||||
|
停机位在关卡设计时预设,具有明确位置、颜色和专用视觉标识。一位一机;来机只能在同色位完成停靠,同色来机不与机位逐一编号绑定。
|
||||||
|
|
||||||
|
普通空格不能作为目标。空停机位可以经过,不构成障碍;上面有飞机时按正常六角占位阻挡。原有飞机即使位于专用机位上,也不计入接机完成数量。
|
||||||
|
|
||||||
|
每种颜色的机位数量等于该色来机总数,首轮不增加备用位。
|
||||||
|
|
||||||
|
### 2.5 场外停机坪
|
||||||
|
|
||||||
|
来机停靠在棋盘六条边外侧,不悬停在正北等罗盘方位。现工程是平顶六边形:六条边是上方、右上方、右下方、下方、左下方、左上方。竖直的左侧、右侧是尖顶六边形才有的边,对应本棋盘的左右两个顶角,不是入口。
|
||||||
|
|
||||||
|
同侧可有多架、多色来机,同色来机也可分处不同侧。停机坪上的排列不绑定精确入口。每个边界入口和出口必须唯一归属某一条边;角部不同端口各有唯一归属。画面分区和规则归属必须一致。
|
||||||
|
|
||||||
|
## 3. 起飞、降落与碰撞
|
||||||
|
|
||||||
|
### 3.1 原有飞机起飞
|
||||||
|
|
||||||
|
1. 从格心沿机头方向起步。
|
||||||
|
2. 按当前轨道配对追踪唯一行进路线。
|
||||||
|
3. 检查能否离场及完整运动过程是否碰撞。
|
||||||
|
4. 成功时立即释放逻辑占位,播放离场动画,永久离开本关。
|
||||||
|
|
||||||
|
实际离场侧由轨道出口决定,不一定等于初始机头方向。失败起飞不改变位置,碰撞回退表现继承原作。
|
||||||
|
|
||||||
|
### 3.2 场外来机降落
|
||||||
|
|
||||||
|
点击后检查当前所在侧的全部边界入口。合法降落需同时满足:入口属于当前侧、轨道通往预设同色机位、机位未占用或预占、路径有效、完整运动过程不碰撞任何在场飞机的六角占位。
|
||||||
|
|
||||||
|
| 结果 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 恰好一个合法结果 | 立即占位并播放降落动画 |
|
||||||
|
| 没有合法结果 | 留在场外,震动并反馈原因 |
|
||||||
|
| 多个合法结果 | 关卡设计错误,不设置自动选择优先级 |
|
||||||
|
|
||||||
|
不采用最近、最远、编号优先或玩家手选。完整停靠结果包括入口路径、目标机位和停稳朝向;同一机位的不同进场朝向也不能任意选择。
|
||||||
|
|
||||||
|
### 3.3 最后一段停靠
|
||||||
|
|
||||||
|
采用原作“格心出发”的反向连接:从目标格入边进入格心,最后一段也检查碰撞。保持驶入朝向,不自动掉头;后续转盘不改变机头。
|
||||||
|
|
||||||
|
经过空停机位与在此结束运动是两种路径判定;空位不强制截停经过的飞机。
|
||||||
|
|
||||||
|
### 3.4 已停靠来机再次起飞
|
||||||
|
|
||||||
|
起步、轨道跟随和碰撞检查与原有飞机相同。成功后立即释放机位、取消完成状态,沿路离场,随后在实际出口所属侧恢复等待。
|
||||||
|
|
||||||
|
`上方停机坪 → 停靠 → 从下方离场 → 下方停机坪`
|
||||||
|
|
||||||
|
下一次只检查下方入口。重新起飞和再次降落分别消耗次数。
|
||||||
|
|
||||||
|
### 3.5 六边形碰撞
|
||||||
|
|
||||||
|
继承原作六边形碰撞体,不以飞机贴图的透明轮廓代替。检查经过被占用格、弯道接触、邻格擦碰、最后入位段和离场必要延伸段。
|
||||||
|
|
||||||
|
绘制、动画和碰撞使用同一套路径数学。禁止画面绕过却判撞、或飞机动画穿过六角占位。
|
||||||
|
|
||||||
|
### 3.6 降落失败反馈
|
||||||
|
|
||||||
|
| 原因 | 文案与表现 |
|
||||||
|
|---|---|
|
||||||
|
| 所在侧不连接适用目标 | 原地震动,“没有可供行进的路线” |
|
||||||
|
| 有连接但路径受阻 | “路线受阻”,高亮路线及第一个阻挡物 |
|
||||||
|
| 目标已占用或预占 | “没有可用的跑道” |
|
||||||
|
|
||||||
|
多个前后阻挡只高亮行进方向上的第一个;移走后再尝试,才显示后续阻挡。这是点击后的失败反馈,不增加选路、确认步骤,也不自动给完整解答。
|
||||||
|
|
||||||
|
## 4. 操作次数、胜负与状态同步
|
||||||
|
|
||||||
|
### 4.1 计步
|
||||||
|
|
||||||
|
原有飞机起飞尝试、来机降落尝试、已停靠来机起飞尝试、单次 60° 旋转,每个接受执行的动作扣 1 次。成功和失败均计步,重复落地也计步。
|
||||||
|
|
||||||
|
点击空白、设置、阅读已有反馈不属于调度动作;同一运动中的飞机重复点击不形成新指令。若实现拖动旋转,跨越多个 60° 档位时按实际执行档位数计步。
|
||||||
|
|
||||||
|
### 4.2 胜利条件
|
||||||
|
|
||||||
|
全部来机同时占据合法同色机位,不在等待区或再次起飞过程中。原有飞机是否清空不影响判胜,不按累计落地次数计数。
|
||||||
|
|
||||||
|
最后一次操作刚好用尽额度但完成目标时成功优先;通关界面等待必要动画结束。
|
||||||
|
|
||||||
|
### 4.3 步数不足与死局
|
||||||
|
|
||||||
|
次数不足时暂停新的调度操作,可选择激励广告补充或重来。空间无解时,增加次数不能解决,需要重来或后续另行设计的恢复功能。
|
||||||
|
|
||||||
|
只保证开局存在预算内免费解,允许玩家误操作造成死局。当前无飞机可动不等于无解,还需考虑旋转和后续起降。搜索未完成不得误报已证明无解;确认无解时不能把广告加步数推荐为解法。
|
||||||
|
|
||||||
|
### 4.4 点击与动画
|
||||||
|
|
||||||
|
逻辑先于动画:起飞成功即释放原位,降落成功即占用目标并扣步,后续点击按更新后的逻辑棋盘判断。移动贴图不作为额外实时障碍,降落目标预占参与碰撞。
|
||||||
|
|
||||||
|
两架来机争抢同一空位时,第一架判定成功立即占位,第二架在动画未结束时也必须重新判定;无其他合法结果就提示“没有可用的跑道”。
|
||||||
|
|
||||||
|
飞机运动时不执行底盘旋转;旋转期间的点击可暂存,到位后按顺序执行。重来、换关、打开弹窗清理未执行暂存指令。
|
||||||
|
|
||||||
|
来机离场后必须明显飞入实际出口侧等待区,不能消失后无提示换位。
|
||||||
|
|
||||||
|
## 5. 界面、视觉与声音
|
||||||
|
|
||||||
|
### 5.1 视觉方向
|
||||||
|
|
||||||
|
机场实验室使用原工程第 07 套「马卡龙」:奶油色世界、白色软托盘、带白缝的糖砖格、藕紫轨道、糖果色棋子与白色飞机剪影。参考 [马卡龙设计规范](../design/directions/macaron/design.md)。主题只改外观,不改匹配规则。
|
||||||
|
|
||||||
|
彩色飞机和机位是主要阅读对象,装饰不得遮挡轨道、机头、机位和碰撞边界。正顶视飞机保留可感知的六角占位边界。通路不着匹配色。
|
||||||
|
|
||||||
|
### 5.2 颜色与身份
|
||||||
|
|
||||||
|
机位具有彩色边框和专用停靠标识;已停靠来机还需独立完成标记,不只凭朝内朝外区分。本稿默认使用小型 P 标记,正式素材沿用统一图标语言。
|
||||||
|
|
||||||
|
飞机与对应机位附加一致的小型颜色类别符号,辅助辨色。主题改变不能重映射匹配关系。
|
||||||
|
|
||||||
|
### 5.3 布局
|
||||||
|
|
||||||
|
顶部显示关卡、剩余次数和接机进度。棋盘六条边外侧预留停机坪,不遮挡边界入口;同侧多机可辨、可点。左右旋转使用独立区域,重来和设置不覆盖棋盘或停机坪。
|
||||||
|
|
||||||
|
沿用原规范的最小触摸尺寸、文字对比度和模态要求。若保留缩放,棋盘、机位和等待区的相对关系一致,不能改变侧归属。
|
||||||
|
|
||||||
|
### 5.4 动效与声音
|
||||||
|
|
||||||
|
起降沿真实轨道,弯道朝向跟随运动。转盘参考原作 240ms 节奏。降落失败原地震动,原有飞机起飞碰撞回退。来机再次起飞不用永久消除式碎裂表现。
|
||||||
|
|
||||||
|
点击、旋转、起飞、停靠、受阻、通关使用可区分的声音;音效可关。减少动态效果时保留状态文字与必要路线反馈。精确素材与时长属于视觉调校,不改变逻辑。
|
||||||
|
|
||||||
|
## 6. 关卡设计与商业化
|
||||||
|
|
||||||
|
### 6.1 关卡配置
|
||||||
|
|
||||||
|
每关包含棋盘和轨道、原有飞机的位置方向颜色、机位的位置颜色、来机身份颜色初始侧、初始额度、广告补充配置、完整免费回放解和主要调度意图。
|
||||||
|
|
||||||
|
### 6.2 制作原则
|
||||||
|
|
||||||
|
- 局部清障可完成目标。参考解记录最终保留的原有飞机数量,不把落地的来机冒充保留的原有飞机。
|
||||||
|
- 外圈容易停靠可以用于教学;通过全部来机同时停好,避免反复使用一个位置累计完成目标。
|
||||||
|
- 顺序关系来自空间阻挡,不依赖隐藏编号或后台优先级。
|
||||||
|
- 换侧调度有可理解的收益,不能普遍靠反复起降碰运气。
|
||||||
|
- 不要求唯一最优解;只禁止同次降落出现多个合法执行结果。
|
||||||
|
|
||||||
|
### 6.3 预算原则
|
||||||
|
|
||||||
|
初始额度覆盖至少一条完整免费解,普通关留出合理调整余量。不能给最少 10 步的关卡只配 5 步,迫使广告通关。
|
||||||
|
|
||||||
|
记录可行解步数、经证明的最优步数、合理非最优解步数、重排成本、先清全盘再接机是否可行及成本。未证明最优,不冒称最优。
|
||||||
|
|
||||||
|
对用预算抑制全清的关卡,期望:
|
||||||
|
|
||||||
|
> 合理解法及容错次数 ≤ 初始额度 < 先清全盘再接机的最少次数。
|
||||||
|
|
||||||
|
无法同时满足则调整布局,不把预算压成必须精确最优。广告补充后允许更费操作的解法,不要求仍禁止全清。
|
||||||
|
|
||||||
|
### 6.4 教学顺序与棋盘规模
|
||||||
|
|
||||||
|
前三关是教学,使用 19 格(半径 2)小棋盘,依次介绍起飞、清除首个阻挡、旋转且飞机不动、同侧接入同色机位、落地占位、再次起飞换侧、次数内完成。第一关不同时引入多色、多侧、长路和换侧调度。
|
||||||
|
|
||||||
|
教学结束后棋盘逐步放大,对齐原作主线而不是继续堆 19 格:
|
||||||
|
|
||||||
|
| 阶段 | 原作依据 | 机场用法 |
|
||||||
|
|---|---|---|
|
||||||
|
| 入门 1–7 关 | v0.5 前 7 关半径 2(19 格),棋子 3–6 枚;另有 3 关教学序章也用 19 格 | 现有三关:19 格,来机 1–2 架 |
|
||||||
|
| 熟悉 | v0.5 第 8 关起半径 3(37 格);第 1–2 章默认 37 格,棋子约 3–11 | 教学后正式关:37 格,原有飞机与来机增多 |
|
||||||
|
| 舒缓加长 | v0.3/v0.5 后期穿插半径 4(61 格);章末可到半径 5(91 格) | 后期长局:61 格,再视容量到 91 格 |
|
||||||
|
| 容量上限 | v0.5:半径 2/3/4/5 的目标棋子约 6 / 11 / 14 / 17 | 机场额外有来机和机位,地面原有飞机不超过同档容量 |
|
||||||
|
|
||||||
|
原作不把棋盘变大当成变难:大盘可以轻松巡游,小盘也可以做局部判断。机场同样:放大是为了容纳更多初始飞机和更长的进场,不是自动加难。
|
||||||
|
|
||||||
|
当前练习关已按这条曲线落地:教学三关之后是两个 37 格练习关(各 8 架地面原有飞机),再加一个 61 格练习关(13 架地面原有飞机)。放大档位只增加初始棋子与进场距离,不引入新规则。
|
||||||
|
|
||||||
|
v0.1 首章已混用 19/37 格;v0.5 录像中的 38–39 关属于第 1 章,棋子 11 和 15 枚,已超过 19 格教学盘能舒服放下的数量。
|
||||||
|
|
||||||
|
### 6.5 IAA
|
||||||
|
|
||||||
|
以主动选择的激励广告补充次数:展示奖励量,成功后只增加次数、保留棋盘,取消或失败不改变棋盘和次数,避免重复领取。保留重来,不把广告设成初始解法必需。
|
||||||
|
|
||||||
|
奖励数量和次数为可调配置,不宣称商业验证完成;其他广告位不纳入本版。独立原型未接 SDK 时,必须把测试奖励标明为测试,不伪装成真实广告观看。
|
||||||
|
|
||||||
|
## 7. 验证与实现边界
|
||||||
|
|
||||||
|
### 7.1 开局可解
|
||||||
|
|
||||||
|
每关保存并回放不使用广告、不超预算、通过真实路径碰撞判定、全部来机同时停好的完整解;记录原有飞机保留量。允许玩家进入死局,不要求所有操作顺序通关。
|
||||||
|
|
||||||
|
### 7.2 停靠无歧义
|
||||||
|
|
||||||
|
覆盖清障、旋转、占位改变和换侧,不仅检查开局或参考解。首轮使用充分但保守的筛选:移走所有飞机、保留机位,在六个朝向下对每侧每色枚举完整结果,至多一个。占位只会减少通路,不会新增结果。
|
||||||
|
|
||||||
|
这属于制作筛选,不是新增玩家规则;限制过强时可用完整可达状态验证替代。
|
||||||
|
|
||||||
|
### 7.3 重点测试
|
||||||
|
|
||||||
|
| 场景 | 预期 |
|
||||||
|
|---|---|
|
||||||
|
| 清走普通格上的红机 | 不生成红色机位 |
|
||||||
|
| 同侧多个入口,仅一个合法结果 | 自动执行该结果 |
|
||||||
|
| 只有其他侧可接入 | 当前来机不能跨侧找入口 |
|
||||||
|
| 无色轨道连接同色位 | 正常判定降落 |
|
||||||
|
| 弯道擦邻机 | 受阻 |
|
||||||
|
| 多个前后阻挡 | 只高亮第一个 |
|
||||||
|
| 连点抢位 | 第一架预占,第二架重新判断 |
|
||||||
|
| 来机异侧离场 | 换到实际出口侧并恢复未完成 |
|
||||||
|
| 反复使用一个位 | 不累计完成全部目标 |
|
||||||
|
| 最后一次完成目标 | 通关优先 |
|
||||||
|
| 当前不动但旋转可解 | 不误报死局 |
|
||||||
|
| 错误操作导致真死局 | 不承诺加步数可解决 |
|
||||||
|
|
||||||
|
### 7.4 状态与接口
|
||||||
|
|
||||||
|
规则层保存飞机类型、身份、颜色、位置、朝向、状态、等待侧、机位与占用、底盘朝向、额度及动画相关锁。分别返回起飞、降落、旋转结果,提供真实路径、目标、停稳方向或首个阻挡,供表现层使用。
|
||||||
|
|
||||||
|
新求解器必须考虑降落增加占位、换侧、朝向和动作成本,不直接沿用原作“能清就清”的求解策略。
|
||||||
|
|
||||||
|
首先独立实验实现,关卡、进度与原作主线隔离。
|
||||||
|
|
||||||
|
## 8. 首轮范围与未验证结论
|
||||||
|
|
||||||
|
### 8.1 三个教学关
|
||||||
|
|
||||||
|
1. 单机接入:19 格,局部清障后停靠,明显保留原有飞机。
|
||||||
|
2. 同色不同侧:19 格,证明自动匹配与停靠无歧义。
|
||||||
|
3. 双色调度:19 格,包含停靠阻挡、再次起飞换侧与预算。
|
||||||
|
|
||||||
|
这三关只承担教学。正式关从 37 格起放大,见 6.4。不增加计时、燃油、随机来机、尺寸差异、备用位或特殊障碍。
|
||||||
|
|
||||||
|
### 8.2 辅助功能
|
||||||
|
|
||||||
|
撤销不作为核心恢复方式,也不直接继承免费撤销经济。首轮玩家原型仅提供重来和失败反馈;主动提示与撤销的次数、计步和商业化后续独立设计。测试回放不作为玩家通关机制。
|
||||||
|
|
||||||
|
### 8.3 仍需试玩验证
|
||||||
|
|
||||||
|
玩家能否理解同侧自动匹配;多机位情况下无歧义布局是否丰富;额度是否兼容容错与局部清障;换侧是否有目的;死局是否导致过长返工;六边停机坪是否清晰易点。
|
||||||
|
|
||||||
|
**核心验收:玩家能看懂从哪里进入、为何受阻、停下后影响什么,并在无需清空棋盘的情况下完成全部接机。**
|
||||||
@@ -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)。
|
||||||
@@ -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);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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) } }); }
|
||||||
|
};
|
||||||
@@ -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%)}}
|
||||||
@@ -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 图标图集;尚非最终美术资产。
|
||||||
|
- 奖励接口和幂等逻辑已实现,默认补充明确标注为测试奖励;没有接入真实广告或凭据验证服务。
|
||||||
|
- 不提供玩家撤销、主动提示、拖动旋转、缩放;只保留本轮批准的首轮按钮交互。
|
||||||
|
- 只验证三个冻结样例,未证明大规模关卡的丰富度、玩家理解度或广告收益。
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/serve.mjs",
|
"dev": "node scripts/serve.mjs",
|
||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
|
"test:airport": "node --test tests/airport.test.js",
|
||||||
|
"airport:verify": "node scripts/verify-airport.mjs",
|
||||||
|
"airport:generate": "node scripts/design-airport-levels.mjs",
|
||||||
|
"airport:practice": "node scripts/design-airport-practice-levels.mjs",
|
||||||
|
"build:airport": "npm run airport:verify && node scripts/build.mjs",
|
||||||
"build": "npm run levels:versions && npm run levels:v04 && npm run levels:v05:verify && npm run design && node scripts/build.mjs",
|
"build": "npm run levels:versions && npm run levels:v04 && npm run levels:v05:verify && npm run design && node scripts/build.mjs",
|
||||||
"levels": "node scripts/generate-levels.mjs && npm run levels:versions",
|
"levels": "node scripts/generate-levels.mjs && npm run levels:versions",
|
||||||
"levels:versions": "node scripts/build-campaign-versions.mjs",
|
"levels:versions": "node scripts/build-campaign-versions.mjs",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||||
const out=new URL('../dist/',import.meta.url);
|
const out=new URL('../dist/',import.meta.url);
|
||||||
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
||||||
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/car-loop','labs/variants','labs/flower','labs/campaign-review','labs/hex-video/designv2.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/car-loop','labs/variants','labs/flower','labs/campaign-review','labs/airport','labs/hex-video/designv2.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||||
console.log('Static build ready in dist/');
|
console.log('Static build ready in dist/');
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Deterministic offline design of the four teaching levels.
|
||||||
|
// L1/L2 hand-authored (straight board, single direct landing). L3/L4 searched
|
||||||
|
// for the "rotate to connect" and "connect + clear a blocker" teaching beats.
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { hexagon, random, randomTracks, rotateBoard, DIRS, key, opposite } from '../src/core.js';
|
||||||
|
import { catalogs, createState, checkLanding, checkDeparture, solveAirport, replay, validateAirportLevel, uniquenessIssues } from '../labs/airport/core.js';
|
||||||
|
|
||||||
|
const rng = random(9172026);
|
||||||
|
const straight = [3, 4, 5, 0, 1, 2];
|
||||||
|
const turn = [1, 0, 3, 2, 5, 4];
|
||||||
|
const pick = a => a[Math.floor(rng() * a.length)];
|
||||||
|
|
||||||
|
function boardWith(r, overrides = {}) {
|
||||||
|
const cells = hexagon(r).map(c => ({ ...c, tracks: [...straight] }));
|
||||||
|
for (const [k, t] of Object.entries(overrides)) {
|
||||||
|
const [q, r] = k.split(',').map(Number);
|
||||||
|
cells.find(c => c.q === q && c.r === r).tracks = [...t];
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
function exits(board, cell, dir) {
|
||||||
|
const map = new Map(board.cells.map(c => [key(c.q, c.r), c]));
|
||||||
|
let { q, r } = cell, d = dir; const seen = new Set();
|
||||||
|
while (true) {
|
||||||
|
const mark = `${q},${r},${d}`; if (seen.has(mark)) return -1; seen.add(mark);
|
||||||
|
q += DIRS[d][0]; r += DIRS[d][1];
|
||||||
|
const next = map.get(key(q, r));
|
||||||
|
if (!next) return d;
|
||||||
|
d = next.tracks[opposite(d)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function candidates(cells) {
|
||||||
|
const boards = Array.from({ length: 6 }, (_, o) => rotateBoard({ cells }, o));
|
||||||
|
return cells.map(c => ({ q: c.q, r: c.r, sides: boards.map(b => Array.from({ length: 6 }, (_, d) => exits(b, c, d)).filter(d => d >= 0)) }))
|
||||||
|
.filter(c => c.sides.some(s => s.length) && c.sides.every(s => new Set(s).size === s.length));
|
||||||
|
}
|
||||||
|
const base = (cells, stands, arrivals) => ({ id: 'search', name: 'search', cells, stands, arrivals, originals: [], moveLimit: 40, reward: { steps: 5, maxClaims: 3 } });
|
||||||
|
|
||||||
|
// Add `count` originals that are individually clearable and never block a landing
|
||||||
|
// that was legal before they were placed. Used for the no-blocking teaching levels.
|
||||||
|
function addSafeOriginals(level, count, palette) {
|
||||||
|
const cat = catalogs(level);
|
||||||
|
const board = cat[0].board;
|
||||||
|
const used = new Set([...level.stands.map(s => key(s.q, s.r))]);
|
||||||
|
const arrivals = level.arrivals.map(a => {
|
||||||
|
const st = createState(level); st.orientation = 0;
|
||||||
|
const r = checkLanding(level, st, a.id);
|
||||||
|
return { id: a.id, okStatus: r.status === 'land' };
|
||||||
|
});
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const options = board.cells.filter(c => !used.has(key(c.q, c.r)))
|
||||||
|
.flatMap(c => Array.from({ length: 6 }, (_, d) => d).filter(d => exits(board, c, d) >= 0).map(d => ({ q: c.q, r: c.r, dir: d })));
|
||||||
|
let placed = false;
|
||||||
|
while (options.length && !placed) {
|
||||||
|
const o = options.splice(Math.floor(rng() * options.length), 1)[0];
|
||||||
|
const cand = { id: `out${level.originals.length + 1}`, q: o.q, r: o.r, dir: o.dir, color: palette[i] ?? 4 };
|
||||||
|
level.originals.push(cand);
|
||||||
|
const st = createState(level); st.orientation = 0;
|
||||||
|
let safe = checkDeparture(level, st, cand.id).status === 'exit';
|
||||||
|
for (const a of arrivals) {
|
||||||
|
const s = createState(level); s.orientation = 0;
|
||||||
|
const r = checkLanding(level, s, a.id);
|
||||||
|
if (a.okStatus && r.status !== 'land') safe = false;
|
||||||
|
}
|
||||||
|
if (safe) { used.add(key(cand.q, cand.r)); placed = true; }
|
||||||
|
else level.originals.pop();
|
||||||
|
}
|
||||||
|
if (!placed) throw new Error(`cannot place safe original ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(level, id, name, lesson) {
|
||||||
|
level.id = id; level.name = name; level.lesson = lesson;
|
||||||
|
const solution = solveAirport(level, createState(level));
|
||||||
|
if (solution.status !== 'solved') throw new Error(`${name}: not solvable (${solution.status})`);
|
||||||
|
const cleared = solveAirport(level, createState(level), { maxStates: 250000, clearFirst: true });
|
||||||
|
if (cleared.status !== 'solved') throw new Error(`${name}: clear-first not solvable`);
|
||||||
|
if (!(cleared.minimumActions > level.moveLimit)) throw new Error(`${name}: clear-first ${cleared.minimumActions} not > moveLimit ${level.moveLimit}`);
|
||||||
|
const remainingOriginals = replay(level, solution.actions).state.planes.filter(p => p.kind === 'original' && p.state === 'ground').length;
|
||||||
|
if (remainingOriginals < 1) throw new Error(`${name}: no originals remain`);
|
||||||
|
level.solution = solution.actions;
|
||||||
|
level.experience = solution.actions;
|
||||||
|
level.evidence = {
|
||||||
|
minimumActions: solution.minimumActions,
|
||||||
|
remainingOriginals,
|
||||||
|
clearFirst: { status: 'solved', minimumActions: cleared.minimumActions },
|
||||||
|
experienceActions: solution.actions.length,
|
||||||
|
uniqueEmptyBoard: true,
|
||||||
|
};
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = [];
|
||||||
|
|
||||||
|
// ---- L1: one off-board plane, direct landing into the same-colour stand. ----
|
||||||
|
{
|
||||||
|
const cells = boardWith(2);
|
||||||
|
const level = base(cells, [{ id: 'P1', q: 0, r: 0, color: 8 }], [{ id: 'in1', color: 8, side: 0 }]);
|
||||||
|
addSafeOriginals(level, 3, [2, 5, 3]);
|
||||||
|
level.moveLimit = 3;
|
||||||
|
const done = finish(level, 'airport-1', '留一点空间', '点击场外的可可色飞机,它会飞进同色的可可色机位。');
|
||||||
|
if (done.evidence.minimumActions !== 1) throw new Error('L1 must be a single landing');
|
||||||
|
output.push(done);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- L2: same teaching, stronger colour discrimination (rose decoy near strawberry). ----
|
||||||
|
{
|
||||||
|
const cells = boardWith(2);
|
||||||
|
const level = base(cells, [{ id: 'P1', q: 0, r: 0, color: 0 }], [{ id: 'in1', color: 0, side: 2 }]);
|
||||||
|
addSafeOriginals(level, 3, [7, 5, 3]);
|
||||||
|
level.moveLimit = 3;
|
||||||
|
const done = finish(level, 'airport-2', '同色才停靠', '只有草莓色机位能停草莓色飞机,别被玫瑰色飞机混淆。');
|
||||||
|
if (done.evidence.minimumActions !== 1) throw new Error('L2 must be a single landing');
|
||||||
|
output.push(done);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- L3: one off-board plane, must rotate the board to connect the runway. ----
|
||||||
|
{
|
||||||
|
let found = null;
|
||||||
|
for (let seed = 0; seed < 200000 && !found; seed++) {
|
||||||
|
const cells = hexagon(2).map(c => ({ ...c, tracks: [...turn] }));
|
||||||
|
for (let j = 0, n = 2 + Math.floor(rng() * 7); j < n; j++) { const c = cells[Math.floor(rng() * cells.length)]; c.tracks = randomTracks(rng); }
|
||||||
|
const options = candidates(cells);
|
||||||
|
for (const opt of options) {
|
||||||
|
const s0 = opt.sides[0];
|
||||||
|
for (let side = 0; side < 6; side++) {
|
||||||
|
if (s0.includes(side)) continue;
|
||||||
|
const d = opt.sides[1].includes(side) ? 1 : opt.sides[5].includes(side) ? -1 : 0;
|
||||||
|
if (!d) continue;
|
||||||
|
const level = base(cells, [{ id: 'P1', q: opt.q, r: opt.r, color: 8 }], [{ id: 'in1', color: 8, side }]);
|
||||||
|
if (uniquenessIssues(level).length) continue;
|
||||||
|
const st0 = createState(level); st0.orientation = 0;
|
||||||
|
if (checkLanding(level, st0, 'in1').status !== 'no-route') continue;
|
||||||
|
addSafeOriginals(level, 3, [2, 5, 3]);
|
||||||
|
level.moveLimit = 4;
|
||||||
|
const done = finish(level, 'airport-3', '转一下再接入', '跑道还没连通。旋转棋盘,让来机所在边通到停机位。');
|
||||||
|
const rotations = done.solution.filter(a => a.type === 'rotate').length;
|
||||||
|
const clears = done.solution.filter(a => a.type === 'tap' && a.id.startsWith('out')).length;
|
||||||
|
if (rotations !== 1 || clears !== 0 || done.evidence.minimumActions !== 2) continue;
|
||||||
|
found = done;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) throw new Error('L3 not found');
|
||||||
|
output.push(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- L4: two off-board planes plus blockers; runway must connect and stay clear. ----
|
||||||
|
{
|
||||||
|
let found = null;
|
||||||
|
for (let seed = 0; seed < 200000 && !found; seed++) {
|
||||||
|
const cells = hexagon(2).map(c => ({ ...c, tracks: [...turn] }));
|
||||||
|
for (let j = 0, n = 2 + Math.floor(rng() * 7); j < n; j++) { const c = cells[Math.floor(rng() * cells.length)]; c.tracks = randomTracks(rng); }
|
||||||
|
const options = candidates(cells);
|
||||||
|
if (options.length < 2) continue;
|
||||||
|
const pairs = options.flatMap((a, i) => options.slice(i + 1).filter(b => a.sides.every((s, o) => s.every(d => !b.sides[o].includes(d)))).map(b => [a, b]));
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const stands = pair.map((s, i) => ({ id: `P${i + 1}`, q: s.q, r: s.r, color: i === 0 ? 8 : 0 }));
|
||||||
|
const level = base(cells, stands, []);
|
||||||
|
const cat = catalogs(level);
|
||||||
|
const arrivals = stands.map((s, i) => {
|
||||||
|
const routes = cat.flatMap(c => c.arrivals.filter(r => r.standId === s.id));
|
||||||
|
return { id: `in${i + 1}`, color: s.color, side: pick(routes).side };
|
||||||
|
});
|
||||||
|
if (arrivals[0].side === arrivals[1].side) continue;
|
||||||
|
level.arrivals = arrivals;
|
||||||
|
// A blocker must occupy a cell on one arrival's landing path (teaching: clear the blocker).
|
||||||
|
const used = new Set(stands.map(s => key(s.q, s.r)));
|
||||||
|
const catBoard = catalogs(level)[0].board;
|
||||||
|
const pathCells = new Set();
|
||||||
|
for (const a of arrivals) {
|
||||||
|
const st = createState(level); st.orientation = 0;
|
||||||
|
const r = checkLanding(level, st, a.id);
|
||||||
|
if (r.status === 'land') for (const s of r.steps) pathCells.add(`${s.q},${s.r}`);
|
||||||
|
}
|
||||||
|
const blocker = cells.find(c => !used.has(key(c.q, c.r)) && pathCells.has(key(c.q, c.r)));
|
||||||
|
if (!blocker) continue;
|
||||||
|
// Every original gets a real exit direction so clear-first stays solvable.
|
||||||
|
const validDir = c => Array.from({ length: 6 }, (_, d) => d).find(d => exits(catBoard, c, d) >= 0) ?? 0;
|
||||||
|
level.originals.push({ id: 'out1', q: blocker.q, r: blocker.r, dir: validDir(blocker), color: 2 });
|
||||||
|
used.add(key(blocker.q, blocker.r));
|
||||||
|
for (let i = 1; i < 5; i++) {
|
||||||
|
const free = cells.filter(c => !used.has(key(c.q, c.r)));
|
||||||
|
const c = pick(free); used.add(key(c.q, c.r));
|
||||||
|
level.originals.push({ id: `out${i + 1}`, q: c.q, r: c.r, dir: validDir(c), color: [5, 3, 4, 6][i - 1] });
|
||||||
|
}
|
||||||
|
if (validateAirportLevel(level).length) continue;
|
||||||
|
const solution = solveAirport(level, createState(level));
|
||||||
|
if (solution.status !== 'solved' || solution.remainingOriginals < 1) continue;
|
||||||
|
const rotations = solution.actions.filter(a => a.type === 'rotate').length;
|
||||||
|
const clears = solution.actions.filter(a => a.type === 'tap' && a.id.startsWith('out')).length;
|
||||||
|
if (rotations < 1 || clears < 1) continue;
|
||||||
|
level.moveLimit = Math.max(6, solution.minimumActions + 2);
|
||||||
|
try {
|
||||||
|
const done = finish(level, 'airport-4', '清障再接入', '跑道要连通,通路也不能被飞机挡住。先移开阻挡,再旋转接入两架来机。');
|
||||||
|
if (done.evidence.minimumActions !== solution.minimumActions) continue;
|
||||||
|
found = done;
|
||||||
|
break;
|
||||||
|
} catch { continue; }
|
||||||
|
}
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
if (!found) throw new Error('L4 not found');
|
||||||
|
output.push(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.length !== 4) throw new Error(`Expected 4 levels, got ${output.length}`);
|
||||||
|
for (const l of output) {
|
||||||
|
if (validateAirportLevel(l).length) throw new Error(`invalid ${l.id}`);
|
||||||
|
if (!replay(l, l.solution).won) throw new Error(`replay fail ${l.id}`);
|
||||||
|
console.log(`${l.id} "${l.name}": optimum ${l.evidence.minimumActions}, budget ${l.moveLimit}, remaining ${l.evidence.remainingOriginals}, clear-first ${l.evidence.clearFirst.minimumActions}, actions ${l.solution.length}`);
|
||||||
|
}
|
||||||
|
await writeFile(new URL('../labs/airport/levels.js', import.meta.url),
|
||||||
|
'// Frozen, deterministic airport fixtures. Regenerate with node scripts/design-airport-levels.mjs.\nexport const LEVELS = ' + JSON.stringify(output, null, 2) + ';\n');
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Append two radius-3 practice levels. Leaves the 19-cell teaching trio untouched.
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { hexagon, random, randomTracks, rotateBoard, DIRS, key, opposite } from '../src/core.js';
|
||||||
|
import { catalogs, createState, solveAirport, replay, validateAirportLevel } from '../labs/airport/core.js';
|
||||||
|
import { LEVELS as teaching } from '../labs/airport/levels.js';
|
||||||
|
|
||||||
|
const rng = random(9172027), turn = [1, 0, 3, 2, 5, 4];
|
||||||
|
const pick = a => a[Math.floor(rng() * a.length)];
|
||||||
|
function exits(board, cell, dir) {
|
||||||
|
const map = new Map(board.cells.map(c => [key(c.q, c.r), c]));
|
||||||
|
let { q, r } = cell, d = dir; const seen = new Set();
|
||||||
|
while (true) {
|
||||||
|
const mark = `${q},${r},${d}`; if (seen.has(mark)) return -1; seen.add(mark);
|
||||||
|
q += DIRS[d][0]; r += DIRS[d][1];
|
||||||
|
const next = map.get(key(q, r));
|
||||||
|
if (!next) return d;
|
||||||
|
d = next.tracks[opposite(d)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function candidates(cells) {
|
||||||
|
const boards = Array.from({ length: 6 }, (_, o) => rotateBoard({ cells }, o));
|
||||||
|
return cells.map(c => ({ q: c.q, r: c.r, sides: boards.map(b => Array.from({ length: 6 }, (_, d) => exits(b, c, d)).filter(d => d >= 0)) }))
|
||||||
|
.filter(c => c.sides.some(s => s.length) && c.sides.every(s => new Set(s).size === s.length));
|
||||||
|
}
|
||||||
|
const base = (cells, stands, arrivals) => ({ id: 'search', name: 'search', cells, stands, arrivals, originals: [], moveLimit: 40, reward: { steps: 5, maxClaims: 3 } });
|
||||||
|
const TEACHING = 4; // The four 19-cell lessons are always the first slice.
|
||||||
|
const extra = [];
|
||||||
|
const names = ['更大的停机坪', '绕一圈再接入'];
|
||||||
|
const lessons = ['棋盘变大了,规则不变:清出通路,把来机停到同色机位。', '在 37 格上转动轨道,让不同边的来机各自停好。'];
|
||||||
|
|
||||||
|
for (let seed = 0; seed < 12000 && extra.length < 2; seed++) {
|
||||||
|
const cells = hexagon(3).map(c => ({ ...c, tracks: [...turn] }));
|
||||||
|
for (let j = 0, n = 4 + Math.floor(rng() * 8); j < n; j++) pick(cells).tracks = randomTracks(rng);
|
||||||
|
const options = candidates(cells); if (options.length < 2) continue;
|
||||||
|
const pairs = options.flatMap((a, i) => options.slice(i + 1).filter(b => a.sides.every((s, o) => s.every(d => !b.sides[o].includes(d)))).map(b => [a, b]));
|
||||||
|
if (!pairs.length) continue;
|
||||||
|
const spots = pick(pairs);
|
||||||
|
const twoColor = extra.length === 1;
|
||||||
|
const stands = spots.map((s, i) => ({ id: `P${i + 1}`, q: s.q, r: s.r, color: twoColor && i === 1 ? 0 : 8 }));
|
||||||
|
let level = base(cells, stands, []);
|
||||||
|
const cat = catalogs(level);
|
||||||
|
const arrivals = stands.map((s, i) => {
|
||||||
|
const routes = cat.flatMap(c => c.arrivals.filter(r => r.standId === s.id));
|
||||||
|
return { id: `in${i + 1}`, color: s.color, side: pick(routes).side };
|
||||||
|
});
|
||||||
|
if (arrivals[0].side === arrivals[1].side) continue;
|
||||||
|
level = base(cells, stands, arrivals);
|
||||||
|
const used = new Set(stands.map(s => key(s.q, s.r)));
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const free = cells.filter(c => !used.has(key(c.q, c.r))); if (!free.length) break;
|
||||||
|
const c = pick(free); used.add(key(c.q, c.r));
|
||||||
|
level.originals.push({ id: `out${i + 1}`, q: c.q, r: c.r, dir: Math.floor(rng() * 6), color: [2, 3, 4, 5, 1, 6, 7, 9][i] });
|
||||||
|
}
|
||||||
|
if (validateAirportLevel(level).length) continue;
|
||||||
|
const solution = solveAirport(level, createState(level), { maxStates: 25000, maxDepth: 16 });
|
||||||
|
if (solution.status !== 'solved' || solution.remainingOriginals < 2) continue;
|
||||||
|
const rotations = solution.actions.filter(a => a.type === 'rotate').length;
|
||||||
|
const clearCount = solution.actions.filter(a => a.type === 'tap' && a.id.startsWith('out')).length;
|
||||||
|
if (clearCount < 1 || rotations < 1) continue;
|
||||||
|
level.moveLimit = solution.minimumActions + 4;
|
||||||
|
const cleared = solveAirport(level, createState(level), { maxStates: 40000, maxDepth: 28, clearFirst: true });
|
||||||
|
if (cleared.status !== 'solved' || cleared.minimumActions <= level.moveLimit) continue;
|
||||||
|
if (!replay(level, solution.actions).won) continue;
|
||||||
|
const stage = extra.length;
|
||||||
|
extra.push({
|
||||||
|
...level,
|
||||||
|
id: `airport-${TEACHING + stage + 1}`,
|
||||||
|
name: names[stage],
|
||||||
|
lesson: lessons[stage],
|
||||||
|
solution: solution.actions,
|
||||||
|
experience: solution.actions,
|
||||||
|
evidence: {
|
||||||
|
seedAttempt: seed, radius: 3, cells: 37,
|
||||||
|
minimumActions: solution.minimumActions,
|
||||||
|
remainingOriginals: replay(level, solution.actions).state.planes.filter(p => p.kind === 'original' && p.state === 'ground').length,
|
||||||
|
clearFirst: { status: 'solved', minimumActions: cleared.minimumActions },
|
||||||
|
experienceActions: solution.actions.length, uniqueEmptyBoard: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Practice ${stage + 1}: seed ${seed}, optimum ${solution.minimumActions}, remaining ${extra.at(-1).evidence.remainingOriginals}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra.length !== 2) throw Error(`Only found ${extra.length} practice levels`);
|
||||||
|
|
||||||
|
// Third practice level: radius 4 (61 cells). The original campaign leaves the
|
||||||
|
// 19-cell teaching arc for 37-cell chapters and later ships 61-cell boards, so
|
||||||
|
// the airport roster grows the board instead of stacking more rules on 19 cells.
|
||||||
|
const large = [];
|
||||||
|
const largeNames = ['空港全景'];
|
||||||
|
const largeLessons = ['61 格空港:原有飞机更多,先在人群里清出通路,再把两色来机各自接好。'];
|
||||||
|
const palette = [2, 3, 4, 5, 1, 6, 7, 9, 10, 11, 0, 8, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
for (let seed = 0; seed < 60000 && large.length < 1; seed++) {
|
||||||
|
const cells = hexagon(4).map(c => ({ ...c, tracks: [...turn] }));
|
||||||
|
for (let j = 0, n = 6 + Math.floor(rng() * 10); j < n; j++) pick(cells).tracks = randomTracks(rng);
|
||||||
|
const options = candidates(cells); if (options.length < 2) continue;
|
||||||
|
const pairs = options.flatMap((a, i) => options.slice(i + 1).filter(b => a.sides.every((s, o) => s.every(d => !b.sides[o].includes(d)))).map(b => [a, b]));
|
||||||
|
if (!pairs.length) continue;
|
||||||
|
const spots = pick(pairs);
|
||||||
|
const stands = spots.map((s, i) => ({ id: `P${i + 1}`, q: s.q, r: s.r, color: i === 0 ? 8 : 0 }));
|
||||||
|
let level = base(cells, stands, []);
|
||||||
|
const cat = catalogs(level);
|
||||||
|
const arrivals = stands.map((s, i) => {
|
||||||
|
const routes = cat.flatMap(c => c.arrivals.filter(r => r.standId === s.id));
|
||||||
|
return { id: `in${i + 1}`, color: s.color, side: pick(routes).side };
|
||||||
|
});
|
||||||
|
if (arrivals[0].side === arrivals[1].side) continue;
|
||||||
|
level = base(cells, stands, arrivals);
|
||||||
|
const used = new Set(stands.map(s => key(s.q, s.r)));
|
||||||
|
// Ground planes are placed on cells that actually have an exit, then given one
|
||||||
|
// of those headings. Random headings would leave most of a 61-cell board stuck.
|
||||||
|
const dirOptions = cells.map(c => ({ q: c.q, r: c.r, dirs: Array.from({ length: 6 }, (_, d) => exits(cat[0].board, c, d)).filter(d => d >= 0) })).filter(c => c.dirs.length);
|
||||||
|
for (let i = 0; i < 13; i++) {
|
||||||
|
const free = dirOptions.filter(o => !used.has(key(o.q, o.r))); if (!free.length) break;
|
||||||
|
const c = pick(free); used.add(key(c.q, c.r));
|
||||||
|
level.originals.push({ id: `out${i + 1}`, q: c.q, r: c.r, dir: pick(c.dirs), color: palette[i] });
|
||||||
|
}
|
||||||
|
if (validateAirportLevel(level).length) continue;
|
||||||
|
const solution = solveAirport(level, createState(level), { maxStates: 80000, maxDepth: 14 });
|
||||||
|
if (solution.status !== 'solved' || solution.remainingOriginals < 2) continue;
|
||||||
|
const rotations = solution.actions.filter(a => a.type === 'rotate').length;
|
||||||
|
const clearCount = solution.actions.filter(a => a.type === 'tap' && a.id.startsWith('out')).length;
|
||||||
|
if (clearCount < 1 || rotations < 1) continue;
|
||||||
|
level.moveLimit = solution.minimumActions + 4;
|
||||||
|
const cleared = solveAirport(level, createState(level), { maxStates: 200000, maxDepth: 30, clearFirst: true });
|
||||||
|
if (cleared.status !== 'solved' || cleared.minimumActions <= level.moveLimit) continue;
|
||||||
|
const won = replay(level, solution.actions);
|
||||||
|
if (!won.won) continue;
|
||||||
|
large.push({
|
||||||
|
...level,
|
||||||
|
id: `airport-${TEACHING + extra.length + 1}`,
|
||||||
|
name: largeNames[0],
|
||||||
|
lesson: largeLessons[0],
|
||||||
|
solution: solution.actions,
|
||||||
|
experience: solution.actions,
|
||||||
|
evidence: {
|
||||||
|
seedAttempt: seed, radius: 4, cells: 61,
|
||||||
|
minimumActions: solution.minimumActions,
|
||||||
|
remainingOriginals: won.state.planes.filter(p => p.kind === 'original' && p.state === 'ground').length,
|
||||||
|
clearFirst: { status: 'solved', minimumActions: cleared.minimumActions },
|
||||||
|
experienceActions: solution.actions.length, uniqueEmptyBoard: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Practice 3: seed ${seed}, optimum ${solution.minimumActions}, remaining ${large.at(-1).evidence.remainingOriginals}, clear-first ${cleared.minimumActions}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (large.length !== 1) throw Error(`Only found ${large.length} radius-4 practice levels`);
|
||||||
|
await writeFile(new URL('../labs/airport/levels.js', import.meta.url), '// Frozen, deterministic airport fixtures. Four teaching lessons plus radius-3 and radius-4 practice.\nexport const LEVELS = ' + JSON.stringify([...teaching.slice(0, TEACHING), ...extra, ...large], null, 2) + ';\n');
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { LEVELS } from '../labs/airport/levels.js';
|
||||||
|
import { validateAirportLevel, solveAirport, replay, createState } from '../labs/airport/core.js';
|
||||||
|
const report=[];
|
||||||
|
for(const level of LEVELS){
|
||||||
|
assert.deepEqual(validateAirportLevel(level),[]);
|
||||||
|
const solved=solveAirport(level),clear=solveAirport(level,createState(level),{clearFirst:true});
|
||||||
|
assert.equal(solved.status,'solved');assert.equal(solved.minimumActions,level.evidence.minimumActions);
|
||||||
|
assert.ok(replay(level,level.solution).won);assert.ok(replay(level,level.experience).won);
|
||||||
|
assert.equal(clear.status,'solved');assert.ok(clear.minimumActions>level.moveLimit);
|
||||||
|
report.push({level:level.id,minimum:solved.minimumActions,budget:level.moveLimit,experience:level.experience.length,
|
||||||
|
clearFirst:clear.minimumActions,remainingOriginals:solved.remainingOriginals,unique:'all 6 sides × 6 orientations'});
|
||||||
|
}
|
||||||
|
console.table(report);
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { hexagon, trace, opposite } from '../src/core.js';
|
||||||
|
import { pathPoints, sweepPath, position, PORTS } from '../src/path-math.js';
|
||||||
|
import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose } from '../labs/airport/core.js';
|
||||||
|
import { LEVELS } from '../labs/airport/levels.js';
|
||||||
|
import { requestReward } from '../labs/airport/rewards.js';
|
||||||
|
|
||||||
|
const clone=x=>structuredClone(x);
|
||||||
|
const fixture=()=>({id:'fixture',name:'直线机场',cells:hexagon(2),stands:[{id:'P1',q:0,r:0,color:8}],
|
||||||
|
arrivals:[{id:'in1',color:8,side:0}],originals:[],moveLimit:10,reward:{steps:5,maxClaims:2}});
|
||||||
|
const tap=id=>({type:'tap',id});
|
||||||
|
const execute=(game,action)=>{const result=game.begin(action);game.finish(action.type==='rotate'?'rotation':action.id);return result;};
|
||||||
|
|
||||||
|
test('frozen levels have unique routes at every side/orientation and exact budgeted proofs',()=>{
|
||||||
|
for(const level of LEVELS){
|
||||||
|
assert.deepEqual(validateAirportLevel(level),[],level.id);
|
||||||
|
assert.deepEqual(uniquenessIssues(level),[]);
|
||||||
|
const answer=solveAirport(level);
|
||||||
|
assert.equal(answer.status,'solved');assert.equal(answer.minimumActions,level.evidence.minimumActions);
|
||||||
|
const result=replay(level,level.solution);assert.ok(result.won);assert.ok(result.moves>=0);
|
||||||
|
assert.equal(result.state.planes.filter(p=>p.kind==='original'&&p.state==='ground').length,level.evidence.remainingOriginals);
|
||||||
|
assert.ok(level.evidence.remainingOriginals>0);
|
||||||
|
assert.ok(replay(level,level.experience).won);
|
||||||
|
const all=solveAirport(level,createState(level),{clearFirst:true});
|
||||||
|
assert.equal(all.status,'solved');assert.equal(all.minimumActions,level.evidence.clearFirst.minimumActions);
|
||||||
|
assert.ok(all.minimumActions>level.moveLimit,'clearing all must cost more than free budget');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const bayNormal=side=>{const n=PORTS[side],l=Math.hypot(n.x,n.y),x=n.x/l,y=n.y/l;const c=Math.cos(Math.PI/6),s=Math.sin(Math.PI/6);return{x:x*c-y*s,y:x*s+y*c};};
|
||||||
|
test('waiting aprons sit on the board outline flat sides, outside every cell',()=>{
|
||||||
|
assert.deepEqual(SIDES,['上方','右上方','右下方','下方','左下方','左上方']);
|
||||||
|
assert.equal(boardRadius(hexagon(2)),2);
|
||||||
|
assert.equal(boardRadius(hexagon(3)),3);
|
||||||
|
for(let side=0;side<6;side++){
|
||||||
|
const plane={id:'in1',kind:'arrival',state:'waiting',side};
|
||||||
|
const pose=waitingPose(plane,[plane],2),n=bayNormal(side);
|
||||||
|
assert.ok(Math.abs(pose.x-n.x*apronDistance(2))<1e-9&&Math.abs(pose.y-n.y*apronDistance(2))<1e-9,`side ${side} bay on its flat-side normal`);
|
||||||
|
for(const cell of hexagon(2)){const p=position(cell.q,cell.r);assert.ok(pose.x*n.x+pose.y*n.y>p.x*n.x+p.y*n.y+0.5,`side ${side} bay must stand off the board`);}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('each waiting bay sits on a flat side of the pointy-top outline, nose pointing at the board',()=>{
|
||||||
|
// The cell cluster's outline is pointy-top: its wide flat sides are half a step
|
||||||
|
// (30°) from the port normals at screen angles -60/0/60/120/180/240.
|
||||||
|
for(let side=0;side<6;side++){
|
||||||
|
const plane={id:'in1',kind:'arrival',state:'waiting',side};
|
||||||
|
const pose=waitingPose(plane,[plane],2),n=bayNormal(side);
|
||||||
|
const dir={x:pose.x/Math.hypot(pose.x,pose.y),y:pose.y/Math.hypot(pose.x,pose.y)};
|
||||||
|
assert.ok(Math.abs(dir.x-n.x)<1e-9&&Math.abs(dir.y-n.y)<1e-9,`side ${side} must lie on its flat-side normal`);
|
||||||
|
assert.ok(Math.abs(Math.hypot(pose.x,pose.y)-apronDistance(2))<1e-9);
|
||||||
|
// Nose vector in screen space is (sin(angle), -cos(angle)); it must equal the
|
||||||
|
// inward normal (opposite of the outward normal).
|
||||||
|
const inward={x:-n.x,y:-n.y};
|
||||||
|
assert.ok(Math.abs(Math.sin(pose.angle)-inward.x)<1e-9&&Math.abs(-Math.cos(pose.angle)-inward.y)<1e-9,`side ${side} nose must point at the board`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('same-side aircraft stack outward along the flat-side normal, never tangent',()=>{
|
||||||
|
const group=[0,1,2].map(i=>({id:`in${i}`,kind:'arrival',state:'waiting',side:0}));
|
||||||
|
const n=bayNormal(0),tangent={x:-n.y,y:n.x};
|
||||||
|
let previous=null;
|
||||||
|
for(const plane of group){
|
||||||
|
const pose=waitingPose(plane,group,2);
|
||||||
|
// Zero tangential component: position is a scalar multiple of the normal.
|
||||||
|
assert.ok(Math.abs(pose.x*tangent.x+pose.y*tangent.y)<1e-9,`${plane.id} drifts along the tangent`);
|
||||||
|
const dist=Math.hypot(pose.x,pose.y);
|
||||||
|
if(previous!==null) assert.ok(dist>previous,`${plane.id} must be farther out than the previous`);
|
||||||
|
previous=dist;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('landing is the exact reverse of centre-out geometry and retains inward heading',()=>{
|
||||||
|
const level=fixture(),state=createState(level),route=checkLanding(level,state,'in1');
|
||||||
|
assert.equal(route.status,'land');assert.equal(route.dir,3);
|
||||||
|
const reversed=pathPoints(trace(level,{id:'virtual',q:0,r:0,dir:0},[],true)).reverse();
|
||||||
|
assert.deepEqual(route.points,reversed);assert.deepEqual(route.points.at(-1),position(0,0));
|
||||||
|
assert.ok(catalogs(level)[0].arrivals.every(r=>r.dir===opposite(Number(r.signature.split(':')[1]))));
|
||||||
|
});
|
||||||
|
test('landing selects across a side, not an exact entrance or another side',()=>{
|
||||||
|
const level=LEVELS[2];
|
||||||
|
let checkedNoRoute=false,checkedLand=false;
|
||||||
|
for(let o=0;o<6;o++)for(let side=0;side<6;side++){
|
||||||
|
const state=createState(level);state.orientation=o;state.planes=state.planes.filter(p=>p.kind==='arrival');state.planes[0].side=side;
|
||||||
|
const route=checkLanding(level,state,state.planes[0].id);
|
||||||
|
const matches=catalogs(level)[o].arrivals.filter(r=>r.color===state.planes[0].color&&r.side===side);
|
||||||
|
if(!matches.length){assert.equal(route.status,'no-route');checkedNoRoute=true;}
|
||||||
|
else{assert.equal(route.status,'land');assert.equal(route.side,side);checkedLand=true;}
|
||||||
|
}
|
||||||
|
assert.ok(checkedNoRoute&&checkedLand);
|
||||||
|
});
|
||||||
|
test('clearing a colored original does not create a stand or count toward the goal',()=>{
|
||||||
|
const level=fixture();level.originals=[{id:'red',q:2,r:0,dir:2,color:8}];
|
||||||
|
const game=new AirportGame(level);assert.equal(execute(game,tap('red')).status,'exit');
|
||||||
|
assert.equal(game.won,false);assert.equal(game.level.stands.length,1);
|
||||||
|
assert.equal(game.state.planes.find(p=>p.id==='red').state,'gone');
|
||||||
|
});
|
||||||
|
test('incoming collision reports only the first obstacle, then the next',()=>{
|
||||||
|
const level=fixture();level.originals=[{id:'near',q:0,r:-1,dir:0,color:0},{id:'outer',q:0,r:-2,dir:0,color:2}];
|
||||||
|
const state=createState(level);
|
||||||
|
assert.equal(checkLanding(level,state,'in1').blocker,'outer');
|
||||||
|
state.planes.find(p=>p.id==='outer').state='gone';
|
||||||
|
assert.equal(checkLanding(level,state,'in1').blocker,'near');
|
||||||
|
});
|
||||||
|
test('curved/grazing collision uses the shared continuous hex sweep, including terminal leg',()=>{
|
||||||
|
let grazing=0,terminal=0;
|
||||||
|
for(const level of LEVELS)for(let o=0;o<6;o++)for(const route of catalogs(level)[o].arrivals){
|
||||||
|
const visited=new Set(route.steps.map(s=>`${s.q},${s.r}`));
|
||||||
|
for(const cell of level.cells){
|
||||||
|
if(cell.q===route.q&&cell.r===route.r)continue;
|
||||||
|
const obstacle={...position(cell.q,cell.r),id:'blocker'},hit=sweepPath(route.points,[obstacle]);
|
||||||
|
if(!hit)continue;
|
||||||
|
const state={orientation:o,planes:[{id:'in',kind:'arrival',state:'waiting',color:route.color,side:route.side},{id:'blocker',kind:'original',state:'ground',color:0,dir:0,q:cell.q,r:cell.r}]};
|
||||||
|
const actual=checkLanding(level,state,'in');assert.equal(actual.status,'blocked');assert.equal(actual.blocker,'blocker');
|
||||||
|
if(!visited.has(`${cell.q},${cell.r}`))grazing++;
|
||||||
|
if(sweepPath(route.points.slice(-2),[obstacle]))terminal++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(grazing>0,'fixtures must exercise neighboring-cell grazing');assert.ok(terminal>0,'terminal segment is swept');
|
||||||
|
});
|
||||||
|
test('atomic reservation prevents two fast landings into the same free stand',()=>{
|
||||||
|
const level=fixture();level.arrivals.push({id:'in2',color:8,side:0});
|
||||||
|
const orientation=catalogs(level).findIndex(c=>c.arrivals.length);
|
||||||
|
const route=catalogs(level)[orientation].arrivals[0];level.arrivals.forEach(p=>p.side=route.side);
|
||||||
|
const game=new AirportGame(level);game.state.orientation=orientation;
|
||||||
|
const first=game.begin(tap('in1'));
|
||||||
|
assert.equal(first.status,'land');assert.ok(game.busy.has('in1'));
|
||||||
|
assert.equal(game.begin(tap('in1')).status,'busy');
|
||||||
|
assert.equal(game.begin(tap('in2')).status,'no-stand');
|
||||||
|
assert.equal(game.moves,level.moveLimit);assert.equal(game.state.planes.filter(p=>p.state==='parked').length,1);
|
||||||
|
});
|
||||||
|
test('off-board taps are free, while ground departures and rotations cost one',()=>{
|
||||||
|
const level=fixture();level.originals=[{id:'out',q:0,r:1,dir:0,color:0}];
|
||||||
|
const game=new AirportGame(level);
|
||||||
|
assert.equal(execute(game,tap('out')).status,'exit');assert.equal(game.moves,level.moveLimit-1);
|
||||||
|
assert.equal(execute(game,{type:'rotate',direction:1}).status,'rotated');assert.equal(game.moves,level.moveLimit-2);
|
||||||
|
assert.equal(execute(game,tap('in1')).status,'land');assert.equal(game.moves,level.moveLimit-2);
|
||||||
|
});
|
||||||
|
test('returning arrival waits at actual exit side; landing count is not cumulative',()=>{
|
||||||
|
const level=fixture();level.arrivals.push({id:'in2',color:0,side:1});level.stands.push({id:'P2',q:2,r:-1,color:0});
|
||||||
|
const game=new AirportGame(level);assert.equal(execute(game,tap('in1')).status,'land');
|
||||||
|
const departure=execute(game,tap('in1'));assert.equal(departure.status,'exit');assert.equal(departure.side,3);
|
||||||
|
const plane=game.state.planes.find(p=>p.id==='in1');assert.equal(plane.state,'waiting');assert.equal(plane.side,3);assert.equal(plane.q,undefined);
|
||||||
|
assert.equal(game.won,false);assert.equal(execute(game,tap('in1')).status,'land');assert.equal(game.moves,level.moveLimit-1);assert.equal(game.won,false);
|
||||||
|
});
|
||||||
|
test('rotation keeps aircraft and stands fixed; only rotations cost here',()=>{
|
||||||
|
const level=fixture();level.originals=[{id:'block',q:0,r:-1,dir:3,color:2}];
|
||||||
|
const game=new AirportGame(level),planes=clone(game.state.planes),stands=clone(level.stands);
|
||||||
|
assert.equal(execute(game,tap('in1')).status,'blocked');assert.equal(game.moves,level.moveLimit);assert.deepEqual(game.state.planes,planes);
|
||||||
|
for(let i=0;i<6;i++)assert.equal(execute(game,{type:'rotate',direction:1}).status,'rotated');
|
||||||
|
assert.equal(game.state.orientation,0);assert.deepEqual(game.state.planes,planes);assert.deepEqual(level.stands,stands);assert.equal(game.moves,level.moveLimit-6);
|
||||||
|
assert.equal(execute(game,tap('missing')).status,'invalid');assert.equal(game.moves,level.moveLimit-6);
|
||||||
|
});
|
||||||
|
test('a free landing still wins after the budget is spent',()=>{
|
||||||
|
const level=fixture();level.moveLimit=1;level.originals=[{id:'out',q:0,r:1,dir:0,color:0}];
|
||||||
|
const game=new AirportGame(level);
|
||||||
|
assert.equal(game.begin(tap('out')).status,'exit');assert.equal(game.moves,0);
|
||||||
|
assert.equal(game.begin(tap('in1')).status,'land');assert.equal(game.moves,0);assert.ok(game.won);
|
||||||
|
assert.equal(game.begin({type:'rotate',direction:1}).status,'finished');
|
||||||
|
});
|
||||||
|
test('validator rejects ambiguous targets, equal-target/different-heading routes and bad counts',()=>{
|
||||||
|
const level=fixture();assert.deepEqual(validateAirportLevel(level),[]);
|
||||||
|
level.stands.push({id:'P2',q:1,r:0,color:8});level.arrivals.push({id:'in2',color:8,side:1});
|
||||||
|
assert.ok(validateAirportLevel(level).includes('存在多个合法停靠结果'));
|
||||||
|
const game=new AirportGame(level);assert.equal(game.begin(tap('in1')).status,'ambiguous');assert.equal(game.moves,10);
|
||||||
|
const bent=fixture();bent.cells.forEach(c=>c.tracks=[1,0,3,2,5,4]);bent.stands[0].q=2;bent.stands[0].r=-2;
|
||||||
|
assert.ok(uniquenessIssues(bent).some(i=>new Set(i.results.map(r=>r.split(':')[0])).size===1));
|
||||||
|
const malformed=fixture();malformed.arrivals=[];assert.ok(validateAirportLevel(malformed).length>0);
|
||||||
|
});
|
||||||
|
test('empty stands do not obstruct original takeoff regardless of color',()=>{
|
||||||
|
const level=fixture();level.originals=[{id:'out',q:0,r:1,dir:0,color:0}];
|
||||||
|
const game=new AirportGame(level);assert.equal(execute(game,tap('out')).status,'exit');assert.equal(game.level.stands[0].color,8);
|
||||||
|
});
|
||||||
|
test('solver distinguishes a rotation rescue, proven deadlock and search limits',()=>{
|
||||||
|
const level=LEVELS[1],state=createState(level);
|
||||||
|
assert.equal(solveAirport(level,state,{maxStates:1}).status,'unknown');
|
||||||
|
const rotating=LEVELS.find(l=>l.solution.some(a=>a.type==='rotate'));assert.equal(solveAirport(rotating).status,'solved');
|
||||||
|
const dead=fixture();dead.cells.forEach(c=>c.tracks=[1,0,3,2,5,4]);
|
||||||
|
assert.equal(solveAirport(dead).status,'deadlock');
|
||||||
|
assert.equal(solveAirport(fixture(),createState(fixture()),{maxDepth:0}).status,'unknown');
|
||||||
|
});
|
||||||
|
test('teaching arc: rotation connects the runway and blockers must be cleared',()=>{
|
||||||
|
// L3: the off-board plane is not connected at start; one rotation opens the runway.
|
||||||
|
const l3=LEVELS[2];let s3=createState(l3);
|
||||||
|
assert.equal(checkLanding(l3,s3,'in1').status,'no-route');
|
||||||
|
s3=transition(l3,s3,{type:'rotate',direction:-1}).state;
|
||||||
|
assert.equal(checkLanding(l3,s3,'in1').status,'land');
|
||||||
|
// L4: an original blocks a runway at the start; the proven replay rotates and clears a blocker.
|
||||||
|
const l4=LEVELS[3];const s4=createState(l4);
|
||||||
|
assert.ok(l4.arrivals.some(a=>checkLanding(l4,s4,a.id).status==='blocked'));
|
||||||
|
assert.ok(l4.solution.some(a=>a.type==='rotate'));
|
||||||
|
assert.ok(l4.solution.some(a=>a.type==='tap'&&a.id.startsWith('out')));
|
||||||
|
assert.ok(replay(l4,l4.solution).won);
|
||||||
|
});
|
||||||
|
test('reward receipts are idempotent, limited and do not mutate the board',async()=>{
|
||||||
|
const game=new AirportGame(fixture()),state=clone(game.state);game.moves=0;
|
||||||
|
assert.equal((await requestReward(game,async()=>({completed:false,id:'a'}))).status,'cancelled');assert.equal(game.moves,0);
|
||||||
|
assert.equal((await requestReward(game,async()=>{throw Error('network');})).status,'failed');
|
||||||
|
assert.equal((await requestReward(game,async()=>({completed:true,id:'a'}))).status,'granted');assert.equal(game.moves,5);
|
||||||
|
assert.equal((await requestReward(game,async()=>({completed:true,id:'a'}))).status,'cancelled');assert.equal(game.moves,5);
|
||||||
|
assert.deepEqual(game.state,state);
|
||||||
|
assert.equal(game.grantReward({completed:'false',id:'invalid'}),false);
|
||||||
|
assert.equal(game.grantReward({completed:true,id:'second'}),true);assert.equal(game.moves,10);
|
||||||
|
assert.equal(game.grantReward({completed:true,id:'over-limit'}),false);
|
||||||
|
game.deadlocked=true;assert.equal(game.grantReward({completed:true,id:'b'}),false);
|
||||||
|
});
|
||||||
|
test('reward completion cannot revive a restarted session or accept concurrent claims',async()=>{
|
||||||
|
const game=new AirportGame(fixture());let current=true,resolve;
|
||||||
|
const pending=requestReward(game,()=>new Promise(r=>resolve=r),()=>current);
|
||||||
|
assert.equal((await requestReward(game,async()=>({completed:true,id:'double'}))).status,'unavailable');
|
||||||
|
current=false;resolve({completed:true,id:'old'});assert.equal((await pending).status,'stale');assert.equal(game.moves,10);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user