Compare commits
6
Commits
6e72b95ad8
...
10962babe3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10962babe3 | ||
|
|
f40efb8ba9 | ||
|
|
79afda0085 | ||
|
|
c99716b1d5 | ||
|
|
0e67c4c188 | ||
|
|
1be0e9caf1 |
@@ -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,303 @@
|
|||||||
|
# 六角机场:起降调度游戏设计文档
|
||||||
|
|
||||||
|
**版本:** 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 场外停机坪
|
||||||
|
|
||||||
|
来机停靠在棋盘轮廓六条平边外侧(右上方、右方、右下方、左下方、左方、左上方),不悬停在正北等罗盘方位。这六条平边就是入口边;角上的端口只归其中一边,不能两边同时进。
|
||||||
|
|
||||||
|
同侧可有多架、多色来机,同色来机也可分处不同侧。停机坪上的排列不绑定精确入口。每个边界入口和出口按端口位置落入的 60° 平边扇区唯一归属;画面上的停机坪边和规则上的进场边必须是同一条。来机只能从当前所在平边的开口进场,不能跨边。
|
||||||
|
|
||||||
|
## 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)。主题只改外观,不改匹配规则。
|
||||||
|
|
||||||
|
彩色飞机和机位是主要阅读对象,装饰不得遮挡轨道、机头、机位和碰撞边界。正顶视飞机保留可感知的六角占位边界。通路不着匹配色。
|
||||||
|
|
||||||
|
盘边跑道与停机坪航线(2026-09-21):
|
||||||
|
|
||||||
|
- 每条板边轨道不再在糖砖内收口,而是沿端口法线延伸到托盘外缘(圆头收回半个线宽,不伸出棋盘)。
|
||||||
|
- 托盘外、六个侧的法线方向画固定浅色停机坪航线,等待机停在其上;航线不随底盘旋转,跑道随底盘旋转。
|
||||||
|
- 当某架来机的当前侧在当前朝向下接得上同色机位时,用中性强调色(马卡龙 `hint`,不是飞机或机位颜色)点亮盘内通道,开口加圈、保留进场箭头;被挡时阻挡物仍用 `blocked` 标红。跑道线始终裁在托盘内。常显跑道负责说明"这里有没有开口",高亮负责说明"这架能不能进"。
|
||||||
|
- 停机坪机位仍按其原有平边法线摆放(与入口法线差半步),不做位移。
|
||||||
|
|
||||||
|
### 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,577 @@
|
|||||||
|
# 六角机场:进场调度 · 改造设计说明
|
||||||
|
|
||||||
|
**方案名:** 进场调度
|
||||||
|
**版本:** 改造方案定稿(文档)· 2026-09-24;2026-09-25 覆盖转盘
|
||||||
|
**状态:** 规则见 4.4。刻度环竖切可在 [labs/inbound](../labs/inbound/index.html) 试玩。不改冻结的机场实验室。
|
||||||
|
**关系:** 本文是下一版玩法改造方案。v0.1 规则基线仍是 [airport-design.md](./airport-design.md),本文引用它,不改写它。4.4 覆盖 v0.1 的滑轨转盘,不回写那份文档。实验室冻结关与实现仍以 [机场实验室](../labs/airport/README.md) 和 `labs/airport/levels.js` 为准,本文不改那些文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 结论
|
||||||
|
|
||||||
|
下一版不是 Hex 换皮,是进场解谜。
|
||||||
|
|
||||||
|
飞机版从 Hex 演化而来,但目标和用户已经反过来。Hex 要把场内箭头全部移走,爽点是 combo 和清空。飞机版的核心是:场外飞机从固定的边进入,停到指定机位。解谜。爽点是用最少操作完成目标。必须按新的解谜玩法改造,不能在 Hex 消除上打补丁。
|
||||||
|
|
||||||
|
v0.1 把胜利条件写成「全部来机同时停靠、不要求清空」,但动词、教学、计步仍在教清场。用额度禁止「先清再接机」是负向约束,不是正谜题。环路车场已经证明:只转盘再批量放行无聊,不要复活成自动进场。Hex 和 v0.1 的转盘还有第二个问题:轨道滑到不动的飞机底下,转完才知道哪个口亮、调边从哪边出去。新主题要求操作能在动手前数出来。滑轨不留。
|
||||||
|
|
||||||
|
改造后只保留三个正动词:**转、进、调边**。转的是刻度环,打开或关掉固定的口。通关只看全部来机是否同时在位。预览和失败试探不计星。不加步广告。地面原有飞机在第一切片里是不能点的固定挡板。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 定位
|
||||||
|
|
||||||
|
### 1.1 产品一句话
|
||||||
|
|
||||||
|
竖屏六角进场解谜:转刻度打开固定的口,把场外飞机一架一架停进同色机位;棋盘要被正确停满,不是被清空。
|
||||||
|
|
||||||
|
### 1.2 与 Hex 主线相反
|
||||||
|
|
||||||
|
| 项目 | Hex 主线 | 进场调度 |
|
||||||
|
|---|---|---|
|
||||||
|
| 目标 | 场内箭头全部离场 | 场外来机全部同时停好 |
|
||||||
|
| 棋盘终局 | 空 | 正确停满 |
|
||||||
|
| 爽点 | 连续离场、combo、清空涟漪 | 用最少提交完成调度 |
|
||||||
|
| 旋转 | 免费试路;转完才知道谁能走 | 刻度差几格,转之前能数;预览只确认;确认才计星 |
|
||||||
|
| 失败尝试 | 免费 | 免费(只反馈,不计星) |
|
||||||
|
| 场外物 | 无 | 六边停机坪上来机,从当前边进入并留下 |
|
||||||
|
| 用户 | 连点清空 | 短关归位 |
|
||||||
|
|
||||||
|
依据:[原作说明](../README.md)。主线规则不改。飞机版不再共用「点一下少一个」的教学。
|
||||||
|
|
||||||
|
### 1.3 真正的谜题
|
||||||
|
|
||||||
|
地面原有飞机只阻挡,不改道。留下它们不会造出新路。所以「哪些该留」在 v0.1 规则下不是谜题。
|
||||||
|
|
||||||
|
进场调度只做这三件事:
|
||||||
|
|
||||||
|
1. 缺口还差几格盖住这个口。
|
||||||
|
2. 谁先停(停好变成新障碍)。
|
||||||
|
3. 要不要调边再进。
|
||||||
|
|
||||||
|
### 1.4 核心体验
|
||||||
|
|
||||||
|
玩家看边、看口、看刻度差几格、看谁挡在固定路径上。左右转的预览只确认已经能数出的开闭。点场外飞机,有唯一同色通路就进去留下。停错边就调边:先离场到已经画在盘上的出口边,再转,再进。每一次确认提交都要在动手前说得出目的。
|
||||||
|
|
||||||
|
操作保持直接:不手选路线、不拖飞机、不降落确认框、不自动批量进场。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 对 v0.1 的诊断
|
||||||
|
|
||||||
|
诊断已经完成。本节只记录,不重开。
|
||||||
|
|
||||||
|
### 2.1 规则与教学打架
|
||||||
|
|
||||||
|
v0.1 写明:「在有限操作次数内,让本关全部来机同时完成停靠,即可过关。棋盘上可以保留原有飞机,不要求清空棋盘。」
|
||||||
|
|
||||||
|
冻结 7 关的 `lesson` 仍在教清场:
|
||||||
|
|
||||||
|
| 关 | 名 | lesson |
|
||||||
|
|---|---|---|
|
||||||
|
| 4 | 清障再接入 | 跑道要连通,通路也不能被飞机挡住。先移开阻挡,再旋转接入两架来机。 |
|
||||||
|
| 5 | 更大的停机坪 | 棋盘变大了,规则不变:清出通路,把来机停到同色机位。 |
|
||||||
|
| 7 | 空港全景 | 61 格空港:原有飞机更多,先在人群里清出通路,再把两色来机各自接好。 |
|
||||||
|
|
||||||
|
第 1–3 关分别教点进来、同色、旋转接入,方向对;从第 4 关起重新把「先清」写成正动作。
|
||||||
|
|
||||||
|
### 2.2 额度承担了两件不该由它承担的事
|
||||||
|
|
||||||
|
v0.1 旋转、失败尝试、起飞都扣次数。额度同时是失败线和「不许先清再接」的墙。玩家不敢转、不敢试。设计文档 6.3 的公式:
|
||||||
|
|
||||||
|
> 合理解及容错 ≤ 初始额度 < 先清再接的最少次数
|
||||||
|
|
||||||
|
这是负向约束。放弃。布局若只存在「先清再接」,改布局,不压预算。
|
||||||
|
|
||||||
|
### 2.3 地面飞机与来机共用点击
|
||||||
|
|
||||||
|
点地面飞机起飞、点来机进场,同一套「点飞机」。教学和手指都会滑回 Hex。第一切片切断这条共用。
|
||||||
|
|
||||||
|
### 2.4 环路车场的约束
|
||||||
|
|
||||||
|
[car-loop-research.md](./car-loop-research.md)(2026-09-10,暂缓):只转盘再批量放行被判无聊。系统拿走选择,转动前看不出后果,放走只带来单向收益,观看占比高。
|
||||||
|
|
||||||
|
进场调度禁止:转完自动进场、一转点亮两架就一起进去、把「进」收成整批发车键。也禁止把「转完才知道后果」留在规则里。见 4.4。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 与 v0.1 的规则差
|
||||||
|
|
||||||
|
### 3.1 保留
|
||||||
|
|
||||||
|
- 六角轨道、弯道转向、交叉不换轨。轨道画在世界坐标里,不随转滑动。
|
||||||
|
- 60° 只拨动刻度环。不转底盘格子,不把另一格的轨道滑到飞机底下。细则见 4.4,覆盖本条旧表述和 v0.1 第 2.1 节的滑轨转盘。
|
||||||
|
- 飞机位置、机头、机位、六边停机坪都不转。调边出口射线也不转。
|
||||||
|
- 六边停机坪固定在轮廓平边外侧(右上方、右方、右下方、左下方、左方、左上方)。角上端口只归一边。
|
||||||
|
- 同色机位;一位一机;每种颜色的机位数量等于该色来机总数;不设备用位。
|
||||||
|
- 一次点击至多一个合法进场。没有合法结果就反馈;多个合法结果是关卡错误,不设自动优先级,不手选。
|
||||||
|
- 六边形碰撞;只高亮行进方向上第一个阻挡。
|
||||||
|
- 全部来机同时占据合法同色机位才算赢。不按累计落地次数计数。原有飞机(若存在)是否在场不影响判胜。
|
||||||
|
- 已停来机可离场,出现在实际出口那条边,仍须重新停好。来机身份不会变成原有飞机。
|
||||||
|
- 2026-09-21 的进场高亮:从装饰升成主决策面。常显跑道说明「这里有没有开口」;高亮说明「这架能不能进」。被挡时阻挡物仍标红。
|
||||||
|
|
||||||
|
空间、碰撞、停机坪归属、无歧义进场的细则仍以 v0.1 第 2–3 节为准,除非被本节明确覆盖。
|
||||||
|
|
||||||
|
### 3.2 砍掉
|
||||||
|
|
||||||
|
| v0.1 | 进场调度 |
|
||||||
|
|---|---|
|
||||||
|
| 地面飞机和来机共用点击起飞 | 地面飞机第一切片不可点。来机只点「进」。已停来机的「调边」是单独动作,不和「进」共用点击。 |
|
||||||
|
| 失败尝试扣步 | 失败试探免费,不计星。 |
|
||||||
|
| 预览中的旋转、未确认的旋转扣步 | 预览不计星。确认这一转才提交,计 1。 |
|
||||||
|
| 用额度禁止全清 | 不设失败步数。不靠预算压「先清再接」。 |
|
||||||
|
| 通关看剩余次数;激励广告加步数 | 通关只看同时停好。不加步广告。 |
|
||||||
|
| 教学「先移开阻挡 / 清出通路」 | 丢掉花瓣巡游和「清挡再接」。 |
|
||||||
|
|
||||||
|
### 3.3 胜利条件(必须写死)
|
||||||
|
|
||||||
|
**胜利 = 全部来机同时停在合法同色机位。**
|
||||||
|
|
||||||
|
不要求清空。不设失败步数。用尽某条「标准调度 N」也不判负,只影响星级。最后一架进场成功即胜,通关界面等必要动画结束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 三个动词
|
||||||
|
|
||||||
|
界面上只有这三件事可提交。点击空白、设置、阅读反馈、预览中的左右转,都不是提交。
|
||||||
|
|
||||||
|
### 4.1 转
|
||||||
|
|
||||||
|
左右转一格(60°)。转的是刻度环上的缺口,不是棋盘。
|
||||||
|
|
||||||
|
- 棋盘、轨道、机位、已停飞机、机头、停机坪都不动。
|
||||||
|
- 每个进场口固定在一条平边上,沿固定轨道通向固定机位。口上有一枚刻度,画在同一只环上。
|
||||||
|
- 缺口盖住哪枚刻度,哪个口开。没盖住的口关。夹角就是还要转几格,转之前就在画面上。
|
||||||
|
- **预览:** 缺口跟手移到下一格,对应的口亮或灭。预览确认已经能数出的结果,不揭开新路径,不计星。
|
||||||
|
- **确认这一格**才写入朝向,计 1。
|
||||||
|
- 拖动若保留:跟手预览;松手落档后确认才提交;跨多格按确认格数计星。第一切片用按钮。
|
||||||
|
|
||||||
|
转的阅读对象是刻度差,不是「把棋子转到能飞走」,也不是转完再看谁亮了。玩家每转一次应能在动手前说出:我转这一格,是为了让缺口盖住这个口。
|
||||||
|
|
||||||
|
### 4.2 进
|
||||||
|
|
||||||
|
点场外停机坪上的来机。
|
||||||
|
|
||||||
|
- 只检查当前所在平边的入口,不跨边。
|
||||||
|
- 当前边有**唯一**同色通路:进场并留下。停稳锁机位,进度 +1。计 1 星步。
|
||||||
|
- 没有通路或被挡:**免费**。只标第一个阻挡。不扣星。不改位置。
|
||||||
|
- 多个合法结果:关卡作废,不自动选。
|
||||||
|
- 成功进场的飞机占位,可能挡住后续来机。这是顺序谜题的来源。
|
||||||
|
|
||||||
|
### 4.3 调边
|
||||||
|
|
||||||
|
点已停来机,令其离场到实际出口边。这是正动作,不是失误。
|
||||||
|
|
||||||
|
- 离场前先亮目标边(实际出口将落入的那条平边)。
|
||||||
|
- 成功离场:释放机位、取消完成、进度 −1,飞机出现在出口边停机坪,仍算未完成。计 1 星步。
|
||||||
|
- 碰撞失败:免费,只标第一个阻挡,不扣星,机位仍锁。
|
||||||
|
- 标准步若包含调边,结算必须标成「为了换边」,不要标成浪费。
|
||||||
|
- 来机等待时不能自行绕盘换侧。要换边,必须先落、再调边。因此「错边」在能先落的前提下才成立。若某条边没有通向该机位的口,转满 6 格也进不去;这必须能直接看见,不能靠试转发现。看不见、只能试出来的,是坏布局。
|
||||||
|
|
||||||
|
调边的控件不能做成「再点一次飞机就起飞」。第一切片:已停来机点选后出现明确的调边按钮,或使用与场外「进」可区分的手势/按钮。禁止与「进」共用同一个点击语义。
|
||||||
|
|
||||||
|
### 4.4 可预见性(覆盖 v0.1 与 Hex 的转盘)
|
||||||
|
|
||||||
|
v0.1 与 Hex 共用 `rotateBoard`:格子坐标和轨道一起转 60°,飞机位置和机头不转。转完之后,固定机位底下换成另一格的轨道,固定机头对着的也不再是刚才那条线。哪个口会亮、调边从哪条边出去、谁会挡在新路径上,都要等转完重算。
|
||||||
|
|
||||||
|
这是弱可预见。免费预览只是把试转提前,玩家仍然不能在动手前数出结果。环路车场已把「转动前看不出后果」判过。新主题要的是精确操作:差几格、挡住谁、调边落在哪条边,都是动手前的事实。滑轨不适合,废弃。
|
||||||
|
|
||||||
|
写死:
|
||||||
|
|
||||||
|
1. 转不改变任何一格的轨道,也不改变任何飞机的位置和机头。
|
||||||
|
2. 口是世界坐标里的固定物件。它在哪条边、通向哪个机位、经过哪些格,不随朝向改变。朝向只决定这个口开还是关。
|
||||||
|
3. 开闭由刻度环决定。环 6 格,左右各一格。口的刻度与当前缺口的夹角,就是还要转几格。夹角在画面上,不在玩家脑子里。不在口上印数字朝向;数的是格。
|
||||||
|
4. 调边的出口边是已停飞机沿固定轨道、固定机头走出的那条边。转不改变这条射线。离场前亮目标边,是确认已经画在盘上的射线,不是揭晓。
|
||||||
|
5. 谁挡住谁也不随转改变。转不会把一条新路径滑到已停飞机底下。挡在这条路上的飞机,转之前就在暗轨上。
|
||||||
|
6. 不是每条边都有通向每个机位的口。没有口的边,转满 6 格也进不去。这是看得见的布局事实(这条边没有该色的口),不是试转结论。要换到有口的边,仍走调边。因此调边不会因为「每条边最终都能对准每个口」而失效。
|
||||||
|
7. 预览可以关掉做验收:不点预览,读者仍能指出向哪转一格会打开哪个口、关掉哪个口,以及调边落到哪条边。指不出的布局作废,不叫更难。
|
||||||
|
|
||||||
|
明确不采用:
|
||||||
|
|
||||||
|
- 保留滑轨转盘,只把预览做得更满。预览不是可预见性。
|
||||||
|
- 飞机和机位跟着棋盘一起转。顺序谜题依赖的占位会跟着跑,精确性更差。Labyrinth 仍不借。
|
||||||
|
- 一个口在不同朝向通向不同机位,或出口边随朝向改变。那是把滑轨请回来。
|
||||||
|
- 用试转搜索 6 个朝向当作谜题。竖切第 3 关已经禁止「转两次才对上」。
|
||||||
|
|
||||||
|
第一切片每个口只有一枚刻度。一个机位可以有两个口,分属不同边、不同刻度。一格同时开关两个口,等于两枚刻度正好差一格,且一枚正在缺口下。以后若允许一个口两枚刻度,仍必须转之前能数,不得改回滑轨。本竖切不做两枚刻度。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 地面飞机(第一切片)
|
||||||
|
|
||||||
|
地面原有飞机改成**不能点的固定挡板**。
|
||||||
|
|
||||||
|
- 外观仍是停着的飞机,没有箭头。
|
||||||
|
- 占位与碰撞与 v0.1 原有飞机相同。
|
||||||
|
- 不能起飞、不能调边、不能变成来机。
|
||||||
|
- 第一切片不提供「推出」。
|
||||||
|
- 等「留下它才能保住一条路」能被玩家看见,再做成单独工具,且不能和「进」共用点击。
|
||||||
|
|
||||||
|
竖切 6 关(见第 12 节)覆盖对齐、顺序、调边,**不放固定挡板**,避免挡板章提前漏进来。挡板章见 8.3,用 37 格,不在本竖切。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 计步、星级、广告
|
||||||
|
|
||||||
|
### 6.1 什么计入星级
|
||||||
|
|
||||||
|
开局公示**标准调度 N**:已证明的最短提交步。未证明不得冒称最短。
|
||||||
|
|
||||||
|
计入星级的只有:
|
||||||
|
|
||||||
|
1. 确认旋转
|
||||||
|
2. 成功进场
|
||||||
|
3. 成功调边
|
||||||
|
|
||||||
|
不计星:预览旋转、失败的进、失败的调边、撤销、重来、点空白、读反馈。
|
||||||
|
|
||||||
|
### 6.2 星级
|
||||||
|
|
||||||
|
| 条件 | 星 |
|
||||||
|
|---|---|
|
||||||
|
| 完成(全部来机同时在位) | 1 星 |
|
||||||
|
| 提交步 ≤ N+1 | 2 星 |
|
||||||
|
| 提交步 ≤ N | 3 星 |
|
||||||
|
|
||||||
|
没有失败步数。N 只用于星,不用于把玩家踢出关卡。
|
||||||
|
|
||||||
|
### 6.3 撤销与主线可过
|
||||||
|
|
||||||
|
前 30 关免费撤销。撤销恢复棋盘、朝向、来机侧、进度和已提交星步。
|
||||||
|
|
||||||
|
主线必须不用道具能过。不把提示、加机位、加步做成过关必需。
|
||||||
|
|
||||||
|
### 6.4 广告卖什么
|
||||||
|
|
||||||
|
广告放在赢了之后,不放在失败线上。
|
||||||
|
|
||||||
|
卖:
|
||||||
|
|
||||||
|
- 看下一步标准动作
|
||||||
|
- 结算星星加成
|
||||||
|
- 涂装
|
||||||
|
|
||||||
|
不卖:
|
||||||
|
|
||||||
|
- 额外机位
|
||||||
|
- 「不加步就无解」
|
||||||
|
- 加步数(本方案无失败步数,加步没有对象)
|
||||||
|
|
||||||
|
v0.1 的激励加步、`rewards.js` 测试 +5 次,下一版不再作为主循环。未接真实广告 SDK;本文不规定价格和展示频率。
|
||||||
|
|
||||||
|
### 6.5 星星若不被在意
|
||||||
|
|
||||||
|
若以后试玩没人在意星星,只在**支线**用 Golf Peaks 式「只发 N 张调度牌」。失败是重试,不是卖加步。主线仍必须不用道具能过。此条是预案,不是本竖切范围。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 反馈
|
||||||
|
|
||||||
|
每一次成功进场后 3–8 秒内给一次奖,不做连击倍率。
|
||||||
|
|
||||||
|
| 事件 | 反馈 |
|
||||||
|
|---|---|
|
||||||
|
| 转(未动手) | 缺口与各口刻度同环可见;差几格能数 |
|
||||||
|
| 转(预览) | 缺口跟手到下一格;对应口亮或灭。不揭开新路径,不计星 |
|
||||||
|
| 转(确认) | 短到位音;缺口锁定 |
|
||||||
|
| 进(成功) | 短落地音、机位锁定、P 标记、进度 +1。进场动画 0.6–1.2 秒。不是碎裂,不是冲出棋盘 |
|
||||||
|
| 进(失败) | 震动;只高亮第一个阻挡;不计星 |
|
||||||
|
| 调边(预告) | 离场前亮出口射线上的那条边;射线不随转改变 |
|
||||||
|
| 调边(成功) | 离场到出口边停机坪;P 消失;进度 −1 |
|
||||||
|
| 赢 | 已停飞机一起亮。棋盘被正确停满,不是被清空。接着 3 秒回放提交序列,并排「你的步数 / 标准 N」,按钮是「再少一步」 |
|
||||||
|
|
||||||
|
禁止:清空涟漪、并行离场碎裂、连击倍率、把调边做成消除特效。
|
||||||
|
|
||||||
|
视觉可继续用马卡龙(v0.1 第 5 节)。主题只改外观,不改匹配规则。彩色飞机、机位、口和刻度是阅读对象;装饰不得遮挡轨道、机头、机位、口、刻度环和高亮。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 关卡语法
|
||||||
|
|
||||||
|
### 8.1 单关体量
|
||||||
|
|
||||||
|
- 丢掉花瓣巡游和「清挡再接」。
|
||||||
|
- 单关 2–6 个提交动作。教学开口(竖切第 1 关)允许 1 个提交。
|
||||||
|
- 入门 20–40 秒,后期 60–90 秒。这是制作目标,不是已测人均时长。
|
||||||
|
- 进场动画 0.6–1.2 秒。观看不得压过决策。
|
||||||
|
|
||||||
|
### 8.2 一章只教一件事
|
||||||
|
|
||||||
|
| 章 | 盘 | 只教一件事 | 不做 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 对齐 | 19 格,1 架来机,无箭头飞机 | 边已通就点进来;转一下口才亮;一转会同时开关两边 | 多色、调边、清场 |
|
||||||
|
| 顺序 | 19 格,2 架 | 先停 A 会挡住 B;同色只能从自己边进;一转可点亮两架,但仍逐架点 | 自动批量进场 |
|
||||||
|
| 调边 | 19 格 | 错边要先落、再调到出口边、再转、再进;标准步包含这次调边 | 把调边写成失误 |
|
||||||
|
| 挡板 | 37 格 | 固定障碍不能飞走,只能把缺口转到另一个口的刻度上;落地本身变成新障碍 | 「先清全盘再接」 |
|
||||||
|
|
||||||
|
本竖切只用 19 格,覆盖前三章,不含挡板章。
|
||||||
|
|
||||||
|
### 8.3 挡板章(非本竖切)
|
||||||
|
|
||||||
|
37 格。固定挡板不可点。玩家必须改开另一个已经画在盘上的口,不能清走挡板,也不能指望转盘把路径从挡板底下滑开。落地的来机是新障碍,不是清障工具。若某布局的唯一解是「先清再接」,改布局。
|
||||||
|
|
||||||
|
「推出」是更后的单独工具,本竖切不做。
|
||||||
|
|
||||||
|
### 8.4 制作禁则
|
||||||
|
|
||||||
|
- 同一次「进」不得出现多个合法结果。
|
||||||
|
- 标准调度必须可回放:真实路径、真实碰撞、全部来机同时停好。
|
||||||
|
- N 必须是已证明最短提交步。搜索未完成不得冒称。
|
||||||
|
- 参考解记录调边时,结算标签是「为了换边」。
|
||||||
|
- 不要用步数把「先清再接」判失败——第一切片本来就不能点地面飞机。
|
||||||
|
- 不要把花瓣巡游、加长观看路线当成内容。
|
||||||
|
- 不得用滑轨制造转后才出现的新路径或新出口。口的刻度、路径和调边射线必须在未预览时可读。指不出的布局作废。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 用户与会话
|
||||||
|
|
||||||
|
主用户:短关归位(水排序、整理、推箱子轻度)。不是 Hex 连点党,也不是「点一下少一个」的箭头消除用户。
|
||||||
|
|
||||||
|
次用户:愿意为 3 星重打的人。用结算回放和「再少一步」服务他们,不把门禁设在主线。
|
||||||
|
|
||||||
|
会话:连续 3–8 个短关。广告在赢了之后。
|
||||||
|
|
||||||
|
单关时长和两类用户的广告忍受对照:**未知,不编**。第 11 节的对照只说明品类怎么卖广告、怎么处理「少」,不推导本游戏的人均时长或广告次数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 明确不做
|
||||||
|
|
||||||
|
- 连击
|
||||||
|
- 并行离场
|
||||||
|
- 清空涟漪
|
||||||
|
- 实时画线(不借 Flight Control)
|
||||||
|
- 持续来流
|
||||||
|
- 计时
|
||||||
|
- 燃油
|
||||||
|
- 随机来机
|
||||||
|
- 自动批量进场(不借环路车场,不借转盘发车)
|
||||||
|
- 滑轨转盘,以及「只靠预览把试转提前」
|
||||||
|
- 飞机或机位跟着棋盘转
|
||||||
|
- 手选路线
|
||||||
|
- 多结果自动优先级
|
||||||
|
- 用步数把「先清再接」判失败
|
||||||
|
- 备用机位
|
||||||
|
- 尺寸差异
|
||||||
|
- 特殊障碍
|
||||||
|
- 加步广告
|
||||||
|
- 失败步数墙
|
||||||
|
- 地面飞机与来机共用点击
|
||||||
|
- 第一切片提供「推出」
|
||||||
|
- 把方案改回箭头消除、Hex 换皮、实时塔台
|
||||||
|
|
||||||
|
布局若只存在「先清再接」,改布局,不压预算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 调研证据(已核对,不重搜)
|
||||||
|
|
||||||
|
本节只收录本交接已打开核对过的对照。不把未核对的下载量或时长写成事实。单关时长与两类用户的广告忍受:**未知**。
|
||||||
|
|
||||||
|
品类与「少」怎么被看见:
|
||||||
|
|
||||||
|
- 2026-05 全球下载榜:《Arrows Puzzle Escape》登顶。卖点是一眼能懂、快速重试。[来源](http://www.gamelook.com.cn/2026/06/595439/)
|
||||||
|
- 2026-06:《Arrows GO!》海外单月下载超 2200 万,四个月累计超 6500 万,纯广告。[来源](http://www.gamelook.com.cn/2026/07/597131/)
|
||||||
|
- 同趣(《箭头消消消》《挪车冲冲冲》《猪了个猪》):后来去掉步数墙和时间墙,改成不用道具也能过。《猪了个猪》人均在线约 1800 秒升到约 3000 秒,人均广告约 5 次升到约 12 次。[来源](http://www.gamelook.com.cn/2025/12/584448/)
|
||||||
|
- 排序品类激励视频有效位是胜利后加倍、外观、主动提示、额外空间;步数或空间耗尽后的复活广告存在,但不是机场该抄的主失败。[来源](https://www.gamigion.com/the-complete-guide-to-sort-games-monetization-part-2/)
|
||||||
|
|
||||||
|
对照什么、不借什么:
|
||||||
|
|
||||||
|
- Cosmic Express:送到同色家并留下,多绕也算过,所以「少」看不见。[评测](https://www.rockpapershotgun.com/cosmic-express-review) · [商店](https://store.steampowered.com/app/583270/Cosmic_Express/)
|
||||||
|
- Golf Peaks:固定杆数卡打进洞,无计时,无分数惩罚。「少」是关卡本身,但失败是重试不是卖加步。[商店](https://store.steampowered.com/app/923260/Golf_Peaks/) · [介绍](https://indie-hive.com/golf-peaks/)
|
||||||
|
- Railbound:有限轨道连接车厢,放松,不是清空。[商店](https://store.steampowered.com/app/1967510/Railbound/)
|
||||||
|
- Ricochet Robots:公开最少步,比的是这个数。[百科](https://en.wikipedia.org/wiki/Ricochet_Robots)
|
||||||
|
- Monument Valley:转结构造路,颜色标可操作处,设计成大多数人能过完。[百科](https://en.wikipedia.org/wiki/Monument_Valley_(video_game))
|
||||||
|
- Flight Control:实时画线进同色跑道,相撞结束,飞机不留下。不借实时。[百科](https://en.wikipedia.org/wiki/Flight_Control_(video_game))
|
||||||
|
- Labyrinth:迷宫动、棋子跟着走。本方案棋盘、飞机、机位都不转,仍不借。[百科](https://en.wikipedia.org/wiki/Labyrinth_(board_game))
|
||||||
|
- Sokoban:终局是都在目标上,死锁靠撤销。[百科](https://en.wikipedia.org/wiki/Sokoban)
|
||||||
|
- Unpacking:放对有拟音,放错红框,可再拿起,无计时。[百科](https://en.wikipedia.org/wiki/Unpacking_(video_game))
|
||||||
|
- A Little to the Left:摆正响铃。[评测](https://www.theguardian.com/games/2022/nov/07/a-little-to-the-left-review-a-supremely-rewarding-ode-to-neatness)
|
||||||
|
- Parking Jam 奖驶离清空,不借。Water Sort / Ball Sort 奖倒进同色容器,可重开。
|
||||||
|
|
||||||
|
没有一款同时具备「固定机场、刻度开闭进场口、场外物体从固定边进入并留下、以少操作为爽点」。Hex 的滑轨转盘不在这个组合里。这是新组合,不是换皮。
|
||||||
|
|
||||||
|
本方案因此:N 必须公示(否则「少」看不见);主线无失败步数墙;广告放在胜利后;调边是正动作;不自动批量进场;转之前能数出开闭。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 6 关竖切制作卡
|
||||||
|
|
||||||
|
制作卡是设计,不是可运行关卡数据。不要写入 `labs/airport/levels.js`。
|
||||||
|
|
||||||
|
共用约束:
|
||||||
|
|
||||||
|
- 全部 19 格(半径 2)。
|
||||||
|
- 覆盖对齐、顺序、调边。不含 37 格挡板章。
|
||||||
|
- 无固定挡板、无箭头飞机、无「推出」。
|
||||||
|
- 不含自动批量进场,不含「先清全盘再接」。
|
||||||
|
- 每关不经提示、不点预览,玩家应能说出「我转这一格,是为了让缺口盖住这个口」(第 1 关的互补句:不用转,这个口的刻度已经在缺口下)。
|
||||||
|
- 颜色只用可可、草莓。轨道不分色。
|
||||||
|
- 边名与 v0.1 相同:右上方、右方、右下方、左下方、左方、左上方。
|
||||||
|
- 进场路径短,2–5 格。禁止花瓣巡游。
|
||||||
|
- 标准步是制作意图构成,落地时必须另证最短,未证不得写进关卡的 N。
|
||||||
|
|
||||||
|
### 竖切 1 · 口已经开了
|
||||||
|
|
||||||
|
**章:** 对齐
|
||||||
|
**教学意图:** 边已通就点进来。来机从当前边进入同色机位并留下。胜利是停好,不是飞走。
|
||||||
|
|
||||||
|
**棋盘:** 19 格。无固定挡板。右方有唯一可可口,刻度正在缺口下,所以开局口开。机位在右半外圈,进场 2–3 格直线或一弯,全程无占位,路径转之前就画在盘上。高亮在可可来机上已亮。其他边不要放该色的口,也不要放一个更亮的诱口。
|
||||||
|
|
||||||
|
**来机:** 1 架,可可,右方停机坪。
|
||||||
|
**机位:** 1 个可可位。
|
||||||
|
**固定挡板:** 无。
|
||||||
|
|
||||||
|
**标准步大概构成:** 进 × 1。教学开口,本竖切唯一允许 1 个提交的关。
|
||||||
|
|
||||||
|
**玩家应说出的一句话:** 这个口已经开了,点进来就停好。
|
||||||
|
|
||||||
|
**明确不做:** 旋转才能过;多色;调边;地面飞机;把飞机点飞;任何清场暗示。若玩家误转,缺口离开这枚刻度,口灭;刻度还在,再转回来或撤销即可。不要把「必须转」做成这一关的解,也不要让玩家靠试转才发现口在哪。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 竖切 2 · 转一下才通
|
||||||
|
|
||||||
|
**章:** 对齐
|
||||||
|
**教学意图:** 转一下口才亮。刻度差一格,动手前能数。预览不计星;确认这一格才提交。
|
||||||
|
|
||||||
|
**棋盘:** 19 格。无固定挡板。草莓机位在盘内右上象限。右上方有唯一草莓口,路径 2–5 格,转之前就能顺着暗轨看到机位。该口刻度在当前缺口顺时针一格,开局口灭。右转一格,缺口盖住这枚刻度,口亮。不要另一边同时开关,把「一转两口」留给下一关。
|
||||||
|
|
||||||
|
**来机:** 1 架,草莓,右上方停机坪。
|
||||||
|
**机位:** 1 个草莓位。
|
||||||
|
**固定挡板:** 无。
|
||||||
|
|
||||||
|
**标准步大概构成:** 转 × 1 + 进 × 1。
|
||||||
|
|
||||||
|
**玩家应说出的一句话:** 我转这一格,是为了让缺口盖住这个口。
|
||||||
|
|
||||||
|
**明确不做:** 多色;调边;清场;失败旋转扣星;开局口已亮(那是第 1 关);一转同时教训两边开关。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 竖切 3 · 一转两口
|
||||||
|
|
||||||
|
**章:** 对齐
|
||||||
|
**教学意图:** 一转会同时开关两边。两枚刻度差一格,转之前都看得见。
|
||||||
|
|
||||||
|
**棋盘:** 19 格。无固定挡板。可可机位有两个口,路径都固定,不随转改变。右方口的刻度正在缺口下,开局亮,但来机在左方,不能跨边。左方口的刻度在逆时针一格,开局灭。左转一格:左方亮、右方灭。点来机在开局应失败免费(左方没有开着的口)。
|
||||||
|
|
||||||
|
**来机:** 1 架,可可,左方停机坪。仍是一架,避免滑进顺序课。
|
||||||
|
**机位:** 1 个可可位。
|
||||||
|
**固定挡板:** 无。
|
||||||
|
|
||||||
|
**标准步大概构成:** 转 × 1 + 进 × 1。
|
||||||
|
|
||||||
|
**玩家应说出的一句话:** 这一转打开了我的口,也关掉了对面。
|
||||||
|
|
||||||
|
**明确不做:** 第二架来机;多色;调边;清场;转完自动进场。不要做成「转两次才对上」的寻朝向游戏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 竖切 4 · 谁先停
|
||||||
|
|
||||||
|
**章:** 顺序
|
||||||
|
**教学意图:** 先停 A 会挡住 B。停稳的来机是新障碍。谁先点,由空间决定,不靠隐藏编号。
|
||||||
|
|
||||||
|
**棋盘:** 19 格。无固定挡板。开局两口都已亮,**本关标准解不含转**,避免和对齐章抢课。草莓机位靠近右方入口短路径;可可机位路径穿过草莓机位邻格。先停草莓 → 可可进场撞第一阻挡(草莓)。先停可可 → 草莓仍可进。错序后靠撤销回去;不要把调边做成更短解(错序 + 调边必须长于 2)。
|
||||||
|
|
||||||
|
**来机:** 2 架。可可在右上方;草莓在右方。
|
||||||
|
**机位:** 可可 1、草莓 1。
|
||||||
|
**固定挡板:** 无。
|
||||||
|
|
||||||
|
**标准步大概构成:** 进可可 + 进草莓。意图 N = 2。
|
||||||
|
|
||||||
|
**玩家应说出的一句话:** 先停那架会挡住另一架,所以要换顺序。
|
||||||
|
|
||||||
|
**明确不做:** 自动批量进场;清场;必须旋转;把调边写成正解;做错就判负。失败的「进」只标第一个阻挡,不计星。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 竖切 5 · 两架各自进
|
||||||
|
|
||||||
|
**章:** 顺序
|
||||||
|
**教学意图:** 同色只能从自己边进。一转可点亮两架,但仍逐架点。这是对环路车场批量放行的反例。
|
||||||
|
|
||||||
|
**棋盘:** 19 格。无固定挡板。草莓口在右方,可可口在左方。两枚刻度在同一格,开局缺口不在那格,两口都灭。转一格同时盖住两枚刻度,两口一起亮。路径固定、互不挡,先点谁都通。若玩家只点一架就等另一架自己进来,另一架必须继续等。禁止任何「两口都亮则自动进」。
|
||||||
|
|
||||||
|
**来机:** 2 架。草莓右方,可可左方。
|
||||||
|
**机位:** 草莓 1、可可 1,各靠近自己边。
|
||||||
|
**固定挡板:** 无。
|
||||||
|
|
||||||
|
**标准步大概构成:** 转 × 1 + 进 × 2。两架进场顺序不影响 N。
|
||||||
|
|
||||||
|
**玩家应说出的一句话:** 我转是为了打开这两个口,但还是要一架一架点进来。
|
||||||
|
|
||||||
|
**明确不做:** 自动批量进场;整批发车键;清场;调边;用顺序阻挡抢第 4 关的课(本关两机互不挡)。不要第三色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 竖切 6 · 先停是为了换边
|
||||||
|
|
||||||
|
**章:** 调边
|
||||||
|
**教学意图:** 错边要先落、再调到出口边、再转、再进。来机不能自行绕盘。标准步包含这次调边;结算标成「为了换边」。
|
||||||
|
|
||||||
|
**棋盘:** 19 格。无固定挡板。不能用 1 架来机做本关:唯一来机一落下就赢,调边进不了标准解。必须 2 架。
|
||||||
|
|
||||||
|
空间故事:
|
||||||
|
|
||||||
|
1. 草莓在右上方。右上方有草莓口,刻度正在缺口下,可先落。这是错边:停稳后挡住可可从右方进场。阻挡在固定路径上,转之前就看得见。
|
||||||
|
2. 草莓调边。出口射线固定指向右下方,转不改变它。离场前先亮右下方。草莓出现在右下方停机坪,进度回到未完成。
|
||||||
|
3. 右下方的草莓口、右方的可可口,刻度都在顺时针下一格。转一格:这两个口亮,右上方口灭。
|
||||||
|
4. 可可从右方进。草莓从右下方进。两者最终同时在位。
|
||||||
|
|
||||||
|
若先停草莓却不调边,可可永远被挡。右下方的草莓口开局没开,草莓也不在那条边,不能直接进去。其他边没有这两个口的,转满也不会把口转出来。
|
||||||
|
|
||||||
|
**来机:** 2 架。草莓右上方,可可右方。
|
||||||
|
**机位:** 草莓 1、可可 1。
|
||||||
|
**固定挡板:** 无。
|
||||||
|
|
||||||
|
**标准步大概构成:** 进草莓 + 调边草莓 + 转 × 1 + 进可可 + 进草莓。意图 5 个提交,落在 2–6。回放里这次调边的标签是「为了换边」,不是浪费。
|
||||||
|
|
||||||
|
**玩家应说出的一句话:** 我先停是为了换到那条边,再转开口进来。
|
||||||
|
|
||||||
|
**明确不做:** 把调边写成失误或惩罚;1 架来机;自动换边;清场;固定挡板;失败步数墙把这 5 步判负。不要让「不先落、直接绕到右下方」成为可能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 竖切总表
|
||||||
|
|
||||||
|
| 关 | 名 | 章 | 来机 | 挡板 | 标准步大概构成 | 只教 |
|
||||||
|
|---|---|---|---:|---|---|---|
|
||||||
|
| 1 | 口已经开了 | 对齐 | 1 | 无 | 进 | 边已通就点进来 |
|
||||||
|
| 2 | 转一下才通 | 对齐 | 1 | 无 | 转、进 | 转一下口才亮 |
|
||||||
|
| 3 | 一转两口 | 对齐 | 1 | 无 | 转、进 | 一转同时开关两边 |
|
||||||
|
| 4 | 谁先停 | 顺序 | 2 | 无 | 进、进 | 先停 A 挡住 B |
|
||||||
|
| 5 | 两架各自进 | 顺序 | 2 | 无 | 转、进、进 | 一转两口仍逐架点 |
|
||||||
|
| 6 | 先停是为了换边 | 调边 | 2 | 无 | 进、调边、转、进、进 | 调边是正动作 |
|
||||||
|
|
||||||
|
6 关做完,玩家应掌握三动词,且不会把胜利理解成清空。挡板、推出、37 格,下一切片再说。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 实现边界
|
||||||
|
|
||||||
|
本文只定规则与制作卡。
|
||||||
|
|
||||||
|
- 不改 `docs/airport-design.md`。
|
||||||
|
- 不改 `labs/airport/`。刻度环试玩在 `labs/inbound/`,不是冻结关的替代。
|
||||||
|
- 不接广告 SDK。
|
||||||
|
- 竖切制作卡仍不是可发布关卡数据。`labs/inbound/levels.js` 只供试玩;标准 N 已由回放核对,未做全状态最短证明。
|
||||||
|
- 冻结 7 关保留为 v0.1 原型证据,不在本文重做。
|
||||||
|
|
||||||
|
求解器不能沿用 Hex「能清就清」,也不能调用 `rotateBoard` 解释本方案的转。朝向是刻度环下标。口开当且仅当该口刻度等于下标。进场与调边路径在未旋转的棋盘上计算。必须计算:降落增加占位、调边换侧、确认转格成本、失败免费。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 验收(设计,非代码)
|
||||||
|
|
||||||
|
- 胜利只问是否同时停好。
|
||||||
|
- 预览和失败试探不计星。
|
||||||
|
- 不点预览也能指出:向哪转一格打开哪个口、关掉哪个口;调边落到哪条边。转不移动轨道、飞机、机位、机头。
|
||||||
|
- 不加步广告。
|
||||||
|
- 地面飞机第一切片不可点飞。
|
||||||
|
- 三个动词可独立指出,调边在结算里不是浪费。
|
||||||
|
- 竖切 6 关均为 19 格设计卡,覆盖对齐、顺序、调边,无自动批量进场,无「先清全盘再接」。
|
||||||
|
- 每关能指向玩家应说的那一句话。
|
||||||
|
- 未把未核对的时长、广告忍受写成事实。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 六角机场实验室
|
||||||
|
|
||||||
|
独立的起降调度原型。前四关是 19 格教学;视觉使用原工程马卡龙方案。不覆盖主线关卡或存档。
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
在项目根目录运行 `npm run dev`,打开 [六角机场](http://localhost:4173/labs/airport/index.html)。静态打包包含本实验目录。设计规则见 [设计文档](../../docs/airport-design.md)。
|
||||||
|
|
||||||
|
点击地面飞机起飞,点击场外停机坪上的飞机降落;左右转每次 60°。成功、失败与旋转都消耗一次。灰色轨道不分颜色;来机匹配彩色专用机位。P 标记表示已停好的来机。它再次起飞后,停到实际出口那条边的停机坪,仍需重新停好。
|
||||||
|
|
||||||
|
板边轨道一直画到托盘外缘,不伸出棋盘。托盘外是以棋盘中心为焦点的六等分扇形停机坪,对应六条平边;扇形不随底盘旋转。当等待机在当前朝向下接得上同色机位时,提示线从飞机接到盘内通道;旋转时通道会亮灭,据此判断能否进场。
|
||||||
|
|
||||||
|
## 关卡证据
|
||||||
|
|
||||||
|
所有数据冻结在 `levels.js`。`solution` 是最优免费解,`experience` 是制作意图回放。
|
||||||
|
|
||||||
|
| 关卡 | 格子 | 最少操作 | 初始额度 | 体验回放 | 先清全盘再接机最少操作 | 最优解保留原有飞机 |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| 留一点空间 | 19 | 1 | 3 | 1 | 4 | 3 |
|
||||||
|
| 同色才停靠 | 19 | 1 | 3 | 1 | 4 | 3 |
|
||||||
|
| 转一下再接入 | 19 | 2 | 4 | 2 | 5 | 3 |
|
||||||
|
| 清障再接入 | 19 | 5 | 7 | 5 | 11 | 4 |
|
||||||
|
| 更大的停机坪 | 37 | 6 | 10 | 6 | 19 | 7 |
|
||||||
|
| 绕一圈再接入 | 37 | 10 | 14 | 10 | 15 | 5 |
|
||||||
|
| 空港全景 | 61 | 8 | 12 | 8 | 18 | 10 |
|
||||||
|
|
||||||
|
前四关为教学。第 5、6 关是半径 3 的练习关。第 7 关把棋盘放大到半径 4(61 格)。最优性通过统一动作成本 BFS 的完整找到最短路径证明;状态上限或深度上限只返回 unknown。
|
||||||
|
|
||||||
|
每关通过空棋盘、六朝向、六边、每色完整结果的无歧义校验。入口侧按端口位置落入的平边扇区归类,名称依次为右上方、右方、右下方、左下方、左方、左上方;角部端口只归一边。来机只能从当前停机坪那条平边进场,画在对应边外侧,不使用正北等罗盘悬停点。
|
||||||
|
|
||||||
|
```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,596 @@
|
|||||||
|
import { AirportGame, SIDES, boardAt, catalogs, parkedCount, copy, boardRadius, waitingPose, apronDistance, trayHull, rimCrossing, TRAY_PAD } from './core.js';
|
||||||
|
import { LEVELS } from './levels.js';
|
||||||
|
import { requestReward } from './rewards.js';
|
||||||
|
import { position, PORTS, hexPoints, trackPoints, sweepPath } from '../../src/path-math.js';
|
||||||
|
import { DIRS, key } from '../../src/core.js';
|
||||||
|
import { roundedHex } from '../../src/tile-shape.js';
|
||||||
|
import { travelProgress } from '../../src/exit-animation.js';
|
||||||
|
import { collisionPhase } from '../../src/collision-animation.js';
|
||||||
|
import { artDirection, directionTheme } from '../../src/art-directions.js';
|
||||||
|
import { ICONS } from '../../src/ui-skin.js';
|
||||||
|
|
||||||
|
const direction = artDirection('macaron');
|
||||||
|
const world = directionTheme(direction, 'lagoon');
|
||||||
|
const pigments = direction.pigments;
|
||||||
|
const materials = world.materials;
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id), canvas = $('board'), ctx = canvas.getContext('2d');
|
||||||
|
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const STORAGE = 'hex-airport-v1';
|
||||||
|
let saved = { completed: [], last: 0, sound: true };
|
||||||
|
try { const value = JSON.parse(localStorage.getItem(STORAGE)); if (value && Array.isArray(value.completed)) saved = { ...saved, ...value }; } catch {}
|
||||||
|
function persist() { try { localStorage.setItem(STORAGE, JSON.stringify(saved)); } catch {} }
|
||||||
|
let index = Number.isInteger(saved.last) && saved.last >= 0 && saved.last < LEVELS.length ? saved.last : 0;
|
||||||
|
let game, motions = [], rotation = null, feedback = null, queue = [], requestId = 0, diagnosis = null, generation = 0;
|
||||||
|
let dragSpin = null;
|
||||||
|
const STEP=Math.PI/3, SNAP=22*Math.PI/180, FLICK_MS=240, FLICK_ANG=.18;
|
||||||
|
function wrapDelta(a){while(a>Math.PI)a-=Math.PI*2;while(a<-Math.PI)a+=Math.PI*2;return a;}
|
||||||
|
let view = { width: 390, height: 460, size: 28, ox: 195, oy: 230 }, audioContext;
|
||||||
|
const worker = new Worker(new URL('./solver-worker.js', import.meta.url), { type: 'module' });
|
||||||
|
worker.onmessage = ({ data }) => {
|
||||||
|
if (data.request !== requestId) return;
|
||||||
|
diagnosis = data.result;
|
||||||
|
if (diagnosis.status === 'deadlock') game.deadlocked = true;
|
||||||
|
settle();
|
||||||
|
};
|
||||||
|
worker.onerror = () => { diagnosis = { status: 'unknown' }; settle(); };
|
||||||
|
function diagnose() {
|
||||||
|
diagnosis = null;
|
||||||
|
worker.postMessage({ request: ++requestId, level: game.level, state: copy(game.state) });
|
||||||
|
}
|
||||||
|
function sound(name) {
|
||||||
|
if (!saved.sound) return;
|
||||||
|
try {
|
||||||
|
audioContext ??= new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
audioContext.resume().catch(() => {});
|
||||||
|
const frequencies = { land: [440, 660], exit: [660, 880], rotated: [330, 440], hit: [145], win: [523, 659, 784] }[name] || [480];
|
||||||
|
frequencies.forEach((frequency, i) => {
|
||||||
|
const oscillator = audioContext.createOscillator(), gain = audioContext.createGain(), t = audioContext.currentTime + i * .09;
|
||||||
|
oscillator.frequency.value = frequency; gain.gain.setValueAtTime(.001, t); gain.gain.linearRampToValueAtTime(.045, t + .01); gain.gain.exponentialRampToValueAtTime(.001, t + .18);
|
||||||
|
oscillator.connect(gain); gain.connect(audioContext.destination); oscillator.start(t); oscillator.stop(t + .2);
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
function notice(message, warning = false) { $('notice').textContent = message; $('notice').classList.toggle('warning', warning); }
|
||||||
|
let cheers = [];
|
||||||
|
// A short candy burst around the stand that just accepted a plane. Pieces stay
|
||||||
|
// outside the hex so they do not cover the nose or the bay mark. Windows with
|
||||||
|
// animation effects off still gets the burst: skipping it hid the landing.
|
||||||
|
function cheer(at, color) {
|
||||||
|
const colors = ['#EE5398', '#E5680C', '#4C9E5A', '#578BF0', '#937DE4', '#D06E93', '#FFFFFF', pigments[color].color];
|
||||||
|
cheers.push({
|
||||||
|
x: at.x, y: at.y, start: performance.now(), duration: 1100,
|
||||||
|
bits: Array.from({ length: 16 }, (_, i) => ({
|
||||||
|
angle: -Math.PI / 2 + (i / 16) * Math.PI * 2 + (i % 2 ? .1 : -.06),
|
||||||
|
reach: .1 + (i % 3) * .04,
|
||||||
|
spin: (i % 2 ? 1 : -1) * (.55 + (i % 3) * .15),
|
||||||
|
len: i % 4 === 3 ? 0 : .26 + (i % 3) * .04,
|
||||||
|
color: colors[i % colors.length],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function drawCheers(now) {
|
||||||
|
const before = cheers.length;
|
||||||
|
cheers = cheers.filter(c => now - c.start < c.duration);
|
||||||
|
const unit = Math.max(view.size, 1);
|
||||||
|
const stroke = Math.max(.16, 4.5 / unit);
|
||||||
|
const dot = Math.max(.09, 3 / unit);
|
||||||
|
for (const c of cheers) {
|
||||||
|
const t = (now - c.start) / c.duration;
|
||||||
|
const fade = t < .05 ? t / .05 : Math.max(0, 1 - (t - .7) / .3);
|
||||||
|
for (const bit of c.bits) {
|
||||||
|
const ease = 1 - Math.pow(1 - t, 2);
|
||||||
|
const dist = 1.14 + ease * bit.reach;
|
||||||
|
const curl = bit.spin * ease * .14;
|
||||||
|
const x = c.x + Math.cos(bit.angle + curl) * dist;
|
||||||
|
const y = c.y + Math.sin(bit.angle + curl) * dist + ease * ease * .05;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.rotate(bit.angle + t * bit.spin);
|
||||||
|
ctx.globalAlpha = fade;
|
||||||
|
if (bit.len) line([{ x: -bit.len / 2, y: 0 }, { x: bit.len / 2, y: 0 }], bit.color, stroke);
|
||||||
|
else { ctx.beginPath(); ctx.arc(0, 0, dot, 0, Math.PI * 2); ctx.fillStyle = bit.color; ctx.fill(); }
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return before > 0 && !cheers.length;
|
||||||
|
}
|
||||||
|
function loadLevel(next) {
|
||||||
|
generation++; requestId++; index = next; saved.last = index; persist();
|
||||||
|
game = new AirportGame(LEVELS[index]); motions = []; cheers = []; rotation = null; dragSpin = null; feedback = null; queue = []; diagnosis = null;
|
||||||
|
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
|
||||||
|
$('level-title').textContent = `第 ${index + 1} 关`;
|
||||||
|
$('level-name').textContent = game.level.name;
|
||||||
|
notice(game.level.lesson); resize(); diagnose();
|
||||||
|
}
|
||||||
|
function waitingLocation(plane, state = game.state) {
|
||||||
|
return waitingPose(plane, state.planes, boardRadius(game.level.cells));
|
||||||
|
}
|
||||||
|
function planeLocation(plane, state = game.state) {
|
||||||
|
return plane.state === 'waiting' ? waitingLocation(plane, state) : { ...position(plane.q, plane.r), angle: plane.dir * Math.PI / 3 };
|
||||||
|
}
|
||||||
|
function screen(point) { return { x: view.ox + point.x * view.size, y: view.oy + point.y * view.size }; }
|
||||||
|
// A waiting plane whose edge actually connects to a same-colour stand: the runway
|
||||||
|
// exists (whether or not a plane currently blocks it). Used to prompt the player
|
||||||
|
// and to draw the approach guide; the player still performs the landing.
|
||||||
|
function approachHints() {
|
||||||
|
if (game.won || game.deadlocked) return [];
|
||||||
|
const routes = catalogs(game.level)[game.state.orientation].arrivals;
|
||||||
|
const blockers = game.state.planes.filter(p => (p.state === 'ground' || p.state === 'parked'))
|
||||||
|
.map(p => ({ ...position(p.q, p.r), id: p.id }));
|
||||||
|
const hints = [];
|
||||||
|
for (const plane of game.state.planes) {
|
||||||
|
if (plane.state !== 'waiting') continue;
|
||||||
|
const route = routes.find(r => r.side === plane.side && r.color === plane.color);
|
||||||
|
if (!route) continue; // no entrance / no connected runway from this edge
|
||||||
|
const hit = sweepPath(route.points, blockers.filter(b => b.id !== plane.id));
|
||||||
|
hints.push({ plane, from: planeLocation(plane), points: route.points, blocker: hit ? hit.blocker : null });
|
||||||
|
}
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
function sync() {
|
||||||
|
$('airspace').setAttribute('aria-busy', String(game.busy.size > 0 || game.rotating));
|
||||||
|
$('moves').textContent = game.moves;
|
||||||
|
const parked = parkedCount(game.state), total = game.level.arrivals.length;
|
||||||
|
$('progress').textContent = `${parked} / ${total}`;
|
||||||
|
$('progress-bar').style.width = `${100 * parked / total}%`;
|
||||||
|
$('left').disabled = $('right').disabled = game.busy.size > 0 || game.rotating || game.moves <= 0 || game.won || game.deadlocked;
|
||||||
|
$('sound').textContent = `声音:${saved.sound ? '开' : '关'}`;
|
||||||
|
const controls = $('plane-controls');
|
||||||
|
const live = new Set();
|
||||||
|
for (const plane of game.state.planes) {
|
||||||
|
if (plane.state === 'gone') continue;
|
||||||
|
live.add(plane.id);
|
||||||
|
let button = controls.querySelector(`[data-plane="${plane.id}"]`);
|
||||||
|
if (!button) {
|
||||||
|
button = document.createElement('button'); button.type = 'button'; button.className = 'plane-hit'; button.dataset.plane = plane.id;
|
||||||
|
button.addEventListener('click', () => perform({ type: 'tap', id: plane.id })); controls.append(button);
|
||||||
|
}
|
||||||
|
const label = plane.state === 'waiting' ? `${SIDES[plane.side]}停机坪待降落` : plane.kind === 'arrival' ? '已停靠,可再次起飞' : '地面待起飞';
|
||||||
|
button.setAttribute('aria-label', `${pigments[plane.color].name}飞机 ${plane.id},${label}`);
|
||||||
|
const p = screen(planeLocation(plane));
|
||||||
|
button.style.left = `${p.x}px`; button.style.top = `${p.y}px`;
|
||||||
|
button.style.width = button.style.height = `${Math.max(44, view.size * 1.65)}px`;
|
||||||
|
button.disabled = game.busy.has(plane.id) || game.won || game.deadlocked || (game.moves <= 0 && plane.state !== 'waiting');
|
||||||
|
button.classList.toggle('queued', queue.some(a => a.id === plane.id));
|
||||||
|
}
|
||||||
|
for (const button of [...controls.children]) if (!live.has(button.dataset.plane)) button.remove();
|
||||||
|
}
|
||||||
|
function perform(action, opts = {}) {
|
||||||
|
if (document.querySelector('dialog[open]')) return;
|
||||||
|
if (game.rotating && action.type === 'tap') {
|
||||||
|
if (!queue.some(a => a.id === action.id) && !game.busy.has(action.id)) queue.push(action);
|
||||||
|
sync(); return;
|
||||||
|
}
|
||||||
|
const before = copy(game.state), result = game.begin(action);
|
||||||
|
if (['busy', 'invalid', 'finished', 'out-of-moves'].includes(result.status)) return;
|
||||||
|
if (result.status === 'ambiguous') { notice('此布局有多个停靠结果,已暂停操作。', true); return; }
|
||||||
|
feedback = null; const now = performance.now();
|
||||||
|
if (result.status === 'rotated') {
|
||||||
|
if (opts.skipSpin) game.finish('rotation');
|
||||||
|
else rotation = { old: boardAt(game.level, before.orientation), direction: action.direction, start: now, duration: 240 };
|
||||||
|
sound('rotated'); notice('轨道已调整,观察飞机所在侧的连接。');
|
||||||
|
} else {
|
||||||
|
const plane = before.planes.find(p => p.id === action.id), updated = game.state.planes.find(p => p.id === action.id);
|
||||||
|
let points, kind;
|
||||||
|
if (result.status === 'land') {
|
||||||
|
const start = waitingLocation(plane, before);
|
||||||
|
// Exterior approach stays outside the board and joins the selected boundary port.
|
||||||
|
const mouth = result.points[0], n = PORTS[result.exitDir ?? plane.side], len = Math.hypot(n.x,n.y);
|
||||||
|
const approach = { x: mouth.x + n.x / len * 1.3, y: mouth.y + n.y / len * 1.3 };
|
||||||
|
points = [start, approach, ...result.points]; kind = 'land'; sound('land');
|
||||||
|
notice('停靠成功。留意新飞机是否挡住其他通路。');
|
||||||
|
} else if (result.status === 'exit') {
|
||||||
|
points = [...result.points]; kind = 'exit'; sound('exit');
|
||||||
|
if (plane.kind === 'arrival') {
|
||||||
|
const last = points.at(-1), n = PORTS[result.exitDir ?? result.side], len = Math.hypot(n.x,n.y);
|
||||||
|
points.push({ x:last.x+n.x/len*1.3, y:last.y+n.y/len*1.3 }, waitingLocation(updated));
|
||||||
|
notice(`来机已起飞,将在${SIDES[result.side]}停机坪等待。`);
|
||||||
|
} else notice('飞机已离场,空出的通路可以用于接机。');
|
||||||
|
} else {
|
||||||
|
sound('hit'); kind = plane.state === 'waiting' ? 'shake' : 'bump';
|
||||||
|
points = result.travelPoints || result.points || [planeLocation(plane)];
|
||||||
|
if (kind === 'bump' && result.status !== 'blocked') points = [planeLocation(plane)];
|
||||||
|
feedback = { ...result, until: now + 4500 };
|
||||||
|
const messages = { 'blocked':'路线受阻 · 先移开高亮的飞机', 'no-stand':'没有可用的跑道', 'no-route':'没有可供行进的路线', 'loop':'没有可供离场的路线' };
|
||||||
|
notice(messages[result.status] || '没有可供行进的路线', true);
|
||||||
|
}
|
||||||
|
const length = pathLength(points);
|
||||||
|
motions.push({ id: plane.id, plane, updated, kind, points, blocker: result.blocker, start: now, duration: kind === 'shake' ? (reduced ? 100 : 420) : kind === 'bump' ? 800 : motionTiming(length, kind) });
|
||||||
|
}
|
||||||
|
sync(); diagnose();
|
||||||
|
}
|
||||||
|
function pathLength(points) { let length=0; for(let i=1;i<points.length;i++)length+=Math.hypot(points[i].x-points[i-1].x,points[i].y-points[i-1].y); return length; }
|
||||||
|
// Descent reads slower than taxi-out; both keep one cruising speed per unit and
|
||||||
|
// accelerate only past the knee, like the original exit timing.
|
||||||
|
function motionTiming(length, kind) {
|
||||||
|
const msPerUnit = kind === 'land' ? 150 : 100, knee = 2600, maxMs = 3600, ramp = 90;
|
||||||
|
const linear = length * msPerUnit;
|
||||||
|
const base = linear <= knee ? linear : Math.min(maxMs, knee + (linear - knee) * (maxMs - knee) / Math.max(1, linear - knee) * (1 - Math.exp(-(linear - knee) / Math.max(1, maxMs - knee))));
|
||||||
|
return Math.max(kind === 'land' ? 560 : 320, base + Math.min(ramp, base / 3));
|
||||||
|
}
|
||||||
|
function along(points, progress) {
|
||||||
|
if (points.length < 2) return { ...points[0], angle: 0 };
|
||||||
|
let remaining = pathLength(points) * Math.min(1, Math.max(0, progress));
|
||||||
|
for(let i=1;i<points.length;i++){
|
||||||
|
const a=points[i-1],b=points[i],length=Math.hypot(b.x-a.x,b.y-a.y);
|
||||||
|
if(remaining<=length||i===points.length-1){const t=length?Math.min(1,remaining/length):0;return{x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,angle:Math.atan2(b.y-a.y,b.x-a.x)+Math.PI/2};}
|
||||||
|
remaining-=length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function show(dialog) { queue=[]; sync(); if(!dialog.open)dialog.showModal(); }
|
||||||
|
function settle() {
|
||||||
|
if (motions.length || rotation) return;
|
||||||
|
sync();
|
||||||
|
// Prompt the player when a waiting plane's runway is connected (blocked or not).
|
||||||
|
if (!game.won && !game.deadlocked && approachHints().length) {
|
||||||
|
const guide = '来机已连通:点击它接入同色机位。';
|
||||||
|
if ($('notice').textContent !== guide && !$('notice').classList.contains('warning')) notice(guide);
|
||||||
|
}
|
||||||
|
if (game.won) {
|
||||||
|
if (cheers.some(c => performance.now() - c.start < c.duration)) return;
|
||||||
|
if (!$('result').open) {
|
||||||
|
if (!saved.completed.includes(game.level.id)) saved.completed.push(game.level.id);
|
||||||
|
persist(); sound('win');
|
||||||
|
$('result-title').textContent='全部到位';
|
||||||
|
const remaining=game.state.planes.filter(p=>p.kind==='original'&&p.state==='ground').length;
|
||||||
|
$('result-copy').textContent=`全部 ${game.level.arrivals.length} 架来机已停好,剩余 ${game.moves} 次操作。场内还保留了 ${remaining} 架原有飞机。`;
|
||||||
|
$('next').hidden=false;$('next').textContent=index<LEVELS.length-1?'下一关':'回到第一关';
|
||||||
|
$('reward').hidden=true;$('reward-note').hidden=true;show($('result'));
|
||||||
|
}
|
||||||
|
} else if (game.moves <= 0 || game.deadlocked) {
|
||||||
|
$('next').hidden=true;
|
||||||
|
$('result-title').textContent=game.deadlocked?'当前调度已无法完成':'操作次数已用完';
|
||||||
|
$('result-copy').textContent=game.deadlocked?'增加次数也无法解开当前局面。重新安排起降顺序,再试一次。':!diagnosis?'正在检查当前局面…':diagnosis.status==='solved'?'当前局面仍有可行路线,可以补充操作次数继续。':'尚未确定当前局面是否可完成,补充次数不保证能解围。';
|
||||||
|
const available=!game.deadlocked&&game.rewardCount<game.level.reward.maxClaims;
|
||||||
|
if(!game.deadlocked&&!available)$('result-copy').textContent='本关的补充次数已用完,可以重新安排调度再挑战。';
|
||||||
|
$('reward').hidden=!available;$('reward').disabled=!diagnosis||game.rewardPending;
|
||||||
|
$('reward').textContent=window.airportAdProvider?`观看广告 · +${game.level.reward.steps} 次`:`测试补充 · +${game.level.reward.steps} 次`;
|
||||||
|
$('reward-note').hidden=!available||!!window.airportAdProvider;
|
||||||
|
show($('result'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing uses exactly the same normalized paths as collision and replay.
|
||||||
|
function polygon(points, fill, stroke, width=.04) {
|
||||||
|
ctx.beginPath();points.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();
|
||||||
|
if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=width;ctx.stroke();}
|
||||||
|
}
|
||||||
|
function line(points,color,width=.06){if(!points?.length)return;ctx.beginPath();points.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.strokeStyle=color;ctx.lineWidth=width;ctx.lineCap='round';ctx.lineJoin='round';ctx.stroke();}
|
||||||
|
function label(text,x,y,size,color=world.ink){ctx.fillStyle=color;ctx.font=`700 ${size}px "Resource Han Rounded","Microsoft YaHei",sans-serif`;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(text,x,y);}
|
||||||
|
function roundedPoly(poly,radius){
|
||||||
|
return poly.flatMap((v,i)=>{
|
||||||
|
const prev=poly[(i+poly.length-1)%poly.length],next=poly[(i+1)%poly.length];
|
||||||
|
const ka=Math.min(.5,radius/Math.hypot(prev.x-v.x,prev.y-v.y)),kb=Math.min(.5,radius/Math.hypot(next.x-v.x,next.y-v.y));
|
||||||
|
const a={x:v.x+(prev.x-v.x)*ka,y:v.y+(prev.y-v.y)*ka},b={x:v.x+(next.x-v.x)*kb,y:v.y+(next.y-v.y)*kb};
|
||||||
|
return Array.from({length:6},(_,j)=>{const t=j/5,u=1-t;return {x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y};});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function ringTint(cell){
|
||||||
|
const rings=materials.rings||[materials.board];
|
||||||
|
return rings[(Math.abs(cell.q)+Math.abs(cell.r)+Math.abs(cell.q+cell.r))/2%rings.length];
|
||||||
|
}
|
||||||
|
const FLAT=Math.cos(Math.PI/6);
|
||||||
|
function hull(points){
|
||||||
|
const sorted=[...points].sort((a,b)=>a.x-b.x||a.y-b.y),lower=[],upper=[],cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
|
||||||
|
for(const q of sorted){while(lower.length>=2&&cross(lower.at(-2),lower.at(-1),q)<=0)lower.pop();lower.push(q);}
|
||||||
|
for(const q of sorted.reverse()){while(upper.length>=2&&cross(upper.at(-2),upper.at(-1),q)<=0)upper.pop();upper.push(q);}
|
||||||
|
return [...lower.slice(0,-1),...upper.slice(0,-1)];
|
||||||
|
}
|
||||||
|
function insideHull(poly,x,y){for(let i=0;i<poly.length;i++){const a=poly[i],b=poly[(i+1)%poly.length];if((b.x-a.x)*(y-a.y)-(b.y-a.y)*(x-a.x)<-1e-9)return false;}return true;}
|
||||||
|
function clipToHull(points,poly){
|
||||||
|
const out=[];
|
||||||
|
for(let i=0;i<points.length;i++){
|
||||||
|
const p=points[i],inn=insideHull(poly,p.x,p.y),prev=i?points[i-1]:null,prevIn=prev&&insideHull(poly,prev.x,prev.y);
|
||||||
|
if(inn){
|
||||||
|
if(prev&&!prevIn){const dx=p.x-prev.x,dy=p.y-prev.y,l=Math.hypot(dx,dy)||1,t=rimCrossing(poly,p,{x:-dx/l,y:-dy/l});if(t!==null)out.push({x:p.x-dx/l*t,y:p.y-dy/l*t});}
|
||||||
|
out.push(p);
|
||||||
|
}else if(prevIn){const dx=p.x-prev.x,dy=p.y-prev.y,l=Math.hypot(dx,dy)||1,t=rimCrossing(poly,prev,{x:dx/l,y:dy/l});if(t!==null)out.push({x:prev.x+dx/l*t,y:prev.y+dy/l*t});}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function apronSector(hull,side,outerR,steps=16,inset=.035){
|
||||||
|
const a0=-Math.PI/2+side*Math.PI/3+inset,a1=-Math.PI/2+(side+1)*Math.PI/3-inset,pts=[];
|
||||||
|
for(let i=0;i<=steps;i++){
|
||||||
|
const a=a0+(a1-a0)*i/steps,dir={x:Math.cos(a),y:Math.sin(a)};
|
||||||
|
const t=rimCrossing(hull,{x:0,y:0},dir),r=(t??0)+.08;
|
||||||
|
pts.push({x:dir.x*r,y:dir.y*r});
|
||||||
|
}
|
||||||
|
for(let i=steps;i>=0;i--){
|
||||||
|
const a=a0+(a1-a0)*i/steps;
|
||||||
|
pts.push({x:Math.cos(a)*outerR,y:Math.sin(a)*outerR});
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
function rgba(hex,a){const n=hex.replace('#','');return `rgba(${parseInt(n.slice(0,2),16)},${parseInt(n.slice(2,4),16)},${parseInt(n.slice(4,6),16)},${a})`;}
|
||||||
|
function drawApronFans(hull,outerR){
|
||||||
|
const fill='#d1d1d1';
|
||||||
|
for(let side=0;side<6;side++){
|
||||||
|
polygon(apronSector(hull,side,outerR),rgba(fill,.2));
|
||||||
|
const inset=.035,a0=-Math.PI/2+side*Math.PI/3+inset,a1=-Math.PI/2+(side+1)*Math.PI/3-inset;
|
||||||
|
ctx.beginPath();ctx.arc(0,0,outerR,a0,a1);
|
||||||
|
ctx.strokeStyle=rgba(fill,.16);ctx.lineWidth=.055;ctx.lineCap='round';ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function drawSpinArc(hull,angle){
|
||||||
|
if(!angle||Math.abs(angle)<.02)return;
|
||||||
|
let r=0;for(const v of hull)r=Math.max(r,Math.hypot(v.x,v.y));
|
||||||
|
r+=.18;
|
||||||
|
const a0=-Math.PI/2,commit=Math.abs(angle)>=SNAP;
|
||||||
|
ctx.beginPath();ctx.arc(0,0,r,a0,a0+angle,angle<0);
|
||||||
|
ctx.strokeStyle=commit?direction.accent:rgba(materials.track,.55);
|
||||||
|
ctx.lineWidth=commit?.12:.09;ctx.lineCap='round';ctx.stroke();
|
||||||
|
}
|
||||||
|
const NOSE=.70;
|
||||||
|
function nosePoint(where){
|
||||||
|
return {x:where.x+Math.sin(where.angle)*NOSE,y:where.y-Math.cos(where.angle)*NOSE};
|
||||||
|
}
|
||||||
|
function aircraft(plane,where,alpha=1){
|
||||||
|
ctx.save();ctx.translate(where.x,where.y);ctx.globalAlpha=alpha;
|
||||||
|
const color=pigments[plane.color].color;
|
||||||
|
const face=roundedHex(0,0,.90,.16);
|
||||||
|
polygon(face,color);
|
||||||
|
polygon(face,null,'#FFFFFF',.09);
|
||||||
|
ctx.save();ctx.rotate(where.angle);
|
||||||
|
const silhouette=[[0,-NOSE],[.11,-.50],[.11,-.12],[.58,.20],[.58,.32],[.12,.13],[.11,.44],[.28,.60],[.28,.68],[0,.56],[-.28,.68],[-.28,.60],[-.11,.44],[-.12,.13],[-.58,.32],[-.58,.20],[-.11,-.12],[-.11,-.50]];
|
||||||
|
polygon(silhouette.map(([x,y])=>({x,y})),'#FFFFFF');
|
||||||
|
line([{x:0,y:-.46},{x:0,y:-.28}],color,.07);
|
||||||
|
ctx.restore();
|
||||||
|
label(String.fromCharCode(65+plane.color),-.50,-.46,.24,world.ink);
|
||||||
|
if(plane.state==='parked'){
|
||||||
|
ctx.beginPath();ctx.arc(.52,.50,.22,0,Math.PI*2);ctx.fillStyle=direction.accent;ctx.fill();label('P',.52,.50,.26,'#FFFFFF');
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
// Stand icon. 'bay' is the trial; set STAND_MARK to 'plain' to restore the white pad.
|
||||||
|
const STAND_MARK='bay';
|
||||||
|
function drawStand(stand){
|
||||||
|
const p=position(stand.q,stand.r),color=pigments[stand.color].color,letter=String.fromCharCode(65+stand.color);
|
||||||
|
if(STAND_MARK==='plain'){
|
||||||
|
polygon(roundedHex(p.x,p.y,.70,.16),'#FFFFFF',color,.08);
|
||||||
|
label('P',p.x,p.y-.06,.52,color);label(letter,p.x,p.y+.38,.22,world.ink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Inverted aircraft: a candy ring around a white face. Outer edge matches the
|
||||||
|
// old stroke's reach, so the runway gutter is unchanged. The face stays white
|
||||||
|
// so the P keeps its contrast on every pigment, including honey.
|
||||||
|
polygon(roundedHex(p.x,p.y,.74,.16),color);
|
||||||
|
polygon(roundedHex(p.x,p.y,.56,.20),'#FFFFFF');
|
||||||
|
label('P',p.x,p.y-.06,.46,color);label(letter,p.x,p.y+.30,.22,world.ink);
|
||||||
|
}
|
||||||
|
function draw(now){
|
||||||
|
const dpr=Math.min(devicePixelRatio||1,2);ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,view.width,view.height);
|
||||||
|
ctx.translate(view.ox,view.oy);ctx.scale(view.size,view.size);
|
||||||
|
const board=rotation?rotation.old:catalogs(game.level)[game.state.orientation].board;
|
||||||
|
const hullPts=trayHull(board.cells);
|
||||||
|
// Six equal apron fans, fixed around the board centre. They replace the old
|
||||||
|
// strip runways so each visual flat (the ABCDEF wedges) is a readable sector.
|
||||||
|
const radius=boardRadius(game.level.cells),apronR=apronDistance(radius);
|
||||||
|
const stacks=Math.max(1,...[0,1,2,3,4,5].map(s=>game.level.arrivals.filter(a=>a.side===s).length));
|
||||||
|
const outerR=apronR+(stacks-1)*1.12+.85;
|
||||||
|
drawApronFans(hullPts,outerR);
|
||||||
|
if(dragSpin)drawSpinArc(hullPts,dragSpin.angle);
|
||||||
|
if(dragSpin?.settling){
|
||||||
|
const s=dragSpin.settling,p=Math.min(1,(now-s.start)/s.duration),u=p*p*(3-2*p);
|
||||||
|
dragSpin.angle=s.from+(s.to-s.from)*u;
|
||||||
|
if(p>=1){const dir=s.commitDir;dragSpin=null;if(dir)perform({type:'rotate',direction:dir},{skipSpin:true});}
|
||||||
|
}
|
||||||
|
ctx.save();
|
||||||
|
const boardSpin=rotation?(Math.min(1,(now-rotation.start)/rotation.duration)**2*(3-2*Math.min(1,(now-rotation.start)/rotation.duration)))*rotation.direction*STEP:(dragSpin?.angle||0);
|
||||||
|
if(boardSpin)ctx.rotate(boardSpin);
|
||||||
|
const tray=grow=>roundedPoly(hull(board.cells.flatMap(c=>{const p=position(c.q,c.r);return hexPoints(p.x,p.y,TRAY_PAD+grow);})),.9);
|
||||||
|
polygon(tray(.06).map(q=>({x:q.x,y:q.y+.20})),materials.shadowSoft);
|
||||||
|
polygon(tray(0).map(q=>({x:q.x,y:q.y+.10})),materials.shadow);
|
||||||
|
polygon(tray(0),materials.tray);
|
||||||
|
for(const cell of board.cells){const p=position(cell.q,cell.r);polygon(roundedHex(p.x,p.y,.90,.16),ringTint(cell));}
|
||||||
|
// Runways stay one piece with the sugar bricks: an end that joins a neighbouring
|
||||||
|
// brick keeps its port radius, while a board-edge end is pulled inside the flat side.
|
||||||
|
const cellKeys=new Set(board.cells.map(c=>key(c.q,c.r)));
|
||||||
|
const trackWidth=.11,portRadius=Math.hypot(PORTS[0].x,PORTS[0].y),visualRadius=Math.max(0,.90*FLAT-trackWidth/2-.01);
|
||||||
|
const exterior=(cell,d)=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
|
||||||
|
// A boundary runway reaches the tray outline instead of stopping inside the brick,
|
||||||
|
// but never leaves the board: the round cap is pulled back by half the stroke.
|
||||||
|
const rimEnd=(point,dir)=>{
|
||||||
|
const n=PORTS[dir],l=Math.hypot(n.x,n.y),ux=n.x/l,uy=n.y/l,t=rimCrossing(hullPts,point,{x:ux,y:uy});
|
||||||
|
if(t===null)return null;
|
||||||
|
const reach=Math.max(0,t-trackWidth/2);
|
||||||
|
return {x:point.x+ux*reach,y:point.y+uy*reach};
|
||||||
|
};
|
||||||
|
for(const cell of board.cells){
|
||||||
|
const p=position(cell.q,cell.r);
|
||||||
|
for(let d=0;d<6;d++){
|
||||||
|
if(d>=cell.tracks[d])continue;
|
||||||
|
const b=cell.tracks[d],points=trackPoints(p.x,p.y,1,d,b),shrink=1-visualRadius/portRadius;
|
||||||
|
const last=points[points.length-1];
|
||||||
|
const dx0=exterior(cell,d)?(points[0].x-p.x)*shrink:0,dy0=exterior(cell,d)?(points[0].y-p.y)*shrink:0;
|
||||||
|
const dx1=exterior(cell,b)?(last.x-p.x)*shrink:0,dy1=exterior(cell,b)?(last.y-p.y)*shrink:0;
|
||||||
|
points.forEach((q,i)=>{const t=i/(points.length-1),u=1-t;q.x-=u*u*u*dx0+t*t*t*dx1;q.y-=u*u*u*dy0+t*t*t*dy1;});
|
||||||
|
if(exterior(cell,d)){const g=rimEnd(points[0],d);if(g)points.unshift(g);}
|
||||||
|
if(exterior(cell,b)){const g=rimEnd(points.at(-1),b);if(g)points.push(g);}
|
||||||
|
line(points,materials.track,trackWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
// Stands, like the aircraft, never turn with the base.
|
||||||
|
for(const stand of game.level.stands)drawStand(stand);
|
||||||
|
if(feedback&&now<feedback.until){
|
||||||
|
const pts=feedback.travelPoints||feedback.points;
|
||||||
|
if(pts?.length){
|
||||||
|
// Macaron blocked reading: a chunky blocked-colour line ending in an orange cross.
|
||||||
|
line(pts,materials.blocked,.126);
|
||||||
|
const tip=pts.at(-1),k=.13;
|
||||||
|
line([{x:tip.x-k,y:tip.y-k},{x:tip.x+k,y:tip.y+k}],materials.blocked,.11);
|
||||||
|
line([{x:tip.x-k,y:tip.y+k},{x:tip.x+k,y:tip.y-k}],materials.blocked,.11);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const moving=new Set(motions.map(m=>m.id));
|
||||||
|
const kicks=new Map(), impacts=[];
|
||||||
|
for(const motion of motions)if(motion.kind==='bump'&&motion.blocker){
|
||||||
|
const phase=collisionPhase(Math.min(1,(now-motion.start)/motion.duration),reduced);
|
||||||
|
const blocker=game.state.planes.find(p=>p.id===motion.blocker);
|
||||||
|
if(!blocker)continue;
|
||||||
|
const contact=motion.points.at(-1),target=planeLocation(blocker);
|
||||||
|
if(phase.kick>0){
|
||||||
|
const angle=Math.atan2(target.y-contact.y,target.x-contact.x);
|
||||||
|
kicks.set(blocker.id,{x:Math.cos(angle)*phase.kick*.32,y:Math.sin(angle)*phase.kick*.32});
|
||||||
|
}
|
||||||
|
if(phase.impact>0)impacts.push({contact,target,impact:phase.impact});
|
||||||
|
}
|
||||||
|
// Macaron blocked reading: the blocked cell blushes under the impact.
|
||||||
|
for(const {target} of impacts)polygon(roundedHex(target.x,target.y,.90,.16),materials.blush);
|
||||||
|
for(const plane of game.state.planes)if(plane.state!=='gone'&&!moving.has(plane.id)){
|
||||||
|
const where=planeLocation(plane),kick=kicks.get(plane.id);
|
||||||
|
if(kick){where.x+=kick.x;where.y+=kick.y;}
|
||||||
|
aircraft(plane,where);
|
||||||
|
}
|
||||||
|
for(const motion of motions){
|
||||||
|
const progress=Math.min(1,(now-motion.start)/motion.duration);
|
||||||
|
let where,alpha=1;
|
||||||
|
if(motion.kind==='shake'){
|
||||||
|
where=planeLocation(motion.plane);where.x+=reduced?0:Math.sin(progress*36)*.11*(1-progress);
|
||||||
|
}else if(motion.kind==='bump'){
|
||||||
|
where=along(motion.points,collisionPhase(progress,reduced).distance);
|
||||||
|
if(motion.points.length===1)where.angle=motion.plane.dir*Math.PI/3;
|
||||||
|
} else {
|
||||||
|
where = along(motion.points, travelProgress(progress, motion.duration));
|
||||||
|
if (motion.kind === 'exit' && motion.plane.kind === 'original') alpha = 1 - Math.max(0, (progress - .8) / .2);
|
||||||
|
if (motion.kind === 'land' && progress >= .96 && !motion.cheered) {
|
||||||
|
motion.cheered = true;
|
||||||
|
cheer(position(motion.updated.q, motion.updated.r), motion.plane.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aircraft({...motion.plane,state:motion.kind==='land'&&progress>.96?'parked':motion.plane.state==='parked'?'ground':motion.plane.state},where,alpha);
|
||||||
|
}
|
||||||
|
// Macaron blocked reading: an orange cross grows at the contact while the impact holds.
|
||||||
|
for(const {contact,target,impact} of impacts){
|
||||||
|
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2,k=.30*impact;
|
||||||
|
line([{x:x-k,y:y-k},{x:x+k,y:y+k}],materials.blocked,.11);
|
||||||
|
line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,.11);
|
||||||
|
}
|
||||||
|
// Runway read: when a waiting plane's edge connects to a same-colour stand, light the
|
||||||
|
// channel from the nose, across the apron, onto the board path. The ripple stays on
|
||||||
|
// the plane. Board runways stay inside the tray. A ring at the opening and an inbound
|
||||||
|
// arrow still guide; a blocker ripples red.
|
||||||
|
if (!motions.length && !rotation && !dragSpin && !document.querySelector('dialog[open]')) {
|
||||||
|
const hints = approachHints(), T = now / 1000;
|
||||||
|
for (const h of hints) {
|
||||||
|
const onboard = clipToHull(h.points, hullPts);
|
||||||
|
const nose = nosePoint(h.from);
|
||||||
|
const pts = onboard.length ? [nose, ...onboard] : [nose, ...h.points];
|
||||||
|
if (pts.length > 1) {
|
||||||
|
line(pts, materials.hintGlow, .19);
|
||||||
|
line(pts, materials.hint, .085);
|
||||||
|
const at = along(pts, (T * 0.6) % 1), dir = at.angle - Math.PI / 2, s = .26;
|
||||||
|
polygon([{ x: at.x + Math.cos(dir) * s, y: at.y + Math.sin(dir) * s }, { x: at.x + Math.cos(dir + 2.4) * s, y: at.y + Math.sin(dir + 2.4) * s }, { x: at.x + Math.cos(dir - 2.4) * s, y: at.y + Math.sin(dir - 2.4) * s }], world.ink);
|
||||||
|
const ring = onboard[0] || along(pts, .08);
|
||||||
|
ctx.save(); ctx.globalAlpha = .9; ctx.beginPath(); ctx.arc(ring.x, ring.y, .28, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = materials.hint; ctx.lineWidth = .10; ctx.stroke(); ctx.restore();
|
||||||
|
}
|
||||||
|
for (const phase of [T % 1, (T + .5) % 1]) {
|
||||||
|
ctx.save(); ctx.globalAlpha = (1 - phase) * .8;
|
||||||
|
ctx.beginPath(); ctx.arc(h.from.x, h.from.y, .5 + phase * 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = materials.hint; ctx.lineWidth = .10; ctx.stroke(); ctx.restore();
|
||||||
|
}
|
||||||
|
const blocker = h.blocker && game.state.planes.find(p => p.id === h.blocker);
|
||||||
|
if (blocker) {
|
||||||
|
const bp = position(blocker.q, blocker.r);
|
||||||
|
for (const phase of [(T * 1.3) % 1, (T * 1.3 + .5) % 1]) {
|
||||||
|
ctx.save(); ctx.globalAlpha = (1 - phase) * .85;
|
||||||
|
ctx.beginPath(); ctx.arc(bp.x, bp.y, .5 + phase * 1.2, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = materials.blocked; ctx.lineWidth = .12; ctx.stroke(); ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cheerDone = drawCheers(now);
|
||||||
|
let changed = false;
|
||||||
|
motions = motions.filter(m => { if (now - m.start < m.duration) return true; game.finish(m.id); changed = true; return false; });
|
||||||
|
if (rotation && now - rotation.start >= rotation.duration) { rotation = null; game.finish('rotation'); changed = true; const pending = queue; queue = []; for (const action of pending) perform(action); }
|
||||||
|
if (changed) { sync(); settle(); }
|
||||||
|
else if (cheerDone && game.won && !$('result').open) settle();
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
function resize(){
|
||||||
|
const rect=$('airspace').getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);
|
||||||
|
const radius=game?boardRadius(game.level.cells):2;
|
||||||
|
const stacks=game?Math.max(1,...[0,1,2,3,4,5].map(s=>game.level.arrivals.filter(a=>a.side===s).length)):1;
|
||||||
|
const span=(apronDistance(radius)+(stacks-1)*1.12+1.8)*2;
|
||||||
|
view={width:rect.width,height:rect.height,size:Math.min(rect.width/span,rect.height/span),ox:rect.width/2,oy:rect.height/2};
|
||||||
|
canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr);if(game)sync();
|
||||||
|
}
|
||||||
|
new ResizeObserver(resize).observe($('airspace'));
|
||||||
|
// Rim drag: follow the finger within ±60°. A quick flick still turns one sector.
|
||||||
|
// Release past SNAP commits that sector; otherwise the board eases back.
|
||||||
|
canvas.addEventListener('pointerdown', event => {
|
||||||
|
if (document.querySelector('dialog[open]') || game.won || game.deadlocked || game.moves <= 0 || game.rotating || dragSpin) return;
|
||||||
|
const rect = $('airspace').getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left - view.ox, y = event.clientY - rect.top - view.oy;
|
||||||
|
if (Math.hypot(x, y) <= Math.min(view.width, view.height) * 0.28) return;
|
||||||
|
dragSpin = { id: event.pointerId, x, y, startAngle: Math.atan2(y, x), angle: 0, t0: performance.now(), analog: false, settling: null };
|
||||||
|
canvas.setPointerCapture?.(event.pointerId);
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointermove', event => {
|
||||||
|
if (!dragSpin || event.pointerId !== dragSpin.id || dragSpin.settling || rotation) return;
|
||||||
|
const rect = $('airspace').getBoundingClientRect();
|
||||||
|
let delta = wrapDelta(Math.atan2(event.clientY - rect.top - view.oy, event.clientX - rect.left - view.ox) - dragSpin.startAngle);
|
||||||
|
delta = Math.max(-STEP, Math.min(STEP, delta));
|
||||||
|
if (Math.abs(delta) > .04) dragSpin.analog = true;
|
||||||
|
if (dragSpin.analog) dragSpin.angle = delta;
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointerup', event => {
|
||||||
|
if (!dragSpin || event.pointerId !== dragSpin.id) { dragSpin = null; return; }
|
||||||
|
if (dragSpin.settling) return;
|
||||||
|
const rect = $('airspace').getBoundingClientRect(), dt = performance.now() - dragSpin.t0;
|
||||||
|
let delta = wrapDelta(Math.atan2(event.clientY - rect.top - view.oy, event.clientX - rect.left - view.ox) - dragSpin.startAngle);
|
||||||
|
const flick = dt < FLICK_MS && Math.abs(delta) > FLICK_ANG;
|
||||||
|
if (flick) {
|
||||||
|
dragSpin = null;
|
||||||
|
perform({ type: 'rotate', direction: delta > 0 ? 1 : -1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const angle = dragSpin.analog ? dragSpin.angle : 0;
|
||||||
|
if (Math.abs(angle) >= SNAP) {
|
||||||
|
const dir = angle > 0 ? 1 : -1;
|
||||||
|
dragSpin.settling = { from: angle, to: dir * STEP, start: performance.now(), duration: reduced ? 80 : 180, commitDir: dir };
|
||||||
|
} else if (Math.abs(angle) > .01) {
|
||||||
|
dragSpin.settling = { from: angle, to: 0, start: performance.now(), duration: reduced ? 80 : 180, commitDir: 0 };
|
||||||
|
} else dragSpin = null;
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointercancel', () => { dragSpin = null; });
|
||||||
|
|
||||||
|
$('left').onclick=()=>perform({type:'rotate',direction:-1});$('right').onclick=()=>perform({type:'rotate',direction:1});
|
||||||
|
$('restart').onclick=$('retry').onclick=()=>loadLevel(index);
|
||||||
|
$('next').onclick=()=>loadLevel((index+1)%LEVELS.length);
|
||||||
|
$('settings').onclick=()=>show($('help'));
|
||||||
|
$('sound').onclick=()=>{saved.sound=!saved.sound;persist();sync();};
|
||||||
|
$('levels').onclick=()=>{
|
||||||
|
$('level-list').replaceChildren(...LEVELS.map((level,i)=>{
|
||||||
|
const button=document.createElement('button');button.className=`level-option${i===index?' current':''}`;
|
||||||
|
const title=document.createElement('strong');title.textContent=`${i+1} · ${level.name}${saved.completed.includes(level.id)?' ✓':''}`;
|
||||||
|
const caption=document.createElement('small');caption.textContent=level.lesson;
|
||||||
|
button.append(title,caption);button.onclick=()=>loadLevel(i);return button;
|
||||||
|
}));show($('menu'));
|
||||||
|
};
|
||||||
|
for(const button of document.querySelectorAll('[data-close]'))button.onclick=()=>{button.closest('dialog').close();settle();};
|
||||||
|
$('result').addEventListener('cancel',event=>event.preventDefault());
|
||||||
|
$('reward').onclick=async()=>{
|
||||||
|
const current=game,session=generation;$('reward').disabled=true;
|
||||||
|
const provider=window.airportAdProvider|| (async()=>({id:crypto.randomUUID(),completed:true}));
|
||||||
|
const outcome=await requestReward(current,provider,()=>game===current&&generation===session);
|
||||||
|
if(game!==current||generation!==session)return;
|
||||||
|
if(outcome.status==='granted'){$('result').close();notice(`已补充 ${game.level.reward.steps} 次操作。`);sync();diagnose();}
|
||||||
|
else{$('result-copy').textContent=outcome.status==='failed'?'未能获得奖励,请稍后重试。':'未完成奖励,操作次数保持不变。';$('reward').disabled=false;}
|
||||||
|
};
|
||||||
|
// Reuse the accepted UI atlas rather than substituting a new icon family.
|
||||||
|
const atlas=new Image();atlas.src='../../assets/ui-icons-generated.png';atlas.onload=()=>{
|
||||||
|
for(const target of document.querySelectorAll('[data-icon]')){
|
||||||
|
const f=ICONS[target.dataset.icon],g=target.getContext('2d');
|
||||||
|
g.clearRect(0,0,target.width,target.height);
|
||||||
|
g.fillStyle=target.closest('.restart,.primary')?'#FFFFFF':'#5A3A44';
|
||||||
|
g.fillRect(0,0,target.width,target.height);
|
||||||
|
g.globalCompositeOperation='destination-in';
|
||||||
|
g.drawImage(atlas,f.x,f.y,f.w,f.h,0,0,target.width,target.height);
|
||||||
|
g.globalCompositeOperation='source-over';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadLevel(index);resize();requestAnimationFrame(draw);
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
// 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, hexPoints } from '../../src/path-math.js';
|
||||||
|
|
||||||
|
// Six visual flats of the pointy-top outline (bay normals at -60/0/60/120/180/-120).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
export function bayNormal(side) {
|
||||||
|
const n = PORTS[side], length = Math.hypot(n.x, n.y), x = n.x / length, y = n.y / length;
|
||||||
|
const c = Math.cos(Math.PI / 6), s = Math.sin(Math.PI / 6);
|
||||||
|
return { x: x * c - y * s, y: x * s + y * c };
|
||||||
|
}
|
||||||
|
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 { x: bx, y: by } = bayNormal(plane.side);
|
||||||
|
const d = apronDistance(radius);
|
||||||
|
return { x: bx * (d + offset), y: by * (d + offset), angle: Math.atan2(-by, -bx) + Math.PI / 2 };
|
||||||
|
}
|
||||||
|
// A boundary port belongs to the visual flat whose outward normal is nearest the
|
||||||
|
// port's position, in 60° wedges centred on the bay normals. Ties fall on the
|
||||||
|
// clockwise edge so a corner never belongs to two sides.
|
||||||
|
export function visualSideFromPoint(x, y) {
|
||||||
|
// 60° wedges starting at the top (-90°): that boundary belongs to side 0.
|
||||||
|
let t = Math.atan2(y, x) + Math.PI / 2;
|
||||||
|
t = ((t % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
||||||
|
return Math.min(5, Math.floor(t / (Math.PI / 3) + 1e-12));
|
||||||
|
}
|
||||||
|
export function visualSide(q, r, portDir) {
|
||||||
|
const p = position(q, r), n = PORTS[portDir];
|
||||||
|
return visualSideFromPoint(p.x + n.x, p.y + n.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// White tray outline in board-local units, shared by drawing, the edge-runway
|
||||||
|
// reach and the runway-read tests. A convex hull, so ray crossings are unambiguous.
|
||||||
|
export const TRAY_PAD = 1.34;
|
||||||
|
const hullCache = new WeakMap();
|
||||||
|
export function trayHull(cells) {
|
||||||
|
if (hullCache.has(cells)) return hullCache.get(cells);
|
||||||
|
const points = cells.flatMap(c => { const p = position(c.q, c.r); return hexPoints(p.x, p.y, TRAY_PAD); });
|
||||||
|
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y), lower = [], upper = [];
|
||||||
|
const 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); }
|
||||||
|
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
||||||
|
hullCache.set(cells, hull);
|
||||||
|
return hull;
|
||||||
|
}
|
||||||
|
// Distance from `from` (inside the hull) to the tray outline along unit `dir`.
|
||||||
|
export function rimCrossing(hull, from, dir) {
|
||||||
|
let exit = null;
|
||||||
|
for (let i = 0; i < hull.length; i++) {
|
||||||
|
const a = hull[i], b = hull[(i + 1) % hull.length];
|
||||||
|
const ex = b.x - a.x, ey = b.y - a.y, den = dir.x * ey - dir.y * ex;
|
||||||
|
if (Math.abs(den) < 1e-12) continue;
|
||||||
|
const wx = a.x - from.x, wy = a.y - from.y;
|
||||||
|
const t = (wx * ey - wy * ex) / den, u = (wx * dir.y - wy * dir.x) / den;
|
||||||
|
if (t > 0 && u >= -1e-9 && u <= 1 + 1e-9 && (exit === null || t > exit)) exit = t;
|
||||||
|
}
|
||||||
|
return exit;
|
||||||
|
}
|
||||||
|
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 last = route.steps.at(-1), exitDir = last.exit;
|
||||||
|
return { ...route, exitDir, side: visualSide(last.q, last.r, exitDir), 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, exitDir: departure.exitDir,
|
||||||
|
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 out = route.exitDir ?? route.side;
|
||||||
|
const swept = route.status === 'exit'
|
||||||
|
? [...points, { x: points.at(-1).x + PORTS[out].x * 24, y: points.at(-1).y + PORTS[out].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 图标图集;尚非最终美术资产。
|
||||||
|
- 奖励接口和幂等逻辑已实现,默认补充明确标注为测试奖励;没有接入真实广告或凭据验证服务。
|
||||||
|
- 不提供玩家撤销、主动提示、拖动旋转、缩放;只保留本轮批准的首轮按钮交互。
|
||||||
|
- 只验证三个冻结样例,未证明大规模关卡的丰富度、玩家理解度或广告收益。
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { hexPoints, position, trackPoints } from '../../src/path-math.js';
|
||||||
|
import { apronDistance, bayNormal, boardRadius, trayHull, waitingPose } from '../airport/core.js';
|
||||||
|
import { checkRelocate, COLORS, Inbound, SIDES, catalog } from './core.js';
|
||||||
|
import { LEVELS } from './levels.js';
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const canvas = $('board'), ctx = canvas.getContext('2d');
|
||||||
|
const view = { dpr: 1, w: 1, h: 1, ox: 0, oy: 0, size: 1 };
|
||||||
|
let index = Math.max(0, Math.min(LEVELS.length - 1, (Number(new URLSearchParams(location.search).get('level')) || 1) - 1));
|
||||||
|
let game, mouths, preview, previewSteps, selected, anim, feedback, poses;
|
||||||
|
|
||||||
|
function load(next) {
|
||||||
|
index = (next + LEVELS.length) % LEVELS.length;
|
||||||
|
game = new Inbound(LEVELS[index]);
|
||||||
|
mouths = catalog(LEVELS[index]);
|
||||||
|
preview = game.state.dial;
|
||||||
|
previewSteps = 0;
|
||||||
|
selected = null;
|
||||||
|
anim = feedback = null;
|
||||||
|
history.replaceState(null, '', `?level=${index + 1}`);
|
||||||
|
sync();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
const level = LEVELS[index];
|
||||||
|
$('level-title').textContent = `第 ${index + 1} 关 · ${level.name}`;
|
||||||
|
$('notice').textContent = level.lesson;
|
||||||
|
$('stars').textContent = String(game.state.stars);
|
||||||
|
$('standard').textContent = `/ ${level.standard}`;
|
||||||
|
const done = game.state.planes.filter(plane => plane.state === 'parked').length;
|
||||||
|
$('progress').textContent = `${done} / ${game.state.planes.length}`;
|
||||||
|
$('confirm').disabled = previewSteps === 0 || !!anim;
|
||||||
|
$('confirm').textContent = previewSteps === 0 ? '确认这一转' : `确认 ${Math.abs(previewSteps)} 格`;
|
||||||
|
$('undo').disabled = !game.history.length || !!anim;
|
||||||
|
$('left').disabled = $('right').disabled = !!anim;
|
||||||
|
const plane = game.state.planes.find(item => item.id === selected);
|
||||||
|
$('relocate').hidden = !plane || plane.state !== 'parked' || !!anim;
|
||||||
|
$('preview-note').textContent = previewSteps
|
||||||
|
? '这是预览。确认才计星。进场仍按已经确认的缺口。'
|
||||||
|
: '缺口和刻度差几格,转之前就能数。预览不计星。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
view.dpr = Math.min(devicePixelRatio || 1, 2);
|
||||||
|
view.w = Math.max(1, rect.width);
|
||||||
|
view.h = Math.max(1, rect.height);
|
||||||
|
canvas.width = Math.round(view.w * view.dpr);
|
||||||
|
canvas.height = Math.round(view.h * view.dpr);
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldPoint(event) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return { x: (event.clientX - rect.left - view.ox) / view.size, y: (event.clientY - rect.top - view.oy) / view.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function poseOf(plane) {
|
||||||
|
if (anim?.planeId === plane.id) return anim.pose;
|
||||||
|
if (plane.state === 'waiting') {
|
||||||
|
const shaped = game.state.planes.map(item => ({ ...item, kind: 'arrival' }));
|
||||||
|
return waitingPose({ ...plane, kind: 'arrival' }, shaped, boardRadius(LEVELS[index].cells));
|
||||||
|
}
|
||||||
|
const at = position(plane.q, plane.r);
|
||||||
|
return { x: at.x, y: at.y, angle: plane.dir * Math.PI / 3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(points, color, width) {
|
||||||
|
if (!points || points.length < 2) return;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0].x, points[0].y);
|
||||||
|
for (const point of points.slice(1)) ctx.lineTo(point.x, point.y);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function circle(x, y, r, fill, stroke) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
||||||
|
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 0.06; ctx.stroke(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(text, x, y, size, color) {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = `700 ${size}px 'Resource Han Rounded','PingFang SC',sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(text, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sample(points, t) {
|
||||||
|
const lengths = [0];
|
||||||
|
for (let i = 1; i < points.length; i++) lengths.push(lengths.at(-1) + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y));
|
||||||
|
const target = lengths.at(-1) * t, at = lengths.findIndex(length => length >= target);
|
||||||
|
const i = Math.max(1, at);
|
||||||
|
const span = lengths[i] - lengths[i - 1] || 1;
|
||||||
|
const u = (target - lengths[i - 1]) / span;
|
||||||
|
const a = points[i - 1], b = points[i];
|
||||||
|
return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u, angle: Math.atan2(b.x - a.x, -(b.y - a.y)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const level = LEVELS[index];
|
||||||
|
ctx.setTransform(view.dpr, 0, 0, view.dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, view.w, view.h);
|
||||||
|
const radius = boardRadius(level.cells);
|
||||||
|
const reach = apronDistance(radius) + 1.15;
|
||||||
|
view.size = Math.min(view.w, view.h) / (reach * 2);
|
||||||
|
view.ox = view.w / 2;
|
||||||
|
view.oy = view.h / 2;
|
||||||
|
ctx.translate(view.ox, view.oy);
|
||||||
|
ctx.scale(view.size, view.size);
|
||||||
|
const hull = trayHull(level.cells);
|
||||||
|
let hullR = 0;
|
||||||
|
for (const point of hull) hullR = Math.max(hullR, Math.hypot(point.x, point.y));
|
||||||
|
const ringR = hullR + 0.55;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
hull.forEach((point, i) => i ? ctx.lineTo(point.x, point.y + 0.12) : ctx.moveTo(point.x, point.y + 0.12));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = '#E6DDD6';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
hull.forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
for (const cell of level.cells) {
|
||||||
|
const at = position(cell.q, cell.r);
|
||||||
|
const pts = hexPoints(at.x, at.y, 0.9);
|
||||||
|
ctx.beginPath();
|
||||||
|
pts.forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = '#F8E7DC';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
for (const cell of level.cells) {
|
||||||
|
const at = position(cell.q, cell.r);
|
||||||
|
for (let dir = 0; dir < 6; dir++) {
|
||||||
|
const other = cell.tracks[dir];
|
||||||
|
if (dir >= other) continue;
|
||||||
|
line(trackPoints(at.x, at.y, 0.92, dir, other), '#96707B', 0.09);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let side = 0; side < 6; side++) {
|
||||||
|
const normal = bayNormal(side);
|
||||||
|
const distance = apronDistance(radius) + 0.72;
|
||||||
|
label(SIDES[side], normal.x * distance, normal.y * distance, 0.32, '#A98590');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mouth of mouths) {
|
||||||
|
const color = COLORS[mouth.stand.color].color;
|
||||||
|
const previewOpen = mouth.tick === preview;
|
||||||
|
const committedOpen = mouth.tick === game.state.dial;
|
||||||
|
line(mouth.arrivalPoints, previewOpen ? '#F7C948' : 'rgba(150,112,123,.35)', previewOpen ? 0.16 : 0.07);
|
||||||
|
const gate = mouth.arrivalPoints[0];
|
||||||
|
circle(gate.x, gate.y, committedOpen ? 0.22 : 0.16, previewOpen ? color : '#FFFFFF', color);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, ringR, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = '#E6DDD6';
|
||||||
|
ctx.lineWidth = 0.08;
|
||||||
|
ctx.stroke();
|
||||||
|
for (let seat = 0; seat < 6; seat++) {
|
||||||
|
const angle = -Math.PI / 2 + seat * Math.PI / 3;
|
||||||
|
circle(Math.cos(angle) * ringR, Math.sin(angle) * ringR, 0.1, '#F5EFE7', '#E6DDD6');
|
||||||
|
}
|
||||||
|
const groups = new Map();
|
||||||
|
for (const mouth of mouths) {
|
||||||
|
if (!groups.has(mouth.tick)) groups.set(mouth.tick, []);
|
||||||
|
groups.get(mouth.tick).push(mouth);
|
||||||
|
}
|
||||||
|
for (const [tick, group] of groups) {
|
||||||
|
const angle = -Math.PI / 2 + tick * Math.PI / 3;
|
||||||
|
group.forEach((mouth, slot) => {
|
||||||
|
const shift = (slot - (group.length - 1) / 2) * 0.28;
|
||||||
|
const x = Math.cos(angle) * ringR + Math.cos(angle + Math.PI / 2) * shift;
|
||||||
|
const y = Math.sin(angle) * ringR + Math.sin(angle + Math.PI / 2) * shift;
|
||||||
|
const gate = mouth.arrivalPoints[0];
|
||||||
|
line([{ x, y }, gate], COLORS[mouth.stand.color].color, 0.035);
|
||||||
|
circle(x, y, 0.28, COLORS[mouth.stand.color].color, '#FFFFFF');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const notchAngle = -Math.PI / 2 + preview * Math.PI / 3;
|
||||||
|
const nx = Math.cos(notchAngle) * (ringR + 0.42), ny = Math.sin(notchAngle) * (ringR + 0.42);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(nx, ny);
|
||||||
|
ctx.rotate(notchAngle + Math.PI / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -0.38);
|
||||||
|
ctx.lineTo(0.26, 0.22);
|
||||||
|
ctx.lineTo(-0.26, 0.22);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = previewSteps ? '#E07416' : '#5A3A44';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
if (preview !== game.state.dial) {
|
||||||
|
const held = -Math.PI / 2 + game.state.dial * Math.PI / 3;
|
||||||
|
circle(Math.cos(held) * ringR, Math.sin(held) * ringR, 0.12, '#5A3A44');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stand of level.stands) {
|
||||||
|
const at = position(stand.q, stand.r), color = COLORS[stand.color].color;
|
||||||
|
const occupied = game.state.planes.some(plane => plane.state === 'parked' && plane.q === stand.q && plane.r === stand.r && anim?.planeId !== plane.id);
|
||||||
|
if (occupied) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
hexPoints(at.x, at.y, 0.62).forEach((point, i) => i ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
circle(at.x, at.y, 0.36, '#FFFFFF');
|
||||||
|
label(COLORS[stand.color].name[0], at.x, at.y, 0.34, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedback && performance.now() < feedback.until) {
|
||||||
|
line(feedback.travel, '#F0663A', 0.14);
|
||||||
|
const tip = feedback.travel.at(-1);
|
||||||
|
line([{ x: tip.x - 0.16, y: tip.y - 0.16 }, { x: tip.x + 0.16, y: tip.y + 0.16 }], '#F0663A', 0.08);
|
||||||
|
line([{ x: tip.x - 0.16, y: tip.y + 0.16 }, { x: tip.x + 0.16, y: tip.y - 0.16 }], '#F0663A', 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
poses = [];
|
||||||
|
for (const plane of game.state.planes) {
|
||||||
|
if (plane.state === 'parked' && anim?.planeId !== plane.id) {
|
||||||
|
const ray = checkRelocate(level, game.state, plane.id);
|
||||||
|
if (ray.points) line(ray.points, plane.id === selected ? COLORS[plane.color].color : 'rgba(90,58,68,.35)', plane.id === selected ? 0.1 : 0.05);
|
||||||
|
}
|
||||||
|
const pose = poseOf(plane);
|
||||||
|
poses.push({ id: plane.id, ...pose });
|
||||||
|
drawPlane(plane, pose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPlane(plane, pose) {
|
||||||
|
const color = COLORS[plane.color].color;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(pose.x, pose.y);
|
||||||
|
ctx.rotate(pose.angle || 0);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -0.62);
|
||||||
|
ctx.lineTo(0.16, -0.2);
|
||||||
|
ctx.lineTo(0.55, 0.05);
|
||||||
|
ctx.lineTo(0.16, 0.12);
|
||||||
|
ctx.lineTo(0.12, 0.42);
|
||||||
|
ctx.lineTo(0, 0.28);
|
||||||
|
ctx.lineTo(-0.12, 0.42);
|
||||||
|
ctx.lineTo(-0.16, 0.12);
|
||||||
|
ctx.lineTo(-0.55, 0.05);
|
||||||
|
ctx.lineTo(-0.16, -0.2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
if (plane.state === 'parked' && anim?.planeId !== plane.id) {
|
||||||
|
circle(pose.x + 0.42, pose.y + 0.36, 0.18, '#E07416');
|
||||||
|
label('P', pose.x + 0.42, pose.y + 0.36, 0.22, '#FFFFFF');
|
||||||
|
}
|
||||||
|
if (plane.id === selected) circle(pose.x, pose.y, 0.78, null, '#5A3A44');
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(now) {
|
||||||
|
if (!anim) return;
|
||||||
|
const t = Math.min(1, (now - anim.start) / anim.duration);
|
||||||
|
const eased = t * t * (3 - 2 * t);
|
||||||
|
anim.pose = sample(anim.points, eased);
|
||||||
|
draw();
|
||||||
|
if (t < 1) requestAnimationFrame(frame);
|
||||||
|
else finishAnim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishAnim() {
|
||||||
|
anim = null;
|
||||||
|
sync();
|
||||||
|
draw();
|
||||||
|
if (game.won) openResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(points, planeId) {
|
||||||
|
anim = { points, planeId, start: performance.now(), duration: 720, pose: points[0] };
|
||||||
|
sync();
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(result, planeId) {
|
||||||
|
selected = planeId;
|
||||||
|
if (result.travel) feedback = { travel: result.travel, until: performance.now() + 900 };
|
||||||
|
$('notice').textContent = result.status === 'blocked' ? '被挡住了。只标第一个阻挡,不计星。' : '这条边现在没有开着的同色口。';
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlane(id) {
|
||||||
|
if (anim || game.won) return;
|
||||||
|
const plane = game.state.planes.find(item => item.id === id);
|
||||||
|
if (plane.state === 'parked') {
|
||||||
|
selected = id;
|
||||||
|
const ray = checkRelocate(LEVELS[index], game.state, id);
|
||||||
|
sync();
|
||||||
|
$('notice').textContent = ray.sideName ? `调边会落到${ray.sideName}。这条射线不随转改变。` : LEVELS[index].lesson;
|
||||||
|
draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previewSteps) {
|
||||||
|
sync();
|
||||||
|
$('notice').textContent = '先确认这一转,或转回原位。预览不能进场。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = game.act({ type: 'land', id });
|
||||||
|
if (result.status !== 'land') { fail(result, id); return; }
|
||||||
|
selected = null;
|
||||||
|
const from = waitingPose({ ...plane, kind: 'arrival', state: 'waiting' }, [{ ...plane, kind: 'arrival', state: 'waiting' }], boardRadius(LEVELS[index].cells));
|
||||||
|
play([from, ...result.mouth.arrivalPoints], id);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function turn(direction) {
|
||||||
|
if (anim) return;
|
||||||
|
preview = (preview + direction + 6) % 6;
|
||||||
|
previewSteps += direction;
|
||||||
|
if (previewSteps === 0) preview = game.state.dial;
|
||||||
|
sync();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (!previewSteps || anim) return;
|
||||||
|
game.act({ type: 'confirm', dial: preview, steps: Math.abs(previewSteps) });
|
||||||
|
previewSteps = 0;
|
||||||
|
preview = game.state.dial;
|
||||||
|
sync();
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function relocate() {
|
||||||
|
if (anim || !selected) return;
|
||||||
|
const before = game.state.planes.find(plane => plane.id === selected);
|
||||||
|
const result = game.act({ type: 'relocate', id: selected });
|
||||||
|
if (result.status !== 'relocate') { fail(result, selected); return; }
|
||||||
|
const end = waitingPose({ id: before.id, kind: 'arrival', state: 'waiting', side: result.side }, [{ id: before.id, kind: 'arrival', state: 'waiting', side: result.side }], boardRadius(LEVELS[index].cells));
|
||||||
|
selected = null;
|
||||||
|
play([...result.points, end], before.id);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResult() {
|
||||||
|
const level = LEVELS[index];
|
||||||
|
$('result-copy').textContent = `提交 ${game.state.stars} / 标准 ${level.standard}。${level.lesson}`;
|
||||||
|
$('next').hidden = index === LEVELS.length - 1;
|
||||||
|
$('result').showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('left').onclick = () => turn(-1);
|
||||||
|
$('right').onclick = () => turn(1);
|
||||||
|
$('confirm').onclick = confirm;
|
||||||
|
$('undo').onclick = () => { game.act({ type: 'undo' }); preview = game.state.dial; previewSteps = 0; selected = null; sync(); draw(); };
|
||||||
|
$('restart').onclick = () => load(index);
|
||||||
|
$('relocate').onclick = relocate;
|
||||||
|
$('levels').onclick = () => $('menu').showModal();
|
||||||
|
$('help-open').onclick = () => $('help').showModal();
|
||||||
|
$('next').onclick = () => { $('result').close(); load(index + 1); };
|
||||||
|
$('retry').onclick = () => { $('result').close(); load(index); };
|
||||||
|
document.querySelectorAll('[data-close]').forEach(button => button.onclick = () => button.closest('dialog').close());
|
||||||
|
|
||||||
|
const list = $('level-list');
|
||||||
|
LEVELS.forEach((level, i) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = `${i + 1} ${level.name}`;
|
||||||
|
button.onclick = () => { $('menu').close(); load(i); };
|
||||||
|
list.append(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', event => {
|
||||||
|
const at = worldPoint(event);
|
||||||
|
const hit = poses?.find(pose => Math.hypot(pose.x - at.x, pose.y - at.y) < 0.9);
|
||||||
|
if (hit) onPlane(hit.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
new ResizeObserver(resize).observe(canvas);
|
||||||
|
load(index);
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// Inbound dial. A turn moves a notch on a 6-mark ring. It does not slide tracks
|
||||||
|
// under aircraft, and it does not call rotateBoard. Mouths are fixed world
|
||||||
|
// objects: open iff mouth.tick === dial.
|
||||||
|
import { opposite, trace } from '../../src/core.js';
|
||||||
|
import { pathPoints, position, sweepPath, PORTS } from '../../src/path-math.js';
|
||||||
|
import { visualSide, SIDES } from '../airport/core.js';
|
||||||
|
|
||||||
|
export { SIDES };
|
||||||
|
export const COLORS = {
|
||||||
|
0: { name: '草莓', color: '#EE5398' },
|
||||||
|
8: { name: '可可', color: '#B5806A' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const boardOf = level => ({ cells: level.cells });
|
||||||
|
const clone = value => structuredClone(value);
|
||||||
|
|
||||||
|
function departure(level, q, r, dir) {
|
||||||
|
const route = trace(boardOf(level), { id: 'probe', q, r, dir }, [], true);
|
||||||
|
if (route.status !== 'exit') return { status: route.status, route, points: pathPoints(route) };
|
||||||
|
const last = route.steps.at(-1);
|
||||||
|
return { status: 'exit', route, points: pathPoints(route), side: visualSide(last.q, last.r, last.exit), exitDir: last.exit };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalog(level) {
|
||||||
|
return level.mouths.map(mouth => {
|
||||||
|
const stand = level.stands.find(item => item.id === mouth.standId);
|
||||||
|
const inbound = departure(level, stand.q, stand.r, mouth.departDir);
|
||||||
|
const parkedDir = opposite(mouth.departDir);
|
||||||
|
const outbound = departure(level, stand.q, stand.r, parkedDir);
|
||||||
|
return {
|
||||||
|
...mouth,
|
||||||
|
stand,
|
||||||
|
side: inbound.side,
|
||||||
|
sideName: SIDES[inbound.side],
|
||||||
|
parkedDir,
|
||||||
|
exitSide: outbound.side,
|
||||||
|
exitSideName: SIDES[outbound.side],
|
||||||
|
arrivalPoints: [...inbound.points].reverse(),
|
||||||
|
departPoints: outbound.points,
|
||||||
|
cells: inbound.route.steps.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createState(level) {
|
||||||
|
return {
|
||||||
|
dial: level.dial,
|
||||||
|
stars: 0,
|
||||||
|
planes: level.arrivals.map(plane => ({
|
||||||
|
id: plane.id, color: plane.color, side: plane.side, state: 'waiting',
|
||||||
|
q: null, r: null, dir: null, standId: null, mouthId: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parked = state => state.planes.filter(plane => plane.state === 'parked');
|
||||||
|
const obstacles = planes => planes.map(plane => ({ ...position(plane.q, plane.r), id: plane.id }));
|
||||||
|
|
||||||
|
export function checkLanding(level, state, planeId) {
|
||||||
|
const plane = state.planes.find(item => item.id === planeId);
|
||||||
|
if (!plane || plane.state !== 'waiting') return { status: 'invalid' };
|
||||||
|
const mouths = catalog(level).filter(mouth => mouth.tick === state.dial && mouth.side === plane.side && mouth.stand.color === plane.color);
|
||||||
|
if (!mouths.length) return { status: 'no-route', plane };
|
||||||
|
const taken = new Set(parked(state).map(item => `${item.q},${item.r}`));
|
||||||
|
const free = mouths.filter(mouth => !taken.has(`${mouth.stand.q},${mouth.stand.r}`));
|
||||||
|
if (!free.length) return { status: 'no-stand', plane };
|
||||||
|
const blocks = obstacles(parked(state));
|
||||||
|
const legal = [], blocked = [];
|
||||||
|
for (const mouth of free) {
|
||||||
|
const hit = sweepPath(mouth.arrivalPoints, blocks);
|
||||||
|
if (!hit) legal.push(mouth);
|
||||||
|
else blocked.push({ mouth, blocker: hit.blocker, point: hit.point, travel: hit.points });
|
||||||
|
}
|
||||||
|
if (legal.length > 1) return { status: 'ambiguous', plane, candidates: legal };
|
||||||
|
if (legal.length === 1) return { status: 'land', plane, mouth: legal[0] };
|
||||||
|
if (blocked.length === 1) return { status: 'blocked', plane, ...blocked[0] };
|
||||||
|
return { status: 'ambiguous', plane, candidates: blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkRelocate(level, state, planeId) {
|
||||||
|
const plane = state.planes.find(item => item.id === planeId);
|
||||||
|
if (!plane || plane.state !== 'parked') return { status: 'invalid' };
|
||||||
|
const outbound = departure(level, plane.q, plane.r, plane.dir);
|
||||||
|
if (outbound.status !== 'exit') return { status: 'no-route', plane };
|
||||||
|
const end = outbound.points.at(-1), port = PORTS[outbound.exitDir];
|
||||||
|
const swept = [...outbound.points, { x: end.x + port.x * 4, y: end.y + port.y * 4 }];
|
||||||
|
const hit = sweepPath(swept, obstacles(parked(state).filter(item => item.id !== plane.id)));
|
||||||
|
if (hit) return { status: 'blocked', plane, blocker: hit.blocker, point: hit.point, travel: hit.points, side: outbound.side };
|
||||||
|
return { status: 'relocate', plane, side: outbound.side, sideName: SIDES[outbound.side], points: outbound.points };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLanding(state, check) {
|
||||||
|
const next = clone(state);
|
||||||
|
const plane = next.planes.find(item => item.id === check.plane.id);
|
||||||
|
plane.state = 'parked';
|
||||||
|
plane.q = check.mouth.stand.q;
|
||||||
|
plane.r = check.mouth.stand.r;
|
||||||
|
plane.dir = check.mouth.parkedDir;
|
||||||
|
plane.standId = check.mouth.standId;
|
||||||
|
plane.mouthId = check.mouth.id;
|
||||||
|
plane.side = null;
|
||||||
|
next.stars += 1;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRelocate(state, check) {
|
||||||
|
const next = clone(state);
|
||||||
|
const plane = next.planes.find(item => item.id === check.plane.id);
|
||||||
|
plane.state = 'waiting';
|
||||||
|
plane.side = check.side;
|
||||||
|
plane.q = plane.r = plane.dir = plane.standId = plane.mouthId = null;
|
||||||
|
next.stars += 1;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transition(level, state, action) {
|
||||||
|
if (action.type === 'confirm') {
|
||||||
|
const dial = action.dial, steps = action.steps;
|
||||||
|
if (!Number.isInteger(dial) || dial < 0 || dial > 5 || !Number.isInteger(steps) || steps < 1) return { commit: false, result: { status: 'invalid' } };
|
||||||
|
const next = clone(state);
|
||||||
|
next.dial = dial;
|
||||||
|
next.stars += steps;
|
||||||
|
return { commit: true, state: next, result: { status: 'confirmed', dial, steps } };
|
||||||
|
}
|
||||||
|
if (action.type === 'land') {
|
||||||
|
const check = checkLanding(level, state, action.id);
|
||||||
|
if (check.status !== 'land') return { commit: false, result: check };
|
||||||
|
return { commit: true, state: applyLanding(state, check), result: check };
|
||||||
|
}
|
||||||
|
if (action.type === 'relocate') {
|
||||||
|
const check = checkRelocate(level, state, action.id);
|
||||||
|
if (check.status !== 'relocate') return { commit: false, result: check };
|
||||||
|
return { commit: true, state: applyRelocate(state, check), result: check };
|
||||||
|
}
|
||||||
|
return { commit: false, result: { status: 'invalid' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWon(state) {
|
||||||
|
return state.planes.length > 0 && state.planes.every(plane => plane.state === 'parked');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Inbound {
|
||||||
|
constructor(level) {
|
||||||
|
this.level = level;
|
||||||
|
this.state = createState(level);
|
||||||
|
this.history = [];
|
||||||
|
}
|
||||||
|
get won() { return isWon(this.state); }
|
||||||
|
act(action) {
|
||||||
|
if (action.type === 'undo') {
|
||||||
|
if (!this.history.length) return { status: 'invalid' };
|
||||||
|
this.state = this.history.pop();
|
||||||
|
return { status: 'undone' };
|
||||||
|
}
|
||||||
|
const outcome = transition(this.level, this.state, action);
|
||||||
|
if (!outcome.commit) return outcome.result;
|
||||||
|
this.history.push(clone(this.state));
|
||||||
|
this.state = outcome.state;
|
||||||
|
return { ...outcome.result, won: this.won, stars: this.state.stars };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateInbound(level) {
|
||||||
|
const issues = [];
|
||||||
|
const mouths = catalog(level);
|
||||||
|
for (const mouth of mouths) {
|
||||||
|
if (mouth.side === undefined) issues.push(`${mouth.id} 没有出口`);
|
||||||
|
if (mouth.cells < 1 || mouth.cells > 5) issues.push(`${mouth.id} 路径 ${mouth.cells} 格,超出 1–5`);
|
||||||
|
}
|
||||||
|
for (let dial = 0; dial < 6; dial++) {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const mouth of mouths.filter(item => item.tick === dial)) {
|
||||||
|
const group = `${mouth.side}:${mouth.stand.color}`;
|
||||||
|
groups.set(group, (groups.get(group) || 0) + 1);
|
||||||
|
}
|
||||||
|
for (const [group, count] of groups) if (count > 1) issues.push(`朝向 ${dial} ${group} 有 ${count} 个口`);
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
for (const mouth of mouths) {
|
||||||
|
const signature = `${mouth.side}:${mouth.tick}:${mouth.standId}`;
|
||||||
|
if (seen.has(signature)) issues.push(`重复口 ${signature}`);
|
||||||
|
seen.add(signature);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a reader can count before touching preview. Independent of dial mutation.
|
||||||
|
export function reading(level, dial) {
|
||||||
|
return catalog(level).map(mouth => ({
|
||||||
|
id: mouth.id,
|
||||||
|
side: mouth.sideName,
|
||||||
|
tick: mouth.tick,
|
||||||
|
open: mouth.tick === dial,
|
||||||
|
right: (mouth.tick - dial + 6) % 6,
|
||||||
|
left: (dial - mouth.tick + 6) % 6,
|
||||||
|
exitSide: mouth.exitSideName,
|
||||||
|
cells: mouth.cells,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#F5EFE7">
|
||||||
|
<title>进场调度 · 刻度环试玩</title>
|
||||||
|
<link rel="stylesheet" href="./style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="inbound">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="icon-button" id="levels" aria-label="选择关卡">关</button>
|
||||||
|
<div><p class="eyebrow">进场调度 · 试玩</p><h1 id="level-title">第 1 关</h1></div>
|
||||||
|
<button class="icon-button" id="help-open" aria-label="怎么读刻度">?</button>
|
||||||
|
</header>
|
||||||
|
<section class="status" aria-label="进度">
|
||||||
|
<div><span>提交</span><strong id="stars">0</strong><small id="standard">/ 1</small></div>
|
||||||
|
<div><span>停好</span><strong id="progress">0 / 1</strong></div>
|
||||||
|
</section>
|
||||||
|
<p class="notice" id="notice" role="status"></p>
|
||||||
|
<section class="airspace" id="airspace">
|
||||||
|
<canvas id="board" aria-label="固定机场、进场口和刻度环"></canvas>
|
||||||
|
</section>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="left" class="turn">左转一格</button>
|
||||||
|
<button id="confirm" class="confirm" disabled>确认这一转</button>
|
||||||
|
<button id="right" class="turn">右转一格</button>
|
||||||
|
</div>
|
||||||
|
<p class="preview-note" id="preview-note">缺口和刻度差几格,转之前就能数。预览不计星。</p>
|
||||||
|
<footer>
|
||||||
|
<button id="undo">撤销</button>
|
||||||
|
<button id="relocate" hidden>调边</button>
|
||||||
|
<button id="restart">重来</button>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
<dialog id="menu">
|
||||||
|
<button class="close" data-close aria-label="关闭">×</button>
|
||||||
|
<h2>六关竖切</h2>
|
||||||
|
<div id="level-list"></div>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="help">
|
||||||
|
<button class="close" data-close aria-label="关闭">×</button>
|
||||||
|
<h2>刻度环</h2>
|
||||||
|
<ol>
|
||||||
|
<li>棋盘、轨道、飞机都不转。转的是环上的缺口。</li>
|
||||||
|
<li>每个口固定在一条边上。环上同色的点是它的刻度。</li>
|
||||||
|
<li>缺口盖住刻度,这个口才开。差几格,转之前数。</li>
|
||||||
|
<li>左右转是预览,不计星。确认才写入,跨几格计几星。</li>
|
||||||
|
<li>点场外飞机进场。点已停飞机,再按调边。调边落到已经画出的出口边,转不改变它。</li>
|
||||||
|
</ol>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="result">
|
||||||
|
<h2>全部到位</h2>
|
||||||
|
<p id="result-copy"></p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button id="next" class="confirm">下一关</button>
|
||||||
|
<button id="retry" class="turn">再打一次</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<script type="module" src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { hexagon } from '../../src/core.js';
|
||||||
|
|
||||||
|
const board = () => hexagon(2);
|
||||||
|
|
||||||
|
// Mouths are fixed. departDir is the heading whose reverse is the arrival.
|
||||||
|
// Parked heading is the opposite, so 调边 follows a different ray.
|
||||||
|
export const LEVELS = [
|
||||||
|
{
|
||||||
|
id: 'in-1', name: '口已经开了', standard: 1, dial: 0,
|
||||||
|
lesson: '这个口已经开了,点进来就停好。',
|
||||||
|
cells: board(),
|
||||||
|
stands: [{ id: 'cocoa', q: 1, r: -1, color: 8 }],
|
||||||
|
mouths: [{ id: 'cocoa-right', standId: 'cocoa', departDir: 2, tick: 0 }],
|
||||||
|
arrivals: [{ id: 'cocoa', color: 8, side: 1 }],
|
||||||
|
solution: [{ type: 'land', id: 'cocoa' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-2', name: '转一下才通', standard: 2, dial: 0,
|
||||||
|
lesson: '我转这一格,是为了让缺口盖住这个口。',
|
||||||
|
cells: board(),
|
||||||
|
stands: [{ id: 'berry', q: 1, r: -1, color: 0 }],
|
||||||
|
mouths: [{ id: 'berry-up', standId: 'berry', departDir: 0, tick: 1 }],
|
||||||
|
arrivals: [{ id: 'berry', color: 0, side: 0 }],
|
||||||
|
solution: [{ type: 'confirm', dial: 1, steps: 1 }, { type: 'land', id: 'berry' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-3', name: '一转两口', standard: 2, dial: 0,
|
||||||
|
lesson: '这一转打开了我的口,也关掉了对面。',
|
||||||
|
cells: board(),
|
||||||
|
stands: [{ id: 'cocoa', q: 0, r: 0, color: 8 }],
|
||||||
|
mouths: [
|
||||||
|
{ id: 'cocoa-right', standId: 'cocoa', departDir: 1, tick: 0 },
|
||||||
|
{ id: 'cocoa-left', standId: 'cocoa', departDir: 4, tick: 5 },
|
||||||
|
],
|
||||||
|
arrivals: [{ id: 'cocoa', color: 8, side: 4 }],
|
||||||
|
solution: [{ type: 'confirm', dial: 5, steps: 1 }, { type: 'land', id: 'cocoa' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-4', name: '谁先停', standard: 2, dial: 0,
|
||||||
|
lesson: '先停那架会挡住另一架,所以要换顺序。',
|
||||||
|
cells: board(),
|
||||||
|
stands: [
|
||||||
|
{ id: 'cocoa', q: 0, r: -1, color: 8 },
|
||||||
|
{ id: 'berry', q: 1, r: -2, color: 0 },
|
||||||
|
],
|
||||||
|
mouths: [
|
||||||
|
{ id: 'cocoa-up', standId: 'cocoa', departDir: 1, tick: 0 },
|
||||||
|
{ id: 'berry-right', standId: 'berry', departDir: 2, tick: 0 },
|
||||||
|
],
|
||||||
|
arrivals: [
|
||||||
|
{ id: 'cocoa', color: 8, side: 0 },
|
||||||
|
{ id: 'berry', color: 0, side: 1 },
|
||||||
|
],
|
||||||
|
solution: [{ type: 'land', id: 'cocoa' }, { type: 'land', id: 'berry' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-5', name: '两架各自进', standard: 3, dial: 0,
|
||||||
|
lesson: '我转是为了打开这两个口,但还是要一架一架点进来。',
|
||||||
|
cells: board(),
|
||||||
|
stands: [
|
||||||
|
{ id: 'berry', q: 1, r: -1, color: 0 },
|
||||||
|
{ id: 'cocoa', q: 0, r: 0, color: 8 },
|
||||||
|
],
|
||||||
|
mouths: [
|
||||||
|
{ id: 'berry-right', standId: 'berry', departDir: 2, tick: 1 },
|
||||||
|
{ id: 'cocoa-left', standId: 'cocoa', departDir: 4, tick: 1 },
|
||||||
|
],
|
||||||
|
arrivals: [
|
||||||
|
{ id: 'berry', color: 0, side: 1 },
|
||||||
|
{ id: 'cocoa', color: 8, side: 4 },
|
||||||
|
],
|
||||||
|
solution: [
|
||||||
|
{ type: 'confirm', dial: 1, steps: 1 },
|
||||||
|
{ type: 'land', id: 'berry' },
|
||||||
|
{ type: 'land', id: 'cocoa' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-6', name: '先停是为了换边', standard: 5, dial: 0,
|
||||||
|
lesson: '我先停是为了换到那条边,再转开口进来。',
|
||||||
|
cells: board(),
|
||||||
|
stands: [
|
||||||
|
{ id: 'berry', q: 1, r: -1, color: 0 },
|
||||||
|
{ id: 'cocoa', q: 0, r: -1, color: 8 },
|
||||||
|
],
|
||||||
|
mouths: [
|
||||||
|
{ id: 'berry-up', standId: 'berry', departDir: 0, tick: 0 },
|
||||||
|
{ id: 'berry-down', standId: 'berry', departDir: 3, tick: 1 },
|
||||||
|
{ id: 'cocoa-right', standId: 'cocoa', departDir: 2, tick: 1 },
|
||||||
|
],
|
||||||
|
arrivals: [
|
||||||
|
{ id: 'berry', color: 0, side: 0 },
|
||||||
|
{ id: 'cocoa', color: 8, side: 1 },
|
||||||
|
],
|
||||||
|
solution: [
|
||||||
|
{ type: 'land', id: 'berry' },
|
||||||
|
{ type: 'relocate', id: 'berry' },
|
||||||
|
{ type: 'confirm', dial: 1, steps: 1 },
|
||||||
|
{ type: 'land', id: 'cocoa' },
|
||||||
|
{ type: 'land', id: 'berry' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@font-face{font-family:'Resource Han Rounded';font-weight:700;font-display:swap;src:url(../../assets/fonts/ResourceHanRoundedCN-Bold.subset.woff2) format('woff2')}
|
||||||
|
:root{font-family:'Resource Han Rounded',ui-rounded,'PingFang SC','Microsoft YaHei',sans-serif;color:#5A3A44;background:#F5EFE7;font-synthesis:none}
|
||||||
|
*{box-sizing:border-box}html,body{background:#F5EFE7}body{margin:0;min-height:100svh}button{font:inherit;color:inherit;cursor:pointer;border:0;background:transparent}button:disabled{opacity:.4;cursor:default}button:focus-visible{outline:3px solid #5A3A44;outline-offset:3px}[hidden]{display:none!important}
|
||||||
|
.inbound{max-width:520px;margin:auto;padding:16px 16px 20px;min-height:100svh;display:flex;flex-direction:column}
|
||||||
|
.topbar{display:flex;align-items:center;justify-content:space-between;text-align:center}
|
||||||
|
.eyebrow{margin:0;font-size:12px;letter-spacing:.16em;color:#A98590;font-weight:700}h1{margin:0;font-size:26px;line-height:1.2}
|
||||||
|
.icon-button{width:44px;height:44px;border-radius:50%;background:#fff;box-shadow:0 4px 14px rgba(110,70,82,.14);font-weight:700}
|
||||||
|
.status{display:flex;justify-content:space-between;margin:14px 0 8px;font-size:13px;color:#A98590}
|
||||||
|
.status div{display:flex;align-items:baseline;gap:4px}.status strong{color:#5A3A44;font-size:28px;line-height:1}#standard{font-size:16px;color:#A98590}
|
||||||
|
.notice{min-height:44px;margin:0 0 8px;font-size:15px;line-height:1.45}
|
||||||
|
.airspace{flex:1;min-height:420px;position:relative}canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none}
|
||||||
|
.controls{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:8px;margin-top:8px}
|
||||||
|
.turn,.confirm,#undo,#restart,#relocate,#next,#retry{min-height:48px;border-radius:999px;padding:0 12px;font-weight:700}
|
||||||
|
.turn{background:#fff;box-shadow:0 3px 0 #E6DDD6}.confirm{background:#E07416;color:#fff;box-shadow:0 3px 0 #C96A0E}
|
||||||
|
.preview-note{margin:8px 0;text-align:center;font-size:12px;color:#A98590;min-height:18px}
|
||||||
|
footer{display:flex;gap:8px;justify-content:center}footer button{background:#fff;box-shadow:0 3px 0 #E6DDD6;padding:0 18px}
|
||||||
|
#relocate{background:#5A3A44;color:#fff}
|
||||||
|
dialog{border:0;border-radius:24px;background:#fff;color:#5A3A44;padding:24px;width:min(390px,calc(100vw - 32px));box-shadow:0 18px 40px rgba(90,58,68,.18)}dialog::backdrop{background:rgba(62,46,53,.5)}dialog h2{margin:0 0 12px}dialog li,dialog p{font-size:14px;line-height:1.7;color:#7A5560}
|
||||||
|
.close{position:absolute;right:12px;top:8px;width:36px;height:36px;border-radius:50%;background:#fff;box-shadow:0 4px 14px rgba(110,70,82,.14)}
|
||||||
|
#level-list{display:grid;gap:8px}#level-list button{text-align:left;background:#F5EFE7;border-radius:14px;padding:12px 14px;min-height:48px}
|
||||||
|
.dialog-actions{display:flex;gap:8px;margin-top:16px}.dialog-actions button{flex:1}
|
||||||
|
@media(max-height:760px){.airspace{min-height:300px}h1{font-size:22px}}
|
||||||
@@ -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/inbound','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,218 @@
|
|||||||
|
// 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 probe = base(cells, [{ id: 'P1', q: opt.q, r: opt.r, color: 8 }], []);
|
||||||
|
if (uniquenessIssues(probe).length) continue;
|
||||||
|
const cat = catalogs(probe);
|
||||||
|
for (let side = 0; side < 6; side++) {
|
||||||
|
const at0 = cat[0].arrivals.some(r => r.side === side);
|
||||||
|
const turn = cat[1].arrivals.some(r => r.side === side) ? 1 : cat[5].arrivals.some(r => r.side === side) ? -1 : 0;
|
||||||
|
if (at0 || !turn) 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,296 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { hexagon, trace, opposite, DIRS } from '../src/core.js';
|
||||||
|
import { pathPoints, sweepPath, position, PORTS, hexPoints } from '../src/path-math.js';
|
||||||
|
import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose, boardAt, trayHull, rimCrossing, bayNormal, TRAY_PAD, visualSide, visualSideFromPoint } 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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('each boundary port belongs to exactly one visual flat, matching the waiting bay',()=>{
|
||||||
|
const cells=hexagon(2),keys=new Set(cells.map(c=>`${c.q},${c.r}`));
|
||||||
|
const seen=new Map();
|
||||||
|
for(const cell of cells)for(let d=0;d<6;d++){
|
||||||
|
if(keys.has(`${cell.q+DIRS[d][0]},${cell.r+DIRS[d][1]}`))continue;
|
||||||
|
const p=position(cell.q,cell.r),n=PORTS[d],side=visualSide(cell.q,cell.r,d);
|
||||||
|
assert.ok(side>=0&&side<=5,`port ${cell.q},${cell.r} dir ${d}`);
|
||||||
|
assert.equal(side,visualSideFromPoint(p.x+n.x,p.y+n.y));
|
||||||
|
const bay=bayNormal(side),port={x:p.x+n.x,y:p.y+n.y};
|
||||||
|
const ang=Math.atan2(port.y,port.x),bang=Math.atan2(bay.y,bay.x);
|
||||||
|
let delta=ang-bang;while(delta>Math.PI)delta-=Math.PI*2;while(delta<-Math.PI)delta+=Math.PI*2;
|
||||||
|
assert.ok(Math.abs(delta)<=Math.PI/6+1e-9,`port ${cell.q},${cell.r} dir ${d} must sit in side ${side}'s 60° wedge`);
|
||||||
|
if(!seen.has(side))seen.set(side,0);seen.set(side,seen.get(side)+1);
|
||||||
|
}
|
||||||
|
assert.equal(seen.size,6,'all six flats receive at least one port');
|
||||||
|
});
|
||||||
|
test('a waiting plane cannot enter from a neighbouring visual edge',()=>{
|
||||||
|
const cells=hexagon(2).map(c=>({...c,tracks:[3,4,5,0,1,2]}));
|
||||||
|
const level={id:'edge',name:'edge',cells,stands:[{id:'P1',q:0,r:-2,color:8}],arrivals:[{id:'in1',color:8,side:0}],originals:[],moveLimit:10,reward:{steps:5,maxClaims:2}};
|
||||||
|
const routes=catalogs(level)[0].arrivals.filter(r=>r.color===8);
|
||||||
|
const occupied=new Set(routes.map(r=>r.side));
|
||||||
|
const home=routes.map(r=>r.side).find(s=>routes.filter(r=>r.side===s).length===1);
|
||||||
|
const other=[0,1,2,3,4,5].find(s=>!occupied.has(s));
|
||||||
|
assert.equal(typeof other,'number','need a visual edge this stand does not open onto');
|
||||||
|
const state=createState(level);state.planes[0].side=other;
|
||||||
|
assert.equal(checkLanding(level,state,'in1').status,'no-route');
|
||||||
|
state.planes[0].side=home;
|
||||||
|
assert.equal(checkLanding(level,state,'in1').status,'land');
|
||||||
|
assert.equal(checkLanding(level,state,'in1').side,home);
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
const left=transition(l3,s3,{type:'rotate',direction:-1}).state,right=transition(l3,s3,{type:'rotate',direction:1}).state;
|
||||||
|
assert.ok(checkLanding(l3,left,'in1').status==='land'||checkLanding(l3,right,'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);
|
||||||
|
});
|
||||||
|
const FACE_NORMALS=[0,1,2].map(k=>({x:Math.cos(Math.PI/6+k*Math.PI/3),y:Math.sin(Math.PI/6+k*Math.PI/3)}));
|
||||||
|
const insideFace=(x,y,cell,radius=.9)=>{const p=position(cell.q,cell.r),dx=x-p.x,dy=y-p.y;return FACE_NORMALS.every(n=>Math.abs(dx*n.x+dy*n.y)<=radius*Math.cos(Math.PI/6)+1e-9);};
|
||||||
|
const EDGE_ENDPOINT_RADIUS=.90*Math.cos(Math.PI/6)-.11/2-.01;
|
||||||
|
const insideHull=(hull,x,y)=>{for(let i=0;i<hull.length;i++){const a=hull[i],b=hull[(i+1)%hull.length];if((b.x-a.x)*(y-a.y)-(b.y-a.y)*(x-a.x)<-1e-9)return false;}return true;};
|
||||||
|
test('the tray outline is convex and contains every brick',()=>{
|
||||||
|
for(const level of LEVELS){
|
||||||
|
const hull=trayHull(level.cells);
|
||||||
|
assert.ok(hull.length>=6,`${level.id} hull`);
|
||||||
|
const cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
|
||||||
|
for(let i=0;i<hull.length;i++)assert.ok(cross(hull[i],hull[(i+1)%hull.length],hull[(i+2)%hull.length])>0,`${level.id} hull must stay convex`);
|
||||||
|
for(const cell of level.cells){
|
||||||
|
const p=position(cell.q,cell.r);
|
||||||
|
for(const v of hexPoints(p.x,p.y,.90))assert.ok(insideHull(hull,v.x,v.y),`${level.id} hull must enclose brick ${cell.q},${cell.r}`);
|
||||||
|
assert.ok(TRAY_PAD>.90,`${level.id} tray pad must clear the brick face`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('every board-edge runway reaches the tray outline without crossing another brick',()=>{
|
||||||
|
for(const level of LEVELS)for(let o=0;o<6;o++){
|
||||||
|
const board=boardAt(level,o),hull=trayHull(board.cells),keys=new Set(board.cells.map(c=>`${c.q},${c.r}`));
|
||||||
|
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];
|
||||||
|
for(const port of [d,b]){
|
||||||
|
if(keys.has(`${cell.q+DIRS[port][0]},${cell.r+DIRS[port][1]}`))continue; // shares an edge with a brick
|
||||||
|
const n=PORTS[port],l=Math.hypot(n.x,n.y),ux=n.x/l,uy=n.y/l;
|
||||||
|
const end={x:p.x+ux*EDGE_ENDPOINT_RADIUS,y:p.y+uy*EDGE_ENDPOINT_RADIUS};
|
||||||
|
const t=rimCrossing(hull,end,{x:ux,y:uy});
|
||||||
|
assert.ok(t!==null&&t>0,`${level.id} o${o} ${cell.q},${cell.r} port ${port} must reach the tray outline`);
|
||||||
|
for(let s=1;s<24;s++){
|
||||||
|
const at={x:end.x+ux*t*s/24,y:end.y+uy*t*s/24};
|
||||||
|
for(const c of board.cells){
|
||||||
|
if(c.q===cell.q&&c.r===cell.r)continue;
|
||||||
|
assert.ok(!insideFace(at.x,at.y,c),`${level.id} o${o} runway ${cell.q},${cell.r} port ${port} must not graze brick ${c.q},${c.r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('apron slabs start outside the tray and stack along each side normal',()=>{
|
||||||
|
for(const level of LEVELS){
|
||||||
|
const radius=boardRadius(level.cells),hull=trayHull(level.cells);
|
||||||
|
for(let side=0;side<6;side++){
|
||||||
|
const n=bayNormal(side),rim=rimCrossing(hull,{x:0,y:0},n);
|
||||||
|
assert.ok(rim!==null&&rim>0,`${level.id} side ${side} slab needs a rim`);
|
||||||
|
for(const arrival of level.arrivals.filter(a=>a.side===side)){
|
||||||
|
const pose=waitingPose({...arrival,kind:'arrival',state:'waiting'},level.arrivals.map(a=>({...a,kind:'arrival',state:'waiting'})),radius);
|
||||||
|
// The plane sits on the slab axis, beyond the tray: the lane reaches its bay.
|
||||||
|
assert.ok(pose.x*n.x+pose.y*n.y>rim,`${level.id} side ${side} bay must sit outside the tray`);
|
||||||
|
assert.ok(Math.abs(pose.x*n.y-pose.y*n.x)<1e-9,`${level.id} side ${side} bay must stay on the slab axis`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Inbound, catalog, checkLanding, reading, validateInbound } from '../labs/inbound/core.js';
|
||||||
|
import { LEVELS } from '../labs/inbound/levels.js';
|
||||||
|
|
||||||
|
test('slice levels are unambiguous and replay the standard dispatch', () => {
|
||||||
|
for (const level of LEVELS) {
|
||||||
|
assert.deepEqual(validateInbound(level), [], level.id);
|
||||||
|
const game = new Inbound(level);
|
||||||
|
for (const action of level.solution) {
|
||||||
|
const result = game.act(action);
|
||||||
|
assert.notEqual(result.status, 'blocked', `${level.id} ${action.type} ${action.id || ''}`);
|
||||||
|
assert.notEqual(result.status, 'ambiguous', level.id);
|
||||||
|
assert.notEqual(result.status, 'invalid', level.id);
|
||||||
|
}
|
||||||
|
assert.equal(game.won, true, level.id);
|
||||||
|
assert.equal(game.state.stars, level.standard, level.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a turn is countable before preview, and does not move geometry', () => {
|
||||||
|
const turn = LEVELS[1];
|
||||||
|
const before = reading(turn, 0);
|
||||||
|
assert.equal(before[0].open, false);
|
||||||
|
assert.equal(before[0].right, 1);
|
||||||
|
assert.equal(before[0].side, '右上方');
|
||||||
|
const after = reading(turn, 1);
|
||||||
|
assert.equal(after[0].open, true);
|
||||||
|
assert.equal(after[0].side, before[0].side);
|
||||||
|
assert.equal(after[0].exitSide, before[0].exitSide);
|
||||||
|
assert.equal(catalog(turn)[0].stand.q, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one turn opens the near mouth and closes the far one', () => {
|
||||||
|
const level = LEVELS[2];
|
||||||
|
const start = reading(level, 0);
|
||||||
|
const right = start.find(mouth => mouth.side === '右方');
|
||||||
|
const left = start.find(mouth => mouth.side === '左方');
|
||||||
|
assert.equal(right.open, true);
|
||||||
|
assert.equal(left.open, false);
|
||||||
|
assert.equal(left.left, 1);
|
||||||
|
const turned = reading(level, 5);
|
||||||
|
assert.equal(turned.find(mouth => mouth.side === '左方').open, true);
|
||||||
|
assert.equal(turned.find(mouth => mouth.side === '右方').open, false);
|
||||||
|
const game = new Inbound(level);
|
||||||
|
assert.equal(game.act({ type: 'land', id: 'cocoa' }).status, 'no-route');
|
||||||
|
assert.equal(game.state.stars, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parking the near plane blocks the path that crosses its stand', () => {
|
||||||
|
const level = LEVELS[3];
|
||||||
|
const game = new Inbound(level);
|
||||||
|
assert.equal(game.act({ type: 'land', id: 'berry' }).status, 'land');
|
||||||
|
const blocked = game.act({ type: 'land', id: 'cocoa' });
|
||||||
|
assert.equal(blocked.status, 'blocked');
|
||||||
|
assert.equal(blocked.blocker, 'berry');
|
||||||
|
assert.equal(game.state.stars, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relocate exit side does not depend on the dial', () => {
|
||||||
|
const level = LEVELS[5];
|
||||||
|
const game = new Inbound(level);
|
||||||
|
assert.equal(game.act({ type: 'land', id: 'berry' }).status, 'land');
|
||||||
|
const first = game.act({ type: 'relocate', id: 'berry' });
|
||||||
|
assert.equal(first.sideName, '右下方');
|
||||||
|
assert.equal(game.state.planes.find(plane => plane.id === 'berry').side, 2);
|
||||||
|
const other = new Inbound(level);
|
||||||
|
other.act({ type: 'confirm', dial: 3, steps: 3 });
|
||||||
|
other.act({ type: 'confirm', dial: 0, steps: 3 });
|
||||||
|
other.act({ type: 'land', id: 'berry' });
|
||||||
|
assert.equal(other.act({ type: 'relocate', id: 'berry' }).sideName, '右下方');
|
||||||
|
const mouths = catalog(level);
|
||||||
|
assert.equal(mouths.find(mouth => mouth.id === 'berry-up').exitSideName, '右下方');
|
||||||
|
assert.equal(mouths.find(mouth => mouth.id === 'cocoa-right').sideName, '右方');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a closed mouth stays closed for all six dials if that side has no tick', () => {
|
||||||
|
const level = LEVELS[0];
|
||||||
|
for (let dial = 0; dial < 6; dial++) {
|
||||||
|
const open = reading(level, dial).filter(mouth => mouth.open);
|
||||||
|
if (dial === 0) assert.equal(open.length, 1);
|
||||||
|
else assert.equal(open.length, 0);
|
||||||
|
}
|
||||||
|
const game = new Inbound(level);
|
||||||
|
game.act({ type: 'confirm', dial: 1, steps: 1 });
|
||||||
|
assert.equal(checkLanding(level, game.state, 'cocoa').status, 'no-route');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user