居中标题并接入指定WAV与随机关卡音乐

This commit is contained in:
toshiocn
2026-10-23 12:52:55 +08:00
parent eeff9373f2
commit c13b850fb2
39 changed files with 305 additions and 56 deletions
+10 -1
View File
@@ -16,7 +16,16 @@
"compileType": "game", "compileType": "game",
"simulatorPluginLibVersion": {}, "simulatorPluginLibVersion": {},
"packOptions": { "packOptions": {
"ignore": [], "ignore": [
{
"type": "folder",
"value": ".audio-sources"
},
{
"type": "folder",
"value": "wechat-campaign-200-r2/.audio-sources"
}
],
"include": [] "include": []
}, },
"isGameTourist": false, "isGameTourist": false,
@@ -0,0 +1 @@
User-selected WAV originals are retained byte-for-byte here. Runtime files in music/ are 22050 Hz 16-bit stereo PCM WAV. Completion in audio/complete.wav is 22050 Hz mono, matching the existing SFX pool. Full durations and loop spans are retained. manifest.json records both source and runtime hashes. npm run build verifies runtime files rather than resynthesizing them; it needs no ffmpeg. To re-export a loop with ffmpeg: ffmpeg -i .audio-sources/play-a.wav -map_metadata -1 -fflags +bitexact -flags:a +bitexact -ar 22050 -ac 2 -c:a pcm_s16le music/play-a.wav . Source assets are excluded from WeChat upload and runtime archives.
@@ -0,0 +1,31 @@
# 明亮教具室:正式循环编配 v1
2026-10-23。保留已确认的音色与主题;本目录是独立音乐交付,不修改任何游戏版本。
| 文件 | 用途 | 时长 | 速度与结构 |
|---|---|---|---|
| 01-menu-new-theme-loop-30s | 新菜单主题 | 30秒 | 96 BPM,12小节 |
| 02-BGM-A-sunlit-classroom-loop | 原菜单主题改为关卡 A | 67.2秒 | 保持100 BPM,28小节 |
| 03-BGM-B-soft-wood-loop | 保留木质关卡主题,淘汰原柔钢琴 A | 约65.455秒 | 保持88 BPM,24小节 |
| 04-complete-retained-3s | 保留通关短句 | 3秒 | 与通过的 demo 字节一致,不循环 |
两首关卡曲为保持原主题速度及完整乐句略超1分钟。不是简单复制30秒并淡入淡出:去除原示范的结束句,补齐完整和声周期;加入短句留白、轻微配器变化,末段属和声回到开头主和声。音符尾音与短房间反射循环跨接,无首尾淡入淡出或停顿。菜单采用新的旋律,仍沿用同一组柔和电琴、木琴和圆润低音音色。
全部是固定混音,不做落格、节点或快速操作驱动的音量变化。编曲保持中低音域、短尾音与较少和声声部,为连线 SFX 留出空间。仍需实际游戏内试听决定统一静态 BGM 音量;本交付未进行游戏内叠加或手机听感验收。
## 文件用途
- WAV:44.1kHz / 16bit / 立体声,正式循环基准文件,循环区间为整文件。
- OGG:三首循环曲的压缩版本,是否可用须由目标播放宿主确认。
- MP3:便捷试听;不同播放器对编码延迟和填充处理不同,不承诺任意播放器原生整文件循环都无缝。开发时优先使用 WAV,或确认解码后长度与循环点的压缩格式。
- arrangement-and-validation.json:完整事件、时长、帧数、峰值、RMS、边界相邻样本差。
- build-loops.cjs / render.generated.cjs:可复现源文件。直接运行后者重制 WAV/MP3;完整构建脚本依赖相邻保留的 demo 源文件,另生成 OGG 和复制通关短句。
循环检查涵盖PCM峰值、削波与跨边界连续性,不代表真实听音设备验收。通关MP3/WAV与此前版本完全一致。
## 音源与署名
旋律、编配、合成与渲染为本项目制作,非任天堂曲目改编。电琴、低音与节奏为程序合成;钢琴、木琴单音采样来自 tonejs-instruments,贡献者 N. P. Brosowsky,原始来源 Versilian Studios / VSCO 2,上游合集标注 CC BY 3.0。已进行移调、滤波、包络、声像和原创编配。正式上线保留署名与许可证信息,来源资料及本次所需采样已放入 sources/。
- https://github.com/nbrosowsky/tonejs-instruments
- https://creativecommons.org/licenses/by/3.0/
Binary file not shown.
@@ -0,0 +1,38 @@
{
"select": {
"sourceSHA256": "49c981ad32a632d0358d2681dbec3e96f2bf5d4a504808d592b531855dd0e812",
"output": "music/select.wav",
"sha256": "9678b61b466f6fc74e52742e4bf7b4f29c8eb8bc0c254e5acb864a0f5829159c",
"sampleRate": 22050,
"channels": 2,
"bytes": 2646044,
"seconds": 30
},
"play-a": {
"sourceSHA256": "701db5c91cf92ddf77901bd251a0f705fd98b6b46b25b35e0290d995d61d0093",
"output": "music/play-a.wav",
"sha256": "8babe95ed870257a0e64f0720455cd80935b88e3f53bbdedc53a444708b28a85",
"sampleRate": 22050,
"channels": 2,
"bytes": 5927084,
"seconds": 67.2
},
"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",
"sha256": "84db2b1332d7d9e2ed83cada331be7df8774c1195399296fdead409d93180afa",
"sampleRate": 22050,
"channels": 1,
"bytes": 132344,
"seconds": 3
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -1,3 +1,16 @@
## 2026-10-23:居中标题与指定WAV音乐
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
- 标题保持原画并水平居中;设置移到总进度、关卡范围标签同一行。小屏仍为每页15关,按钮无重叠。
- 菜单30秒循环;每次进入关卡从67.2秒A、约65.455秒B中随机选一首循环。同关重绘、移动、静音恢复及后台恢复不重选,切关再随机。
- 通关短句替换原通关音,使用交付的3秒WAV,仅播放一次;通关之前先停止BGM,下一关恢复循环。短句沿用音效开关及实际起播同步,BGM沿用音乐开关,无动态压低或叠加。
- 原始44.1kHz/16bit立体声WAV原件保留于.audio-sources并记录哈希;运行BGM适配为22.05kHz/16bit立体声WAV,通关短句为同采样率单声道,完整时长与乐句保留。没有改用MP3或OGG。npm构建校验现成WAV,不依赖ffmpeg。
- 长BGM放入music分包,异步加载期间操作不阻塞;回调只播放当前场景,静音、后台、通关及加载失败均不会迟到播放。原始来源目录已从根工程与子工程上传配置中排除;music/CREDITS.md保留音源署名。
- 验收:89项模块测试、200关触摸回归;375及320×568浏览器布局,随机选择、分包取消、前后台与音效同步均有回归。微信真机音量、循环边界听感与分包下载仍需预览验收。
包:../releases/NumberSwipe-main-居中标题与指定WAV音乐-20261023.zip。工程仍为wechat-campaign-200-r2。
## 2026-10-23:参考图标题还原 ## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。 版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
+13
View File
@@ -1,3 +1,16 @@
## 2026-10-23:居中标题与指定WAV音乐
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
- 标题保持原画并水平居中;设置移到总进度、关卡范围标签同一行。小屏仍为每页15关,按钮无重叠。
- 菜单30秒循环;每次进入关卡从67.2秒A、约65.455秒B中随机选一首循环。同关重绘、移动、静音恢复及后台恢复不重选,切关再随机。
- 通关短句替换原通关音,使用交付的3秒WAV,仅播放一次;通关之前先停止BGM,下一关恢复循环。短句沿用音效开关及实际起播同步,BGM沿用音乐开关,无动态压低或叠加。
- 原始44.1kHz/16bit立体声WAV原件保留于.audio-sources并记录哈希;运行BGM适配为22.05kHz/16bit立体声WAV,通关短句为同采样率单声道,完整时长与乐句保留。没有改用MP3或OGG。npm构建校验现成WAV,不依赖ffmpeg。
- 长BGM放入music分包,异步加载期间操作不阻塞;回调只播放当前场景,静音、后台、通关及加载失败均不会迟到播放。原始来源目录已从根工程与子工程上传配置中排除;music/CREDITS.md保留音源署名。
- 验收:89项模块测试、200关触摸回归;375及320×568浏览器布局,随机选择、分包取消、前后台与音效同步均有回归。微信真机音量、循环边界听感与分包下载仍需预览验收。
包:../releases/NumberSwipe-main-居中标题与指定WAV音乐-20261023.zip。工程仍为wechat-campaign-200-r2。
## 2026-10-23:参考图标题还原 ## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。 版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
+13
View File
@@ -1,3 +1,16 @@
## 2026-10-23:居中标题与指定WAV音乐
版本:2026-10-23-centered-title-selected-wav。使用用户交付的NumberSwipe-BGM-LOOP-v1-20261023音频;旧版发布包保留。
- 标题保持原画并水平居中;设置移到总进度、关卡范围标签同一行。小屏仍为每页15关,按钮无重叠。
- 菜单30秒循环;每次进入关卡从67.2秒A、约65.455秒B中随机选一首循环。同关重绘、移动、静音恢复及后台恢复不重选,切关再随机。
- 通关短句替换原通关音,使用交付的3秒WAV,仅播放一次;通关之前先停止BGM,下一关恢复循环。短句沿用音效开关及实际起播同步,BGM沿用音乐开关,无动态压低或叠加。
- 原始44.1kHz/16bit立体声WAV原件保留于.audio-sources并记录哈希;运行BGM适配为22.05kHz/16bit立体声WAV,通关短句为同采样率单声道,完整时长与乐句保留。没有改用MP3或OGG。npm构建校验现成WAV,不依赖ffmpeg。
- 长BGM放入music分包,异步加载期间操作不阻塞;回调只播放当前场景,静音、后台、通关及加载失败均不会迟到播放。原始来源目录已从根工程与子工程上传配置中排除;music/CREDITS.md保留音源署名。
- 验收:89项模块测试、200关触摸回归;375及320×568浏览器布局,随机选择、分包取消、前后台与音效同步均有回归。微信真机音量、循环边界听感与分包下载仍需预览验收。
包:../releases/NumberSwipe-main-居中标题与指定WAV音乐-20261023.zip。工程仍为wechat-campaign-200-r2。
## 2026-10-23:参考图标题还原 ## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。 版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
+1
View File
@@ -4,6 +4,7 @@ export function buildAudio(root){
const cues={ui:[[659,.055,0],[784,.06,.025]],hint:[[523,.09,0],[659,.10,.055],[784,.13,.11]],correct:[[659,.11,0]],error:[[330,.09,0],[247,.09,.045]],undo:[[440,.065,0]],restart:[[523,.08,0],[392,.09,.06]],complete:[[261.6256,.96,0,.8],[329.6276,.68,0,.62],[392,.62,0,.48],[523.251,.5,0,.16]],checkpoint:[[523,.14,0]]}; const cues={ui:[[659,.055,0],[784,.06,.025]],hint:[[523,.09,0],[659,.10,.055],[784,.13,.11]],correct:[[659,.11,0]],error:[[330,.09,0],[247,.09,.045]],undo:[[440,.065,0]],restart:[[523,.08,0],[392,.09,.06]],complete:[[261.6256,.96,0,.8],[329.6276,.68,0,.62],[392,.62,0,.48],[523.251,.5,0,.16]],checkpoint:[[523,.14,0]]};
for(let n=1;n<=36;n++){const f=523*Math.pow(2,(n-1)/12);cues['move-'+n]=[[f,.09,0]];cues['checkpoint-'+n]=[[f,.14,0]];} for(let n=1;n<=36;n++){const f=523*Math.pow(2,(n-1)/12);cues['move-'+n]=[[f,.09,0]];cues['checkpoint-'+n]=[[f,.14,0]];}
for(const [name,tones]of Object.entries(cues)){ for(const [name,tones]of Object.entries(cues)){
if(name==='complete')continue; // User supplied completion WAV is retained.
const rate=22050,length=Math.ceil((Math.max(...tones.map(([,d,t])=>d+t))+.02)*rate),b=Buffer.alloc(44+length*2); const rate=22050,length=Math.ceil((Math.max(...tones.map(([,d,t])=>d+t))+.02)*rate),b=Buffer.alloc(44+length*2);
b.write('RIFF');b.writeUInt32LE(b.length-8,4);b.write('WAVEfmt ',8);b.writeUInt32LE(16,16);b.writeUInt16LE(1,20);b.writeUInt16LE(1,22);b.writeUInt32LE(rate,24);b.writeUInt32LE(rate*2,28);b.writeUInt16LE(2,32);b.writeUInt16LE(16,34);b.write('data',36);b.writeUInt32LE(length*2,40); b.write('RIFF');b.writeUInt32LE(b.length-8,4);b.write('WAVEfmt ',8);b.writeUInt32LE(16,16);b.writeUInt16LE(1,20);b.writeUInt16LE(1,22);b.writeUInt32LE(rate,24);b.writeUInt32LE(rate*2,28);b.writeUInt16LE(2,32);b.writeUInt16LE(16,34);b.write('data',36);b.writeUInt32LE(length*2,40);
for(let i=0;i<length;i++){const t=i/rate;let sample=0;for(const [f,d,offset,weight=1]of tones){const u=t-offset;if(u<0||u>=d)continue; for(let i=0;i<length;i++){const t=i/rate;let sample=0;for(const [f,d,offset,weight=1]of tones){const u=t-offset;if(u<0||u>=d)continue;
+1 -5
View File
@@ -12,8 +12,4 @@ test('教学和组合规则用具体动作及奇偶例子解释,没有未解
test('过关提示同步使用实际播放起点,非请求时间',()=>{const h=setup();h.sound.play('complete');assert.equal(audioDiagnostics.completeStartedAt,null);const requested=audioDiagnostics.completeRequestedAt;h.contexts.find(c=>c.src==='audio/complete.wav').playing();assert.ok(audioDiagnostics.completeStartedAt>=requested);}); test('过关提示同步使用实际播放起点,非请求时间',()=>{const h=setup();h.sound.play('complete');assert.equal(audioDiagnostics.completeStartedAt,null);const requested=audioDiagnostics.completeRequestedAt;h.contexts.find(c=>c.src==='audio/complete.wav').playing();assert.ok(audioDiagnostics.completeStartedAt>=requested);});
test('过关和弦收尾持续衰减,不在后半段重新起音',()=>{ test('过关短句使用用户提供的3秒WAV,不与旧和弦叠加',()=>{const b=fs.readFileSync(path.join(root,'audio/complete.wav'));assert.equal(b.readUInt32LE(24),22050);assert.equal((b.length-44)/(22050*2),3);const h=setup();let stops=0;h.sound.beforeComplete=()=>stops++;h.sound.play('complete');assert.equal(stops,1);assert.deepEqual(h.played,['audio/complete.wav']);});
const b=fs.readFileSync(path.join(root,'audio/complete.wav')),rate=b.readUInt32LE(24),rms=[];
for(let t=.4;t<.8;t+=.1){let energy=0,n=0;for(let i=Math.floor(t*rate);i<Math.min(Math.floor((t+.1)*rate),(b.length-44)/2);i++){const v=b.readInt16LE(44+i*2);energy+=v*v;n++;}rms.push(Math.sqrt(energy/n));}
for(let i=1;i<rms.length;i++)assert.ok(rms[i]<rms[i-1],rms.join(','));
});
Binary file not shown.
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -1,4 +1,10 @@
{ {
"deviceOrientation": "portrait", "deviceOrientation": "portrait",
"showStatusBar": false "showStatusBar": false,
"subpackages": [
{
"name": "music",
"root": "music/"
}
]
} }
+3 -22
View File
@@ -1,22 +1,3 @@
import fs from 'node:fs';import path from 'node:path'; import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';
const rate=22050; // User-selected WAVs are versioned assets, never overwritten by synthesis.
function writeWav(root,name,samples){const n=samples.length,b=Buffer.alloc(44+n*2);b.write('RIFF');b.writeUInt32LE(b.length-8,4);b.write('WAVEfmt ',8);b.writeUInt32LE(16,16);b.writeUInt16LE(1,20);b.writeUInt16LE(1,22);b.writeUInt32LE(rate,24);b.writeUInt32LE(rate*2,28);b.writeUInt16LE(2,32);b.writeUInt16LE(16,34);b.write('data',36);b.writeUInt32LE(n*2,40);for(let i=0;i<n;i++)b.writeInt16LE(Math.round(Math.max(-1,Math.min(1,samples[i]))*32767),44+i*2);fs.writeFileSync(path.join(root,'music',name+'.wav'),b);} export function buildMusic(root){const manifest=JSON.parse(fs.readFileSync(path.join(root,'.audio-sources/manifest.json')));for(const item of Object.values(manifest)){const b=fs.readFileSync(path.join(root,item.output));if(b.toString('ascii',0,4)!=='RIFF'||crypto.createHash('sha256').update(b).digest('hex')!==item.sha256)throw Error('Selected WAV resource mismatch: '+item.output);}}
export function buildMusic(root){
fs.mkdirSync(path.join(root,'music'),{recursive:true});
// Selection music is byte-for-byte unchanged.
const select=new Float64Array(rate*16),notes=[60,67,72,64,69,67,64,62];
for(let k=0;k<notes.length;k++){const f=440*2**((notes[k]-69)/12),offset=k*2;for(let j=0;j<rate*4;j++){const t=j/rate,env=(1-Math.exp(-t/.025))*Math.exp(-t/.65)*Math.min(1,(4-t)/.15);select[(offset*rate+j)%select.length]+=(Math.sin(2*Math.PI*f*t)+.12*Math.sin(4*Math.PI*f*t))*env*.24;}}
writeWav(root,'select',select);
// Original 32-second warm pad: no percussion, note attacks or recurring beat.
// Integer cycles and periodic crossfades preserve both waveform and slope at wrap.
const seconds=32,samples=new Float64Array(rate*seconds),chords=[[48,55,60,64],[45,52,55,60],[48,55,62,67]];
const voices=chords.map((notes,k)=>notes.map((midi,j)=>({f:Math.round(440*2**((midi-69)/12)*seconds)/seconds,phase:(k*1.7+j*.9),gain:j===0?.75:1})));
let sum=0,peak=0;
for(let i=0;i<samples.length;i++){const t=i/rate;let value=0;
for(let k=0;k<voices.length;k++){const weight=(1-Math.cos(2*Math.PI*(t/seconds-k/voices.length)))/2;
for(const v of voices[k]){const phase=2*Math.PI*v.f*t+v.phase;value+=weight*v.gain*(Math.sin(phase)+.055*Math.sin(2*phase)+.012*Math.sin(3*phase));}}
samples[i]=value;sum+=value*value;peak=Math.max(peak,Math.abs(value));
}
const gain=Math.min(.035/Math.sqrt(sum/samples.length),.16/peak);for(let i=0;i<samples.length;i++)samples[i]*=gain;
writeWav(root,'play',samples);
}
+3 -4
View File
@@ -4,7 +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('old arrows retain distance, rush to endpoint and fresh arrows move at base speed',()=>{const a=new HintArrows(),motion={total:300},events=[];let f=a.frame('a',0,events);a.heads(f,motion,0);f=a.frame('a',100,events);assert.equal(a.heads(f,motion,0)[0],6.5);events.push({sequence:1,kind:'move'});f=a.frame('a',116,events);const at=a.heads(f,motion,30);assert.ok(at[0]>6.5&&at[0]<10);assert.equal(at[1],30);f=a.frame('a',216,events);const next=a.heads(f,motion,30);assert.ok(next[0]>150);assert.equal(next[1],36.5);for(let t=316;t<=416;t+=100)a.heads(a.frame('a',t,events),motion,30);assert.ok(a.particles.every(p=>!p.rush));a.reset();assert.equal(a.key,null);});
test('settings have exactly three independent preferences, and hint has its own cue',()=>{const ui=boot();ui.select(3);ui.button('设置');assert.deepEqual(Array.from(ui.api.buttons(),b=>b.label),['音乐:开','音效开','振动:开','完成']);ui.button('完成');ui.button('指路');assert.equal(ui.played.at(-1),'audio/hint.wav');ui.button('选关');assert.ok(ui.played.includes('music/select.wav'));ui.button('下一页');assert.equal(ui.played.at(-1),'audio/ui.wav');}); test('settings have exactly three independent preferences, and hint has its own cue',()=>{const ui=boot();ui.select(3);ui.button('设置');assert.deepEqual(Array.from(ui.api.buttons(),b=>b.label),['音乐:开','音效开','振动:开','完成']);ui.button('完成');ui.button('指路');assert.equal(ui.played.at(-1),'audio/hint.wav');ui.button('选关');assert.ok(ui.played.includes('music/select.wav'));ui.button('下一页');assert.equal(ui.played.at(-1),'audio/ui.wav');});
test('actual fixed numeric node uses checkpoint pitch, final cell uses complete chord',()=>{const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));const i=levels.findIndex(l=>l.cells.some(c=>c.clue>1&&c.clue<l.cells.length));const l=levels[i],ui=boot();ui.select(i);const route=solve(l)[0];for(const id of route){ui.advance(80);ui.cell(id);const e=ui.api.snapshot().feedback.events.at(-1);if(e.kind==='checkpoint')assert.equal(ui.played.at(-1),'audio/checkpoint-'+e.step+'.wav');}assert.equal(ui.played.at(-1),'audio/complete.wav');assert.ok(ui.api.snapshot().feedback.events.some(e=>e.kind==='checkpoint'));}); test('actual fixed numeric node uses checkpoint pitch, final cell uses complete chord',()=>{const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));const i=levels.findIndex(l=>l.cells.some(c=>c.clue>1&&c.clue<l.cells.length));const l=levels[i],ui=boot();ui.select(i);const route=solve(l)[0];for(const id of route){ui.advance(80);ui.cell(id);const e=ui.api.snapshot().feedback.events.at(-1);if(e.kind==='checkpoint')assert.equal(ui.played.at(-1),'audio/checkpoint-'+e.step+'.wav');}assert.equal(ui.played.at(-1),'audio/complete.wav');assert.ok(ui.api.snapshot().feedback.events.some(e=>e.kind==='checkpoint'));});
test('two original music loops are quiet, nonempty and have a smooth loop seam',()=>{for(const name of ['select','play']){const b=fs.readFileSync(new URL('./music/'+name+'.wav',import.meta.url));assert.equal(b.readUInt32LE(24),22050);assert.ok(b.length>600000);assert.ok(Math.abs(b.readInt16LE(44)-b.readInt16LE(b.length-2))<100);}}); test('selected menu and 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('warm gameplay ambient is 32 seconds, softly leveled and free from percussive envelope jumps',()=>{const b=fs.readFileSync(new URL('./music/play.wav',import.meta.url)),n=(b.length-44)/2,frames=[];assert.equal(n/22050,32);let sum=0,peak=0;for(let i=0;i<n;i++){const v=b.readInt16LE(44+i*2)/32767;sum+=v*v;peak=Math.max(peak,Math.abs(v));}assert.ok(Math.sqrt(sum/n)>.03&&Math.sqrt(sum/n)<.04);assert.ok(peak<.17);for(let i=0;i<n;i+=2205){let energy=0,count=0;for(let j=i;j<Math.min(i+2205,n);j++){const v=b.readInt16LE(44+j*2)/32767;energy+=v*v;count++;}frames.push(Math.sqrt(energy/count));}assert.ok(frames.every((v,i)=>!i||Math.abs(v-frames[i-1])<.006));assert.ok(Math.abs(b.readInt16LE(44)-b.readInt16LE(b.length-2))<50);}); 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('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.equal(m.audio.src,'music/play.wav');assert.equal(m.audio.volume,.14);m.toggle();assert.equal(new Music(api).enabled,false);});
+1
View File
@@ -0,0 +1 @@
Music supplied by the user: NumberSwipe-BGM-LOOP-v1-20261023. Original composition, arrangement and synthesis for this project. Piano and xylophone source samples: tonejs-instruments, N. P. Brosowsky; Versilian Studios / VSCO 2; CC BY 3.0. Samples have been pitched, filtered, enveloped and arranged. https://github.com/nbrosowsky/tonejs-instruments ; https://creativecommons.org/licenses/by/3.0/ . Runtime WAV adapted to 22050 Hz; originals and manifest retained in .audio-sources (not shipped). Completion jingle is audio/complete.wav.
+1
View File
@@ -0,0 +1 @@
// Music-only subpackage; playback uses native WAV resources.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -111,6 +111,14 @@
{ {
"type": "file", "type": "file",
"value": "selection-title.test.mjs" "value": "selection-title.test.mjs"
},
{
"type": "folder",
"value": ".audio-sources"
},
{
"type": "file",
"value": "selected-music.test.mjs"
} }
] ]
}, },
+1 -1
View File
@@ -22,7 +22,7 @@ export function browserAudio(report){
stop(){generation++;player.pause();if(player.readyState)player.currentTime=0;}, stop(){generation++;player.pause();if(player.readyState)player.currentTime=0;},
play(){ play(){
const current=generation; const current=generation;
player.play().then(()=>{if(!disposed&&current===generation){playHandler();report('音效已播放 · '+(++plays)+' 次');}}) player.play().then(()=>{if(!disposed&&current===generation){playHandler();report((player.loop?'音乐':'音效')+'已播放 · '+player.src.split('/').at(-1)+' · '+(++plays)+' 次');}})
.catch(reason=>{if(current===generation)error(reason);}); .catch(reason=>{if(current===generation)error(reason);});
} }
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,115 @@
> numberswipe-main@1.0.0 check
> npm run build && npm test && npm run smoke
> numberswipe-main@1.0.0 build
> node build.mjs
Built campaign-200-r2: 200 levels
> numberswipe-main@1.0.0 test
> node --test *.test.mjs
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (14.9009ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (2.1632ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (54.554ms)
✔ 音频错误可诊断,不再静默吞掉 (1.5022ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (50.2806ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (34.0885ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (0.6778ms)
✔ 过关短句使用用户提供的3秒WAV,不与旧和弦叠加 (2.7838ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (94.9455ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (1024.4193ms)
✔ guides through every irregular block stay inside its occupied shape (38.5314ms)
✔ visited, conflicting and hinted category cells retain their material identity (13.3387ms)
✔ five font subsets match their manifest and fit the package budget (4.7748ms)
✔ upstream adapter fails closed if the inspected render seams change (1.1066ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (23.2625ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (2.0215ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (11.0858ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (3.2947ms)
✔ fixed channels remain anchored at their block centers after number enlargement (1060.741ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (2.5724ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.458ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (6.5873ms)
✔ completion waits at most 120ms and ignores late audio callbacks (1.2868ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (1.6916ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (1.0837ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (0.6282ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.38ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (1.3569ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (207.1856ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (148.4917ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (86.424ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (134.7882ms)
✔ pressure and rebound have bounded amplitude and duration (0.6392ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.5552ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (46.156ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (3.0031ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (109.6829ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (17.1175ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (5.642ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (9.2333ms)
✔ all 200 levels reveal consecutive segments without changing the player path (2016.9027ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (1.5701ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (0.6078ms)
✔ coral teaching ribbon preserves visited masks and offers direction in reduced motion (2.9612ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.6602ms)
✔ 实际教学关动画:basic (5.3181ms)
✔ 实际教学关动画:walls (1.0642ms)
✔ 实际教学关动画:straight (2.4424ms)
✔ 实际教学关动画:vertical (7.5609ms)
✔ 实际教学关动画:corner (1.7796ms)
✔ 实际教学关动画:colour (2.6619ms)
✔ 实际教学关动画:parity (1.9387ms)
✔ 实际教学关动画:rhythm-door (0.9813ms)
✔ 实际教学关动画:hex (0.9572ms)
✔ 实际教学关动画:unknown-start (2.9763ms)
✔ 200关的组合规则均能找到对应真实教学关 (147.2545ms)
✔ 教学减速,结束停留1秒后自动循环 (3.0083ms)
✔ music loops independently, persists mute and cancels in background (2.8131ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (0.9428ms)
✔ settings have exactly three independent preferences, and hint has its own cue (238.1837ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (167.2175ms)
✔ selected menu and both level loops are stereo PCM WAV with complete musical durations (21.4585ms)
✔ selected four runtime WAVs retain the original durations after sample-rate adaptation (40.46ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (2.8611ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (27899.3609ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (58.2145ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (1.3006ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.4739ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (7.5203ms)
✔ quick undo cancels removed node rebound, including last-step removal (2.1063ms)
✔ first launch goes directly to gameplay (148.3509ms)
✔ resume unfinished route after exit from gameplay (194.5898ms)
✔ resume unfinished route after exit from selection (180.1794ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (120.5731ms)
✔ replaying a cleared level does not replace the unfinished save (165.7154ms)
✔ legacy saves, malformed data and full completion have valid startup targets (86.919ms)
✔ selection pages expose fifteen cards and fit phone safe areas (337.4545ms)
✔ hint-only frames reuse static UI, and player input invalidates it (59.5498ms)
✔ hint curves are cached and distance lookup is stable at boundaries (0.9446ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (52.7928ms)
✔ entering a level chooses either track once; redraw, mute and background resume keep that choice (2.0173ms)
✔ subpackage completion never plays a stale scene after hiding, muting, or clearing (0.592ms)
✔ music load failure is silent and one real resume can retry; muted pending loads stay silent (0.3667ms)
✔ runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle (279.7373ms)
✔ title is centered and selection settings share the progress and level-range row (294.8849ms)
✔ completion pauses background loop, plays one selected jingle and the next level resumes a loop (172.4642ms)
✔ title artwork is a transparent, high resolution PNG packaged locally (3.6602ms)
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (0.8729ms)
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (406.3256ms)
ℹ tests 89
ℹ suites 0
ℹ pass 89
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 28380.4433
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
@@ -0,0 +1 @@
音效已播放 · complete.wav · 3 次
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"channel": "main", "channel": "main",
"version": "2026-10-23-reference-title-artwork", "version": "2026-10-23-centered-title-selected-wav",
"date": "2026-10-23", "date": "2026-10-23",
"baseVersion": "2026-10-22-resume-chapter-lessons", "baseVersion": "2026-10-22-resume-chapter-lessons",
"baseMainSHA256": "bb35d5edf8d96eb3253c78052d219b32f9a4241efd9971537f6c9c8896188125", "baseMainSHA256": "bb35d5edf8d96eb3253c78052d219b32f9a4241efd9971537f6c9c8896188125",
"package": "NumberSwipe-main-参考图标题还原版-20261023.zip", "package": "NumberSwipe-main-居中标题与指定WAV音乐-20261023.zip",
"promotedFrom": "2026-10-22-layered-feedback-music-flow", "promotedFrom": "2026-10-22-layered-feedback-music-flow",
"previousVersion": "2026-10-23-warm-ambient-bgm" "previousVersion": "2026-10-23-reference-title-artwork"
} }
@@ -0,0 +1,8 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import crypto from 'node:crypto';import {Music} from './src/music.mjs';import {boot} from './qa/runtime-harness.mjs';import {solve} from './src/upstream/rules.mjs';import {titleLayout,selectionRow} from './src/selection-title.mjs';
const stub=()=>{const calls=[];return {calls,api:{getStorageSync:()=>'',setStorageSync(){},createInnerAudioContext:()=>({stop(){calls.push('stop');},play(){calls.push(this.src);},onError(){}})}};};
test('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('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));});
@@ -1,4 +1,4 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {loadSelectionTitle,paintSelectionTitle,titleLayout} from './src/selection-title.mjs';import {chapterLayout} from './src/level-select.mjs';import {boot} from './qa/runtime-harness.mjs'; import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {loadSelectionTitle,paintSelectionTitle,titleLayout} from './src/selection-title.mjs';import {chapterLayout} from './src/level-select.mjs';import {boot} from './qa/runtime-harness.mjs';
test('title artwork is a transparent, high resolution PNG packaged locally',()=>{const b=fs.readFileSync(new URL('./images/title-reference.png',import.meta.url));assert.equal(b.subarray(1,4).toString(),'PNG');assert.equal(b.readUInt32BE(16),2132);assert.equal(b.readUInt32BE(20),737);assert.equal(b[25],6);}); test('title artwork is a transparent, high resolution PNG packaged locally',()=>{const b=fs.readFileSync(new URL('./images/title-reference.png',import.meta.url));assert.equal(b.subarray(1,4).toString(),'PNG');assert.equal(b.readUInt32BE(16),2132);assert.equal(b.readUInt32BE(20),737);assert.equal(b[25],6);});
test('asynchronous native image load requests a repaint and draws the artwork without a font substitute',()=>{const image={width:2132,height:737},draws=[];let invalidations=0;loadSelectionTitle({createImage:()=>image},()=>invalidations++);assert.equal(image.src,'images/title-reference.png');paintSelectionTitle({drawImage:(...args)=>draws.push(args)},375,92);assert.equal(draws.length,0);image.onload();assert.equal(invalidations,1);paintSelectionTitle({drawImage:(...args)=>draws.push(args)},375,92);assert.equal(draws[0][0],image);assert.equal(draws[0][3],224);assert.ok(Math.abs(draws[0][4]/draws[0][3]-737/2132)<1e-10);}); test('asynchronous native image load requests a repaint and draws the artwork without a font substitute',()=>{const image={width:2132,height:737},draws=[];let invalidations=0;loadSelectionTitle({createImage:()=>image},()=>invalidations++);assert.equal(image.src,'images/title-reference.png');paintSelectionTitle({drawImage:(...args)=>draws.push(args)},375,92);assert.equal(draws.length,0);image.onload();assert.equal(invalidations,1);paintSelectionTitle({drawImage:(...args)=>draws.push(args)},375,92);assert.equal(draws[0][0],image);assert.equal(draws[0][3],224);assert.ok(Math.abs(draws[0][4]/draws[0][3]-737/2132)<1e-10);});
test('artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes',()=>{for(const [width,height]of [[320,568],[375,667],[390,844],[414,896]]){const b=titleLayout(width,92),grid=chapterLayout(width,height,92,34);assert.ok(b.y+b.w*737/2132<b.pillY);assert.ok(b.x>=16&&b.x+b.w<=width-72);assert.ok(b.pillY+24<grid.y);assert.ok(grid.h>=44&&grid.w>=44);const ui=boot(new Map(),width,height);ui.select(3);ui.button('选关');const logo=ui.images.find(i=>i.source==='images/title-reference.png');assert.ok(logo);assert.ok(logo.w>180);assert.equal(ui.api.buttons().filter(b=>/^\d+( ✓)?$/.test(b.label)).length,15);}}); test('artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes',()=>{for(const [width,height]of [[320,568],[375,667],[390,844],[414,896]]){const b=titleLayout(width,92),grid=chapterLayout(width,height,92,34);assert.ok(b.y+b.w*737/2132<b.pillY);assert.equal(b.x+b.w/2,width/2);assert.ok(b.x>=16&&b.x+b.w<=width-16);assert.ok(b.pillY+24<grid.y);assert.ok(grid.h>=44&&grid.w>=44);const ui=boot(new Map(),width,height);ui.select(3);ui.button('选关');const logo=ui.images.find(i=>i.source==='images/title-reference.png');assert.ok(logo);assert.ok(logo.w>180);assert.equal(ui.api.buttons().filter(b=>/^\d+( ✓)?$/.test(b.label)).length,15);}});
+1
View File
@@ -9,6 +9,7 @@ export class CasualAudio{
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{}}} 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{}}}
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(kind==='complete'&&!delay)this.beforeComplete?.();
if(!this.unlock())return;const now=Date.now(); if(!this.unlock())return;const now=Date.now();
if(kind==='error'&&now-(this.lastAt.error??-Infinity)<FEEDBACK.errorGapMs)return;this.lastAt[kind]=now; if(kind==='error'&&now-(this.lastAt.error??-Infinity)<FEEDBACK.errorGapMs)return;this.lastAt[kind]=now;
if(delay>0){const serial=this.serial,t=setTimeout(()=>{this.timers.delete(t);if(serial===this.serial)this.play(kind,0,step);},delay*1000);this.timers.add(t);return;} if(delay>0){const serial=this.serial,t=setTimeout(()=>{this.timers.delete(t);if(serial===this.serial)this.play(kind,0,step);},delay*1000);this.timers.add(t);return;}
+4 -4
View File
@@ -1,4 +1,4 @@
import {paintSelectionTitle,titleLayout} from './selection-title.mjs'; import {paintSelectionTitle,titleLayout,selectionRow} from './selection-title.mjs';
import {C,label,panel,roundRect} from './bright-classroom/theme.mjs'; import {C,label,panel,roundRect} from './bright-classroom/theme.mjs';
export function chapterLayout(width,height,top,bottom,count=15){ export function chapterLayout(width,height,top,bottom,count=15){
@@ -18,7 +18,7 @@ export function levelCard(ctx,labelText,x,y,w,h,{completed,current}){
// Original reference artwork, rather than a font reconstruction. // Original reference artwork, rather than a font reconstruction.
export function selectionHeader(ctx,width,top,progress,first,last){ export function selectionHeader(ctx,width,top,progress,first,last){
paintSelectionTitle(ctx,width,top); paintSelectionTitle(ctx,width,top);
const gap=10,pw=132,rw=104,x=(width-pw-rw-gap)/2,py=titleLayout(width,top).pillY; const row=selectionRow(width,top),{gap,x}=row,pw=row.w,rw=row.w,py=row.y;
panel(ctx,x,py,pw,24,12,C.surface,2);label(ctx,'总进度 '+progress,x+pw/2,py+12,12,C.ink,700); panel(ctx,x,py,pw,26,13,C.surface,2);label(ctx,'总进度 '+progress,x+pw/2,py+13,12,C.ink,700);
panel(ctx,x+pw+gap,py,rw,24,12,C.coral,2);label(ctx,'第 '+first+'–'+last+' 关',x+pw+gap+rw/2,py+12,12,C.onPrimary,700); panel(ctx,x+pw+gap,py,rw,26,13,C.coral,2);label(ctx,'第 '+first+'–'+last+' 关',x+pw+gap+rw/2,py+13,12,C.onPrimary,700);
} }
+7 -4
View File
@@ -1,4 +1,4 @@
import {loadSelectionTitle} 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';
import {feedback} from './feedback.mjs'; import {feedback} from './feedback.mjs';
@@ -58,7 +58,7 @@ 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':'play'); const state=window.variantSnapshot();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);
@@ -78,7 +78,7 @@ function draw() {
button('设置',width-72,top+4,56,34,()=>settingsOpen=true);
const levels=levelButtons(),pages=Math.ceil(ordered.length/15); const levels=levelButtons(),pages=Math.ceil(ordered.length/15);
if(!selectWasOpen)page=Math.floor((state.displayOrder-1)/15); if(!selectWasOpen)page=Math.floor((state.displayOrder-1)/15);
selectWasOpen=true;page=Math.max(0,Math.min(pages-1,page)); selectWasOpen=true;page=Math.max(0,Math.min(pages-1,page));
@@ -86,6 +86,7 @@ function draw() {
const nodes=chapterLevels.map(l=>levels[ordered.indexOf(l)]); const nodes=chapterLevels.map(l=>levels[ordered.indexOf(l)]);
const layout=chapterLayout(width,height,top,bottom,15); const layout=chapterLayout(width,height,top,bottom,15);
selectionHeader(ctx,width,top,$('progress').textContent,page*15+1,Math.min((page+1)*15,ordered.length)); selectionHeader(ctx,width,top,$('progress').textContent,page*15+1,Math.min((page+1)*15,ordered.length));
const setting=selectionRow(width,top).settings;button('设置',setting.x,setting.y,setting.w,setting.h,()=>settingsOpen=true);
nodes.forEach((node,j)=>{ nodes.forEach((node,j)=>{
const row=Math.floor(j/layout.columns),column=j%layout.columns,last=nodes.length-row*layout.columns; const row=Math.floor(j/layout.columns),column=j%layout.columns,last=nodes.length-row*layout.columns;
const offset=0; const offset=0;
@@ -208,7 +209,8 @@ 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;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;music.resume();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();
resize(); resize();
requestPaint(); requestPaint();
} }); } });
@@ -217,6 +219,7 @@ 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,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);
loadSelectionTitle(wx,requestPaint); loadSelectionTitle(wx,requestPaint);
resize(); resize();
requestPaint(); requestPaint();
+12 -4
View File
@@ -1,9 +1,17 @@
export class Music{ export class Music{
constructor(api){this.api=api;this.enabled=true;this.paused=false;try{this.enabled=api.getStorageSync('number-swipe:music:v1')!=='off';}catch{}} 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{}}
scene(name){this.name=name;if(!this.enabled||this.paused||this.playing===name)return;try{if(!this.audio){this.audio=this.api.createInnerAudioContext();this.audio.loop=true;this.audio.volume=.16;this.audio.obeyMuteSwitch=false;this.audio.onError?.(()=>{this.playing=null;this.failed=true;});}if(this.failed)return;this.audio.stop();this.audio.volume=name==='play'?.14:.16;this.audio.src='music/'+name+'.wav';this.audio.play();this.playing=name;}catch{this.failed=true;}} 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;}
}
stop(){this.audio?.stop();this.playing=null;} stop(){this.audio?.stop();this.playing=null;}
suspend(){this.paused=true;this.stop();} suspend(){this.paused=true;this.stop();}
resume(){this.paused=false;this.failed=false;if(this.name)this.scene(this.name);} 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();} 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};} snapshot(){return {enabled:this.enabled,playing:this.playing||null,track:this.current||null,loaded:this.loaded,failed:!!this.failed};}
} }
@@ -1,4 +1,6 @@
let title=null,ready=false; let title=null,ready=false;
export function loadSelectionTitle(api,invalidate){if(title||!api.createImage)return;title=api.createImage();title.onload=()=>{ready=true;invalidate();};title.onerror=()=>{ready=false;};title.src='images/title-reference.png';} export function loadSelectionTitle(api,invalidate){if(title||!api.createImage)return;title=api.createImage();title.onload=()=>{ready=true;invalidate();};title.onerror=()=>{ready=false;};title.src='images/title-reference.png';}
export function titleLayout(width,top){const compact=width<350,w=Math.min(224,width-132);return {x:(width-74-w)/2,y:top-18,w,pillY:top+(compact?48:66),gridOffset:compact?80:98,gap:compact?8:10};} export function titleLayout(width,top){const compact=width<350,w=Math.min(224,width-132);return {x:(width-w)/2,y:top-18,w,pillY:top+(compact?48:66),gridOffset:compact?80:98,gap:compact?8:10};}
export function paintSelectionTitle(ctx,width,top){if(!ready)return;const b=titleLayout(width,top);ctx.drawImage(title,b.x,b.y,b.w,b.w*title.height/title.width);} export function paintSelectionTitle(ctx,width,top){if(!ready)return;const b=titleLayout(width,top);ctx.drawImage(title,b.x,b.y,b.w,b.w*title.height/title.width);}
export function selectionRow(width,top){const gap=6,settingsW=54,w=(width-36-settingsW-gap*2)/2,y=titleLayout(width,top).pillY;return {x:18,y,w,gap,settings:{x:width-18-settingsW,y,w:settingsW,h:26}};}