更新微信小游戏版:动画教学、分段高亮指路及发布包
This commit is contained in:
+3
-1
@@ -21,5 +21,7 @@
|
|||||||
},
|
},
|
||||||
"isGameTourist": false,
|
"isGameTourist": false,
|
||||||
"appid": "wx7a14d0d70b5dd13d",
|
"appid": "wx7a14d0d70b5dd13d",
|
||||||
"editorSetting": {}
|
"editorSetting": {},
|
||||||
|
"miniprogramRoot": "wechat-campaign-200-r2/",
|
||||||
|
"projectname": "NumberSwipe-campaign-200-r2"
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,143 @@
|
|||||||
|
# NumberSwipe 主程交接
|
||||||
|
|
||||||
|
更新时间:2026-10-20(北京时间)。面向接任主程;先读本文件、同目录 README.md,再查源码。当前任务已完成,没有正在等待执行的开发指令;下一步根据用户真机反馈继续调整。
|
||||||
|
|
||||||
|
## 1. 当前版本和目录
|
||||||
|
|
||||||
|
| 用途 | 目录或入口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 本会话微信开发工作副本 | `G:\项目\NumberSwipe\wechat-campaign-200-r2` |
|
||||||
|
| 已同步的微信项目副本 | `G:\项目\NumberSwipe-Variants\wechat-campaign-200-r2` |
|
||||||
|
| 200关评分修订版网页源项目 | `G:\项目\NumberSwipe-Variants\campaign-200-r2` |
|
||||||
|
| 微信导入入口 | 微信项目中的 `project.config.json`,运行产物为 `game.js` |
|
||||||
|
| 网页试玩入口 | `G:\项目\NumberSwipe-Variants\campaign-200-r2\index.html` |
|
||||||
|
| 旧版,避免误改 | `NumberSwipe-Variants\campaign-200`、`campaign-50`(实际53关) |
|
||||||
|
|
||||||
|
两个微信目录不是自动同步关系。本会话在 NumberSwipe 下的微信子项目修改、验证后,再复制指定修改文件到 Variants 对应目录。继续开发前对比两边的源码和时间,避免覆盖其他会话的新改动。原 NumberSwipe 其他模式、旧网页 DEMO 不在本次修改范围。
|
||||||
|
|
||||||
|
微信版关卡来源:`scored-2026-10-15-r2`,200关、20章,每章10关。微信适配版本标记:`2026-10-19-animated-lessons-one-second`。不得从同级 `wechat` 或其他模式的微信目录混入关卡。
|
||||||
|
|
||||||
|
本次交接核对时,两边 `game.js` 及下述最新压缩包内的 `game.js` SHA-256 相同:
|
||||||
|
|
||||||
|
```text
|
||||||
|
4069ac9b0f38d42196a544f008988808c227653abb0bdb8e326aa7adcfb861a5
|
||||||
|
```
|
||||||
|
|
||||||
|
这只是交接时点的快照;之后有新改动时应重新核对。
|
||||||
|
|
||||||
|
## 2. 最新交付与用户要求
|
||||||
|
|
||||||
|
最新微信包:
|
||||||
|
|
||||||
|
`G:\项目\NumberSwipe-Variants\releases\NumberSwipe-campaign-200-r2-高亮指路-保留玩家填色-20261020.zip`
|
||||||
|
|
||||||
|
同名副本也在 `G:\项目\NumberSwipe\releases`。更早的“动画教学”包较慢,不能当最新版。压缩包已经构建,导入微信开发者工具后重新编译并生成真机预览;旧二维码可能加载旧代码。本会话未上传或发布小游戏。
|
||||||
|
|
||||||
|
最新两项用户要求已实施:
|
||||||
|
|
||||||
|
1. 新机制说明必须使用真实游戏关卡画面,文案与动画联合解释,不再只有文字。
|
||||||
|
2. 用户认为逐格停顿效率低,要求整段约1秒、连续流畅;当前 `duration:1000` 指整段,不是每格1秒。结束停留,点击“重播”再播放。
|
||||||
|
|
||||||
|
不要恢复此前每拍1300毫秒、移动450毫秒后停顿的实现,也不要未经用户要求放慢动画。错误反馈和门状态变化仍保留;是否足够看清需真机反馈。
|
||||||
|
|
||||||
|
目录里还包含通关操作栏修订:完成后底部由“下一关”独占,替换重开/撤销/指路;最后一关为“返回选关”。反馈、操作栏与棋盘分区,出现按钮不能挪动或遮盖棋盘。该修订见当前源码、README 和 validation,不要用旧文件覆盖。
|
||||||
|
|
||||||
|
## 3. 源码定位及修改方式
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/main.mjs` | 微信 Canvas 页面、触摸路由、规则面板、演示布局、动画绘制、前后台处理 |
|
||||||
|
| `src/copy.mjs` | 最新十类教学及组合规则文案;保留具体动作、奇偶例子,不退回旧网页术语 |
|
||||||
|
| `src/hints.mjs` | 分段提示判定、生命周期和独立透明层光效 |
|
||||||
|
| `src/lessons.mjs` | 按当前机制选择真实教学关、通过求解器取得演示路线、错误例子、回退、1秒时间轴 |
|
||||||
|
| `src/platform.mjs` | wx 到上游 UI 所需接口的适配、PACK、存储和 Canvas |
|
||||||
|
| `src/audio.mjs` | 微信原生音频播放器,静音、后台停止、试听及错误诊断 |
|
||||||
|
| `audio-assets.mjs` | 生成本地 WAV 资源 |
|
||||||
|
| `build.mjs` | esbuild 打包,注入微信适配及演示绘制接口 |
|
||||||
|
| `src/upstream/*` | 网页 r2 的四个原始快照;来源及哈希见 source-manifest.json |
|
||||||
|
|
||||||
|
保持 `src/upstream/levels.json`、`rules.mjs`、`ui.mjs`、`audio.mjs` 快照原样。微信改动放适配层,必要的上游 UI 替换通过 build.mjs 注入;不要手工修改压缩后的 game.js。
|
||||||
|
|
||||||
|
教学实现细节:
|
||||||
|
|
||||||
|
- 共十类:基础、墙、横通道、竖通道、拐角、双色、奇偶、节奏门、六边形、无起点。
|
||||||
|
- 首次教学及手动规则页都使用实际教学关。组合关按出现的机制拆段,不展示当前正式关的完整答案。
|
||||||
|
- 演示复用上游盘面绘制;build.mjs 导出 `renderLesson`,暂存并恢复绘制状态,用独立 Canvas 输出。`previewOnly` 阻止教学绘制更新实际游戏 UI、报错、操作按钮等。
|
||||||
|
- 墙/同色显示红色阻挡线,方向/奇偶显示错误及回退。门展示切换、穿过保持开启、撤销穿门。
|
||||||
|
- `lessonFrame` 使用连续进度,main.mjs 绘制移动光圈和连接段;结束保持结果。翻段/重播重新计时;后台停止绘制,返回重新演示。
|
||||||
|
- 不修改玩家路线、存档、提示次数、音效记录;不要为了教学调用实际玩家输入函数。
|
||||||
|
- 只读诊断:`GameGlobal.numberSwipeR2.snapshot()`、`.buttons()`。可查真实游戏状态、音频状态及当前教学。
|
||||||
|
|
||||||
|
## 4. 已确认的玩法与表现约束
|
||||||
|
|
||||||
|
- 每格仅走一次,共边移动、不可斜走;固定数字表示第几步到达。无计时、体力和分数。
|
||||||
|
- 松手续画。拖动时只有回到紧邻的上一格才撤销一步,碰其他已走格不撤销;撤销按钮退一步,重开清空。
|
||||||
|
- 方向格是明确的通道:lr 横向、du 纵向,ru/dr/dl/lu 指定两侧拐角,可反向通过,不能作为端点。图标居中固定,线从图标穿过,正确亮起、错误变红,均不位移。
|
||||||
|
- 双色仅用蓝/橙区分,连续两格必须异色。不要恢复三角/圆形叠加标识;内部 category 名字仍可能为 circle/triangle,不等于需要画形状。
|
||||||
|
- 大格/异形整块一步,不画内部拼接缝;双色、奇偶不要求必须带大格。
|
||||||
|
- 奇偶按访问步数判定。避免同一格叠加奇偶和方向图标。
|
||||||
|
- 永久墙不能通过。节奏门空盘关闭,起点算一步,每进入一格切换一次;移动前检查门状态。合法穿过后保持开启,撤销穿门那一步后恢复切换,重开重置。
|
||||||
|
- 六边形共边六邻接,可以混合其他已支持机制;关卡应宽向展开,避免全部是纵向蛇形单路线。
|
||||||
|
- 指路现为高对比分段提示(白色/深色双描边与流动光点),只展示最早未完成的一段。节点为起点、数字点、方向点、奇偶点、终点,按正确解排序;正确到达段末后消失,再点才提示下一段。暂不接广告。提示使用独立透明 hintCanvas,清空或擦除只影响提示层;已走格多边形和实际连接段让出给原棋盘,保留整格填色及数字、方向反馈。不要在主 Canvas 上使用 destination-out 清除玩家绘制。
|
||||||
|
- 不恢复已被用户否决的相对步差、先后链、到达窗口、开关钥匙开门。保留的是永久墙和节奏门。
|
||||||
|
- 玩法可以混合,但新机制首次出现需极简教学;用户不需要专门的大格教学。
|
||||||
|
- 沿用原项目白底、紫色路线及既有 UI;不要重新发明一套皮肤。
|
||||||
|
|
||||||
|
音效:普通移动沿当前音色每步升一个半音;撤销/重开后步数决定音高。其他五类反馈音保持既有听感。微信使用随包41个 WAV(36个移动音+五类反馈)及 `wx.createInnerAudioContext`,不依赖浏览器振荡器。静音/进入后台时停止播放和延迟音效。
|
||||||
|
|
||||||
|
## 5. 构建、测试与实际验证边界
|
||||||
|
|
||||||
|
在当前微信项目目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location 'G:\项目\NumberSwipe\wechat-campaign-200-r2'
|
||||||
|
node build.mjs
|
||||||
|
node --test hints.test.mjs lessons.test.mjs audio-copy.test.mjs
|
||||||
|
node smoke.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
本机 Node:`C:\Program Files\nodejs\node.exe`。build.mjs 当前硬编码 esbuild 位置为 `G:/项目/NumberSwipe/modes/01-number-stroke/node_modules/esbuild/lib/main.js`,迁移电脑需调整依赖路径。播放器无第三方运行时依赖。
|
||||||
|
|
||||||
|
已报告通过:
|
||||||
|
|
||||||
|
- lessons.test.mjs:12项,包括十类教学、200关机制映射及1秒连续时间轴/结束停留。
|
||||||
|
- audio-copy.test.mjs:6项,原生音频、静音/后台、音高、资源和文案。
|
||||||
|
- smoke.mjs:实际 game.js 在模拟 wx/Canvas 中的200关唯一解、触摸通关、撤销/重开/提示、存档和前后台;教学状态隔离与窄屏分页。
|
||||||
|
- 当前 validation.json 另记录通关按钮布局及4种手机尺寸检查。该内容可能来自其他会话的更新,以当前文件为准。
|
||||||
|
|
||||||
|
不要把 Node 模拟器、假 Canvas 和边界断言当作真实屏幕截图或真机验收。本会话未验证手机实际帧率、1秒内的错误提示可读性、真实字体布局及音效听感;需要开发者工具和手机预览确认。文档交接本身未重新运行全套游戏测试。
|
||||||
|
|
||||||
|
smoke.mjs 会校验上游路径和快照哈希,并覆写 validation.json。移到新机器时缺少上游目录会导致来源检查失败,不能直接删除该检查掩盖问题。
|
||||||
|
|
||||||
|
## 6. 长线关卡设计背景(调整关卡时才需展开)
|
||||||
|
|
||||||
|
用户反馈原200关重复、后半程太简单、最大18格、六边形多为单调竖向路径。r2 是评分后重排并重制的版本,最大36格,不能误把旧200关当当前数据。评分仅是推理负担代理,不能当真人难度或通关率。
|
||||||
|
|
||||||
|
用户主观锚点对应旧版编号:
|
||||||
|
|
||||||
|
- 需要提示/否则思考很久:73、76、77、80、85、88、144。
|
||||||
|
- 略难、应在100关以后经常出现:149、158;比需要提示简单明显。
|
||||||
|
- 喜欢但不能过多:无固定起点的111(12-1)、136(14-6)。此前端口17851/17581口述有歧义,用户明确确认关卡号码和无起点特征;不要据端口推翻反馈。
|
||||||
|
|
||||||
|
r2 无起点关序号为45、65、105、145、185。网页设计资料、评分表、关卡映射及作者证明在 `campaign-200-r2` 的 DESIGN/SCORES/DIFFICULTY 页面、design.json 和 reports 下。作者解不应打包给玩家;运行判定用规则求解。
|
||||||
|
|
||||||
|
此前局域网约定:17850=53关,17851=旧200关,17852=r2评分修订200关。服务可能已停止或换址,先查实际监听和返回版本,不能凭端口断言当前内容。微信包与这些网页服务相互独立。
|
||||||
|
|
||||||
|
## 7. 接手时的行动顺序与注意事项
|
||||||
|
|
||||||
|
1. 确认用户在修改微信版还是网页版,阅读本文件及当前 README,核对两个微信目录是否仍一致。
|
||||||
|
2. 读取当前源码及 Git 状态;原工作区存在其他会话未提交文件,不清理、不回滚。用户已授权的范围内直接完成工作,不重复请求确认。
|
||||||
|
3. 若继续调教学,先看 lessons.mjs/main.mjs/copy.mjs,再看 build.mjs 的 renderLesson 注入。保持上游快照、200关排列和存档键 `variant:campaign-200-r2:v1`。
|
||||||
|
4. 修改后构建和运行与改动相关的检查,再同步微信副本、生成带新版本名的独立包。不要默认两个目录有自动镜像。
|
||||||
|
5. 本环境 Variants 目录不在默认可写根内,写入需通过工具申请 require_escalated;读取允许。不要绕过权限。
|
||||||
|
|
||||||
|
当前 package.ps1 可生成仅含运行必需文件的独立导入包。重新打包时需明确包含 game.js/game.json/project.config.json/audio 和所需开发文件,排除作者解、研究资料及无关项目;按 project.config.json 的上传忽略配置复核资源。上传发布前沿用用户当前授权范围,不假定本交接授予上传授权。
|
||||||
|
|
||||||
|
给新会话的起始消息可直接用:
|
||||||
|
|
||||||
|
> 请接任 NumberSwipe 主程,先读取 G:\项目\NumberSwipe\wechat-campaign-200-r2\HANDOFF.md 和同目录 README.md,核对当前文件及另一个微信副本后,再按我的新需求继续。不要覆盖其他会话更新,也不要重新生成或打乱当前200关。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 数字一笔 · campaign-200-r2 微信小游戏
|
||||||
|
|
||||||
|
本包源于 G:\项目\NumberSwipe-Variants\campaign-200-r2,版本 scored-2026-10-15-r2。完整保留原版 200 关及 playOrder,不混入原 NumberSwipe 项目关卡,也不重新生成关卡。
|
||||||
|
|
||||||
|
## 导入
|
||||||
|
|
||||||
|
微信开发者工具 → 小游戏 → 导入本目录(含 game.js / game.json / project.config.json)。已经构建,不需要“构建 npm”。项目沿用 Variants 根配置中的小游戏 AppID;需使用有权限的微信账号登录,才能真机预览和上传。本次未执行上传。
|
||||||
|
|
||||||
|
内容包含原有基础、大块/异形、墙、通道、双色交替、奇偶、节奏门及六边盘面。保留 20 章顺序、全部选关、可关闭教学规则、手势插值、逐格回退、撤销、重开、指路、通关后手动下一关,以及原版半音音阶音效。音频改用随包提供的41个WAV文件和 wx.createInnerAudioContext 播放,保留半音上行与五类反馈音;关闭音效会立即停止,重新开启会播放试听音。微信版不再依赖浏览器振荡器。
|
||||||
|
|
||||||
|
选关界面用分页适配微信 Canvas。反馈通过微信原生输入弹窗填写;导出记录保存到小游戏用户目录,并在支持时打开微信文件分享界面。玩法、判定和盘面绘制仍直接使用 r2 模块,通过轻量宿主接口适配,不加载 WebView。
|
||||||
|
|
||||||
|
## 来源与验证
|
||||||
|
|
||||||
|
src/upstream 下 levels.json、rules.mjs、ui.mjs、audio.mjs 是上游原文件的完整快照,source-manifest.json 记录原始路径及 SHA-256。构建为 ui.mjs 注入微信宿主,替换音频、规则文案和浏览器下载记录入口。上游四个快照文件保持不变。发布包没有 design.json、作者解及反例文件。
|
||||||
|
|
||||||
|
存档 key 延续 variant:campaign-200-r2:v1,保存于微信本地;浏览器和微信存储彼此独立,不会自动迁移网页进度。
|
||||||
|
|
||||||
|
validation.json:200/200 唯一解及模拟微信触摸通关,撤销/重开/指路,前后台循环和存档/音效偏好恢复。原 r2 的 8 项测试也全部通过。尚需在微信开发者工具和手机检查显示、音效听感及平台兼容性;模拟验收不等于真机验收。
|
||||||
|
|
||||||
|
## 重建
|
||||||
|
|
||||||
|
此目录 src 保存完整适配源码。build.mjs 默认引用原 NumberSwipe 工程的 esbuild;如独立移动目录,需要将该 import 改为本机可用的 esbuild。运行 node build.mjs 后重新打包。smoke.mjs 会读 source-manifest.json 指定的上游路径检查哈希,然后对实际 game.js 做 200 关模拟验收。
|
||||||
|
|
||||||
|
## 真机音效及文案修订(2026-10-19)
|
||||||
|
|
||||||
|
改用微信原生音频播放器与本地音效资源;关闭后再次点“音效开”会播放试听音。新增6项音频/文案回归检查。进后台与系统音频中断时停止音效和延迟播放。错误不再静默忽略,可从只读状态的audio字段查询。
|
||||||
|
|
||||||
|
十类教学说明及组合规则采用具体动作与例子,例如“共边”改为“边挨着边,不能斜着走”,“奇偶”说明第1、3、5步与第2、4、6步。复杂规则可以分页阅读。关卡的布局、约束、顺序与原有微信存档保持不变。
|
||||||
|
|
||||||
|
修改后需要在开发者工具重新编译并重新生成真机预览,旧二维码可能仍加载旧包。自动测试验证播放调用和非静音音频资源,手机上的实际听感仍需真机确认。
|
||||||
|
|
||||||
|
## 通关按钮布局修订
|
||||||
|
|
||||||
|
通关后“下一关”独占底部操作栏,替换重开/撤销/指路;最后一关显示“返回选关”。盘面、反馈文字与操作栏固定分区,按钮出现时不挪动盘面。验证320×568、375×667、390×844、414×896四种尺寸下无按钮/盘面遮挡,且能正确跳转。
|
||||||
|
|
||||||
|
## 关卡动画教学(2026-10-19)
|
||||||
|
|
||||||
|
首次机制教学和手动规则页加入真实教学关卡画面,复用正式盘面绘制与规则判定,不制作独立示意图。文字说明与逐步动画同步,黄色光圈表示演示位置;墙和同色展示红色阻挡线,通道及奇偶展示变红、退回及正确路线。节奏门演示切换、穿过锁定及撤销穿门。支持重播、逐段阅读。组合关按实际出现的机制拆段说明,使用对应极简教学关,避免提前展示正式关解法。
|
||||||
|
|
||||||
|
演示独立于玩家路线、提示次数、存档及音效;关闭页面继续原关。后台停止绘制,返回时从头演示。新增 lessons.test.mjs 的11项教学检查;smoke.mjs 检查动画前后路线、错误状态、棋盘坐标和通关状态不变,并验证200关及窄屏规则页。仍需在微信开发者工具和手机检查实际动画与排版。
|
||||||
|
|
||||||
|
### 教学动画提速
|
||||||
|
整段演示固定约1秒,逐帧连续连线,去掉每格停顿与重复静止步骤。演示结束后停留在结果,点击重播可再次观看。
|
||||||
|
|
||||||
|
## 高亮分段指路与玩家填色(2026-10-20)
|
||||||
|
恢复高对比提示:点击“指路”直接显示最早未正确完成的一段,按起点、数字点、方向点、奇偶点和终点分段。提示为半透明白线、深色描边及连续流动白色光点;正确到达段末后消失,再次点击才显示下一段,暂不接广告。
|
||||||
|
|
||||||
|
提示使用独立透明 Canvas,每帧只清空提示层,并从中擦除玩家已经走过的格子区域;已画的连接段不再叠加提示。玩家棋盘及整格填色、数字、方向反馈继续由原绘制代码负责,双色关保留原有蓝/橙格色。撤销、重开会按实际路线恢复相应提示。
|
||||||
|
|
||||||
|
构建:node build.mjs。检查:node --test hints.test.mjs lessons.test.mjs audio-copy.test.mjs(23项),node smoke.mjs(200关,并比较点击指路前后玩家填色数据)。浏览器像素回归:node qa/serve.mjs 后打开 http://127.0.0.1:17859/qa/hints.html,检查200关前两步的已走格填色采样像素在开启提示、撤销再画前后相同;qa/hint-player-fill.png 为浏览器检查截图,不能作为微信真机截图。独立导入包通过 ./package.ps1 生成,只含运行必需文件。真机显示仍需手机预览确认。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import fs from 'node:fs';import path from 'node:path';
|
||||||
|
// 与 r2 的半音上行、其余五类提示音采用相同的频率和时序。
|
||||||
|
export function buildAudio(root){const out=path.join(root,'audio');fs.mkdirSync(out,{recursive:true});const cues={correct:[[659,.09,0],[880,.14,.075]],error:[[330,.10,0],[247,.15,.09]],undo:[[587,.07,0],[440,.10,.055]],restart:[[523,.075,0],[392,.09,.065],[523,.11,.14]],complete:[[523,.12,0],[659,.12,.10],[784,.14,.20],[1046,.25,.32]]};for(let n=1;n<=36;n++)cues['move-'+n]=[[523*Math.pow(2,(n-1)/12),.07,0]];
|
||||||
|
for(const [name,tones]of Object.entries(cues)){const rate=22050,length=Math.ceil((Math.max(...tones.map(([,d,t])=>t+d))+.03)*rate),b=Buffer.alloc(44+length*2);b.write('RIFF');b.writeUInt32LE(b.length-8,4);b.write('WAVEfmt ',8);b.writeUInt32LE(16,16);b.writeUInt16LE(1,20);b.writeUInt16LE(1,22);b.writeUInt32LE(rate,24);b.writeUInt32LE(rate*2,28);b.writeUInt16LE(2,32);b.writeUInt16LE(16,34);b.write('data',36);b.writeUInt32LE(length*2,40);for(let i=0;i<length;i++){const t=i/rate;let sample=0;for(const [f,d,offset]of tones){const u=t-offset;if(u>=0&&u<d){const gain=name==='error'?.24:.32;const env=u<.008?u/.008:Math.pow(.0001/gain,(u-.008)/(d-.008));sample+=Math.sin(2*Math.PI*f*u)*gain*env;}}b.writeInt16LE(Math.round(Math.max(-1,Math.min(1,sample))*32767),44+i*2);}fs.writeFileSync(path.join(out,name+'.wav'),b);}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {CasualAudio,stopAudio,audioDiagnostics} from './src/audio.mjs';import {tutorials,rulesFor,playerText} from './src/copy.mjs';
|
||||||
|
const root=path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
function setup(){const played=[],contexts=[],storage=new Map(),options=[];globalThis.wx={setInnerAudioOption:o=>options.push(o),showToast:()=>{},createInnerAudioContext:()=>{const a={stops:0,stop(){this.stops++;},play(){assert.ok(fs.existsSync(path.join(root,this.src)));played.push(this.src);},onError(fn){this.error=fn;}};contexts.push(a);return a;}};const sound=new CasualAudio({}, {getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v)},'sound');return {sound,played,contexts,storage,options};}
|
||||||
|
test('手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和五类反馈音',()=>{const h=setup();for(let step=1;step<=36;step++)h.sound.play('move',0,step);for(const kind of ['correct','error','undo','restart','complete'])h.sound.play(kind);assert.equal(h.played.length,41);assert.equal(h.contexts.length,4);assert.ok(h.options.every(o=>o.obeyMuteSwitch===false));assert.ok(h.contexts.every(a=>a.obeyMuteSwitch===false));});
|
||||||
|
test('关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器',()=>{const h=setup();h.sound.play('move');h.sound.toggle();const count=h.played.length;h.sound.play('move');assert.equal(h.played.length,count);assert.equal(h.storage.get('sound'),'off');h.sound.toggle();assert.equal(h.played.at(-1),'audio/correct.wav');assert.equal(h.storage.get('sound'),'on');const stops=h.contexts.map(a=>a.stops);stopAudio();assert.ok(h.contexts.every((a,i)=>a.stops>stops[i]));});
|
||||||
|
test('延迟通关音在静音或进入后台时取消,不在返回后突然播放',async()=>{const h=setup();h.sound.play('complete',.02);h.sound.toggle();await new Promise(resolve=>setTimeout(resolve,40));assert.equal(h.played.length,0);});
|
||||||
|
test('音频错误可诊断,不再静默吞掉',()=>{const h=setup();h.sound.unlock();h.contexts[0].error({errCode:10003,errMsg:'test missing audio'});assert.equal(audioDiagnostics.lastError.errCode,10003);});
|
||||||
|
test('所有音频均为非静音 PCM;第 13 步比第 1 步高一个八度',()=>{const crossings=[];for(const file of fs.readdirSync(path.join(root,'audio'))){const b=fs.readFileSync(path.join(root,'audio',file));assert.equal(b.toString('ascii',0,4),'RIFF');assert.equal(b.readUInt32LE(24),22050);assert.equal(b.readUInt16LE(22),1);let energy=0,z=0;for(let i=44;i<b.length;i+=2){const x=b.readInt16LE(i);energy+=x*x;if(i>44&&x>=0&&b.readInt16LE(i-2)<0)z++;}assert.ok(energy>100000,file+' must not be silent');if(file==='move-1.wav')crossings[0]=z;if(file==='move-13.wav')crossings[1]=z;}assert.ok(Math.abs(crossings[1]/crossings[0]-2)<.1);});
|
||||||
|
test('教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语',()=>{const levels=JSON.parse(fs.readFileSync(path.join(root,'src/upstream/levels.json'))).levels;for(const l of levels){const text=rulesFor(l);assert.ok(!/共边|落格|访问步号/.test(text),l.id);assert.ok(text.includes('每个格子只能经过一次'));}assert.ok(tutorials.basic.includes('不能斜着走'));assert.ok(tutorials.parity.includes('1、3、5'));assert.ok(tutorials['rhythm-door'].includes('起点也算一步'));assert.ok(!playerText('认清异形共边').includes('共边'));});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,39 @@
|
|||||||
|
import {buildAudio} from './audio-assets.mjs';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { build } from 'file:///G:/项目/NumberSwipe/modes/01-number-stroke/node_modules/esbuild/lib/main.js';
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
buildAudio(root);
|
||||||
|
await build({ absWorkingDir: root, entryPoints: ['src/main.mjs'], outfile: 'game.js', bundle: true, format: 'iife', platform: 'neutral', target: 'es2020', minify: true, plugins: [{ name: 'wechat-host', setup(b) {
|
||||||
|
b.onLoad({ filter: /upstream[\\/]ui\.mjs$/ }, args => {
|
||||||
|
let contents = fs.readFileSync(args.path, 'utf8');
|
||||||
|
contents=contents.replace("from './audio.mjs'", "from '../audio.mjs'");
|
||||||
|
const originalRules=contents.split('\n').find(line=>line.startsWith('function rulesFor('));
|
||||||
|
if(!originalRules)throw Error('上游规则文案入口已变化');
|
||||||
|
contents=contents.replace(originalRules,'const rulesFor=playerRules;');
|
||||||
|
contents=contents.replace("const $=id=>document.getElementById(id),canvas=$('board'),ctx=canvas.getContext('2d');","const $=id=>document.getElementById(id);let canvas=$('board'),ctx=canvas.getContext('2d'),previewOnly=false;");
|
||||||
|
contents=contents.replace(" updateTutorial(bad);", " if(previewOnly)return;updateTutorial(bad);");
|
||||||
|
const hintHandler=contents.split('\n').find(line=>line.startsWith("$('hint').onclick="));
|
||||||
|
if(!hintHandler)throw Error('上游指路入口已变化');
|
||||||
|
contents=contents.replace(hintHandler, `$('hint').onclick=()=>{record.hints++;focus=[];const segment=segmentHints.request(level,solution,path);setStatus(segment?'沿半透明光路连接两端;走偏可撤销或重开,正确到达后提示消失。':'已经完成。');draw();};`);
|
||||||
|
contents=contents.replace("function start(i,open=true){", "function start(i,open=true){segmentHints.clear();");
|
||||||
|
contents=contents.replace("if(level.tutorial&&!bad.length&&!cleared)focus=", "if(false)focus=");
|
||||||
|
contents+=`\nexport function hintView(){const h=segmentHints.view(level,solution,path);return h?{...h,visitedPolygons:polygons.filter(p=>path.includes(p.id)).map(p=>p.p.map(v=>[...v]))}:null;}\n`;
|
||||||
|
contents="import {createHintController} from '../hints.mjs';\nconst segmentHints=createHintController();\n"+contents;
|
||||||
|
contents+=`
|
||||||
|
export function renderLesson(target,l,route,w,h){
|
||||||
|
const saved={canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews};
|
||||||
|
try{canvas={clientWidth:w,clientHeight:h,get width(){return target.width},set width(v){target.width=v},get height(){return target.height},set height(v){target.height=v}};ctx=target.getContext('2d');level={...l,tutorial:null};solution=[];path=route;focus=[];cleared=false;previewOnly=true;draw();return {centers:Object.fromEntries(centers),channels:channelViews,cells:cellViews};}
|
||||||
|
finally{({canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews}=saved);previewOnly=false;}
|
||||||
|
}`;
|
||||||
|
contents="import {rulesFor as playerRules} from '../copy.mjs';\n"+contents;
|
||||||
|
const old = contents.split('\n').find(line => line.startsWith("$('export').onclick="));
|
||||||
|
if (!old)
|
||||||
|
throw Error('上游导出入口变化,需要重新检查微信适配');
|
||||||
|
contents = contents.replace(old, `$('export').onclick=()=>{saveRecord();exportRecords({version:pack.version,exportedAt:new Date().toISOString(),clearedIds:completed,records});setStatus('已保存试玩记录。');};`);
|
||||||
|
return { contents: "import {PACK,document,window,localStorage,exportRecords,performance} from '../platform.mjs';\n" + contents, loader: 'js' };
|
||||||
|
});
|
||||||
|
} }] });
|
||||||
|
console.log('Built campaign-200-r2: 200 levels');
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"deviceOrientation": "portrait",
|
||||||
|
"showStatusBar": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {hintSegment,createHintController,drawHint} from './src/hints.mjs';
|
||||||
|
import {solve} from './src/upstream/rules.mjs';
|
||||||
|
const solution=['a','b','c','d','e','f','g'];
|
||||||
|
const level={cells:solution.map(id=>({id,...(id==='a'?{clue:1}:id==='c'?{direction:'lr'}:id==='e'?{clue:5}:{} )}))};
|
||||||
|
test('empty, partial and wrong routes reveal the first incomplete full segment',()=>{
|
||||||
|
for(const path of [[],['a'],['a','b'],['f','e'],['a','d','c']])assert.deepEqual(hintSegment(level,solution,path).route,['a','b','c']);
|
||||||
|
assert.deepEqual(hintSegment(level,solution,['a','b','c','f']).route,['c','d','e']);
|
||||||
|
assert.equal(hintSegment(level,solution,solution),null);
|
||||||
|
});
|
||||||
|
test('completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments',()=>{
|
||||||
|
const h=createHintController();h.request(level,solution,[]);
|
||||||
|
assert.ok(h.view(level,solution,['a','d']));
|
||||||
|
assert.deepEqual(h.request(level,solution,['a']).route,['a','b','c']);
|
||||||
|
assert.equal(h.view(level,solution,['a','b','c']),null);
|
||||||
|
assert.equal(h.view(level,solution,['a','b','c','d']),null);
|
||||||
|
assert.deepEqual(h.request(level,solution,['a','b','c']).route,['c','d','e']);
|
||||||
|
assert.deepEqual(h.view(level,solution,[]).route,['a','b','c']);
|
||||||
|
assert.equal(h.view({...level},solution,[]),null);
|
||||||
|
});
|
||||||
|
test('all 200 levels reveal consecutive segments without changing the player path',()=>{
|
||||||
|
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));
|
||||||
|
for(const l of pack.levels){
|
||||||
|
const s=solve(l)[0],h=createHintController();let path=[];
|
||||||
|
while(path.length<s.length){
|
||||||
|
const before=[...path],segment=h.request(l,s,path);assert.deepEqual(path,before);assert.ok(segment.route.length>=2,l.id);
|
||||||
|
for(let n=path.length;n<=segment.end;n++){path=s.slice(0,n+1);assert.equal(Boolean(h.view(l,s,path)),n<segment.end,l.id);}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('hint draws skip player edges and mask visited cells on their own layer; compositing is restored',()=>{
|
||||||
|
const events=[],arcs=[],strokes=[];let from,to,stack=[];
|
||||||
|
const ctx=new Proxy({globalCompositeOperation:'source-over',save(){stack.push(this.globalCompositeOperation);},restore(){this.globalCompositeOperation=stack.pop();},beginPath(){from=to=null;},moveTo:(...p)=>{from=p;},lineTo:(...p)=>{to=p;},stroke(){if(from&&to)strokes.push([from,to]);},arc:(...p)=>arcs.push(p),fill(){events.push({kind:'fill',mode:this.globalCompositeOperation});}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
|
||||||
|
const poly=[[0,0],[100,0],[100,100],[0,100]];
|
||||||
|
drawHint(ctx,{route:['a','b','c'],visitedPolygons:[poly]},{a:[0,0],b:[100,0],c:[200,0]},50,300,['a','b']);
|
||||||
|
assert.deepEqual(strokes,[[[100,0],[200,0]],[[100,0],[200,0]]],'only unwalked edge receives hint strokes');
|
||||||
|
assert.ok(events.some(e=>e.kind==='fill'&&e.mode==='destination-out'));
|
||||||
|
assert.equal(ctx.globalCompositeOperation,'source-over');
|
||||||
|
assert.equal(arcs.length,1,'no moving light or rings over already walked edge');
|
||||||
|
});
|
||||||
|
test('parity is a segment anchor; colour is not; flow moves continuously across cells',()=>{
|
||||||
|
const l={cells:solution.map(id=>({id,...(id==='d'?{parity:'odd'}:{category:'circle'})}))};
|
||||||
|
assert.deepEqual(hintSegment(l,solution,[]).route,['a','b','c','d']);
|
||||||
|
const arcs=[],ctx=new Proxy({arc:(...p)=>arcs.push(p)},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
|
||||||
|
drawHint(ctx,{route:['a','b','c']},{a:[0,0],b:[100,0],c:[100,100]},50,300);
|
||||||
|
assert.deepEqual(arcs.at(-3).slice(0,2),[50,0]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {makeLesson,lessonFrame,lessonKeys} from './src/lessons.mjs';import {violations,doorLatched,doorOpen} from './src/upstream/rules.mjs';
|
||||||
|
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));
|
||||||
|
for(const l of pack.levels.filter(l=>l.tutorial))test('实际教学关动画:'+l.tutorial,()=>{const lesson=makeLesson(pack,l.tutorial);assert.equal(lesson.level.id,l.id);assert.equal(violations(l,lesson.solution).length,0);for(let i=0;i<lesson.beats.length-1;i++){const frame=lessonFrame(lesson,(i+.5)*lesson.duration/(lesson.beats.length-1));assert.ok(frame.message.length);assert.ok(frame.route.every(id=>l.cells.some(c=>c.id===id)));if(violations(l,frame.route).length)assert.match(frame.message,/错误示例/);}if(['straight','vertical','corner','parity'].includes(l.tutorial))assert.ok(lesson.beats.some(b=>b.message?.includes('错误示例')));if(l.tutorial==='rhythm-door'){const states=lesson.beats.map(b=>[doorOpen(l.doors[0],b.route),doorLatched(l.doors[0],b.route)]);assert.ok(states.some(([open])=>!open));assert.ok(states.some(([,latched])=>latched));assert.equal(states.at(-1)[1],false);}});
|
||||||
|
test('200关的组合规则均能找到对应真实教学关',()=>{for(const l of pack.levels)for(const key of lessonKeys(l))assert.ok(makeLesson(pack,key));});
|
||||||
|
|
||||||
|
test('整段演示一秒内连续完成,结束后停留并支持从头重播',()=>{for(const level of pack.levels.filter(l=>l.tutorial)){const lesson=makeLesson(pack,level.tutorial);assert.equal(lesson.duration,1000);for(let i=0;i<lesson.beats.length-1;i++){const time=(i+.4)*1000/(lesson.beats.length-1);const f=lessonFrame(lesson,time);assert.ok(Math.abs(f.progress-.4)<1e-8);assert.equal(f.finished,false);}const end=lessonFrame(lesson,1000);assert.equal(end.finished,true);assert.equal(end.progress,1);assert.deepEqual(lessonFrame(lesson,20000).route,end.route);assert.equal(lessonFrame(lesson,0).finished,false);}});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$releaseDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'releases'
|
||||||
|
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
|
||||||
|
$archivePath = Join-Path $releaseDir 'NumberSwipe-campaign-200-r2-高亮指路-保留玩家填色-20261020.zip'
|
||||||
|
$assets = @('game.js','game.json','project.config.json','audio') | ForEach-Object { Join-Path $PSScriptRoot $_ }
|
||||||
|
Compress-Archive -LiteralPath $assets -DestinationPath $archivePath -Force
|
||||||
|
Get-FileHash -LiteralPath $archivePath -Algorithm SHA256
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"setting": {
|
||||||
|
"es6": false,
|
||||||
|
"minified": false,
|
||||||
|
"urlCheck": false
|
||||||
|
},
|
||||||
|
"compileType": "game",
|
||||||
|
"simulatorPluginLibVersion": {},
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"value": "src"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "build.mjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "smoke.mjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "README.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "source-manifest.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "validation.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "audio-assets.mjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "audio-copy.test.mjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "HANDOFF.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "lessons.test.mjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "hints.test.mjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "package.ps1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"value": "qa"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"isGameTourist": false,
|
||||||
|
"appid": "wx7a14d0d70b5dd13d",
|
||||||
|
"editorSetting": {},
|
||||||
|
"projectname": "NumberSwipe-campaign-200-r2",
|
||||||
|
"description": "数字一笔200关评分修订版,源自campaign-200-r2",
|
||||||
|
"miniprogramRoot": "./"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html><meta charset="utf-8"><title>指路与玩家填色像素回归</title>
|
||||||
|
<style>body{font:16px sans-serif;background:#eee}#views{display:flex;gap:12px}canvas{width:375px;height:812px;background:white}h1{font-size:20px}</style>
|
||||||
|
<h1>指路与玩家填色像素回归</h1><p id="result">检查中…</p><div id="views"></div>
|
||||||
|
<script type="module">
|
||||||
|
import {solve} from '../src/upstream/rules.mjs';
|
||||||
|
const pack=await(await fetch('../src/upstream/levels.json')).json();
|
||||||
|
const source=await(await fetch('../game.js')).text();
|
||||||
|
const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));
|
||||||
|
const canvases=[],handlers={},frames=new Map(),storage=new Map();let frameId=0,now=100000;
|
||||||
|
class Clock extends Date{static now(){return now;}}
|
||||||
|
const wx={
|
||||||
|
createCanvas(){const c=document.createElement('canvas');canvases.push(c);return c;},
|
||||||
|
getWindowInfo:()=>({windowWidth:375,windowHeight:812,pixelRatio:1,safeArea:{top:44,bottom:778}}),
|
||||||
|
getMenuButtonBoundingClientRect:()=>({bottom:80}),getPerformance:()=>({now:()=>now}),
|
||||||
|
getStorageSync:k=>storage.get(k)||'',setStorageSync:(k,v)=>storage.set(k,v),removeStorageSync:k=>storage.delete(k),
|
||||||
|
setInnerAudioOption(){},createInnerAudioContext:()=>({stop(){},onError(){},play(){}}),showModal(){},env:{USER_DATA_PATH:'/user'}
|
||||||
|
};
|
||||||
|
for(const n of ['TouchStart','TouchMove','TouchEnd','TouchCancel','Hide','Show','WindowResize'])wx['on'+n]=cb=>handlers[n]=cb;
|
||||||
|
const global={};
|
||||||
|
new Function('wx','GameGlobal','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout','Date',source)(wx,global,cb=>{frames.set(++frameId,cb);return frameId;},id=>frames.delete(id),()=>1,()=>{},Clock);
|
||||||
|
const api=global.numberSwipeR2;
|
||||||
|
const tick=(ms=16)=>{now+=ms;const [id,fn]=frames.entries().next().value;frames.delete(id);fn();};tick();
|
||||||
|
const tap=(x,y)=>{const e={changedTouches:[{identifier:1,clientX:x,clientY:y}],touches:[{identifier:1,clientX:x,clientY:y}]};handlers.TouchStart(e);handlers.TouchEnd(e);tick();};
|
||||||
|
const button=label=>{const b=api.buttons().find(b=>b.label===label);if(!b||!b.enabled)throw Error('缺少按钮 '+label);tap(b.x+b.w/2,b.y+b.h/2);};
|
||||||
|
const cell=id=>{const [x,y]=api.snapshot().centers[id];tap(x,y+174);};
|
||||||
|
const select=i=>{if(api.snapshot().screen!=='select')button('选关');let n=0;while(!api.buttons().some(b=>b.label===String(i+1)||b.label===`${i+1} ✓`)){button('下一页');if(++n>25)throw Error('分页失败');}button(api.buttons().some(b=>b.label===String(i+1))?String(i+1):`${i+1} ✓`);if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');};
|
||||||
|
const capture=()=>{const c=document.createElement('canvas');c.width=375;c.height=812;c.getContext('2d').drawImage(canvases[0],0,0);return c;};
|
||||||
|
const samples=(route)=>{const s=api.snapshot(),ctx=canvases[0].getContext('2d');return route.map(id=>{const p=s.centers[id];return Array.from(ctx.getImageData(Math.round(p[0]-s.unit*.08),Math.round(p[1]+174+s.unit*.18),5,5).data);});};
|
||||||
|
let checked=0;const result=document.querySelector('#result');
|
||||||
|
try{
|
||||||
|
for(let i=0;i<levels.length;i++){
|
||||||
|
select(i);const route=solve(levels[i])[0],n=Math.min(2,route.length-1);
|
||||||
|
for(const id of route.slice(0,n))cell(id);
|
||||||
|
const before=samples(route.slice(0,n));const base=capture();
|
||||||
|
button('重开');button('指路');for(const id of route.slice(0,n))cell(id);tick(300);
|
||||||
|
const after=samples(route.slice(0,n));
|
||||||
|
if(JSON.stringify(before)!==JSON.stringify(after))throw Error('玩家填色像素变化:第 '+(i+1)+' 关');
|
||||||
|
button('撤销');cell(route[n-1]);tick(300);
|
||||||
|
if(JSON.stringify(before)!==JSON.stringify(samples(route.slice(0,n))))throw Error('撤销后填色变化:'+(i+1));
|
||||||
|
checked++;
|
||||||
|
if(i===3){document.querySelector('#views').replaceChildren(base,capture());}
|
||||||
|
}
|
||||||
|
result.textContent=`PASS:${checked}/200 关,点击指路前后和撤销重画后,玩家已走格子的实际 Canvas 填色像素一致。左图无提示,右图开启高对比提示。`;
|
||||||
|
}catch(e){result.textContent='FAIL:'+e.message;console.error(e);}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
||||||
|
http.createServer((req,res)=>{
|
||||||
|
const file=path.resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://localhost').pathname));
|
||||||
|
if(!file.startsWith(root+path.sep)){res.writeHead(403).end();return;}
|
||||||
|
try{res.setHeader('Content-Type',file.endsWith('.html')?'text/html; charset=utf-8':file.endsWith('.json')?'application/json':'text/javascript; charset=utf-8');res.end(fs.readFileSync(file));}catch{res.writeHead(404).end();}
|
||||||
|
}).listen(17859,'127.0.0.1',()=>console.log('Canvas QA: http://127.0.0.1:17859/qa/hints.html'));
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { solve, violations, assertStructure } from './src/upstream/rules.mjs';
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url)), pack = JSON.parse(fs.readFileSync(path.join(root, 'src/upstream/levels.json'))), manifest = JSON.parse(fs.readFileSync(path.join(root, 'source-manifest.json')));
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
const hash = p => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||||
|
assert.equal(hash(path.join(root, 'src/upstream', file.name)), file.sha256);
|
||||||
|
assert.equal(hash(path.join(manifest.source, file.name)), file.sha256);
|
||||||
|
}
|
||||||
|
const ordered = pack.playOrder.map(id => pack.levels.find(l => l.id === id));
|
||||||
|
assert.equal(ordered.length, 200);
|
||||||
|
assert.equal(new Set(pack.playOrder).size, 200);
|
||||||
|
function boot(storage = new Map(), width = 375, height = 812) {
|
||||||
|
const handlers = {}, frames = new Map(), played=[];
|
||||||
|
let frameId = 0,now=100000;
|
||||||
|
class Clock extends Date {static now(){return now;}}
|
||||||
|
const text = [],images=[];
|
||||||
|
const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(parseFloat(ctx.font)||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
|
||||||
|
const wx = {setInnerAudioOption:()=>{},createInnerAudioContext:()=>({stop(){},onError(){},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}}), createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
|
||||||
|
for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize'])
|
||||||
|
wx['on' + name] = cb => handlers[name] = cb;
|
||||||
|
const global = {};
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(root, 'game.js'), 'utf8'), {Date:Clock, wx, GameGlobal: global, requestAnimationFrame: cb => { frames.set(++frameId, cb); return frameId; }, cancelAnimationFrame: id => frames.delete(id), setTimeout: () => 1, clearTimeout: () => { } }, { timeout: 10000 });
|
||||||
|
const tick = () => { const [id, fn] = frames.entries().next().value; frames.delete(id);text.length=0;images.length=0; fn(); };
|
||||||
|
tick();
|
||||||
|
const touch = (kind, x, y) => handlers[kind]({ changedTouches: [{ identifier: 1, clientX: x, clientY: y }], touches: [{ identifier: 1, clientX: x, clientY: y }] });
|
||||||
|
const tap = (x, y) => { touch('TouchStart', x, y); touch('TouchEnd', x, y); tick(); };
|
||||||
|
const api = global.numberSwipeR2;
|
||||||
|
const button = label => { const b = api.buttons().find(b => b.label === label); assert.ok(b, '缺少按钮 ' + label); assert.ok(b.enabled, '按钮不可用 ' + label); tap(b.x + b.w / 2, b.y + b.h / 2); };
|
||||||
|
const cell = id => { const [x, y] = api.snapshot().centers[id]; tap(x, y + 174); };
|
||||||
|
const select = i => { if (api.snapshot().screen !== 'select')
|
||||||
|
button('选关'); const label = String(i + 1); let steps = 0; while (!api.buttons().some(b => b.label === label || b.label === label + ' ✓')) {
|
||||||
|
button('下一页');
|
||||||
|
assert.ok(++steps < 25);
|
||||||
|
} button(api.buttons().some(b => b.label === label) ? label : label + ' ✓'); if (api.buttons().some(b => b.label === '返回游戏'))
|
||||||
|
button('返回游戏'); assert.equal(api.snapshot().level, ordered[i].id); };
|
||||||
|
return { advance(ms){now+=ms;tick();}, api, button, cell, select, tick, handlers, frames, storage, text, played,images };
|
||||||
|
}
|
||||||
|
const ui = boot(), rows = [];
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const l = ordered[i];
|
||||||
|
assertStructure(l);
|
||||||
|
const paths = solve(l, { limit: 2, nodeLimit: 1500000 });
|
||||||
|
assert.equal(paths.length, 1, '非唯一 ' + l.id);
|
||||||
|
const solution = paths[0];
|
||||||
|
assert.equal(violations(l, solution).length, 0);
|
||||||
|
ui.select(i);
|
||||||
|
ui.button('规则');
|
||||||
|
assert.ok(!/共边|落格|访问步号/.test(ui.api.snapshot().rules),l.id);
|
||||||
|
if(l.tutorial){const before=ui.api.snapshot();for(let j=0;j<25;j++)ui.advance(1300);const after=ui.api.snapshot();assert.deepEqual(after.path,before.path,'演示不能修改玩家路线');assert.deepEqual(after.centers,before.centers,'演示不能改变游戏布局');assert.deepEqual(after.violations,before.violations);assert.equal(after.cleared,before.cleared);assert.ok(after.lesson?.message);ui.button('重播');}
|
||||||
|
let ruleTurns=0;
|
||||||
|
while(ui.api.buttons().some(b=>b.label==='下一段'&&b.enabled)){ui.button('下一段');assert.ok(++ruleTurns<15);}
|
||||||
|
assert.ok(ui.api.buttons().every(b=>b.y>=0&&b.y+b.h<=812),'规则按钮不能越界');
|
||||||
|
ui.button('返回游戏');
|
||||||
|
ui.cell(solution[0]);
|
||||||
|
if (solution.length > 1)
|
||||||
|
ui.cell(solution[1]);
|
||||||
|
const originalFills=JSON.stringify(ui.api.snapshot().cellViews);
|
||||||
|
ui.button('撤销');
|
||||||
|
assert.equal(ui.api.snapshot().path.length, 1);
|
||||||
|
ui.button('重开');
|
||||||
|
assert.equal(ui.api.snapshot().path.length, 0);
|
||||||
|
ui.button('指路');
|
||||||
|
const hinted=ui.api.snapshot().hint;
|
||||||
|
assert.ok(hinted?.route.length>=2);
|
||||||
|
assert.equal(ui.api.snapshot().path.length,0);
|
||||||
|
for (const [step,id] of solution.entries()) {
|
||||||
|
ui.cell(id);
|
||||||
|
if(step===1)assert.equal(JSON.stringify(ui.api.snapshot().cellViews),originalFills,'点击指路前后玩家填色必须一致 '+l.id);
|
||||||
|
assert.equal(Boolean(ui.api.snapshot().hint),step<hinted.end,'到达段末后消失');
|
||||||
|
}
|
||||||
|
const s = ui.api.snapshot();
|
||||||
|
assert.equal(s.cleared, true, '无法触摸通关 ' + l.id);
|
||||||
|
assert.equal(s.violations.length, 0);
|
||||||
|
const action=ui.api.buttons().find(b=>b.label===(i===199?'返回选关':'下一关 →'));
|
||||||
|
assert.ok(action,'通关按钮必须存在');
|
||||||
|
assert.ok(!ui.api.buttons().some(b=>['重开','撤销','指路'].includes(b.label)),'通关按钮独占操作栏');
|
||||||
|
const board=ui.images.at(-1);assert.ok(action.y>=board.y+board.h+24,'下一关不能覆盖盘面');
|
||||||
|
rows.push({ order: i + 1, id: l.id, geometry: l.geometry, cells: l.cells.length, unique: true, touchCleared: true });
|
||||||
|
}
|
||||||
|
ui.handlers.Hide();
|
||||||
|
assert.equal(ui.frames.size, 0);
|
||||||
|
ui.handlers.Show();
|
||||||
|
assert.equal(ui.frames.size, 1);
|
||||||
|
ui.tick();
|
||||||
|
assert.ok(ui.played.length>500,'发布包必须实际调用原生音频播放');
|
||||||
|
assert.ok(!ui.api.snapshot().rules.includes('共边'));
|
||||||
|
const restored = boot(ui.storage, 320, 640);
|
||||||
|
assert.equal(restored.api.snapshot().level, ordered[199].id);
|
||||||
|
assert.equal(JSON.parse(ui.storage.get('variant:campaign-200-r2:v1')).length, 200);
|
||||||
|
restored.button('继续 · 20-10');restored.button('规则');assert.ok(restored.api.buttons().every(b=>b.y>=0&&b.y+b.h<=640));const smallBefore=restored.api.snapshot();restored.advance(5200);assert.deepEqual(restored.api.snapshot().path,smallBefore.path);restored.button('返回游戏');
|
||||||
|
restored.button('音效开');
|
||||||
|
const reopen = boot(ui.storage);
|
||||||
|
assert.equal(reopen.api.snapshot().soundEnabled, false);
|
||||||
|
// 在多种手机尺寸下验证通关后的真实按钮、绘制区域与点击跳转。
|
||||||
|
for(const [w,h]of [[320,568],[375,667],[390,844],[414,896]]){
|
||||||
|
const phone=boot(new Map(),w,h);
|
||||||
|
for(const i of [0,28,34,99,198,199]){
|
||||||
|
phone.select(i);for(const id of solve(ordered[i],{limit:1})[0])phone.cell(id);
|
||||||
|
const buttons=phone.api.buttons(),board=phone.images.at(-1),action=buttons.find(b=>b.label===(i===199?'返回选关':'下一关 →'));
|
||||||
|
assert.ok(action.y>=board.y+board.h+24,w+'x'+h+' 不覆盖盘面');
|
||||||
|
assert.ok(action.x>=0&&action.x+action.w<=w&&action.y+action.h<=h-34,'不能进入底部安全区');
|
||||||
|
for(let a=0;a<buttons.length;a++)for(let b=a+1;b<buttons.length;b++){const x=buttons[a],y=buttons[b];assert.ok(x.x+x.w<=y.x||y.x+y.w<=x.x||x.y+x.h<=y.y||y.y+y.h<=x.y,'按钮不能相叠');}
|
||||||
|
phone.button(action.label);
|
||||||
|
if(i===199)assert.equal(phone.api.snapshot().screen,'select');else{assert.equal(phone.api.snapshot().displayOrder,i+2);assert.ok(phone.api.buttons().some(b=>b.label==='重开'||b.label==='返回游戏'));}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const narrow=boot(new Map(),320,568);
|
||||||
|
for(const l of ordered.filter(l=>l.tutorial)){narrow.select(ordered.indexOf(l));narrow.button('规则');let pages=0;do{assert.ok(narrow.api.buttons().every(b=>b.y>=0&&b.y+b.h<=568));const before=narrow.api.snapshot();narrow.advance(3900);const after=narrow.api.snapshot();for(const field of ['path','centers','doors','latchedDoors','records','lastSound'])assert.deepEqual(after[field],before[field],field);if(!narrow.api.buttons().some(b=>b.label==='下一段'&&b.enabled))break;narrow.button('下一段');assert.ok(++pages<30);}while(true);narrow.button('返回游戏');}
|
||||||
|
const result = {segmentHints:true,playerFillUnchangedAfterHint:true,animatedLessons:10,previewStateIsolation:true,narrowTutorials:'320x568', source: manifest.source, version: pack.version, total: 200, passed: 200, sourceHashesMatch: true, uniqueSolution: true, simulatedWeChatTouch: true, undoRestartHint: true, storageRestore: true, foregroundLoop: true, nativeAudioPlaybackCalls: ui.played.length, plainLanguageRules: true, pagedRules: true, clearActionLayout: true, phoneSizesChecked: 4, rows };
|
||||||
|
fs.writeFileSync(path.join(root, 'validation.json'), JSON.stringify(result, null, 2));
|
||||||
|
console.log('PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。');
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"source": "G:/项目/NumberSwipe-Variants/campaign-200-r2",
|
||||||
|
"mode": "campaign-200-r2",
|
||||||
|
"version": "scored-2026-10-15-r2",
|
||||||
|
"levels": 200,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "levels.json",
|
||||||
|
"sha256": "0adf0c10a0677a922539fd5578808a8852d889fdc2afdcc8ce73848880d00f10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rules.mjs",
|
||||||
|
"sha256": "3a903983f3d63d220f7e607c0d5b60d4d5faa31c02c413a5107f660d7a3058e0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ui.mjs",
|
||||||
|
"sha256": "8ac80f0b7646986f1ea9f79b401ac0f89d52abadb2e9857690390c2780fc87b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audio.mjs",
|
||||||
|
"sha256": "7cecb4bae026919131325d86e970bb7bee55d4569d574af78032714e7bb1cf04"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"adaptationRevision": "2026-10-19-animated-lessons-one-second",
|
||||||
|
"notes": "上游四文件保持原样;微信宿主加载时替换音频实现,优化玩家可见文案,不改变200关布局、约束及顺序。"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 微信原生短音频播放:资源随包提供,不依赖浏览器振荡器。
|
||||||
|
const instances=new Set();
|
||||||
|
export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:0};
|
||||||
|
export function stopAudio(){for(const instance of instances)instance.stop();}
|
||||||
|
export class CasualAudio{
|
||||||
|
constructor(host,storage,key){this.storage=storage;this.key=key;this.channels=[];this.cursor=0;this.timers=new Set();this.lastEvent=null;this.lastAt={};this.warned=false;try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}instances.add(this);}
|
||||||
|
unlock(){if(!this.enabled||!wx.createInnerAudioContext)return false;
|
||||||
|
try{wx.setInnerAudioOption?.({obeyMuteSwitch:false,mixWithOther:true});if(!this.channels.length)for(let i=0;i<4;i++){const a=wx.createInnerAudioContext();a.obeyMuteSwitch=false;a.volume=.85;a.onError?.(error=>this.error(error));this.channels.push(a);}return true;}catch(error){this.error(error);return false;}}
|
||||||
|
error(error){audioDiagnostics.lastError={errCode:error?.errCode||0,errMsg:error?.errMsg||String(error)};if(!this.warned){this.warned=true;wx.showToast?.({title:'音效加载失败,请重新进入游戏',icon:'none'});}}
|
||||||
|
stop(){for(const timer of this.timers)clearTimeout(timer);this.timers.clear();for(const a of this.channels){try{a.stop();}catch{}}}
|
||||||
|
toggle(){this.enabled=!this.enabled;try{this.storage.setItem(this.key,this.enabled?'on':'off');}catch{}if(this.enabled)this.play('correct');else this.stop();return this.enabled;}
|
||||||
|
play(kind,delay=0,step=1){if(!this.unlock())return;const now=Date.now();if(kind==='error'&&now-(this.lastAt.error||0)<180)return;this.lastAt[kind]=now;
|
||||||
|
if(kind==='restart')this.stop();const file=kind==='move'?'move-'+Math.max(1,Math.min(36,Math.floor(step))):kind;
|
||||||
|
const run=()=>{if(!this.enabled)return;const a=this.channels[this.cursor++%this.channels.length];try{a.stop();a.src='audio/'+file+'.wav';a.play();this.lastEvent=kind;audioDiagnostics.plays++;}catch(error){this.error(error);}};
|
||||||
|
if(delay>0){const timer=setTimeout(()=>{this.timers.delete(timer);run();},delay*1000);this.timers.add(timer);}else run();}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export const tutorials={
|
||||||
|
basic:'从数字 1 开始,按住滑动,把所有格子连起来。只能走到边挨着边的格子,不能斜着走。每格只能经过一次。',
|
||||||
|
walls:'黑黄条纹是一堵墙,不能直接穿过去。请从别的格子绕到墙的另一边。',
|
||||||
|
straight:'画着横线的格子只能横着穿过:从左边进、右边出,也可以反过来。不能在里面转弯,也不能作为起点或终点。',
|
||||||
|
vertical:'画着竖线的格子只能竖着穿过:从上边进、下边出,也可以反过来。不能在里面转弯,也不能作为起点或终点。',
|
||||||
|
corner:'这个格子里画着一条转弯的线。沿这条线,从一端进入、另一端离开;正着走、反着走都可以。不能作为起点或终点。',
|
||||||
|
colour:'每走一步都要换颜色:蓝色 → 橙色 → 蓝色。同一种颜色的两个格子,即使挨着,也不能连续走。',
|
||||||
|
parity:'从起点开始数,第一个格子算第 1 步。“奇”格必须在第 1、3、5…步经过;“偶”格必须在第 2、4、6…步经过。',
|
||||||
|
'rhythm-door':'门一开始是关着的。每走进一个格子,门就开、关切换一次;起点也算一步。只有显示“开”时才能穿过。穿过后,门会保持打开;如果退回到穿门之前,门又会继续切换。',
|
||||||
|
hex:'六边形的每条边旁边都可能有一个格子。只要两个格子有一整条边挨在一起,就能直接走过去。每经过一个格子算一步。',
|
||||||
|
'unknown-start':'这一关没有数字 1,可以自己选起点。先看看其他数字,试着找出从哪里开始,才能按正确的步数到达它们。'
|
||||||
|
};
|
||||||
|
export function rulesFor(l){const lines=['按住滑动,把所有格子连成一条线。每个格子只能经过一次。',l.geometry==='hex'?'六边形之间,有一整条边挨在一起才能走过去。':'只能走到边挨着边的格子,不能斜着走。','格子里的数字表示第几步到这里。例如数字 5,必须在第 5 步走到它。'];
|
||||||
|
if(l.geometry==='blocks')lines.push('一个大格或不规则形状的格子,整个算一步。');
|
||||||
|
for(const [feature,key] of [['direction','corner'],['alternation','colour'],['parity','parity'],['walls','walls'],['rhythm-door','rhythm-door']])if(l.features.includes(feature))lines.push(feature==='direction'?'有线条的格子,要沿图中的线从一端进入、另一端离开;不能作为起点或终点。':tutorials[key]);
|
||||||
|
if(!l.cells.some(c=>c.clue===1))lines.push(tutorials['unknown-start']);
|
||||||
|
lines.push('走错了,可以沿刚才的路线往回退;也可以点“撤销”退一步,点“重开”重新开始。');return lines.join('\n');}
|
||||||
|
const replacements={
|
||||||
|
'固定数字步号不符':'到达数字格的步数不对',
|
||||||
|
'该格步号奇偶不符':'走到“奇”格要数到 1、3、5…;走到“偶”格要数到 2、4、6…',
|
||||||
|
'进出方向不符':'请沿格子里画的线,从一端进入、另一端离开',
|
||||||
|
'这一步门是关闭的':'门现在关着,请先走到别的格子,让门切换到“开”',
|
||||||
|
'蓝橙颜色必须交替':'每一步都要换颜色:蓝色和橙色交替走',
|
||||||
|
'规则不符,请沿来时路退回上一格再试。':'这一步不符合规则,请沿刚才的路线退回上一格再试。',
|
||||||
|
'下一步在黄色边框格;提示不会自动画路。':'下一步走到黄色边框的格子,请自己连过去。',
|
||||||
|
'请沿来时路逐格退回黄色边框格,再继续。':'请沿刚才的路线,一格一格退回黄色边框处,再试另一条路。',
|
||||||
|
'起点需要调整,请重开并从黄色边框格开始。':'起点选错了。请点“重开”,从黄色边框的格子开始。',
|
||||||
|
'门现在关闭;绕行落格后会切换。':'门现在关着。先走到别的格子,门就会开、关切换一次。',
|
||||||
|
'黑黄墙不能穿过,请从另一侧绕行。':tutorials.walls,
|
||||||
|
'同色格不能连续经过,请换另一种颜色。':'下一步要换颜色:蓝格之后走橙格,橙格之后走蓝格。'
|
||||||
|
};
|
||||||
|
export function playerText(value){let s=String(value);for(const [from,to]of Object.entries(replacements))s=s.split(from).join(to);return s.replaceAll(',沿来时路逐格退回。','。请沿刚才的路线退回。').replaceAll('访问步号','第几步经过').replaceAll('步号','步数').replaceAll('认清异形共边','找出不规则格子旁边能走的格子').replaceAll('六向共边','六边形怎么走').replaceAll('共边','边挨着边');}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export function hintSegment(level, solution, path) {
|
||||||
|
const cells=new Map(level.cells.map(c=>[c.id,c]));
|
||||||
|
const anchors=solution.flatMap((id,i)=>{const c=cells.get(id);return i===0||i===solution.length-1||c.clue!==undefined||c.direction||c.parity?[i]:[];});
|
||||||
|
let prefix=0;while(prefix<path.length&&path[prefix]===solution[prefix])prefix++;
|
||||||
|
for(let i=1;i<anchors.length;i++)if(prefix<=anchors[i])return {start:anchors[i-1],end:anchors[i],route:solution.slice(anchors[i-1],anchors[i]+1)};
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export function createHintController(){
|
||||||
|
let active=null,owner=null;
|
||||||
|
return {
|
||||||
|
clear(){active=owner=null;},
|
||||||
|
request(level,solution,path){owner=level;active=hintSegment(level,solution,path);return active;},
|
||||||
|
view(level,solution,path){
|
||||||
|
if(owner!==level)active=null;
|
||||||
|
if(!active)return null;
|
||||||
|
let prefix=0;while(prefix<path.length&&path[prefix]===solution[prefix])prefix++;
|
||||||
|
if(prefix>active.end){active=null;return null;}
|
||||||
|
const current=hintSegment(level,solution,path);
|
||||||
|
if(current&¤t.start<active.start)active=current;
|
||||||
|
return {...active,route:[...active.route]};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw only on the dedicated transparent hint canvas, never on the player board.
|
||||||
|
export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
|
||||||
|
if(!hint||hint.route.length<2)return;
|
||||||
|
const points=hint.route.map(id=>centers[id]);if(points.some(p=>!p))return;
|
||||||
|
const visited=new Set(playerPath);
|
||||||
|
const covered=hint.route.slice(1).map((b,i)=>{const a=hint.route[i];return playerPath.some((id,j)=>j>0&&((playerPath[j-1]===a&&id===b)||(playerPath[j-1]===b&&id===a)));});
|
||||||
|
const width=Math.max(4,Math.min(9,unit*.13));
|
||||||
|
const progress=elapsed%Math.max(1200,(points.length-1)*240)/Math.max(1200,(points.length-1)*240)*(points.length-1);
|
||||||
|
ctx.save();ctx.lineCap='round';ctx.lineJoin='round';
|
||||||
|
const trace=(i,color,w)=>{if(covered[i])return;ctx.beginPath();ctx.moveTo(...points[i]);ctx.lineTo(...points[i+1]);ctx.strokeStyle=color;ctx.lineWidth=w;ctx.stroke();};
|
||||||
|
for(let i=0;i<points.length-1;i++)trace(i,'rgba(35,36,58,.62)',width+4);
|
||||||
|
for(let i=0;i<points.length-1;i++)trace(i,'rgba(255,255,255,.78)',width);
|
||||||
|
for(let j=10;j>=0;j--){
|
||||||
|
const t=progress-j*.045;if(t<0)continue;
|
||||||
|
const k=Math.min(Math.floor(t),points.length-2),f=t-k;if(covered[k])continue;
|
||||||
|
const a=points[k],b=points[k+1],x=a[0]+(b[0]-a[0])*f,y=a[1]+(b[1]-a[1])*f;
|
||||||
|
ctx.beginPath();ctx.arc(x,y,width*(.45+(10-j)*.035),0,Math.PI*2);
|
||||||
|
ctx.fillStyle=`rgba(255,255,255,${.12+(10-j)*.075})`;ctx.fill();
|
||||||
|
if(j===0){ctx.strokeStyle='rgba(35,36,58,.9)';ctx.lineWidth=2;ctx.stroke();}
|
||||||
|
}
|
||||||
|
for(const i of [0,points.length-1]){
|
||||||
|
if(visited.has(hint.route[i]))continue;
|
||||||
|
ctx.beginPath();ctx.arc(...points[i],Math.max(8,unit*.23),0,Math.PI*2);
|
||||||
|
ctx.strokeStyle='rgba(35,36,58,.72)';ctx.lineWidth=7;ctx.stroke();
|
||||||
|
ctx.strokeStyle='rgba(255,255,255,.92)';ctx.lineWidth=3;ctx.stroke();
|
||||||
|
}
|
||||||
|
// Clear the hint inside every actually visited cell, including wrong routes.
|
||||||
|
// The base board's filled cells, numbers and channel effects stay fully visible.
|
||||||
|
ctx.globalCompositeOperation='destination-out';ctx.fillStyle='#000000';
|
||||||
|
for(const p of hint.visitedPolygons||[]){
|
||||||
|
ctx.beginPath();ctx.moveTo(...p[0]);for(const v of p.slice(1))ctx.lineTo(...v);ctx.closePath();ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {solve,doorOpen,doorLatched,graph,violations} from './upstream/rules.mjs';
|
||||||
|
import {tutorials} from './copy.mjs';
|
||||||
|
export function lessonKeys(level){if(level.tutorial)return [level.tutorial];const keys=['basic'];if(level.features.includes('walls'))keys.push('walls');for(const [key,kind]of [['straight','lr'],['vertical','du']])if(level.cells.some(c=>c.direction===kind))keys.push(key);if(level.cells.some(c=>c.direction&&!['lr','du'].includes(c.direction)))keys.push('corner');if(level.features.includes('alternation'))keys.push('colour');if(level.features.includes('parity'))keys.push('parity');if(level.doors?.length)keys.push('rhythm-door');if(level.geometry==='hex')keys.push('hex');if(!level.cells.some(c=>c.clue===1))keys.push('unknown-start');return keys;}
|
||||||
|
export function makeLesson(pack,key){const level=pack.levels.find(l=>l.tutorial===key);if(!level)throw Error('缺少教学关 '+key);const solution=solve(level)[0];const routes=Array.from({length:solution.length+1},(_,n)=>solution.slice(0,n));if(key==='rhythm-door'){for(let n=solution.length-1;n>=0;n--)routes.push(solution.slice(0,n));}const examples=[];const g=graph(level);
|
||||||
|
if(key==='walls')examples.push({after:1,route:solution.slice(0,1),blocked:level.walls[0],message:'试着穿墙:这条边被挡住,需要绕行'});
|
||||||
|
if(key==='colour') {const first=level.cells.find(c=>c.id===solution[0]);const other=g.adj[g.ids.get(first.id)].map(i=>g.cells[i]).find(c=>c.category===first.category);if(other)examples.push({after:1,route:solution.slice(0,1),blocked:[first.id,other.id],message:'同色不能连续走;改走旁边的橙色格'});}
|
||||||
|
if(['straight','vertical','corner','parity'].includes(key)){for(let n=1;n<solution.length;n++){const prefix=solution.slice(0,n);const bad=g.adj[g.ids.get(prefix.at(-1))].map(i=>g.cells[i].id).find(id=>!prefix.includes(id)&&violations(level,[...prefix,id]).some(v=>v.key.startsWith((key==='parity'?'parity':'direction')+':')));if(bad){examples.push({after:n,route:[...prefix,bad],message:key==='parity'?'错误示例:奇偶与当前步数不符,格子变红':'错误示例:没有沿图中的两端进出,图标变红'});break;}}}
|
||||||
|
if(key==='parity'){const start=solution[0];for(const j of g.adj[g.ids.get(start)])for(const k of g.adj[j]){const route=[start,g.cells[j].id,g.cells[k].id];if(new Set(route).size===3&&violations(level,route).some(v=>v.key.startsWith('parity:'))&&!examples.length)examples.push({after:1,route,message:'错误示例:第 3 步走到“偶”格,格子变红'});}}
|
||||||
|
const beats=[];routes.forEach((route,i)=>{beats.push({route});if(i<solution.length)for(const e of examples.filter(e=>e.after===i)){beats.push(e);for(let back=e.route.length-1;back>route.length;back--)beats.push({route:e.route.slice(0,back),message:'沿来时路逐格退回,再按规则走'});beats.push({route,message:e.blocked?'改走符合规则的相邻格子':'沿来时路退回,重新按规则走'});}});
|
||||||
|
const continuous=[];for(const beat of beats){const previous=continuous.at(-1);if(previous&&JSON.stringify(previous.route)===JSON.stringify(beat.route)&&!beat.blocked&&!previous.blocked)continue;if(previous&&beat.route.length>previous.route.length+1){for(let n=previous.route.length+1;n<beat.route.length;n++)continuous.push({route:beat.route.slice(0,n)});}continuous.push(beat);}return {key,level,solution,routes,beats:continuous,copy:tutorials[key],duration:1000};}
|
||||||
|
export function lessonFrame(lesson,elapsed){const duration=lesson.duration,segments=lesson.beats.length-1,position=Math.max(0,Math.min(1,elapsed/duration))*segments,index=Math.min(segments,Math.floor(position)+1),beat=lesson.beats[Math.min(index,lesson.beats.length-1)],route=beat.route,previous=lesson.beats[Math.max(0,Math.min(index-1,lesson.beats.length-1))].route;const n=route.length,key=lesson.key,c=lesson.level.cells.find(c=>c.id===route.at(-1));let message=n?'第 '+n+' 步':'先观察格子,再开始连线';if(key==='basic')message=n?'第 '+n+' 步 · 每个格子只经过一次':'从数字 1 开始';if(key==='colour'&&n)message+=' · '+(c.category==='circle'?'蓝色;下一步换橙色':'橙色;下一步换蓝色');if(key==='parity'&&n)message+=' · '+(n%2?'奇数步:1、3、5…':'偶数步:2、4、6…');if(['straight','vertical','corner'].includes(key)){const at=route.findIndex(id=>lesson.level.cells.find(c=>c.id===id)?.direction);message=at<0?'观察线条连接的两端':at===n-1?'从线的一端进入,下一步沿另一端离开':'沿指定的线穿过,图标亮起';}if(key==='rhythm-door'){const d=lesson.level.doors[0];message+=' · '+(doorLatched(d,route)?'已经穿过:门保持打开':doorOpen(d,route)?'门开:现在可以穿过':'门关:此时不能穿过');if(previous.length>n)message='回退到第 '+n+' 步 · '+(doorLatched(d,route)?'尚未退过门,保持打开':'已撤销穿门,恢复开关切换');}if(key==='walls')message=n?'沿其他格子绕过去,黑黄墙始终不能穿越':'找到黑黄墙:这条边不能走';if(key==='hex')message=n?'第 '+n+' 步 · 沿相邻的整条边移动':'观察六边形相邻的边';if(key==='unknown-start')message=n?'第 '+n+' 步 · 核对是否按时到达数字格':'没有数字 1:观察其他数字,选择起点';message=beat.message||message;return {route,previous,message,blocked:beat.blocked,progress:elapsed>=duration?1:position-Math.floor(position),finished:elapsed>=duration,index};}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {stopAudio,audioDiagnostics} from './audio.mjs';
|
||||||
|
import { mainCanvas, boardCanvas, document, window, PACK } from './platform.mjs';
|
||||||
|
import {renderLesson,hintView} from './upstream/ui.mjs';
|
||||||
|
import {drawHint} from './hints.mjs';
|
||||||
|
import {lessonKeys,makeLesson,lessonFrame} from './lessons.mjs';
|
||||||
|
import {tutorials} from './copy.mjs';
|
||||||
|
const lessonCanvas=wx.createCanvas();let lesson=null,lessonStarted=0,lessonState=null,lessonCache=new Map();
|
||||||
|
const $ = id => document.getElementById(id), ctx = mainCanvas.getContext('2d');
|
||||||
|
let width = 375, height = 812, top = 60, bottom = 24, page = 0, buttons = [], pointer = null, visible = true, raf = 0;
|
||||||
|
let rulePage=0,ruleKey='';
|
||||||
|
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
|
||||||
|
let hintAnimation={key:'',started:0};
|
||||||
|
const ordered = PACK.playOrder.map(id => PACK.levels.find(l => l.id === id));
|
||||||
|
function resize() { const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); width = info.windowWidth; height = info.windowHeight; top = Math.max(info.safeArea?.top || 0, wx.getMenuButtonBoundingClientRect?.().bottom || 0) + 12; bottom = Math.max(16, height - (info.safeArea?.bottom || height)); window.devicePixelRatio = Math.min(info.pixelRatio || 1, 3); mainCanvas.width = width * window.devicePixelRatio; mainCanvas.height = height * window.devicePixelRatio; $('board').clientWidth = width; $('board').clientHeight = Math.max(140, height - top - bottom - 218); window.handlers.resize?.(); }
|
||||||
|
function text(value, x, y, size = 16, color = '#23243A') { ctx.fillStyle = color; ctx.font = size + 'px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(value), x, y); }
|
||||||
|
function splitLines(value,maxWidth,size=16){ctx.font=size+'px sans-serif';const lines=[];for(const paragraph of String(value).split('\n')){let line='';for(const c of paragraph){if(line&&ctx.measureText(line+c).width>maxWidth){lines.push(line);line='';}line+=c;}lines.push(line);}return lines;}
|
||||||
|
function wrapped(value,x,y,maxWidth,size=16){const lines=splitLines(value,maxWidth,size);lines.forEach((line,i)=>text(line,x,y+i*(size+9),size));return lines.length;}
|
||||||
|
function button(label, x, y, w, h, run, { enabled = true, primary = false } = {}) { ctx.fillStyle = enabled ? (primary ? '#6558F5' : '#ECECF3') : '#F4F4F6'; ctx.fillRect(x, y, w, h); text(label, x + w / 2, y + h / 2, 15, enabled ? (primary ? '#FFFFFF' : '#23243A') : '#AAAAAA'); buttons.push({ label, x, y, w, h, run, enabled }); }
|
||||||
|
function click(id) { if(id==='btn-rules')ruleKey=''; $('board').handlers.pointerup?.(); pointer = null; $(id).click(); }
|
||||||
|
function levelButtons() { return $('pack-list').children.flatMap(section => section.children[1].children); }
|
||||||
|
function draw() {
|
||||||
|
if (!visible)
|
||||||
|
return;
|
||||||
|
const d = window.devicePixelRatio;
|
||||||
|
ctx.setTransform(d, 0, 0, d, 0, 0);
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
buttons = [];
|
||||||
|
const select = $('screen-play').className.includes('hidden');
|
||||||
|
if (select) {
|
||||||
|
text('数字一笔', width / 2, top + 24, 30);
|
||||||
|
text('200关 · 评分修订版', width / 2, top + 61, 16);
|
||||||
|
text($('progress').textContent, width / 2, top + 90, 14);
|
||||||
|
const rows = Math.max(1, Math.floor((height - top - bottom - 242) / 57)), count = rows * 4, pages = Math.ceil(200 / count);
|
||||||
|
page = Math.max(0, Math.min(pages - 1, page));
|
||||||
|
const levels = levelButtons();
|
||||||
|
for (let j = 0; j < count && page * count + j < levels.length; j++) {
|
||||||
|
const index = page * count + j, node = levels[index];
|
||||||
|
button(node.textContent + (node.className.includes('cleared') ? ' ✓' : ''), 18 + j % 4 * (width - 30) / 4, top + 120 + Math.floor(j / 4) * 57, (width - 48) / 4 - 4, 45, () => node.click(), { primary: node.className.includes('current') });
|
||||||
|
}
|
||||||
|
button('上一页', 18, height - bottom - 106, 90, 38, () => page = Math.max(0, page - 1));
|
||||||
|
text((page + 1) + ' / ' + pages, width / 2, height - bottom - 87);
|
||||||
|
button('下一页', width - 108, height - bottom - 106, 90, 38, () => page = Math.min(pages - 1, page + 1));
|
||||||
|
button($('btn-continue').textContent, 18, height - bottom - 56, width - 126, 42, () => click('btn-continue'), { primary: true });
|
||||||
|
button('反馈', width - 98, height - bottom - 56, 80, 42, feedback);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
button('选关', 16, top, 62, 36, () => click('btn-back'));
|
||||||
|
text('第 ' + window.variantSnapshot().displayOrder + ' 关', width / 2, top + 18, 20);
|
||||||
|
button($('btn-sound').textContent, width - 94, top, 78, 36, () => click('btn-sound'));
|
||||||
|
text($('level-sub').textContent, width / 2, top + 59, 14);
|
||||||
|
ctx.drawImage(boardCanvas, 0, top + 82, width, $('board').clientHeight);
|
||||||
|
const hint=hintView(),boardState=window.variantSnapshot();
|
||||||
|
if(hint){
|
||||||
|
const key=boardState.level+':'+hint.start+':'+hint.end;
|
||||||
|
if(hintAnimation.key!==key)hintAnimation={key,started:Date.now()};
|
||||||
|
const h=$('board').clientHeight;
|
||||||
|
if(hintCanvas.width!==Math.round(width*d))hintCanvas.width=Math.round(width*d);
|
||||||
|
if(hintCanvas.height!==Math.round(h*d))hintCanvas.height=Math.round(h*d);
|
||||||
|
hintContext.setTransform(d,0,0,d,0,0);hintContext.clearRect(0,0,width,h);
|
||||||
|
drawHint(hintContext,hint,boardState.centers,boardState.unit,Date.now()-hintAnimation.started,boardState.path);
|
||||||
|
ctx.drawImage(hintCanvas,0,top+82,width,h);
|
||||||
|
}else hintAnimation={key:'',started:0};
|
||||||
|
const foot = height - bottom - 102;
|
||||||
|
const cleared=window.variantSnapshot().cleared;
|
||||||
|
const message=cleared?(window.variantSnapshot().displayOrder===ordered.length?'200 关全部完成!':'完成!准备好就进入下一关。'):($('status').className.includes('show')?$('status').textContent:'按住滑动连线 · 沿来时路逐格回退');
|
||||||
|
const messageLines=splitLines(message,width-36,13);
|
||||||
|
messageLines.slice(0,2).forEach((line,i)=>text(i===1&&messageLines.length>2?line.slice(0,-1)+'…':line,width/2,foot-12+i*22,13,cleared?'#6558F5':'#777789'));
|
||||||
|
if(cleared){
|
||||||
|
button(!$('next').hidden?'下一关 →':'返回选关',16,foot+22,width-32,44,()=>click(!$('next').hidden?'next':'btn-back'),{primary:true});
|
||||||
|
}else{
|
||||||
|
const bw=(width-48)/3;
|
||||||
|
button('重开',16,foot+22,bw,44,()=>click('restart'));
|
||||||
|
button('撤销',24+bw,foot+22,bw,44,()=>click('undo'),{enabled:!$('undo').disabled});
|
||||||
|
button('指路',32+2*bw,foot+22,bw,44,()=>click('hint'));
|
||||||
|
}
|
||||||
|
button('规则', 16, top + 43, 56, 30, () => click('btn-rules'));
|
||||||
|
text('剩余 ' + $('remaining-n').textContent, width - 52, top + 58, 14);
|
||||||
|
}
|
||||||
|
if (!$('rules-sheet').hidden) {
|
||||||
|
ctx.fillStyle = 'rgba(25,24,44,.55)';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
const panelTop=top+12,panelHeight=Math.max(240,height-panelTop-bottom-20);
|
||||||
|
ctx.fillRect(16,panelTop,width-32,panelHeight);
|
||||||
|
buttons=[];
|
||||||
|
text('怎么玩',width/2,panelTop+28,23);
|
||||||
|
const key=window.variantSnapshot().level+':'+$('rule-text').textContent;
|
||||||
|
if(ruleKey!==key){ruleKey=key;rulePage=0;lesson=null;}
|
||||||
|
const current=ordered.find(l=>l.id===window.variantSnapshot().level),keys=lessonKeys(current);
|
||||||
|
const pages=[];const visualHeight=Math.max(90,Math.min(220,panelHeight*.34));
|
||||||
|
const perPage=Math.max(1,Math.floor((panelHeight-visualHeight-230)/23));
|
||||||
|
for(const k of keys){const extra=k==='basic'&&!current.tutorial?'\n格子里的数字表示第几步到这里,例如数字 5,要在第 5 步到达。'+(current.geometry==='blocks'?'大格或不规则格子,整个只算一步。':'')+'走错后沿原路退一步,或点撤销;重开会清空路线。':'';const lines=splitLines(tutorials[k]+extra,width-72,15);for(let n=0;n<lines.length;n+=perPage)pages.push({key:k,lines:lines.slice(n,n+perPage)});}
|
||||||
|
rulePage=Math.max(0,Math.min(pages.length-1,rulePage));const part=pages[rulePage];
|
||||||
|
const lessonKey=key+':'+rulePage;
|
||||||
|
if(!lesson||lesson.id!==lessonKey){if(!lessonCache.has(part.key))lessonCache.set(part.key,makeLesson(PACK,part.key));lesson={...lessonCache.get(part.key),id:lessonKey};lessonStarted=Date.now();}
|
||||||
|
lessonState=lessonFrame(lesson,Date.now()-lessonStarted);
|
||||||
|
const area={x:32,y:panelTop+49,w:width-64,h:visualHeight};
|
||||||
|
const view=renderLesson(lessonCanvas,lesson.level,lessonState.progress<1?lessonState.previous:lessonState.route,area.w,area.h);
|
||||||
|
ctx.drawImage(lessonCanvas,area.x,area.y,area.w,area.h);
|
||||||
|
if(lessonState.blocked){const [a,b]=lessonState.blocked.map(id=>view.centers[id]);ctx.beginPath();ctx.moveTo(area.x+a[0],area.y+a[1]);ctx.lineTo(area.x+b[0],area.y+b[1]);ctx.setLineDash([5,5]);ctx.strokeStyle='#E5484D';ctx.lineWidth=4;ctx.stroke();ctx.setLineDash([]);text('×',area.x+(a[0]+b[0])/2,area.y+(a[1]+b[1])/2,26,'#E5484D');}
|
||||||
|
const last=lessonState.route.at(-1),prev=lessonState.previous.at(-1),end=view.centers[last],start=view.centers[prev]||end;
|
||||||
|
if(end){const t=lessonState.progress,x=area.x+start[0]+(end[0]-start[0])*t,y=area.y+start[1]+(end[1]-start[1])*t;if(start!==end){ctx.beginPath();ctx.moveTo(area.x+start[0],area.y+start[1]);ctx.lineTo(x,y);ctx.strokeStyle='#6558F5';ctx.lineWidth=6;ctx.lineCap='round';ctx.stroke();}ctx.beginPath();ctx.arc(x,y,10,0,Math.PI*2);ctx.strokeStyle='#F5A524';ctx.lineWidth=3;ctx.stroke();}
|
||||||
|
const msg=splitLines(lessonState.message,width-76,14);msg.forEach((line,i)=>text(line,width/2,area.y+area.h+16+i*20,14,'#6558F5'));
|
||||||
|
part.lines.forEach((line,i)=>text(line,width/2,area.y+area.h+65+i*23,15));
|
||||||
|
button('重播',width/2-32,panelTop+panelHeight-144,64,30,()=>{lessonStarted=Date.now();});
|
||||||
|
if(pages.length>1){button('上一段',38,panelTop+panelHeight-102,80,30,()=>{rulePage--;lesson=null;},{enabled:rulePage>0});text((rulePage+1)+' / '+pages.length,width/2,panelTop+panelHeight-87,14);button('下一段',width-118,panelTop+panelHeight-102,80,30,()=>{rulePage++;lesson=null;},{enabled:rulePage<pages.length-1});}
|
||||||
|
button('返回游戏',40,panelTop+panelHeight-60,width-80,42,()=>click('close-rules'),{primary:true});
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
function feedback() { wx.showModal({ title: '试玩反馈', editable: true, placeholderText: '记录有趣、太难或规则不清楚的关卡', content: $('reasoning').value || '', success: r => { if (r.confirm) {
|
||||||
|
$('reasoning').value = r.content || '';
|
||||||
|
click('close-feedback');
|
||||||
|
wx.showModal({ title: '反馈已保存', content: '是否导出全部试玩记录?', success: r => { if (r.confirm)
|
||||||
|
click('export'); } });
|
||||||
|
} } }); }
|
||||||
|
function event(p) { return { clientX: p.clientX, clientY: p.clientY - top - 82, pointerId: p.identifier, pointerType: 'touch', preventDefault() { } }; }
|
||||||
|
wx.onTouchStart(e => { if (pointer !== null)
|
||||||
|
return; const p = e.changedTouches[0]; if (!p)
|
||||||
|
return; const b = buttons.find(b => p.clientX >= b.x && p.clientX <= b.x + b.w && p.clientY >= b.y && p.clientY <= b.y + b.h); if (b) {
|
||||||
|
if (b.enabled)
|
||||||
|
b.run();
|
||||||
|
return;
|
||||||
|
} if (!$('rules-sheet').hidden || $('screen-play').className.includes('hidden'))
|
||||||
|
return; if (p.clientY < top + 82 || p.clientY > top + 82 + $('board').clientHeight)
|
||||||
|
return; pointer = p.identifier; $('board').handlers.pointerdown?.(event(p)); });
|
||||||
|
wx.onTouchMove(e => { const p = e.touches.find(p => p.identifier === pointer); if (p)
|
||||||
|
$('board').handlers.pointermove?.(event(p)); });
|
||||||
|
const release = e => { if (e.changedTouches.some(p => p.identifier === pointer)) {
|
||||||
|
$('board').handlers.pointerup?.();
|
||||||
|
pointer = null;
|
||||||
|
} };
|
||||||
|
wx.onTouchEnd(release);
|
||||||
|
wx.onTouchCancel(release);
|
||||||
|
wx.onHide(() => { visible = false; lessonStarted=Date.now(); stopAudio(); cancelAnimationFrame(raf); pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
|
||||||
|
wx.onShow(() => { if (!visible) {
|
||||||
|
visible = true;lessonStarted=Date.now();
|
||||||
|
window.handlers.resize?.();
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
} });
|
||||||
|
wx.onAudioInterruptionBegin?.(stopAudio);
|
||||||
|
wx.onWindowResize?.(resize);
|
||||||
|
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
|
||||||
|
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),hint:hintView(),audio:{...audioDiagnostics},rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
|
||||||
|
resize();
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import PACK from './upstream/levels.json';
|
||||||
|
import {tutorials,playerText} from './copy.mjs';
|
||||||
|
for(const level of PACK.levels){if(level.tutorial)level.caption=tutorials[level.tutorial]||level.caption;level.title=playerText(level.title);}
|
||||||
|
export { PACK };
|
||||||
|
export const performance = wx.getPerformance ? wx.getPerformance() : { now: () => Date.now() };
|
||||||
|
if (!Array.prototype.at)
|
||||||
|
Object.defineProperty(Array.prototype, 'at', { value: function (index) { index = Math.trunc(index) || 0; return this[index < 0 ? this.length + index : index]; }, configurable: true, writable: true });
|
||||||
|
export const mainCanvas = wx.createCanvas();
|
||||||
|
const boardCanvas = wx.createCanvas();
|
||||||
|
const elements = new Map();
|
||||||
|
class Element {
|
||||||
|
constructor(id = '') { this.id = id; this.children = []; this.handlers = {}; this.value = ''; this.style = {}; this.className = ''; this.textContent = ''; this.hidden = false; this.disabled = false; }
|
||||||
|
set textContent(value){this._text=playerText(value);}
|
||||||
|
get textContent(){return this._text||'';}
|
||||||
|
replaceChildren(...children) { this.children = children; }
|
||||||
|
append(...children) { this.children.push(...children); }
|
||||||
|
addEventListener(type, fn) { this.handlers[type] = fn; }
|
||||||
|
setAttribute() { }
|
||||||
|
setPointerCapture() { }
|
||||||
|
getBoundingClientRect() { return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }; }
|
||||||
|
click() { if (!this.disabled)
|
||||||
|
this.onclick?.(); }
|
||||||
|
}
|
||||||
|
export const document = {
|
||||||
|
getElementById(id) { if (!elements.has(id))
|
||||||
|
elements.set(id, new Element(id)); return elements.get(id); },
|
||||||
|
createElement() { return new Element(); }
|
||||||
|
};
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
board.getContext = () => boardCanvas.getContext('2d');
|
||||||
|
for (const key of ['width', 'height'])
|
||||||
|
Object.defineProperty(board, key, { get: () => boardCanvas[key], set: value => { boardCanvas[key] = value; } });
|
||||||
|
board.clientWidth = 375;
|
||||||
|
board.clientHeight = 440;
|
||||||
|
document.getElementById('screen-play').className = 'screen hidden';
|
||||||
|
document.getElementById('rules-sheet').hidden = true;
|
||||||
|
document.getElementById('feedback-sheet').hidden = true;
|
||||||
|
export const localStorage = {
|
||||||
|
getItem(key) { try {
|
||||||
|
return wx.getStorageSync(key) || null;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
} },
|
||||||
|
setItem(key, value) { try {
|
||||||
|
wx.setStorageSync(key, String(value));
|
||||||
|
}
|
||||||
|
catch { } },
|
||||||
|
removeItem(key) { wx.removeStorageSync(key); }
|
||||||
|
};
|
||||||
|
export const window = {
|
||||||
|
devicePixelRatio: 1, handlers: {},
|
||||||
|
addEventListener(type, fn) { this.handlers[type] = fn; },
|
||||||
|
AudioContext: wx.createWebAudioContext ? function () { return wx.createWebAudioContext(); } : undefined
|
||||||
|
};
|
||||||
|
export { boardCanvas };
|
||||||
|
export function exportRecords(payload) {
|
||||||
|
const filename = wx.env.USER_DATA_PATH + '/NumberSwipe-campaign-200-r2-records.json';
|
||||||
|
try {
|
||||||
|
wx.getFileSystemManager().writeFileSync(filename, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
if (wx.shareFileMessage)
|
||||||
|
wx.shareFileMessage({ filePath: filename, fileName: 'NumberSwipe-r2-试玩记录.json' });
|
||||||
|
else
|
||||||
|
wx.showModal({ title: '记录已保存', content: filename, showCancel: false });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
wx.showToast({ title: '记录导出失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Small offline Web Audio cues: no network or audio-file dependency.
|
||||||
|
export class CasualAudio {
|
||||||
|
constructor(host,storage,key){this.host=host;this.storage=storage;this.key=key;this.context=null;this.lastEvent=null;this.lastAt={};try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}}
|
||||||
|
unlock(){if(!this.enabled)return false;try{const Audio=this.host.AudioContext||this.host.webkitAudioContext;if(!Audio)return false;if(!this.context)this.context=new Audio();if(this.context.state==='suspended')this.context.resume()?.catch(()=>{});return true;}catch{return false;}}
|
||||||
|
toggle(){this.enabled=!this.enabled;try{this.storage.setItem(this.key,this.enabled?'on':'off');}catch{}if(this.enabled)this.unlock();return this.enabled;}
|
||||||
|
play(kind,delay=0,step=1){if(!this.unlock())return;const ctx=this.context,now=ctx.currentTime;const gap=kind==='error'?.18:0;if(this.lastAt[kind]!==undefined&&now-this.lastAt[kind]<gap)return;this.lastAt[kind]=now;this.lastEvent=kind;
|
||||||
|
const cues={move:[[523*Math.pow(2,(Math.max(1,step)-1)/12),.07,0]],correct:[[659,.09,0],[880,.14,.075]],error:[[330,.10,0],[247,.15,.09]],undo:[[587,.07,0],[440,.10,.055]],restart:[[523,.075,0],[392,.09,.065],[523,.11,.14]],complete:[[523,.12,0],[659,.12,.10],[784,.14,.20],[1046,.25,.32]]};
|
||||||
|
for(const [frequency,duration,offset]of cues[kind]||[]){try{const start=now+delay+offset,osc=ctx.createOscillator(),gain=ctx.createGain();osc.type='sine';osc.frequency.setValueAtTime(frequency,start);gain.gain.setValueAtTime(.0001,start);gain.gain.linearRampToValueAtTime(kind==='error'?.055:.075,start+.008);gain.gain.exponentialRampToValueAtTime(.0001,start+duration);osc.connect(gain);gain.connect(ctx.destination);osc.onended=()=>{osc.disconnect();gain.disconnect();};osc.start(start);osc.stop(start+duration+.015);}catch{}}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
|||||||
|
// Pure rules / geometry / solver. Each mode receives its own standalone copy.
|
||||||
|
export function units(c){return c.shape?c.shape.map(([x,y])=>[c.x+x,c.y+y]):Array.from({length:(c.w||1)*(c.h||1)},(_,i)=>[c.x+i%(c.w||1),c.y+Math.floor(i/(c.w||1))]);}
|
||||||
|
export function outlineUnits(c){
|
||||||
|
const occupied=new Set(units(c).map(p=>p.join(','))),edges=[];
|
||||||
|
for(const [x,y]of units(c))for(const [dx,dy,a,b]of [[0,-1,[x,y],[x+1,y]],[1,0,[x+1,y],[x+1,y+1]],[0,1,[x+1,y+1],[x,y+1]],[-1,0,[x,y+1],[x,y]]])if(!occupied.has((x+dx)+','+(y+dy)))edges.push([a,b]);
|
||||||
|
const next=new Map(edges.map(([a,b])=>[a.join(','),b])),start=edges[0][0],points=[start];let at=next.get(start.join(','));
|
||||||
|
while(at.join(',')!==start.join(',')){points.push(at);at=next.get(at.join(','));if(points.length>edges.length)throw Error('Invalid block contour');}
|
||||||
|
return points.filter((b,i)=>{const a=points[(i+points.length-1)%points.length],d=points[(i+1)%points.length];return (b[0]-a[0])*(d[1]-b[1])!==(b[1]-a[1])*(d[0]-b[0]);});
|
||||||
|
}
|
||||||
|
export function graph(level){
|
||||||
|
const cells=level.cells, ids=new Map(cells.map((c,i)=>[c.id,i]));
|
||||||
|
if(ids.size!==cells.length)throw Error('重复格 id');
|
||||||
|
const adj=cells.map(()=>[]), occupancy=new Map();
|
||||||
|
if(level.geometry!=='hex') for(let i=0;i<cells.length;i++)for(const [x,y]of units(cells[i])){const k=x+','+y;if(occupancy.has(k))throw Error('拼块重叠');occupancy.set(k,i);}
|
||||||
|
for(let i=0;i<cells.length;i++)for(let j=i+1;j<cells.length;j++){
|
||||||
|
let connected=false;
|
||||||
|
if(level.geometry==='hex'){let dq=cells[j].q-cells[i].q,dr=cells[j].r-cells[i].r;connected=[[1,0],[-1,0],[0,1],[0,-1],[1,-1],[-1,1]].some(([a,b])=>a===dq&&b===dr);}
|
||||||
|
else connected=units(cells[i]).some(([x,y])=>[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>occupancy.get((x+a)+','+(y+b))===j));
|
||||||
|
if(connected){adj[i].push(j);adj[j].push(i);}
|
||||||
|
}
|
||||||
|
const clues=new Map();for(const c of cells)if(c.clue!==undefined){if(!Number.isInteger(c.clue)||c.clue<1||c.clue>cells.length||clues.has(c.clue))throw Error('数字范围或重复');clues.set(c.clue,ids.get(c.id));}
|
||||||
|
return {cells,ids,adj,clues};
|
||||||
|
}
|
||||||
|
export function constraints(level){
|
||||||
|
const result=[];
|
||||||
|
for(let i=0;i<(level.relations||[]).length;i++)result.push({key:'relation:'+i,kind:'relation',...level.relations[i]});
|
||||||
|
for(let i=0;i<(level.orderChains||[]).length;i++)for(let j=0;j<level.orderChains[i].length-1;j++)result.push({key:'order:'+i+':'+j,kind:'order',from:level.orderChains[i][j],to:level.orderChains[i][j+1]});
|
||||||
|
for(let i=0;i<(level.windows||[]).length;i++)result.push({key:'window:'+i,kind:'window',...level.windows[i]});
|
||||||
|
for(const c of level.cells)if(c.parity)result.push({key:'parity:'+c.id,kind:'parity',cell:c.id,value:c.parity});
|
||||||
|
for(const c of level.cells)if(c.direction)result.push({key:'direction:'+c.id,kind:'direction',cell:c.id,value:c.direction});
|
||||||
|
if((level.features||[]).includes('alternation'))result.push({key:'alternation',kind:'alternation'});
|
||||||
|
for(let i=0;i<(level.walls||[]).length;i++)result.push({key:'wall:'+i,kind:'wall',edge:level.walls[i]});
|
||||||
|
for(let i=0;i<(level.doors||[]).length;i++)result.push({key:'door:'+i,kind:'door',...level.doors[i]});
|
||||||
|
for(let i=0;i<(level.gates||[]).length;i++)result.push({key:'gate:'+i,kind:'gate',...level.gates[i]});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export function sideBetween(level,cellId,neighborId){
|
||||||
|
const c=level.cells.find(c=>c.id===cellId),n=level.cells.find(c=>c.id===neighborId);const occupied=new Set(units(n).map(p=>p.join(',')));const sides=new Set();for(const [x,y]of units(c))for(const [dx,dy,s]of [[-1,0,'l'],[1,0,'r'],[0,-1,'u'],[0,1,'d']])if(occupied.has((x+dx)+','+(y+dy)))sides.add(s);return sides.size===1?[...sides][0]:null;
|
||||||
|
}
|
||||||
|
export function directionAt(level,path,k){if(k===0||k===path.length-1)return null;const a=sideBetween(level,path[k],path[k-1]),b=sideBetween(level,path[k],path[k+1]);return a&&b?[a,b].sort().join(''):null;}
|
||||||
|
export function doorLatched(door,path){
|
||||||
|
for(let i=1;i<path.length;i++)if(door.edge.includes(path[i-1])&&door.edge.includes(path[i])&&(Boolean(door.initialOpen)!==Boolean(i%2)))return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
export function doorOpen(door,path){return Array.isArray(path)?doorLatched(door,path)||(Boolean(door.initialOpen)!==Boolean(path.length%2)):Boolean(door.initialOpen)!==Boolean(path%2);}
|
||||||
|
export const directionNames={lr:'左右直行',du:'上下直行',ru:'上与右拐角',dr:'右与下拐角',dl:'下与左拐角',lu:'左与上拐角'};
|
||||||
|
export function edgeBlocked(level,path,a,b,{omit=null}={}){
|
||||||
|
for(let i=0;i<(level.walls||[]).length;i++)if(omit!=='wall:'+i&&level.walls[i].includes(a)&&level.walls[i].includes(b))return true;
|
||||||
|
for(let i=0;i<(level.doors||[]).length;i++)if(omit!=='door:'+i&&level.doors[i].edge.includes(a)&&level.doors[i].edge.includes(b)&&!doorOpen(level.doors[i],path))return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
export function violations(level,path,{omit=null,complete=path.length===level.cells.length,ignoreClues=false}={}){
|
||||||
|
const positions=new Map(path.map((id,i)=>[id,i+1])),bad=[];
|
||||||
|
if(!ignoreClues)for(const c of level.cells)if(c.clue!==undefined){const at=positions.get(c.id);if(at!==undefined&&at!==c.clue||path.length>=c.clue&&path[c.clue-1]!==c.id)bad.push({key:'clue:'+c.id,cells:[c.id,path[c.clue-1]].filter(Boolean),message:'固定数字步号不符'});}
|
||||||
|
for(const r of constraints(level)){
|
||||||
|
if(r.key===omit)continue;
|
||||||
|
let fail=false,where=[];
|
||||||
|
if(r.kind==='relation'){
|
||||||
|
const a=positions.get(r.from),b=positions.get(r.to);where=[r.from,r.to];
|
||||||
|
fail=b!==undefined&&(a===undefined||b-a!==r.delta)||a!==undefined&&b===undefined&&path.length>=a+r.delta;
|
||||||
|
}else if(r.kind==='order'){where=[r.from,r.to];fail=positions.has(r.to)&&(!positions.has(r.from)||positions.get(r.from)>positions.get(r.to));}
|
||||||
|
else if(r.kind==='window'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined?(p<r.min||p>r.max):path.length>=r.max;}
|
||||||
|
else if(r.kind==='parity'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined&&(p%2===1)!==(r.value==='odd');}
|
||||||
|
else if(r.kind==='direction'){where=[r.cell];const p=positions.get(r.cell);fail=p===1||p!==undefined&&p<path.length&&directionAt(level,path,p-1)!==r.value||complete&&p===path.length;}
|
||||||
|
else if(r.kind==='alternation'){for(let i=1;i<path.length;i++){const a=level.cells.find(c=>c.id===path[i-1]),b=level.cells.find(c=>c.id===path[i]);if(a.category===b.category){fail=true;where.push(a.id,b.id);}}}
|
||||||
|
else if(r.kind==='wall'||r.kind==='door')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])&&(r.kind==='wall'||!doorOpen(r,path.slice(0,i)))){fail=true;where=r.edge;}}
|
||||||
|
else if(r.kind==='gate')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])){const s=positions.get(r.switchCell);if(s===undefined||s>i){fail=true;where=r.edge;}}}
|
||||||
|
if(fail)bad.push({key:r.key,cells:where,message:r.kind==='relation'?'相对步差不符':r.kind==='order'?'标记次序不符':r.kind==='window'?'到达窗口不符':r.kind==='parity'?'该格步号奇偶不符':r.kind==='direction'?'进出方向不符':r.kind==='wall'?'永久墙不能穿过':r.kind==='door'?'这一步门是关闭的':r.kind==='gate'?'开关尚未打开此门':'蓝橙颜色必须交替'});
|
||||||
|
}
|
||||||
|
return bad;
|
||||||
|
}
|
||||||
|
export function canAppend(level,path,id){
|
||||||
|
const g=graph(level);if(path.includes(id)||!g.ids.has(id))return false;
|
||||||
|
if(!path.length)return !g.clues.has(1)||g.cells[g.clues.get(1)].id===id;
|
||||||
|
const last=path.at(-1);if(edgeBlocked(level,path,last,id))return false;if(!g.adj[g.ids.get(last)].includes(g.ids.get(id)))return false;
|
||||||
|
if((level.features||[]).includes('alternation')&&g.cells[g.ids.get(last)].category===g.cells[g.ids.get(id)].category)return false;
|
||||||
|
for(const gate of level.gates||[])if(gate.edge.includes(last)&&gate.edge.includes(id)&&!path.includes(gate.switchCell))return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function solve(level,{limit=2,omit=null,ignoreClues=false,prune=true,nodeLimit=Infinity}={}){
|
||||||
|
const g=graph(level),n=g.cells.length,results=[],path=[],seen=new Set();let nodes=0;
|
||||||
|
const active=constraints(level).filter(r=>r.key!==omit),alternating=active.some(r=>r.kind==='alternation');
|
||||||
|
const adj=g.adj.map((a,i)=>a.filter(j=>(!alternating||g.cells[i].category!==g.cells[j].category)&&!active.some(r=>r.kind==='wall'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id))));
|
||||||
|
const forced=ignoreClues?new Map():g.clues;
|
||||||
|
function connected(cur){const reached=new Set([cur]),queue=[cur];for(let k=0;k<queue.length;k++)for(const j of adj[queue[k]])if(!seen.has(j)&&!reached.has(j)){reached.add(j);queue.push(j);}return reached.size===n-path.length+1;}
|
||||||
|
function visit(i){if(++nodes>nodeLimit)throw Error('求解节点预算耗尽,不能视为唯一');seen.add(i);path.push(g.cells[i].id);
|
||||||
|
const k=path.length;
|
||||||
|
if(!violations(level,path,{omit,ignoreClues}).length){
|
||||||
|
if(k===n)results.push([...path]);
|
||||||
|
else if(!prune||connected(i)){
|
||||||
|
const f=forced.get(k+1);let next=adj[i].filter(j=>!seen.has(j)&&(f===undefined||j===f));
|
||||||
|
next.sort((a,b)=>adj[a].filter(x=>!seen.has(x)).length-adj[b].filter(x=>!seen.has(x)).length);
|
||||||
|
for(const j of next){if(!ignoreClues&&g.cells[j].clue!==undefined&&g.cells[j].clue!==k+1)continue;
|
||||||
|
let blocked=edgeBlocked(level,path,g.cells[i].id,g.cells[j].id,{omit});for(const r of active)if(r.kind==='gate'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id)&&!path.includes(r.switchCell))blocked=true;
|
||||||
|
if(!blocked)visit(j);if(results.length>=limit)break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.pop();seen.delete(i);
|
||||||
|
}
|
||||||
|
let starts=forced.has(1)?[forced.get(1)]:g.cells.map((_,i)=>i);
|
||||||
|
for(const i of starts){if(!ignoreClues&&g.cells[i].clue!==undefined&&g.cells[i].clue!==1)continue;visit(i);if(results.length>=limit)break;}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
export function hint(solution,path){let k=0;while(k<path.length&&path[k]===solution[k])k++;if(k<path.length)return {kind:k?'backtrack':'restart',cell:k?path[k-1]:solution[0]};return k===solution.length?{kind:'none'}:{kind:k?'next':'start',cell:solution[k]};}
|
||||||
|
export function assertStructure(level){
|
||||||
|
const g=graph(level),n=g.cells.length;if(level.features?.includes('direction')&&(level.geometry==='hex'||level.cells.some(c=>c.direction&&units(c).length!==1)))throw Error('方向标记放在方形单位格,可与矩形/异形拼块混用');if(n<3||n>40)throw Error('格数须为3–40');
|
||||||
|
if(level.geometry==='hex'){const keys=new Set();for(const c of g.cells){if(!Number.isInteger(c.q)||!Number.isInteger(c.r))throw Error('六边坐标');const key=c.q+','+c.r;if(keys.has(key))throw Error('六边重叠');keys.add(key);}}
|
||||||
|
else for(const c of g.cells){if(!Number.isInteger(c.x)||!Number.isInteger(c.y))throw Error('坐标');const u=units(c);if(!u.length||new Set(u.map(p=>p.join(','))).size!==u.length)throw Error('异形重复');const seen=new Set([u[0].join(',')]);for(let changed=true;changed;){changed=false;for(const [x,y]of u)if(!seen.has(x+','+y)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>seen.has((x+a)+','+(y+b)))){seen.add(x+','+y);changed=true;}}if(seen.size!==u.length)throw Error('异形不连通');}
|
||||||
|
const reach=new Set([0]),queue=[0];for(let k=0;k<queue.length;k++)for(const j of g.adj[queue[k]])if(!reach.has(j)){reach.add(j);queue.push(j);}if(reach.size!==n)throw Error('盘面不连通');
|
||||||
|
if((level.doors||[]).length>1)throw Error('步进门只允许一道门');
|
||||||
|
const physicalEdges=[...(level.walls||[]),...(level.doors||[]).map(d=>d.edge)].map(e=>[...e].sort().join('|'));if(new Set(physicalEdges).size!==physicalEdges.length)throw Error('墙门共边重复');
|
||||||
|
const pairs=[];
|
||||||
|
for(const r of constraints(level)){
|
||||||
|
for(const id of [r.cell,r.from,r.to,r.switchCell,...(r.edge||[])].filter(Boolean))if(!g.ids.has(id))throw Error('约束引用不存在');
|
||||||
|
if(r.kind==='relation'&&(!Number.isInteger(r.delta)||r.delta<1||r.delta>=n||r.from===r.to))throw Error('步差');
|
||||||
|
if(r.kind==='order')pairs.push([r.from,r.to]);
|
||||||
|
if(r.kind==='window'&&(!Number.isInteger(r.min)||!Number.isInteger(r.max)||r.min<1||r.max>n||r.max-r.min<2||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('窗口需允许至少两个同奇偶步号且不与数字重叠');
|
||||||
|
if(r.kind==='parity'&&(!['odd','even'].includes(r.value)||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('奇偶字段或同格数字冲突');
|
||||||
|
if(r.kind==='direction'&&!Object.keys(directionNames).includes(r.value))throw Error('方向');
|
||||||
|
if((r.kind==='wall'||r.kind==='door')&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('墙或门不在共边');
|
||||||
|
if(r.kind==='gate'&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('门不在共边');
|
||||||
|
}
|
||||||
|
if((level.gates||[]).length>3||new Set((level.gates||[]).map(r=>r.switchCell)).size!==(level.gates||[]).length)throw Error('最多三门且开关一对一');
|
||||||
|
if((level.features||[]).includes('alternation')&&g.cells.some(c=>!['circle','triangle'].includes(c.category)))throw Error('缺少类别');
|
||||||
|
function cycle(v,stack=new Set(),done=new Set()){if(stack.has(v))throw Error('先后链循环');if(done.has(v))return;stack.add(v);for(const [a,b]of pairs)if(a===v)cycle(b,stack,done);stack.delete(v);done.add(v);}for(const [a]of pairs)cycle(a);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {graph,units,sideBetween,outlineUnits,solve,violations,canAppend,hint,doorOpen,doorLatched,directionNames} from './rules.mjs';
|
||||||
|
import {CasualAudio} from './audio.mjs';
|
||||||
|
// The build embeds this module together with the local rule file and JSON.
|
||||||
|
const pack=PACK;const playLevels=(pack.playOrder||pack.levels.map(l=>l.id)).map(id=>pack.levels.find(l=>l.id===id));const total=playLevels.length;
|
||||||
|
const BOARD_FONT='ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif';
|
||||||
|
const UI_THEME={cell:'#E6E7EC',ink:'#23243A',primary:'#6558F5',bright:'#7D72FF',danger:'#E5484D',hint:'#F5A524',ring:'#DFE1EC'};let toastTimer=null;let channelViews=[];let cellViews=[];let inputKind="mouse";
|
||||||
|
const $=id=>document.getElementById(id),canvas=$('board'),ctx=canvas.getContext('2d');
|
||||||
|
let level,solution,path=[],index=0,selected='入门',polygons=[],centers=new Map(),unit=1,focus=[],drag=false,lastPoint=null,cleared=false;
|
||||||
|
const memoryKey='variant:campaign-200-r2:v1';const sound=new CasualAudio(window,localStorage,memoryKey+':sound');let completed=[];try{completed=JSON.parse(localStorage.getItem(memoryKey)||'[]');}catch{}
|
||||||
|
const labels=new Map();let started=0,firstDrawAt=null,record=null;let records=[];try{records=JSON.parse(localStorage.getItem(memoryKey+':records')||'[]');}catch{}
|
||||||
|
function saveRecord(){if(!record)return;record.reasoning=$('reasoning').value;record.elapsedSeconds=Math.round((performance.now()-started)/1000);record.completed=cleared;const copy={...record,endedAt:new Date().toISOString()};const at=records.findIndex(r=>r.attemptId===copy.attemptId);if(at>=0)records[at]=copy;else records.push(copy);try{localStorage.setItem(memoryKey+':records',JSON.stringify(records.slice(-500)));}catch{}}
|
||||||
|
function setStatus(s){$('status').textContent=s;$('status').className='toast show';if(toastTimer)clearTimeout(toastTimer);toastTimer=setTimeout(()=>{$('status').className='toast';},1800);}function showPlay(){if(level.tutorial&&!completed.includes(level.id)){$('rule-text').textContent=level.caption;$('rules-sheet').hidden=false;}$('screen-select').className='screen hidden';$('screen-play').className='screen enter';draw();}function showSelect(){saveRecord();$('screen-select').className='screen enter';$('screen-play').className='screen hidden';renderSelectors();$('status').className='toast';}
|
||||||
|
function modeMarkers(){labels.clear();let ids=[...(level.relations||[]).flatMap(r=>[r.from,r.to]),...(level.orderChains||[]).flat()];for(const id of ids)if(!labels.has(id))labels.set(id,String.fromCharCode(65+labels.size));}
|
||||||
|
function makeRecord(){return {attemptId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,8),mode:pack.mode,level:level.id,contentRevision:level.contentRevision,order:level.order,chapter:level.chapter,title:level.title,startedAt:new Date().toISOString(),thinkingBeforeFirstDrawSeconds:null,elapsedSeconds:0,firstError:null,backtracks:0,restarts:0,hints:0,completed:false,reasoning:''};}
|
||||||
|
function start(i,open=true){if(record)saveRecord();index=i;level=playLevels[i];solution=solve(level)[0];if(!solution)throw Error('没有合法路线');path=[];focus=[];cleared=false;started=performance.now();firstDrawAt=null;record=makeRecord();$('reasoning').value='';modeMarkers();$('goal').textContent=level.title;$('caption').textContent=level.caption||'先观察,再画;错了可以退回。';$('title').textContent='数字一笔';$('rule-text').textContent=rulesFor(level);try{localStorage.setItem(memoryKey+':current',level.id);}catch{}setStatus('');renderConstraints();renderSelectors();if(open)showPlay();draw();}
|
||||||
|
function renderSelectors(){
|
||||||
|
const list=$('pack-list');list.replaceChildren();for(const chapter of pack.chapters){const section=document.createElement('section');section.className='pack';const head=document.createElement('div');head.className='pack-title';const title=document.createElement('span');title.textContent=chapter.title;const count=document.createElement('span');count.className='pack-progress';const chapterLevels=playLevels.filter(l=>l.chapter===chapter.id);count.textContent=chapterLevels.filter(l=>completed.includes(l.id)).length+' / '+chapterLevels.length;head.append(title,count);const grid=document.createElement('div');grid.className='level-grid';for(const l of chapterLevels){const i=playLevels.indexOf(l),button=document.createElement('button');button.levelId=l.id;button.textContent=String(playLevels.indexOf(l)+1);button.title=l.title;button.className='level-tile'+(completed.includes(l.id)?' cleared':'')+(i===index?' current':'');button.onclick=()=>start(i);grid.append(button);}section.append(head,grid);list.append(section);}
|
||||||
|
$('position').textContent=level.chapter+'-'+(playLevels.filter(l=>l.chapter===level.chapter).indexOf(level)+1);$('level-sub').textContent=level.chapterTitle+' · 第'+(index+1)+'关'+(level.tutorial?' · 引导':'');$('progress').textContent=completed.filter(id=>playLevels.some(l=>l.id===id)).length+' / '+total;$('overall-fill').style.width=(completed.filter(id=>playLevels.some(l=>l.id===id)).length/total*100)+'%';$('btn-continue').textContent='继续 · '+level.chapter+'-'+(playLevels.filter(l=>l.chapter===level.chapter).indexOf(level)+1);$('level-progress').replaceChildren();for(const l of playLevels.filter(l=>l.chapter===level.chapter)){const seg=document.createElement('i');seg.className=(completed.includes(l.id)?'cleared ':'')+(l.id===level.id?'current':'');$('level-progress').append(seg);}
|
||||||
|
}
|
||||||
|
function rulesFor(l){const a=['每格恰好经过一次,共边才能走;固定数字是访问步号。'];if(l.geometry==='blocks')a.push('大格和异形整块只算一步。');if(l.geometry==='hex')a.push('六边形有六个共边方向。');if(l.features.includes('direction'))a.push('通道连接哪两边,就从哪两边进出,可反向;通道不能是端点。');if(l.features.includes('alternation'))a.push('蓝→橙→蓝,每一步换颜色;蓝色与橙色交替。');if(l.features.includes('parity'))a.push('奇/偶标记要求该格访问步号的奇偶,不按面积计数。');if(l.features.includes('walls'))a.push('黑黄墙永久封闭这条共边。');if(l.features.includes('rhythm-door'))a.push('门空盘关,起点也切换,每落一格切换一次;开门时通过后保持开启。回退撤销穿门那一步或重开后,恢复切换。');return a.join(' ');}
|
||||||
|
function renderConstraints(){$('constraints').replaceChildren();}
|
||||||
|
function localGeometry(){
|
||||||
|
const raw=[],center=new Map();for(const c of level.cells){if(level.geometry==='hex'){
|
||||||
|
// Rotate the axial strip into portrait orientation; adjacency remains axial.
|
||||||
|
const x=1.5*c.r,y=Math.sqrt(3)*(c.q+c.r/2);center.set(c.id,[x,y]);let p=[];for(let a=0;a<6;a++){const angle=a*Math.PI/3;p.push([x+Math.cos(angle),y+Math.sin(angle)]);}raw.push({id:c.id,p});
|
||||||
|
}else{const u=units(c);center.set(c.id,[u.reduce((s,p)=>s+p[0]+.5,0)/u.length,u.reduce((s,p)=>s+p[1]+.5,0)/u.length]);for(const [x,y]of u)raw.push({id:c.id,p:[[x,y],[x+1,y],[x+1,y+1],[x,y+1]]});}}
|
||||||
|
return {raw,center};
|
||||||
|
}
|
||||||
|
function draw(){if(!level)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;const dpr=window.devicePixelRatio||1;canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
||||||
|
const {raw,center}=localGeometry(),all=raw.flatMap(r=>r.p),xmin=Math.min(...all.map(p=>p[0])),xmax=Math.max(...all.map(p=>p[0])),ymin=Math.min(...all.map(p=>p[1])),ymax=Math.max(...all.map(p=>p[1]));unit=Math.min(level.cells.length<=6?96:110,(width-20)/(xmax-xmin),(height-20-height*.06)/(ymax-ymin));const ox=(width-unit*(xmax-xmin))/2-unit*xmin,oy=(height-unit*(ymax-ymin))/2-unit*ymin-height*.03;const tr=([x,y])=>[ox+x*unit,oy+y*unit];polygons=raw.map(r=>({...r,p:r.p.map(tr)}));centers=new Map([...center].map(([id,p])=>[id,tr(p)]));
|
||||||
|
// Place irregular-block labels inside the nearest occupied unit, as in the original view.
|
||||||
|
if(level.geometry!=='hex')for(const c of level.cells)if(c.shape){const middle=center.get(c.id);const candidates=units(c).map(([x,y])=>[x+.5,y+.5]);candidates.sort((a,b)=>Math.hypot(a[0]-middle[0],a[1]-middle[1])-Math.hypot(b[0]-middle[0],b[1]-middle[1]));centers.set(c.id,tr(candidates[0]));}
|
||||||
|
const bad=violations(level,path);if(level.tutorial&&!bad.length&&!cleared)focus=path.length?[solution[path.length]]:level.cells.filter(c=>c.direction).map(c=>c.id);const badIds=new Set(bad.flatMap(b=>b.cells));channelViews=[];cellViews=[];
|
||||||
|
for(let i=1;i<path.length;i++)drawBridge(path[i-1],path[i]);
|
||||||
|
for(const c of level.cells){const visited=path.includes(c.id),start=c.clue===1,ring=c.clue!==undefined||c.parity||c.direction;const alternate=c.category!==undefined;let fill=badIds.has(c.id)?UI_THEME.danger:alternate?(c.category==='circle'?'#64acf3':'#ffae55'):cleared?UI_THEME.bright:visited||start?UI_THEME.primary:UI_THEME.cell;const foreground=badIds.has(c.id)||!alternate&&(visited||start)?'#FFFFFF':UI_THEME.ink;const cellUnit=level.geometry==='hex'?Math.sqrt(3)*unit:unit;cellViews.push({cell:c.id,fill,error:badIds.has(c.id),category:c.category||null,cellUnit});if(focus.includes(c.id))paintCell(c,cellUnit*.008,UI_THEME.hint);if(ring)paintCell(c,cellUnit*.045,visited||start?UI_THEME.ring:UI_THEME.ink);paintCell(c,cellUnit*.05+(ring?Math.max(2,cellUnit*.05):0),fill);
|
||||||
|
const [x,y]=centers.get(c.id),step=path.indexOf(c.id)+1;let value=step||c.clue;const ratio=value===1?.6:value>=10?.46:.52,font=Math.max(14,Math.min(52,cellUnit*ratio));ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=foreground;
|
||||||
|
if(c.direction){const radius=Math.min(unit*.26,28),state=directionState(c,badIds);drawDirectionPath(c,x,y);drawChannel(c.direction,x,y,radius,state);channelViews.push({cell:c.id,kind:c.direction,center:[x,y],radius,state});}
|
||||||
|
if(value&&c.direction){const size=Math.max(10,Math.min(18,unit*.22));ctx.fillStyle=visited||start?'#FFFFFF':UI_THEME.cell;roundedRect(x+unit*.105,y-unit*.38,unit*.28,unit*.28,unit*.07);ctx.fill();ctx.fillStyle=UI_THEME.ink;ctx.font='800 '+size+'px '+BOARD_FONT;ctx.fillText(String(value),x+unit*.245,y-unit*.24);}
|
||||||
|
else if(value){ctx.font='800 '+font+'px '+BOARD_FONT;ctx.fillText(String(value),x,y+(c.direction?unit*.09:c.parity?-unit*.06:0)+font*.04);if(c.parity){ctx.font='700 '+Math.max(10,unit*.19)+'px '+BOARD_FONT;ctx.fillText(c.parity==='odd'?'奇':'偶',x,y+unit*.27);}if(c.clue!==undefined&&step&&step!==c.clue){ctx.font='800 '+Math.max(10,unit*.19)+'px '+BOARD_FONT;ctx.fillText('需'+c.clue,x,y-unit*.3);}}
|
||||||
|
else if(c.parity){ctx.font='700 '+Math.max(16,unit*.44)+'px '+BOARD_FONT;ctx.fillText(c.parity==='odd'?'奇':'偶',x,y+unit*.015);}
|
||||||
|
}
|
||||||
|
for(const edge of level.walls||[])drawBarrier(edge,'wall');for(const d of level.doors||[])drawBarrier(d.edge,doorLatched(d,path)?'latched':doorOpen(d,path)?'open':'closed');
|
||||||
|
updateTutorial(bad);$('remaining-n').textContent=cleared?'完成':String(level.cells.length-path.length);$('remaining').className='remaining'+(cleared?' done':'');if(level.doors?.length)renderConstraints();if(bad.length)setStatus(bad[0].message+',沿来时路逐格退回。');$('undo').disabled=!path.length;$('next').hidden=!cleared||index===playLevels.length-1;
|
||||||
|
}
|
||||||
|
function roundedRect(x,y,w,h,r){r=Math.min(r,w/2,h/2);ctx.beginPath();ctx.moveTo(x+r,y);ctx.lineTo(x+w-r,y);ctx.quadraticCurveTo(x+w,y,x+w,y+r);ctx.lineTo(x+w,y+h-r);ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);ctx.lineTo(x+r,y+h);ctx.quadraticCurveTo(x,y+h,x,y+h-r);ctx.lineTo(x,y+r);ctx.quadraticCurveTo(x,y,x+r,y);ctx.closePath();}
|
||||||
|
function paintCell(c,inset,colour){ctx.fillStyle=colour;if(level.geometry==='hex'){const p=polygons.find(p=>p.id===c.id).p,[cx,cy]=centers.get(c.id),scale=1-inset/(unit*.86);const pts=p.map(([x,y])=>[cx+(x-cx)*scale,cy+(y-cy)*scale]);ctx.beginPath();for(let i=0;i<6;i++){const a=pts[(i+5)%6],b=pts[i],d=pts[(i+1)%6];const radius=Math.max(2,Math.sqrt(3)*unit*.18-inset*.2),tangent=Math.min(radius/Math.sqrt(3),Math.hypot(a[0]-b[0],a[1]-b[1])*.3);const toward=p=>{const length=Math.hypot(p[0]-b[0],p[1]-b[1]);return [b[0]+(p[0]-b[0])*tangent/length,b[1]+(p[1]-b[1])*tangent/length];};const before=toward(a),after=toward(d);if(i===0)ctx.moveTo(...before);else ctx.lineTo(...before);ctx.quadraticCurveTo(...b,...after);}ctx.closePath();ctx.fill();return;}
|
||||||
|
const polygonsHere=polygons.filter(p=>p.id===c.id),u=units(c),origin=[polygonsHere[0].p[0][0]-u[0][0]*unit,polygonsHere[0].p[0][1]-u[0][1]*unit],radius=Math.max(2,unit*.18-inset*.2);
|
||||||
|
if(!c.shape){roundedRect(origin[0]+c.x*unit+inset,origin[1]+c.y*unit+inset,c.w*unit-inset*2,c.h*unit-inset*2,radius);ctx.fill();return;}
|
||||||
|
const boundary=outlineUnits(c).map(([x,y])=>[origin[0]+x*unit,origin[1]+y*unit]);
|
||||||
|
const insetPoints=boundary.map((b,i)=>{const a=boundary[(i+boundary.length-1)%boundary.length],d=boundary[(i+1)%boundary.length];const n=([x,y],[xx,yy])=>{const len=Math.hypot(xx-x,yy-y);return [-(yy-y)/len,(xx-x)/len];};const n1=n(a,b),n2=n(b,d);return [b[0]+(n1[0]+n2[0])*inset,b[1]+(n1[1]+n2[1])*inset];});
|
||||||
|
ctx.beginPath();for(let i=0;i<insetPoints.length;i++){const a=insetPoints[(i+insetPoints.length-1)%insetPoints.length],b=insetPoints[i],d=insetPoints[(i+1)%insetPoints.length];const cross=(b[0]-a[0])*(d[1]-b[1])-(b[1]-a[1])*(d[0]-b[0]);const r=cross>0?Math.min(radius,Math.hypot(b[0]-a[0],b[1]-a[1])/2,Math.hypot(d[0]-b[0],d[1]-b[1])/2):0;const toward=(p)=>{const len=Math.hypot(p[0]-b[0],p[1]-b[1]);return [b[0]+(p[0]-b[0])*r/len,b[1]+(p[1]-b[1])*r/len];};const before=toward(a),after=toward(d);if(i===0)ctx.moveTo(...before);else ctx.lineTo(...before);ctx.quadraticCurveTo(...b,...after);}ctx.closePath();ctx.fill();}
|
||||||
|
|
||||||
|
function drawBridge(a,b){const segments=sharedSegments(a,b);if(!segments.length)return;const [p,q]=segments[0],cx=(p[0]+q[0])/2,cy=(p[1]+q[1])/2,len=Math.hypot(q[0]-p[0],q[1]-p[1]),nx=-(q[1]-p[1])/len,ny=(q[0]-p[0])/len;ctx.beginPath();ctx.moveTo(cx-nx*unit*.09,cy-ny*unit*.09);ctx.lineTo(cx+nx*unit*.09,cy+ny*unit*.09);ctx.lineWidth=(level.geometry==='hex'?Math.sqrt(3)*unit:unit)*.46;ctx.lineCap='butt';ctx.strokeStyle=cleared?UI_THEME.bright:UI_THEME.primary;ctx.stroke();}
|
||||||
|
function sharedSegments(a,b){const edges=[];for(const p of polygons.filter(p=>p.id===a))for(const q of polygons.filter(p=>p.id===b))for(let i=0;i<p.p.length;i++)for(let j=0;j<q.p.length;j++){const x=p.p[i],y=p.p[(i+1)%p.p.length],v=q.p[j],w=q.p[(j+1)%q.p.length];if(Math.hypot(x[0]-w[0],x[1]-w[1])<.01&&Math.hypot(y[0]-v[0],y[1]-v[1])<.01)edges.push([x,y]);}return edges;}
|
||||||
|
function sideOf(c,id){return sideBetween(level,c.id,id);}
|
||||||
|
function directionState(c,badIds=new Set()){const k=path.indexOf(c.id);if(k<0)return 'idle';if(k===0||badIds.has(c.id))return 'error';const entry=sideOf(c,path[k-1]);if(!c.direction.includes(entry))return 'error';if(k<path.length-1){const exit=sideOf(c,path[k+1]);if([entry,exit].sort().join('')!==c.direction)return 'error';}return 'correct';}
|
||||||
|
function drawDirectionPath(c,x,y){const k=path.indexOf(c.id);if(k<0)return;const vectors={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};ctx.beginPath();if(k>0){const [dx,dy]=(vectors[sideOf(c,path[k-1])]||[0,0]);ctx.moveTo(x+dx*unit*.44,y+dy*unit*.44);ctx.lineTo(x,y);}else ctx.moveTo(x,y);if(k<path.length-1){const [dx,dy]=(vectors[sideOf(c,path[k+1])]||[0,0]);ctx.lineTo(x+dx*unit*.44,y+dy*unit*.44);}ctx.lineCap='round';ctx.lineJoin='round';ctx.lineWidth=unit*.14;ctx.strokeStyle='#BEB8FF';ctx.stroke();}
|
||||||
|
function drawChannel(kind,x,y,r,state='idle'){const vector={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};const [a,b]=kind.split('').map(k=>vector[k]);ctx.beginPath();ctx.moveTo(x+a[0]*r,y+a[1]*r);ctx.lineTo(x,y);ctx.lineTo(x+b[0]*r,y+b[1]*r);ctx.lineCap='round';ctx.lineJoin='round';ctx.lineWidth=10;ctx.strokeStyle=state==='correct'?'#C8C2FF':state==='error'?'#FFD5D7':'#DFE1EC';ctx.stroke();ctx.lineWidth=5;ctx.strokeStyle=state==='correct'?'#FFFFFF':state==='error'?'#E5484D':UI_THEME.ink;ctx.stroke();}
|
||||||
|
function drawBarrier(edge,kind){const isOpen=kind==='open'||kind==='latched';const left=polygons.filter(p=>p.id===edge[0]),right=polygons.filter(p=>p.id===edge[1]);let commons=[];for(const a of left)for(const b of right)for(let i=0;i<a.p.length;i++)for(let j=0;j<b.p.length;j++){const p=a.p[i],q=a.p[(i+1)%a.p.length],r=b.p[j],t=b.p[(j+1)%b.p.length];if(Math.hypot(p[0]-t[0],p[1]-t[1])<.01&&Math.hypot(q[0]-r[0],q[1]-r[1])<.01)commons.push([p,q]);}for(const common of commons){const [a,b]=common;const point=f=>[a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f];ctx.lineCap='butt';ctx.lineWidth=9;ctx.strokeStyle=isOpen?'#23895f':kind==='wall'?'#272638':'#aa4d25';ctx.beginPath();if(isOpen){ctx.moveTo(...point(.05));ctx.lineTo(...point(.26));ctx.moveTo(...point(.74));ctx.lineTo(...point(.95));}else{ctx.moveTo(...point(.05));ctx.lineTo(...point(.95));}ctx.stroke();if(kind==='wall'){ctx.lineWidth=4;ctx.strokeStyle='#ffc542';ctx.setLineDash([5,4]);ctx.beginPath();ctx.moveTo(...point(.05));ctx.lineTo(...point(.95));ctx.stroke();ctx.setLineDash([]);}if(kind!=='wall'){const [x,y]=point(.5);ctx.font='700 '+Math.max(10,Math.min(12,unit*.3))+'px '+BOARD_FONT;ctx.textAlign='center';ctx.fillStyle=isOpen?'#12623f':'#993b1e';ctx.fillText(kind==='latched'?'已过':isOpen?'开':'关',x+10,y-8);}}}
|
||||||
|
function inside(x,y,p){let hit=false;for(let i=0,j=p.length-1;i<p.length;j=i++){const [a,b]=p[i],[c,d]=p[j];if((b>y)!==(d>y)&&x<(c-a)*(y-b)/(d-b)+a)hit=!hit;}return hit;}
|
||||||
|
function hit(x,y){return polygons.find(s=>{if(level.geometry!=='hex')return inside(x,y,s.p);const [cx,cy]=centers.get(s.id),scale=inputKind==='touch'? .92:.86;return inside(x,y,s.p.map(([px,py])=>[cx+(px-cx)*scale,cy+(py-cy)*scale]));})?.id;}
|
||||||
|
function markError(id,message){sound.play('error');if(!record.firstError)record.firstError={step:path.length+1,cell:id,message};setStatus(message);}
|
||||||
|
function advance(id){if(!id||cleared)return;const at=path.indexOf(id);if(at!==-1){if(path.length>=2&&at===path.length-2){record.backtracks++;path.pop();sound.play('undo');focus=[];setStatus('已回退,继续观察剩余格。');draw();}return;}
|
||||||
|
if(!canAppend(level,path,id)){const g=graph(level),adjacent=path.length&&g.adj[g.ids.get(path.at(-1))].includes(g.ids.get(id));if(adjacent||!path.length){markError(id,path.length?(level.walls?.some(e=>e.includes(path.at(-1))&&e.includes(id))?'这是永久墙,不能穿过。':level.doors?.some(d=>d.edge.includes(path.at(-1))&&d.edge.includes(id))?'门现在关闭;绕行落格后会切换。':'同色格不能连续经过,请换另一种颜色。'):'请从数字 1 开始。');}return;}
|
||||||
|
if(firstDrawAt===null){firstDrawAt=performance.now();record.thinkingBeforeFirstDrawSeconds=Math.round((firstDrawAt-started)/1000);}path.push(id);focus=[];const bad=violations(level,path);if(bad.length)markError(id,bad[0].message);else{const cell=level.cells.find(c=>c.id===id);sound.play(cell.clue!==undefined&&cell.clue===path.length?'correct':'move',0,path.length);setStatus('');}if(path.length===level.cells.length&&!bad.length){cleared=true;record.completed=true;record.elapsedSeconds=Math.round((performance.now()-started)/1000);if(!completed.includes(level.id)){completed.push(level.id);try{localStorage.setItem(memoryKey,JSON.stringify(completed));}catch{}}setStatus(index===playLevels.length-1?total+'关完成!':'完成!');sound.play('complete',.23);saveRecord();renderSelectors();}draw();}
|
||||||
|
function point(e){const r=canvas.getBoundingClientRect();return [e.clientX-r.left,e.clientY-r.top];}
|
||||||
|
canvas.addEventListener('pointerdown',e=>{e.preventDefault();sound.unlock();inputKind=e.pointerType||'mouse';drag=true;canvas.setPointerCapture(e.pointerId);lastPoint=point(e);advance(hit(...lastPoint));});
|
||||||
|
canvas.addEventListener('pointermove',e=>{if(!drag)return;e.preventDefault();const p=point(e),a=lastPoint||p,d=Math.hypot(p[0]-a[0],p[1]-a[1]),steps=Math.ceil(d/Math.max(3,unit*.1));for(let i=1;i<=steps;i++)advance(hit(a[0]+(p[0]-a[0])*i/steps,a[1]+(p[1]-a[1])*i/steps));lastPoint=p;});
|
||||||
|
function end(){drag=false;lastPoint=null;}canvas.addEventListener('pointerup',end);canvas.addEventListener('pointercancel',end);
|
||||||
|
$('undo').onclick=()=>{sound.unlock();if(path.length){path.pop();sound.play('undo');record.backtracks++;record.completed=false;cleared=false;focus=[];setStatus('已撤销一步。');draw();}};
|
||||||
|
$('restart').onclick=()=>{sound.unlock();sound.play('restart');path=[];cleared=false;focus=[];record.restarts++;record.completed=false;setStatus('已重开。');draw();};
|
||||||
|
$('hint').onclick=()=>{record.hints++;const h=hint(solution,path);focus=h.kind==='none'?[]:[h.cell];setStatus(h.kind==='backtrack'?'请沿来时路逐格退回黄色边框格,再继续。':h.kind==='restart'?'起点需要调整,请重开并从黄色边框格开始。':h.kind==='none'?'已经完成。':'下一步在黄色边框格;提示不会自动画路。');draw();};
|
||||||
|
$('next').onclick=()=>{if(index+1<playLevels.length)start(index+1);};
|
||||||
|
$('export').onclick=()=>{saveRecord();const payload={version:pack.version,exportedAt:new Date().toISOString(),clearedIds:completed,records};const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='NumberSwipe-DEMO200-试玩记录.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);setStatus('已导出全部试玩记录。');};
|
||||||
|
window.addEventListener('pagehide',saveRecord);
|
||||||
|
$('btn-back').onclick=showSelect;$('btn-continue').onclick=showPlay;$('btn-rules').onclick=()=>{$('rules-sheet').hidden=false;};$('close-rules').onclick=()=>{$('rules-sheet').hidden=true;};$('btn-feedback').onclick=()=>{$('feedback-sheet').hidden=false;};$('close-feedback').onclick=()=>{saveRecord();$('feedback-sheet').hidden=true;};
|
||||||
|
window.addEventListener('resize',draw);window.visualViewport?.addEventListener('resize',draw);window.addEventListener('orientationchange',draw);let initialId=null;try{initialId=localStorage.getItem(memoryKey+':current');}catch{}let initial=playLevels.findIndex(l=>l.id===initialId);start(initial>=0?initial:0,false);
|
||||||
|
function renderSoundButton(){$('btn-sound').textContent=sound.enabled?'音效开':'音效关';$('btn-sound').title=sound.enabled?'关闭音效':'开启音效';$('btn-sound').setAttribute?.('aria-pressed',String(sound.enabled));}
|
||||||
|
$('btn-sound').onclick=()=>{sound.toggle();renderSoundButton();};renderSoundButton();
|
||||||
|
function updateTutorial(bad){if(!level.tutorial)return;let text=level.caption;if(bad.length)text='规则不符,请沿来时路退回上一格再试。';else if(cleared)text='学会了!接下来试试正式关卡。';else if(level.tutorial==='colour')text=path.length===1?'下一步去橙色块;同色蓝块不能连续走。':path.length===2?'再换回蓝色,完成蓝 → 橙 → 蓝。':level.caption;else if(['straight','vertical','corner'].includes(level.tutorial)){const c=level.cells.find(c=>c.direction);const at=path.indexOf(c.id);if(at>=0&&at===path.length-1)text=level.tutorial==='straight'?'沿横线从另一侧离开,不能拐弯。':level.tutorial==='vertical'?'沿竖线从另一侧离开,不能向左右走。':'沿图中拐角连接的另一侧离开。';}$('caption').textContent=text;}
|
||||||
|
// Read-only snapshot used for interaction verification; never supplies an answer.
|
||||||
|
window.variantSnapshot=()=>({mode:pack.mode,level:level.id,order:level.order,displayOrder:index+1,tutorial:level.tutorial||null,soundEnabled:sound.enabled,lastSound:sound.lastEvent,chapter:level.chapter,records:records.length,cellViews,geometry:level.geometry,directionMarkers:channelViews,screen:$('screen-play').className.includes('hidden')?'select':'play',path:[...path],violations:violations(level,path),cleared,centers:Object.fromEntries(centers),unit,openedGates:(level.gates||[]).map(g=>path.includes(g.switchCell)),doors:(level.doors||[]).map(d=>doorOpen(d,path)),latchedDoors:(level.doors||[]).map(d=>doorLatched(d,path))});
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user