feat(airport): add inbound dispatch prototype and landing effects
This commit is contained in:
@@ -190,7 +190,7 @@
|
|||||||
|
|
||||||
### 5.4 动效与声音
|
### 5.4 动效与声音
|
||||||
|
|
||||||
起降沿真实轨道,弯道朝向跟随运动。转盘参考原作 240ms 节奏。降落失败原地震动,原有飞机起飞碰撞回退。来机再次起飞不用永久消除式碎裂表现。
|
起降沿真实轨道,弯道朝向跟随运动。转盘参考原作 240ms 节奏。降落失败原地震动,原有飞机起飞碰撞回退。来机再次起飞不用永久消除式碎裂表现。来机成功停靠时,在该机周围播放一小段彩带,不遮住机头和机位标识。
|
||||||
|
|
||||||
点击、旋转、起飞、停靠、受阻、通关使用可区分的声音;音效可关。减少动态效果时保留状态文字与必要路线反馈。精确素材与时长属于视觉调校,不改变逻辑。
|
点击、旋转、起飞、停靠、受阻、通关使用可区分的声音;音效可关。减少动态效果时保留状态文字与必要路线反馈。精确素材与时长属于视觉调校,不改变逻辑。
|
||||||
|
|
||||||
|
|||||||
@@ -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 格设计卡,覆盖对齐、顺序、调边,无自动批量进场,无「先清全盘再接」。
|
||||||
|
- 每关能指向玩家应说的那一句话。
|
||||||
|
- 未把未核对的时长、广告忍受写成事实。
|
||||||
+135
-30
@@ -22,6 +22,9 @@ try { const value = JSON.parse(localStorage.getItem(STORAGE)); if (value && Arra
|
|||||||
function persist() { try { localStorage.setItem(STORAGE, JSON.stringify(saved)); } catch {} }
|
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 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 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;
|
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' });
|
const worker = new Worker(new URL('./solver-worker.js', import.meta.url), { type: 'module' });
|
||||||
worker.onmessage = ({ data }) => {
|
worker.onmessage = ({ data }) => {
|
||||||
@@ -49,9 +52,52 @@ function sound(name) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
function notice(message, warning = false) { $('notice').textContent = message; $('notice').classList.toggle('warning', warning); }
|
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) {
|
function loadLevel(next) {
|
||||||
generation++; requestId++; index = next; saved.last = index; persist();
|
generation++; requestId++; index = next; saved.last = index; persist();
|
||||||
game = new AirportGame(LEVELS[index]); motions = []; rotation = null; feedback = null; queue = []; diagnosis = null;
|
game = new AirportGame(LEVELS[index]); motions = []; cheers = []; rotation = null; dragSpin = null; feedback = null; queue = []; diagnosis = null;
|
||||||
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
|
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
|
||||||
$('level-title').textContent = `第 ${index + 1} 关`;
|
$('level-title').textContent = `第 ${index + 1} 关`;
|
||||||
$('level-name').textContent = game.level.name;
|
$('level-name').textContent = game.level.name;
|
||||||
@@ -110,7 +156,7 @@ function sync() {
|
|||||||
}
|
}
|
||||||
for (const button of [...controls.children]) if (!live.has(button.dataset.plane)) button.remove();
|
for (const button of [...controls.children]) if (!live.has(button.dataset.plane)) button.remove();
|
||||||
}
|
}
|
||||||
function perform(action) {
|
function perform(action, opts = {}) {
|
||||||
if (document.querySelector('dialog[open]')) return;
|
if (document.querySelector('dialog[open]')) return;
|
||||||
if (game.rotating && action.type === 'tap') {
|
if (game.rotating && action.type === 'tap') {
|
||||||
if (!queue.some(a => a.id === action.id) && !game.busy.has(action.id)) queue.push(action);
|
if (!queue.some(a => a.id === action.id) && !game.busy.has(action.id)) queue.push(action);
|
||||||
@@ -121,7 +167,8 @@ function perform(action) {
|
|||||||
if (result.status === 'ambiguous') { notice('此布局有多个停靠结果,已暂停操作。', true); return; }
|
if (result.status === 'ambiguous') { notice('此布局有多个停靠结果,已暂停操作。', true); return; }
|
||||||
feedback = null; const now = performance.now();
|
feedback = null; const now = performance.now();
|
||||||
if (result.status === 'rotated') {
|
if (result.status === 'rotated') {
|
||||||
rotation = { old: boardAt(game.level, before.orientation), direction: action.direction, start: now, duration: 240 };
|
if (opts.skipSpin) game.finish('rotation');
|
||||||
|
else rotation = { old: boardAt(game.level, before.orientation), direction: action.direction, start: now, duration: 240 };
|
||||||
sound('rotated'); notice('轨道已调整,观察飞机所在侧的连接。');
|
sound('rotated'); notice('轨道已调整,观察飞机所在侧的连接。');
|
||||||
} else {
|
} else {
|
||||||
const plane = before.planes.find(p => p.id === action.id), updated = game.state.planes.find(p => p.id === action.id);
|
const plane = before.planes.find(p => p.id === action.id), updated = game.state.planes.find(p => p.id === action.id);
|
||||||
@@ -181,6 +228,7 @@ function settle() {
|
|||||||
if ($('notice').textContent !== guide && !$('notice').classList.contains('warning')) notice(guide);
|
if ($('notice').textContent !== guide && !$('notice').classList.contains('warning')) notice(guide);
|
||||||
}
|
}
|
||||||
if (game.won) {
|
if (game.won) {
|
||||||
|
if (cheers.some(c => performance.now() - c.start < c.duration)) return;
|
||||||
if (!$('result').open) {
|
if (!$('result').open) {
|
||||||
if (!saved.completed.includes(game.level.id)) saved.completed.push(game.level.id);
|
if (!saved.completed.includes(game.level.id)) saved.completed.push(game.level.id);
|
||||||
persist(); sound('win');
|
persist(); sound('win');
|
||||||
@@ -264,6 +312,19 @@ function drawApronFans(hull,outerR){
|
|||||||
ctx.strokeStyle=rgba(fill,.16);ctx.lineWidth=.055;ctx.lineCap='round';ctx.stroke();
|
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){
|
function aircraft(plane,where,alpha=1){
|
||||||
ctx.save();ctx.translate(where.x,where.y);ctx.globalAlpha=alpha;
|
ctx.save();ctx.translate(where.x,where.y);ctx.globalAlpha=alpha;
|
||||||
const color=pigments[plane.color].color;
|
const color=pigments[plane.color].color;
|
||||||
@@ -271,7 +332,7 @@ function aircraft(plane,where,alpha=1){
|
|||||||
polygon(face,color);
|
polygon(face,color);
|
||||||
polygon(face,null,'#FFFFFF',.09);
|
polygon(face,null,'#FFFFFF',.09);
|
||||||
ctx.save();ctx.rotate(where.angle);
|
ctx.save();ctx.rotate(where.angle);
|
||||||
const silhouette=[[0,-.70],[.11,-.50],[.11,-.12],[.58,.20],[.58,.32],[.12,.13],[.11,.44],[.28,.60],[.28,.68],[0,.56],[-.28,.68],[-.28,.60],[-.11,.44],[-.12,.13],[-.58,.32],[-.58,.20],[-.11,-.12],[-.11,-.50]];
|
const silhouette=[[0,-NOSE],[.11,-.50],[.11,-.12],[.58,.20],[.58,.32],[.12,.13],[.11,.44],[.28,.60],[.28,.68],[0,.56],[-.28,.68],[-.28,.60],[-.11,.44],[-.12,.13],[-.58,.32],[-.58,.20],[-.11,-.12],[-.11,-.50]];
|
||||||
polygon(silhouette.map(([x,y])=>({x,y})),'#FFFFFF');
|
polygon(silhouette.map(([x,y])=>({x,y})),'#FFFFFF');
|
||||||
line([{x:0,y:-.46},{x:0,y:-.28}],color,.07);
|
line([{x:0,y:-.46},{x:0,y:-.28}],color,.07);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
@@ -281,6 +342,22 @@ function aircraft(plane,where,alpha=1){
|
|||||||
}
|
}
|
||||||
ctx.restore();
|
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){
|
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);
|
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);
|
ctx.translate(view.ox,view.oy);ctx.scale(view.size,view.size);
|
||||||
@@ -292,8 +369,15 @@ function draw(now){
|
|||||||
const stacks=Math.max(1,...[0,1,2,3,4,5].map(s=>game.level.arrivals.filter(a=>a.side===s).length));
|
const 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;
|
const outerR=apronR+(stacks-1)*1.12+.85;
|
||||||
drawApronFans(hullPts,outerR);
|
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();
|
ctx.save();
|
||||||
if(rotation){const p=Math.min(1,(now-rotation.start)/rotation.duration),u=p*p*(3-2*p);ctx.rotate(rotation.direction*Math.PI/3*u);}
|
const boardSpin=rotation?(Math.min(1,(now-rotation.start)/rotation.duration)**2*(3-2*Math.min(1,(now-rotation.start)/rotation.duration)))*rotation.direction*STEP:(dragSpin?.angle||0);
|
||||||
|
if(boardSpin)ctx.rotate(boardSpin);
|
||||||
const tray=grow=>roundedPoly(hull(board.cells.flatMap(c=>{const p=position(c.q,c.r);return hexPoints(p.x,p.y,TRAY_PAD+grow);})),.9);
|
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(.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).map(q=>({x:q.x,y:q.y+.10})),materials.shadow);
|
||||||
@@ -328,11 +412,7 @@ function draw(now){
|
|||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
// Stands, like the aircraft, never turn with the base.
|
// Stands, like the aircraft, never turn with the base.
|
||||||
for(const stand of game.level.stands){
|
for(const stand of game.level.stands)drawStand(stand);
|
||||||
const p=position(stand.q,stand.r),color=pigments[stand.color].color;
|
|
||||||
polygon(roundedHex(p.x,p.y,.70,.16),'#FFFFFF',color,.08);
|
|
||||||
label('P',p.x,p.y-.06,.52,color);label(String.fromCharCode(65+stand.color),p.x,p.y+.38,.22,world.ink);
|
|
||||||
}
|
|
||||||
if(feedback&&now<feedback.until){
|
if(feedback&&now<feedback.until){
|
||||||
const pts=feedback.travelPoints||feedback.points;
|
const pts=feedback.travelPoints||feedback.points;
|
||||||
if(pts?.length){
|
if(pts?.length){
|
||||||
@@ -374,6 +454,10 @@ function draw(now){
|
|||||||
} else {
|
} else {
|
||||||
where = along(motion.points, travelProgress(progress, motion.duration));
|
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 === '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);
|
aircraft({...motion.plane,state:motion.kind==='land'&&progress>.96?'parked':motion.plane.state==='parked'?'ground':motion.plane.state},where,alpha);
|
||||||
}
|
}
|
||||||
@@ -384,14 +468,15 @@ function draw(now){
|
|||||||
line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,.11);
|
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
|
// Runway read: when a waiting plane's edge connects to a same-colour stand, light the
|
||||||
// channel in a neutral hint colour from the plane, across the apron, onto the board
|
// channel from the nose, across the apron, onto the board path. The ripple stays on
|
||||||
// path. Board runways themselves stay inside the tray. A ripple at the plane, a ring
|
// the plane. Board runways stay inside the tray. A ring at the opening and an inbound
|
||||||
// at the opening, and an inbound arrow still guide; a blocker ripples red.
|
// arrow still guide; a blocker ripples red.
|
||||||
if (!motions.length && !rotation && !document.querySelector('dialog[open]')) {
|
if (!motions.length && !rotation && !dragSpin && !document.querySelector('dialog[open]')) {
|
||||||
const hints = approachHints(), T = now / 1000;
|
const hints = approachHints(), T = now / 1000;
|
||||||
for (const h of hints) {
|
for (const h of hints) {
|
||||||
const onboard = clipToHull(h.points, hullPts);
|
const onboard = clipToHull(h.points, hullPts);
|
||||||
const pts = onboard.length ? [h.from, ...onboard] : [h.from, ...h.points];
|
const nose = nosePoint(h.from);
|
||||||
|
const pts = onboard.length ? [nose, ...onboard] : [nose, ...h.points];
|
||||||
if (pts.length > 1) {
|
if (pts.length > 1) {
|
||||||
line(pts, materials.hintGlow, .19);
|
line(pts, materials.hintGlow, .19);
|
||||||
line(pts, materials.hint, .085);
|
line(pts, materials.hint, .085);
|
||||||
@@ -417,10 +502,12 @@ function draw(now){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const cheerDone = drawCheers(now);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
motions = motions.filter(m => { if (now - m.start < m.duration) return true; game.finish(m.id); changed = true; return 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 (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(); }
|
if (changed) { sync(); settle(); }
|
||||||
|
else if (cheerDone && game.won && !$('result').open) settle();
|
||||||
requestAnimationFrame(draw);
|
requestAnimationFrame(draw);
|
||||||
}
|
}
|
||||||
function resize(){
|
function resize(){
|
||||||
@@ -432,26 +519,44 @@ function resize(){
|
|||||||
canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr);if(game)sync();
|
canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr);if(game)sync();
|
||||||
}
|
}
|
||||||
new ResizeObserver(resize).observe($('airspace'));
|
new ResizeObserver(resize).observe($('airspace'));
|
||||||
// Swiping anywhere outside the board centre turns the base, like dragging the
|
// Rim drag: follow the finger within ±60°. A quick flick still turns one sector.
|
||||||
// rim in the original. The gesture never selects a route, so it stays direct.
|
// Release past SNAP commits that sector; otherwise the board eases back.
|
||||||
let dragPointer = null;
|
|
||||||
canvas.addEventListener('pointerdown', event => {
|
canvas.addEventListener('pointerdown', event => {
|
||||||
if (document.querySelector('dialog[open]') || game.won || game.deadlocked || game.moves <= 0 || game.rotating) return;
|
if (document.querySelector('dialog[open]') || game.won || game.deadlocked || game.moves <= 0 || game.rotating || dragSpin) return;
|
||||||
const rect = $('airspace').getBoundingClientRect();
|
const rect = $('airspace').getBoundingClientRect();
|
||||||
dragPointer = { id: event.pointerId, x: event.clientX - rect.left - view.ox, y: event.clientY - rect.top - view.oy };
|
const x = event.clientX - rect.left - view.ox, y = event.clientY - rect.top - view.oy;
|
||||||
|
if (Math.hypot(x, y) <= Math.min(view.width, view.height) * 0.28) return;
|
||||||
|
dragSpin = { id: event.pointerId, x, y, startAngle: Math.atan2(y, x), angle: 0, t0: performance.now(), analog: false, settling: null };
|
||||||
canvas.setPointerCapture?.(event.pointerId);
|
canvas.setPointerCapture?.(event.pointerId);
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointerup', event => {
|
canvas.addEventListener('pointermove', event => {
|
||||||
if (!dragPointer || event.pointerId !== dragPointer.id) { dragPointer = null; return; }
|
if (!dragSpin || event.pointerId !== dragSpin.id || dragSpin.settling || rotation) return;
|
||||||
const rect = $('airspace').getBoundingClientRect(), start = dragPointer;
|
const rect = $('airspace').getBoundingClientRect();
|
||||||
dragPointer = null;
|
let delta = wrapDelta(Math.atan2(event.clientY - rect.top - view.oy, event.clientX - rect.left - view.ox) - dragSpin.startAngle);
|
||||||
if (Math.hypot(start.x, start.y) <= Math.min(view.width, view.height) * 0.28) return;
|
delta = Math.max(-STEP, Math.min(STEP, delta));
|
||||||
let delta = Math.atan2(event.clientY - rect.top - view.oy, event.clientX - rect.left - view.ox) - Math.atan2(start.y, start.x);
|
if (Math.abs(delta) > .04) dragSpin.analog = true;
|
||||||
if (delta > Math.PI) delta -= Math.PI * 2;
|
if (dragSpin.analog) dragSpin.angle = delta;
|
||||||
if (delta < -Math.PI) delta += Math.PI * 2;
|
|
||||||
if (Math.abs(delta) > 0.18) perform({ type: 'rotate', direction: delta > 0 ? 1 : -1 });
|
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointercancel', () => { dragPointer = null; });
|
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});
|
$('left').onclick=()=>perform({type:'rotate',direction:-1});$('right').onclick=()=>perform({type:'rotate',direction:1});
|
||||||
$('restart').onclick=$('retry').onclick=()=>loadLevel(index);
|
$('restart').onclick=$('retry').onclick=()=>loadLevel(index);
|
||||||
|
|||||||
@@ -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}}
|
||||||
+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/airport','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,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