fix: descend undo notes and smooth next-level transitions
This commit is contained in:
@@ -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:居中标题与指定WAV音乐
|
||||||
|
|
||||||
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
|
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
|
||||||
|
|||||||
@@ -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:居中标题与指定WAV音乐
|
||||||
|
|
||||||
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
|
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
|
||||||
|
|||||||
@@ -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连续播放与节点缩放强化
|
## 2026-10-23:BGMA连续播放与节点缩放强化
|
||||||
|
|
||||||
关卡只使用BGMA,移除运行包BGMB;选关音乐保留。音乐基础音量降低15%:选关0.136,关卡0.119。过关短音乐起播时压低到基础音量25%,结束、取消或播放失败后恢复;切关不停止或重播BGMA。音乐和音效开关保持独立。普通格峰值1.1、特殊节点峰值1.3,回弹时长仍为90ms。构建目标ES2019,兼容微信上传打包器。
|
关卡只使用BGMA,移除运行包BGMB;选关音乐保留。音乐基础音量降低15%:选关0.136,关卡0.119。过关短音乐起播时压低到基础音量25%,结束、取消或播放失败后恢复;切关不停止或重播BGMA。音乐和音效开关保持独立。普通格峰值1.1、特殊节点峰值1.3,回弹时长仍为90ms。构建目标ES2019,兼容微信上传打包器。
|
||||||
|
|||||||
@@ -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,7 +8,7 @@ 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: 'es2019', 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');
|
||||||
@@ -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);');
|
||||||
|
|||||||
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",
|
"type": "file",
|
||||||
"value": "bgm-duck.test.mjs"
|
"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;
|
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);}
|
||||||
|
|||||||
@@ -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,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;
|
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(); };
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"channel": "main",
|
"channel": "main",
|
||||||
"version": "2026-10-23-bgma-duck-node-scale",
|
"version": "2026-10-23-descending-undo-fast-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-BGMA连续播放与节点缩放强化-20261023.zip",
|
"package": "NumberSwipe-main-回退音阶与流畅切关-20261023.zip",
|
||||||
"promotedFrom": "2026-10-22-layered-feedback-music-flow",
|
"promotedFrom": "2026-10-22-layered-feedback-music-flow",
|
||||||
"previousVersion": "2026-10-23-centered-title-selected-wav"
|
"previousVersion": "2026-10-23-bgma-duck-node-scale"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function boot(storage = new Map(), width = 375, height = 812) {
|
|||||||
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(); };
|
||||||
|
|||||||
@@ -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;}}
|
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)};}
|
||||||
finishComplete(){const finish=this.completeFinish;this.completeFinish=null;finish?.();}
|
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;}
|
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();
|
||||||
@@ -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(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();
|
||||||
if(kind!=='complete'&&now<(this.highestUntil||0))return;
|
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(!['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 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.src=(this.variant==='original'&&!['ui','hint'].includes(kind)?'audio-original/':'audio/')+file+'.wav';
|
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'){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?.();}
|
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';
|
import {numberStamp} from './number-stamp.mjs';
|
||||||
|
|
||||||
const caches=new WeakMap();
|
const caches=new WeakMap();
|
||||||
|
export const boardDiagnostics={liveRenders:0,previewRenders: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));
|
||||||
|
|
||||||
@@ -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}={}){
|
export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),preview=false}={}){
|
||||||
|
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);
|
||||||
@@ -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);}
|
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};
|
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;}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {IdleWarmup} from './idle-warmup.mjs';
|
||||||
import {loadSelectionTitle,selectionRow} from './selection-title.mjs';
|
import {loadSelectionTitle,selectionRow} from './selection-title.mjs';
|
||||||
import {Music} from './music.mjs';
|
import {Music} from './music.mjs';
|
||||||
import {HintArrows} from './hint-arrows.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 {tutorials} from './copy.mjs';
|
||||||
import {C,textFont,label,panel,fontDiagnostics} from './bright-classroom/theme.mjs';
|
import {C,textFont,label,panel,fontDiagnostics} from './bright-classroom/theme.mjs';
|
||||||
import {background,buttonArt,stage,statusPill} from './bright-classroom/ui.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 lessonCanvas=wx.createCanvas();let lesson=null,lessonStarted=0,lessonState=null,lessonCache=new Map();
|
||||||
const $ = id => document.getElementById(id), ctx = mainCanvas.getContext('2d');
|
const $ = id => document.getElementById(id), ctx = mainCanvas.getContext('2d');
|
||||||
let width = 375, height = 812, top = 60, bottom = 24, page = 0, buttons = [], pointer = null, visible = true, raf = 0;
|
let 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;
|
let rulePage=0,ruleKey='',lessonBoardCache=null,lessonBoardRenders=0;
|
||||||
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
|
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
|
||||||
const music=new Music(wx),hintArrows=new HintArrows();
|
const music=new Music(wx),hintArrows=new HintArrows();
|
||||||
@@ -58,7 +61,10 @@ function draw() {
|
|||||||
renderedFrames++;
|
renderedFrames++;
|
||||||
const d = window.devicePixelRatio;
|
const d = window.devicePixelRatio;
|
||||||
ctx.setTransform(d, 0, 0, d, 0, 0);
|
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;}
|
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};
|
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);
|
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.onTouchEnd(release);
|
||||||
wx.onTouchCancel(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) {
|
wx.onShow(() => { if (!visible) {
|
||||||
visible = true;lessonStarted=Date.now();settingsOpen=false;resumeGameplay();
|
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();
|
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.onAudioInterruptionEnd?.(()=>{if(visible){music.resume();requestPaint();}});
|
||||||
wx.onWindowResize?.(resize);
|
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.beforeComplete=()=>music.duck();
|
||||||
feedback.audio.afterComplete=()=>music.unduck();
|
feedback.audio.afterComplete=()=>music.unduck();
|
||||||
loadSelectionTitle(wx,requestPaint);
|
loadSelectionTitle(wx,requestPaint);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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);});
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"undoRestartHint": true,
|
"undoRestartHint": true,
|
||||||
"storageRestore": true,
|
"storageRestore": true,
|
||||||
"foregroundLoop": true,
|
"foregroundLoop": true,
|
||||||
"nativeAudioPlaybackCalls": 2243,
|
"nativeAudioPlaybackCalls": 2043,
|
||||||
"plainLanguageRules": true,
|
"plainLanguageRules": true,
|
||||||
"pagedRules": true,
|
"pagedRules": true,
|
||||||
"clearActionLayout": true,
|
"clearActionLayout": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user