108 changed files with 2311 additions and 340 deletions
@@ -0,0 +1,17 @@
# iOS完成音播放中立即切关:诊断与修订
## 已知事实
用户在iOS微信小游戏中发现:通关短音乐和彩带仍播放时立即点击下一关会停顿,关闭音效则不卡。卡顿不应描述为单独的通关动画启动卡顿。
旧调用链为:下一关按钮→上游start适配入口→feedback.reset()→CasualAudio.stop()。stop先调用finishComplete解除BGM压低,再停止四个音效通道,其中包括正在播放完成WAV的第四路。
qa/jingle-next-trace.mjs以dc83712的真实游戏包和当前游戏包驱动同一个按钮流程,对音频接口做指令计数。旧包对活动完成通道调用stop为1次,切关后BGM为0.119;新版为0次,完成音src保持不变,切关后BGM仍为0.02975,待自然结束恢复0.119。这说明强制中断路径确实存在,并被移除;它不是对iOS底层stop耗时的直接测量。
## 修订
反馈reset增加显式preserveComplete选项,仅换关/重开使用;后台、静音、音频中断仍硬停止。完成音结束回调按具体完成播放对象的身份保护,独立于关卡输入事件serial。新关卡声音在完成音期间使用其余三路,避免节点或UI复用第四路时再次触发stop。未增加音效播放器数量,未改动WAV资源或A3绘制。
## 验收边界
自动化覆盖跨关续播、完成音自然结束后BGM恢复、连续完成与过期回调、UI/节点抢占、静音、后台和播放异常。iOS真机仍需重新验证“短音乐播放期间立刻点下一关”,不能凭这份调用链报告宣称真机帧率已达标。
@@ -0,0 +1,79 @@
{
"id": "hint-soft-ribbon-a3-bright",
"status": "production",
"units": "logical-canvas-pixels",
"body": {
"widthRatio": 0.235,
"widthMin": 7,
"widthMax": 26,
"cornerRadiusRatio": 0.2,
"edgeOuterExtra": 5,
"edgeInnerExtra": 2,
"edgeOuter": "rgba(255,191,126,0.10)",
"edgeInner": "rgba(255,199,140,0.18)",
"gradient": [
[
0,
"rgba(255,196,136,0.78)"
],
[
0.55,
"rgba(255,185,126,0.72)"
],
[
1,
"rgba(255,196,139,0.65)"
]
],
"crownWidthRatio": 0.76,
"crownOffset": [
-0.8,
-1.2
],
"crownColor": "rgba(255,240,203,0.24)",
"shadowColor": "rgba(197,122,53,0.09)",
"shadowBlur": 5,
"shadowY": 2
},
"highlight": {
"count": 1,
"speed": 60,
"minCycleMs": 1800,
"radiusRatioToWidth": 0.62,
"stretchAlongPath": 2.2,
"gradient": [
[
0,
"rgba(255,255,245,0.90)"
],
[
0.5,
"rgba(255,251,225,0.48)"
],
[
1,
"rgba(255,251,225,0)"
]
]
},
"endpointFade": {
"radiusRatio": 0.39,
"opaqueEraseUntil": 0.6
},
"target": {
"blur": 15,
"shadowColor": "rgba(250,194,99,0.70)",
"faceColor": "rgba(255,233,166,0.12)",
"tintCenterColor": "rgba(255,235,175,0.36)",
"tintRadiusRatio": 0.8,
"breathCycleMs": 2200,
"breathMin": 0.65,
"breathMax": 1
},
"constraints": {
"noArrows": true,
"noBracket": true,
"noTargetRing": true
},
"keepoutFeather": 3
}
@@ -0,0 +1,79 @@
{
"id": "hint-soft-ribbon-a3",
"status": "design-handoff-not-production",
"units": "logical-canvas-pixels",
"body": {
"widthRatio": 0.235,
"widthMin": 7,
"widthMax": 26,
"cornerRadiusRatio": 0.2,
"edgeOuterExtra": 5,
"edgeInnerExtra": 2,
"edgeOuter": "rgba(245,163,102,0.10)",
"edgeInner": "rgba(247,171,112,0.16)",
"gradient": [
[
0,
"rgba(251,182,123,0.72)"
],
[
0.55,
"rgba(249,169,115,0.62)"
],
[
1,
"rgba(247,180,120,0.52)"
]
],
"crownWidthRatio": 0.76,
"crownOffset": [
-0.8,
-1.2
],
"crownColor": "rgba(255,224,180,0.16)",
"shadowColor": "rgba(197,122,53,0.15)",
"shadowBlur": 5,
"shadowY": 2
},
"highlight": {
"count": 1,
"speed": 60,
"minCycleMs": 1800,
"radiusRatioToWidth": 0.62,
"stretchAlongPath": 2.2,
"gradient": [
[
0,
"rgba(255,255,233,0.82)"
],
[
0.5,
"rgba(255,248,214,0.42)"
],
[
1,
"rgba(255,248,214,0)"
]
]
},
"endpointFade": {
"radiusRatio": 0.39,
"opaqueEraseUntil": 0.6
},
"target": {
"blur": 15,
"shadowColor": "rgba(250,194,99,0.70)",
"faceColor": "rgba(255,222,147,0.10)",
"tintCenterColor": "rgba(255,223,147,0.32)",
"tintRadiusRatio": 0.8,
"breathCycleMs": 2200,
"breathMin": 0.65,
"breathMax": 1
},
"constraints": {
"noArrows": true,
"noBracket": true,
"noTargetRing": true,
"noProductionChangeInPreview": true
}
}
+79
View File
@@ -52,5 +52,84 @@
{"name":"白字/珊瑚(仅诊断,不采用)","fg":"onPrimary","bg":"coral","minimum":null}, {"name":"白字/珊瑚(仅诊断,不采用)","fg":"onPrimary","bg":"coral","minimum":null},
{"name":"格面/舞台(仅诊断,格边承担识别)","fg":"cellFace","bg":"stage","minimum":null}, {"name":"格面/舞台(仅诊断,格边承担识别)","fg":"cellFace","bg":"stage","minimum":null},
{"name":"蓝类别面/橙类别面(仅诊断,另配形状)","fg":"categoryBlueFace","bg":"categoryOrangeFace","minimum":null} {"name":"蓝类别面/橙类别面(仅诊断,另配形状)","fg":"categoryBlueFace","bg":"categoryOrangeFace","minimum":null}
],
"hintSoftRibbon": {
"id": "hint-soft-ribbon-a3-bright",
"status": "production",
"units": "logical-canvas-pixels",
"body": {
"widthRatio": 0.235,
"widthMin": 7,
"widthMax": 26,
"cornerRadiusRatio": 0.2,
"edgeOuterExtra": 5,
"edgeInnerExtra": 2,
"edgeOuter": "rgba(255,191,126,0.10)",
"edgeInner": "rgba(255,199,140,0.18)",
"gradient": [
[
0,
"rgba(255,196,136,0.78)"
],
[
0.55,
"rgba(255,185,126,0.72)"
],
[
1,
"rgba(255,196,139,0.65)"
] ]
],
"crownWidthRatio": 0.76,
"crownOffset": [
-0.8,
-1.2
],
"crownColor": "rgba(255,240,203,0.24)",
"shadowColor": "rgba(197,122,53,0.09)",
"shadowBlur": 5,
"shadowY": 2
},
"highlight": {
"count": 1,
"speed": 60,
"minCycleMs": 1800,
"radiusRatioToWidth": 0.62,
"stretchAlongPath": 2.2,
"gradient": [
[
0,
"rgba(255,255,245,0.90)"
],
[
0.5,
"rgba(255,251,225,0.48)"
],
[
1,
"rgba(255,251,225,0)"
]
]
},
"endpointFade": {
"radiusRatio": 0.39,
"opaqueEraseUntil": 0.6
},
"target": {
"blur": 15,
"shadowColor": "rgba(250,194,99,0.70)",
"faceColor": "rgba(255,233,166,0.12)",
"tintCenterColor": "rgba(255,235,175,0.36)",
"tintRadiusRatio": 0.8,
"breathCycleMs": 2200,
"breathMin": 0.65,
"breathMax": 1
},
"constraints": {
"noArrows": true,
"noBracket": true,
"noTargetRing": true
},
"keepoutFeather": 3
}
} }
+10 -1
View File
@@ -16,7 +16,16 @@
"compileType": "game", "compileType": "game",
"simulatorPluginLibVersion": {}, "simulatorPluginLibVersion": {},
"packOptions": { "packOptions": {
"ignore": [], "ignore": [
{
"type": "folder",
"value": ".audio-sources"
},
{
"type": "folder",
"value": "wechat-campaign-200-r2/.audio-sources"
}
],
"include": [] "include": []
}, },
"isGameTourist": false, "isGameTourist": false,
@@ -0,0 +1 @@
User-selected WAV originals are retained byte-for-byte here. Runtime files in music/ are 22050 Hz 16-bit stereo PCM WAV. Completion in audio/complete.wav is 22050 Hz mono, matching the existing SFX pool. Full durations and loop spans are retained. manifest.json records both source and runtime hashes. npm run build verifies runtime files rather than resynthesizing them; it needs no ffmpeg. To re-export a loop with ffmpeg: ffmpeg -i .audio-sources/play-a.wav -map_metadata -1 -fflags +bitexact -flags:a +bitexact -ar 22050 -ac 2 -c:a pcm_s16le music/play-a.wav . Source assets are excluded from WeChat upload and runtime archives.
@@ -0,0 +1,31 @@
# 明亮教具室:正式循环编配 v1
2026-10-23。保留已确认的音色与主题;本目录是独立音乐交付,不修改任何游戏版本。
| 文件 | 用途 | 时长 | 速度与结构 |
|---|---|---|---|
| 01-menu-new-theme-loop-30s | 新菜单主题 | 30秒 | 96 BPM,12小节 |
| 02-BGM-A-sunlit-classroom-loop | 原菜单主题改为关卡 A | 67.2秒 | 保持100 BPM,28小节 |
| 03-BGM-B-soft-wood-loop | 保留木质关卡主题,淘汰原柔钢琴 A | 约65.455秒 | 保持88 BPM,24小节 |
| 04-complete-retained-3s | 保留通关短句 | 3秒 | 与通过的 demo 字节一致,不循环 |
两首关卡曲为保持原主题速度及完整乐句略超1分钟。不是简单复制30秒并淡入淡出:去除原示范的结束句,补齐完整和声周期;加入短句留白、轻微配器变化,末段属和声回到开头主和声。音符尾音与短房间反射循环跨接,无首尾淡入淡出或停顿。菜单采用新的旋律,仍沿用同一组柔和电琴、木琴和圆润低音音色。
全部是固定混音,不做落格、节点或快速操作驱动的音量变化。编曲保持中低音域、短尾音与较少和声声部,为连线 SFX 留出空间。仍需实际游戏内试听决定统一静态 BGM 音量;本交付未进行游戏内叠加或手机听感验收。
## 文件用途
- WAV:44.1kHz / 16bit / 立体声,正式循环基准文件,循环区间为整文件。
- OGG:三首循环曲的压缩版本,是否可用须由目标播放宿主确认。
- MP3:便捷试听;不同播放器对编码延迟和填充处理不同,不承诺任意播放器原生整文件循环都无缝。开发时优先使用 WAV,或确认解码后长度与循环点的压缩格式。
- arrangement-and-validation.json:完整事件、时长、帧数、峰值、RMS、边界相邻样本差。
- build-loops.cjs / render.generated.cjs:可复现源文件。直接运行后者重制 WAV/MP3;完整构建脚本依赖相邻保留的 demo 源文件,另生成 OGG 和复制通关短句。
循环检查涵盖PCM峰值、削波与跨边界连续性,不代表真实听音设备验收。通关MP3/WAV与此前版本完全一致。
## 音源与署名
旋律、编配、合成与渲染为本项目制作,非任天堂曲目改编。电琴、低音与节奏为程序合成;钢琴、木琴单音采样来自 tonejs-instruments,贡献者 N. P. Brosowsky,原始来源 Versilian Studios / VSCO 2,上游合集标注 CC BY 3.0。已进行移调、滤波、包络、声像和原创编配。正式上线保留署名与许可证信息,来源资料及本次所需采样已放入 sources/。
- https://github.com/nbrosowsky/tonejs-instruments
- https://creativecommons.org/licenses/by/3.0/
Binary file not shown.
@@ -0,0 +1,29 @@
{
"select": {
"sourceSHA256": "49c981ad32a632d0358d2681dbec3e96f2bf5d4a504808d592b531855dd0e812",
"output": "music/select.wav",
"sha256": "9678b61b466f6fc74e52742e4bf7b4f29c8eb8bc0c254e5acb864a0f5829159c",
"sampleRate": 22050,
"channels": 2,
"bytes": 2646044,
"seconds": 30
},
"play-a": {
"sourceSHA256": "701db5c91cf92ddf77901bd251a0f705fd98b6b46b25b35e0290d995d61d0093",
"output": "music/play-a.wav",
"sha256": "8babe95ed870257a0e64f0720455cd80935b88e3f53bbdedc53a444708b28a85",
"sampleRate": 22050,
"channels": 2,
"bytes": 5927084,
"seconds": 67.2
},
"complete": {
"sourceSHA256": "535ba25326c9ee2c70ccfa117ef22af389db37b86ba7f1a4b9d42ba8453ed27f",
"output": "audio/complete.wav",
"sha256": "84db2b1332d7d9e2ed83cada331be7df8774c1195399296fdead409d93180afa",
"sampleRate": 22050,
"channels": 1,
"bytes": 132344,
"seconds": 3
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+99
View File
@@ -1,3 +1,102 @@
## 2026-10-23:过关音乐跨关续播与iOS切关排查
版本2026-10-23-jingle-through-next,基于dc83712(已发布A3,包含6abbc37与8f2263b)。独立包:../releases/NumberSwipe-main-A3过关音乐跨关续播-20261023.zip。保留旧发布包和解压目录,不上传、不推送。
- 用户真机对照:iOS中完成音/彩带播放期间立即点击下一关会卡一下;关闭音效后不卡。旧代码start→feedback.reset→audio.stop会向正在播放的完成音发出stop,并立刻finishComplete→BGM恢复。实际旧/新游戏包调用链对照见qa/jingle-next-trace-20261023.json,属于原生调用指令取证,不是iOS耗时测量。
- 按用户新要求,切关只取消旧关视觉、震动和过期落格音;保留正在播的3秒过关短音乐,既不stop、不改src,也不重播。BGM继续保持基础音量25%,由这首短音乐自身的自然结束回调恢复正常。
- 完成音占用第四路期间,新关卡移动、节点、指路和按钮音效借用原有三路普通池,保持四路音效上限,不抢占完成音;保持短音乐音量0.6375、基础BGM音量0.119及音高规则。
- 普通换关和重开保留正在播放的完成音。关闭音效、后台/音频中断、播放失败仍按原规则取消;再次完成新关卡维持完成反馈优先级。旧结束回调不能把新一轮完成音的BGM提前恢复。
- 检查包含117项模块测试、200关触摸回归及旧/新调用链对照。针对用例覆盖真实下一关按钮、规则/UI/节点不抢占、自然结束恢复、后台与静音、连续完成和异常分支。日志见qa/jingle-next-check-20261023.txt。
- 本版已移除“点下一关时停止正在播的完成音”的原生调用,但尚无本机iOS性能追踪;不能将浏览器或测试替身的结果称为真机卡顿已消除。请用新包真机复测这一明确时机。
## 2026-10-23:A3明亮软糖指路(包含6abbc37)
版本2026-10-23-soft-ribbon-a3-bright,以6abbc37为基础,包含iOS通关早点击负载优化、奇偶节点彩带、选关/规则/设置统一音效、设置冒号和“乐”字字重修复。独立包:../releases/NumberSwipe-main-A3明亮软糖指路-20261023.zip。旧包、旧解压目录不变;不上传、不推送。
- 按用户A3交接接入Canvas程序化软糖带,不使用PNG。保留0.235带宽比例(7–26逻辑像素)、圆润接缝、0.39pitch端点消隐;移除旧箭头、细白芯、双描边和目标圈。
- 原参数对照保存在docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json;有效提亮参数在同目录hint-soft-ribbon-a3-bright.tokens.json及正式tokens.json的hintSoftRibbon命名空间。杏橙主体提亮、柔冠由低透明暖白增至0.24、棕影由0.15降至0.09;尺寸、速度和呼吸周期保持设计值。
- 一段一个宽亮斑按真实弧长流动(60px/s,最短周期1800ms)。玩家落格不重置分段时钟;只过滤已走边,使用独立带面画布source-atop,帧帧从干净静态带面开始。
- 目标取真实单元轮廓,材质/桥→目标柔光→实际数字/规则符号/墙门依次合成;目标缓慢呼吸(2200ms,0.65–1),不缩放、不复制或写死目标数字。玩家已走格及外晕在软糖和目标光两层都排除。
- 弧长、合法圆角、静态带面和目标材质/符号层缓存;支持矩形、异形、孔洞和六边形。完成段、重开、换关和后台释放旧绘制状态;不支持离屏合成时保留静态带面。
- 新增6项A3模块测试,并更新旧箭头视觉断言。200关指路填色像素一致;完整测试/200关触摸记录见qa/a3-check-20261023.txt。浏览器尺寸与样张验收入口为qa/bright-classroom.html的“A3尺寸与动画验收”。微信/iOS/Android真机帧率与混合效果仍需上传后复测。
## 2026-10-23:iOS通关早点击与界面反馈修复
版本:2026-10-23-ios-clear-ui-fix,基于0cefce4。独立新包:../releases/NumberSwipe-main-iOS通关切换与界面反馈修复-20261023.zip;旧包及旧包解压目录保持原样,不上传微信,不推送远端。
- 用户真机复现条件为iOS微信小游戏,彩带播放后立即点击下一关会卡顿,稍后点击正常。上版通关时以“clear”新键重启下一关材质预热,与动画争抢时间。本版删除重复启动,通关停止预热;点击下一关先取消旧动画帧和预热,再执行切换。
- 通关静态背景在落格动画收束后保存一次,庆祝帧只合成背景和彩带,不反复绘制全部UI。设置、规则、尺寸变化、后台及换关会使背景失效;不等待2.6秒庆祝结束即可切换。
- 奇偶节点的有效落格现在开启原有局部彩带,方向节点仍保留音效、缩放与振动;最后一格只执行完成反馈,两组节点彩带上限不变。
- 游戏内选关、规则、设置和返回游戏统一使用现有audio/ui.wav。通关最初120ms内也允许按钮音效;不改变静音偏好。
- 设置统一为音乐:开/关、音效:开/关、振动:开/关。“乐”在旧Sans字体子集中缺失,已补齐500/600/700三个实际字重并保留原字库覆盖;数字和标题字形资源保持原样。
- 验收:104项模块测试;200关触摸回归;新增通关后0/40/110/300/3000ms切关及演出背景复用、统一按钮音效和字体覆盖测试。浏览器真实Canvas20组早点击切关记录见qa/ios-clear-browser-20261023.txt。
- iOS真机待复测。浏览器不替代真机性能验收;交专门打包上传会话上传后,重点验证连续过关立即切换、静音/后台恢复和不同密度关卡。
## 2026-10-23:回退音阶与流畅切关
版本:2026-10-23-descending-undo-fast-next;基于90d83b3的BGMA连续播放与节点缩放版。仅本地开发与打包,不上传微信,不开启开发者工具服务端口,不推送远端。
- 手势回退与撤销键按被撤销步号,使用前进同音色音阶逆序播放。例如7步回退播放7、6、5、4、3、2、1,最后一步清空仍发1;重开保持原音效。快速回退与前进共用三路声音池,过密声音只保留最新音高,不补播积压。
- 过关短音乐只调整播放增益:0.75→0.6375,降低15%。普通移动、节点、重开、BGM基础音量及通关压低/恢复逻辑不改;原WAV资源不变。
- 点击下一关不再现场求解:构建时准备并验证200关完整路线,源关卡哈希记录在src/prepared-routes.json。玩家仍按原公开规则自行操作,提示机制不变。移除start/showPlay的重复棋盘绘制。
- 下一关空棋盘的材质按空闲帧逐格预热,拖动暂停、超时取消;换关、选关、后台和尺寸变化终止旧任务。预热不产生声音、振动、进度或玩家反馈,缓存仍使用原有内存上限。
- 浏览器真实Canvas抽测第91/163/192关:62.3/130.5/152.6ms降为13.1/8.4/7.8ms;数据见qa/next-transition-browser-20261023.json。此为同机浏览器数据,不视为真机帧率验收。
- 验收:101项模块测试、200关触摸回归;原始关卡与上游快照不变,旧发布包保留。手势/按钮降序音阶、音量、BGM不中断、预热生命周期与单次绘制均有回归。
包:../releases/NumberSwipe-main-回退音阶与流畅切关-20261023.zip。交专门打包上传会话处理上传。
## 2026-10-23:居中标题与指定WAV音乐
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
- 标题保持原画并水平居中;设置移到总进度、关卡范围标签同一行。小屏仍为每页15关,按钮无重叠。
- 菜单30秒循环;每次进入关卡从67.2秒A、约65.455秒B中随机选一首循环。同关重绘、移动、静音恢复及后台恢复不重选,切关再随机。
- 通关短句替换原通关音,使用交付的3秒WAV,仅播放一次;通关之前先停止BGM,下一关恢复循环。短句沿用音效开关及实际起播同步,BGM沿用音乐开关,无动态压低或叠加。
- 原始44.1kHz/16bit立体声WAV原件保留于.audio-sources并记录哈希;运行BGM适配为22.05kHz/16bit立体声WAV,通关短句为同采样率单声道,完整时长与乐句保留。没有改用MP3或OGG。npm构建校验现成WAV,不依赖ffmpeg。
- 长BGM放入music分包,异步加载期间操作不阻塞;回调只播放当前场景,静音、后台、通关及加载失败均不会迟到播放。原始来源目录已从根工程与子工程上传配置中排除;music/CREDITS.md保留音源署名。
- 验收:89项模块测试、200关触摸回归;375及320×568浏览器布局,随机选择、分包取消、前后台与音效同步均有回归。微信真机音量、循环边界听感与分包下载仍需预览验收。
包:../releases/NumberSwipe-main-居中标题与指定WAV音乐-20261023.zip。工程仍为wechat-campaign-200-r2。
## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
- 移除普通字体描边模拟;使用参考图经imagegen提取的独立透明标题美术资源images/title-reference.png,保留定制橙紫字形、圆润奶油贴纸边、倒角高光及立体厚度。资源为2132×737,等比绘制,首加载完成自动刷新。
- 选关顶部按真实图片比例预留空间,小屏缩小网格间距,继续保持每页3×5关、触摸目标至少44px,进度与范围标签不被遮挡。
- 图片资源随发布包保存;浏览器预览与模拟宿主支持原生图片加载。
- 验收:83项模块测试、200关模拟触摸;375宽及320×568浏览器画面核对。最新温暖BGM资源哈希不变。真机显示仍需微信预览确认。
包:../releases/NumberSwipe-main-参考图标题还原版-20261023.zip,保留之前版本。
## 2026-10-23:温暖无节拍关卡BGM试听版
版本:2026-10-23-warm-ambient-bgm。用户选择试用温暖、无节拍、弱旋律方向;本轮仅替换关卡音乐,选关音乐保持原文件哈希。
- 原创32秒循环:低中音区柔和和声缓慢交叠,没有鼓点、规律敲击或突出旋律;采用周期波形与平滑交叉渐变,避免循环接缝。
- PCM均方根约0.035,峰值小于0.17,关卡播放音量0.14;选关音量仍为0.16。保留音乐/音效/振动独立开关及后台、中断恢复。
- 节点确认音、通关和弦、缩放、触觉、存档、选关标题及200关规则保留。之前节点跟手与立体标题发布包保留。
- 验收:80项模块测试、200关触摸回归;生成资源的接缝、包络突变、音量及开关恢复检查。实际听感与真机扬声器/耳机效果需用户试听。
试听资源:music/play.wav。发布包:../releases/NumberSwipe-main-温暖无节拍BGM试听版-20261023.zip。
## 2026-10-23:节点反馈与立体标题
版本:2026-10-23-responsive-nodes-sticker-header。开发分支:codex/node-feedback-responsive。以当前正式主工程为基线;没有从历史副本覆盖文件。
- 修复只有固定数字触发checkpoint的问题:合法到达数字、奇偶、方向节点均使用独立checkpoint-N确认音及中震;最后一格只触发完成和弦。错误不冒充节点,也不参照答案判断。
- 所有普通落格、回退落点(撤销最后一步时为被撤销格)、错误局部提示改为90ms小回弹,峰值1.025;特殊节点90ms峰值1.065。只缩放块体表面,文字锚点、规则线和命中区域保持稳定。
- 回退和撤销加入轻震。经用户选择保留轻/中强度,把普通震动最短间隔70ms改为61ms,连续触发密度约增14.8%;不宣称单次物理强度提高15%。短震降级、关闭开关、预览禁震及后台取消继续有效。
- 原数字节点彩带的触发、范围、最多两组及1500ms时长保留。新增奇偶/方向节点使用较大缩放和确认音,不额外扩大彩带。
- 选关标题按参考图使用橙紫双色、奶油描边和叠层立体处理;进度奶油标签、关卡范围橙色标签。保持每页15关及320×568安全区域。
- 选关与关卡BGM正常保留,音乐资源、音量及独立开关本轮没有更换;音效、震动和回退不停止音乐。
验证:78项模块测试通过(含200关每一步特殊节点音效断言),200关模拟触摸回归通过;375宽、320×568浏览器排版通过。200关提示填色像素复核记录见qa。真机音效起播、振动手感与高密度帧率仍待iPhone和Android验收。
BGM待选方向:①温暖无节拍氛围,低音区软垫与缓慢和声、弱旋律(优先推荐);②稀疏毛毡钢琴,少量低中音、长留白;③柔和木质环境音,去固定节拍、极少敲击,保留教具室气质。当前16秒循环暂留,未擅自替换。
包:../releases/NumberSwipe-main-节点跟手与立体标题-20261023.zip。之前主版本包保留。
# NumberSwipe 正式主版本 # NumberSwipe 正式主版本
本目录是唯一正式微信主工程,属于 NumberSwipe_New 仓库 main 分支。2026-10-23 已将分层反馈、音乐与流动指路强化版提升为主版本。版本以 release-info.json 为准。 本目录是唯一正式微信主工程,属于 NumberSwipe_New 仓库 main 分支。2026-10-23 已将分层反馈、音乐与流动指路强化版提升为主版本。版本以 release-info.json 为准。
+99
View File
@@ -1,3 +1,102 @@
## 2026-10-23:过关音乐跨关续播与iOS切关排查
版本2026-10-23-jingle-through-next,基于dc83712(已发布A3,包含6abbc37与8f2263b)。独立包:../releases/NumberSwipe-main-A3过关音乐跨关续播-20261023.zip。保留旧发布包和解压目录,不上传、不推送。
- 用户真机对照:iOS中完成音/彩带播放期间立即点击下一关会卡一下;关闭音效后不卡。旧代码start→feedback.reset→audio.stop会向正在播放的完成音发出stop,并立刻finishComplete→BGM恢复。实际旧/新游戏包调用链对照见qa/jingle-next-trace-20261023.json,属于原生调用指令取证,不是iOS耗时测量。
- 按用户新要求,切关只取消旧关视觉、震动和过期落格音;保留正在播的3秒过关短音乐,既不stop、不改src,也不重播。BGM继续保持基础音量25%,由这首短音乐自身的自然结束回调恢复正常。
- 完成音占用第四路期间,新关卡移动、节点、指路和按钮音效借用原有三路普通池,保持四路音效上限,不抢占完成音;保持短音乐音量0.6375、基础BGM音量0.119及音高规则。
- 普通换关和重开保留正在播放的完成音。关闭音效、后台/音频中断、播放失败仍按原规则取消;再次完成新关卡维持完成反馈优先级。旧结束回调不能把新一轮完成音的BGM提前恢复。
- 检查包含117项模块测试、200关触摸回归及旧/新调用链对照。针对用例覆盖真实下一关按钮、规则/UI/节点不抢占、自然结束恢复、后台与静音、连续完成和异常分支。日志见qa/jingle-next-check-20261023.txt。
- 本版已移除“点下一关时停止正在播的完成音”的原生调用,但尚无本机iOS性能追踪;不能将浏览器或测试替身的结果称为真机卡顿已消除。请用新包真机复测这一明确时机。
## 2026-10-23:A3明亮软糖指路(包含6abbc37)
版本2026-10-23-soft-ribbon-a3-bright,以6abbc37为基础,包含iOS通关早点击负载优化、奇偶节点彩带、选关/规则/设置统一音效、设置冒号和“乐”字字重修复。独立包:../releases/NumberSwipe-main-A3明亮软糖指路-20261023.zip。旧包、旧解压目录不变;不上传、不推送。
- 按用户A3交接接入Canvas程序化软糖带,不使用PNG。保留0.235带宽比例(7–26逻辑像素)、圆润接缝、0.39pitch端点消隐;移除旧箭头、细白芯、双描边和目标圈。
- 原参数对照保存在docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json;有效提亮参数在同目录hint-soft-ribbon-a3-bright.tokens.json及正式tokens.json的hintSoftRibbon命名空间。杏橙主体提亮、柔冠由低透明暖白增至0.24、棕影由0.15降至0.09;尺寸、速度和呼吸周期保持设计值。
- 一段一个宽亮斑按真实弧长流动(60px/s,最短周期1800ms)。玩家落格不重置分段时钟;只过滤已走边,使用独立带面画布source-atop,帧帧从干净静态带面开始。
- 目标取真实单元轮廓,材质/桥→目标柔光→实际数字/规则符号/墙门依次合成;目标缓慢呼吸(2200ms,0.65–1),不缩放、不复制或写死目标数字。玩家已走格及外晕在软糖和目标光两层都排除。
- 弧长、合法圆角、静态带面和目标材质/符号层缓存;支持矩形、异形、孔洞和六边形。完成段、重开、换关和后台释放旧绘制状态;不支持离屏合成时保留静态带面。
- 新增6项A3模块测试,并更新旧箭头视觉断言。200关指路填色像素一致;完整测试/200关触摸记录见qa/a3-check-20261023.txt。浏览器尺寸与样张验收入口为qa/bright-classroom.html的“A3尺寸与动画验收”。微信/iOS/Android真机帧率与混合效果仍需上传后复测。
## 2026-10-23:iOS通关早点击与界面反馈修复
版本:2026-10-23-ios-clear-ui-fix,基于0cefce4。独立新包:../releases/NumberSwipe-main-iOS通关切换与界面反馈修复-20261023.zip;旧包及旧包解压目录保持原样,不上传微信,不推送远端。
- 用户真机复现条件为iOS微信小游戏,彩带播放后立即点击下一关会卡顿,稍后点击正常。上版通关时以“clear”新键重启下一关材质预热,与动画争抢时间。本版删除重复启动,通关停止预热;点击下一关先取消旧动画帧和预热,再执行切换。
- 通关静态背景在落格动画收束后保存一次,庆祝帧只合成背景和彩带,不反复绘制全部UI。设置、规则、尺寸变化、后台及换关会使背景失效;不等待2.6秒庆祝结束即可切换。
- 奇偶节点的有效落格现在开启原有局部彩带,方向节点仍保留音效、缩放与振动;最后一格只执行完成反馈,两组节点彩带上限不变。
- 游戏内选关、规则、设置和返回游戏统一使用现有audio/ui.wav。通关最初120ms内也允许按钮音效;不改变静音偏好。
- 设置统一为音乐:开/关、音效:开/关、振动:开/关。“乐”在旧Sans字体子集中缺失,已补齐500/600/700三个实际字重并保留原字库覆盖;数字和标题字形资源保持原样。
- 验收:104项模块测试;200关触摸回归;新增通关后0/40/110/300/3000ms切关及演出背景复用、统一按钮音效和字体覆盖测试。浏览器真实Canvas20组早点击切关记录见qa/ios-clear-browser-20261023.txt。
- iOS真机待复测。浏览器不替代真机性能验收;交专门打包上传会话上传后,重点验证连续过关立即切换、静音/后台恢复和不同密度关卡。
## 2026-10-23:回退音阶与流畅切关
版本:2026-10-23-descending-undo-fast-next;基于90d83b3的BGMA连续播放与节点缩放版。仅本地开发与打包,不上传微信,不开启开发者工具服务端口,不推送远端。
- 手势回退与撤销键按被撤销步号,使用前进同音色音阶逆序播放。例如7步回退播放7、6、5、4、3、2、1,最后一步清空仍发1;重开保持原音效。快速回退与前进共用三路声音池,过密声音只保留最新音高,不补播积压。
- 过关短音乐只调整播放增益:0.75→0.6375,降低15%。普通移动、节点、重开、BGM基础音量及通关压低/恢复逻辑不改;原WAV资源不变。
- 点击下一关不再现场求解:构建时准备并验证200关完整路线,源关卡哈希记录在src/prepared-routes.json。玩家仍按原公开规则自行操作,提示机制不变。移除start/showPlay的重复棋盘绘制。
- 下一关空棋盘的材质按空闲帧逐格预热,拖动暂停、超时取消;换关、选关、后台和尺寸变化终止旧任务。预热不产生声音、振动、进度或玩家反馈,缓存仍使用原有内存上限。
- 浏览器真实Canvas抽测第91/163/192关:62.3/130.5/152.6ms降为13.1/8.4/7.8ms;数据见qa/next-transition-browser-20261023.json。此为同机浏览器数据,不视为真机帧率验收。
- 验收:101项模块测试、200关触摸回归;原始关卡与上游快照不变,旧发布包保留。手势/按钮降序音阶、音量、BGM不中断、预热生命周期与单次绘制均有回归。
包:../releases/NumberSwipe-main-回退音阶与流畅切关-20261023.zip。交专门打包上传会话处理上传。
## 2026-10-23:居中标题与指定WAV音乐
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
- 标题保持原画并水平居中;设置移到总进度、关卡范围标签同一行。小屏仍为每页15关,按钮无重叠。
- 菜单30秒循环;每次进入关卡从67.2秒A、约65.455秒B中随机选一首循环。同关重绘、移动、静音恢复及后台恢复不重选,切关再随机。
- 通关短句替换原通关音,使用交付的3秒WAV,仅播放一次;通关之前先停止BGM,下一关恢复循环。短句沿用音效开关及实际起播同步,BGM沿用音乐开关,无动态压低或叠加。
- 原始44.1kHz/16bit立体声WAV原件保留于.audio-sources并记录哈希;运行BGM适配为22.05kHz/16bit立体声WAV,通关短句为同采样率单声道,完整时长与乐句保留。没有改用MP3或OGG。npm构建校验现成WAV,不依赖ffmpeg。
- 长BGM放入music分包,异步加载期间操作不阻塞;回调只播放当前场景,静音、后台、通关及加载失败均不会迟到播放。原始来源目录已从根工程与子工程上传配置中排除;music/CREDITS.md保留音源署名。
- 验收:89项模块测试、200关触摸回归;375及320×568浏览器布局,随机选择、分包取消、前后台与音效同步均有回归。微信真机音量、循环边界听感与分包下载仍需预览验收。
包:../releases/NumberSwipe-main-居中标题与指定WAV音乐-20261023.zip。工程仍为wechat-campaign-200-r2。
## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
- 移除普通字体描边模拟;使用参考图经imagegen提取的独立透明标题美术资源images/title-reference.png,保留定制橙紫字形、圆润奶油贴纸边、倒角高光及立体厚度。资源为2132×737,等比绘制,首加载完成自动刷新。
- 选关顶部按真实图片比例预留空间,小屏缩小网格间距,继续保持每页3×5关、触摸目标至少44px,进度与范围标签不被遮挡。
- 图片资源随发布包保存;浏览器预览与模拟宿主支持原生图片加载。
- 验收:83项模块测试、200关模拟触摸;375宽及320×568浏览器画面核对。最新温暖BGM资源哈希不变。真机显示仍需微信预览确认。
包:../releases/NumberSwipe-main-参考图标题还原版-20261023.zip,保留之前版本。
## 2026-10-23:温暖无节拍关卡BGM试听版
版本:2026-10-23-warm-ambient-bgm。用户选择试用温暖、无节拍、弱旋律方向;本轮仅替换关卡音乐,选关音乐保持原文件哈希。
- 原创32秒循环:低中音区柔和和声缓慢交叠,没有鼓点、规律敲击或突出旋律;采用周期波形与平滑交叉渐变,避免循环接缝。
- PCM均方根约0.035,峰值小于0.17,关卡播放音量0.14;选关音量仍为0.16。保留音乐/音效/振动独立开关及后台、中断恢复。
- 节点确认音、通关和弦、缩放、触觉、存档、选关标题及200关规则保留。之前节点跟手与立体标题发布包保留。
- 验收:80项模块测试、200关触摸回归;生成资源的接缝、包络突变、音量及开关恢复检查。实际听感与真机扬声器/耳机效果需用户试听。
试听资源:music/play.wav。发布包:../releases/NumberSwipe-main-温暖无节拍BGM试听版-20261023.zip。
## 2026-10-23:节点反馈与立体标题
版本:2026-10-23-responsive-nodes-sticker-header。开发分支:codex/node-feedback-responsive。以当前正式主工程为基线;没有从历史副本覆盖文件。
- 修复只有固定数字触发checkpoint的问题:合法到达数字、奇偶、方向节点均使用独立checkpoint-N确认音及中震;最后一格只触发完成和弦。错误不冒充节点,也不参照答案判断。
- 所有普通落格、回退落点(撤销最后一步时为被撤销格)、错误局部提示改为90ms小回弹,峰值1.025;特殊节点90ms峰值1.065。只缩放块体表面,文字锚点、规则线和命中区域保持稳定。
- 回退和撤销加入轻震。经用户选择保留轻/中强度,把普通震动最短间隔70ms改为61ms,连续触发密度约增14.8%;不宣称单次物理强度提高15%。短震降级、关闭开关、预览禁震及后台取消继续有效。
- 原数字节点彩带的触发、范围、最多两组及1500ms时长保留。新增奇偶/方向节点使用较大缩放和确认音,不额外扩大彩带。
- 选关标题按参考图使用橙紫双色、奶油描边和叠层立体处理;进度奶油标签、关卡范围橙色标签。保持每页15关及320×568安全区域。
- 选关与关卡BGM正常保留,音乐资源、音量及独立开关本轮没有更换;音效、震动和回退不停止音乐。
验证:78项模块测试通过(含200关每一步特殊节点音效断言),200关模拟触摸回归通过;375宽、320×568浏览器排版通过。200关提示填色像素复核记录见qa。真机音效起播、振动手感与高密度帧率仍待iPhone和Android验收。
BGM待选方向:①温暖无节拍氛围,低音区软垫与缓慢和声、弱旋律(优先推荐);②稀疏毛毡钢琴,少量低中音、长留白;③柔和木质环境音,去固定节拍、极少敲击,保留教具室气质。当前16秒循环暂留,未擅自替换。
包:../releases/NumberSwipe-main-节点跟手与立体标题-20261023.zip。之前主版本包保留。
# 历史开发记录 # 历史开发记录
2026-10-23 本版本已提升为正式 main。以下独立实验版和不替换主版的说明仅描述提升前历史;当前入口以 README.md 和 release-info.json 为准。 2026-10-23 本版本已提升为正式 main。以下独立实验版和不替换主版的说明仅描述提升前历史;当前入口以 README.md 和 release-info.json 为准。
+103
View File
@@ -1,3 +1,106 @@
## 2026-10-23:过关音乐跨关续播与iOS切关排查
版本2026-10-23-jingle-through-next,基于dc83712(已发布A3,包含6abbc37与8f2263b)。独立包:../releases/NumberSwipe-main-A3过关音乐跨关续播-20261023.zip。保留旧发布包和解压目录,不上传、不推送。
- 用户真机对照:iOS中完成音/彩带播放期间立即点击下一关会卡一下;关闭音效后不卡。旧代码start→feedback.reset→audio.stop会向正在播放的完成音发出stop,并立刻finishComplete→BGM恢复。实际旧/新游戏包调用链对照见qa/jingle-next-trace-20261023.json,属于原生调用指令取证,不是iOS耗时测量。
- 按用户新要求,切关只取消旧关视觉、震动和过期落格音;保留正在播的3秒过关短音乐,既不stop、不改src,也不重播。BGM继续保持基础音量25%,由这首短音乐自身的自然结束回调恢复正常。
- 完成音占用第四路期间,新关卡移动、节点、指路和按钮音效借用原有三路普通池,保持四路音效上限,不抢占完成音;保持短音乐音量0.6375、基础BGM音量0.119及音高规则。
- 普通换关和重开保留正在播放的完成音。关闭音效、后台/音频中断、播放失败仍按原规则取消;再次完成新关卡维持完成反馈优先级。旧结束回调不能把新一轮完成音的BGM提前恢复。
- 检查包含117项模块测试、200关触摸回归及旧/新调用链对照。针对用例覆盖真实下一关按钮、规则/UI/节点不抢占、自然结束恢复、后台与静音、连续完成和异常分支。日志见qa/jingle-next-check-20261023.txt。
- 本版已移除“点下一关时停止正在播的完成音”的原生调用,但尚无本机iOS性能追踪;不能将浏览器或测试替身的结果称为真机卡顿已消除。请用新包真机复测这一明确时机。
## 2026-10-23:A3明亮软糖指路(包含6abbc37)
版本2026-10-23-soft-ribbon-a3-bright,以6abbc37为基础,包含iOS通关早点击负载优化、奇偶节点彩带、选关/规则/设置统一音效、设置冒号和“乐”字字重修复。独立包:../releases/NumberSwipe-main-A3明亮软糖指路-20261023.zip。旧包、旧解压目录不变;不上传、不推送。
- 按用户A3交接接入Canvas程序化软糖带,不使用PNG。保留0.235带宽比例(7–26逻辑像素)、圆润接缝、0.39pitch端点消隐;移除旧箭头、细白芯、双描边和目标圈。
- 原参数对照保存在docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json;有效提亮参数在同目录hint-soft-ribbon-a3-bright.tokens.json及正式tokens.json的hintSoftRibbon命名空间。杏橙主体提亮、柔冠由低透明暖白增至0.24、棕影由0.15降至0.09;尺寸、速度和呼吸周期保持设计值。
- 一段一个宽亮斑按真实弧长流动(60px/s,最短周期1800ms)。玩家落格不重置分段时钟;只过滤已走边,使用独立带面画布source-atop,帧帧从干净静态带面开始。
- 目标取真实单元轮廓,材质/桥→目标柔光→实际数字/规则符号/墙门依次合成;目标缓慢呼吸(2200ms,0.65–1),不缩放、不复制或写死目标数字。玩家已走格及外晕在软糖和目标光两层都排除。
- 弧长、合法圆角、静态带面和目标材质/符号层缓存;支持矩形、异形、孔洞和六边形。完成段、重开、换关和后台释放旧绘制状态;不支持离屏合成时保留静态带面。
- 新增6项A3模块测试,并更新旧箭头视觉断言。200关指路填色像素一致;完整测试/200关触摸记录见qa/a3-check-20261023.txt。浏览器尺寸与样张验收入口为qa/bright-classroom.html的“A3尺寸与动画验收”。微信/iOS/Android真机帧率与混合效果仍需上传后复测。
## 2026-10-23:iOS通关早点击与界面反馈修复
版本:2026-10-23-ios-clear-ui-fix,基于0cefce4。独立新包:../releases/NumberSwipe-main-iOS通关切换与界面反馈修复-20261023.zip;旧包及旧包解压目录保持原样,不上传微信,不推送远端。
- 用户真机复现条件为iOS微信小游戏,彩带播放后立即点击下一关会卡顿,稍后点击正常。上版通关时以“clear”新键重启下一关材质预热,与动画争抢时间。本版删除重复启动,通关停止预热;点击下一关先取消旧动画帧和预热,再执行切换。
- 通关静态背景在落格动画收束后保存一次,庆祝帧只合成背景和彩带,不反复绘制全部UI。设置、规则、尺寸变化、后台及换关会使背景失效;不等待2.6秒庆祝结束即可切换。
- 奇偶节点的有效落格现在开启原有局部彩带,方向节点仍保留音效、缩放与振动;最后一格只执行完成反馈,两组节点彩带上限不变。
- 游戏内选关、规则、设置和返回游戏统一使用现有audio/ui.wav。通关最初120ms内也允许按钮音效;不改变静音偏好。
- 设置统一为音乐:开/关、音效:开/关、振动:开/关。“乐”在旧Sans字体子集中缺失,已补齐500/600/700三个实际字重并保留原字库覆盖;数字和标题字形资源保持原样。
- 验收:104项模块测试;200关触摸回归;新增通关后0/40/110/300/3000ms切关及演出背景复用、统一按钮音效和字体覆盖测试。浏览器真实Canvas20组早点击切关记录见qa/ios-clear-browser-20261023.txt。
- iOS真机待复测。浏览器不替代真机性能验收;交专门打包上传会话上传后,重点验证连续过关立即切换、静音/后台恢复和不同密度关卡。
## 2026-10-23:回退音阶与流畅切关
版本:2026-10-23-descending-undo-fast-next;基于90d83b3的BGMA连续播放与节点缩放版。仅本地开发与打包,不上传微信,不开启开发者工具服务端口,不推送远端。
- 手势回退与撤销键按被撤销步号,使用前进同音色音阶逆序播放。例如7步回退播放7、6、5、4、3、2、1,最后一步清空仍发1;重开保持原音效。快速回退与前进共用三路声音池,过密声音只保留最新音高,不补播积压。
- 过关短音乐只调整播放增益:0.75→0.6375,降低15%。普通移动、节点、重开、BGM基础音量及通关压低/恢复逻辑不改;原WAV资源不变。
- 点击下一关不再现场求解:构建时准备并验证200关完整路线,源关卡哈希记录在src/prepared-routes.json。玩家仍按原公开规则自行操作,提示机制不变。移除start/showPlay的重复棋盘绘制。
- 下一关空棋盘的材质按空闲帧逐格预热,拖动暂停、超时取消;换关、选关、后台和尺寸变化终止旧任务。预热不产生声音、振动、进度或玩家反馈,缓存仍使用原有内存上限。
- 浏览器真实Canvas抽测第91/163/192关:62.3/130.5/152.6ms降为13.1/8.4/7.8ms;数据见qa/next-transition-browser-20261023.json。此为同机浏览器数据,不视为真机帧率验收。
- 验收:101项模块测试、200关触摸回归;原始关卡与上游快照不变,旧发布包保留。手势/按钮降序音阶、音量、BGM不中断、预热生命周期与单次绘制均有回归。
包:../releases/NumberSwipe-main-回退音阶与流畅切关-20261023.zip。交专门打包上传会话处理上传。
## 2026-10-23:BGMA连续播放与节点缩放强化
关卡只使用BGMA,移除运行包BGMB;选关音乐保留。音乐基础音量降低15%:选关0.136,关卡0.119。过关短音乐起播时压低到基础音量25%,结束、取消或播放失败后恢复;切关不停止或重播BGMA。音乐和音效开关保持独立。普通格峰值1.1、特殊节点峰值1.3,回弹时长仍为90ms。构建目标ES2019,兼容微信上传打包器。
## 2026-10-23:居中标题与指定WAV音乐
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
- 标题保持原画并水平居中;设置移到总进度、关卡范围标签同一行。小屏仍为每页15关,按钮无重叠。
- 菜单30秒循环;每次进入关卡从67.2秒A、约65.455秒B中随机选一首循环。同关重绘、移动、静音恢复及后台恢复不重选,切关再随机。
- 通关短句替换原通关音,使用交付的3秒WAV,仅播放一次;通关之前先停止BGM,下一关恢复循环。短句沿用音效开关及实际起播同步,BGM沿用音乐开关,无动态压低或叠加。
- 原始44.1kHz/16bit立体声WAV原件保留于.audio-sources并记录哈希;运行BGM适配为22.05kHz/16bit立体声WAV,通关短句为同采样率单声道,完整时长与乐句保留。没有改用MP3或OGG。npm构建校验现成WAV,不依赖ffmpeg。
- 长BGM放入music分包,异步加载期间操作不阻塞;回调只播放当前场景,静音、后台、通关及加载失败均不会迟到播放。原始来源目录已从根工程与子工程上传配置中排除;music/CREDITS.md保留音源署名。
- 验收:89项模块测试、200关触摸回归;375及320×568浏览器布局,随机选择、分包取消、前后台与音效同步均有回归。微信真机音量、循环边界听感与分包下载仍需预览验收。
包:../releases/NumberSwipe-main-居中标题与指定WAV音乐-20261023.zip。工程仍为wechat-campaign-200-r2。
## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
- 移除普通字体描边模拟;使用参考图经imagegen提取的独立透明标题美术资源images/title-reference.png,保留定制橙紫字形、圆润奶油贴纸边、倒角高光及立体厚度。资源为2132×737,等比绘制,首加载完成自动刷新。
- 选关顶部按真实图片比例预留空间,小屏缩小网格间距,继续保持每页3×5关、触摸目标至少44px,进度与范围标签不被遮挡。
- 图片资源随发布包保存;浏览器预览与模拟宿主支持原生图片加载。
- 验收:83项模块测试、200关模拟触摸;375宽及320×568浏览器画面核对。最新温暖BGM资源哈希不变。真机显示仍需微信预览确认。
包:../releases/NumberSwipe-main-参考图标题还原版-20261023.zip,保留之前版本。
## 2026-10-23:温暖无节拍关卡BGM试听版
版本:2026-10-23-warm-ambient-bgm。用户选择试用温暖、无节拍、弱旋律方向;本轮仅替换关卡音乐,选关音乐保持原文件哈希。
- 原创32秒循环:低中音区柔和和声缓慢交叠,没有鼓点、规律敲击或突出旋律;采用周期波形与平滑交叉渐变,避免循环接缝。
- PCM均方根约0.035,峰值小于0.17,关卡播放音量0.14;选关音量仍为0.16。保留音乐/音效/振动独立开关及后台、中断恢复。
- 节点确认音、通关和弦、缩放、触觉、存档、选关标题及200关规则保留。之前节点跟手与立体标题发布包保留。
- 验收:80项模块测试、200关触摸回归;生成资源的接缝、包络突变、音量及开关恢复检查。实际听感与真机扬声器/耳机效果需用户试听。
试听资源:music/play.wav。发布包:../releases/NumberSwipe-main-温暖无节拍BGM试听版-20261023.zip。
## 2026-10-23:节点反馈与立体标题
版本:2026-10-23-responsive-nodes-sticker-header。开发分支:codex/node-feedback-responsive。以当前正式主工程为基线;没有从历史副本覆盖文件。
- 修复只有固定数字触发checkpoint的问题:合法到达数字、奇偶、方向节点均使用独立checkpoint-N确认音及中震;最后一格只触发完成和弦。错误不冒充节点,也不参照答案判断。
- 所有普通落格、回退落点(撤销最后一步时为被撤销格)、错误局部提示改为90ms小回弹,峰值1.025;特殊节点90ms峰值1.065。只缩放块体表面,文字锚点、规则线和命中区域保持稳定。
- 回退和撤销加入轻震。经用户选择保留轻/中强度,把普通震动最短间隔70ms改为61ms,连续触发密度约增14.8%;不宣称单次物理强度提高15%。短震降级、关闭开关、预览禁震及后台取消继续有效。
- 原数字节点彩带的触发、范围、最多两组及1500ms时长保留。新增奇偶/方向节点使用较大缩放和确认音,不额外扩大彩带。
- 选关标题按参考图使用橙紫双色、奶油描边和叠层立体处理;进度奶油标签、关卡范围橙色标签。保持每页15关及320×568安全区域。
- 选关与关卡BGM正常保留,音乐资源、音量及独立开关本轮没有更换;音效、震动和回退不停止音乐。
验证:78项模块测试通过(含200关每一步特殊节点音效断言),200关模拟触摸回归通过;375宽、320×568浏览器排版通过。200关提示填色像素复核记录见qa。真机音效起播、振动手感与高密度帧率仍待iPhone和Android验收。
BGM待选方向:①温暖无节拍氛围,低音区软垫与缓慢和声、弱旋律(优先推荐);②稀疏毛毡钢琴,少量低中音、长留白;③柔和木质环境音,去固定节拍、极少敲击,保留教具室气质。当前16秒循环暂留,未擅自替换。
包:../releases/NumberSwipe-main-节点跟手与立体标题-20261023.zip。之前主版本包保留。
# NumberSwipe 正式主版本 # NumberSwipe 正式主版本
本目录是唯一正式微信主工程,属于 NumberSwipe_New 仓库 main 分支。2026-10-23 已将分层反馈、音乐与流动指路强化版提升为主版本。版本以 release-info.json 为准。 本目录是唯一正式微信主工程,属于 NumberSwipe_New 仓库 main 分支。2026-10-23 已将分层反馈、音乐与流动指路强化版提升为主版本。版本以 release-info.json 为准。
+1
View File
@@ -4,6 +4,7 @@ export function buildAudio(root){
const cues={ui:[[659,.055,0],[784,.06,.025]],hint:[[523,.09,0],[659,.10,.055],[784,.13,.11]],correct:[[659,.11,0]],error:[[330,.09,0],[247,.09,.045]],undo:[[440,.065,0]],restart:[[523,.08,0],[392,.09,.06]],complete:[[261.6256,.96,0,.8],[329.6276,.68,0,.62],[392,.62,0,.48],[523.251,.5,0,.16]],checkpoint:[[523,.14,0]]}; const cues={ui:[[659,.055,0],[784,.06,.025]],hint:[[523,.09,0],[659,.10,.055],[784,.13,.11]],correct:[[659,.11,0]],error:[[330,.09,0],[247,.09,.045]],undo:[[440,.065,0]],restart:[[523,.08,0],[392,.09,.06]],complete:[[261.6256,.96,0,.8],[329.6276,.68,0,.62],[392,.62,0,.48],[523.251,.5,0,.16]],checkpoint:[[523,.14,0]]};
for(let n=1;n<=36;n++){const f=523*Math.pow(2,(n-1)/12);cues['move-'+n]=[[f,.09,0]];cues['checkpoint-'+n]=[[f,.14,0]];} for(let n=1;n<=36;n++){const f=523*Math.pow(2,(n-1)/12);cues['move-'+n]=[[f,.09,0]];cues['checkpoint-'+n]=[[f,.14,0]];}
for(const [name,tones]of Object.entries(cues)){ for(const [name,tones]of Object.entries(cues)){
if(name==='complete')continue; // User supplied completion WAV is retained.
const rate=22050,length=Math.ceil((Math.max(...tones.map(([,d,t])=>d+t))+.02)*rate),b=Buffer.alloc(44+length*2); const rate=22050,length=Math.ceil((Math.max(...tones.map(([,d,t])=>d+t))+.02)*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); 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,weight=1]of tones){const u=t-offset;if(u<0||u>=d)continue; for(let i=0;i<length;i++){const t=i/rate;let sample=0;for(const [f,d,offset,weight=1]of tones){const u=t-offset;if(u<0||u>=d)continue;
+1 -5
View File
@@ -12,8 +12,4 @@ test('教学和组合规则用具体动作及奇偶例子解释,没有未解
test('过关提示同步使用实际播放起点,非请求时间',()=>{const h=setup();h.sound.play('complete');assert.equal(audioDiagnostics.completeStartedAt,null);const requested=audioDiagnostics.completeRequestedAt;h.contexts.find(c=>c.src==='audio/complete.wav').playing();assert.ok(audioDiagnostics.completeStartedAt>=requested);}); test('过关提示同步使用实际播放起点,非请求时间',()=>{const h=setup();h.sound.play('complete');assert.equal(audioDiagnostics.completeStartedAt,null);const requested=audioDiagnostics.completeRequestedAt;h.contexts.find(c=>c.src==='audio/complete.wav').playing();assert.ok(audioDiagnostics.completeStartedAt>=requested);});
test('过关和弦收尾持续衰减,不在后半段重新起音',()=>{ test('过关短句使用用户提供的3秒WAV,不与旧和弦叠加',()=>{const b=fs.readFileSync(path.join(root,'audio/complete.wav'));assert.equal(b.readUInt32LE(24),22050);assert.equal((b.length-44)/(22050*2),3);const h=setup();let stops=0;h.sound.beforeComplete=()=>stops++;h.sound.play('complete');assert.equal(stops,1);assert.deepEqual(h.played,['audio/complete.wav']);});
const b=fs.readFileSync(path.join(root,'audio/complete.wav')),rate=b.readUInt32LE(24),rms=[];
for(let t=.4;t<.8;t+=.1){let energy=0,n=0;for(let i=Math.floor(t*rate);i<Math.min(Math.floor((t+.1)*rate),(b.length-44)/2);i++){const v=b.readInt16LE(44+i*2);energy+=v*v;n++;}rms.push(Math.sqrt(energy/n));}
for(let i=1;i<rms.length;i++)assert.ok(rms[i]<rms[i-1],rms.join(','));
});
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {Music} from './src/music.mjs';import {CasualAudio} from './src/audio.mjs';
function setup(){const contexts=[];const api={getStorageSync(){},setStorageSync(){},createInnerAudioContext(){const a={stop(){},play(){},onError(fn){this.error=fn;},onEnded(fn){this.ended=fn;},offEnded(fn){if(this.ended===fn)this.ended=null;}};contexts.push(a);return a;}};globalThis.wx=api;const m=new Music(api);m.scene('play','one');const sound=new CasualAudio({}, {getItem(){},setItem(){}},'test');sound.beforeComplete=()=>m.duck();sound.afterComplete=()=>m.unduck();return {m,sound,contexts};}
test('jingle end, cancellation and playback error restore BGM without restarting',()=>{const {m,sound}=setup();sound.play('complete');const a=sound.channels[3];assert.equal(m.ducked,true);a.ended();assert.equal(m.audio.volume,.119);sound.play('complete');const late=a.ended;sound.stop();assert.equal(m.ducked,false);sound.play('complete');late();assert.equal(m.ducked,true);a.error({errMsg:'failed'});assert.equal(m.ducked,false);sound.play('complete');sound.toggle();assert.equal(m.ducked,false);});
test('muted effects never duck BGM and unrelated effect errors cannot end jingle ducking',()=>{const {m,sound}=setup();sound.enabled=false;sound.play('complete');assert.equal(m.ducked,false);sound.enabled=true;sound.play('complete');sound.channels[0].error({errMsg:'unrelated'});assert.equal(m.ducked,true);sound.stop();});
test('hide and music preference do not revive playback through a late jingle callback',()=>{const {m,sound}=setup();sound.play('complete');m.suspend();sound.stop();assert.equal(m.playing,null);assert.equal(m.ducked,false);m.resume();assert.equal(m.audio.volume,.119);sound.play('complete');m.toggle();sound.channels[3].ended();assert.equal(m.playing,null);assert.equal(m.enabled,false);m.toggle();assert.equal(m.audio.volume,.119);});
test('BGMB is absent from the runtime manifest and music package',()=>{const manifest=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));assert.equal(manifest['play-b'],undefined);assert.equal(fs.existsSync(new URL('./music/play-b.wav',import.meta.url)),false);});
@@ -12,7 +12,7 @@ export function applyBrightClassroom(source) {
if(canvas.width!==Math.round(width*dpr))canvas.width=Math.round(width*dpr); if(canvas.width!==Math.round(width*dpr))canvas.width=Math.round(width*dpr);
if(canvas.height!==Math.round(height*dpr))canvas.height=Math.round(height*dpr); if(canvas.height!==Math.round(height*dpr))canvas.height=Math.round(height*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0); ctx.setTransform(dpr,0,0,dpr,0,0);
brightScene=renderBrightBoard(ctx,level,path,width,height,{preview:previewOnly}); brightScene=renderBrightBoard(ctx,level,path,width,height,{preview:previewOnly,...brightHintOptions(Date.now()),pixelRatio:dpr});
({polygons,centers,unit}=brightScene);channelViews=brightScene.channels;cellViews=brightScene.views; ({polygons,centers,unit}=brightScene);channelViews=brightScene.channels;cellViews=brightScene.views;
const bad=violations(level,path); const bad=violations(level,path);
${tail}`; ${tail}`;
@@ -28,13 +28,15 @@ ${tail}`;
// Preview rendering must restore every reference used by the live hint layer. // Preview rendering must restore every reference used by the live hint layer.
source=source.replace('const saved={canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews};','const saved={canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews,brightScene};'); source=source.replace('const saved={canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews};','const saved={canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews,brightScene};');
source=source.replace('({canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews}=saved)','({canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews,brightScene}=saved)'); source=source.replace('({canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews}=saved)','({canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews,brightScene}=saved)');
source=`import {renderBrightBoard,hintGeometry,drawBarriers} from '../bright-classroom/board-renderer.mjs';\nlet brightScene=null;\n`+source; source=`import {renderBrightBoard,hintGeometry,drawBarriers} from '../bright-classroom/board-renderer.mjs';\nlet brightScene=null,brightHintKey='',brightHintStarted=0;\n`+source;
source+=` source+=`
export function refreshBrightBoard(now){ export function refreshBrightBoard(now){
if(!brightScene?.animating)return; const options=brightHintOptions(now);if(!brightScene?.animating&&!options.hintTarget&&!brightScene?.hintLayers)return;
const dpr=window.devicePixelRatio||1;ctx.setTransform(dpr,0,0,dpr,0,0); const dpr=window.devicePixelRatio||1;ctx.setTransform(dpr,0,0,dpr,0,0);
brightScene=renderBrightBoard(ctx,level,path,canvas.clientWidth,canvas.clientHeight,{now}); brightScene=renderBrightBoard(ctx,level,path,canvas.clientWidth,canvas.clientHeight,{now,...options,pixelRatio:dpr});
} }
function brightHintOptions(now){const h=previewOnly?null:segmentHints.view(level,solution,path),key=h?level.id+':'+h.start+':'+h.end:'';if(key!==brightHintKey){brightHintKey=key;brightHintStarted=now;}return {hintTarget:h?.route.at(-1)||null,hintElapsed:now-brightHintStarted};}
export function resetBrightHint(){brightHintKey='';if(brightScene)brightScene.hintLayers=null;}
export function brightOverlay(target){if(brightScene)drawBarriers(target,brightScene,path);} export function brightOverlay(target){if(brightScene)drawBarriers(target,brightScene,path);}
export function brightAnimating(){return Boolean(brightScene?.animating);} export function brightAnimating(){return Boolean(brightScene?.animating);}
`; `;
@@ -133,5 +133,5 @@ test('checkpoint ribbons trigger on correct arrival only and never duplicate per
test('checkpoint pulse is small, centered in time and settles without looping',async()=>{ test('checkpoint pulse is small, centered in time and settles without looping',async()=>{
const {checkpointScale}=await import('./src/bright-classroom/board-renderer.mjs'); const {checkpointScale}=await import('./src/bright-classroom/board-renderer.mjs');
assert.equal(checkpointScale(0),1);assert.equal(checkpointScale(130),1.035);assert.equal(checkpointScale(260),1);assert.equal(checkpointScale(1000),1);assert.equal(checkpointScale(130,true),1); assert.equal(checkpointScale(0),1);assert.equal(checkpointScale(45),1.3);assert.equal(checkpointScale(90),1);assert.equal(checkpointScale(1000),1);assert.equal(checkpointScale(45,true),1);
}); });
+2 -1
View File
@@ -13,7 +13,8 @@ from fontTools.varLib.instancer import instantiateVariableFont
root = Path(__file__).resolve().parent root = Path(__file__).resolve().parent
corpus = ''.join(p.read_text(encoding='utf-8') for p in (root / 'src').rglob('*') corpus = ''.join(p.read_text(encoding='utf-8') for p in (root / 'src').rglob('*')
if p.suffix in ('.mjs', '.json')) if p.suffix in ('.mjs', '.json'))
chars = ''.join(sorted(set(corpus + ''.join(chr(i) for i in range(32, 127)) + '✓→…!:·'))) previous_chars = json.loads((root / 'fonts' / 'manifest.json').read_text(encoding='utf-8')).get('characters', '')
chars = ''.join(sorted(set(corpus + previous_chars + ''.join(chr(i) for i in range(32, 127)) + '✓→…!:·')))
manifest = {'generator': 'fonttools', 'sources': [], 'outputs': [], 'characters': chars} manifest = {'generator': 'fonttools', 'sources': [], 'outputs': [], 'characters': chars}
for family, weights in [('notosanssc', [500, 600, 700]), ('nunito', [800])]: for family, weights in [('notosanssc', [500, 600, 700]), ('nunito', [800])]:
source = root / '.font-sources' / (family + '.ttf') source = root / '.font-sources' / (family + '.ttf')
+6 -2
View File
@@ -1,3 +1,4 @@
import {buildPreparedRoutes} from './prepared-routes-build.mjs';
import {buildMusic} from './music-assets.mjs'; import {buildMusic} from './music-assets.mjs';
import {applyLayeredFeedback} from './layered-feedback-build.mjs'; import {applyLayeredFeedback} from './layered-feedback-build.mjs';
import {buildAudio} from './audio-assets.mjs'; import {buildAudio} from './audio-assets.mjs';
@@ -7,8 +8,8 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { build } from 'esbuild'; import { build } from 'esbuild';
const root = path.dirname(fileURLToPath(import.meta.url)); const root = path.dirname(fileURLToPath(import.meta.url));
buildAudio(root);buildMusic(root); buildAudio(root);buildMusic(root);buildPreparedRoutes(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) { await build({ absWorkingDir: root, entryPoints: ['src/main.mjs'], outfile: 'game.js', bundle: true, format: 'iife', platform: 'neutral', target: 'es2019', minify: true, plugins: [{ name: 'wechat-host', setup(b) {
b.onLoad({ filter: /upstream[\\/]ui\.mjs$/ }, args => { b.onLoad({ filter: /upstream[\\/]ui\.mjs$/ }, args => {
let contents = fs.readFileSync(args.path, 'utf8'); let contents = fs.readFileSync(args.path, 'utf8');
contents=contents.replace("from './audio.mjs'", "from '../audio.mjs'"); contents=contents.replace("from './audio.mjs'", "from '../audio.mjs'");
@@ -46,6 +47,9 @@ export function renderLesson(target,l,route,w,h){
contents+=` contents+=`
export function resumeGameplay(){const saved=readResume(localStorage),target=resumeTarget(playLevels,completed,saved,level.id);if(level.id!==playLevels[target.index].id||cleared){start(target.index,false);path=validResumePath(level,target.path,canAppend);draw();}showPlay();} export function resumeGameplay(){const saved=readResume(localStorage),target=resumeTarget(playLevels,completed,saved,level.id);if(level.id!==playLevels[target.index].id||cleared){start(target.index,false);path=validResumePath(level,target.path,canAppend);draw();}showPlay();}
`; `;
const solveSeam='solution=solve(level)[0]';if(!contents.includes(solveSeam))throw Error('Prepared-route seam changed');contents=contents.replace(solveSeam,'solution=[...prepared.routes[level.id]]');
const firstDraw='if(open)showPlay();draw();';if(!contents.includes(firstDraw))throw Error('Initial board draw seam changed');contents=contents.replace(firstDraw,'if(open)showPlay();else draw();');
contents="import prepared from '../prepared-routes.json';\n"+contents;
contents=applyBrightClassroom(contents); contents=applyBrightClassroom(contents);
contents=applyLayeredFeedback(contents); contents=applyLayeredFeedback(contents);
contents=contents.replace(' if(previewOnly)return;updateTutorial(bad);',' if(previewOnly)return;persistResume();updateTutorial(bad);'); contents=contents.replace(' if(previewOnly)return;updateTutorial(bad);',' if(previewOnly)return;persistResume();updateTutorial(bad);');
+1 -1
View File
@@ -58,7 +58,7 @@ test('drag near a previous-block boundary cannot undo; deeper entry can, and und
test('tutorial, redraw, hint and restored saved routes do not generate player feedback',()=>{ test('tutorial, redraw, hint and restored saved routes do not generate player feedback',()=>{
const ui=boot();ui.select(4);const n=ui.api.snapshot().feedback.sequence;ui.button('规则');ui.advance(1000);ui.advance(1000);ui.button('返回游戏');ui.button('指路');ui.advance(400);assert.equal(ui.api.snapshot().feedback.sequence,n);ui.cell(solve(levels[4])[0][0]);const restored=boot(ui.storage);assert.equal(restored.api.snapshot().feedback.sequence,0); const ui=boot();ui.select(4);const n=ui.api.snapshot().feedback.sequence;ui.button('规则');ui.advance(1000);ui.advance(1000);ui.button('返回游戏');ui.button('指路');ui.advance(400);assert.equal(ui.api.snapshot().feedback.sequence,n);ui.cell(solve(levels[4])[0][0]);const restored=boot(ui.storage);assert.equal(restored.api.snapshot().feedback.sequence,0);
}); });
test('pressure and rebound have bounded amplitude and duration',()=>{assert.equal(landingScale(60),.985);assert.equal(landingScale(120),1);assert.equal(checkpointScale(130),1.035);assert.equal(checkpointScale(260),1);}); test('pressure and rebound have bounded amplitude and duration',()=>{assert.equal(landingScale(45),1.1);assert.equal(landingScale(90),1);assert.equal(checkpointScale(45),1.3);assert.equal(checkpointScale(90),1);});
test('interpolation cannot duplicate the same failed-cell event within one native movement',()=>{ test('interpolation cannot duplicate the same failed-cell event within one native movement',()=>{
const f=new Feedback();f.begin();for(let i=0;i<40;i++)f.emit('error','l','wall-cell',3);f.end();assert.equal(f.sequence,1);f.begin();f.emit('error','l','wall-cell',3);f.end();assert.equal(f.sequence,2); const f=new Feedback();f.begin();for(let i=0;i<40;i++)f.emit('error','l','wall-cell',3);f.end();assert.equal(f.sequence,1);f.begin();f.emit('error','l','wall-cell',3);f.end();assert.equal(f.sequence,2);
}); });
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -10
View File
@@ -21,23 +21,23 @@
{ {
"file": "NumberSwipeSans500.ttf", "file": "NumberSwipeSans500.ttf",
"weight": 500, "weight": 500,
"sha256": "a02c66351825f2af3e15de47e332d0dff11c60854ce37b0c0c8e2ae3de52aca5", "sha256": "5b6d256d43bea106cadc32edeeb4c4b21042f0e3bf8122eac5ed0e68fd9b532f",
"bytes": 121952, "bytes": 122760,
"glyphs": 503 "glyphs": 506
}, },
{ {
"file": "NumberSwipeSans600.ttf", "file": "NumberSwipeSans600.ttf",
"weight": 600, "weight": 600,
"sha256": "6bd45b2532ee4afed2ff8c1bbb43cac6cc8490983df8c7312a3b57aa53241a9f", "sha256": "66c9321439aff83dfed12bd0d61c785c07e9562d0b93ff765def4591cc209043",
"bytes": 121940, "bytes": 122748,
"glyphs": 503 "glyphs": 506
}, },
{ {
"file": "NumberSwipeSans700.ttf", "file": "NumberSwipeSans700.ttf",
"weight": 700, "weight": 700,
"sha256": "cf4834799e8c9277902654f90ca9b86165eb8afcf62e8ce08a3b0c33f09eba35", "sha256": "ae15bd1dec2c724363e3faec928c0641b103f8ab30003f040b93cf9ac5b0d88c",
"bytes": 121972, "bytes": 122776,
"glyphs": 503 "glyphs": 506
}, },
{ {
"file": "NumberSwipeDigits800.ttf", "file": "NumberSwipeDigits800.ttf",
@@ -55,5 +55,5 @@
"characters": "重开撤销指路已走步还剩块第关0123456789 ·" "characters": "重开撤销指路已走步还剩块第关0123456789 ·"
} }
], ],
"characters": "\n !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~·×–“”…→✓、。一三上下不与且两个中为久么之也习了交亮从他以们会位住余作使例供依侧保信修候值偶允先入全六共关其具再冲准减几出分切划则刚别到前剩力加动包区单卡即原去又双反发变叠口另只可右号合同后向否启和哪唯喜器回园围固图在坐块域堵墙处备复多大太失奇好如始子字存学它完定察对导封小少尚就尽尾局峰工左差己已布并序应座开异式引弯当录形往径循微必态怎思恢恰想戏成或战所才打找把拍拐择拼持指按挑挡挨振换接推提撤播支收改放效教数整斜新方旁时是显替最有未束条来果枢标核格框桥楚横橙次欢正此步段每永求没沿法浏深混清渐游源滑点版状玩环现理生用画留略的盘直相看着知矩短确示离种积称移空穿突窗竖章端笔符第算类索红约纹纽线练终经绕继续综缺置者耗能脚自至色节花范荡落蓝行表被要观规视览角解计订认让记许设访评试该误请读调败资赖走起越趣跨路转载边达过近返还这进连退选逐通道那邻部都配里重链销错门闭问间随难需面音页顺须预频题颜馈验黄黑!,/:;?" "characters": "\n !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~·×–“”…→✓、。一三上下不与且两个中为久么之乐也习了交亮从他以们会位住余作使例供依侧保信修候值偶允先入全六共关其具再冲准减几出分切划则刚别到前剩力加动包区单卡即原去又双反发变叠口另只可右号合同后向否启和哪唯喜器回园围固图在坐块域堵墙处备复多大太失奇好如始子字存学它完定察对导封小少尚就尽尾局峰工左差己已布并序应度座开异式引弯当录形往径循微必态怎思总恢恰想戏成或战所才打找把拍拐择拼持指按挑挡挨振换接推提撤播支收改放效教数整斜新方旁时是显替最有未束条来果枢标核格框桥楚横橙次欢正此步段每永求没沿法浏深混清渐游源滑点版状玩环现理生用画留略的盘直相看着知矩短确示离种积称移空穿突窗竖章端笔符第算类索红约纹纽线练终经绕继续综缺置者耗能脚自至色节花范荡落蓝行表被要观规视览角解计订认让记许设访评试该误请读调败资赖走起越趣跨路转载边达过近返还这进连退选逐通道那邻部都配里重链销错门闭问间随难需面音页顺须预频题颜馈验黄黑!,/:;?"
} }
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -1,4 +1,10 @@
{ {
"deviceOrientation": "portrait", "deviceOrientation": "portrait",
"showStatusBar": false "showStatusBar": false,
"subpackages": [
{
"name": "music",
"root": "music/"
}
]
} }
+6 -6
View File
@@ -35,27 +35,27 @@ test('hint draws skip player edges and mask visited cells on their own layer; co
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 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]]; 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']); 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.equal(strokes.length,4);assert.ok(strokes.every(s=>JSON.stringify(s)===JSON.stringify([[100,0],[200,0]])),'only unwalked edge receives hint strokes');
assert.ok(events.some(e=>e.kind==='fill'&&e.mode==='destination-out')); assert.ok(events.some(e=>e.kind==='fill'&&e.mode==='destination-out'));
assert.equal(ctx.globalCompositeOperation,'source-over'); assert.equal(ctx.globalCompositeOperation,'source-over');
assert.equal(arcs.length,1,'no moving light or rings over already walked edge'); assert.equal(arcs.length,0,'A3 has no light beads or endpoint rings');
}); });
test('parity is a segment anchor; colour is not; flow moves continuously across cells',()=>{ 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'})}))}; const l={cells:solution.map(id=>({id,...(id==='d'?{parity:'odd'}:{category:'circle'})}))};
assert.deepEqual(hintSegment(l,solution,[]).route,['a','b','c','d']); 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)}); 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); 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]); assert.equal(arcs.length,0,'static fallback has no moving beads');
}); });
test('coral teaching ribbon preserves visited masks and offers direction in reduced motion',()=>{ test('soft teaching ribbon preserves visited masks without arrows in reduced motion',()=>{
for(const reducedMotion of [false,true]){ for(const reducedMotion of [false,true]){
const strokes=[],fills=[],stack=[]; const strokes=[],fills=[],stack=[];
const ctx=new Proxy({globalCompositeOperation:'source-over',save(){stack.push(this.globalCompositeOperation);},restore(){this.globalCompositeOperation=stack.pop();},stroke(){strokes.push({color:this.strokeStyle,mode:this.globalCompositeOperation});},fill(){fills.push(this.globalCompositeOperation);}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)}); const ctx=new Proxy({globalCompositeOperation:'source-over',save(){stack.push(this.globalCompositeOperation);},restore(){this.globalCompositeOperation=stack.pop();},stroke(){strokes.push({color:this.strokeStyle,mode:this.globalCompositeOperation});},fill(){fills.push(this.globalCompositeOperation);}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
const path=['a']; const path=['a'];
drawHint(ctx,{route:['a','b'],guides:[{a:'a',b:'b',points:[[0,0],[100,0]]}],visitedShapes:[{rings:[[[0,0],[20,0],[20,20],[0,20]]],radius:3,depth:3,maskPad:4}],keepouts:[{x:40,y:0,w:12,h:15}],reducedMotion},{},50,300,path); drawHint(ctx,{route:['a','b'],guides:[{a:'a',b:'b',points:[[0,0],[100,0]]}],visitedShapes:[{rings:[[[0,0],[20,0],[20,20],[0,20]]],radius:3,depth:3,maskPad:4}],keepouts:[{x:40,y:0,w:12,h:15}],reducedMotion},{},50,300,path);
assert.ok(strokes.some(s=>s.color==='#F49B75')); assert.ok(strokes.some(s=>s.color==='rgba(255,185,126,0.72)'));
assert.ok(strokes.filter(s=>s.color==='#C96E48'&&s.mode==='source-over').length>=1,'directional chevron'); assert.equal(strokes.filter(s=>s.color==='#C96E48').length,0,'no chevrons');
assert.ok(fills.includes('destination-out')); assert.ok(fills.includes('destination-out'));
assert.equal(ctx.globalCompositeOperation,'source-over'); assert.equal(ctx.globalCompositeOperation,'source-over');
assert.deepEqual(path,['a']); assert.deepEqual(path,['a']);
@@ -0,0 +1,6 @@
{
"source": "User supplied reference screenshot, title cutout preserved through imagegen editing",
"width": 2132,
"height": 737,
"alpha": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,12 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {Music} from './src/music.mjs';import {CasualAudio} from './src/audio.mjs';import {boot} from './qa/runtime-harness.mjs';
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),routes=JSON.parse(fs.readFileSync(new URL('./src/prepared-routes.json',import.meta.url))).routes;
function setup(){const contexts=[];const api={getStorageSync(){},setStorageSync(){},createInnerAudioContext(){const a={stops:0,plays:0,stop(){this.stops++;},play(){this.plays++;},onError(fn){this.error=fn;},onEnded(fn){this.ended=fn;},offEnded(fn){if(this.ended===fn)this.ended=null;},onPlay(fn){this.started=fn;},offPlay(fn){if(this.started===fn)this.started=null;}};contexts.push(a);return a;}};globalThis.wx=api;const m=new Music(api);m.scene('play','one');const sound=new CasualAudio({}, {getItem(){},setItem(){}},'test');sound.beforeComplete=()=>m.duck();sound.afterComplete=()=>m.unduck();return {sound,m,contexts};}
test('level-transition reset keeps completion source, gain and native playback; natural end restores BGM',()=>{const {sound,m}=setup();sound.play('complete');const complete=sound.channels[3],stops=complete.stops;for(let i=0;i<3;i++){sound.stop({preserveComplete:true});m.scene('play','next');assert.equal(complete.stops,stops);assert.equal(complete.src,'audio/complete.wav');assert.equal(complete.volume,.75*.85);assert.equal(m.ducked,true);assert.equal(m.audio.volume,.119*.25);}complete.ended();assert.equal(m.ducked,false);assert.equal(m.audio.volume,.119);});
test('UI, hints, errors, restart and numeric nodes share ordinary voices while completion owns fourth channel',()=>{const {sound,m,contexts}=setup();sound.play('complete');const complete=sound.channels[3],stops=complete.stops;for(const kind of ['ui','hint','error','correct','checkpoint','restart','move','undo']){sound.stop({preserveComplete:true});sound.play(kind,0,7);assert.equal(complete.src,'audio/complete.wav');assert.equal(complete.stops,stops);assert.equal(complete.plays,1);assert.equal(m.ducked,true);}assert.equal(contexts.length,5,'one BGM plus existing four effect channels');complete.ended();assert.equal(m.ducked,false);sound.play('checkpoint',0,8);assert.equal(complete.src,'audio/checkpoint-8.wav');});
test('a newer completion retains priority and stale completion callbacks cannot unduck it',()=>{const {sound,m}=setup();sound.play('complete');const old=sound.channels[3].ended;sound.stop({preserveComplete:true});sound.play('complete');const latest=sound.channels[3].ended;old();assert.equal(m.ducked,true);latest();assert.equal(m.ducked,false);});
test('mute, native error and hard lifecycle stop still terminate the preserved jingle',()=>{for(const action of ['mute','error','hide']){const {sound,m}=setup();sound.play('complete');const complete=sound.channels[3],late=complete.ended;sound.stop({preserveComplete:true});if(action==='mute')sound.toggle();else if(action==='error')complete.error({errMsg:'test'});else{m.suspend();sound.stop();}assert.equal(m.ducked,false);late();assert.equal(m.ducked,false);if(action==='hide')assert.equal(m.playing,null);}});
test('real next-level tap does not stop or reload playing jingle and does not restore BGM early',()=>{const ui=boot();ui.select(3);const id=ui.api.snapshot().level;routes[id].forEach(ui.cell);const complete=ui.audioContexts.find(a=>a.src==='audio/complete.wav');let stops=0;complete.stop=()=>stops++;const plays=ui.played.filter(p=>p==='audio/complete.wav').length;ui.button('下一关 →');assert.equal(ui.api.snapshot().displayOrder,5);assert.equal(stops,0);assert.equal(ui.api.snapshot().music.ducked,true);assert.equal(ui.api.snapshot().music.volume,.119*.25);assert.equal(ui.api.snapshot().audio.completePlaying,true);if(ui.api.buttons().some(b=>b.label==='返回游戏'))ui.button('返回游戏');ui.cell(routes[ui.api.snapshot().level][0]);ui.button('设置');assert.equal(complete.src,'audio/complete.wav');assert.equal(stops,0);assert.equal(ui.played.filter(p=>p==='audio/complete.wav').length,plays);complete.ended();assert.equal(ui.api.snapshot().music.ducked,false);assert.equal(ui.api.snapshot().music.volume,.119);assert.equal(ui.api.snapshot().audio.completePlaying,false);});
test('background stops preserved completion and ignores its late end when resuming the unfinished next level',()=>{const ui=boot();ui.select(3);routes[ui.api.snapshot().level].forEach(ui.cell);const complete=ui.audioContexts.find(a=>a.src==='audio/complete.wav');ui.button('下一关 →');const late=complete.ended;ui.handlers.Hide();assert.equal(ui.api.snapshot().music.ducked,false);assert.equal(ui.api.snapshot().audio.completePlaying,false);late();assert.equal(ui.api.snapshot().music.playing,null);ui.handlers.Show();assert.equal(ui.api.snapshot().displayOrder,5);assert.equal(ui.api.snapshot().music.volume,.119);});
test('a failing next-level ordinary voice cannot release completion ducking early',()=>{const {sound,m}=setup();sound.play('complete');sound.stop({preserveComplete:true});sound.channels[0].play=()=>{throw Error('ordinary voice unavailable');};sound.play('checkpoint',0,7);assert.equal(m.ducked,true);sound.channels[3].ended();assert.equal(m.ducked,false);});
@@ -2,14 +2,14 @@ export function applyLayeredFeedback(source){
const replace=(old,value)=>{if(!source.includes(old))throw Error('Layered feedback seam changed: '+old.slice(0,80));source=source.replace(old,value);}; const replace=(old,value)=>{if(!source.includes(old))throw Error('Layered feedback seam changed: '+old.slice(0,80));source=source.replace(old,value);};
source="import {feedback} from '../feedback.mjs';\nimport {inBacktrackCore} from '../backtrack.mjs';\n"+source; source="import {feedback} from '../feedback.mjs';\nimport {inBacktrackCore} from '../backtrack.mjs';\n"+source;
replace('const labels=new Map();',"feedback.attach(sound,wx,localStorage);\nconst labels=new Map();"); replace('const labels=new Map();',"feedback.attach(sound,wx,localStorage);\nconst labels=new Map();");
replace('function start(i,open=true){','function start(i,open=true){feedback.reset();'); replace('function start(i,open=true){','function start(i,open=true){feedback.reset({preserveComplete:true});');
replace("function markError(id,message){sound.play('error');","function markError(id,message){feedback.emit('error',level,id,path.at(-1)===id?path.length:path.length+1);"); replace("function markError(id,message){sound.play('error');","function markError(id,message){feedback.emit('error',level,id,path.at(-1)===id?path.length:path.length+1);");
replace("record.backtracks++;path.pop();sound.play('undo');","record.backtracks++;const removed=path.pop();feedback.emit('undo',level,removed,path.length,{from:path.at(-1)});"); replace("record.backtracks++;path.pop();sound.play('undo');","record.backtracks++;const removed=path.pop();feedback.emit('undo',level,removed,path.length,{from:path.at(-1)});");
replace("path.pop();sound.play('undo');record.backtracks++;","const removed=path.pop();feedback.emit('undo',level,removed,path.length,{from:path.at(-1)});record.backtracks++;"); replace("path.pop();sound.play('undo');record.backtracks++;","const removed=path.pop();feedback.emit('undo',level,removed,path.length,{from:path.at(-1)});record.backtracks++;");
const audio="if(path.length<level.cells.length)sound.play(cell.clue!==undefined&&cell.clue===path.length?(cell.clue>1?'checkpoint':'correct'):'move',0,path.length)"; const audio="if(path.length<level.cells.length)sound.play(cell.clue!==undefined&&cell.clue===path.length?(cell.clue>1?'checkpoint':'correct'):'move',0,path.length)";
replace(audio,"if(path.length<level.cells.length)feedback.emit(cell.clue>1&&cell.clue===path.length?'checkpoint':'move',level,id,path.length,{from:path.at(-2)})"); replace(audio,"if(path.length<level.cells.length)feedback.emit((cell.clue>1&&cell.clue===path.length)||cell.parity||cell.direction?'checkpoint':'move',level,id,path.length,{from:path.at(-2),ribbon:Boolean(cell.parity)||(cell.clue>1&&cell.clue===path.length)})");
replace("sound.play('complete',0)","feedback.emit('complete',level,id,path.length,{from:path.at(-2)})"); replace("sound.play('complete',0)","feedback.emit('complete',level,id,path.length,{from:path.at(-2)})");
replace("sound.unlock();sound.play('restart');","feedback.reset();sound.unlock();sound.play('restart');"); replace("sound.unlock();sound.play('restart');","feedback.reset({preserveComplete:true});sound.unlock();sound.play('restart');");
replace('advance(hit(a[0]+(p[0]-a[0])*i/steps,a[1]+(p[1]-a[1])*i/steps))','advanceAt(a[0]+(p[0]-a[0])*i/steps,a[1]+(p[1]-a[1])*i/steps)'); replace('advance(hit(a[0]+(p[0]-a[0])*i/steps,a[1]+(p[1]-a[1])*i/steps))','advanceAt(a[0]+(p[0]-a[0])*i/steps,a[1]+(p[1]-a[1])*i/steps)');
source+=`\nfunction advanceAt(x,y){const id=hit(x,y);if(path.length>=2&&id===path.at(-2)&&!inBacktrackCore(polygons,id,x,y,unit))return;advance(id);}\n`; source+=`\nfunction advanceAt(x,y){const id=hit(x,y);if(path.length>=2&&id===path.at(-2)&&!inBacktrackCore(polygons,id,x,y,unit))return;advance(id);}\n`;
for(const event of ['pointerdown','pointermove']){ for(const event of ['pointerdown','pointermove']){
+3 -7
View File
@@ -1,7 +1,3 @@
import fs from 'node:fs';import path from 'node:path'; import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';
// Original sparse pentatonic loops. Each damped note wraps its tail across the seam. // User-selected WAVs are versioned assets, never overwritten by synthesis.
export function buildMusic(root){fs.mkdirSync(path.join(root,'music'),{recursive:true});const rate=22050,seconds=16,n=rate*seconds; export function buildMusic(root){const manifest=JSON.parse(fs.readFileSync(path.join(root,'.audio-sources/manifest.json')));for(const item of Object.values(manifest)){const b=fs.readFileSync(path.join(root,item.output));if(b.toString('ascii',0,4)!=='RIFF'||crypto.createHash('sha256').update(b).digest('hex')!==item.sha256)throw Error('Selected WAV resource mismatch: '+item.output);}}
for(const [name,notes]of Object.entries({select:[60,67,72,64,69,67,64,62],play:[48,55,60,55,57,52,55,50]})){
const samples=new Float64Array(n);for(let k=0;k<notes.length;k++){const f=440*2**((notes[k]-69)/12),offset=k*2;for(let j=0;j<rate*4;j++){const t=j/rate,env=(1-Math.exp(-t/.025))*Math.exp(-t/.65)*Math.min(1,(4-t)/.15);samples[(offset*rate+j)%n]+=(Math.sin(2*Math.PI*f*t)+.12*Math.sin(4*Math.PI*f*t))*env*.24;}}
const b=Buffer.alloc(44+n*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(n*2,40);for(let i=0;i<n;i++)b.writeInt16LE(Math.round(samples[i]*32767),44+i*2);fs.writeFileSync(path.join(root,'music',name+'.wav'),b);
}}
+4 -2
View File
@@ -2,6 +2,8 @@ import test from 'node:test';import assert from 'node:assert/strict';import fs f
import {Music} from './src/music.mjs';import {HintArrows} from './src/hint-arrows.mjs';import {boot} from './qa/runtime-harness.mjs';import {solve} from './src/upstream/rules.mjs'; import {Music} from './src/music.mjs';import {HintArrows} from './src/hint-arrows.mjs';import {boot} from './qa/runtime-harness.mjs';import {solve} from './src/upstream/rules.mjs';
test('music loops independently, persists mute and cancels in background',()=>{const stored=new Map(),calls=[];const api={getStorageSync:k=>stored.get(k),setStorageSync:(k,v)=>stored.set(k,v),createInnerAudioContext:()=>({stop(){calls.push('stop');},play(){calls.push(this.src);},onError(){}})};const m=new Music(api);m.scene('select');m.scene('select');assert.equal(calls.filter(x=>x==='music/select.wav').length,1);assert.equal(m.audio.loop,true);m.scene('play');m.suspend();m.scene('select');assert.equal(m.playing,null);m.resume();assert.equal(m.playing,'select');m.toggle();assert.equal(new Music(api).enabled,false);}); test('music loops independently, persists mute and cancels in background',()=>{const stored=new Map(),calls=[];const api={getStorageSync:k=>stored.get(k),setStorageSync:(k,v)=>stored.set(k,v),createInnerAudioContext:()=>({stop(){calls.push('stop');},play(){calls.push(this.src);},onError(){}})};const m=new Music(api);m.scene('select');m.scene('select');assert.equal(calls.filter(x=>x==='music/select.wav').length,1);assert.equal(m.audio.loop,true);m.scene('play');m.suspend();m.scene('select');assert.equal(m.playing,null);m.resume();assert.equal(m.playing,'select');m.toggle();assert.equal(new Music(api).enabled,false);});
test('old arrows retain distance, rush to endpoint and fresh arrows move at base speed',()=>{const a=new HintArrows(),motion={total:300},events=[];let f=a.frame('a',0,events);a.heads(f,motion,0);f=a.frame('a',100,events);assert.equal(a.heads(f,motion,0)[0],6.5);events.push({sequence:1,kind:'move'});f=a.frame('a',116,events);const at=a.heads(f,motion,30);assert.ok(at[0]>6.5&&at[0]<10);assert.equal(at[1],30);f=a.frame('a',216,events);const next=a.heads(f,motion,30);assert.ok(next[0]>150);assert.equal(next[1],36.5);for(let t=316;t<=416;t+=100)a.heads(a.frame('a',t,events),motion,30);assert.ok(a.particles.every(p=>!p.rush));a.reset();assert.equal(a.key,null);}); test('old arrows retain distance, rush to endpoint and fresh arrows move at base speed',()=>{const a=new HintArrows(),motion={total:300},events=[];let f=a.frame('a',0,events);a.heads(f,motion,0);f=a.frame('a',100,events);assert.equal(a.heads(f,motion,0)[0],6.5);events.push({sequence:1,kind:'move'});f=a.frame('a',116,events);const at=a.heads(f,motion,30);assert.ok(at[0]>6.5&&at[0]<10);assert.equal(at[1],30);f=a.frame('a',216,events);const next=a.heads(f,motion,30);assert.ok(next[0]>150);assert.equal(next[1],36.5);for(let t=316;t<=416;t+=100)a.heads(a.frame('a',t,events),motion,30);assert.ok(a.particles.every(p=>!p.rush));a.reset();assert.equal(a.key,null);});
test('settings have exactly three independent preferences, and hint has its own cue',()=>{const ui=boot();ui.select(3);ui.button('设置');assert.deepEqual(Array.from(ui.api.buttons(),b=>b.label),['音乐:开','音效开','振动:开','完成']);ui.button('完成');ui.button('指路');assert.equal(ui.played.at(-1),'audio/hint.wav');ui.button('选关');assert.ok(ui.played.includes('music/select.wav'));ui.button('下一页');assert.equal(ui.played.at(-1),'audio/ui.wav');}); test('settings have exactly three independent preferences, and hint has its own cue',()=>{const ui=boot();ui.select(3);ui.button('设置');assert.deepEqual(Array.from(ui.api.buttons(),b=>b.label),['音乐:开','音效:开','振动:开','完成']);ui.button('完成');ui.button('指路');assert.equal(ui.played.at(-1),'audio/hint.wav');ui.button('选关');assert.ok(ui.played.includes('music/select.wav'));ui.button('下一页');assert.equal(ui.played.at(-1),'audio/ui.wav');});
test('actual fixed numeric node uses checkpoint pitch, final cell uses complete chord',()=>{const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));const i=levels.findIndex(l=>l.cells.some(c=>c.clue>1&&c.clue<l.cells.length));const l=levels[i],ui=boot();ui.select(i);const route=solve(l)[0];for(const id of route){ui.advance(80);ui.cell(id);const e=ui.api.snapshot().feedback.events.at(-1);if(e.kind==='checkpoint')assert.equal(ui.played.at(-1),'audio/checkpoint-'+e.step+'.wav');}assert.equal(ui.played.at(-1),'audio/complete.wav');assert.ok(ui.api.snapshot().feedback.events.some(e=>e.kind==='checkpoint'));}); test('actual fixed numeric node uses checkpoint pitch, final cell uses complete chord',()=>{const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));const i=levels.findIndex(l=>l.cells.some(c=>c.clue>1&&c.clue<l.cells.length));const l=levels[i],ui=boot();ui.select(i);const route=solve(l)[0];for(const id of route){ui.advance(80);ui.cell(id);const e=ui.api.snapshot().feedback.events.at(-1);if(e.kind==='checkpoint')assert.equal(ui.played.at(-1),'audio/checkpoint-'+e.step+'.wav');}assert.equal(ui.played.at(-1),'audio/complete.wav');assert.ok(ui.api.snapshot().feedback.events.some(e=>e.kind==='checkpoint'));});
test('two original music loops are quiet, nonempty and have a smooth loop seam',()=>{for(const name of ['select','play']){const b=fs.readFileSync(new URL('./music/'+name+'.wav',import.meta.url));assert.equal(b.readUInt32LE(24),22050);assert.ok(b.length>600000);assert.ok(Math.abs(b.readInt16LE(44)-b.readInt16LE(b.length-2))<100);}}); test('selected menu and BGMA loop are stereo PCM WAV with complete musical durations',()=>{const meta=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));for(const name of ['select','play-a']){const b=fs.readFileSync(new URL('./'+meta[name].output,import.meta.url));assert.equal(b.readUInt32LE(24),22050);assert.equal(b.readUInt16LE(22),2);assert.ok(meta[name].seconds>29);assert.equal(b.length,meta[name].bytes);}});
test('selected runtime WAVs retain the original durations after sample-rate adaptation',()=>{const meta=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));for(const [name,item]of Object.entries(meta)){const b=fs.readFileSync(new URL('./.audio-sources/'+name+'.wav',import.meta.url)),seconds=(b.length-44)/(44100*4);assert.ok(Math.abs(seconds-item.seconds)<1/22050);}});
test('gameplay ambient uses lower scene volume and keeps independent mute and resume behavior',()=>{const store=new Map(),api={getStorageSync:k=>store.get(k),setStorageSync:(k,v)=>store.set(k,v),createInnerAudioContext:()=>({stop(){},play(){this.plays=(this.plays||0)+1;},onError(){}})},m=new Music(api);m.scene('select');assert.equal(m.audio.volume,.136);m.scene('play');assert.equal(m.audio.volume,.119);assert.equal(m.audio.loop,true);const plays=m.audio.plays;m.scene('play');assert.equal(m.audio.plays,plays);m.suspend();m.resume();assert.ok(m.audio.src==='music/play-a.wav');assert.equal(m.audio.volume,.119);m.toggle();assert.equal(new Music(api).enabled,false);});
+1
View File
@@ -0,0 +1 @@
Music supplied by the user: NumberSwipe-BGM-LOOP-v1-20261023. Original composition, arrangement and synthesis for this project. Piano and xylophone source samples: tonejs-instruments, N. P. Brosowsky; Versilian Studios / VSCO 2; CC BY 3.0. Samples have been pitched, filtered, enveloped and arranged. https://github.com/nbrosowsky/tonejs-instruments ; https://creativecommons.org/licenses/by/3.0/ . Runtime WAV adapted to 22050 Hz; originals and manifest retained in .audio-sources (not shipped). Completion jingle is audio/complete.wav.
+1
View File
@@ -0,0 +1 @@
// Music-only subpackage; playback uses native WAV resources.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {boot} from './qa/runtime-harness.mjs';import {solve} from './src/upstream/rules.mjs';import {Haptics} from './src/haptics.mjs';import {FEEDBACK} from './src/feedback-params.mjs';import {feedback} from './src/feedback.mjs';import {renderBrightBoard,landingScale,checkpointScale} from './src/bright-classroom/board-renderer.mjs';
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));
test('all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes',()=>{
const ui=boot();let numeric=0,parity=0,direction=0;
for(let i=0;i<levels.length;i++){const level=levels[i],route=solve(level)[0];ui.select(i);for(let step=1;step<=route.length;step++){ui.advance(80);const id=route[step-1],cell=level.cells.find(c=>c.id===id);ui.cell(id);const e=ui.api.snapshot().feedback.events.at(-1),last=step===route.length,node=(cell.clue>1&&cell.clue===step)||cell.parity||cell.direction;assert.equal(e.cell,id);assert.equal(e.kind,last?'complete':node?'checkpoint':'move','level '+(i+1)+' step '+step);assert.equal(ui.played.at(-1),last?'audio/complete.wav':node?'audio/checkpoint-'+step+'.wav':'audio/move-'+step+'.wav');if(node&&!last){assert.equal(e.ribbon,Boolean(cell.parity)||Boolean(cell.clue>1&&cell.clue===step));if(cell.clue>1)numeric++;if(cell.parity)parity++;if(cell.direction)direction++;}}}
assert.ok(numeric>0&&parity>0&&direction>0);});
test('drag back and undo button both vibrate lightly; disabling vibration suppresses both',()=>{
const ui=boot(),route=solve(levels[3])[0];ui.select(3);for(const id of route.slice(0,3)){ui.advance(80);ui.cell(id);}ui.advance(61);let n=ui.vibrated.length;ui.cell(route[1]);assert.equal(ui.api.snapshot().path.length,2);assert.equal(ui.vibrated.length,n+1);assert.equal(ui.vibrated.at(-1).type,'light');ui.advance(61);ui.button('撤销');assert.equal(ui.vibrated.length,n+2);assert.equal(ui.vibrated.at(-1).type,'light');ui.button('设置');ui.button('振动:开');ui.button('完成');ui.advance(80);ui.cell(route[1]);n=ui.vibrated.length;ui.advance(80);ui.button('撤销');assert.equal(ui.vibrated.length,n);assert.equal(ui.api.snapshot().music.playing,'play');});
test('haptic cadence is approximately 15 percent denser without changing light or medium grades',()=>{let now=0;const calls=[],h=new Haptics({vibrateShort:o=>calls.push(o.type)},{getItem:()=>null},{now:()=>now});h.play('move');now=60;h.play('undo');assert.deepEqual(calls,['light']);now=61;h.play('undo');assert.deepEqual(calls,['light','light']);h.play('checkpoint');assert.equal(calls.at(-1),'medium');assert.ok(Math.abs(70/FEEDBACK.hapticGapMs-1.15)<.01);});
test('regular and special scale settle at 90ms with a larger special peak',()=>{assert.equal(FEEDBACK.moveMs,90);assert.equal(FEEDBACK.checkpointMs,90);assert.equal(landingScale(45),1.1);assert.equal(checkpointScale(45),1.3);for(const t of [-10,0,90,1000]){assert.equal(landingScale(t),1);assert.equal(checkpointScale(t),1);}});
test('rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons',t=>{
const old=feedback.now;t.after(()=>{feedback.reset();feedback.now=old;});const level=levels[3],route=solve(level)[0],scales=[],ctx=new Proxy({measureText:()=>({width:20}),scale:(x,y)=>scales.push([x,y])},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});feedback.reset();feedback.now=()=>1000;feedback.emit('undo',level,route[1],1,{from:route[0]});renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1045});assert.ok(scales.some(([x])=>x===1.1));scales.length=0;feedback.now=()=>1100;feedback.emit('checkpoint',level,route[0],1,{ribbon:false});const scene=renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1145});assert.ok(scales.some(([x])=>x===1.3));assert.equal(scene.bursts.length,0);});
test('quick undo cancels removed node rebound, including last-step removal',t=>{const old=feedback.now;t.after(()=>{feedback.reset();feedback.now=old;});const level=levels[3],route=solve(level)[0],scales=[],ctx=new Proxy({measureText:()=>({width:20}),scale:x=>scales.push(x)},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});feedback.reset();feedback.now=()=>1000;feedback.emit('checkpoint',level,route[1],2,{ribbon:false});feedback.now=()=>1010;feedback.emit('undo',level,route[1],1,{from:route[0]});renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1045});assert.equal(scales.length,1);assert.ok(scales[0]>1&&scales[0]<=1.1);scales.length=0;feedback.now=()=>1100;feedback.emit('undo',level,route[0],0,{});renderBrightBoard(ctx,level,[],375,400,{now:1145});assert.deepEqual(scales,[1.1]);});
test('navigation buttons use the same selection cue and settings include uniform colons',()=>{
const ui=boot();ui.select(0);for(const action of ['规则','返回游戏','设置']){const n=ui.played.length;ui.button(action);assert.equal(ui.played.length,n+1);assert.equal(ui.played.at(-1),'audio/ui.wav');}
assert.deepEqual(Array.from(ui.api.buttons(),b=>b.label),['音乐:开','音效:开','振动:开','完成']);ui.button('完成');const n=ui.played.filter(p=>p==='audio/ui.wav').length;ui.button('选关');assert.equal(ui.played.filter(p=>p==='audio/ui.wav').length,n+1);assert.equal(ui.played.filter(p=>p.startsWith('audio/')).at(-1),'audio/ui.wav');
});
test('early next during confetti cancels warming; settled ceremony reuses its background',()=>{
const ui=boot();for(const delay of [0,40,110,300,3000]){ui.select(3);solve(levels[3])[0].forEach(ui.cell);assert.equal(ui.api.snapshot().warming,false);ui.advance(delay);const before=ui.api.snapshot().clearBaseHits;ui.advance(16);if(delay>=300&&delay<2600)assert.ok(ui.api.snapshot().clearBaseHits>before);ui.button('下一关 →');assert.equal(ui.api.snapshot().displayOrder,5);assert.equal(ui.api.snapshot().cleared,false);ui.advance(100);assert.equal(ui.api.snapshot().displayOrder,5);}
});
test('font corpus includes every settings label including music le glyph',()=>{const m=JSON.parse(fs.readFileSync(new URL('./fonts/manifest.json',import.meta.url)));for(const c of '音乐音效振动开关设置:')assert.ok(m.characters.includes(c),'missing '+c);});
+2 -2
View File
@@ -1,9 +1,9 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$releaseDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'releases' $releaseDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'releases'
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
$releaseInfo = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'release-info.json') -Raw | ConvertFrom-Json $releaseInfo = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'release-info.json') -Raw -Encoding UTF8 | ConvertFrom-Json
$archivePath = Join-Path $releaseDir $releaseInfo.package $archivePath = Join-Path $releaseDir $releaseInfo.package
$assets = @('game.js','game.json','project.config.json','audio','audio-original','music','fonts','release-info.json') | ForEach-Object { Join-Path $PSScriptRoot $_ } $assets = @('game.js','game.json','project.config.json','audio','audio-original','music','images','fonts','release-info.json') | ForEach-Object { Join-Path $PSScriptRoot $_ }
Compress-Archive -LiteralPath $assets -DestinationPath $archivePath -Force Compress-Archive -LiteralPath $assets -DestinationPath $archivePath -Force
$hashStream = [IO.File]::OpenRead($archivePath) $hashStream = [IO.File]::OpenRead($archivePath)
try { $hasher = [Security.Cryptography.SHA256]::Create(); [BitConverter]::ToString($hasher.ComputeHash($hashStream)).Replace('-','') } finally { $hashStream.Dispose() } try { $hasher = [Security.Cryptography.SHA256]::Create(); [BitConverter]::ToString($hasher.ComputeHash($hashStream)).Replace('-','') } finally { $hashStream.Dispose() }
@@ -0,0 +1,2 @@
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {solve,violations} from './src/upstream/rules.mjs';
export function buildPreparedRoutes(root){const bytes=fs.readFileSync(path.join(root,'src/upstream/levels.json')),pack=JSON.parse(bytes),routes={};for(const id of pack.playOrder){const level=pack.levels.find(l=>l.id===id),route=solve(level,{limit:1})[0];if(!route||route.length!==level.cells.length||violations(level,route).length)throw Error('Invalid prepared route '+id);routes[id]=route;}fs.writeFileSync(path.join(root,'src/prepared-routes.json'),JSON.stringify({sourceSHA256:crypto.createHash('sha256').update(bytes).digest('hex'),routes}));}
+37 -1
View File
@@ -103,6 +103,42 @@
{ {
"type": "file", "type": "file",
"value": "music-flow.test.mjs" "value": "music-flow.test.mjs"
},
{
"type": "file",
"value": "node-feedback.test.mjs"
},
{
"type": "file",
"value": "selection-title.test.mjs"
},
{
"type": "folder",
"value": ".audio-sources"
},
{
"type": "file",
"value": "selected-music.test.mjs"
},
{
"type": "file",
"value": "bgm-duck.test.mjs"
},
{
"type": "file",
"value": "prepared-routes-build.mjs"
},
{
"type": "file",
"value": "undo-next.test.mjs"
},
{
"type": "file",
"value": "soft-hint.test.mjs"
},
{
"type": "file",
"value": "jingle-next.test.mjs"
} }
] ]
}, },
@@ -110,6 +146,6 @@
"appid": "wx7a14d0d70b5dd13d", "appid": "wx7a14d0d70b5dd13d",
"editorSetting": {}, "editorSetting": {},
"projectname": "NumberSwipe_New-main", "projectname": "NumberSwipe_New-main",
"description": "正式主版本:分层反馈、音乐与流动指路", "description": "正式主版本:节点音效、90ms跟手缩放与橙紫立体标题",
"miniprogramRoot": "./" "miniprogramRoot": "./"
} }
@@ -0,0 +1 @@
PASS 28/28:A3四尺寸、七类真实关卡,静态带面未逐帧重建,目标呼吸使用缓存。浏览器验收,微信真机待验。 320×568 / 第4关;320×568 / 第28关;320×568 / 第9关;320×568 / 第65关;320×568 / 第29关;320×568 / 第66关;320×568 / 第163关;375×812 / 第4关;375×812 / 第28关;375×812 / 第9关;375×812 / 第65关;375×812 / 第29关;375×812 / 第66关;375×812 / 第163关;390×844 / 第4关;390×844 / 第28关;390×844 / 第9关;390×844 / 第65关;390×844 / 第29关;390×844 / 第66关;390×844 / 第163关;414×896 / 第4关;414×896 / 第28关;414×896 / 第9关;414×896 / 第65关;414×896 / 第29关;414×896 / 第66关;414×896 / 第163关
@@ -0,0 +1,136 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (8.9435ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (1.1639ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (47.0186ms)
✔ 音频错误可诊断,不再静默吞掉 (0.8142ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (49.8985ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (39.0574ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (1.1057ms)
✔ 过关短句使用用户提供的3秒WAV,不与旧和弦叠加 (7.5766ms)
✔ jingle end, cancellation and playback error restore BGM without restarting (6.6654ms)
✔ muted effects never duck BGM and unrelated effect errors cannot end jingle ducking (0.5332ms)
✔ hide and music preference do not revive playback through a late jingle callback (0.5819ms)
✔ BGMB is absent from the runtime manifest and music package (0.9783ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (187.7866ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (1687.1491ms)
✔ guides through every irregular block stay inside its occupied shape (67.4851ms)
✔ visited, conflicting and hinted category cells retain their material identity (15.4878ms)
✔ five font subsets match their manifest and fit the package budget (6.3089ms)
✔ upstream adapter fails closed if the inspected render seams change (4.4174ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (39.8189ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (2.0616ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (12.7032ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (5.3524ms)
✔ fixed channels remain anchored at their block centers after number enlargement (2251.9192ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (7.9589ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.8892ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (8.628ms)
✔ completion waits at most 120ms and ignores late audio callbacks (0.7635ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (0.9155ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (4.573ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (1.2133ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (16.0315ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (58.0774ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (333.9406ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (513.6096ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (263.342ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (316.5441ms)
✔ pressure and rebound have bounded amplitude and duration (0.7293ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.4654ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (54.316ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (4.2573ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (196.5803ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (20.3321ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (8.1753ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (1.1961ms)
✔ all 200 levels reveal consecutive segments without changing the player path (3772.7025ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (3.0586ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (2.7027ms)
✔ soft teaching ribbon preserves visited masks without arrows in reduced motion (3.0311ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (2.0014ms)
✔ 实际教学关动画:basic (5.7751ms)
✔ 实际教学关动画:walls (1.3457ms)
✔ 实际教学关动画:straight (3.5357ms)
✔ 实际教学关动画:vertical (5.928ms)
✔ 实际教学关动画:corner (2.104ms)
✔ 实际教学关动画:colour (1.229ms)
✔ 实际教学关动画:parity (0.7272ms)
✔ 实际教学关动画:rhythm-door (2.0488ms)
✔ 实际教学关动画:hex (16.839ms)
✔ 实际教学关动画:unknown-start (8.1501ms)
✔ 200关的组合规则均能找到对应真实教学关 (146.9163ms)
✔ 教学减速,结束停留1秒后自动循环 (3.789ms)
✔ music loops independently, persists mute and cancels in background (2.5341ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (1.0276ms)
✔ settings have exactly three independent preferences, and hint has its own cue (531.0682ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (357.6796ms)
✔ selected menu and BGMA loop are stereo PCM WAV with complete musical durations (12.9153ms)
✔ selected runtime WAVs retain the original durations after sample-rate adaptation (46.8893ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (0.7041ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (53800.1232ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (82.8582ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (3.0517ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.3485ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (9.0174ms)
✔ quick undo cancels removed node rebound, including last-step removal (3.4522ms)
✔ navigation buttons use the same selection cue and settings include uniform colons (63.494ms)
✔ early next during confetti cancels warming; settled ceremony reuses its background (285.4005ms)
✔ font corpus includes every settings label including music le glyph (1.8083ms)
✔ first launch goes directly to gameplay (493.2614ms)
✔ resume unfinished route after exit from gameplay (615.7597ms)
✔ resume unfinished route after exit from selection (504.8706ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (183.7206ms)
✔ replaying a cleared level does not replace the unfinished save (188.5401ms)
✔ legacy saves, malformed data and full completion have valid startup targets (219.7827ms)
✔ selection pages expose fifteen cards and fit phone safe areas (514.0614ms)
✔ hint-only frames reuse static UI, and player input invalidates it (151.9144ms)
✔ hint curves are cached and distance lookup is stable at boundaries (2.1228ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (130.4526ms)
✔ BGMA stays continuous across level changes and completion redraws (2.7072ms)
✔ subpackage completion never plays a stale scene after hiding, muting, or clearing (0.6587ms)
✔ music load failure is silent and one real resume can retry; muted pending loads stay silent (0.6025ms)
✔ runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle (266.2246ms)
✔ title is centered and selection settings share the progress and level-range row (925.6168ms)
✔ completion ducks BGMA until jingle ends and next level never restarts it (252.8562ms)
✔ title artwork is a transparent, high resolution PNG packaged locally (12.136ms)
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (1.315ms)
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (1184.7768ms)
✔ A3 width, one wide patch and brighter body preserve original design proportions (7.0053ms)
✔ ribbon caches its body, draws source-atop only offscreen and starts each frame clean (13.5722ms)
✔ one patch travels at 60px/s on the full route and does not restart when player advances (0.5319ms)
✔ reduced motion and missing canvas use a static ribbon without main-board compositing (0.6184ms)
✔ real target contours and smoothed routes stay attached to actual 200-level geometry (2580.5724ms)
✔ live target breath uses cached board layers, arrival and background stop obsolete visuals (273.3177ms)
✔ undo button reverses seven played steps as 7,6,5,4,3,2,1 (723.4609ms)
✔ drag reverses seven played steps as 7,6,5,4,3,2,1 (316.3682ms)
✔ completion amplitude is reduced 15 percent while node, undo and restart volumes stay unchanged (2.476ms)
✔ fast reverse coalesces to the latest descending pitch and cannot replay an old forward note (2.3642ms)
✔ all prepared routes match the immutable level hash and public rules (32.8606ms)
✔ entering next level draws the live board once and preserves the running BGMA player (2786.6811ms)
✔ next-board warming advances one item per frame and cancels on deadline, replacement or hide (0.8861ms)
✔ idle material warming never changes the player path or emits sound and vibration (113.63ms)
ℹ tests 110
ℹ suites 0
ℹ pass 110
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 54956.9338
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
@@ -0,0 +1 @@
PASS:200/200关,点击A3指路前后和撤销重画后,玩家已走格子的实际Canvas填色像素一致。
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+69
View File
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>数字一笔 · 明亮教具室试玩</title>
<style>
@font-face{font-family:NumberSwipeSans500;src:url(../fonts/NumberSwipeSans500.ttf)}
@font-face{font-family:NumberSwipeSans600;src:url(../fonts/NumberSwipeSans600.ttf)}
@font-face{font-family:NumberSwipeSans700;src:url(../fonts/NumberSwipeSans700.ttf)}
@font-face{font-family:NumberSwipeDigits800;src:url(../fonts/NumberSwipeDigits800.ttf)}
@font-face{font-family:NumberSwipePlayful;src:url(../fonts/NumberSwipePlayful.ttf)}
*{box-sizing:border-box}body{margin:0;background:#fff6df;color:#43382f;font:15px NumberSwipeSans500,sans-serif}header{padding:28px 32px 20px;border-bottom:1px solid #e5d7ba}h1{margin:0 0 8px;font:25px NumberSwipeSans700}p{margin:8px 0;line-height:1.7;color:#71604f}main{display:flex;gap:32px;padding:28px 32px;align-items:flex-start}.tools{width:270px;position:sticky;top:24px}.tools h2{font-size:17px;margin:24px 0 10px}.tools button,select{font:14px NumberSwipeSans600;cursor:pointer;border:1px solid #d8c4a0;background:#fffbf2;color:#43382f;border-radius:12px;padding:12px;margin:0 6px 8px 0}.tools button:hover{border-color:#6552d9;color:#5140b7}.tools select{width:100%}.phone{border:1px solid #d8c4a0;border-radius:24px;overflow:hidden;box-shadow:0 12px 34px #43382f14;background:#ffe69a}canvas{display:block;touch-action:none}.note{font-size:13px}#status{background:#ece7ff;color:#5140b7;border-radius:12px;padding:12px;margin-top:16px}a{color:#5140b7}@media(max-width:750px){main{padding:16px;flex-direction:column}.tools{width:100%;position:static}.phone{align-self:center}header{padding:20px}}
header,.tools{display:none}body{width:375px}main{padding:0;display:block}.phone{border:0;border-radius:0;box-shadow:none}
</style>
<header><h1>数字一笔 · 节点反馈与立体标题验收</h1><p>200关实际游戏包的 Canvas 预览。全部画面由程序绘制,字体随包提供。</p></header>
<main><aside class="tools"><label for="size">屏幕尺寸</label><select id="size"><option>375×812</option><option>320×568</option><option>375×667</option><option>390×844</option><option>414×896</option></select><label for="feedback-mode">反馈对照</label><select id="feedback-mode"><option value="enhanced">强化版</option><option value="original">原版</option></select><h2>真实关卡样张</h2><div id="samples"></div><h2>指定关卡</h2><select id="level"></select><button id="partial">画前几步</button><button id="hint">显示指路</button><button id="clear">完成本关</button><button id="select">返回选关</button><p class="note">在右侧直接点击或滑动。音乐、音效与振动开关在游戏选关页的“设置”中。辅助演示按钮仅属于此验收页。</p><div id="status">加载字体与游戏包…</div><p class="note" id="audio-status" role="status">音效:点击游戏后播放</p><p class="note">本页不触发触觉。原版/强化版切换用于音色和表现对照;iPhone、Android触觉与性能仍需真机验收。</p><p><a href="hints.html" target="_blank">运行200关提示像素检查</a></p></aside><div class="phone" id="phone"></div></main>
<script type="module">
import {solve} from '../src/upstream/rules.mjs';
import {browserAudio} from './browser-audio.mjs';
await Promise.all(['NumberSwipeSans500','NumberSwipeSans600','NumberSwipeSans700','NumberSwipeDigits800','NumberSwipePlayful'].map(f=>document.fonts.load('16px '+f)));
const source=await(await fetch('../game.js')).text(),pack=await(await fetch('../src/upstream/levels.json')).json();
const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));
const size=document.querySelector('#size'),levelSelect=document.querySelector('#level'),status=document.querySelector('#status'),phone=document.querySelector('#phone');
let runtime,feedbackMode='enhanced';
for(const [i,l] of levels.entries()){const o=document.createElement('option');o.value=i;o.textContent=`第 ${i+1} 关 · ${l.title}`;levelSelect.append(o);}
const samples=[['基础大块',3],['异形拼块',levels.findIndex(l=>l.cells.some(c=>c.shape)&&l.cells.length>=12)],['通道与数字',levels.findIndex(l=>l.features.includes('direction')&&l.cells.some(c=>c.direction))],['双色与奇偶',levels.findIndex(l=>l.features.includes('alternation')&&l.features.includes('parity'))],['节奏门',levels.findIndex(l=>l.doors?.length)],['六边墙',levels.findIndex(l=>l.geometry==='hex'&&l.walls?.length)],['密集36块',levels.findIndex(l=>l.id==='r2-163')]];
for(const [name,index] of samples){const b=document.createElement('button');b.textContent=name;b.onclick=()=>show(index);document.querySelector('#samples').append(b);}
function boot(){
const [width,height]=size.value.split('×').map(Number),handlers={},frames=new Map(),canvases=[],store=new Map();let frame=0,now=100000;
const timers=new Set(),sound=browserAudio(message=>{document.querySelector('#audio-status').textContent=message;});
const later=(callback,delay)=>{const timer=setTimeout(()=>{timers.delete(timer);callback();},delay);timers.add(timer);return timer;};
const cancel=timer=>{clearTimeout(timer);timers.delete(timer);};
class Clock extends Date{static now(){return now;}}
const wx={createImage(){const i=new Image();return {get width(){return i.naturalWidth;},get height(){return i.naturalHeight;},set onload(fn){i.onload=fn;},set onerror(fn){i.onerror=fn;},set src(v){i.src=new URL('../'+v,location.href).href;},_native:i};},__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,createCanvas(){const c=document.createElement('canvas');const p=c.getContext('2d'),draw=p.drawImage.bind(p);p.drawImage=(i,...args)=>draw(i?._native||i,...args);if(!canvases.length)canvases.push(c);return c;},loadFont:p=>p.split('/').at(-1).replace('.ttf',''),getWindowInfo:()=>({windowWidth:width,windowHeight:height,pixelRatio:2,safeArea:{top:24,bottom:height-24}}),getMenuButtonBoundingClientRect:()=>({bottom:42}),getPerformance:()=>({now:()=>now}),getStorageSync:k=>store.get(k)||'',setStorageSync:(k,v)=>store.set(k,v),removeStorageSync:k=>store.delete(k),setInnerAudioOption:sound.setInnerAudioOption,createInnerAudioContext:sound.createInnerAudioContext,showModal(){},env:{USER_DATA_PATH:'/preview'}};
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,fn=>{frames.set(++frame,fn);return frame;},id=>frames.delete(id),later,cancel,Clock);
const api=global.numberSwipeR2,canvas=canvases[0];canvas.style.width=width+'px';canvas.style.height=height+'px';phone.style.width=width+'px';phone.replaceChildren(canvas);
function tick(ms=16){now+=ms;const callbacks=[...frames.values()];frames.clear();for(const fn of callbacks)fn();}
function touch(kind,x,y){handlers[kind]({changedTouches:[{identifier:1,clientX:x,clientY:y}],touches:[{identifier:1,clientX:x,clientY:y}]});}
function tap(x,y){touch('TouchStart',x,y);touch('TouchEnd',x,y);tick();}
function button(name){const b=api.buttons().find(b=>b.label===name&&b.enabled);if(!b)throw Error('缺少按钮 '+name);tap(b.x+b.w/2,b.y+b.h/2);}
function cell(id){const s=api.snapshot(),p=s.centers[id];tap(p[0],p[1]+s.boardRect.y);}
function select(index){if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');if(api.snapshot().screen!=='select')button('选关');let n=0;while(!api.buttons().some(b=>b.label===String(index+1)||b.label===`${index+1} ✓`)){button(api.snapshot().selection.page>Math.floor(index/15)+1?'上一页':'下一页');if(++n>30)throw Error('分页失败');}button(api.buttons().some(b=>b.label===String(index+1))?String(index+1):`${index+1} ✓`);if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');tick(300);}
let down=false;
const coords=e=>{const r=canvas.getBoundingClientRect();return [(e.clientX-r.left)*width/r.width,(e.clientY-r.top)*height/r.height];};
canvas.onpointerdown=e=>{down=true;canvas.setPointerCapture(e.pointerId);touch('TouchStart',...coords(e));tick();};
canvas.onpointermove=e=>{if(down){touch('TouchMove',...coords(e));tick();}};
canvas.onpointerup=e=>{down=false;touch('TouchEnd',...coords(e));tick();};canvas.onpointercancel=e=>{down=false;touch('TouchCancel',...coords(e));tick();};
tick();return {tick,api,button,cell,select,dispose(){sound.dispose();for(const timer of timers)clearTimeout(timer);timers.clear();}};
}
function show(index){runtime?.dispose();runtime=boot();runtime.select(index);levelSelect.value=index;update();}
function update(){const s=runtime.api.snapshot();status.textContent=`第 ${s.displayOrder} 关 · ${s.path.length} / ${levels.find(l=>l.id===s.level).cells.length} 步 · ${s.theme}`;}
document.querySelector('#feedback-mode').onchange=e=>{feedbackMode=e.target.value;show(Number(levelSelect.value));};
levelSelect.onchange=()=>show(Number(levelSelect.value));size.onchange=()=>show(Number(levelSelect.value));
document.querySelector('#partial').onclick=()=>{const l=levels.find(l=>l.id===runtime.api.snapshot().level);runtime.button('重开');for(const id of solve(l)[0].slice(0,Math.min(5,l.cells.length-1)))runtime.cell(id);runtime.tick(300);update();};
document.querySelector('#hint').onclick=()=>{runtime.button('指路');update();};
document.querySelector('#clear').onclick=()=>{const l=levels.find(l=>l.id===runtime.api.snapshot().level);runtime.button('重开');for(const id of solve(l)[0])runtime.cell(id);runtime.tick(600);update();};
document.querySelector('#select').onclick=()=>{runtime.button('选关');update();};
show(3);function pump(){runtime.tick();requestAnimationFrame(pump);}requestAnimationFrame(pump);
const settingsCheck=document.createElement('button');settingsCheck.textContent='设置文字验收';settingsCheck.onclick=()=>{runtime.button('设置');update();};document.querySelector('.tools').append(settingsCheck);
const timingCheck=document.createElement('button');timingCheck.textContent='通关早点击验收';document.querySelector('.tools').append(timingCheck);
timingCheck.onclick=()=>{const results=[];for(const i of [3,90,162,191])for(const delay of [0,40,110,300,3000]){runtime.select(i);for(const id of solve(levels[i])[0])runtime.cell(id);runtime.tick(delay);const start=performance.now();runtime.button('下一关 →');const elapsed=performance.now()-start;if(runtime.api.snapshot().displayOrder!==i+2||runtime.api.snapshot().cleared)throw Error('切关失败');results.push({level:i+1,delay,ms:Number(elapsed.toFixed(1))});}status.textContent='PASS 20/20:通关后0/40/110/300/3000ms切关,上一关演出均取消。真实Canvas浏览器数据,iOS真机待复测。\n'+JSON.stringify(results);};
const a3Sample=document.createElement('button');a3Sample.textContent='A3软糖样张';a3Sample.onclick=()=>{show(3);runtime.cell(solve(levels[3])[0][0]);runtime.button('指路');runtime.tick(900);update();};document.querySelector('.tools').append(a3Sample);
const a3Check=document.createElement('button');a3Check.textContent='A3尺寸与动画验收';document.querySelector('.tools').append(a3Check);a3Check.onclick=()=>{
const rows=[];for(const dimensions of ['320×568','375×812','390×844','414×896'])for(const [,index]of samples){if(index<0)continue;size.value=dimensions;show(index);runtime.cell(solve(levels[index])[0][0]);runtime.button('指路');runtime.tick(1800);const before=runtime.api.snapshot();for(let n=0;n<12;n++)runtime.tick(16);const after=runtime.api.snapshot();if(after.softHint.builds!==before.softHint.builds)throw Error('静态带面重复构建');if(after.hint&&after.softHint.targetCacheHits<=before.softHint.targetCacheHits)throw Error('目标暖光未使用缓存');rows.push(dimensions+' / 第'+(index+1)+'关');}
status.textContent='PASS '+rows.length+'/'+rows.length+':A3四尺寸、七类真实关卡,静态带面未逐帧重建,目标呼吸使用缓存。浏览器验收,微信真机待验。\n'+rows.join(';');
};
size.value='375×812';a3Sample.onclick();
</script></html>
@@ -9,7 +9,7 @@
@font-face{font-family:NumberSwipePlayful;src:url(../fonts/NumberSwipePlayful.ttf)} @font-face{font-family:NumberSwipePlayful;src:url(../fonts/NumberSwipePlayful.ttf)}
*{box-sizing:border-box}body{margin:0;background:#fff6df;color:#43382f;font:15px NumberSwipeSans500,sans-serif}header{padding:28px 32px 20px;border-bottom:1px solid #e5d7ba}h1{margin:0 0 8px;font:25px NumberSwipeSans700}p{margin:8px 0;line-height:1.7;color:#71604f}main{display:flex;gap:32px;padding:28px 32px;align-items:flex-start}.tools{width:270px;position:sticky;top:24px}.tools h2{font-size:17px;margin:24px 0 10px}.tools button,select{font:14px NumberSwipeSans600;cursor:pointer;border:1px solid #d8c4a0;background:#fffbf2;color:#43382f;border-radius:12px;padding:12px;margin:0 6px 8px 0}.tools button:hover{border-color:#6552d9;color:#5140b7}.tools select{width:100%}.phone{border:1px solid #d8c4a0;border-radius:24px;overflow:hidden;box-shadow:0 12px 34px #43382f14;background:#ffe69a}canvas{display:block;touch-action:none}.note{font-size:13px}#status{background:#ece7ff;color:#5140b7;border-radius:12px;padding:12px;margin-top:16px}a{color:#5140b7}@media(max-width:750px){main{padding:16px;flex-direction:column}.tools{width:100%;position:static}.phone{align-self:center}header{padding:20px}} *{box-sizing:border-box}body{margin:0;background:#fff6df;color:#43382f;font:15px NumberSwipeSans500,sans-serif}header{padding:28px 32px 20px;border-bottom:1px solid #e5d7ba}h1{margin:0 0 8px;font:25px NumberSwipeSans700}p{margin:8px 0;line-height:1.7;color:#71604f}main{display:flex;gap:32px;padding:28px 32px;align-items:flex-start}.tools{width:270px;position:sticky;top:24px}.tools h2{font-size:17px;margin:24px 0 10px}.tools button,select{font:14px NumberSwipeSans600;cursor:pointer;border:1px solid #d8c4a0;background:#fffbf2;color:#43382f;border-radius:12px;padding:12px;margin:0 6px 8px 0}.tools button:hover{border-color:#6552d9;color:#5140b7}.tools select{width:100%}.phone{border:1px solid #d8c4a0;border-radius:24px;overflow:hidden;box-shadow:0 12px 34px #43382f14;background:#ffe69a}canvas{display:block;touch-action:none}.note{font-size:13px}#status{background:#ece7ff;color:#5140b7;border-radius:12px;padding:12px;margin-top:16px}a{color:#5140b7}@media(max-width:750px){main{padding:16px;flex-direction:column}.tools{width:100%;position:static}.phone{align-self:center}header{padding:20px}}
</style> </style>
<header><h1>数字一笔 · 分层反馈独立实验版</h1><p>200关实际游戏包的 Canvas 预览。全部画面由程序绘制,字体随包提供。</p></header> <header><h1>数字一笔 · 节点反馈与立体标题验收</h1><p>200关实际游戏包的 Canvas 预览。全部画面由程序绘制,字体随包提供。</p></header>
<main><aside class="tools"><label for="size">屏幕尺寸</label><select id="size"><option>375×812</option><option>320×568</option><option>375×667</option><option>390×844</option><option>414×896</option></select><label for="feedback-mode">反馈对照</label><select id="feedback-mode"><option value="enhanced">强化版</option><option value="original">原版</option></select><h2>真实关卡样张</h2><div id="samples"></div><h2>指定关卡</h2><select id="level"></select><button id="partial">画前几步</button><button id="hint">显示指路</button><button id="clear">完成本关</button><button id="select">返回选关</button><p class="note">在右侧直接点击或滑动。音乐、音效与振动开关在游戏选关页的“设置”中。辅助演示按钮仅属于此验收页。</p><div id="status">加载字体与游戏包…</div><p class="note" id="audio-status" role="status">音效:点击游戏后播放</p><p class="note">本页不触发触觉。原版/强化版切换用于音色和表现对照;iPhone、Android触觉与性能仍需真机验收。</p><p><a href="hints.html" target="_blank">运行200关提示像素检查</a></p></aside><div class="phone" id="phone"></div></main> <main><aside class="tools"><label for="size">屏幕尺寸</label><select id="size"><option>375×812</option><option>320×568</option><option>375×667</option><option>390×844</option><option>414×896</option></select><label for="feedback-mode">反馈对照</label><select id="feedback-mode"><option value="enhanced">强化版</option><option value="original">原版</option></select><h2>真实关卡样张</h2><div id="samples"></div><h2>指定关卡</h2><select id="level"></select><button id="partial">画前几步</button><button id="hint">显示指路</button><button id="clear">完成本关</button><button id="select">返回选关</button><p class="note">在右侧直接点击或滑动。音乐、音效与振动开关在游戏选关页的“设置”中。辅助演示按钮仅属于此验收页。</p><div id="status">加载字体与游戏包…</div><p class="note" id="audio-status" role="status">音效:点击游戏后播放</p><p class="note">本页不触发触觉。原版/强化版切换用于音色和表现对照;iPhone、Android触觉与性能仍需真机验收。</p><p><a href="hints.html" target="_blank">运行200关提示像素检查</a></p></aside><div class="phone" id="phone"></div></main>
<script type="module"> <script type="module">
import {solve} from '../src/upstream/rules.mjs'; import {solve} from '../src/upstream/rules.mjs';
@@ -20,7 +20,7 @@ const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));
const size=document.querySelector('#size'),levelSelect=document.querySelector('#level'),status=document.querySelector('#status'),phone=document.querySelector('#phone'); const size=document.querySelector('#size'),levelSelect=document.querySelector('#level'),status=document.querySelector('#status'),phone=document.querySelector('#phone');
let runtime,feedbackMode='enhanced'; let runtime,feedbackMode='enhanced';
for(const [i,l] of levels.entries()){const o=document.createElement('option');o.value=i;o.textContent=`第 ${i+1} 关 · ${l.title}`;levelSelect.append(o);} for(const [i,l] of levels.entries()){const o=document.createElement('option');o.value=i;o.textContent=`第 ${i+1} 关 · ${l.title}`;levelSelect.append(o);}
const samples=[['基础大块',3],['异形拼块',levels.findIndex(l=>l.cells.some(c=>c.shape)&&l.cells.length>=12)],['通道与数字',levels.findIndex(l=>l.features.includes('direction')&&l.cells.some(c=>c.clue&&c.direction))],['双色与奇偶',levels.findIndex(l=>l.features.includes('alternation')&&l.features.includes('parity'))],['节奏门',levels.findIndex(l=>l.doors?.length)],['六边墙',levels.findIndex(l=>l.geometry==='hex'&&l.walls?.length)],['密集36块',levels.findIndex(l=>l.id==='r2-163')]]; const samples=[['基础大块',3],['异形拼块',levels.findIndex(l=>l.cells.some(c=>c.shape)&&l.cells.length>=12)],['通道与数字',levels.findIndex(l=>l.features.includes('direction')&&l.cells.some(c=>c.direction))],['双色与奇偶',levels.findIndex(l=>l.features.includes('alternation')&&l.features.includes('parity'))],['节奏门',levels.findIndex(l=>l.doors?.length)],['六边墙',levels.findIndex(l=>l.geometry==='hex'&&l.walls?.length)],['密集36块',levels.findIndex(l=>l.id==='r2-163')]];
for(const [name,index] of samples){const b=document.createElement('button');b.textContent=name;b.onclick=()=>show(index);document.querySelector('#samples').append(b);} for(const [name,index] of samples){const b=document.createElement('button');b.textContent=name;b.onclick=()=>show(index);document.querySelector('#samples').append(b);}
function boot(){ function boot(){
const [width,height]=size.value.split('×').map(Number),handlers={},frames=new Map(),canvases=[],store=new Map();let frame=0,now=100000; const [width,height]=size.value.split('×').map(Number),handlers={},frames=new Map(),canvases=[],store=new Map();let frame=0,now=100000;
@@ -28,11 +28,11 @@ function boot(){
const later=(callback,delay)=>{const timer=setTimeout(()=>{timers.delete(timer);callback();},delay);timers.add(timer);return timer;}; const later=(callback,delay)=>{const timer=setTimeout(()=>{timers.delete(timer);callback();},delay);timers.add(timer);return timer;};
const cancel=timer=>{clearTimeout(timer);timers.delete(timer);}; const cancel=timer=>{clearTimeout(timer);timers.delete(timer);};
class Clock extends Date{static now(){return now;}} class Clock extends Date{static now(){return now;}}
const wx={__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,createCanvas(){const c=document.createElement('canvas');if(!canvases.length)canvases.push(c);return c;},loadFont:p=>p.split('/').at(-1).replace('.ttf',''),getWindowInfo:()=>({windowWidth:width,windowHeight:height,pixelRatio:2,safeArea:{top:24,bottom:height-24}}),getMenuButtonBoundingClientRect:()=>({bottom:42}),getPerformance:()=>({now:()=>now}),getStorageSync:k=>store.get(k)||'',setStorageSync:(k,v)=>store.set(k,v),removeStorageSync:k=>store.delete(k),setInnerAudioOption:sound.setInnerAudioOption,createInnerAudioContext:sound.createInnerAudioContext,showModal(){},env:{USER_DATA_PATH:'/preview'}}; const wx={createImage(){const i=new Image();return {get width(){return i.naturalWidth;},get height(){return i.naturalHeight;},set onload(fn){i.onload=fn;},set onerror(fn){i.onerror=fn;},set src(v){i.src=new URL('../'+v,location.href).href;},_native:i};},__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,createCanvas(){const c=document.createElement('canvas');const p=c.getContext('2d'),draw=p.drawImage.bind(p);p.drawImage=(i,...args)=>draw(i?._native||i,...args);if(!canvases.length)canvases.push(c);return c;},loadFont:p=>p.split('/').at(-1).replace('.ttf',''),getWindowInfo:()=>({windowWidth:width,windowHeight:height,pixelRatio:2,safeArea:{top:24,bottom:height-24}}),getMenuButtonBoundingClientRect:()=>({bottom:42}),getPerformance:()=>({now:()=>now}),getStorageSync:k=>store.get(k)||'',setStorageSync:(k,v)=>store.set(k,v),removeStorageSync:k=>store.delete(k),setInnerAudioOption:sound.setInnerAudioOption,createInnerAudioContext:sound.createInnerAudioContext,showModal(){},env:{USER_DATA_PATH:'/preview'}};
for(const n of ['TouchStart','TouchMove','TouchEnd','TouchCancel','Hide','Show','WindowResize'])wx['on'+n]=cb=>handlers[n]=cb; 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,fn=>{frames.set(++frame,fn);return frame;},id=>frames.delete(id),later,cancel,Clock); const global={};new Function('wx','GameGlobal','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout','Date',source)(wx,global,fn=>{frames.set(++frame,fn);return frame;},id=>frames.delete(id),later,cancel,Clock);
const api=global.numberSwipeR2,canvas=canvases[0];canvas.style.width=width+'px';canvas.style.height=height+'px';phone.style.width=width+'px';phone.replaceChildren(canvas); const api=global.numberSwipeR2,canvas=canvases[0];canvas.style.width=width+'px';canvas.style.height=height+'px';phone.style.width=width+'px';phone.replaceChildren(canvas);
function tick(ms=16){now+=ms;const entry=frames.entries().next().value;if(entry){frames.delete(entry[0]);entry[1]();}} function tick(ms=16){now+=ms;const callbacks=[...frames.values()];frames.clear();for(const fn of callbacks)fn();}
function touch(kind,x,y){handlers[kind]({changedTouches:[{identifier:1,clientX:x,clientY:y}],touches:[{identifier:1,clientX:x,clientY:y}]});} function touch(kind,x,y){handlers[kind]({changedTouches:[{identifier:1,clientX:x,clientY:y}],touches:[{identifier:1,clientX:x,clientY:y}]});}
function tap(x,y){touch('TouchStart',x,y);touch('TouchEnd',x,y);tick();} function tap(x,y){touch('TouchStart',x,y);touch('TouchEnd',x,y);tick();}
function button(name){const b=api.buttons().find(b=>b.label===name&&b.enabled);if(!b)throw Error('缺少按钮 '+name);tap(b.x+b.w/2,b.y+b.h/2);} function button(name){const b=api.buttons().find(b=>b.label===name&&b.enabled);if(!b)throw Error('缺少按钮 '+name);tap(b.x+b.w/2,b.y+b.h/2);}
@@ -54,4 +54,14 @@ document.querySelector('#hint').onclick=()=>{runtime.button('指路');update();}
document.querySelector('#clear').onclick=()=>{const l=levels.find(l=>l.id===runtime.api.snapshot().level);runtime.button('重开');for(const id of solve(l)[0])runtime.cell(id);runtime.tick(600);update();}; document.querySelector('#clear').onclick=()=>{const l=levels.find(l=>l.id===runtime.api.snapshot().level);runtime.button('重开');for(const id of solve(l)[0])runtime.cell(id);runtime.tick(600);update();};
document.querySelector('#select').onclick=()=>{runtime.button('选关');update();}; document.querySelector('#select').onclick=()=>{runtime.button('选关');update();};
show(3);function pump(){runtime.tick();requestAnimationFrame(pump);}requestAnimationFrame(pump); show(3);function pump(){runtime.tick();requestAnimationFrame(pump);}requestAnimationFrame(pump);
const settingsCheck=document.createElement('button');settingsCheck.textContent='设置文字验收';settingsCheck.onclick=()=>{runtime.button('设置');update();};document.querySelector('.tools').append(settingsCheck);
const timingCheck=document.createElement('button');timingCheck.textContent='通关早点击验收';document.querySelector('.tools').append(timingCheck);
timingCheck.onclick=()=>{const results=[];for(const i of [3,90,162,191])for(const delay of [0,40,110,300,3000]){runtime.select(i);for(const id of solve(levels[i])[0])runtime.cell(id);runtime.tick(delay);const start=performance.now();runtime.button('下一关 →');const elapsed=performance.now()-start;if(runtime.api.snapshot().displayOrder!==i+2||runtime.api.snapshot().cleared)throw Error('切关失败');results.push({level:i+1,delay,ms:Number(elapsed.toFixed(1))});}status.textContent='PASS 20/20:通关后0/40/110/300/3000ms切关,上一关演出均取消。真实Canvas浏览器数据,iOS真机待复测。\n'+JSON.stringify(results);};
const a3Sample=document.createElement('button');a3Sample.textContent='A3软糖样张';a3Sample.onclick=()=>{show(3);runtime.cell(solve(levels[3])[0][0]);runtime.button('指路');runtime.tick(900);update();};document.querySelector('.tools').append(a3Sample);
const a3Check=document.createElement('button');a3Check.textContent='A3尺寸与动画验收';document.querySelector('.tools').append(a3Check);a3Check.onclick=()=>{
const rows=[];for(const dimensions of ['320×568','375×812','390×844','414×896'])for(const [,index]of samples){if(index<0)continue;size.value=dimensions;show(index);runtime.cell(solve(levels[index])[0][0]);runtime.button('指路');runtime.tick(1800);const before=runtime.api.snapshot();for(let n=0;n<12;n++)runtime.tick(16);const after=runtime.api.snapshot();if(after.softHint.builds!==before.softHint.builds)throw Error('静态带面重复构建');if(after.hint&&after.softHint.targetCacheHits<=before.softHint.targetCacheHits)throw Error('目标暖光未使用缓存');rows.push(dimensions+' / 第'+(index+1)+'关');}
status.textContent='PASS '+rows.length+'/'+rows.length+':A3四尺寸、七类真实关卡,静态带面未逐帧重建,目标呼吸使用缓存。浏览器验收,微信真机待验。\n'+rows.join(';');
};
</script></html> </script></html>
+5 -2
View File
@@ -5,13 +5,14 @@ export function browserAudio(report){
setInnerAudioOption(){}, setInnerAudioOption(){},
createInnerAudioContext(){ createInnerAudioContext(){
const player=new Audio();player.preload='auto';players.add(player); const player=new Audio();player.preload='auto';players.add(player);
let errorHandler=()=>{},playHandler=()=>{},generation=0; let errorHandler=()=>{},playHandler=()=>{},endedHandler=()=>{},generation=0;
const error=reason=>{ const error=reason=>{
if(disposed||reason?.name==='AbortError')return; if(disposed||reason?.name==='AbortError')return;
report('音效播放失败:'+(reason?.message||'请检查页面声音权限')); report('音效播放失败:'+(reason?.message||'请检查页面声音权限'));
errorHandler({errMsg:reason?.message||'浏览器音频加载失败'}); errorHandler({errMsg:reason?.message||'浏览器音频加载失败'});
}; };
player.addEventListener('error',()=>error(player.error)); player.addEventListener('error',()=>error(player.error));
player.addEventListener('ended',()=>{if(!disposed)endedHandler();});
return { return {
set src(path){player.src=new URL('../'+path,location.href).href;}, set src(path){player.src=new URL('../'+path,location.href).href;},
set loop(value){player.loop=value;}, set loop(value){player.loop=value;},
@@ -19,10 +20,12 @@ export function browserAudio(report){
set obeyMuteSwitch(value){}, set obeyMuteSwitch(value){},
onError(callback){errorHandler=callback;}, onError(callback){errorHandler=callback;},
onPlay(callback){playHandler=callback;}, onPlay(callback){playHandler=callback;},
onEnded(callback){endedHandler=callback;},
offEnded(callback){if(endedHandler===callback)endedHandler=()=>{};},
stop(){generation++;player.pause();if(player.readyState)player.currentTime=0;}, stop(){generation++;player.pause();if(player.readyState)player.currentTime=0;},
play(){ play(){
const current=generation; const current=generation;
player.play().then(()=>{if(!disposed&&current===generation){playHandler();report('音效已播放 · '+(++plays)+' 次');}}) player.play().then(()=>{if(!disposed&&current===generation){playHandler();report((player.loop?'音乐':'音效')+'已播放 · '+player.src.split('/').at(-1)+' · '+(++plays)+' 次');}})
.catch(reason=>{if(current===generation)error(reason);}); .catch(reason=>{if(current===generation)error(reason);});
} }
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,7 @@
Browser tab: 10, Title: "指路与玩家填色像素回归", URL: "http://127.0.0.1:17862/qa/hints.html".
0 AXWebArea 指路与玩家填色像素回归, URL: 127.0.0.1:17862/qa/hints.html
1 heading 指路与玩家填色像素回归, Value: 1
2 text 指路与玩家填色像素回归
3 text PASS:200/200 关,点击指路前后和撤销重画后,玩家已走格子的实际 Canvas 填色像素一致。左图无提示,右图开启高对比提示。
The focused UI element is 0 AXWebArea 指路与玩家填色像素回归, URL: 127.0.0.1:17862/qa/hints.html
+3 -3
View File
@@ -16,9 +16,9 @@ const source=await(await fetch('../game.js')).text();
const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id)); 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; const canvases=[],handlers={},frames=new Map(),storage=new Map();let frameId=0,now=100000;
class Clock extends Date{static now(){return now;}} class Clock extends Date{static now(){return now;}}
const wx={__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode, const wx={createImage(){const i=new Image();return {get width(){return i.naturalWidth;},get height(){return i.naturalHeight;},set onload(fn){i.onload=fn;},set onerror(fn){i.onerror=fn;},set src(v){i.src=new URL('../'+v,location.href).href;},_native:i};},__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,
loadFont:p=>p.split('/').at(-1).replace('.ttf',''), loadFont:p=>p.split('/').at(-1).replace('.ttf',''),
createCanvas(){const c=document.createElement('canvas');if(!canvases.length)canvases.push(c);return c;}, createCanvas(){const c=document.createElement('canvas');const p=c.getContext('2d'),draw=p.drawImage.bind(p);p.drawImage=(i,...args)=>draw(i?._native||i,...args);if(!canvases.length)canvases.push(c);return c;},
getWindowInfo:()=>({windowWidth:375,windowHeight:812,pixelRatio:1,safeArea:{top:44,bottom:778}}), getWindowInfo:()=>({windowWidth:375,windowHeight:812,pixelRatio:1,safeArea:{top:44,bottom:778}}),
getMenuButtonBoundingClientRect:()=>({bottom:80}),getPerformance:()=>({now:()=>now}), getMenuButtonBoundingClientRect:()=>({bottom:80}),getPerformance:()=>({now:()=>now}),
getStorageSync:k=>storage.get(k)||'',setStorageSync:(k,v)=>storage.set(k,v),removeStorageSync:k=>storage.delete(k), getStorageSync:k=>storage.get(k)||'',setStorageSync:(k,v)=>storage.set(k,v),removeStorageSync:k=>storage.delete(k),
@@ -28,7 +28,7 @@ for(const n of ['TouchStart','TouchMove','TouchEnd','TouchCancel','Hide','Show',
const global={}; 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); 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 api=global.numberSwipeR2;
const tick=(ms=16)=>{now+=ms;const entry=frames.entries().next().value;if(!entry)return;const [id,fn]=entry;frames.delete(id);fn();};tick(); const tick=(ms=16)=>{now+=ms;const callbacks=[...frames.values()];frames.clear();for(const fn of callbacks)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 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 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 s=api.snapshot(),[x,y]=s.centers[id];tap(x+s.boardRect.x,y+s.boardRect.y);}; const cell=id=>{const s=api.snapshot(),[x,y]=s.centers[id];tap(x+s.boardRect.x,y+s.boardRect.y);};
@@ -0,0 +1 @@
PASS 20/20:通关后0/40/110/300/3000ms切关,上一关演出均取消。真实Canvas浏览器数据,iOS真机待复测。 [{"level":4,"delay":0,"ms":26.3},{"level":4,"delay":40,"ms":3.7},{"level":4,"delay":110,"ms":6.6},{"level":4,"delay":300,"ms":2.7},{"level":4,"delay":3000,"ms":2.5},{"level":91,"delay":0,"ms":3.4},{"level":91,"delay":40,"ms":4.2},{"level":91,"delay":110,"ms":3.8},{"level":91,"delay":300,"ms":2.4},{"level":91,"delay":3000,"ms":2.6},{"level":163,"delay":0,"ms":4.4},{"level":163,"delay":40,"ms":4.1},{"level":163,"delay":110,"ms":4.2},{"level":163,"delay":300,"ms":3.9},{"level":163,"delay":3000,"ms":3.9},{"level":192,"delay":0,"ms":5.9},{"level":192,"delay":40,"ms":3.7},{"level":192,"delay":110,"ms":3.5},{"level":192,"delay":300,"ms":6},{"level":192,"delay":3000,"ms":3.4}]
@@ -0,0 +1,130 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (5.9102ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (0.8733ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (46.9464ms)
✔ 音频错误可诊断,不再静默吞掉 (0.7341ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (37.8488ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (23.6071ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (0.8329ms)
✔ 过关短句使用用户提供的3秒WAV,不与旧和弦叠加 (2.4877ms)
✔ jingle end, cancellation and playback error restore BGM without restarting (8.08ms)
✔ muted effects never duck BGM and unrelated effect errors cannot end jingle ducking (0.5505ms)
✔ hide and music preference do not revive playback through a late jingle callback (0.7296ms)
✔ BGMB is absent from the runtime manifest and music package (1.0822ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (91.9791ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (993.2033ms)
✔ guides through every irregular block stay inside its occupied shape (55.1344ms)
✔ visited, conflicting and hinted category cells retain their material identity (17.1667ms)
✔ five font subsets match their manifest and fit the package budget (5.7186ms)
✔ upstream adapter fails closed if the inspected render seams change (2.9204ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (32.5702ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (2.2832ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (9.4926ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (5.6814ms)
✔ fixed channels remain anchored at their block centers after number enlargement (1574.5104ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (5.6473ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.6263ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (5.1006ms)
✔ completion waits at most 120ms and ignores late audio callbacks (1.0139ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (1.1484ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (0.9185ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (1.7776ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.5243ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (1.8735ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (225.2218ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (107.8661ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (118.274ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (151.5673ms)
✔ pressure and rebound have bounded amplitude and duration (0.8228ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.4525ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (47.005ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (3.1293ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (121.6619ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (14.463ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (3.2962ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (0.7181ms)
✔ all 200 levels reveal consecutive segments without changing the player path (2441.6873ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (1.7172ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (0.6727ms)
✔ coral teaching ribbon preserves visited masks and offers direction in reduced motion (2.9315ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.6576ms)
✔ 实际教学关动画:basic (10.2107ms)
✔ 实际教学关动画:walls (1.4202ms)
✔ 实际教学关动画:straight (6.6802ms)
✔ 实际教学关动画:vertical (3.9633ms)
✔ 实际教学关动画:corner (1.5938ms)
✔ 实际教学关动画:colour (0.8024ms)
✔ 实际教学关动画:parity (0.8455ms)
✔ 实际教学关动画:rhythm-door (1.0885ms)
✔ 实际教学关动画:hex (0.8294ms)
✔ 实际教学关动画:unknown-start (1.8172ms)
✔ 200关的组合规则均能找到对应真实教学关 (147.4251ms)
✔ 教学减速,结束停留1秒后自动循环 (8.2263ms)
✔ music loops independently, persists mute and cancels in background (5.8993ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (0.9877ms)
✔ settings have exactly three independent preferences, and hint has its own cue (236.4223ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (195.7976ms)
✔ selected menu and BGMA loop are stereo PCM WAV with complete musical durations (10.0711ms)
✔ selected runtime WAVs retain the original durations after sample-rate adaptation (28.814ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (0.753ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (31077.6635ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (52.4789ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (1.176ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.1979ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (6.4606ms)
✔ quick undo cancels removed node rebound, including last-step removal (1.8244ms)
✔ navigation buttons use the same selection cue and settings include uniform colons (38.8292ms)
✔ early next during confetti cancels warming; settled ceremony reuses its background (174.7924ms)
✔ font corpus includes every settings label including music le glyph (0.6513ms)
✔ first launch goes directly to gameplay (123.2517ms)
✔ resume unfinished route after exit from gameplay (261.6998ms)
✔ resume unfinished route after exit from selection (197.5838ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (142.5187ms)
✔ replaying a cleared level does not replace the unfinished save (123.7502ms)
✔ legacy saves, malformed data and full completion have valid startup targets (97.8607ms)
✔ selection pages expose fifteen cards and fit phone safe areas (336.2497ms)
✔ hint-only frames reuse static UI, and player input invalidates it (77.9032ms)
✔ hint curves are cached and distance lookup is stable at boundaries (1.2382ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (69.3608ms)
✔ BGMA stays continuous across level changes and completion redraws (2.4107ms)
✔ subpackage completion never plays a stale scene after hiding, muting, or clearing (0.7081ms)
✔ music load failure is silent and one real resume can retry; muted pending loads stay silent (0.4878ms)
✔ runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle (181.1113ms)
✔ title is centered and selection settings share the progress and level-range row (350.0215ms)
✔ completion ducks BGMA until jingle ends and next level never restarts it (171.2163ms)
✔ title artwork is a transparent, high resolution PNG packaged locally (4.8826ms)
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (0.9964ms)
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (477.1889ms)
✔ undo button reverses seven played steps as 7,6,5,4,3,2,1 (272.0277ms)
✔ drag reverses seven played steps as 7,6,5,4,3,2,1 (195.1025ms)
✔ completion amplitude is reduced 15 percent while node, undo and restart volumes stay unchanged (1.5768ms)
✔ fast reverse coalesces to the latest descending pitch and cannot replay an old forward note (2.1951ms)
✔ all prepared routes match the immutable level hash and public rules (25.8434ms)
✔ entering next level draws the live board once and preserves the running BGMA player (1930.219ms)
✔ next-board warming advances one item per frame and cancels on deadline, replacement or hide (0.9028ms)
✔ idle material warming never changes the player path or emits sound and vibration (106.5281ms)
ℹ tests 104
ℹ suites 0
ℹ pass 104
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 31744.7934
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
@@ -0,0 +1 @@
PASS:200/200关,点击指路前后和撤销重画后,玩家已走格子的实际Canvas填色像素一致。
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -0,0 +1,143 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (9.8918ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (1.0694ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (65.2169ms)
✔ 音频错误可诊断,不再静默吞掉 (1.1338ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (108.5817ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (15.5719ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (0.8257ms)
✔ 过关短句使用用户提供的3秒WAV,不与旧和弦叠加 (2.1141ms)
✔ jingle end, cancellation and playback error restore BGM without restarting (11.547ms)
✔ muted effects never duck BGM and unrelated effect errors cannot end jingle ducking (0.6787ms)
✔ hide and music preference do not revive playback through a late jingle callback (1.9364ms)
✔ BGMB is absent from the runtime manifest and music package (1.6944ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (89.6286ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (1515.6813ms)
✔ guides through every irregular block stay inside its occupied shape (63.4561ms)
✔ visited, conflicting and hinted category cells retain their material identity (19.0667ms)
✔ five font subsets match their manifest and fit the package budget (5.3115ms)
✔ upstream adapter fails closed if the inspected render seams change (1.3091ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (36.6093ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (2.7968ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (17.0614ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (4.0689ms)
✔ fixed channels remain anchored at their block centers after number enlargement (1664.2221ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (5.5755ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.6015ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (13.6405ms)
✔ completion waits at most 120ms and ignores late audio callbacks (4.0339ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (2.1007ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (1.1535ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (1.1302ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.4838ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (11.9548ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (255.9046ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (323.3563ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (253.6439ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (256.1896ms)
✔ pressure and rebound have bounded amplitude and duration (0.5723ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.6345ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (98.1338ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (3.5604ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (178.6448ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (19.2762ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (6.9429ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (0.8126ms)
✔ all 200 levels reveal consecutive segments without changing the player path (3113.4039ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (2.9868ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (2.1693ms)
✔ soft teaching ribbon preserves visited masks without arrows in reduced motion (2.4006ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.8447ms)
✔ level-transition reset keeps completion source, gain and native playback; natural end restores BGM (5.4094ms)
✔ UI, hints, errors, restart and numeric nodes share ordinary voices while completion owns fourth channel (0.8477ms)
✔ a newer completion retains priority and stale completion callbacks cannot unduck it (0.4534ms)
✔ mute, native error and hard lifecycle stop still terminate the preserved jingle (0.745ms)
✔ real next-level tap does not stop or reload playing jingle and does not restore BGM early (376.0563ms)
✔ background stops preserved completion and ignores its late end when resuming the unfinished next level (279.6686ms)
✔ a failing next-level ordinary voice cannot release completion ducking early (0.7784ms)
✔ 实际教学关动画:basic (6.571ms)
✔ 实际教学关动画:walls (33.4329ms)
✔ 实际教学关动画:straight (2.4198ms)
✔ 实际教学关动画:vertical (5.7743ms)
✔ 实际教学关动画:corner (1.0693ms)
✔ 实际教学关动画:colour (0.7618ms)
✔ 实际教学关动画:parity (0.6378ms)
✔ 实际教学关动画:rhythm-door (0.9217ms)
✔ 实际教学关动画:hex (0.8469ms)
✔ 实际教学关动画:unknown-start (1.5717ms)
✔ 200关的组合规则均能找到对应真实教学关 (318.4051ms)
✔ 教学减速,结束停留1秒后自动循环 (3.7093ms)
✔ music loops independently, persists mute and cancels in background (9.3779ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (2.3741ms)
✔ settings have exactly three independent preferences, and hint has its own cue (415.6986ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (279.1492ms)
✔ selected menu and BGMA loop are stereo PCM WAV with complete musical durations (14.263ms)
✔ selected runtime WAVs retain the original durations after sample-rate adaptation (21.038ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (0.7343ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (37426.224ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (61.9501ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (1.9877ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.2442ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (7.7073ms)
✔ quick undo cancels removed node rebound, including last-step removal (2.6317ms)
✔ navigation buttons use the same selection cue and settings include uniform colons (48.7545ms)
✔ early next during confetti cancels warming; settled ceremony reuses its background (202.4678ms)
✔ font corpus includes every settings label including music le glyph (0.6479ms)
✔ first launch goes directly to gameplay (186.7249ms)
✔ resume unfinished route after exit from gameplay (526.302ms)
✔ resume unfinished route after exit from selection (387.8133ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (180.3974ms)
✔ replaying a cleared level does not replace the unfinished save (170.0196ms)
✔ legacy saves, malformed data and full completion have valid startup targets (152.2722ms)
✔ selection pages expose fifteen cards and fit phone safe areas (473.1698ms)
✔ hint-only frames reuse static UI, and player input invalidates it (100.3652ms)
✔ hint curves are cached and distance lookup is stable at boundaries (1.8835ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (95.6831ms)
✔ BGMA stays continuous across level changes and completion redraws (5.0744ms)
✔ subpackage completion never plays a stale scene after hiding, muting, or clearing (0.6819ms)
✔ music load failure is silent and one real resume can retry; muted pending loads stay silent (0.9205ms)
✔ runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle (235.8265ms)
✔ title is centered and selection settings share the progress and level-range row (542.0741ms)
✔ completion ducks BGMA until jingle ends and next level never restarts it (247.148ms)
✔ title artwork is a transparent, high resolution PNG packaged locally (3.2742ms)
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (0.7537ms)
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (659.6098ms)
✔ A3 width, one wide patch and brighter body preserve original design proportions (7.7278ms)
✔ ribbon caches its body, draws source-atop only offscreen and starts each frame clean (11.3619ms)
✔ one patch travels at 60px/s on the full route and does not restart when player advances (0.5258ms)
✔ reduced motion and missing canvas use a static ribbon without main-board compositing (0.7072ms)
✔ real target contours and smoothed routes stay attached to actual 200-level geometry (1801.2384ms)
✔ live target breath uses cached board layers, arrival and background stop obsolete visuals (206.814ms)
✔ undo button reverses seven played steps as 7,6,5,4,3,2,1 (587.6605ms)
✔ drag reverses seven played steps as 7,6,5,4,3,2,1 (254.5839ms)
✔ completion amplitude is reduced 15 percent while node, undo and restart volumes stay unchanged (1.7431ms)
✔ fast reverse coalesces to the latest descending pitch and cannot replay an old forward note (29.8648ms)
✔ all prepared routes match the immutable level hash and public rules (31.698ms)
✔ entering next level draws the live board once and preserves the running BGMA player (2261.4085ms)
✔ next-board warming advances one item per frame and cancels on deadline, replacement or hide (0.9236ms)
✔ idle material warming never changes the player path or emits sound and vibration (52.1869ms)
ℹ tests 117
ℹ suites 0
ℹ pass 117
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 38422.2234
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
@@ -0,0 +1,17 @@
{
"method": "Actual game bundle native-command trace using mocked audio API; not iOS timing",
"before": {
"level": 5,
"stopsOnPlayingComplete": 1,
"completeSourceUnchanged": true,
"bgmDucked": false,
"bgmVolume": 0.119
},
"after": {
"level": 5,
"stopsOnPlayingComplete": 0,
"completeSourceUnchanged": true,
"bgmDucked": true,
"bgmVolume": 0.02975
}
}
@@ -0,0 +1,4 @@
import cp from 'node:child_process';import fs from 'node:fs';import {boot} from './runtime-harness.mjs';
const routes=JSON.parse(fs.readFileSync(new URL('../src/prepared-routes.json',import.meta.url))).routes;
function inspect(source){const ui=boot(new Map(),375,812,{source});ui.select(3);routes[ui.api.snapshot().level].forEach(ui.cell);const a=ui.audioContexts.find(a=>a.src==='audio/complete.wav');let stops=0;a.stop=()=>stops++;const originalSource=a.src;ui.button('下一关 →');return {level:ui.api.snapshot().displayOrder,stopsOnPlayingComplete:stops,completeSourceUnchanged:a.src===originalSource,bgmDucked:ui.api.snapshot().music.ducked,bgmVolume:ui.api.snapshot().music.volume};}
const old=cp.execFileSync('git',['show','dc83712:wechat-campaign-200-r2/game.js'],{encoding:'utf8',maxBuffer:4*1024*1024});console.log(JSON.stringify({method:'Actual game bundle native-command trace using mocked audio API; not iOS timing',before:inspect(old),after:inspect()},null,2));
@@ -0,0 +1,26 @@
[
{
"nextLevel": 91,
"ms": 44.39
},
{
"nextLevel": 123,
"ms": 67.92
},
{
"nextLevel": 145,
"ms": 43.31
},
{
"nextLevel": 163,
"ms": 56.98
},
{
"nextLevel": 182,
"ms": 70.77
},
{
"nextLevel": 192,
"ms": 105.95
}
]
@@ -0,0 +1,32 @@
[
{
"version": "修改前",
"nextLevel": 91,
"milliseconds": 62.3
},
{
"version": "修改前",
"nextLevel": 163,
"milliseconds": 130.5
},
{
"version": "修改前",
"nextLevel": 192,
"milliseconds": 152.6
},
{
"version": "优化后",
"nextLevel": 91,
"milliseconds": 13.1
},
{
"version": "优化后",
"nextLevel": 163,
"milliseconds": 8.4
},
{
"version": "优化后",
"nextLevel": 192,
"milliseconds": 7.8
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><style>body{margin:0;padding:20px;width:560px;background:#fff6df;color:#43382f;font:16px 'Microsoft YaHei',sans-serif}h2{font-size:21px;margin:0 0 10px}table{border-collapse:collapse;width:100%;background:#fffbf2;border-radius:12px}th,td{padding:10px;text-align:center;border-bottom:1px solid #eddfc7}td:last-child{color:#326443;font-weight:bold}p{font-size:12px;color:#71604f}</style><h2>下一关点击响应 · 真实Canvas抽测</h2><table><tr><th>目标关卡</th><th>修改前</th><th>优化后</th></tr><tr><td>第91关</td><td>62.3 ms</td><td>13.1 ms</td></tr><tr><td>第163关</td><td>130.5 ms</td><td>8.4 ms</td></tr><tr><td>第192关</td><td>152.6 ms</td><td>7.8 ms</td></tr></table><p>同机浏览器、375×812;包含切关到首帧绘制。真机仍需复测。<br>预计算路线+去重绘制+下一关逐帧材质预热;不影响规则。</p>
@@ -0,0 +1,104 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (3.9885ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (0.7226ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (43.816ms)
✔ 音频错误可诊断,不再静默吞掉 (0.4879ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (17.705ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (10.1978ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (0.67ms)
✔ 过关和弦收尾持续衰减,不在后半段重新起音 (2.9896ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (47.5019ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (610.3905ms)
✔ guides through every irregular block stay inside its occupied shape (29.4299ms)
✔ visited, conflicting and hinted category cells retain their material identity (10.7152ms)
✔ five font subsets match their manifest and fit the package budget (3.8633ms)
✔ upstream adapter fails closed if the inspected render seams change (0.7606ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (18.2888ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (1.8222ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (8.1804ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (2.6229ms)
✔ fixed channels remain anchored at their block centers after number enlargement (781.7528ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (4.1184ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.3466ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (2.9218ms)
✔ completion waits at most 120ms and ignores late audio callbacks (0.5196ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (0.4533ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (0.3297ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (0.5018ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.3539ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (1.3157ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (121.2167ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (60.7207ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (44.9951ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (78.4011ms)
✔ pressure and rebound have bounded amplitude and duration (0.3839ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.2255ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (18.5497ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (1.2874ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (75.56ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (7.6609ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (3.5196ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (0.5284ms)
✔ all 200 levels reveal consecutive segments without changing the player path (1330.6071ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (0.8237ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (0.3012ms)
✔ coral teaching ribbon preserves visited masks and offers direction in reduced motion (1.3822ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.2984ms)
✔ 实际教学关动画:basic (4.1589ms)
✔ 实际教学关动画:walls (0.8719ms)
✔ 实际教学关动画:straight (1.9467ms)
✔ 实际教学关动画:vertical (2.6791ms)
✔ 实际教学关动画:corner (1.1548ms)
✔ 实际教学关动画:colour (0.5767ms)
✔ 实际教学关动画:parity (0.515ms)
✔ 实际教学关动画:rhythm-door (0.704ms)
✔ 实际教学关动画:hex (0.6488ms)
✔ 实际教学关动画:unknown-start (1.7224ms)
✔ 200关的组合规则均能找到对应真实教学关 (91.9189ms)
✔ 教学减速,结束停留1秒后自动循环 (4.4562ms)
✔ music loops independently, persists mute and cancels in background (1.613ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (0.7279ms)
✔ settings have exactly three independent preferences, and hint has its own cue (124.8702ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (107.024ms)
✔ two original music loops are quiet, nonempty and have a smooth loop seam (1.5419ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (24773.4619ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (50.2723ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (1.4843ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.3ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (6.1374ms)
✔ quick undo cancels removed node rebound, including last-step removal (1.9203ms)
✔ first launch goes directly to gameplay (86.9461ms)
✔ resume unfinished route after exit from gameplay (105.339ms)
✔ resume unfinished route after exit from selection (89.2179ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (75.952ms)
✔ replaying a cleared level does not replace the unfinished save (79.5395ms)
✔ legacy saves, malformed data and full completion have valid startup targets (56.1729ms)
✔ selection pages expose fifteen cards and fit phone safe areas (247.9721ms)
✔ hint-only frames reuse static UI, and player input invalidates it (46.1472ms)
✔ hint curves are cached and distance lookup is stable at boundaries (1.1599ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (39.2951ms)
ℹ tests 78
ℹ suites 0
ℹ pass 78
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 25049.2178
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@@ -0,0 +1,109 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (9.0119ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (0.9311ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (47.2484ms)
✔ 音频错误可诊断,不再静默吞掉 (0.5926ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (77.8758ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (18.6892ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (0.6079ms)
✔ 过关和弦收尾持续衰减,不在后半段重新起音 (3.0069ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (129.6431ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (923.4894ms)
✔ guides through every irregular block stay inside its occupied shape (59.9688ms)
✔ visited, conflicting and hinted category cells retain their material identity (19.912ms)
✔ five font subsets match their manifest and fit the package budget (6.5877ms)
✔ upstream adapter fails closed if the inspected render seams change (1.9187ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (30.7559ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (2.2727ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (7.147ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (3.0655ms)
✔ fixed channels remain anchored at their block centers after number enlargement (1269.7132ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (4.1897ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.4945ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (6.0019ms)
✔ completion waits at most 120ms and ignores late audio callbacks (1.2469ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (1.9119ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (1.3278ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (0.7516ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.4369ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (15.9339ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (267.092ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (97.6956ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (76.8074ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (166.166ms)
✔ pressure and rebound have bounded amplitude and duration (0.4204ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.4006ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (35.9257ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (3.0347ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (81.7164ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (9.9401ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (3.6375ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (0.9455ms)
✔ all 200 levels reveal consecutive segments without changing the player path (2293.2505ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (1.0414ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (0.4072ms)
✔ coral teaching ribbon preserves visited masks and offers direction in reduced motion (1.7181ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.4092ms)
✔ 实际教学关动画:basic (5.1841ms)
✔ 实际教学关动画:walls (1.1257ms)
✔ 实际教学关动画:straight (2.8009ms)
✔ 实际教学关动画:vertical (6.6101ms)
✔ 实际教学关动画:corner (2.6063ms)
✔ 实际教学关动画:colour (2.0917ms)
✔ 实际教学关动画:parity (0.8465ms)
✔ 实际教学关动画:rhythm-door (1.214ms)
✔ 实际教学关动画:hex (1.4503ms)
✔ 实际教学关动画:unknown-start (4.5831ms)
✔ 200关的组合规则均能找到对应真实教学关 (216.7609ms)
✔ 教学减速,结束停留1秒后自动循环 (8.4881ms)
✔ music loops independently, persists mute and cancels in background (4.1939ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (1.1354ms)
✔ settings have exactly three independent preferences, and hint has its own cue (242.1052ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (161.9424ms)
✔ two original music loops are quiet, nonempty and have a smooth loop seam (7.5788ms)
✔ warm gameplay ambient is 32 seconds, softly leveled and free from percussive envelope jumps (31.2164ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (0.8613ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (29378.3694ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (40.0552ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (1.0956ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.1883ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (6.2144ms)
✔ quick undo cancels removed node rebound, including last-step removal (1.9216ms)
✔ first launch goes directly to gameplay (136.5207ms)
✔ resume unfinished route after exit from gameplay (169.5016ms)
✔ resume unfinished route after exit from selection (204.0092ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (102.4366ms)
✔ replaying a cleared level does not replace the unfinished save (95.3532ms)
✔ legacy saves, malformed data and full completion have valid startup targets (82.9585ms)
✔ selection pages expose fifteen cards and fit phone safe areas (433.5342ms)
✔ hint-only frames reuse static UI, and player input invalidates it (63.2693ms)
✔ hint curves are cached and distance lookup is stable at boundaries (1.0996ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (46.259ms)
✔ title artwork is a transparent, high resolution PNG packaged locally (3.9438ms)
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (1.4977ms)
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (414.5903ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 29896.8672
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
@@ -1,18 +1,18 @@
import fs from 'node:fs';import vm from 'node:vm';import assert from 'node:assert/strict';import path from 'node:path';import {fileURLToPath} from 'node:url'; import fs from 'node:fs';import vm from 'node:vm';import assert from 'node:assert/strict';import path from 'node:path';import {fileURLToPath} from 'node:url';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const pack=JSON.parse(fs.readFileSync(path.join(root,'src/upstream/levels.json'))),ordered=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id)); const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const pack=JSON.parse(fs.readFileSync(path.join(root,'src/upstream/levels.json'))),ordered=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));
export function boot(storage = new Map(), width = 375, height = 812) { export function boot(storage = new Map(), width = 375, height = 812, {source} = {}) {
const handlers = {}, frames = new Map(), played=[]; const handlers = {}, frames = new Map(), played=[],vibrated=[],audioContexts=[];
let frameId = 0,now=100000; let frameId = 0,now=100000;
class Clock extends Date {static now(){return now;}} class Clock extends Date {static now(){return now;}}
const text = [],images=[]; 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)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) }); const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({source:image.source||null,x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||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' } }; const wx = {createImage:()=>({set src(value){const b=fs.readFileSync(path.join(root,value));this.width=b.readUInt32BE(16);this.height=b.readUInt32BE(20);this.source=value;this.onload?.();}}),vibrateShort:o=>vibrated.push({type:o.type,at:now}),setInnerAudioOption:()=>{},createInnerAudioContext:()=>{const a={stop(){},onError(fn){this.error=fn;},onEnded(fn){this.ended=fn;},offEnded(fn){if(this.ended===fn)this.ended=null;},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}};audioContexts.push(a);return a;}, 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' } };
wx.loadFont=p=>{assert.ok(fs.existsSync(path.join(root,p)),'字体必须随包提供 '+p);return path.basename(p,'.ttf');}; wx.loadFont=p=>{assert.ok(fs.existsSync(path.join(root,p)),'字体必须随包提供 '+p);return path.basename(p,'.ttf');};
for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize']) for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize'])
wx['on' + name] = cb => handlers[name] = cb; wx['on' + name] = cb => handlers[name] = cb;
const global = {}; 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 }); vm.runInNewContext(source||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 entry=frames.entries().next().value;if(!entry)return;const [id,fn]=entry; frames.delete(id);text.length=0;images.length=0; fn(); }; const tick = () => { const callbacks=[...frames.values()];frames.clear();text.length=0;images.length=0;for(const fn of callbacks)fn(); };
tick(); tick();
const touch = (kind, x, y) => handlers[kind]({ changedTouches: [{ identifier: 1, clientX: x, clientY: y }], touches: [{ identifier: 1, clientX: x, clientY: y }] }); 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 tap = (x, y) => { touch('TouchStart', x, y); touch('TouchEnd', x, y); tick(); };
@@ -25,5 +25,5 @@ export function boot(storage = new Map(), width = 375, height = 812) {
assert.ok(++steps < 25); assert.ok(++steps < 25);
} button(api.buttons().some(b => b.label === label) ? label : label + ' ✓'); if (api.buttons().some(b => b.label === '返回游戏')) } 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); }; 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 }; return { advance(ms){now+=ms;tick();}, api, button, cell, select, tick, handlers, frames, storage, text, played,images,vibrated,audioContexts };
} }
@@ -0,0 +1,115 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (14.9009ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (2.1632ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (54.554ms)
✔ 音频错误可诊断,不再静默吞掉 (1.5022ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (50.2806ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (34.0885ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (0.6778ms)
✔ 过关短句使用用户提供的3秒WAV,不与旧和弦叠加 (2.7838ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (94.9455ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (1024.4193ms)
✔ guides through every irregular block stay inside its occupied shape (38.5314ms)
✔ visited, conflicting and hinted category cells retain their material identity (13.3387ms)
✔ five font subsets match their manifest and fit the package budget (4.7748ms)
✔ upstream adapter fails closed if the inspected render seams change (1.1066ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (23.2625ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (2.0215ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (11.0858ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (3.2947ms)
✔ fixed channels remain anchored at their block centers after number enlargement (1060.741ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (2.5724ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.458ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (6.5873ms)
✔ completion waits at most 120ms and ignores late audio callbacks (1.2868ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (1.6916ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (1.0837ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (0.6282ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.38ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (1.3569ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (207.1856ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (148.4917ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (86.424ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (134.7882ms)
✔ pressure and rebound have bounded amplitude and duration (0.6392ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.5552ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (46.156ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (3.0031ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (109.6829ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (17.1175ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (5.642ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (9.2333ms)
✔ all 200 levels reveal consecutive segments without changing the player path (2016.9027ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (1.5701ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (0.6078ms)
✔ coral teaching ribbon preserves visited masks and offers direction in reduced motion (2.9612ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.6602ms)
✔ 实际教学关动画:basic (5.3181ms)
✔ 实际教学关动画:walls (1.0642ms)
✔ 实际教学关动画:straight (2.4424ms)
✔ 实际教学关动画:vertical (7.5609ms)
✔ 实际教学关动画:corner (1.7796ms)
✔ 实际教学关动画:colour (2.6619ms)
✔ 实际教学关动画:parity (1.9387ms)
✔ 实际教学关动画:rhythm-door (0.9813ms)
✔ 实际教学关动画:hex (0.9572ms)
✔ 实际教学关动画:unknown-start (2.9763ms)
✔ 200关的组合规则均能找到对应真实教学关 (147.2545ms)
✔ 教学减速,结束停留1秒后自动循环 (3.0083ms)
✔ music loops independently, persists mute and cancels in background (2.8131ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (0.9428ms)
✔ settings have exactly three independent preferences, and hint has its own cue (238.1837ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (167.2175ms)
✔ selected menu and both level loops are stereo PCM WAV with complete musical durations (21.4585ms)
✔ selected four runtime WAVs retain the original durations after sample-rate adaptation (40.46ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (2.8611ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (27899.3609ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (58.2145ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (1.3006ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.4739ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (7.5203ms)
✔ quick undo cancels removed node rebound, including last-step removal (2.1063ms)
✔ first launch goes directly to gameplay (148.3509ms)
✔ resume unfinished route after exit from gameplay (194.5898ms)
✔ resume unfinished route after exit from selection (180.1794ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (120.5731ms)
✔ replaying a cleared level does not replace the unfinished save (165.7154ms)
✔ legacy saves, malformed data and full completion have valid startup targets (86.919ms)
✔ selection pages expose fifteen cards and fit phone safe areas (337.4545ms)
✔ hint-only frames reuse static UI, and player input invalidates it (59.5498ms)
✔ hint curves are cached and distance lookup is stable at boundaries (0.9446ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (52.7928ms)
✔ entering a level chooses either track once; redraw, mute and background resume keep that choice (2.0173ms)
✔ subpackage completion never plays a stale scene after hiding, muting, or clearing (0.592ms)
✔ music load failure is silent and one real resume can retry; muted pending loads stay silent (0.3667ms)
✔ runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle (279.7373ms)
✔ title is centered and selection settings share the progress and level-range row (294.8849ms)
✔ completion pauses background loop, plays one selected jingle and the next level resumes a loop (172.4642ms)
✔ title artwork is a transparent, high resolution PNG packaged locally (3.6602ms)
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (0.8729ms)
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (406.3256ms)
ℹ tests 89
ℹ suites 0
ℹ pass 89
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 28380.4433
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
@@ -0,0 +1 @@
音效已播放 · complete.wav · 3 次
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+1 -1
View File
@@ -6,5 +6,5 @@ const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
http.createServer((req,res)=>{ http.createServer((req,res)=>{
const file=path.resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://localhost').pathname)); const file=path.resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://localhost').pathname));
if(!file.startsWith(root+path.sep)){res.writeHead(403).end();return;} 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('.wav')?'audio/wav':file.endsWith('.json')?'application/json':'text/javascript; charset=utf-8');res.end(fs.readFileSync(file));}catch{res.writeHead(404).end();} try{res.setHeader('Content-Type',file.endsWith('.html')?'text/html; charset=utf-8':file.endsWith('.png')?'image/png':file.endsWith('.wav')?'audio/wav':file.endsWith('.json')?'application/json':'text/javascript; charset=utf-8');res.end(fs.readFileSync(file));}catch{res.writeHead(404).end();}
}).listen(Number(process.env.NUMBERSWIPE_QA_PORT||17859),'127.0.0.1',()=>console.log('Canvas QA: http://127.0.0.1:17859/qa/hints.html')); }).listen(Number(process.env.NUMBERSWIPE_QA_PORT||17859),'127.0.0.1',()=>console.log('Canvas QA: http://127.0.0.1:17859/qa/hints.html'));
@@ -0,0 +1,127 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (5.9719ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (0.7505ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (49.8525ms)
✔ 音频错误可诊断,不再静默吞掉 (0.5119ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (27.1585ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (13.4334ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (7.7487ms)
✔ 过关短句使用用户提供的3秒WAV,不与旧和弦叠加 (1.9568ms)
✔ jingle end, cancellation and playback error restore BGM without restarting (4.24ms)
✔ muted effects never duck BGM and unrelated effect errors cannot end jingle ducking (0.7256ms)
✔ hide and music preference do not revive playback through a late jingle callback (2.6609ms)
✔ BGMB is absent from the runtime manifest and music package (1.4668ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (89.6057ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (910.9608ms)
✔ guides through every irregular block stay inside its occupied shape (38.2585ms)
✔ visited, conflicting and hinted category cells retain their material identity (13.1484ms)
✔ five font subsets match their manifest and fit the package budget (3.7437ms)
✔ upstream adapter fails closed if the inspected render seams change (0.8671ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (20.4011ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (1.3968ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (9.1895ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (2.4161ms)
✔ fixed channels remain anchored at their block centers after number enlargement (1096.8783ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (3.4084ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.336ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (4.69ms)
✔ completion waits at most 120ms and ignores late audio callbacks (1.528ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (0.7758ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (0.9678ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (0.5194ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.3432ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (4.27ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (197.6697ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (93.488ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (85.5951ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (117.3508ms)
✔ pressure and rebound have bounded amplitude and duration (0.6393ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.3588ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (47.8573ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (2.7155ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (104.3303ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (11.5992ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (2.6982ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (0.6567ms)
✔ all 200 levels reveal consecutive segments without changing the player path (1831.3425ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (1.4713ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (0.5245ms)
✔ coral teaching ribbon preserves visited masks and offers direction in reduced motion (2.4201ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.6225ms)
✔ 实际教学关动画:basic (5.4459ms)
✔ 实际教学关动画:walls (1.3921ms)
✔ 实际教学关动画:straight (2.2782ms)
✔ 实际教学关动画:vertical (7.57ms)
✔ 实际教学关动画:corner (2.7113ms)
✔ 实际教学关动画:colour (1.7488ms)
✔ 实际教学关动画:parity (0.644ms)
✔ 实际教学关动画:rhythm-door (0.75ms)
✔ 实际教学关动画:hex (0.6395ms)
✔ 实际教学关动画:unknown-start (1.3742ms)
✔ 200关的组合规则均能找到对应真实教学关 (166.655ms)
✔ 教学减速,结束停留1秒后自动循环 (3.3536ms)
✔ music loops independently, persists mute and cancels in background (4.7913ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (0.8271ms)
✔ settings have exactly three independent preferences, and hint has its own cue (223.1428ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (248.4456ms)
✔ selected menu and BGMA loop are stereo PCM WAV with complete musical durations (15.0776ms)
✔ selected runtime WAVs retain the original durations after sample-rate adaptation (25.2474ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (0.4916ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (29009.0033ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (65.8824ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (2.9116ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.3386ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (10.7241ms)
✔ quick undo cancels removed node rebound, including last-step removal (2.5595ms)
✔ first launch goes directly to gameplay (133.5197ms)
✔ resume unfinished route after exit from gameplay (247.5837ms)
✔ resume unfinished route after exit from selection (153.6188ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (99.8722ms)
✔ replaying a cleared level does not replace the unfinished save (95.6296ms)
✔ legacy saves, malformed data and full completion have valid startup targets (68.3508ms)
✔ selection pages expose fifteen cards and fit phone safe areas (254.6395ms)
✔ hint-only frames reuse static UI, and player input invalidates it (63.1021ms)
✔ hint curves are cached and distance lookup is stable at boundaries (1.2907ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (47.8203ms)
✔ BGMA stays continuous across level changes and completion redraws (2.0839ms)
✔ subpackage completion never plays a stale scene after hiding, muting, or clearing (0.5922ms)
✔ music load failure is silent and one real resume can retry; muted pending loads stay silent (0.4568ms)
✔ runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle (151.6511ms)
✔ title is centered and selection settings share the progress and level-range row (354.3931ms)
✔ completion ducks BGMA until jingle ends and next level never restarts it (120.0796ms)
✔ title artwork is a transparent, high resolution PNG packaged locally (7.1401ms)
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (0.6299ms)
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (432.4844ms)
✔ undo button reverses seven played steps as 7,6,5,4,3,2,1 (266.8512ms)
✔ drag reverses seven played steps as 7,6,5,4,3,2,1 (181.5857ms)
✔ completion amplitude is reduced 15 percent while node, undo and restart volumes stay unchanged (1.6029ms)
✔ fast reverse coalesces to the latest descending pitch and cannot replay an old forward note (2.7327ms)
✔ all prepared routes match the immutable level hash and public rules (18.0142ms)
✔ entering next level draws the live board once and preserves the running BGMA player (1386.9742ms)
✔ next-board warming advances one item per frame and cancels on deadline, replacement or hide (0.7355ms)
✔ idle material warming never changes the player path or emits sound and vibration (36.9474ms)
ℹ tests 101
ℹ suites 0
ℹ pass 101
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 29450.7015
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
@@ -0,0 +1 @@
PASS:200/200 关,点击指路前后和撤销重画后,玩家已走格子的实际 Canvas 填色像素一致。浏览器真实Canvas,375x812。
@@ -0,0 +1,106 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (4.2496ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (0.4588ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (58.1285ms)
✔ 音频错误可诊断,不再静默吞掉 (1.3962ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (41.1368ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (13.1034ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (0.7765ms)
✔ 过关和弦收尾持续衰减,不在后半段重新起音 (2.9054ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (68.7005ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (570.0971ms)
✔ guides through every irregular block stay inside its occupied shape (30.9901ms)
✔ visited, conflicting and hinted category cells retain their material identity (7.9789ms)
✔ five font subsets match their manifest and fit the package budget (3.2457ms)
✔ upstream adapter fails closed if the inspected render seams change (0.7175ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (15.739ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (1.4517ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (6.6306ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (2.1955ms)
✔ fixed channels remain anchored at their block centers after number enlargement (780.5029ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (2.334ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.2499ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (3.9613ms)
✔ completion waits at most 120ms and ignores late audio callbacks (0.818ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (0.7328ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (0.5939ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (0.5278ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.359ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (1.3903ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (125.9551ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (65.1831ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (47.9813ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (71.153ms)
✔ pressure and rebound have bounded amplitude and duration (0.3831ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.4001ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (24.6483ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (1.6418ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (67.065ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (9.7041ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (2.5105ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (0.6003ms)
✔ all 200 levels reveal consecutive segments without changing the player path (1327.0448ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (0.7515ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (0.3026ms)
✔ coral teaching ribbon preserves visited masks and offers direction in reduced motion (1.3771ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.3138ms)
✔ 实际教学关动画:basic (4.3763ms)
✔ 实际教学关动画:walls (0.859ms)
✔ 实际教学关动画:straight (2.2734ms)
✔ 实际教学关动画:vertical (3.0943ms)
✔ 实际教学关动画:corner (0.9779ms)
✔ 实际教学关动画:colour (0.7234ms)
✔ 实际教学关动画:parity (0.6127ms)
✔ 实际教学关动画:rhythm-door (0.9466ms)
✔ 实际教学关动画:hex (0.8683ms)
✔ 实际教学关动画:unknown-start (2.3708ms)
✔ 200关的组合规则均能找到对应真实教学关 (99.1989ms)
✔ 教学减速,结束停留1秒后自动循环 (3.14ms)
✔ music loops independently, persists mute and cancels in background (3.5682ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (1.237ms)
✔ settings have exactly three independent preferences, and hint has its own cue (126.7275ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (101.1832ms)
✔ two original music loops are quiet, nonempty and have a smooth loop seam (1.4324ms)
✔ warm gameplay ambient is 32 seconds, softly leveled and free from percussive envelope jumps (12.8053ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (0.3121ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (25674.1428ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (48.3745ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (1.2116ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.221ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (5.8198ms)
✔ quick undo cancels removed node rebound, including last-step removal (2.0522ms)
✔ first launch goes directly to gameplay (84.5281ms)
✔ resume unfinished route after exit from gameplay (109.8065ms)
✔ resume unfinished route after exit from selection (100.4282ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (67.2269ms)
✔ replaying a cleared level does not replace the unfinished save (74.6262ms)
✔ legacy saves, malformed data and full completion have valid startup targets (50.4716ms)
✔ selection pages expose fifteen cards and fit phone safe areas (225.6503ms)
✔ hint-only frames reuse static UI, and player input invalidates it (40.9483ms)
✔ hint curves are cached and distance lookup is stable at boundaries (1.2217ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (38.2212ms)
ℹ tests 80
ℹ suites 0
ℹ pass 80
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 25971.4086
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
+10 -3
View File
@@ -1,9 +1,16 @@
{ {
"channel": "main", "channel": "main",
"version": "2026-10-23-main-layered-feedback", "version": "2026-10-23-jingle-through-next",
"date": "2026-10-23", "date": "2026-10-23",
"baseVersion": "2026-10-22-resume-chapter-lessons", "baseVersion": "2026-10-22-resume-chapter-lessons",
"baseMainSHA256": "bb35d5edf8d96eb3253c78052d219b32f9a4241efd9971537f6c9c8896188125", "baseMainSHA256": "bb35d5edf8d96eb3253c78052d219b32f9a4241efd9971537f6c9c8896188125",
"package": "NumberSwipe-main-20261023.zip", "package": "NumberSwipe-main-A3过关音乐跨关续播-20261023.zip",
"promotedFrom": "2026-10-22-layered-feedback-music-flow" "promotedFrom": "2026-10-22-layered-feedback-music-flow",
"previousVersion": "2026-10-23-soft-ribbon-a3-bright",
"baseCommit": "dc83712",
"includes": [
"ios-clear-ui-fix",
"soft-ribbon-a3-bright",
"jingle-through-next"
]
} }
@@ -0,0 +1,8 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import crypto from 'node:crypto';import {Music} from './src/music.mjs';import {boot} from './qa/runtime-harness.mjs';import {solve} from './src/upstream/rules.mjs';import {titleLayout,selectionRow} from './src/selection-title.mjs';
const stub=()=>{const calls=[];return {calls,api:{getStorageSync:()=>'',setStorageSync(){},createInnerAudioContext:()=>({stop(){calls.push('stop');},play(){calls.push(this.src);},onError(){}})}};};
test('BGMA stays continuous across level changes and completion redraws',()=>{const s=stub(),m=new Music(s.api);m.scene('play','one');const calls=s.calls.length;for(let i=0;i<20;i++)m.scene('play','one');m.scene('complete','one');m.scene('play','two');assert.equal(s.calls.length,calls);assert.equal(m.audio.src,'music/play-a.wav');assert.equal(m.audio.loop,true);assert.equal(m.audio.volume,.119);m.duck();assert.equal(m.audio.volume,.02975);m.scene('play','three');assert.equal(s.calls.length,calls);m.unduck();assert.equal(m.audio.volume,.119);});
test('subpackage completion never plays a stale scene after hiding, muting, or clearing',()=>{const s=stub();let task;s.api.loadSubpackage=o=>task=o;const m=new Music(s.api,{random:()=>.1});m.scene('play','one');assert.equal(s.calls.length,0);m.suspend();task.success();assert.equal(s.calls.length,0);m.resume();assert.equal(m.audio.src,'music/play-a.wav');m.scene('complete','one');assert.equal(m.playing,'play');m.resume();assert.equal(m.playing,'play');m.scene('select','one');assert.equal(m.audio.src,'music/select.wav');});
test('music load failure is silent and one real resume can retry; muted pending loads stay silent',()=>{const s=stub();let task,calls=0;s.api.loadSubpackage=o=>{task=o;calls++;};const m=new Music(s.api);m.scene('select');task.fail();m.scene('select');assert.equal(calls,1);m.resume();assert.equal(calls,2);m.toggle();task.success();assert.equal(s.calls.length,0);m.toggle();assert.equal(m.audio.src,'music/select.wav');});
test('runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle',()=>{const meta=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));for(const [name,item]of Object.entries(meta)){for(const [file,hash]of [[item.output,item.sha256],['.audio-sources/'+name+'.wav',item.sourceSHA256]])assert.equal(crypto.createHash('sha256').update(fs.readFileSync(new URL('./'+file,import.meta.url))).digest('hex'),hash);}const config=JSON.parse(fs.readFileSync(new URL('./game.json',import.meta.url)));assert.ok(config.subpackages.some(p=>p.name==='music'&&p.root==='music/'));assert.ok(fs.existsSync(new URL('./music/game.js',import.meta.url)));let main=0;for(const folder of ['audio','audio-original','fonts','images'])for(const file of fs.readdirSync(new URL('./'+folder,import.meta.url)))main+=fs.statSync(new URL('./'+folder+'/'+file,import.meta.url)).size;main+=fs.statSync(new URL('./game.js',import.meta.url)).size;assert.ok(main<4*1024*1024,main);});
test('title is centered and selection settings share the progress and level-range row',()=>{for(const [w,h]of [[320,568],[375,667],[390,844]]){const ui=boot(new Map(),w,h);ui.select(3);ui.button('选关');const image=ui.images.find(i=>i.source==='images/title-reference.png'),settings=ui.api.buttons().find(b=>b.label==='设置');assert.equal(image.x+image.w/2,w/2);assert.equal(settings.y,selectionRow(w,92).y);assert.ok(settings.y>image.y+image.h);const cards=ui.api.buttons().filter(b=>/^\d+( ✓)?$/.test(b.label));assert.equal(cards.length,15);assert.ok(cards.every(b=>b.h>=44));}});
test('completion ducks BGMA until jingle ends and next level never restarts it',()=>{const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),ui=boot();ui.select(3);const level=pack.levels.find(l=>l.id===pack.playOrder[3]);const plays=()=>ui.played.filter(f=>f==='music/play-a.wav').length;const before=plays();solve(level)[0].forEach(id=>{ui.advance(80);ui.cell(id);});assert.equal(ui.api.snapshot().music.playing,'play');assert.equal(ui.api.snapshot().music.ducked,true);assert.equal(ui.api.snapshot().music.volume,.02975);assert.equal(ui.played.filter(f=>f==='audio/complete.wav').length,1);ui.audioContexts.find(a=>a.src==='audio/complete.wav').ended();assert.equal(ui.api.snapshot().music.ducked,false);assert.equal(ui.api.snapshot().music.volume,.119);ui.button('下一关 →');assert.equal(ui.api.snapshot().music.track,'music/play-a.wav');assert.equal(plays(),before);});
@@ -0,0 +1,4 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {loadSelectionTitle,paintSelectionTitle,titleLayout} from './src/selection-title.mjs';import {chapterLayout} from './src/level-select.mjs';import {boot} from './qa/runtime-harness.mjs';
test('title artwork is a transparent, high resolution PNG packaged locally',()=>{const b=fs.readFileSync(new URL('./images/title-reference.png',import.meta.url));assert.equal(b.subarray(1,4).toString(),'PNG');assert.equal(b.readUInt32BE(16),2132);assert.equal(b.readUInt32BE(20),737);assert.equal(b[25],6);});
test('asynchronous native image load requests a repaint and draws the artwork without a font substitute',()=>{const image={width:2132,height:737},draws=[];let invalidations=0;loadSelectionTitle({createImage:()=>image},()=>invalidations++);assert.equal(image.src,'images/title-reference.png');paintSelectionTitle({drawImage:(...args)=>draws.push(args)},375,92);assert.equal(draws.length,0);image.onload();assert.equal(invalidations,1);paintSelectionTitle({drawImage:(...args)=>draws.push(args)},375,92);assert.equal(draws[0][0],image);assert.equal(draws[0][3],224);assert.ok(Math.abs(draws[0][4]/draws[0][3]-737/2132)<1e-10);});
test('artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes',()=>{for(const [width,height]of [[320,568],[375,667],[390,844],[414,896]]){const b=titleLayout(width,92),grid=chapterLayout(width,height,92,34);assert.ok(b.y+b.w*737/2132<b.pillY);assert.equal(b.x+b.w/2,width/2);assert.ok(b.x>=16&&b.x+b.w<=width-16);assert.ok(b.pillY+24<grid.y);assert.ok(grid.h>=44&&grid.w>=44);const ui=boot(new Map(),width,height);ui.select(3);ui.button('选关');const logo=ui.images.find(i=>i.source==='images/title-reference.png');assert.ok(logo);assert.ok(logo.w>180);assert.equal(ui.api.buttons().filter(b=>/^\d+( ✓)?$/.test(b.label)).length,15);}});
+4 -4
View File
@@ -19,14 +19,14 @@ function boot(storage = new Map(), width = 375, height = 812) {
let frameId = 0,now=100000; let frameId = 0,now=100000;
class Clock extends Date {static now(){return now;}} class Clock extends Date {static now(){return now;}}
const text = [],images=[]; 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)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) }); const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({source:image.source||null,x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||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' } }; const wx = {createImage:()=>({set src(value){const b=fs.readFileSync(path.join(root,value));this.width=b.readUInt32BE(16);this.height=b.readUInt32BE(20);this.source=value;this.onload?.();}}),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' } };
wx.loadFont=p=>{assert.ok(fs.existsSync(path.join(root,p)),'字体必须随包提供 '+p);return path.basename(p,'.ttf');}; wx.loadFont=p=>{assert.ok(fs.existsSync(path.join(root,p)),'字体必须随包提供 '+p);return path.basename(p,'.ttf');};
for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize']) for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize'])
wx['on' + name] = cb => handlers[name] = cb; wx['on' + name] = cb => handlers[name] = cb;
const global = {}; 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 }); 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 entry=frames.entries().next().value;if(!entry)return;const [id,fn]=entry; frames.delete(id);text.length=0;images.length=0; fn(); }; const tick = () => { const callbacks=[...frames.values()];frames.clear();text.length=0;images.length=0;for(const fn of callbacks)fn(); };
tick(); tick();
const touch = (kind, x, y) => handlers[kind]({ changedTouches: [{ identifier: 1, clientX: x, clientY: y }], touches: [{ identifier: 1, clientX: x, clientY: y }] }); 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 tap = (x, y) => { touch('TouchStart', x, y); touch('TouchEnd', x, y); tick(); };
@@ -96,7 +96,7 @@ const restored = boot(ui.storage, 320, 640);
assert.equal(restored.api.snapshot().level, ordered[199].id); assert.equal(restored.api.snapshot().level, ordered[199].id);
assert.equal(JSON.parse(ui.storage.get('variant:campaign-200-r2:v1')).length, 200); assert.equal(JSON.parse(ui.storage.get('variant:campaign-200-r2:v1')).length, 200);
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('规则');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('设置');restored.button('音效开');restored.button('完成'); restored.button('设置');restored.button('音效:开');restored.button('完成');
const reopen = boot(ui.storage); const reopen = boot(ui.storage);
assert.equal(reopen.api.snapshot().soundEnabled, false); assert.equal(reopen.api.snapshot().soundEnabled, false);
// 在多种手机尺寸下验证通关后的真实按钮、绘制区域与点击跳转。 // 在多种手机尺寸下验证通关后的真实按钮、绘制区域与点击跳转。
+13
View File
@@ -0,0 +1,13 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
import {SoftHintPainter,softHintTokens as T,softHighlightAt,targetBreath} from './src/soft-hint.mjs';
import {prepareHintMotion} from './src/hint-motion.mjs';import {boot} from './qa/runtime-harness.mjs';
import {renderBrightBoard,hintGeometry} from './src/bright-classroom/board-renderer.mjs';import {inside} from './src/bright-classroom/geometry.mjs';
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id)),routes=JSON.parse(fs.readFileSync(new URL('./src/prepared-routes.json',import.meta.url))).routes;
function context(events=[]){const stack=[];return new Proxy({globalCompositeOperation:'source-over',globalAlpha:1,measureText:()=>({width:20}),save(){stack.push([this.globalCompositeOperation,this.globalAlpha]);},restore(){[this.globalCompositeOperation,this.globalAlpha]=stack.pop();},createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}}),fill(){events.push(['fill',this.globalCompositeOperation]);},fillRect(){events.push(['rect',this.globalCompositeOperation]);},drawImage(){events.push(['image',this.globalCompositeOperation]);}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});}
const hint={route:['a','b','c'],guides:[{a:'a',b:'b',points:[[30,30],[150,30]]},{a:'b',b:'c',points:[[150,30],[150,160]]}],keepouts:[],visitedShapes:[],boardWidth:200,boardHeight:220,dpr:2};
test('A3 width, one wide patch and brighter body preserve original design proportions',()=>{const original=JSON.parse(fs.readFileSync(new URL('../docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json',import.meta.url)));assert.equal(T.body.widthRatio,.235);assert.equal(T.highlight.count,1);assert.equal(T.body.crownWidthRatio,.76);assert.equal(T.endpointFade.radiusRatio,.39);assert.equal(T.body.widthRatio,original.body.widthRatio);assert.notDeepEqual(T.body.gradient,original.body.gradient);for(const pitch of [20,36,104,200])assert.equal(prepareHintMotion(hint,pitch,[]).width,Math.max(7,Math.min(26,pitch*.235)));});
test('ribbon caches its body, draws source-atop only offscreen and starts each frame clean',()=>{let canvases=0;const internal=[],out=[],painter=new SoftHintPainter(()=>{canvases++;return {width:0,height:0,getContext:()=>context(internal)};}),ctx=context(out);painter.draw(ctx,hint,50,300,[]);const base=painter.base;for(let i=0;i<60;i++)painter.draw(ctx,hint,50,300+i*16,[]);assert.equal(canvases,2);assert.equal(painter.base,base);assert.ok(internal.some(e=>e[1]==='source-atop'));assert.ok(out.every(e=>e[1]==='source-over'));painter.reset();assert.equal(painter.base,null);});
test('one patch travels at 60px/s on the full route and does not restart when player advances',()=>{const motion=prepareHintMotion(hint,50,[]);const a=softHighlightAt(motion,300),b=softHighlightAt(motion,800);assert.ok(Math.abs(b.at-a.at-30)<.001);assert.equal(a.cycle,Math.max(1800,motion.total/60*1000));assert.ok(a.at<b.at);const rest=prepareHintMotion(hint,50,['a','b']);assert.equal(rest.guides.length,1);assert.equal(softHighlightAt(motion,800).at,b.at);});
test('reduced motion and missing canvas use a static ribbon without main-board compositing',()=>{const log=[],p=new SoftHintPainter(()=>null),ctx=context(log);p.draw(ctx,{...hint,reducedMotion:true},50,0,[]);assert.ok(!log.some(e=>e[1]==='source-atop'));assert.equal(ctx.globalCompositeOperation,'source-over');assert.equal(targetBreath(0,true),1);assert.equal(targetBreath(0),.65);assert.equal(targetBreath(1100),1);});
test('real target contours and smoothed routes stay attached to actual 200-level geometry',()=>{const ctx=context();for(const level of levels){const route=routes[level.id],scene=renderBrightBoard(ctx,level,[],375,468,{preview:true,now:0}),h=hintGeometry(scene,[],route),m=prepareHintMotion(h,scene.pitch,[]);assert.equal(h.targetId,route.at(-1));assert.equal(h.targetShape.rings,scene.byId.get(route.at(-1)).rings);assert.ok(m.runs.length);for(const run of m.runs)for(const p of run){const valid=h.routeShapes.some(s=>s.parts.some(r=>inside(p,r)));if(!valid)assert.ok(h.guides.some(g=>g.points.some(q=>Math.hypot(q[0]-p[0],q[1]-p[1])<scene.pitch*.03)),level.id+' escaped contour');}}});
test('live target breath uses cached board layers, arrival and background stop obsolete visuals',()=>{const ui=boot();ui.select(3);ui.button('指路');ui.advance(400);const before=ui.api.snapshot();for(let i=0;i<10;i++)ui.advance(16);const after=ui.api.snapshot();assert.ok(after.softHint.targetCacheHits>before.softHint.targetCacheHits);assert.equal(after.softHint.builds,before.softHint.builds);const sequence=after.feedback.sequence;const h=after.hint;for(const id of routes[levels[3].id].slice(0,h.end+1))ui.cell(id);assert.equal(ui.api.snapshot().hint,null);ui.handlers.Hide();assert.equal(ui.frames.size,0);assert.ok(ui.api.snapshot().feedback.sequence>=sequence);});
+17 -13
View File
@@ -1,33 +1,37 @@
import {FEEDBACK} from './feedback-params.mjs'; import {FEEDBACK} from './feedback-params.mjs';
const instances=new Set(); const instances=new Set();
export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:0,mergedMoves:0,completeRequestedAt:null,completeStartedAt:null}; export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:0,mergedMoves:0,completeRequestedAt:null,completeStartedAt:null,completePlaying:false,preservedTransitions:0};
export function stopAudio(){for(const instance of instances)instance.stop();} export function stopAudio(){for(const instance of instances)instance.stop();}
export class CasualAudio{ export class CasualAudio{
constructor(host,storage,key){Object.assign(this,{storage,key,channels:[],cursor:0,timers:new Set(),lastEvent:null,lastAt:{},lastMove:-Infinity,serial:0,variant:'enhanced'});try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}instances.add(this);} constructor(host,storage,key){Object.assign(this,{storage,key,channels:[],cursor:0,timers:new Set(),lastEvent:null,lastAt:{},lastMove:-Infinity,serial:0,variant:'enhanced'});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=.75;a.onError?.(e=>this.error(e));this.channels.push(a);}return true;}catch(e){this.error(e);return false;}} 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=.75;a.onError?.(e=>{if(a===this.channels[3])this.finishComplete();this.error(e);});this.channels.push(a);}return true;}catch(e){this.error(e);return false;}}
error(e){audioDiagnostics.lastError={errCode:e?.errCode||0,errMsg:e?.errMsg||String(e)};} error(e){audioDiagnostics.lastError={errCode:e?.errCode||0,errMsg:e?.errMsg||String(e)};}
stop(){this.serial++;for(const t of this.timers)clearTimeout(t);this.timers.clear();this.moveTimer=null;this.pendingMove=null;this.lastMove=-Infinity;this.highestUntil=0;for(const a of this.channels){try{a.stop();}catch{}}} finishComplete(){const finish=this.completeFinish;this.completeFinish=null;if(finish)audioDiagnostics.completePlaying=false;finish?.();}
stop({preserveComplete=false}={}){const keep=preserveComplete&&Boolean(this.completeFinish);if(keep)audioDiagnostics.preservedTransitions++;else this.finishComplete();this.serial++;for(const t of this.timers)clearTimeout(t);this.timers.clear();this.moveTimer=null;this.pendingMove=null;this.pendingKind=null;this.lastMove=-Infinity;this.highestUntil=0;for(const a of this.channels){if(keep&&a===this.channels[3])continue;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;} 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){ play(kind,delay=0,step=1){
if(!this.unlock())return;const now=Date.now(); if(!this.unlock())return;const now=Date.now();
if(kind==='error'&&now-(this.lastAt.error??-Infinity)<FEEDBACK.errorGapMs)return;this.lastAt[kind]=now; if(kind==='error'&&now-(this.lastAt.error??-Infinity)<FEEDBACK.errorGapMs)return;this.lastAt[kind]=now;
if(delay>0){const serial=this.serial,t=setTimeout(()=>{this.timers.delete(t);if(serial===this.serial)this.play(kind,0,step);},delay*1000);this.timers.add(t);return;} if(delay>0){const serial=this.serial,t=setTimeout(()=>{this.timers.delete(t);if(serial===this.serial)this.play(kind,0,step);},delay*1000);this.timers.add(t);return;}
if(kind==='complete'||kind==='restart')this.stop(); if(kind==='complete'||kind==='restart')this.stop({preserveComplete:kind==='restart'});
if(kind!=='complete'&&now<(this.highestUntil||0))return; if(kind!=='complete'&&kind!=='ui'&&now<(this.highestUntil||0))return;
if(kind!=='move'&&this.moveTimer){clearTimeout(this.moveTimer);this.timers.delete(this.moveTimer);this.moveTimer=null;this.pendingMove=null;} if(!['move','undo'].includes(kind)&&this.moveTimer){clearTimeout(this.moveTimer);this.timers.delete(this.moveTimer);this.moveTimer=null;this.pendingMove=null;}
if(kind==='move'&&this.variant!=='original'&&now-this.lastMove<FEEDBACK.audioGapMs){ if(['move','undo'].includes(kind)&&this.variant!=='original'&&now-this.lastMove<FEEDBACK.audioGapMs){
this.pendingMove=step;audioDiagnostics.mergedMoves++; this.pendingMove=step;this.pendingKind=kind;audioDiagnostics.mergedMoves++;
if(!this.moveTimer){const serial=this.serial;const t=setTimeout(()=>{this.timers.delete(t);this.moveTimer=null;const latest=this.pendingMove;this.pendingMove=null;if(serial===this.serial&&latest!==null&&latest!==undefined)this.run('move',latest);},FEEDBACK.audioGapMs-(now-this.lastMove));this.moveTimer=t;this.timers.add(t);}return; if(!this.moveTimer){const serial=this.serial;const t=setTimeout(()=>{this.timers.delete(t);this.moveTimer=null;const latest=this.pendingMove;this.pendingMove=null;if(serial===this.serial&&latest!==null&&latest!==undefined)this.run(this.pendingKind||'move',latest);},FEEDBACK.audioGapMs-(now-this.lastMove));this.moveTimer=t;this.timers.add(t);}return;
} }
if(this.moveTimer){clearTimeout(this.moveTimer);this.timers.delete(this.moveTimer);this.moveTimer=null;this.pendingMove=null;}
this.run(kind,step); this.run(kind,step);
} }
run(kind,step){ run(kind,step){
if(!this.enabled)return;const now=Date.now();if(kind==='move')this.lastMove=now; if(!this.enabled)return;const now=Date.now();if(kind==='move'||kind==='undo')this.lastMove=now;
const a=this.channels[kind==='move'?this.cursor++%3:3],serial=this.serial,file=kind==='move'||kind==='checkpoint'&&this.variant!=='original'?kind+'-'+Math.max(1,Math.min(36,Math.floor(step))):kind; const ordinary=kind==='move'||kind==='undo'&&this.variant!=='original'||kind!=='complete'&&Boolean(this.completeFinish);
try{a.stop();if(a._feedbackPlay)a.offPlay?.(a._feedbackPlay);a.src=(this.variant==='original'&&!['ui','hint'].includes(kind)?'audio-original/':'audio/')+file+'.wav'; const a=this.channels[ordinary?this.cursor++%3:3],serial=this.serial,file=kind==='undo'&&this.variant!=='original'?'move-'+Math.max(1,Math.min(36,Math.floor(step)+1)):kind==='move'||kind==='checkpoint'&&this.variant!=='original'?kind+'-'+Math.max(1,Math.min(36,Math.floor(step))):kind;
try{if(a===this.channels[3])this.finishComplete();a.stop();if(a._feedbackEnded)a.offEnded?.(a._feedbackEnded);if(a._feedbackPlay)a.offPlay?.(a._feedbackPlay);a.volume=.75*(kind==='complete'?FEEDBACK.completeGain:1);a.src=(this.variant==='original'&&!['ui','hint'].includes(kind)?'audio-original/':'audio/')+file+'.wav';
if(kind==='complete'){this.highestUntil=now+FEEDBACK.completeWaitMs;audioDiagnostics.completeRequestedAt=now;audioDiagnostics.completeStartedAt=typeof a.onPlay==='function'?null:now;} if(kind==='complete'){this.highestUntil=now+FEEDBACK.completeWaitMs;audioDiagnostics.completeRequestedAt=now;audioDiagnostics.completeStartedAt=typeof a.onPlay==='function'?null:now;}
const callback=()=>{if(serial===this.serial&&kind==='complete')audioDiagnostics.completeStartedAt=Date.now();};a._feedbackPlay=callback;a.onPlay?.(callback); const callback=()=>{if(serial===this.serial&&kind==='complete')audioDiagnostics.completeStartedAt=Date.now();};a._feedbackPlay=callback;a.onPlay?.(callback);
if(kind==='complete'){audioDiagnostics.completePlaying=true;this.completeFinish=()=>this.afterComplete?.();const ended=()=>{if(this.completeFinish===finish)this.finishComplete();};const finish=this.completeFinish;a._feedbackEnded=ended;a.onEnded?.(ended);this.beforeComplete?.();}
a.play();this.lastEvent=kind;audioDiagnostics.plays++; a.play();this.lastEvent=kind;audioDiagnostics.plays++;
}catch(e){this.error(e);} }catch(e){if(a===this.channels[3])this.finishComplete();this.error(e);}
} }
} }
@@ -1,3 +1,4 @@
import {drawSoftTarget,targetBreath} from '../soft-hint.mjs';
import {feedback} from '../feedback.mjs'; import {feedback} from '../feedback.mjs';
import {FEEDBACK} from '../feedback-params.mjs'; import {FEEDBACK} from '../feedback-params.mjs';
import {violations,sideBetween,doorOpen,doorLatched} from '../upstream/rules.mjs'; import {violations,sideBetween,doorOpen,doorLatched} from '../upstream/rules.mjs';
@@ -7,6 +8,7 @@ import {cushion} from './cushion.mjs';
import {numberStamp} from './number-stamp.mjs'; import {numberStamp} from './number-stamp.mjs';
const caches=new WeakMap(); const caches=new WeakMap();
export const boardDiagnostics={liveRenders:0,previewRenders:0,targetCacheHits:0};
export const preferences={reducedMotion:false}; export const preferences={reducedMotion:false};
const errorIds=bad=>new Set(bad.flatMap(b=>b.cells)); const errorIds=bad=>new Set(bad.flatMap(b=>b.cells));
@@ -188,19 +190,23 @@ export function drawBarriers(ctx,scene,path,now=Date.now()){
ctx.restore(); ctx.restore();
} }
export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),preview=false}={}){ export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),preview=false,hintTarget=null,hintElapsed=0,pixelRatio=1}={}){
boardDiagnostics[preview?'previewRenders':'liveRenders']++;
let cached=caches.get(ctx); let cached=caches.get(ctx);
if(!cached||cached.level!==level||cached.width!==width||cached.height!==height){ if(!cached||cached.level!==level||cached.width!==width||cached.height!==height){
cached={...buildGeometry(level,width,height),level,width,height,pathKey:null,changedAt:now};caches.set(ctx,cached); cached={...buildGeometry(level,width,height),level,width,height,pathKey:null,changedAt:now};caches.set(ctx,cached);
} }
const scene=cached,pathKey=path.join('|');scene.now=now; const scene=cached,pathKey=path.join('|');scene.now=now;
const target=hintTarget&&!path.includes(hintTarget)&&!preview?hintTarget:null;
const targetKey=JSON.stringify([pathKey,target,width,height,pixelRatio,feedback.epoch,feedback.sequence,preferences.reducedMotion]);
if(target&&scene.hintLayers?.key===targetKey&&!scene.animating){boardDiagnostics.targetCacheHits++;compositeTarget(ctx,scene,hintElapsed);return scene;}
if(scene.feedbackEpoch!==feedback.epoch){scene.bursts=[];scene.feedbackSequence=0;scene.feedbackEpoch=feedback.epoch;} if(scene.feedbackEpoch!==feedback.epoch){scene.bursts=[];scene.feedbackSequence=0;scene.feedbackEpoch=feedback.epoch;}
scene.feedbackEvents=preview?[]:feedback.active(level,now); scene.feedbackEvents=preview?[]:feedback.active(level,now);
if(!preview&&feedback.enhanced){ if(!preview&&feedback.enhanced){
for(const event of scene.feedbackEvents)if(event.sequence>(scene.feedbackSequence||0)){ for(const event of scene.feedbackEvents)if(event.sequence>(scene.feedbackSequence||0)){
if(event.kind==='complete')scene.bursts=[]; if(event.kind==='complete')scene.bursts=[];
else if(event.kind==='undo')scene.bursts=scene.bursts.filter(b=>b.cell!==event.cell); else if(event.kind==='undo')scene.bursts=scene.bursts.filter(b=>b.cell!==event.cell);
else if(event.kind==='checkpoint'&&!preferences.reducedMotion){const g=scene.byId.get(event.cell);if(g)scene.bursts.push({cell:event.cell,point:g.center,started:event.time,seed:event.step});} else if(event.kind==='checkpoint'&&event.ribbon!==false&&!preferences.reducedMotion){const g=scene.byId.get(event.cell);if(g)scene.bursts.push({cell:event.cell,point:g.center,started:event.time,seed:event.step});}
scene.feedbackSequence=event.sequence; scene.feedbackSequence=event.sequence;
} }
scene.bursts=scene.bursts.slice(-FEEDBACK.maxRibbons); scene.bursts=scene.bursts.slice(-FEEDBACK.maxRibbons);
@@ -224,15 +230,25 @@ export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),pr
scene.depth=level.geometry==='hex'?clamp(1.2,scene.pitch*.028,2.2):clamp(3,scene.pitch*T.referenceMatch.cellDepthRatio,T.referenceMatch.cellDepthMax); scene.depth=level.geometry==='hex'?clamp(1.2,scene.pitch*.028,2.2):clamp(3,scene.pitch*T.referenceMatch.cellDepthRatio,T.referenceMatch.cellDepthMax);
scene.steps=new Map(path.map((id,i)=>[id,i+1]));scene.errors=errorIds(violations(level,path));scene.keepouts=[];scene.channels=[]; scene.steps=new Map(path.map((id,i)=>[id,i+1]));scene.errors=errorIds(violations(level,path));scene.keepouts=[];scene.channels=[];
scene.views=scene.cells.map(g=>({cell:g.cell.id,...appearance(g.cell,scene.steps.get(g.cell.id),scene.errors.has(g.cell.id)),error:scene.errors.has(g.cell.id),category:g.cell.category||null,cellUnit:scene.pitch})); scene.views=scene.cells.map(g=>({cell:g.cell.id,...appearance(g.cell,scene.steps.get(g.cell.id),scene.errors.has(g.cell.id)),error:scene.errors.has(g.cell.id),category:g.cell.category||null,cellUnit:scene.pitch}));
ctx.clearRect(0,0,width,height);ctx.save(); let layers=null;
for(let i=0;i<scene.cells.length;i++)drawMaterial(ctx,scene.cells[i],scene.views[i],scene,true); if(target&&typeof wx!=='undefined'&&wx.createCanvas){
drawBridges(ctx,scene,path,true); layers=scene.hintLayers||{base:wx.createCanvas(),top:wx.createCanvas(),glow:wx.createCanvas()};
for(let i=0;i<scene.cells.length;i++)withCheckpointScale(ctx,scene,scene.cells[i],now,()=>drawMaterial(ctx,scene.cells[i],scene.views[i],scene)); for(const c of [layers.base,layers.top,layers.glow]){if(c.width!==Math.round(width*pixelRatio))c.width=Math.round(width*pixelRatio);if(c.height!==Math.round(height*pixelRatio))c.height=Math.round(height*pixelRatio);const p=c.getContext('2d');p.setTransform(pixelRatio,0,0,pixelRatio,0,0);p.clearRect(0,0,width,height);}
drawBridges(ctx,scene,path); layers.key=targetKey;scene.hintLayers=layers;
for(let i=0;i<scene.cells.length;i++)drawSymbols(ctx,scene,scene.cells[i],scene.views[i],path); }else scene.hintLayers=null;
drawCheckpointRibbons(ctx,scene,now); const base=layers?layers.base.getContext('2d'):ctx,top=layers?layers.top.getContext('2d'):ctx;
drawBarriers(ctx,scene,path,now);ctx.restore(); base.clearRect(0,0,width,height);base.save();
scene.animating=!preview&&!preferences.reducedMotion&&(scene.bursts.length>0||scene.feedbackEvents.some(e=>now-e.time<(e.kind==='move'?FEEDBACK.moveMs:e.kind==='undo'?FEEDBACK.undoMs:0))||[...scene.doorTransitions.values()].some(d=>d.from!==d.to&&now-d.started<T.motionMs.door)); for(let i=0;i<scene.cells.length;i++)drawMaterial(base,scene.cells[i],scene.views[i],scene,true);
drawBridges(base,scene,path,true);
for(let i=0;i<scene.cells.length;i++)withCheckpointScale(base,scene,scene.cells[i],now,()=>drawMaterial(base,scene.cells[i],scene.views[i],scene));
drawBridges(base,scene,path);base.restore();
if(layers){const glow=layers.glow.getContext('2d');drawSoftTarget(glow,scene,target,0,true);glow.save();glow.globalCompositeOperation='destination-out';glow.fillStyle='#000';glow.strokeStyle='#000';for(const g of scene.cells.filter(g=>scene.steps.has(g.cell.id))){for(const dy of [0,scene.depth]){trace(glow,g.rings,g.radius,dy);glow.fill();glow.lineWidth=2*(clamp(3,scene.pitch*.065,7)+1);glow.stroke();}}glow.restore();}
else if(target)drawSoftTarget(ctx,scene,target,0,true);
top.save();
for(let i=0;i<scene.cells.length;i++)drawSymbols(top,scene,scene.cells[i],scene.views[i],path);
drawCheckpointRibbons(top,scene,now);drawBarriers(top,scene,path,now);top.restore();
if(layers)compositeTarget(ctx,scene,hintElapsed);
scene.animating=!preview&&!preferences.reducedMotion&&(scene.bursts.length>0||scene.feedbackEvents.some(e=>now-e.time<(e.kind==='checkpoint'||e.kind==='complete'?FEEDBACK.checkpointMs:FEEDBACK.moveMs))||[...scene.doorTransitions.values()].some(d=>d.from!==d.to&&now-d.started<T.motionMs.door));
return scene; return scene;
} }
@@ -247,7 +263,7 @@ export function hintGeometry(scene,path,route){
if(!a||!b||!edge)continue; if(!a||!b||!edge)continue;
guides.push({a:route[i],b:route[i+1],points:[...interiorGuide(a,a.center,edge.entry,scene),...interiorGuide(b,edge.entry,b.center,scene).slice(1)]}); guides.push({a:route[i],b:route[i+1],points:[...interiorGuide(a,a.center,edge.entry,scene),...interiorGuide(b,edge.entry,b.center,scene).slice(1)]});
} }
const value={guides,keepouts:scene.keepouts,visitedShapes:scene.cells.filter(g=>path.includes(g.cell.id)).map(g=>({rings:g.rings,radius:g.radius,depth:scene.depth,maskPad:clamp(3,scene.pitch*.065,7)+1})),reducedMotion:preferences.reducedMotion}; const value={guides,targetId:route.at(-1),targetShape:scene.byId.get(route.at(-1))?{rings:scene.byId.get(route.at(-1)).rings,radius:scene.byId.get(route.at(-1)).radius}:null,routeShapes:route.map(id=>scene.byId.get(id)).filter(Boolean).map(g=>({parts:g.parts})),pitch:scene.pitch,boardWidth:scene.width,boardHeight:scene.height,keepouts:scene.keepouts,visitedShapes:scene.cells.filter(g=>path.includes(g.cell.id)).map(g=>({rings:g.rings,radius:g.radius,depth:scene.depth,maskPad:clamp(3,scene.pitch*.065,7)+1})),reducedMotion:preferences.reducedMotion};
hintGeometryCache.set(scene,{key,keepouts:scene.keepouts,value});return value; hintGeometryCache.set(scene,{key,keepouts:scene.keepouts,value});return value;
} }
@@ -272,11 +288,20 @@ export function checkpointScale(elapsed,reducedMotion=false){
return 1+(FEEDBACK.checkpointMaxScale-1)*Math.sin(Math.PI*elapsed/FEEDBACK.checkpointMs); return 1+(FEEDBACK.checkpointMaxScale-1)*Math.sin(Math.PI*elapsed/FEEDBACK.checkpointMs);
} }
function withCheckpointScale(ctx,scene,g,now,paint){ function withCheckpointScale(ctx,scene,g,now,paint){
const burst=[...scene.bursts].reverse().find(b=>b.cell===g.cell.id); const landing=feedback.enhanced&&scene.feedbackEvents.findLast(e=>e.cell===g.cell.id||e.kind==='undo'&&e.from===g.cell.id);
const landing=feedback.enhanced&&scene.feedbackEvents.findLast(e=>e.cell===g.cell.id&&e.kind==='move'); const scale=preferences.reducedMotion||!landing||landing.kind==='undo'&&landing.from&&landing.from!==g.cell.id?1:landing.kind==='checkpoint'||landing.kind==='complete'?checkpointScale(now-landing.time):landingScale(now-landing.time);
const scale=preferences.reducedMotion?1:burst&&scene.steps.has(g.cell.id)?checkpointScale(now-burst.started):landing&&scene.steps.has(g.cell.id)?landingScale(now-landing.time):1;
if(scale===1){paint();return;} if(scale===1){paint();return;}
ctx.save();ctx.translate(...g.center);ctx.scale(scale,scale);ctx.translate(-g.center[0],-g.center[1]);paint();ctx.restore(); ctx.save();ctx.translate(...g.center);ctx.scale(scale,scale);ctx.translate(-g.center[0],-g.center[1]);paint();ctx.restore();
} }
export function landingScale(elapsed){return elapsed<0||elapsed>=FEEDBACK.moveMs?1:1-(1-FEEDBACK.moveMinScale)*Math.sin(Math.PI*elapsed/FEEDBACK.moveMs);} export function landingScale(elapsed){return elapsed<0||elapsed>=FEEDBACK.moveMs?1:1+(FEEDBACK.moveMaxScale-1)*Math.sin(Math.PI*elapsed/FEEDBACK.moveMs);}
// Warm only the next empty board, one material per idle frame. Never changes live state.
export function* warmBoardMaterials(ctx,level,width,height){
const scene={...buildGeometry(level,width,height),level,steps:new Map(),errors:new Set(),channels:[],keepouts:[]};
scene.depth=level.geometry==='hex'?clamp(1.2,scene.pitch*.028,2.2):clamp(3,scene.pitch*T.referenceMatch.cellDepthRatio,T.referenceMatch.cellDepthMax);
yield;
for(const g of scene.cells){const view=appearance(g.cell,undefined,false);drawMaterial(ctx,g,view,scene);drawSymbols(ctx,scene,g,view,[]);yield;}
}
function compositeTarget(ctx,scene,elapsed){const {width,height,hintLayers:l}=scene;ctx.clearRect(0,0,width,height);ctx.drawImage(l.base,0,0,width,height);ctx.save();ctx.globalAlpha=targetBreath(elapsed,preferences.reducedMotion);ctx.drawImage(l.glow,0,0,width,height);ctx.restore();ctx.drawImage(l.top,0,0,width,height);}
@@ -1,2 +1,2 @@
export const FEEDBACK={moveMs:120,moveMinScale:.985,bridgeMs:100,undoMs:100,checkpointMs:260,checkpointMaxScale:1.035,ribbonMs:1500,maxRibbons:2,hapticGapMs:70,completeHapticDelayMs:90,audioGapMs:50,completeWaitMs:120,errorGapMs:180}; export const FEEDBACK={completeGain:.85,moveMs:90,moveMaxScale:1.1,bridgeMs:70,undoMs:90,checkpointMs:90,checkpointMaxScale:1.3,ribbonMs:1500,maxRibbons:2,hapticGapMs:61,completeHapticDelayMs:90,audioGapMs:50,completeWaitMs:120,errorGapMs:180};
export const priority={move:1,undo:0,error:0,checkpoint:2,complete:3}; export const priority={move:1,undo:0,error:0,checkpoint:2,complete:3};
+2 -2
View File
@@ -19,9 +19,9 @@ export class Feedback{
} }
flush(){const e=this.pending;this.pending=null;if(!e)return; flush(){const e=this.pending;this.pending=null;if(!e)return;
this.audio?.play(e.kind==='checkpoint'?'checkpoint':e.kind,0,e.step); this.audio?.play(e.kind==='checkpoint'?'checkpoint':e.kind,0,e.step);
if(this.enhanced){if(e.kind==='undo'||e.kind==='error')this.haptics?.stop();else this.haptics?.play(e.kind);} if(this.enhanced){if(e.kind==='error')this.haptics?.stop();else this.haptics?.play(e.kind);}
} }
reset(){this.epoch++;this.pending=null;this.visuals=[];this.completion=null;this.audio?.stop();this.haptics?.stop();} reset({preserveComplete=false}={}){this.epoch++;this.pending=null;this.visuals=[];this.completion=null;this.audio?.stop({preserveComplete});this.haptics?.stop();}
completionStart(actual,soundEnabled=true){ completionStart(actual,soundEnabled=true){
const c=this.completion;if(!c)return null;if(c.locked)return c.start; const c=this.completion;if(!c)return null;if(c.locked)return c.start;
if(soundEnabled&&actual!==null&&actual!==undefined&&actual>=c.event.time&&actual<=c.event.time+FEEDBACK.completeWaitMs){c.start=actual;c.locked=true;} if(soundEnabled&&actual!==null&&actual!==undefined&&actual>=c.event.time&&actual<=c.event.time+FEEDBACK.completeWaitMs){c.start=actual;c.locked=true;}
+1 -1
View File
@@ -10,7 +10,7 @@ export class Haptics{
const fallback=()=>{if(generation!==this.generation||request!==this.request||this.now()-sentAt>FEEDBACK.hapticGapMs||!this.enabled)return;this.graded=false;try{this.api.vibrateShort({fail(){}});}catch{}}; const fallback=()=>{if(generation!==this.generation||request!==this.request||this.now()-sentAt>FEEDBACK.hapticGapMs||!this.enabled)return;this.graded=false;try{this.api.vibrateShort({fail(){}});}catch{}};
try{this.api.vibrateShort(this.graded?{type,fail:fallback}:{fail(){}});}catch{if(this.graded)fallback();} try{this.api.vibrateShort(this.graded?{type,fail:fallback}:{fail(){}});}catch{if(this.graded)fallback();}
} }
play(kind){if(!['move','checkpoint','complete'].includes(kind))return;this.pulse(kind==='move'?'light':'medium',kind!=='move'); play(kind){if(!['move','undo','checkpoint','complete'].includes(kind))return;this.pulse(kind==='move'||kind==='undo'?'light':'medium',kind!=='move'&&kind!=='undo');
if(kind==='complete'&&this.enabled&&this.graded&&!this.preview&&typeof this.api?.vibrateShort==='function'){const generation=this.generation;const timer=this.later(()=>{this.timers.delete(timer);if(generation===this.generation&&this.graded)this.pulse('light',true);},FEEDBACK.completeHapticDelayMs);this.timers.add(timer);} if(kind==='complete'&&this.enabled&&this.graded&&!this.preview&&typeof this.api?.vibrateShort==='function'){const generation=this.generation;const timer=this.later(()=>{this.timers.delete(timer);if(generation===this.generation&&this.graded)this.pulse('light',true);},FEEDBACK.completeHapticDelayMs);this.timers.add(timer);}
} }
} }
@@ -0,0 +1,46 @@
import {distance,inside} from './bright-classroom/geometry.mjs';
import {T,clamp} from './bright-classroom/theme.mjs';
const preparedHints=new WeakMap();
export function prepareHintMotion(hint,unit,path){
const key=JSON.stringify([unit,path]),cached=preparedHints.get(hint.guides)||new Map();
if(cached.has(key))return cached.get(key);
const covered=(a,b)=>path.some((id,i)=>i>0&&((path[i-1]===a&&id===b)||(path[i-1]===b&&id===a)));
const guides=hint.guides.filter(g=>!covered(g.a,g.b));
let radius=unit*T.hintSoftRibbon.body.cornerRadiusRatio,runs=smoothHintRuns(guides,radius);
if(hint.routeShapes?.length){const legal=p=>hint.routeShapes.some(s=>s.parts.some(r=>inside(p,r)))||guides.some(g=>g.points.slice(1).some((b,i)=>nearSegment(p,g.points[i],b)<=unit*.02));for(let n=0;n<8&&runs.some(r=>r.some(p=>!legal(p)));n++){radius=n===7?0:radius*.5;runs=smoothHintRuns(guides,radius);}}
const width=clamp(T.hintSoftRibbon.body.widthMin,unit*T.hintSoftRibbon.body.widthRatio,T.hintSoftRibbon.body.widthMax);
let total=0;
const segments=runs.flatMap(points=>points.slice(1).map((b,i)=>({a:points[i],b,length:distance(points[i],b)}))).filter(s=>s.length>.01).map(s=>({...s,start:total,end:total+=s.length}));
const value={guides,runs,width,segments,total};cached.set(key,value);if(cached.size>4)cached.delete(cached.keys().next().value);preparedHints.set(hint.guides,cached);return value;
}
export function hintPointAt(motion,at){
const {segments}=motion;if(!segments.length)return null;
let low=0,high=segments.length-1;
while(low<high){const mid=(low+high)>>1;if(segments[mid].end<at)low=mid+1;else high=mid;}
const segment=segments[low],t=Math.max(0,Math.min(1,(at-segment.start)/segment.length));
return {segment,x:segment.a[0]+(segment.b[0]-segment.a[0])*t,y:segment.a[1]+(segment.b[1]-segment.a[1])*t};
}
// Round the complete route, including turns where two cell guides meet.
export function smoothHintRuns(guides,radius){
const raw=[];let last;
for(const g of guides){
if(!g.points.length)continue;
if(last&&last.b===g.a&&distance(raw.at(-1).at(-1),g.points[0])<.1)raw.at(-1).push(...g.points.slice(1));
else raw.push([...g.points]);
last=g;
}
return raw.map(points=>{
const p=points.filter((v,i)=>!i||distance(v,points[i-1])>.01);
if(p.length<3)return p;
const out=[p[0]];
for(let i=1;i<p.length-1;i++){
const a=p[i-1],b=p[i],c=p[i+1],ab=distance(a,b),bc=distance(b,c),r=Math.min(radius,ab*.45,bc*.45);
const u=[b[0]+(a[0]-b[0])*r/ab,b[1]+(a[1]-b[1])*r/ab],v=[b[0]+(c[0]-b[0])*r/bc,b[1]+(c[1]-b[1])*r/bc];
out.push(u);
for(let k=1;k<=10;k++){const t=k/10,q=1-t;out.push([q*q*u[0]+2*q*t*b[0]+t*t*v[0],q*q*u[1]+2*q*t*b[1]+t*t*v[1]]);}
}
out.push(p.at(-1));return out;
});
}
function nearSegment(p,a,b){const dx=b[0]-a[0],dy=b[1]-a[1],l=dx*dx+dy*dy,t=l?Math.max(0,Math.min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/l)):0;return Math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy);}
+7 -147
View File
@@ -1,6 +1,5 @@
import {C} from './bright-classroom/theme.mjs'; import {drawSoftHint} from './soft-hint.mjs';
import {trace,distance} from './bright-classroom/geometry.mjs'; export {prepareHintMotion,hintPointAt,smoothHintRuns} from './hint-motion.mjs';
export function hintSegment(level, solution, path) { export function hintSegment(level, solution, path) {
const cells=new Map(level.cells.map(c=>[c.id,c])); 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]:[];}); 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]:[];});
@@ -25,149 +24,10 @@ export function createHintController(){
}; };
} }
// Draw only on the dedicated transparent hint canvas, never on the player board.
export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[],arrows=null){ // All compositing stays on the dedicated transparent hint canvas.
export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
if(!hint||hint.route.length<2)return; if(!hint||hint.route.length<2)return;
if(hint.guides){drawGeometryHint(ctx,hint,unit,elapsed,playerPath,arrows);return;} if(!hint.guides){const guides=hint.route.slice(1).flatMap((b,i)=>centers[hint.route[i]]&&centers[b]?[{a:hint.route[i],b,points:[centers[hint.route[i]],centers[b]]}]:[]);hint={...hint,guides};}
const points=hint.route.map(id=>centers[id]);if(points.some(p=>!p))return; drawSoftHint(ctx,hint,hint.pitch||unit,elapsed,playerPath);
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();
}
const preparedHints=new WeakMap();
export function prepareHintMotion(hint,unit,path){
const key=JSON.stringify([unit,path]),cached=preparedHints.get(hint.guides)||new Map();
if(cached.has(key))return cached.get(key);
const covered=(a,b)=>path.some((id,i)=>i>0&&((path[i-1]===a&&id===b)||(path[i-1]===b&&id===a)));
const guides=hint.guides.filter(g=>!covered(g.a,g.b)),runs=smoothHintRuns(guides,unit*.16),width=Math.max(4,Math.min(8,unit*.12));
let total=0;
const segments=runs.flatMap(points=>points.slice(1).map((b,i)=>({a:points[i],b,length:distance(points[i],b)}))).filter(s=>s.length>.01).map(s=>({...s,start:total,end:total+=s.length}));
const value={guides,runs,width,segments,total};cached.set(key,value);if(cached.size>4)cached.delete(cached.keys().next().value);preparedHints.set(hint.guides,cached);return value;
}
export function hintPointAt(motion,at){
const {segments}=motion;if(!segments.length)return null;
let low=0,high=segments.length-1;
while(low<high){const mid=(low+high)>>1;if(segments[mid].end<at)low=mid+1;else high=mid;}
const segment=segments[low],t=Math.max(0,Math.min(1,(at-segment.start)/segment.length));
return {segment,x:segment.a[0]+(segment.b[0]-segment.a[0])*t,y:segment.a[1]+(segment.b[1]-segment.a[1])*t};
}
function drawGeometryHint(ctx,hint,unit,elapsed,path,arrows){
const motion=prepareHintMotion(hint,unit,path),{guides,runs,width,total}=motion;
ctx.save();ctx.lineCap='round';ctx.lineJoin='round';
if(hint.reducedMotion)ctx.setLineDash([7,5]);
const draw=(color,w)=>{ctx.strokeStyle=color;ctx.lineWidth=w;for(const points of runs){if(!points.length)continue;ctx.beginPath();ctx.moveTo(...points[0]);for(const p of points.slice(1))ctx.lineTo(...p);ctx.stroke();}};
// Cream-backed coral teaching ribbon: soft toy material, no metallic rail.
ctx.shadowColor='rgba(148,99,55,.18)';ctx.shadowBlur=unit*.045;ctx.shadowOffsetY=1.2;
draw(C.hintRibbonLight,width+5);ctx.shadowBlur=0;ctx.shadowOffsetY=0;
draw(C.hintRibbon,width);
ctx.globalAlpha=.28;draw(C.hintRibbonLight,width*.48);ctx.globalAlpha=1;
ctx.setLineDash([]);
if(!hint.reducedMotion){
const full=arrows?prepareHintMotion(hint,unit,[]):motion;
let start=0;const first=runs[0]?.[0];if(first&&arrows){let nearest=Infinity;for(const s of full.segments){const d=distance(s.a,first);if(d<nearest){nearest=d;start=s.start;}}}
const cycle=Math.max(1600,total/65*1000),head=(elapsed%cycle)/cycle*total;
const heads=arrows?arrows.manager.heads(arrows,full,start):[head,(head+total/3)%total,(head+total*2/3)%total];
// Three markers carry creamy tails along the same rounded path.
for(const pulseHead of heads){
for(let j=16;j>=0;j--){
let at=pulseHead-j*Math.max(.9,width*.30);if(at<0)continue;
const point=hintPointAt(arrows?full:motion,at);if(point){
const {segment,x,y}=point;
ctx.globalAlpha=(1-j/18)*.85;ctx.shadowColor='rgba(255,245,215,.65)';ctx.shadowBlur=j===0?width*1.2:0;
ctx.beginPath();ctx.arc(x,y,width*(.23+(16-j)/16*.4),0,Math.PI*2);ctx.fillStyle=C.hintSpark;ctx.fill();
if(j===0){ctx.globalAlpha=1;ctx.shadowBlur=0;hintArrow(ctx,segment.a,segment.b,x,y,width);}
}
}
}
ctx.globalAlpha=1;ctx.shadowBlur=0;
}
if(hint.reducedMotion)for(const g of guides){
const b=g.points.at(-1),a=g.points.at(-2);if(a&&b)hintArrow(ctx,a,b,(a[0]+b[0])/2,(a[1]+b[1])/2,width);
}
// Fade the entire ribbon, highlight and moving light before painting the
// endpoint sticker. The line disappears at its inner edge, not at the
// rectangular typography keepout, so no hard stub protrudes past it.
ctx.save();ctx.globalCompositeOperation='destination-out';
for(const point of [runs[0]?.[0],runs.at(-1)?.at(-1)]){
if(!point)continue;
const radius=Math.max(17,unit*.46),[x,y]=point;
const fade=ctx.createRadialGradient(x,y,0,x,y,radius);
if(!fade?.addColorStop)continue;
fade.addColorStop(0,'rgba(0,0,0,1)');
fade.addColorStop(.55,'rgba(0,0,0,1)');
fade.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=fade;ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
}
ctx.restore();
// Destination sticker surrounds the target without hiding its clue.
const target=runs.at(-1)?.at(-1);
if(target){
const r=Math.max(9,unit*.25);
ctx.beginPath();ctx.arc(...target,r,0,Math.PI*2);ctx.strokeStyle=C.hintRibbonLight;ctx.lineWidth=5;ctx.stroke();
ctx.strokeStyle=C.hintRibbon;ctx.lineWidth=2.5;ctx.stroke();
}
// Nothing in the hint layer may obscure player materials or rule typography.
ctx.globalCompositeOperation='destination-out';ctx.fillStyle='#000';ctx.strokeStyle='#000';
for(const shape of hint.visitedShapes||[]){
for(const dy of [0,shape.depth]){trace(ctx,shape.rings,shape.radius,dy);ctx.fill();ctx.lineWidth=shape.maskPad?shape.maskPad*2:3;ctx.stroke();}
}
for(const rect of hint.keepouts||[])ctx.fillRect(rect.x,rect.y,rect.w,rect.h);
ctx.restore();
}
function hintArrow(ctx,a,b,x,y,width){
const len=distance(a,b);if(len<1)return;
const dx=(b[0]-a[0])/len,dy=(b[1]-a[1])/len,r=width*.65;
ctx.beginPath();ctx.moveTo(x-dx*r-dy*r,y-dy*r+dx*r);ctx.lineTo(x+dx*r,y+dy*r);ctx.lineTo(x-dx*r+dy*r,y-dy*r-dx*r);
ctx.strokeStyle=C.hintRibbonDepth;ctx.lineWidth=Math.max(2,width*.38);ctx.stroke();
}
// Round the complete route, including turns where two cell guides meet.
export function smoothHintRuns(guides,radius){
const raw=[];let last;
for(const g of guides){
if(!g.points.length)continue;
if(last&&last.b===g.a&&distance(raw.at(-1).at(-1),g.points[0])<.1)raw.at(-1).push(...g.points.slice(1));
else raw.push([...g.points]);
last=g;
}
return raw.map(points=>{
const p=points.filter((v,i)=>!i||distance(v,points[i-1])>.01);
if(p.length<3)return p;
const out=[p[0]];
for(let i=1;i<p.length-1;i++){
const a=p[i-1],b=p[i],c=p[i+1],ab=distance(a,b),bc=distance(b,c),r=Math.min(radius,ab*.45,bc*.45);
const u=[b[0]+(a[0]-b[0])*r/ab,b[1]+(a[1]-b[1])*r/ab],v=[b[0]+(c[0]-b[0])*r/bc,b[1]+(c[1]-b[1])*r/bc];
out.push(u);
for(let k=1;k<=10;k++){const t=k/10,q=1-t;out.push([q*q*u[0]+2*q*t*b[0]+t*t*v[0],q*q*u[1]+2*q*t*b[1]+t*t*v[1]]);}
}
out.push(p.at(-1));return out;
});
} }
@@ -0,0 +1,5 @@
export class IdleWarmup{
constructor({after=requestAnimationFrame,cancel=cancelAnimationFrame,now=()=>Date.now(),canRun=()=>true}={}){Object.assign(this,{after,cancel,now,canRun});this.epoch=0;}
start(key,iterator){if(this.key===key)return;this.stop();this.key=key;this.iterator=iterator;this.pending=true;const epoch=this.epoch,deadline=this.now()+800;const tick=()=>{this.raf=null;if(epoch!==this.epoch)return;if(this.now()>deadline){this.stop();return;}try{if(this.canRun()&&iterator.next().done){this.pending=false;return;}}catch{this.stop();return;}this.raf=this.after(tick);};this.raf=this.after(tick);}
stop(){this.epoch++;if(this.raf)this.cancel(this.raf);this.raf=null;this.iterator?.return?.();this.iterator=null;this.pending=false;this.key=null;}
}

Some files were not shown because too many files have changed in this diff Show More