fix: descend undo notes and smooth next-level transitions

This commit is contained in:
toshiocn
2026-10-23 16:02:03 +08:00
parent 90d83b3903
commit 0cefce44ef
26 changed files with 301 additions and 26 deletions
+13
View File
@@ -1,3 +1,16 @@
## 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音频;旧版发布包保留。
+13
View File
@@ -1,3 +1,16 @@
## 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音频;旧版发布包保留。
+13
View File
@@ -1,3 +1,16 @@
## 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,兼容微信上传打包器。
+5 -1
View File
@@ -1,3 +1,4 @@
import {buildPreparedRoutes} from './prepared-routes-build.mjs';
import {buildMusic} from './music-assets.mjs';
import {applyLayeredFeedback} from './layered-feedback-build.mjs';
import {buildAudio} from './audio-assets.mjs';
@@ -7,7 +8,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
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: 'es2019', minify: true, plugins: [{ name: 'wechat-host', setup(b) {
b.onLoad({ filter: /upstream[\\/]ui\.mjs$/ }, args => {
let contents = fs.readFileSync(args.path, 'utf8');
@@ -46,6 +47,9 @@ export function renderLesson(target,l,route,w,h){
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();}
`;
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=applyLayeredFeedback(contents);
contents=contents.replace(' if(previewOnly)return;updateTutorial(bad);',' if(previewOnly)return;persistResume();updateTutorial(bad);');
File diff suppressed because one or more lines are too long
@@ -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}));}
@@ -123,6 +123,14 @@
{
"type": "file",
"value": "bgm-duck.test.mjs"
},
{
"type": "file",
"value": "prepared-routes-build.mjs"
},
{
"type": "file",
"value": "undo-next.test.mjs"
}
]
},
@@ -32,7 +32,7 @@ function boot(){
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 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 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);}
+1 -1
View File
@@ -28,7 +28,7 @@ for(const n of ['TouchStart','TouchMove','TouchEnd','TouchCancel','Hide','Show',
const global={};
new Function('wx','GameGlobal','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout','Date',source)(wx,global,cb=>{frames.set(++frameId,cb);return frameId;},id=>frames.delete(id),()=>1,()=>{},Clock);
const api=global.numberSwipeR2;
const tick=(ms=16)=>{now+=ms;const 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 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);};
@@ -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>
@@ -12,7 +12,7 @@ export function boot(storage = new Map(), width = 375, height = 812) {
wx['on' + name] = cb => handlers[name] = cb;
const global = {};
vm.runInNewContext(fs.readFileSync(path.join(root, 'game.js'), 'utf8'), {Date:Clock, wx, GameGlobal: global, requestAnimationFrame: cb => { frames.set(++frameId, cb); return frameId; }, cancelAnimationFrame: id => frames.delete(id), setTimeout: () => 1, clearTimeout: () => { } }, { timeout: 10000 });
const tick = () => { const 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();
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(); };
@@ -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。
+3 -3
View File
@@ -1,10 +1,10 @@
{
"channel": "main",
"version": "2026-10-23-bgma-duck-node-scale",
"version": "2026-10-23-descending-undo-fast-next",
"date": "2026-10-23",
"baseVersion": "2026-10-22-resume-chapter-lessons",
"baseMainSHA256": "bb35d5edf8d96eb3253c78052d219b32f9a4241efd9971537f6c9c8896188125",
"package": "NumberSwipe-main-BGMA连续播放与节点缩放强化-20261023.zip",
"package": "NumberSwipe-main-回退音阶与流畅切关-20261023.zip",
"promotedFrom": "2026-10-22-layered-feedback-music-flow",
"previousVersion": "2026-10-23-centered-title-selected-wav"
"previousVersion": "2026-10-23-bgma-duck-node-scale"
}
+1 -1
View File
@@ -26,7 +26,7 @@ function boot(storage = new Map(), width = 375, height = 812) {
wx['on' + name] = cb => handlers[name] = cb;
const global = {};
vm.runInNewContext(fs.readFileSync(path.join(root, 'game.js'), 'utf8'), {Date:Clock, wx, GameGlobal: global, requestAnimationFrame: cb => { frames.set(++frameId, cb); return frameId; }, cancelAnimationFrame: id => frames.delete(id), setTimeout: () => 1, clearTimeout: () => { } }, { timeout: 10000 });
const tick = () => { const 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();
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(); };
+9 -8
View File
@@ -7,7 +7,7 @@ export class CasualAudio{
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)};}
finishComplete(){const finish=this.completeFinish;this.completeFinish=null;finish?.();}
stop(){this.finishComplete();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{}}}
stop(){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){try{a.stop();}catch{}}}
toggle(){this.enabled=!this.enabled;try{this.storage.setItem(this.key,this.enabled?'on':'off');}catch{}if(this.enabled)this.play('correct');else this.stop();return this.enabled;}
play(kind,delay=0,step=1){
if(!this.unlock())return;const now=Date.now();
@@ -15,17 +15,18 @@ export class CasualAudio{
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'&&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(kind==='move'&&this.variant!=='original'&&now-this.lastMove<FEEDBACK.audioGapMs){
this.pendingMove=step;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(!['move','undo'].includes(kind)&&this.moveTimer){clearTimeout(this.moveTimer);this.timers.delete(this.moveTimer);this.moveTimer=null;this.pendingMove=null;}
if(['move','undo'].includes(kind)&&this.variant!=='original'&&now-this.lastMove<FEEDBACK.audioGapMs){
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(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);
}
run(kind,step){
if(!this.enabled)return;const now=Date.now();if(kind==='move')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;
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.src=(this.variant==='original'&&!['ui','hint'].includes(kind)?'audio-original/':'audio/')+file+'.wav';
if(!this.enabled)return;const now=Date.now();if(kind==='move'||kind==='undo')this.lastMove=now;
const a=this.channels[kind==='move'||kind==='undo'&&this.variant!=='original'?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;}
const callback=()=>{if(serial===this.serial&&kind==='complete')audioDiagnostics.completeStartedAt=Date.now();};a._feedbackPlay=callback;a.onPlay?.(callback);
if(kind==='complete'){this.completeFinish=()=>this.afterComplete?.();const ended=()=>{if(serial===this.serial&&this.completeFinish===finish)this.finishComplete();};const finish=this.completeFinish;a._feedbackEnded=ended;a.onEnded?.(ended);this.beforeComplete?.();}
@@ -7,6 +7,7 @@ import {cushion} from './cushion.mjs';
import {numberStamp} from './number-stamp.mjs';
const caches=new WeakMap();
export const boardDiagnostics={liveRenders:0,previewRenders:0};
export const preferences={reducedMotion:false};
const errorIds=bad=>new Set(bad.flatMap(b=>b.cells));
@@ -189,6 +190,7 @@ export function drawBarriers(ctx,scene,path,now=Date.now()){
}
export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),preview=false}={}){
boardDiagnostics[preview?'previewRenders':'liveRenders']++;
let cached=caches.get(ctx);
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);
@@ -279,3 +281,11 @@ function withCheckpointScale(ctx,scene,g,now,paint){
}
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;}
}
@@ -1,2 +1,2 @@
export const FEEDBACK={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 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};
@@ -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;}
}
+10 -4
View File
@@ -1,3 +1,4 @@
import {IdleWarmup} from './idle-warmup.mjs';
import {loadSelectionTitle,selectionRow} from './selection-title.mjs';
import {Music} from './music.mjs';
import {HintArrows} from './hint-arrows.mjs';
@@ -13,10 +14,12 @@ import {lessonKeys,makeLesson,lessonFrame,loopLessonTime} from './lessons.mjs';
import {tutorials} from './copy.mjs';
import {C,textFont,label,panel,fontDiagnostics} from './bright-classroom/theme.mjs';
import {background,buttonArt,stage,statusPill} from './bright-classroom/ui.mjs';
import {preferences} from './bright-classroom/board-renderer.mjs';
import {preferences,boardDiagnostics,warmBoardMaterials} from './bright-classroom/board-renderer.mjs';
const lessonCanvas=wx.createCanvas();let lesson=null,lessonStarted=0,lessonState=null,lessonCache=new Map();
const $ = id => document.getElementById(id), ctx = mainCanvas.getContext('2d');
let width = 375, height = 812, top = 60, bottom = 24, page = 0, buttons = [], pointer = null, visible = true, raf = 0;
const warmCanvas=wx.createCanvas();warmCanvas.width=1;warmCanvas.height=1;
const warmup=new IdleWarmup({canRun:()=>visible&&pointer===null});let warmupKey='';
let rulePage=0,ruleKey='',lessonBoardCache=null,lessonBoardRenders=0;
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
const music=new Music(wx),hintArrows=new HintArrows();
@@ -58,7 +61,10 @@ function draw() {
renderedFrames++;
const d = window.devicePixelRatio;
ctx.setTransform(d, 0, 0, d, 0, 0);
const state=window.variantSnapshot();music.scene(state.screen==='select'?'select':state.cleared?'complete':'play',state.level);
const state=window.variantSnapshot();
const nextWarm=ordered[state.displayOrder],nextWarmKey=state.level+':'+width+':'+$('board').clientHeight+(state.cleared?':clear':'');
if(state.screen==='play'&&nextWarm){if(warmupKey!==nextWarmKey){warmupKey=nextWarmKey;warmup.start(nextWarmKey,warmBoardMaterials(warmCanvas.getContext('2d'),nextWarm,width,$('board').clientHeight));}}else if(warmupKey){warmup.stop();warmupKey='';}
music.scene(state.screen==='select'?'select':state.cleared?'complete':'play',state.level);
if(!state.cleared||state.screen!=='play'){clearCelebration=null;celebrationSuppressed=false;}
else if(!celebrationSuppressed&&(!clearCelebration||clearCelebration.key!==state.level))clearCelebration={key:state.level,started:Date.now(),requested:audioDiagnostics.completeRequestedAt};
if(clearCelebration)clearCelebration.started=feedback.completionStart(audioDiagnostics.completeStartedAt,state.soundEnabled);
@@ -207,7 +213,7 @@ const release = e => { if (e.changedTouches.some(p => p.identifier === pointer))
} };
wx.onTouchEnd(release);
wx.onTouchCancel(release);
wx.onHide(() => { visible = false;music.suspend();hintArrows.reset(); lessonStarted=Date.now(); celebrationSuppressed=window.variantSnapshot().cleared;clearCelebration=null;feedback.reset();stopAudio(); cancelAnimationFrame(raf);raf=0; pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
wx.onHide(() => { visible = false;warmup.stop();warmupKey='';music.suspend();hintArrows.reset(); lessonStarted=Date.now(); celebrationSuppressed=window.variantSnapshot().cleared;clearCelebration=null;feedback.reset();stopAudio(); cancelAnimationFrame(raf);raf=0; pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
wx.onShow(() => { if (!visible) {
visible = true;lessonStarted=Date.now();settingsOpen=false;resumeGameplay();
const resumed=window.variantSnapshot();music.scene(resumed.screen==='select'?'select':resumed.cleared?'complete':'play',resumed.level);music.resume();
@@ -218,7 +224,7 @@ wx.onAudioInterruptionBegin?.(()=>{music.suspend();feedback.reset();stopAudio();
wx.onAudioInterruptionEnd?.(()=>{if(visible){music.resume();requestPaint();}});
wx.onWindowResize?.(resize);
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),theme:'bright-classroom',release:releaseInfo,fonts:{...fontDiagnostics},renderedFrames,hintBaseHits,lessonBoardRenders,music:music.snapshot(),selection:{page:page+1,pages:Math.ceil(ordered.length/15),pageSize:15,chapter:page+1,chapters:Math.ceil(ordered.length/15)},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},feedback:feedback.snapshot(),rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,finished:lessonState.finished,duration:lesson?.duration,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),theme:'bright-classroom',release:releaseInfo,fonts:{...fontDiagnostics},renderedFrames,boardRenders:boardDiagnostics.liveRenders,hintBaseHits,lessonBoardRenders,music:music.snapshot(),selection:{page:page+1,pages:Math.ceil(ordered.length/15),pageSize:15,chapter:page+1,chapters:Math.ceil(ordered.length/15)},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},feedback:feedback.snapshot(),rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,finished:lessonState.finished,duration:lesson?.duration,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
feedback.audio.beforeComplete=()=>music.duck();
feedback.audio.afterComplete=()=>music.unduck();
loadSelectionTitle(wx,requestPaint);
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
import {IdleWarmup} from './src/idle-warmup.mjs';
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import crypto from 'node:crypto';import {boot} from './qa/runtime-harness.mjs';import {CasualAudio} from './src/audio.mjs';import {solve,violations} from './src/upstream/rules.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));
for(const drag of [false,true])test((drag?'drag':'undo button')+' reverses seven played steps as 7,6,5,4,3,2,1',()=>{const ui=boot();ui.select(3);const route=solve(levels[3])[0];for(const id of route.slice(0,7)){ui.advance(80);ui.cell(id);}const n=ui.played.length;for(let step=7;step>=1;step--){ui.advance(61);if(drag&&step>1)ui.cell(route[step-2]);else ui.button('撤销');assert.equal(ui.api.snapshot().path.length,step-1);}assert.deepEqual(Array.from(ui.played.slice(n)),[7,6,5,4,3,2,1].map(n=>'audio/move-'+n+'.wav'));ui.button('重开');assert.equal(ui.played.at(-1),'audio/restart.wav');});
function audioStub(){const played=[];globalThis.wx={createInnerAudioContext:()=>({stop(){},play(){played.push(this.src);},onError(){},onPlay(){},onEnded(){}})};return {played,sound:new CasualAudio({}, {getItem(){},setItem(){}},'test')};}
test('completion amplitude is reduced 15 percent while node, undo and restart volumes stay unchanged',()=>{const {sound}=audioStub();sound.play('complete');assert.equal(sound.channels[3].volume,.75*.85);sound.stop();sound.play('checkpoint',0,7);assert.equal(sound.channels[3].volume,.75);sound.play('undo',0,6);assert.equal(sound.channels[0].volume,.75);sound.play('restart');assert.equal(sound.channels[3].volume,.75);});
test('fast reverse coalesces to the latest descending pitch and cannot replay an old forward note',t=>{const now=Date.now,later=globalThis.setTimeout,cancel=globalThis.clearTimeout;let time=0,id=0;const jobs=new Map();Date.now=()=>time;globalThis.setTimeout=fn=>{jobs.set(++id,fn);return id;};globalThis.clearTimeout=i=>jobs.delete(i);t.after(()=>{Date.now=now;globalThis.setTimeout=later;globalThis.clearTimeout=cancel;});const {played,sound}=audioStub();sound.play('move',0,7);time=10;sound.play('undo',0,6);time=20;sound.play('undo',0,5);assert.equal(jobs.size,1);time=50;for(const [i,fn]of [...jobs]){jobs.delete(i);fn();}assert.deepEqual(played,['audio/move-7.wav','audio/move-6.wav']);time=60;sound.play('undo',0,4);sound.stop();assert.equal(jobs.size,0);});
test('all prepared routes match the immutable level hash and public rules',()=>{const data=JSON.parse(fs.readFileSync(new URL('./src/prepared-routes.json',import.meta.url)));assert.equal(data.sourceSHA256,crypto.createHash('sha256').update(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))).digest('hex'));assert.equal(Object.keys(data.routes).length,200);for(const l of levels){const path=data.routes[l.id];assert.equal(path.length,l.cells.length);assert.equal(new Set(path).size,path.length);assert.equal(violations(l,path).length,0);}});
test('entering next level draws the live board once and preserves the running BGMA player',()=>{const ui=boot();for(const i of [0,89,121,143,161,180,190]){ui.select(i);solve(levels[i])[0].forEach(ui.cell);const before=ui.api.snapshot().boardRenders,loop=ui.played.filter(f=>f==='music/play-a.wav').length;ui.button('下一关 →');assert.equal(ui.api.snapshot().displayOrder,i+2);assert.equal(ui.api.snapshot().boardRenders-before,1);assert.equal(ui.played.filter(f=>f==='music/play-a.wav').length,loop);}});
test('next-board warming advances one item per frame and cancels on deadline, replacement or hide',()=>{let now=0,id=0,allowed=true,steps=0;const jobs=new Map(),w=new IdleWarmup({after:fn=>{jobs.set(++id,fn);return id;},cancel:id=>jobs.delete(id),now:()=>now,canRun:()=>allowed});const run=()=>{const callbacks=[...jobs.values()];jobs.clear();for(const fn of callbacks)fn();};function* work(){for(let i=0;i<3;i++){steps++;yield;}}w.start('a',work());run();assert.equal(steps,1);allowed=false;run();assert.equal(steps,1);allowed=true;run();assert.equal(steps,2);w.stop();assert.equal(jobs.size,0);w.start('b',work());now=801;run();assert.equal(steps,2);assert.equal(w.pending,false);now=1000;w.start('c',work());w.start('d',work());run();assert.equal(steps,3);w.stop();});
test('idle material warming never changes the player path or emits sound and vibration',()=>{const ui=boot();ui.select(3);const before=ui.api.snapshot(),sounds=ui.played.length,pulses=ui.vibrated.length;for(let i=0;i<40;i++)ui.advance(16);assert.deepEqual(Array.from(ui.api.snapshot().path),Array.from(before.path));assert.equal(ui.played.length,sounds);assert.equal(ui.vibrated.length,pulses);assert.equal(ui.api.snapshot().feedback.sequence,before.feedback.sequence);ui.handlers.Hide();assert.equal(ui.frames.size,0);});
+1 -1
View File
@@ -29,7 +29,7 @@
"undoRestartHint": true,
"storageRestore": true,
"foregroundLoop": true,
"nativeAudioPlaybackCalls": 2243,
"nativeAudioPlaybackCalls": 2043,
"plainLanguageRules": true,
"pagedRules": true,
"clearActionLayout": true,