优化BGMA连续播放与通关音量压低并强化节点缩放

This commit is contained in:
toshiocn
2026-10-23 14:25:35 +08:00
parent c13b850fb2
commit 90d83b3903
19 changed files with 65 additions and 53 deletions
@@ -17,15 +17,6 @@
"bytes": 5927084,
"seconds": 67.2
},
"play-b": {
"sourceSHA256": "1bf440627c5c37e20bcf8c60b9030f518268954958f02648de3edb1072fa269f",
"output": "music/play-b.wav",
"sha256": "477df0c5bc632f1e5963667c216e7f78ec7331f14de90ea2daaac15fe0989609",
"sampleRate": 22050,
"channels": 2,
"bytes": 5773136,
"seconds": 65.45455782312925
},
"complete": {
"sourceSHA256": "535ba25326c9ee2c70ccfa117ef22af389db37b86ba7f1a4b9d42ba8453ed27f",
"output": "audio/complete.wav",
+4
View File
@@ -1,3 +1,7 @@
## 2026-10-23:BGMA连续播放与节点缩放强化
关卡只使用BGMA,移除运行包BGMB;选关音乐保留。音乐基础音量降低15%:选关0.136,关卡0.119。过关短音乐起播时压低到基础音量25%,结束、取消或播放失败后恢复;切关不停止或重播BGMA。音乐和音效开关保持独立。普通格峰值1.1、特殊节点峰值1.3,回弹时长仍为90ms。构建目标ES2019,兼容微信上传打包器。
## 2026-10-23:居中标题与指定WAV音乐
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
+6
View File
@@ -0,0 +1,6 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {Music} from './src/music.mjs';import {CasualAudio} from './src/audio.mjs';
function setup(){const contexts=[];const api={getStorageSync(){},setStorageSync(){},createInnerAudioContext(){const a={stop(){},play(){},onError(fn){this.error=fn;},onEnded(fn){this.ended=fn;},offEnded(fn){if(this.ended===fn)this.ended=null;}};contexts.push(a);return a;}};globalThis.wx=api;const m=new Music(api);m.scene('play','one');const sound=new CasualAudio({}, {getItem(){},setItem(){}},'test');sound.beforeComplete=()=>m.duck();sound.afterComplete=()=>m.unduck();return {m,sound,contexts};}
test('jingle end, cancellation and playback error restore BGM without restarting',()=>{const {m,sound}=setup();sound.play('complete');const a=sound.channels[3];assert.equal(m.ducked,true);a.ended();assert.equal(m.audio.volume,.119);sound.play('complete');const late=a.ended;sound.stop();assert.equal(m.ducked,false);sound.play('complete');late();assert.equal(m.ducked,true);a.error({errMsg:'failed'});assert.equal(m.ducked,false);sound.play('complete');sound.toggle();assert.equal(m.ducked,false);});
test('muted effects never duck BGM and unrelated effect errors cannot end jingle ducking',()=>{const {m,sound}=setup();sound.enabled=false;sound.play('complete');assert.equal(m.ducked,false);sound.enabled=true;sound.play('complete');sound.channels[0].error({errMsg:'unrelated'});assert.equal(m.ducked,true);sound.stop();});
test('hide and music preference do not revive playback through a late jingle callback',()=>{const {m,sound}=setup();sound.play('complete');m.suspend();sound.stop();assert.equal(m.playing,null);assert.equal(m.ducked,false);m.resume();assert.equal(m.audio.volume,.119);sound.play('complete');m.toggle();sound.channels[3].ended();assert.equal(m.playing,null);assert.equal(m.enabled,false);m.toggle();assert.equal(m.audio.volume,.119);});
test('BGMB is absent from the runtime manifest and music package',()=>{const manifest=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));assert.equal(manifest['play-b'],undefined);assert.equal(fs.existsSync(new URL('./music/play-b.wav',import.meta.url)),false);});
@@ -133,5 +133,5 @@ test('checkpoint ribbons trigger on correct arrival only and never duplicate per
test('checkpoint pulse is small, centered in time and settles without looping',async()=>{
const {checkpointScale}=await import('./src/bright-classroom/board-renderer.mjs');
assert.equal(checkpointScale(0),1);assert.equal(checkpointScale(45),1.065);assert.equal(checkpointScale(90),1);assert.equal(checkpointScale(1000),1);assert.equal(checkpointScale(45,true),1);
assert.equal(checkpointScale(0),1);assert.equal(checkpointScale(45),1.3);assert.equal(checkpointScale(90),1);assert.equal(checkpointScale(1000),1);assert.equal(checkpointScale(45,true),1);
});
+1 -1
View File
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
const root = path.dirname(fileURLToPath(import.meta.url));
buildAudio(root);buildMusic(root);
await build({ absWorkingDir: root, entryPoints: ['src/main.mjs'], outfile: 'game.js', bundle: true, format: 'iife', platform: 'neutral', target: 'es2020', minify: true, plugins: [{ name: 'wechat-host', setup(b) {
await build({ absWorkingDir: root, entryPoints: ['src/main.mjs'], outfile: 'game.js', bundle: true, format: 'iife', platform: 'neutral', target: 'es2019', minify: true, plugins: [{ name: 'wechat-host', setup(b) {
b.onLoad({ filter: /upstream[\\/]ui\.mjs$/ }, args => {
let contents = fs.readFileSync(args.path, 'utf8');
contents=contents.replace("from './audio.mjs'", "from '../audio.mjs'");
+1 -1
View File
@@ -58,7 +58,7 @@ test('drag near a previous-block boundary cannot undo; deeper entry can, and und
test('tutorial, redraw, hint and restored saved routes do not generate player feedback',()=>{
const ui=boot();ui.select(4);const n=ui.api.snapshot().feedback.sequence;ui.button('规则');ui.advance(1000);ui.advance(1000);ui.button('返回游戏');ui.button('指路');ui.advance(400);assert.equal(ui.api.snapshot().feedback.sequence,n);ui.cell(solve(levels[4])[0][0]);const restored=boot(ui.storage);assert.equal(restored.api.snapshot().feedback.sequence,0);
});
test('pressure and rebound have bounded amplitude and duration',()=>{assert.equal(landingScale(45),1.025);assert.equal(landingScale(90),1);assert.equal(checkpointScale(45),1.065);assert.equal(checkpointScale(90),1);});
test('pressure and rebound have bounded amplitude and duration',()=>{assert.equal(landingScale(45),1.1);assert.equal(landingScale(90),1);assert.equal(checkpointScale(45),1.3);assert.equal(checkpointScale(90),1);});
test('interpolation cannot duplicate the same failed-cell event within one native movement',()=>{
const f=new Feedback();f.begin();for(let i=0;i<40;i++)f.emit('error','l','wall-cell',3);f.end();assert.equal(f.sequence,1);f.begin();f.emit('error','l','wall-cell',3);f.end();assert.equal(f.sequence,2);
});
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -4,6 +4,6 @@ test('music loops independently, persists mute and cancels in background',()=>{c
test('old arrows retain distance, rush to endpoint and fresh arrows move at base speed',()=>{const a=new HintArrows(),motion={total:300},events=[];let f=a.frame('a',0,events);a.heads(f,motion,0);f=a.frame('a',100,events);assert.equal(a.heads(f,motion,0)[0],6.5);events.push({sequence:1,kind:'move'});f=a.frame('a',116,events);const at=a.heads(f,motion,30);assert.ok(at[0]>6.5&&at[0]<10);assert.equal(at[1],30);f=a.frame('a',216,events);const next=a.heads(f,motion,30);assert.ok(next[0]>150);assert.equal(next[1],36.5);for(let t=316;t<=416;t+=100)a.heads(a.frame('a',t,events),motion,30);assert.ok(a.particles.every(p=>!p.rush));a.reset();assert.equal(a.key,null);});
test('settings have exactly three independent preferences, and hint has its own cue',()=>{const ui=boot();ui.select(3);ui.button('设置');assert.deepEqual(Array.from(ui.api.buttons(),b=>b.label),['音乐:开','音效开','振动:开','完成']);ui.button('完成');ui.button('指路');assert.equal(ui.played.at(-1),'audio/hint.wav');ui.button('选关');assert.ok(ui.played.includes('music/select.wav'));ui.button('下一页');assert.equal(ui.played.at(-1),'audio/ui.wav');});
test('actual fixed numeric node uses checkpoint pitch, final cell uses complete chord',()=>{const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));const i=levels.findIndex(l=>l.cells.some(c=>c.clue>1&&c.clue<l.cells.length));const l=levels[i],ui=boot();ui.select(i);const route=solve(l)[0];for(const id of route){ui.advance(80);ui.cell(id);const e=ui.api.snapshot().feedback.events.at(-1);if(e.kind==='checkpoint')assert.equal(ui.played.at(-1),'audio/checkpoint-'+e.step+'.wav');}assert.equal(ui.played.at(-1),'audio/complete.wav');assert.ok(ui.api.snapshot().feedback.events.some(e=>e.kind==='checkpoint'));});
test('selected menu and both level loops are stereo PCM WAV with complete musical durations',()=>{const meta=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));for(const name of ['select','play-a','play-b']){const b=fs.readFileSync(new URL('./'+meta[name].output,import.meta.url));assert.equal(b.readUInt32LE(24),22050);assert.equal(b.readUInt16LE(22),2);assert.ok(meta[name].seconds>29);assert.equal(b.length,meta[name].bytes);}});
test('selected four runtime WAVs retain the original durations after sample-rate adaptation',()=>{const meta=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));for(const [name,item]of Object.entries(meta)){const b=fs.readFileSync(new URL('./.audio-sources/'+name+'.wav',import.meta.url)),seconds=(b.length-44)/(44100*4);assert.ok(Math.abs(seconds-item.seconds)<1/22050);}});
test('gameplay ambient uses lower scene volume and keeps independent mute and resume behavior',()=>{const store=new Map(),api={getStorageSync:k=>store.get(k),setStorageSync:(k,v)=>store.set(k,v),createInnerAudioContext:()=>({stop(){},play(){this.plays=(this.plays||0)+1;},onError(){}})},m=new Music(api);m.scene('select');assert.equal(m.audio.volume,.16);m.scene('play');assert.equal(m.audio.volume,.14);assert.equal(m.audio.loop,true);const plays=m.audio.plays;m.scene('play');assert.equal(m.audio.plays,plays);m.suspend();m.resume();assert.ok(['music/play-a.wav','music/play-b.wav'].includes(m.audio.src));assert.equal(m.audio.volume,.14);m.toggle();assert.equal(new Music(api).enabled,false);});
test('selected menu and BGMA loop are stereo PCM WAV with complete musical durations',()=>{const meta=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));for(const name of ['select','play-a']){const b=fs.readFileSync(new URL('./'+meta[name].output,import.meta.url));assert.equal(b.readUInt32LE(24),22050);assert.equal(b.readUInt16LE(22),2);assert.ok(meta[name].seconds>29);assert.equal(b.length,meta[name].bytes);}});
test('selected runtime WAVs retain the original durations after sample-rate adaptation',()=>{const meta=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));for(const [name,item]of Object.entries(meta)){const b=fs.readFileSync(new URL('./.audio-sources/'+name+'.wav',import.meta.url)),seconds=(b.length-44)/(44100*4);assert.ok(Math.abs(seconds-item.seconds)<1/22050);}});
test('gameplay ambient uses lower scene volume and keeps independent mute and resume behavior',()=>{const store=new Map(),api={getStorageSync:k=>store.get(k),setStorageSync:(k,v)=>store.set(k,v),createInnerAudioContext:()=>({stop(){},play(){this.plays=(this.plays||0)+1;},onError(){}})},m=new Music(api);m.scene('select');assert.equal(m.audio.volume,.136);m.scene('play');assert.equal(m.audio.volume,.119);assert.equal(m.audio.loop,true);const plays=m.audio.plays;m.scene('play');assert.equal(m.audio.plays,plays);m.suspend();m.resume();assert.ok(m.audio.src==='music/play-a.wav');assert.equal(m.audio.volume,.119);m.toggle();assert.equal(new Music(api).enabled,false);});
Binary file not shown.
@@ -8,8 +8,8 @@ test('all 200 levels: valid numeric, parity and direction arrivals play checkpoi
test('drag back and undo button both vibrate lightly; disabling vibration suppresses both',()=>{
const ui=boot(),route=solve(levels[3])[0];ui.select(3);for(const id of route.slice(0,3)){ui.advance(80);ui.cell(id);}ui.advance(61);let n=ui.vibrated.length;ui.cell(route[1]);assert.equal(ui.api.snapshot().path.length,2);assert.equal(ui.vibrated.length,n+1);assert.equal(ui.vibrated.at(-1).type,'light');ui.advance(61);ui.button('撤销');assert.equal(ui.vibrated.length,n+2);assert.equal(ui.vibrated.at(-1).type,'light');ui.button('设置');ui.button('振动:开');ui.button('完成');ui.advance(80);ui.cell(route[1]);n=ui.vibrated.length;ui.advance(80);ui.button('撤销');assert.equal(ui.vibrated.length,n);assert.equal(ui.api.snapshot().music.playing,'play');});
test('haptic cadence is approximately 15 percent denser without changing light or medium grades',()=>{let now=0;const calls=[],h=new Haptics({vibrateShort:o=>calls.push(o.type)},{getItem:()=>null},{now:()=>now});h.play('move');now=60;h.play('undo');assert.deepEqual(calls,['light']);now=61;h.play('undo');assert.deepEqual(calls,['light','light']);h.play('checkpoint');assert.equal(calls.at(-1),'medium');assert.ok(Math.abs(70/FEEDBACK.hapticGapMs-1.15)<.01);});
test('regular and special scale settle at 90ms with a larger special peak',()=>{assert.equal(FEEDBACK.moveMs,90);assert.equal(FEEDBACK.checkpointMs,90);assert.equal(landingScale(45),1.025);assert.equal(checkpointScale(45),1.065);for(const t of [-10,0,90,1000]){assert.equal(landingScale(t),1);assert.equal(checkpointScale(t),1);}});
test('regular and special scale settle at 90ms with a larger special peak',()=>{assert.equal(FEEDBACK.moveMs,90);assert.equal(FEEDBACK.checkpointMs,90);assert.equal(landingScale(45),1.1);assert.equal(checkpointScale(45),1.3);for(const t of [-10,0,90,1000]){assert.equal(landingScale(t),1);assert.equal(checkpointScale(t),1);}});
test('rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons',t=>{
const old=feedback.now;t.after(()=>{feedback.reset();feedback.now=old;});const level=levels[3],route=solve(level)[0],scales=[],ctx=new Proxy({measureText:()=>({width:20}),scale:(x,y)=>scales.push([x,y])},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});feedback.reset();feedback.now=()=>1000;feedback.emit('undo',level,route[1],1,{from:route[0]});renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1045});assert.ok(scales.some(([x])=>x===1.025));scales.length=0;feedback.now=()=>1100;feedback.emit('checkpoint',level,route[0],1,{ribbon:false});const scene=renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1145});assert.ok(scales.some(([x])=>x===1.065));assert.equal(scene.bursts.length,0);});
const old=feedback.now;t.after(()=>{feedback.reset();feedback.now=old;});const level=levels[3],route=solve(level)[0],scales=[],ctx=new Proxy({measureText:()=>({width:20}),scale:(x,y)=>scales.push([x,y])},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});feedback.reset();feedback.now=()=>1000;feedback.emit('undo',level,route[1],1,{from:route[0]});renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1045});assert.ok(scales.some(([x])=>x===1.1));scales.length=0;feedback.now=()=>1100;feedback.emit('checkpoint',level,route[0],1,{ribbon:false});const scene=renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1145});assert.ok(scales.some(([x])=>x===1.3));assert.equal(scene.bursts.length,0);});
test('quick undo cancels removed node rebound, including last-step removal',t=>{const old=feedback.now;t.after(()=>{feedback.reset();feedback.now=old;});const level=levels[3],route=solve(level)[0],scales=[],ctx=new Proxy({measureText:()=>({width:20}),scale:x=>scales.push(x)},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});feedback.reset();feedback.now=()=>1000;feedback.emit('checkpoint',level,route[1],2,{ribbon:false});feedback.now=()=>1010;feedback.emit('undo',level,route[1],1,{from:route[0]});renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1045});assert.equal(scales.length,1);assert.ok(scales[0]>1&&scales[0]<=1.025);scales.length=0;feedback.now=()=>1100;feedback.emit('undo',level,route[0],0,{});renderBrightBoard(ctx,level,[],375,400,{now:1145});assert.deepEqual(scales,[1.025]);});
test('quick undo cancels removed node rebound, including last-step removal',t=>{const old=feedback.now;t.after(()=>{feedback.reset();feedback.now=old;});const level=levels[3],route=solve(level)[0],scales=[],ctx=new Proxy({measureText:()=>({width:20}),scale:x=>scales.push(x)},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});feedback.reset();feedback.now=()=>1000;feedback.emit('checkpoint',level,route[1],2,{ribbon:false});feedback.now=()=>1010;feedback.emit('undo',level,route[1],1,{from:route[0]});renderBrightBoard(ctx,level,route.slice(0,1),375,400,{now:1045});assert.equal(scales.length,1);assert.ok(scales[0]>1&&scales[0]<=1.1);scales.length=0;feedback.now=()=>1100;feedback.emit('undo',level,route[0],0,{});renderBrightBoard(ctx,level,[],375,400,{now:1145});assert.deepEqual(scales,[1.1]);});
@@ -119,6 +119,10 @@
{
"type": "file",
"value": "selected-music.test.mjs"
},
{
"type": "file",
"value": "bgm-duck.test.mjs"
}
]
},
+4 -1
View File
@@ -5,13 +5,14 @@ export function browserAudio(report){
setInnerAudioOption(){},
createInnerAudioContext(){
const player=new Audio();player.preload='auto';players.add(player);
let errorHandler=()=>{},playHandler=()=>{},generation=0;
let errorHandler=()=>{},playHandler=()=>{},endedHandler=()=>{},generation=0;
const error=reason=>{
if(disposed||reason?.name==='AbortError')return;
report('音效播放失败:'+(reason?.message||'请检查页面声音权限'));
errorHandler({errMsg:reason?.message||'浏览器音频加载失败'});
};
player.addEventListener('error',()=>error(player.error));
player.addEventListener('ended',()=>{if(!disposed)endedHandler();});
return {
set src(path){player.src=new URL('../'+path,location.href).href;},
set loop(value){player.loop=value;},
@@ -19,6 +20,8 @@ export function browserAudio(report){
set obeyMuteSwitch(value){},
onError(callback){errorHandler=callback;},
onPlay(callback){playHandler=callback;},
onEnded(callback){endedHandler=callback;},
offEnded(callback){if(endedHandler===callback)endedHandler=()=>{};},
stop(){generation++;player.pause();if(player.readyState)player.currentTime=0;},
play(){
const current=generation;
@@ -1,12 +1,12 @@
import fs from 'node:fs';import vm from 'node:vm';import assert from 'node:assert/strict';import path from 'node:path';import {fileURLToPath} from 'node:url';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const pack=JSON.parse(fs.readFileSync(path.join(root,'src/upstream/levels.json'))),ordered=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));
export function boot(storage = new Map(), width = 375, height = 812) {
const handlers = {}, frames = new Map(), played=[],vibrated=[];
const handlers = {}, frames = new Map(), played=[],vibrated=[],audioContexts=[];
let frameId = 0,now=100000;
class Clock extends Date {static now(){return now;}}
const text = [],images=[];
const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({source:image.source||null,x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
const wx = {createImage:()=>({set src(value){const b=fs.readFileSync(path.join(root,value));this.width=b.readUInt32BE(16);this.height=b.readUInt32BE(20);this.source=value;this.onload?.();}}),vibrateShort:o=>vibrated.push({type:o.type,at:now}),setInnerAudioOption:()=>{},createInnerAudioContext:()=>({stop(){},onError(){},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}}), createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
const wx = {createImage:()=>({set src(value){const b=fs.readFileSync(path.join(root,value));this.width=b.readUInt32BE(16);this.height=b.readUInt32BE(20);this.source=value;this.onload?.();}}),vibrateShort:o=>vibrated.push({type:o.type,at:now}),setInnerAudioOption:()=>{},createInnerAudioContext:()=>{const a={stop(){},onError(fn){this.error=fn;},onEnded(fn){this.ended=fn;},offEnded(fn){if(this.ended===fn)this.ended=null;},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}};audioContexts.push(a);return a;}, createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
wx.loadFont=p=>{assert.ok(fs.existsSync(path.join(root,p)),'字体必须随包提供 '+p);return path.basename(p,'.ttf');};
for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize'])
wx['on' + name] = cb => handlers[name] = cb;
@@ -25,5 +25,5 @@ export function boot(storage = new Map(), width = 375, height = 812) {
assert.ok(++steps < 25);
} button(api.buttons().some(b => b.label === label) ? label : label + ' ✓'); if (api.buttons().some(b => b.label === '返回游戏'))
button('返回游戏'); assert.equal(api.snapshot().level, ordered[i].id); };
return { advance(ms){now+=ms;tick();}, api, button, cell, select, tick, handlers, frames, storage, text, played,images,vibrated };
return { advance(ms){now+=ms;tick();}, api, button, cell, select, tick, handlers, frames, storage, text, played,images,vibrated,audioContexts };
}
+3 -3
View File
@@ -1,10 +1,10 @@
{
"channel": "main",
"version": "2026-10-23-centered-title-selected-wav",
"version": "2026-10-23-bgma-duck-node-scale",
"date": "2026-10-23",
"baseVersion": "2026-10-22-resume-chapter-lessons",
"baseMainSHA256": "bb35d5edf8d96eb3253c78052d219b32f9a4241efd9971537f6c9c8896188125",
"package": "NumberSwipe-main-居中标题与指定WAV音乐-20261023.zip",
"package": "NumberSwipe-main-BGMA连续播放与节点缩放强化-20261023.zip",
"promotedFrom": "2026-10-22-layered-feedback-music-flow",
"previousVersion": "2026-10-23-reference-title-artwork"
"previousVersion": "2026-10-23-centered-title-selected-wav"
}
@@ -1,8 +1,8 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import crypto from 'node:crypto';import {Music} from './src/music.mjs';import {boot} from './qa/runtime-harness.mjs';import {solve} from './src/upstream/rules.mjs';import {titleLayout,selectionRow} from './src/selection-title.mjs';
const stub=()=>{const calls=[];return {calls,api:{getStorageSync:()=>'',setStorageSync(){},createInnerAudioContext:()=>({stop(){calls.push('stop');},play(){calls.push(this.src);},onError(){}})}};};
test('entering a level chooses either track once; redraw, mute and background resume keep that choice',()=>{const s=stub(),values=[.1,.9];let n=0;const m=new Music(s.api,{random:()=>{n++;return values.shift()??.1;}});m.scene('play','one');assert.equal(m.audio.src,'music/play-a.wav');for(let i=0;i<20;i++)m.scene('play','one');m.suspend();m.resume();m.toggle();m.toggle();assert.equal(n,1);assert.equal(m.audio.src,'music/play-a.wav');m.scene('play','two');assert.equal(m.audio.src,'music/play-b.wav');assert.equal(n,2);assert.equal(m.audio.loop,true);});
test('subpackage completion never plays a stale scene after hiding, muting, or clearing',()=>{const s=stub();let task;s.api.loadSubpackage=o=>task=o;const m=new Music(s.api,{random:()=>.1});m.scene('play','one');assert.equal(s.calls.length,0);m.suspend();task.success();assert.equal(s.calls.length,0);m.resume();assert.equal(m.audio.src,'music/play-a.wav');m.scene('complete','one');assert.equal(m.playing,null);m.resume();assert.equal(m.playing,null);m.scene('select','one');assert.equal(m.audio.src,'music/select.wav');});
test('BGMA stays continuous across level changes and completion redraws',()=>{const s=stub(),m=new Music(s.api);m.scene('play','one');const calls=s.calls.length;for(let i=0;i<20;i++)m.scene('play','one');m.scene('complete','one');m.scene('play','two');assert.equal(s.calls.length,calls);assert.equal(m.audio.src,'music/play-a.wav');assert.equal(m.audio.loop,true);assert.equal(m.audio.volume,.119);m.duck();assert.equal(m.audio.volume,.02975);m.scene('play','three');assert.equal(s.calls.length,calls);m.unduck();assert.equal(m.audio.volume,.119);});
test('subpackage completion never plays a stale scene after hiding, muting, or clearing',()=>{const s=stub();let task;s.api.loadSubpackage=o=>task=o;const m=new Music(s.api,{random:()=>.1});m.scene('play','one');assert.equal(s.calls.length,0);m.suspend();task.success();assert.equal(s.calls.length,0);m.resume();assert.equal(m.audio.src,'music/play-a.wav');m.scene('complete','one');assert.equal(m.playing,'play');m.resume();assert.equal(m.playing,'play');m.scene('select','one');assert.equal(m.audio.src,'music/select.wav');});
test('music load failure is silent and one real resume can retry; muted pending loads stay silent',()=>{const s=stub();let task,calls=0;s.api.loadSubpackage=o=>{task=o;calls++;};const m=new Music(s.api);m.scene('select');task.fail();m.scene('select');assert.equal(calls,1);m.resume();assert.equal(calls,2);m.toggle();task.success();assert.equal(s.calls.length,0);m.toggle();assert.equal(m.audio.src,'music/select.wav');});
test('runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle',()=>{const meta=JSON.parse(fs.readFileSync(new URL('./.audio-sources/manifest.json',import.meta.url)));for(const [name,item]of Object.entries(meta)){for(const [file,hash]of [[item.output,item.sha256],['.audio-sources/'+name+'.wav',item.sourceSHA256]])assert.equal(crypto.createHash('sha256').update(fs.readFileSync(new URL('./'+file,import.meta.url))).digest('hex'),hash);}const config=JSON.parse(fs.readFileSync(new URL('./game.json',import.meta.url)));assert.ok(config.subpackages.some(p=>p.name==='music'&&p.root==='music/'));assert.ok(fs.existsSync(new URL('./music/game.js',import.meta.url)));let main=0;for(const folder of ['audio','audio-original','fonts','images'])for(const file of fs.readdirSync(new URL('./'+folder,import.meta.url)))main+=fs.statSync(new URL('./'+folder+'/'+file,import.meta.url)).size;main+=fs.statSync(new URL('./game.js',import.meta.url)).size;assert.ok(main<4*1024*1024,main);});
test('title is centered and selection settings share the progress and level-range row',()=>{for(const [w,h]of [[320,568],[375,667],[390,844]]){const ui=boot(new Map(),w,h);ui.select(3);ui.button('选关');const image=ui.images.find(i=>i.source==='images/title-reference.png'),settings=ui.api.buttons().find(b=>b.label==='设置');assert.equal(image.x+image.w/2,w/2);assert.equal(settings.y,selectionRow(w,92).y);assert.ok(settings.y>image.y+image.h);const cards=ui.api.buttons().filter(b=>/^\d+( ✓)?$/.test(b.label));assert.equal(cards.length,15);assert.ok(cards.every(b=>b.h>=44));}});
test('completion pauses background loop, plays one selected jingle and the next level resumes a loop',()=>{const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),ui=boot();ui.select(3);const level=pack.levels.find(l=>l.id===pack.playOrder[3]);solve(level)[0].forEach(id=>{ui.advance(80);ui.cell(id);});assert.equal(ui.api.snapshot().music.playing,null);assert.equal(ui.played.at(-1),'audio/complete.wav');assert.equal(ui.played.filter(f=>f==='audio/complete.wav').length,1);ui.button('下一关 →');assert.equal(ui.api.snapshot().music.playing,'play');assert.ok(['music/play-a.wav','music/play-b.wav'].includes(ui.api.snapshot().music.track));});
test('completion ducks BGMA until jingle ends and next level never restarts it',()=>{const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),ui=boot();ui.select(3);const level=pack.levels.find(l=>l.id===pack.playOrder[3]);const plays=()=>ui.played.filter(f=>f==='music/play-a.wav').length;const before=plays();solve(level)[0].forEach(id=>{ui.advance(80);ui.cell(id);});assert.equal(ui.api.snapshot().music.playing,'play');assert.equal(ui.api.snapshot().music.ducked,true);assert.equal(ui.api.snapshot().music.volume,.02975);assert.equal(ui.played.filter(f=>f==='audio/complete.wav').length,1);ui.audioContexts.find(a=>a.src==='audio/complete.wav').ended();assert.equal(ui.api.snapshot().music.ducked,false);assert.equal(ui.api.snapshot().music.volume,.119);ui.button('下一关 →');assert.equal(ui.api.snapshot().music.track,'music/play-a.wav');assert.equal(plays(),before);});
+6 -5
View File
@@ -4,12 +4,12 @@ export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:
export function stopAudio(){for(const instance of instances)instance.stop();}
export class CasualAudio{
constructor(host,storage,key){Object.assign(this,{storage,key,channels:[],cursor:0,timers:new Set(),lastEvent:null,lastAt:{},lastMove:-Infinity,serial:0,variant:'enhanced'});try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}instances.add(this);}
unlock(){if(!this.enabled||!wx.createInnerAudioContext)return false;try{wx.setInnerAudioOption?.({obeyMuteSwitch:false,mixWithOther:true});if(!this.channels.length)for(let i=0;i<4;i++){const a=wx.createInnerAudioContext();a.obeyMuteSwitch=false;a.volume=.75;a.onError?.(e=>this.error(e));this.channels.push(a);}return true;}catch(e){this.error(e);return false;}}
unlock(){if(!this.enabled||!wx.createInnerAudioContext)return false;try{wx.setInnerAudioOption?.({obeyMuteSwitch:false,mixWithOther:true});if(!this.channels.length)for(let i=0;i<4;i++){const a=wx.createInnerAudioContext();a.obeyMuteSwitch=false;a.volume=.75;a.onError?.(e=>{if(a===this.channels[3])this.finishComplete();this.error(e);});this.channels.push(a);}return true;}catch(e){this.error(e);return false;}}
error(e){audioDiagnostics.lastError={errCode:e?.errCode||0,errMsg:e?.errMsg||String(e)};}
stop(){this.serial++;for(const t of this.timers)clearTimeout(t);this.timers.clear();this.moveTimer=null;this.pendingMove=null;this.lastMove=-Infinity;this.highestUntil=0;for(const a of this.channels){try{a.stop();}catch{}}}
finishComplete(){const finish=this.completeFinish;this.completeFinish=null;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{}}}
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(kind==='complete'&&!delay)this.beforeComplete?.();
if(!this.unlock())return;const now=Date.now();
if(kind==='error'&&now-(this.lastAt.error??-Infinity)<FEEDBACK.errorGapMs)return;this.lastAt[kind]=now;
if(delay>0){const serial=this.serial,t=setTimeout(()=>{this.timers.delete(t);if(serial===this.serial)this.play(kind,0,step);},delay*1000);this.timers.add(t);return;}
@@ -25,10 +25,11 @@ export class CasualAudio{
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{a.stop();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.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?.();}
a.play();this.lastEvent=kind;audioDiagnostics.plays++;
}catch(e){this.error(e);}
}catch(e){this.finishComplete();this.error(e);}
}
}
@@ -1,2 +1,2 @@
export const FEEDBACK={moveMs:90,moveMaxScale:1.025,bridgeMs:70,undoMs:90,checkpointMs:90,checkpointMaxScale:1.065,ribbonMs:1500,maxRibbons:2,hapticGapMs:61,completeHapticDelayMs:90,audioGapMs:50,completeWaitMs:120,errorGapMs:180};
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 priority={move:1,undo:0,error:0,checkpoint:2,complete:3};
+2 -1
View File
@@ -219,7 +219,8 @@ 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 })) };
feedback.audio.beforeComplete=()=>music.scene('complete',window.variantSnapshot().level);
feedback.audio.beforeComplete=()=>music.duck();
feedback.audio.afterComplete=()=>music.unduck();
loadSelectionTitle(wx,requestPaint);
resize();
requestPaint();
+9 -7
View File
@@ -1,17 +1,19 @@
export class Music{
constructor(api,{random=Math.random}={}){this.api=api;this.random=random;this.enabled=true;this.paused=false;this.loaded=typeof api.loadSubpackage!=='function';try{this.enabled=api.getStorageSync('number-swipe:music:v1')!=='off';}catch{}}
constructor(api){this.api=api;this.enabled=true;this.paused=false;this.ducked=false;this.loaded=typeof api.loadSubpackage!=='function';try{this.enabled=api.getStorageSync('number-swipe:music:v1')!=='off';}catch{}}
scene(name,level='default'){
if(name==='play'&&(this.level!==level||this.name!=='play'&&this.name!=='complete'))this.track=this.random()<.5?'music/play-a.wav':'music/play-b.wav';
this.name=name;this.level=level;
if(name==='complete'){if(this.playing)this.stop();return;}
if(!this.enabled||this.paused||this.failed)return;
if(!this.loaded){if(!this.loading){this.loading=true;try{this.api.loadSubpackage({name:'music',success:()=>{this.loading=false;this.loaded=true;this.scene(this.name,this.level);},fail:()=>{this.loading=false;this.failed=true;}});}catch{this.loading=false;this.failed=true;}}return;}
const src=name==='select'?'music/select.wav':this.track;if(this.playing===name&&this.current===src)return;
try{if(!this.audio){this.audio=this.api.createInnerAudioContext();this.audio.loop=true;this.audio.obeyMuteSwitch=false;this.audio.onError?.(()=>{this.playing=null;this.failed=true;});}this.audio.stop();this.audio.volume=name==='play'?.14:.16;this.audio.src=src;this.audio.play();this.playing=name;this.current=src;}catch{this.failed=true;}
const scene=name==='select'?'select':'play',src=scene==='select'?'music/select.wav':'music/play-a.wav';
if(this.playing===scene&&this.current===src){this.volume();return;}
try{if(!this.audio){this.audio=this.api.createInnerAudioContext();this.audio.loop=true;this.audio.obeyMuteSwitch=false;this.audio.onError?.(()=>{this.playing=null;this.failed=true;});}this.audio.stop();this.audio.src=src;this.playing=scene;this.current=src;this.volume();this.audio.play();}catch{this.playing=null;this.failed=true;}
}
volume(){if(this.audio)this.audio.volume=(this.playing==='select'?.136:.119)*(this.ducked?.25:1);}
duck(){this.ducked=true;this.volume();}
unduck(){this.ducked=false;this.volume();}
stop(){this.audio?.stop();this.playing=null;}
suspend(){this.paused=true;this.stop();}
suspend(){this.paused=true;this.unduck();this.stop();}
resume(){this.paused=false;this.failed=false;if(this.name)this.scene(this.name,this.level);}
toggle(){this.enabled=!this.enabled;try{this.api.setStorageSync('number-swipe:music:v1',this.enabled?'on':'off');}catch{}this.stop();if(this.enabled)this.resume();}
snapshot(){return {enabled:this.enabled,playing:this.playing||null,track:this.current||null,loaded:this.loaded,failed:!!this.failed};}
snapshot(){return {enabled:this.enabled,playing:this.playing||null,track:this.current||null,loaded:this.loaded,failed:!!this.failed,ducked:this.ducked,volume:this.audio?.volume??0};}
}