保存强化版提升前的主版本工作区

This commit is contained in:
toshiocn
2026-10-23 11:02:33 +08:00
parent 66b3ca4ea0
commit 83e2e40269
26 changed files with 452 additions and 142 deletions
+17 -5
View File
@@ -1,3 +1,15 @@
# 当前交接补充(2026-10-22)
主版本权威目录:`G:\项目\NumberSwipe-Variants\wechat-campaign-200-r2`。当前包:`releases/NumberSwipe-campaign-200-r2-明亮教具室-续玩选关教学流畅版-20261022.zip`;版本标记:`2026-10-22-resume-chapter-lessons`。以下2026-10-20的“两边同步”“最新包”与教学速度描述仅作历史记录,不覆盖本段及当前README。
本轮改动:`src/resume.mjs`处理续玩选关与路线校验;`src/level-select.mjs`处理章节布局和三种通关状态;`build.mjs`在适配层注入启动/退出存档,保持四份上游快照原样;`src/main.mjs`缓存教学示范棋盘和指路静态界面;`src/hints.mjs`与棋盘适配缓存提示几何。教程缓存不得影响玩家状态,输入、尺寸、规则页变化必须使对应缓存失效。后台返回也要进入未完成关卡,不停在选关页。
存档键 `variant:campaign-200-r2:v1:resume` 不取代原通关键。重玩已通关关卡不能覆盖未完成进度。源码及交付版属于main,勿覆盖 `G:\项目\NumberSwipe\.worktrees\layered-feedback`。工作暂存目录 `G:\项目\NumberSwipe\wechat-campaign-200-r2-resume-select` 只用于本轮构建验证。
验收:50项模块测试与200关模拟通关;四档选关按钮边界;浏览器200关填色采样;教学平均绘制1.63→0.72ms/帧、357/360帧复用。真机性能尚未确认。按README所列命令构建/验证。
---
# NumberSwipe 主程交接
更新时间:2026-10-20(北京时间)。面向接任主程;先读本文件、同目录 README.md,再查源码。当前任务已完成,没有正在等待执行的开发指令;下一步根据用户真机反馈继续调整。
@@ -136,8 +148,8 @@ r2 无起点关序号为45、65、105、145、185。网页设计资料、评分
给新会话的起始消息可直接用:
> 请接任 NumberSwipe 主程,先读取 G:\项目\NumberSwipe\wechat-campaign-200-r2\HANDOFF.md 和同目录 README.md,核对当前文件及另一个微信副本后,再按我的新需求继续。不要覆盖其他会话更新,也不要重新生成或打乱当前200关。
+27
View File
@@ -1,3 +1,30 @@
# 主版本:续玩、章节选关与教学动画优化(2026-10-22)
主版本源码以 `G:\项目\NumberSwipe-Variants\wechat-campaign-200-r2` 为准;本轮基于“过关重音修复版”开发。`codex/layered-feedback` 开发分支及其工作树不参与此次同步。版本标记见 `release-info.json`。
- 启动直接进入上次未完成关卡,恢复已画路线;即使退出前停在选关页,冷启动与后台返回均回到未完成关。画线、撤销、重开实时保存,进入后台再补存。完成一关后下次进入下一未完成关;重玩已通关关卡不会覆盖原来的未完成存档。
- 选关每页一章、10关,增加留白;已通关为绿色并带勾,当前关紫色,其他关奶油色。自动定位当前章节,保留全部200关可选。320×568等四档尺寸均检查触摸区域和安全区。
- 新机制教学按“教学页、路线、画布尺寸”缓存示范棋盘,移动光圈仍连续绘制;重播、分段及结束后停留循环保持。指路额外缓存圆滑路径与静态界面,降低反复曲线计算和拖尾阴影开销。玩家填色、规则标注及门状态继续实时更新。
发布包:`../releases/NumberSwipe-campaign-200-r2-明亮教具室-续玩选关教学流畅版-20261022.zip`。直接解压导入,重新编译并生成预览。尚未上传发布。
验证命令:
```powershell
node build.mjs
node --test resume-select.test.mjs bright-classroom.test.mjs hints.test.mjs lessons.test.mjs audio-copy.test.mjs
node smoke.mjs
./package.ps1
```
50项模块测试、200关模拟通关及稳定画面下的200关浏览器填色采样对比通过。浏览器教学对比:375×812、DPR 2、3轮各120帧,平均1.63→0.72 ms/frame,357/360帧复用示范棋盘;数据见 `qa/lesson-performance-20261022.json`,属于本机浏览器绘制耗时,不等于微信手机帧率。微信真机仍需验收退出再进、字体、教学流畅度。
新增存档:`variant:campaign-200-r2:v1:resume`,保留原通关和设置存档键。损坏或过期路线安全回到对应关卡空盘;所有关卡通关后保留可重玩关卡。`node qa/serve.mjs` 提供本机预览,默认端口17859,可用 `NUMBERSWIPE_QA_PORT` 覆盖。
以下为历史版本说明,不作为当前发布包依据。
---
2026-10-22 数字和六边形优化:通道一位数按格宽36%、两位数按30%绘制,上限分别28/25逻辑像素;调整通道及数字牌间距,小屏按比例缩放以避免越界。双色玩法移除圆/三角角标,数字在方块中央,保留奇偶规则文字。六边形厚度1.2–2.2像素,投影透明度0.09,减小偏移、倒角暗部及暖色外晕。发布包:releases/NumberSwipe-campaign-200-r2-明亮教具室-大数字双色轻六边版-20261022.zip。
2026-10-22 指路重新设计:参考任天堂官方《灵活脑学校 一起伸展大脑》玩法画面中的奶油色承托、珊瑚橙强调和清楚的方向标识,结合本游戏圆润积木材质,自行程序化绘制奶油色圆边+浅珊瑚色教学引导带。三个圆角方向标沿圆弧移动,带短柔光尾;终点有贴纸式目标环;减少动态保持静态方向。已走方块、规则文字继续避让。参考图片仅用于分析,未作为游戏资源。连接桥没有改动。
+14
View File
@@ -33,9 +33,23 @@ export function renderLesson(target,l,route,w,h){
if (!old)
throw Error('上游导出入口变化,需要重新检查微信适配');
contents = contents.replace(old, `$('export').onclick=()=>{saveRecord();exportRecords({version:pack.version,exportedAt:new Date().toISOString(),clearedIds:completed,records});setStatus('已保存试玩记录。');};`);
contents="import {RESUME_KEY,readResume,resumeTarget,validResumePath,writeResume} from '../resume.mjs';\n"+contents;
const bootstrap='start(initial>=0?initial:0,false);';
if(!contents.includes(bootstrap))throw Error('Resume bootstrap seam changed');
contents=contents.replace(bootstrap,`const savedResume=readResume(localStorage),resume=resumeTarget(playLevels,completed,savedResume,initialId);start(resume.index,false);path=validResumePath(level,resume.path,canAppend);if(path.length===level.cells.length&&!violations(level,path).length)path=[];draw();showPlay();`);
contents=contents.replace("window.addEventListener('pagehide',saveRecord);","window.addEventListener('pagehide',()=>{saveRecord();persistResume();});");
contents+=`\nfunction persistResume(){if(level)writeResume(localStorage,playLevels,completed,level,path,cleared);}\n`;
contents=contents.replace('if(level.tutorial&&!completed.includes(level.id))','if(level.tutorial&&!completed.includes(level.id)&&!path.length)');
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();}
`;
contents=applyBrightClassroom(contents);
contents=contents.replace(' if(previewOnly)return;updateTutorial(bad);',' if(previewOnly)return;persistResume();updateTutorial(bad);');
return { contents: "import {PACK,document,window,localStorage,exportRecords,performance} from '../platform.mjs';\n" + contents, loader: 'js' };
});
} }] });
console.log('Built campaign-200-r2: 200 levels');
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,7 +1,7 @@
$ErrorActionPreference = 'Stop'
$releaseDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'releases'
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
$archivePath = Join-Path $releaseDir 'NumberSwipe-campaign-200-r2-明亮教具室-过关重音修复版-20261022.zip'
$assets = @('game.js','game.json','project.config.json','audio','fonts') | ForEach-Object { Join-Path $PSScriptRoot $_ }
$archivePath = Join-Path $releaseDir 'NumberSwipe-campaign-200-r2-明亮教具室-续玩选关教学流畅版-20261022.zip'
$assets = @('game.js','game.json','project.config.json','audio','fonts','release-info.json') | ForEach-Object { Join-Path $PSScriptRoot $_ }
Compress-Archive -LiteralPath $assets -DestinationPath $archivePath -Force
Get-FileHash -LiteralPath $archivePath -Algorithm SHA256
+95 -76
View File
@@ -1,76 +1,95 @@
{
"setting": {
"es6": false,
"minified": false,
"urlCheck": false
},
"compileType": "game",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [
{"type":"folder","value":".font-sources"},
{"type":"folder","value":".font-tools"},
{"type":"file","value":"bright-classroom-build.mjs"},
{"type":"file","value":"bright-classroom.test.mjs"},
{"type":"file","value":"build-fonts.py"},
{
"type": "folder",
"value": "src"
},
{
"type": "file",
"value": "build.mjs"
},
{
"type": "file",
"value": "smoke.mjs"
},
{
"type": "file",
"value": "README.md"
},
{
"type": "file",
"value": "source-manifest.json"
},
{
"type": "file",
"value": "validation.json"
},
{
"type": "file",
"value": "audio-assets.mjs"
},
{
"type": "file",
"value": "audio-copy.test.mjs"
},
{
"type": "file",
"value": "HANDOFF.md"
},
{
"type": "file",
"value": "lessons.test.mjs"
},
{
"type": "file",
"value": "hints.test.mjs"
},
{
"type": "file",
"value": "package.ps1"
},
{
"type": "folder",
"value": "qa"
}
]
},
"isGameTourist": false,
"appid": "wx7a14d0d70b5dd13d",
"editorSetting": {},
"projectname": "NumberSwipe-campaign-200-r2",
"description": "数字一笔200关评分修订版,源自campaign-200-r2",
"miniprogramRoot": "./"
}
{
"setting": {
"es6": false,
"minified": false,
"urlCheck": false
},
"compileType": "game",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [
{
"type": "folder",
"value": ".font-sources"
},
{
"type": "folder",
"value": ".font-tools"
},
{
"type": "file",
"value": "bright-classroom-build.mjs"
},
{
"type": "file",
"value": "bright-classroom.test.mjs"
},
{
"type": "file",
"value": "build-fonts.py"
},
{
"type": "folder",
"value": "src"
},
{
"type": "file",
"value": "build.mjs"
},
{
"type": "file",
"value": "smoke.mjs"
},
{
"type": "file",
"value": "README.md"
},
{
"type": "file",
"value": "source-manifest.json"
},
{
"type": "file",
"value": "validation.json"
},
{
"type": "file",
"value": "audio-assets.mjs"
},
{
"type": "file",
"value": "audio-copy.test.mjs"
},
{
"type": "file",
"value": "HANDOFF.md"
},
{
"type": "file",
"value": "lessons.test.mjs"
},
{
"type": "file",
"value": "hints.test.mjs"
},
{
"type": "file",
"value": "package.ps1"
},
{
"type": "folder",
"value": "qa"
},
{
"type": "file",
"value": "resume-select.test.mjs"
}
]
},
"isGameTourist": false,
"appid": "wx7a14d0d70b5dd13d",
"editorSetting": {},
"projectname": "NumberSwipe-campaign-200-r2",
"description": "数字一笔200关主版·续玩选关与教学流畅优化",
"miniprogramRoot": "./"
}
File diff suppressed because one or more lines are too long
@@ -37,7 +37,7 @@ function boot(){
function tap(x,y){touch('TouchStart',x,y);touch('TouchEnd',x,y);tick();}
function button(name){const b=api.buttons().find(b=>b.label===name&&b.enabled);if(!b)throw Error('缺少按钮 '+name);tap(b.x+b.w/2,b.y+b.h/2);}
function cell(id){const s=api.snapshot(),p=s.centers[id];tap(p[0],p[1]+s.boardRect.y);}
function select(index){if(api.snapshot().screen!=='select')button('选关');let n=0;while(!api.buttons().some(b=>b.label===String(index+1)||b.label===`${index+1} ✓`)){button('下一页');if(++n>30)throw Error('分页失败');}button(api.buttons().some(b=>b.label===String(index+1))?String(index+1):`${index+1} ✓`);if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');tick(300);}
function select(index){if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');if(api.snapshot().screen!=='select')button('选关');let n=0;while(!api.buttons().some(b=>b.label===String(index+1)||b.label===`${index+1} ✓`)){button(api.snapshot().selection.chapter>Math.floor(index/10)+1?'上一页':'下一页');if(++n>30)throw Error('分页失败');}button(api.buttons().some(b=>b.label===String(index+1))?String(index+1):`${index+1} ✓`);if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');tick(300);}
let down=false;
const coords=e=>{const r=canvas.getBoundingClientRect();return [(e.clientX-r.left)*width/r.width,(e.clientY-r.top)*height/r.height];};
canvas.onpointerdown=e=>{down=true;canvas.setPointerCapture(e.pointerId);touch('TouchStart',...coords(e));tick();};
@@ -0,0 +1,32 @@
<!doctype html><meta charset="utf-8"><title>提示动画对比检查</title>
<style>body{font:16px sans-serif;line-height:1.6;padding:24px;background:#fff6df;color:#43382f}pre{white-space:pre-wrap}</style>
<h1>提示动画对比检查</h1><p id="result">检查中…</p><pre id="details"></pre>
<script type="module">
const lessonMode=new URL(location.href).searchParams.get('mode')==='lesson';document.querySelector('h1').textContent=lessonMode?'新机制教学动画对比检查':'提示动画对比检查';
const source=await(await fetch('../game.js')).text(),baseline=await(await fetch('baseline-game.js')).text();
function boot(bundle){
const handlers={},frames=new Map();let id=0,now=100000;
class Clock extends Date{static now(){return now;}}
const wx={createCanvas:()=>document.createElement('canvas'),loadFont:()=>'',getWindowInfo:()=>({windowWidth:375,windowHeight:812,pixelRatio:2,safeArea:{top:44,bottom:778}}),getMenuButtonBoundingClientRect:()=>({bottom:80}),getPerformance:()=>({now:()=>now}),getStorageSync:()=>'',setStorageSync(){},removeStorageSync(){},setInnerAudioOption(){},createInnerAudioContext:()=>({stop(){},onError(){},play(){}}),showModal(){},env:{USER_DATA_PATH:'/qa'}};
for(const n of ['TouchStart','TouchMove','TouchEnd','TouchCancel','Hide','Show','WindowResize'])wx['on'+n]=f=>handlers[n]=f;
const global={};new Function('wx','GameGlobal','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout','Date',bundle)(wx,global,fn=>{frames.set(++id,fn);return id;},i=>frames.delete(i),()=>1,()=>{},Clock);
const api=global.numberSwipeR2;
const tick=(ms=16)=>{now+=ms;const entry=frames.entries().next().value;if(entry){frames.delete(entry[0]);entry[1]();}};
const button=name=>{const b=api.buttons().find(b=>b.label===name&&b.enabled);if(!b)throw Error(name);const p={identifier:1,clientX:b.x+b.w/2,clientY:b.y+b.h/2},e={changedTouches:[p],touches:[p]};handlers.TouchStart(e);handlers.TouchEnd(e);tick();};
tick();if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');if(api.snapshot().screen!=='select')button('选关');if(lessonMode){button('5');tick(200);}else{button('4');button('指路');tick(2000);}
for(let i=0;i<12;i++)tick();
return {tick,api};
}
const result={device:'browser Canvas, 375×812, DPR 2',framesPerTrial:120,trials:3,baselineMs:[],optimizedMs:[],staticReuseFrames:0};
try{
for(let trial=0;trial<3;trial++){
for(const [kind,bundle] of [['baselineMs',baseline],['optimizedMs',source]]){
const ui=boot(bundle),before=lessonMode?(ui.api.snapshot().lessonBoardRenders||0):(ui.api.snapshot().hintBaseHits||0),start=performance.now();for(let i=0;i<120;i++)ui.tick();result[kind].push((performance.now()-start)/120);if(kind==='optimizedMs')result.staticReuseFrames+=lessonMode?120-(ui.api.snapshot().lessonBoardRenders-before):ui.api.snapshot().hintBaseHits-before;
document.querySelector('#details').textContent=JSON.stringify(result,null,2);await new Promise(requestAnimationFrame);
}
}
const mean=a=>a.reduce((n,x)=>n+x,0)/a.length;result.baselineMeanMs=mean(result.baselineMs);result.optimizedMeanMs=mean(result.optimizedMs);result.reduction=1-result.optimizedMeanMs/result.baselineMeanMs;
document.querySelector('#result').textContent=`完成:${lessonMode?'教学':'指路'}帧平均绘制耗时 ${result.baselineMeanMs.toFixed(2)} → ${result.optimizedMeanMs.toFixed(2)} ms/frame;${lessonMode?'示范棋盘':'静态界面'}复用 ${result.staticReuseFrames}/360 帧。浏览器对比不代表微信真机帧率。`;
document.querySelector('#details').textContent=JSON.stringify(result,null,2);
}catch(e){document.querySelector('#result').textContent='FAIL:'+e.message;}
</script>
+5 -5
View File
@@ -32,19 +32,19 @@ const tick=(ms=16)=>{now+=ms;const entry=frames.entries().next().value;if(!entry
const tap=(x,y)=>{const e={changedTouches:[{identifier:1,clientX:x,clientY:y}],touches:[{identifier:1,clientX:x,clientY:y}]};handlers.TouchStart(e);handlers.TouchEnd(e);tick();};
const button=label=>{const b=api.buttons().find(b=>b.label===label);if(!b||!b.enabled)throw Error('缺少按钮 '+label);tap(b.x+b.w/2,b.y+b.h/2);};
const cell=id=>{const s=api.snapshot(),[x,y]=s.centers[id];tap(x+s.boardRect.x,y+s.boardRect.y);};
const select=i=>{if(api.snapshot().screen!=='select')button('选关');let n=0;while(!api.buttons().some(b=>b.label===String(i+1)||b.label===`${i+1} ✓`)){button('下一页');if(++n>25)throw Error('分页失败');}button(api.buttons().some(b=>b.label===String(i+1))?String(i+1):`${i+1} ✓`);if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');};
const select=i=>{if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');if(api.snapshot().screen!=='select')button('选关');let n=0;while(!api.buttons().some(b=>b.label===String(i+1)||b.label===`${i+1} ✓`)){button(api.snapshot().selection.chapter>Math.floor(i/10)+1?'上一页':'下一页');if(++n>25)throw Error('分页失败');}button(api.buttons().some(b=>b.label===String(i+1))?String(i+1):`${i+1} ✓`);if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');};
const capture=()=>{const c=document.createElement('canvas');c.width=375;c.height=812;c.getContext('2d').drawImage(canvases[0],0,0);return c;};
const samples=(route)=>{const s=api.snapshot(),ctx=canvases[0].getContext('2d');return route.map(id=>{const p=s.centers[id];return Array.from(ctx.getImageData(Math.round(p[0]-s.unit*.08),Math.round(p[1]+s.boardRect.y+s.unit*.18),5,5).data);});};
let checked=0;const result=document.querySelector('#result');
try{
for(let i=0;i<levels.length;i++){
select(i);const route=solve(levels[i])[0],n=Math.min(2,route.length-1);
for(const id of route.slice(0,n))cell(id);
for(const id of route.slice(0,n))cell(id);tick(1800);
const before=samples(route.slice(0,n));const base=capture();
button('重开');button('指路');for(const id of route.slice(0,n))cell(id);tick(300);
button('重开');button('指路');for(const id of route.slice(0,n))cell(id);tick(1800);
const after=samples(route.slice(0,n));
if(JSON.stringify(before)!==JSON.stringify(after))throw Error('玩家填色像素变化:第 '+(i+1)+' 关');
button('撤销');cell(route[n-1]);tick(300);
if(JSON.stringify(before)!==JSON.stringify(after)){document.querySelector('#views').replaceChildren(base,capture());throw Error('玩家填色像素变化:第 '+(i+1)+' 关');}
button('撤销');cell(route[n-1]);tick(1800);
if(JSON.stringify(before)!==JSON.stringify(samples(route.slice(0,n))))throw Error('撤销后填色变化:'+(i+1));
checked++;
if(i%4===0){result.textContent=`正在检查 ${checked}/200 关…`;await new Promise(resolve=>requestAnimationFrame(resolve));}
@@ -0,0 +1,19 @@
{
"device": "browser Canvas, 375×812, DPR 2",
"framesPerTrial": 120,
"trials": 3,
"baselineMs": [
1.9058333332339923,
1.4066666667660077,
1.5700000000496706
],
"optimizedMs": [
0.7458333333333333,
0.8325000000496705,
0.5725000001490116
],
"staticReuseFrames": 357,
"baselineMeanMs": 1.627500000016557,
"optimizedMeanMs": 0.7169444445106717,
"reduction": 0.5594811400900903
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

@@ -0,0 +1,29 @@
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=[];
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({x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
const wx = {setInnerAudioOption:()=>{},createInnerAudioContext:()=>({stop(){},onError(){},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}}), createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
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;
const global = {};
vm.runInNewContext(fs.readFileSync(path.join(root, 'game.js'), 'utf8'), {Date:Clock, wx, GameGlobal: global, requestAnimationFrame: cb => { frames.set(++frameId, cb); return frameId; }, cancelAnimationFrame: id => frames.delete(id), setTimeout: () => 1, clearTimeout: () => { } }, { timeout: 10000 });
const tick = () => { const entry=frames.entries().next().value;if(!entry)return;const [id,fn]=entry; frames.delete(id);text.length=0;images.length=0; fn(); };
tick();
const touch = (kind, x, y) => handlers[kind]({ changedTouches: [{ identifier: 1, clientX: x, clientY: y }], touches: [{ identifier: 1, clientX: x, clientY: y }] });
const tap = (x, y) => { touch('TouchStart', x, y); touch('TouchEnd', x, y); tick(); };
const api = global.numberSwipeR2;
const button = label => { const b = api.buttons().find(b => b.label === label); assert.ok(b, '缺少按钮 ' + label); assert.ok(b.enabled, '按钮不可用 ' + 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 select = i => { if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏'); if (api.snapshot().screen !== 'select')
button('选关'); const label = String(i + 1); let steps = 0; while (!api.buttons().some(b => b.label === label || b.label === label + ' ✓')) {
button(api.snapshot().selection.chapter>Math.floor(i/10)+1?'上一页':'下一页');
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 };
}
+1 -1
View File
@@ -7,4 +7,4 @@ http.createServer((req,res)=>{
const file=path.resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://localhost').pathname));
if(!file.startsWith(root+path.sep)){res.writeHead(403).end();return;}
try{res.setHeader('Content-Type',file.endsWith('.html')?'text/html; charset=utf-8':file.endsWith('.json')?'application/json':'text/javascript; charset=utf-8');res.end(fs.readFileSync(file));}catch{res.writeHead(404).end();}
}).listen(17859,'127.0.0.1',()=>console.log('Canvas QA: http://127.0.0.1:17859/qa/hints.html'));
}).listen(Number(process.env.NUMBERSWIPE_QA_PORT||17859),'127.0.0.1',()=>console.log('Canvas QA: http://127.0.0.1:17859/qa/hints.html'));
+7
View File
@@ -0,0 +1,7 @@
{
"channel": "main",
"version": "2026-10-22-resume-chapter-lessons",
"date": "2026-10-22",
"basePackage": "NumberSwipe-campaign-200-r2-明亮教具室-过关重音修复版-20261022.zip",
"package": "NumberSwipe-campaign-200-r2-明亮教具室-续玩选关教学流畅版-20261022.zip"
}
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {boot} from './qa/runtime-harness.mjs';
import {solve,canAppend} from './src/upstream/rules.mjs';
import {RESUME_KEY,resumeTarget,validResumePath} from './src/resume.mjs';
import {chapterLayout} from './src/level-select.mjs';
import {prepareHintMotion,hintPointAt} from './src/hints.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));
const closeRules=ui=>{if(ui.api.buttons().some(b=>b.label==='返回游戏'))ui.button('返回游戏');};
test('first launch goes directly to gameplay',()=>{const ui=boot();assert.equal(ui.api.snapshot().screen,'play');assert.equal(ui.api.snapshot().displayOrder,1);});
for(const exitFromSelect of [false,true])test('resume unfinished route after exit from '+(exitFromSelect?'selection':'gameplay'),()=>{
const ui=boot();ui.select(3);const route=solve(levels[3])[0].slice(0,3);route.forEach(ui.cell);
if(exitFromSelect)ui.button('选关');ui.handlers.Hide();
ui.handlers.Show();ui.tick();assert.equal(ui.api.snapshot().screen,'play','warm re-entry also goes to gameplay');assert.deepEqual(Array.from(ui.api.snapshot().path),route);
const restored=boot(ui.storage);assert.equal(restored.api.snapshot().screen,'play');assert.equal(restored.api.snapshot().displayOrder,4);assert.deepEqual(Array.from(restored.api.snapshot().path),route);
assert.ok(restored.api.buttons().some(b=>b.label==='撤销'&&b.enabled));restored.button('撤销');assert.equal(restored.api.snapshot().path.length,2);
restored.button('重开');const clean=boot(restored.storage);assert.equal(clean.api.snapshot().path.length,0);
});
test('completed level resumes next unfinished, including leaving from cleared screen',()=>{
const ui=boot();ui.select(0);solve(levels[0])[0].forEach(ui.cell);ui.handlers.Hide();const restored=boot(ui.storage);assert.equal(restored.api.snapshot().displayOrder,2);assert.equal(restored.api.snapshot().screen,'play');assert.equal(restored.api.snapshot().path.length,0);
});
test('replaying a cleared level does not replace the unfinished save',()=>{
const store=new Map([['variant:campaign-200-r2:v1',JSON.stringify([levels[0].id])]]),ui=boot(store);ui.select(3);solve(levels[3])[0].slice(0,2).forEach(ui.cell);const saved=ui.storage.get(RESUME_KEY);ui.select(0);ui.cell(solve(levels[0])[0][0]);ui.button('选关');ui.handlers.Hide();assert.equal(ui.storage.get(RESUME_KEY),saved);assert.equal(boot(store).api.snapshot().displayOrder,4);
});
test('legacy saves, malformed data and full completion have valid startup targets',()=>{
assert.equal(resumeTarget(levels,[levels[3].id],null,levels[3].id).index,4);
assert.equal(resumeTarget(levels,levels.map(l=>l.id),null,levels[199].id).index,199);
assert.deepEqual(validResumePath(levels[0],['missing'],canAppend),[]);
for(const value of ['broken','{}','{"version":1,"levelId":"missing","path":[]}']){const ui=boot(new Map([[RESUME_KEY,value]]));assert.equal(ui.api.snapshot().screen,'play');assert.equal(ui.api.snapshot().displayOrder,1);}
});
test('chapter pages expose at most ten cards and fit phone safe areas',()=>{
for(const [w,h]of [[320,568],[375,667],[390,844],[414,896]]){
const ui=boot(new Map(),w,h);closeRules(ui);ui.button('选关');
for(let chapter=1;chapter<=20;chapter++){
const cards=ui.api.buttons().filter(b=>/^\d+( ✓)?$/.test(b.label));assert.equal(cards.length,10);assert.equal(ui.api.snapshot().selection.chapter,chapter);
for(const b of cards){assert.ok(b.w>=44&&b.h>=44);assert.ok(b.x>=0&&b.x+b.w<=w&&b.y+b.h<h-34-106);}
const layout=chapterLayout(w,h,92,34);assert.ok(layout.h>=44);
if(chapter<20)ui.button('下一页');
}
ui.button('200');closeRules(ui);ui.button('选关');assert.equal(ui.api.snapshot().selection.chapter,20);assert.ok(ui.api.buttons().some(b=>b.label==='200'));
}
});
test('hint-only frames reuse static UI, and player input invalidates it',()=>{
const ui=boot();ui.select(3);ui.button('指路');ui.advance(1500);const before=ui.api.snapshot().hintBaseHits;
for(let i=0;i<12;i++)ui.advance(16);assert.ok(ui.api.snapshot().hintBaseHits>=before+10);
ui.cell(solve(levels[3])[0][0]);assert.equal(ui.api.snapshot().path.length,1);ui.button('撤销');assert.equal(ui.api.snapshot().path.length,0);
ui.button('规则');assert.ok(ui.api.buttons().some(b=>b.label==='返回游戏'));ui.button('返回游戏');assert.ok(ui.api.snapshot().hint);
});
test('hint curves are cached and distance lookup is stable at boundaries',()=>{
const hint={guides:[{a:'a',b:'b',points:[[0,0],[50,0],[50,50]]}]};const motion=prepareHintMotion(hint,40,[]);assert.equal(prepareHintMotion(hint,40,[]),motion);assert.notEqual(prepareHintMotion(hint,40,['a','b']),motion);
assert.equal(hintPointAt(motion,0).x,0);assert.equal(hintPointAt(motion,motion.total).y,50);
assert.equal(prepareHintMotion(hint,40,['a','b']).total,0);
});
test('new-mechanic lesson reuses the board while the continuous cursor advances',()=>{
const ui=boot();ui.select(4);ui.button('规则');const before=ui.api.snapshot();
for(let i=0;i<6;i++)ui.advance(1);
const after=ui.api.snapshot();assert.ok(after.renderedFrames>=before.renderedFrames+6);assert.equal(after.lessonBoardRenders,before.lessonBoardRenders);assert.deepEqual(after.path,before.path);
ui.button('返回游戏');ui.button('规则');assert.ok(ui.api.snapshot().lesson);
});
+5 -5
View File
@@ -33,9 +33,9 @@ function boot(storage = new Map(), width = 375, height = 812) {
const api = global.numberSwipeR2;
const button = label => { const b = api.buttons().find(b => b.label === label); assert.ok(b, '缺少按钮 ' + label); assert.ok(b.enabled, '按钮不可用 ' + 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 select = i => { if (api.snapshot().screen !== 'select')
const select = i => { if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏'); if (api.snapshot().screen !== 'select')
button('选关'); const label = String(i + 1); let steps = 0; while (!api.buttons().some(b => b.label === label || b.label === label + ' ✓')) {
button('下一页');
button(api.snapshot().selection.chapter>Math.floor(i/10)+1?'上一页':'下一页');
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); };
@@ -95,7 +95,7 @@ assert.ok(!ui.api.snapshot().rules.includes('共边'));
const restored = boot(ui.storage, 320, 640);
assert.equal(restored.api.snapshot().level, ordered[199].id);
assert.equal(JSON.parse(ui.storage.get('variant:campaign-200-r2:v1')).length, 200);
restored.button('继续 · 20-10');restored.button('规则');assert.ok(restored.api.buttons().every(b=>b.y>=0&&b.y+b.h<=640));const smallBefore=restored.api.snapshot();restored.advance(5200);assert.deepEqual(restored.api.snapshot().path,smallBefore.path);restored.button('返回游戏');
restored.button('规则');assert.ok(restored.api.buttons().every(b=>b.y>=0&&b.y+b.h<=640));const smallBefore=restored.api.snapshot();restored.advance(5200);assert.deepEqual(restored.api.snapshot().path,smallBefore.path);restored.button('返回游戏');
restored.button('音效开');
const reopen = boot(ui.storage);
assert.equal(reopen.api.snapshot().soundEnabled, false);
@@ -114,7 +114,7 @@ for(const [w,h]of [[320,568],[375,667],[390,844],[414,896]]){
}
const narrow=boot(new Map(),320,568);
for(const l of ordered.filter(l=>l.tutorial)){narrow.select(ordered.indexOf(l));narrow.button('规则');let pages=0;do{assert.ok(narrow.api.buttons().every(b=>b.y>=0&&b.y+b.h<=568));const before=narrow.api.snapshot();narrow.advance(3900);const after=narrow.api.snapshot();for(const field of ['path','centers','doors','latchedDoors','records','lastSound'])assert.deepEqual(after[field],before[field],field);if(!narrow.api.buttons().some(b=>b.label==='下一段'&&b.enabled))break;narrow.button('下一段');assert.ok(++pages<30);}while(true);narrow.button('返回游戏');}
const idle=boot();idle.advance(1000);assert.equal(idle.frames.size,0,'静态选关页不持续绘制');
const idle=boot();if(idle.api.buttons().some(b=>b.label==='返回游戏'))idle.button('返回游戏');idle.button('选关');idle.advance(1000);assert.equal(idle.frames.size,0,'静态选关页不持续绘制');
assert.ok(Object.values(idle.api.snapshot().fonts).every(v=>v==='loaded'));
idle.button('设置');idle.button('减少动态:关');idle.button('完成');
assert.equal(idle.api.snapshot().reducedMotion,true);
@@ -122,7 +122,7 @@ idle.select(162);idle.cell(solve(ordered[162])[0][0]);idle.advance(1000);
assert.equal(idle.frames.size,0,'减少动态的思考阶段不持续绘制');
idle.button('指路');idle.advance(1000);assert.ok(idle.api.snapshot().hint);assert.equal(idle.frames.size,0,'静态指路仍保留信息但不循环绘制');
const reducedRestored=boot(idle.storage);assert.equal(reducedRestored.api.snapshot().reducedMotion,true);
const result = {theme:'bright-classroom',bundledFonts:true,reducedMotionPersisted:true,idleRenderingStops:true,segmentHints:true,playerFillUnchangedAfterHint:true,animatedLessons:10,previewStateIsolation:true,narrowTutorials:'320x568', source: manifest.source, version: pack.version, total: 200, passed: 200, sourceHashesMatch: true, uniqueSolution: true, simulatedWeChatTouch: true, undoRestartHint: true, storageRestore: true, foregroundLoop: true, nativeAudioPlaybackCalls: ui.played.length, plainLanguageRules: true, pagedRules: true, clearActionLayout: true, phoneSizesChecked: 4, rows };
const result = {startupGameplay:true,chapterSelection:true,theme:'bright-classroom',bundledFonts:true,reducedMotionPersisted:true,idleRenderingStops:true,segmentHints:true,playerFillUnchangedAfterHint:true,animatedLessons:10,previewStateIsolation:true,narrowTutorials:'320x568', source: manifest.source, version: pack.version, total: 200, passed: 200, sourceHashesMatch: true, uniqueSolution: true, simulatedWeChatTouch: true, undoRestartHint: true, storageRestore: true, foregroundLoop: true, nativeAudioPlaybackCalls: ui.played.length, plainLanguageRules: true, pagedRules: true, clearActionLayout: true, phoneSizesChecked: 4, rows };
fs.writeFileSync(path.join(root, 'validation.json'), JSON.stringify(result, null, 2));
console.log('PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。');
@@ -219,15 +219,19 @@ export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),pr
return scene;
}
const hintGeometryCache=new WeakMap();
export function hintGeometry(scene,path,route){
if(!scene)return {};
const key=JSON.stringify([path,route,preferences.reducedMotion]),cached=hintGeometryCache.get(scene);
if(cached?.key===key&&cached.keepouts===scene.keepouts)return cached.value;
const guides=[];
for(let i=0;i<route.length-1;i++){
const a=scene.byId.get(route[i]),b=scene.byId.get(route[i+1]),edge=edgeFor(scene,route[i],route[i+1]);
if(!a||!b||!edge)continue;
guides.push({a:route[i],b:route[i+1],points:[...interiorGuide(a,a.center,edge.entry,scene),...interiorGuide(b,edge.entry,b.center,scene).slice(1)]});
}
return {guides,keepouts:scene.keepouts,visitedShapes:scene.cells.filter(g=>path.includes(g.cell.id)).map(g=>({rings:g.rings,radius:g.radius,depth:scene.depth,maskPad:clamp(3,scene.pitch*.065,7)+1})),reducedMotion:preferences.reducedMotion};
const value={guides,keepouts:scene.keepouts,visitedShapes:scene.cells.filter(g=>path.includes(g.cell.id)).map(g=>({rings:g.rings,radius:g.radius,depth:scene.depth,maskPad:clamp(3,scene.pitch*.065,7)+1})),reducedMotion:preferences.reducedMotion};
hintGeometryCache.set(scene,{key,keepouts:scene.keepouts,value});return value;
}
function drawCheckpointRibbons(ctx,scene,now){
+21 -8
View File
@@ -61,9 +61,25 @@ export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
ctx.restore();
}
function drawGeometryHint(ctx,hint,unit,elapsed,path){
const preparedHints=new WeakMap();
export function prepareHintMotion(hint,unit,path){
const key=JSON.stringify([unit,path]),cached=preparedHints.get(hint.guides);
if(cached?.key===key)return cached.value;
const covered=(a,b)=>path.some((id,i)=>i>0&&((path[i-1]===a&&id===b)||(path[i-1]===b&&id===a)));
const guides=hint.guides.filter(g=>!covered(g.a,g.b)),runs=smoothHintRuns(guides,unit*.16),width=Math.max(4,Math.min(8,unit*.12));
let total=0;
const segments=runs.flatMap(points=>points.slice(1).map((b,i)=>({a:points[i],b,length:distance(points[i],b)}))).filter(s=>s.length>.01).map(s=>({...s,start:total,end:total+=s.length}));
const value={guides,runs,width,segments,total};preparedHints.set(hint.guides,{key,value});return value;
}
export function hintPointAt(motion,at){
const {segments}=motion;if(!segments.length)return null;
let low=0,high=segments.length-1;
while(low<high){const mid=(low+high)>>1;if(segments[mid].end<at)low=mid+1;else high=mid;}
const segment=segments[low],t=Math.max(0,Math.min(1,(at-segment.start)/segment.length));
return {segment,x:segment.a[0]+(segment.b[0]-segment.a[0])*t,y:segment.a[1]+(segment.b[1]-segment.a[1])*t};
}
function drawGeometryHint(ctx,hint,unit,elapsed,path){
const motion=prepareHintMotion(hint,unit,path),{guides,runs,width,total}=motion;
ctx.save();ctx.lineCap='round';ctx.lineJoin='round';
if(hint.reducedMotion)ctx.setLineDash([7,5]);
const draw=(color,w)=>{ctx.strokeStyle=color;ctx.lineWidth=w;for(const points of runs){if(!points.length)continue;ctx.beginPath();ctx.moveTo(...points[0]);for(const p of points.slice(1))ctx.lineTo(...p);ctx.stroke();}};
@@ -74,20 +90,17 @@ function drawGeometryHint(ctx,hint,unit,elapsed,path){
ctx.globalAlpha=.28;draw(C.hintRibbonLight,width*.48);ctx.globalAlpha=1;
ctx.setLineDash([]);
if(!hint.reducedMotion){
const segments=runs.flatMap(points=>points.slice(1).map((b,i)=>({a:points[i],b,length:distance(points[i],b)}))).filter(s=>s.length>.01);
const total=segments.reduce((n,s)=>n+s.length,0),cycle=Math.max(1600,total/65*1000),head=(elapsed%cycle)/cycle*total;
const cycle=Math.max(1600,total/65*1000),head=(elapsed%cycle)/cycle*total;
// Three markers carry creamy tails along the same rounded path.
for(let pulse=0;pulse<3;pulse++){
const pulseHead=(head+pulse*total/3)%total;
for(let j=16;j>=0;j--){
let at=pulseHead-j*Math.max(.9,width*.30);if(at<0)continue;
for(const segment of segments){
if(at>segment.length){at-=segment.length;continue;}
const t=at/segment.length,x=segment.a[0]+(segment.b[0]-segment.a[0])*t,y=segment.a[1]+(segment.b[1]-segment.a[1])*t;
ctx.globalAlpha=(1-j/18)*.85;ctx.shadowColor='rgba(255,245,215,.65)';ctx.shadowBlur=width*(j===0?1.2:.55);
const point=hintPointAt(motion,at);if(point){
const {segment,x,y}=point;
ctx.globalAlpha=(1-j/18)*.85;ctx.shadowColor='rgba(255,245,215,.65)';ctx.shadowBlur=j===0?width*1.2:0;
ctx.beginPath();ctx.arc(x,y,width*(.23+(16-j)/16*.4),0,Math.PI*2);ctx.fillStyle=C.hintSpark;ctx.fill();
if(j===0){ctx.globalAlpha=1;ctx.shadowBlur=0;hintArrow(ctx,segment.a,segment.b,x,y,width);}
break;
}
}
}
@@ -0,0 +1,15 @@
import {C,label,panel,roundRect} from './bright-classroom/theme.mjs';
export function chapterLayout(width,height,top,bottom,count=10){
const columns=width>=360&&height>=700?2:3,rows=Math.ceil(count/columns),gap=14;
const y=top+108,available=height-bottom-114-y;
const h=Math.min(76,(available-gap*(rows-1))/rows),w=Math.min(130,(width-48-gap*(columns-1))/columns);
return {columns,rows,gap,y,w,h,x:(width-(w*columns+gap*(columns-1)))/2};
}
export function levelCard(ctx,labelText,x,y,w,h,{completed,current}){
const fill=completed?'#D6EAD6':current?C.primary:C.surface;
panel(ctx,x,y,w,h-3,Math.min(18,h*.28),fill,3);
if(current){ctx.save();ctx.strokeStyle=completed?C.primary:'#FFFFFF';ctx.lineWidth=2;roundRect(ctx,x+4,y+4,w-8,h-11,Math.min(14,h*.22));ctx.stroke();ctx.restore();}
label(ctx,labelText,x+w/2,y+(h-3)/2,Math.min(24,h*.4),current&&!completed?C.onPrimary:completed?'#326443':C.ink,700);
if(completed){label(ctx,'✓',x+w-13,y+12,12,'#326443',700);}
}
+65 -34
View File
@@ -1,7 +1,9 @@
import releaseInfo from '../release-info.json' with {type:'json'};
import {chapterLayout,levelCard} from './level-select.mjs';
import {CLEAR_DURATION,clearProgress,drawClearCelebration} from './bright-classroom/celebration.mjs';
import {stopAudio,audioDiagnostics} from './audio.mjs';
import { mainCanvas, boardCanvas, document, window, PACK, setViewInvalidator } from './platform.mjs';
import {renderLesson,hintView,refreshBrightBoard,brightOverlay,brightAnimating} from './upstream/ui.mjs';
import {renderLesson,hintView,resumeGameplay,refreshBrightBoard,brightOverlay,brightAnimating} from './upstream/ui.mjs';
import {drawHint} from './hints.mjs';
import {lessonKeys,makeLesson,lessonFrame,loopLessonTime} from './lessons.mjs';
import {tutorials} from './copy.mjs';
@@ -11,22 +13,38 @@ import {preferences} from './bright-classroom/board-renderer.mjs';
const lessonCanvas=wx.createCanvas();let lesson=null,lessonStarted=0,lessonState=null,lessonCache=new Map();
const $ = id => document.getElementById(id), ctx = mainCanvas.getContext('2d');
let width = 375, height = 812, top = 60, bottom = 24, page = 0, buttons = [], pointer = null, visible = true, raf = 0;
let rulePage=0,ruleKey='';
let rulePage=0,ruleKey='',lessonBoardCache=null,lessonBoardRenders=0;
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
let hintAnimation={key:'',started:0};
let settingsOpen=false,pressedLabel='',pressedUntil=0;
let renderedFrames=0,clearCelebration=null;
let renderedFrames=0,clearCelebration=null,selectWasOpen=false;
function requestPaint(){if(visible&&!raf)raf=requestAnimationFrame(draw);}
setViewInvalidator(requestPaint);
const hintBaseCanvas=wx.createCanvas();let hintBaseKey='',hintBaseHits=0;
setViewInvalidator(()=>{hintBaseKey='';requestPaint();});
try{preferences.reducedMotion=wx.getStorageSync('number-swipe:reduced-motion')==='1';}catch{}
const ordered = PACK.playOrder.map(id => PACK.levels.find(l => l.id === id));
function resize() { const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); width = info.windowWidth; height = info.windowHeight; top = Math.max(info.safeArea?.top || 0, wx.getMenuButtonBoundingClientRect?.().bottom || 0) + 12; bottom = Math.max(16, height - (info.safeArea?.bottom || height)); window.devicePixelRatio = Math.min(info.pixelRatio || 1, 3); mainCanvas.width = width * window.devicePixelRatio; mainCanvas.height = height * window.devicePixelRatio; $('board').clientWidth = width; $('board').clientHeight = Math.max(140, height - top - bottom - 218); window.handlers.resize?.(); requestPaint(); }
function resize() { hintBaseKey=''; const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); width = info.windowWidth; height = info.windowHeight; top = Math.max(info.safeArea?.top || 0, wx.getMenuButtonBoundingClientRect?.().bottom || 0) + 12; bottom = Math.max(16, height - (info.safeArea?.bottom || height)); window.devicePixelRatio = Math.min(info.pixelRatio || 1, 3); mainCanvas.width = width * window.devicePixelRatio; mainCanvas.height = height * window.devicePixelRatio; $('board').clientWidth = width; $('board').clientHeight = Math.max(140, height - top - bottom - 218); window.handlers.resize?.(); requestPaint(); }
function text(value, x, y, size = 16, color = C.ink) { label(ctx,value,x,y,size,color); }
function splitLines(value,maxWidth,size=16){textFont(ctx,size);const lines=[];for(const paragraph of String(value).split('\n')){let line='';for(const c of paragraph){if(line&&ctx.measureText(line+c).width>maxWidth){lines.push(line);line='';}line+=c;}lines.push(line);}return lines;}
function wrapped(value,x,y,maxWidth,size=16){const lines=splitLines(value,maxWidth,size);lines.forEach((line,i)=>text(line,x,y+i*(size+9),size));return lines.length;}
function button(label, x, y, w, h, run, { enabled = true, primary = false } = {}) { buttonArt(ctx,label,x,y,w,h,{enabled,primary,pressed:!preferences.reducedMotion&&pressedLabel===label&&Date.now()<pressedUntil}); buttons.push({ label, x, y, w, h, run, enabled }); }
function click(id) { if(id==='btn-rules')ruleKey=''; $('board').handlers.pointerup?.(); pointer = null; $(id).click(); }
function levelButtons() { return $('pack-list').children.flatMap(section => section.children[1].children); }
function paintHintLayer(hint,boardState,d){
if(hint){
const key=boardState.level+':'+hint.start+':'+hint.end;
if(hintAnimation.key!==key)hintAnimation={key,started:Date.now()};
const h=$('board').clientHeight;
if(hintCanvas.width!==Math.round(width*d))hintCanvas.width=Math.round(width*d);
if(hintCanvas.height!==Math.round(h*d))hintCanvas.height=Math.round(h*d);
hintContext.setTransform(d,0,0,d,0,0);hintContext.clearRect(0,0,width,h);
drawHint(hintContext,hint,boardState.centers,boardState.unit,Date.now()-hintAnimation.started,boardState.path);
ctx.drawImage(hintCanvas,0,top+82,width,h);
ctx.save();ctx.translate(0,top+82);brightOverlay(ctx);ctx.restore();
}else hintAnimation={key:'',started:0};
}
function draw() {
raf=0;
if (!visible)
@@ -41,31 +59,47 @@ function draw() {
const actual=audioDiagnostics.completeStartedAt;
clearCelebration.started=actual??(Date.now()-clearCelebration.requested<500?null:clearCelebration.requested);
}
const animationHint=hintView();
const cacheEligible=Boolean(animationHint)&&state.screen==='play'&&!state.cleared&&!settingsOpen&&$('rules-sheet').hidden&&!brightAnimating()&&Date.now()>=pressedUntil;
const baseKey=JSON.stringify([state.level,state.path,width,height,d,preferences.reducedMotion]);
if(cacheEligible&&hintBaseKey===baseKey){
hintBaseHits++;ctx.drawImage(hintBaseCanvas,0,0,width,height);paintHintLayer(animationHint,state,d);
if(!preferences.reducedMotion)requestPaint();return;
}
const currentLevel=ordered.find(l=>l.id===state.level);
const dense=(currentLevel.features||[]).length>=3||state.cellViews?.length>=30;
background(ctx,width,height,!$('screen-play').className.includes('hidden')&&dense);
buttons = [];
const select = $('screen-play').className.includes('hidden');
if (select) {
text('数字一笔', width / 2, top + 24, 30,C.primary);
text('每块一次 · 数字对步数', width / 2, top + 61, 16);
text('已完成 '+$('progress').textContent, width / 2, top + 90, 14,C.inkSecondary);
text('数字一笔', width / 2, top + 18, 30,C.primary);
text('总进度 '+$('progress').textContent, width / 2, top + 42, 12,C.inkSecondary);
button('设置',width-72,top+4,56,34,()=>settingsOpen=true);
const rows = Math.max(1, Math.floor((height - top - bottom - 242) / 57)), count = rows * 4, pages = Math.ceil(200 / count);
page = Math.max(0, Math.min(pages - 1, page));
const levels = levelButtons();
panel(ctx,12,top+110,width-24,rows*57+4,24,C.stage,3);
for (let j = 0; j < count && page * count + j < levels.length; j++) {
const index = page * count + j, node = levels[index];
button(node.textContent + (node.className.includes('cleared') ? ' ✓' : ''), 18 + j % 4 * (width - 30) / 4, top + 120 + Math.floor(j / 4) * 57, (width - 48) / 4 - 4, 45, () => node.click(), { primary: node.className.includes('current') });
}
button('上一页', 18, height - bottom - 106, 90, 38, () => page = Math.max(0, page - 1));
const levels=levelButtons(),pages=PACK.chapters.length;
if(!selectWasOpen)page=Math.floor((state.displayOrder-1)/10);
selectWasOpen=true;page=Math.max(0,Math.min(pages-1,page));
const chapter=PACK.chapters[page],chapterLevels=ordered.filter(l=>l.chapter===chapter.id);
const nodes=chapterLevels.map(l=>levels[ordered.indexOf(l)]);
const layout=chapterLayout(width,height,top,bottom,nodes.length);
text('第 '+(page+1)+' 章 · '+chapter.title.replace(/^第.*?章[·\s]*/,''),width/2,top+66,18,C.primary);
text(nodes.filter(n=>n.className.includes('cleared')).length+' / '+nodes.length+' 已通过',width/2,top+89,13,C.inkSecondary);
nodes.forEach((node,j)=>{
const row=Math.floor(j/layout.columns),column=j%layout.columns,last=nodes.length-row*layout.columns;
const offset=last<layout.columns?(layout.columns-last)*(layout.w+layout.gap)/2:0;
const x=layout.x+offset+column*(layout.w+layout.gap),y=layout.y+row*(layout.h+layout.gap);
const label=node.textContent+(node.className.includes('cleared')?' ✓':'');
levelCard(ctx,node.textContent,x,y,layout.w,layout.h,{completed:node.className.includes('cleared'),current:node.className.includes('current')});
buttons.push({label,x,y,w:layout.w,h:layout.h,run:()=>node.click(),enabled:true});
});
button('上一页', 18, height - bottom - 106, 90, 38, () => page = Math.max(0, page - 1),{enabled:page>0});
text((page + 1) + ' / ' + pages, width / 2, height - bottom - 87);
button('下一页', width - 108, height - bottom - 106, 90, 38, () => page = Math.min(pages - 1, page + 1));
button('下一页', width - 108, height - bottom - 106, 90, 38, () => page = Math.min(pages - 1, page + 1),{enabled:page<pages-1});
button($('btn-continue').textContent, 18, height - bottom - 56, width - 126, 42, () => click('btn-continue'), { primary: true });
button('反馈', width - 98, height - bottom - 56, 80, 42, feedback);
}
else {
selectWasOpen=false;
button('选关', 16, top, 62, 36, () => click('btn-back'));
panel(ctx,width/2-60,top+2,120,32,16,C.coral,3);
text('第 ' + window.variantSnapshot().displayOrder + ' 关', width / 2, top + 18, 18,C.onPrimary);
@@ -74,18 +108,6 @@ function draw() {
stage(ctx,width,top+82,$('board').clientHeight,dense);
refreshBrightBoard(Date.now());
ctx.drawImage(boardCanvas, 0, top + 82, width, $('board').clientHeight);
const hint=hintView(),boardState=window.variantSnapshot();
if(hint){
const key=boardState.level+':'+hint.start+':'+hint.end;
if(hintAnimation.key!==key)hintAnimation={key,started:Date.now()};
const h=$('board').clientHeight;
if(hintCanvas.width!==Math.round(width*d))hintCanvas.width=Math.round(width*d);
if(hintCanvas.height!==Math.round(h*d))hintCanvas.height=Math.round(h*d);
hintContext.setTransform(d,0,0,d,0,0);hintContext.clearRect(0,0,width,h);
drawHint(hintContext,hint,boardState.centers,boardState.unit,Date.now()-hintAnimation.started,boardState.path);
ctx.drawImage(hintCanvas,0,top+82,width,h);
ctx.save();ctx.translate(0,top+82);brightOverlay(ctx);ctx.restore();
}else hintAnimation={key:'',started:0};
const foot = height - bottom - 102;
const cleared=window.variantSnapshot().cleared;
const message=cleared?(window.variantSnapshot().displayOrder===ordered.length?'200 关全部完成!':'完成!准备好就进入下一关。'):($('status').className.includes('show')&&$('status').textContent?$('status').textContent:'按住滑动连线 · 沿来时路逐格回退');
@@ -121,7 +143,10 @@ function draw() {
if(!lesson||lesson.id!==lessonKey){if(!lessonCache.has(part.key))lessonCache.set(part.key,makeLesson(PACK,part.key));lesson={...lessonCache.get(part.key),id:lessonKey};lessonStarted=Date.now();}
lessonState=lessonFrame(lesson,preferences.reducedMotion?lesson.duration:loopLessonTime(lesson,Date.now()-lessonStarted));
const area={x:32,y:panelTop+49,w:width-64,h:visualHeight};
const view=renderLesson(lessonCanvas,lesson.level,lessonState.progress<1?lessonState.previous:lessonState.route,area.w,area.h);
const lessonRoute=lessonState.progress<1?lessonState.previous:lessonState.route;
const lessonBoardKey=JSON.stringify([lesson.id,lessonRoute,area.w,area.h,d]);
if(lessonBoardCache?.key!==lessonBoardKey){lessonBoardRenders++;lessonBoardCache={key:lessonBoardKey,view:renderLesson(lessonCanvas,lesson.level,lessonRoute,area.w,area.h)};}
const view=lessonBoardCache.view;
ctx.drawImage(lessonCanvas,area.x,area.y,area.w,area.h);
if(lessonState.blocked){const [a,b]=lessonState.blocked.map(id=>view.centers[id]);ctx.beginPath();ctx.moveTo(area.x+a[0],area.y+a[1]);ctx.lineTo(area.x+b[0],area.y+b[1]);ctx.setLineDash([5,5]);ctx.strokeStyle=C.danger;ctx.lineWidth=4;ctx.stroke();ctx.setLineDash([]);text('×',area.x+(a[0]+b[0])/2,area.y+(a[1]+b[1])/2,26,C.danger);}
const last=lessonState.route.at(-1),prev=lessonState.previous.at(-1),end=view.centers[last],start=view.centers[prev]||end;
@@ -140,6 +165,11 @@ function draw() {
button('减少动态:'+(preferences.reducedMotion?'开':'关'),44,y+125,width-88,48,()=>{preferences.reducedMotion=!preferences.reducedMotion;wx.setStorageSync('number-swipe:reduced-motion',preferences.reducedMotion?'1':'0');});
button('完成',44,y+205,width-88,48,()=>settingsOpen=false,{primary:true});
}
if(cacheEligible){
hintBaseCanvas.width=mainCanvas.width;hintBaseCanvas.height=mainCanvas.height;
hintBaseCanvas.getContext('2d').drawImage(mainCanvas,0,0);hintBaseKey=baseKey;
}else hintBaseKey='';
if(!settingsOpen&&$('rules-sheet').hidden&&state.screen==='play')paintHintLayer(animationHint,state,d);
const liveBoard=!settingsOpen&&$('rules-sheet').hidden&&state.screen==='play';
const liveLesson=!settingsOpen&&!$('rules-sheet').hidden&&!preferences.reducedMotion&&Boolean(lesson);
const liveClear=liveBoard&&clearCelebration&&!preferences.reducedMotion&&(clearCelebration.started===null||Date.now()-clearCelebration.started<CLEAR_DURATION);
@@ -155,7 +185,7 @@ function event(p) { return { clientX: p.clientX, clientY: p.clientY - top - 82,
wx.onTouchStart(e => { if (pointer !== null)
return; const p = e.changedTouches[0]; if (!p)
return; const b = buttons.find(b => p.clientX >= b.x && p.clientX <= b.x + b.w && p.clientY >= b.y && p.clientY <= b.y + b.h); if (b) {
if (b.enabled){pressedLabel=b.label;pressedUntil=Date.now()+120;
if (b.enabled){hintBaseKey='';pressedLabel=b.label;pressedUntil=Date.now()+120;
b.run();
requestPaint();
}
@@ -173,13 +203,14 @@ wx.onTouchEnd(release);
wx.onTouchCancel(release);
wx.onHide(() => { visible = false; lessonStarted=Date.now(); stopAudio(); cancelAnimationFrame(raf);raf=0; pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
wx.onShow(() => { if (!visible) {
visible = true;lessonStarted=Date.now();
visible = true;lessonStarted=Date.now();settingsOpen=false;resumeGameplay();
resize();
requestPaint();
} });
wx.onAudioInterruptionBegin?.(stopAudio);
wx.onWindowResize?.(resize);
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),theme:'bright-classroom',fonts:{...fontDiagnostics},renderedFrames,reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},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,selection:{chapter:page+1,chapters:PACK.chapters.length},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},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 })) };
resize();
requestPaint();
+22
View File
@@ -0,0 +1,22 @@
export const RESUME_KEY='variant:campaign-200-r2:v1:resume';
export function readResume(storage){try{const value=JSON.parse(storage.getItem(RESUME_KEY)||'null');return value?.version===1&&typeof value.levelId==='string'?value:null;}catch{return null;}}
export function resumeTarget(levels,completed,saved,legacyId){
const done=new Set(completed),savedIndex=levels.findIndex(l=>l.id===saved?.levelId),legacy=levels.findIndex(l=>l.id===legacyId);
if(savedIndex>=0&&!done.has(levels[savedIndex].id))return {index:savedIndex,path:Array.isArray(saved.path)?saved.path:[]};
const from=savedIndex>=0?savedIndex:legacy;
if(from>=0&&!done.has(levels[from].id))return {index:from,path:[]};
for(let step=1;step<=levels.length;step++){const i=(Math.max(-1,from)+step)%levels.length;if(!done.has(levels[i].id))return {index:i,path:[]};}
return {index:Math.max(0,from),path:[]};
}
export function validResumePath(level,route,canAppend){
if(!Array.isArray(route)||route.length>level.cells.length)return [];
const restored=[];for(const id of route){if(typeof id!=='string'||!canAppend(level,restored,id))return [];restored.push(id);}return restored;
}
export function writeResume(storage,levels,completed,current,path,cleared){
const previous=readResume(storage),done=new Set(completed);
// Replaying a completed level must not replace the last unfinished level.
if(done.has(current.id)&&!cleared&&previous&&!done.has(previous.levelId)&&levels.some(l=>l.id===previous.levelId))return;
const next=cleared||done.has(current.id)?resumeTarget(levels,completed,null,current.id):{index:levels.indexOf(current),path:[...path]};
const value=JSON.stringify({version:1,levelId:levels[next.index].id,path:next.path});
try{if(storage.getItem(RESUME_KEY)!==value)storage.setItem(RESUME_KEY,value);}catch{}
}
+2
View File
@@ -1,4 +1,6 @@
{
"startupGameplay": true,
"chapterSelection": true,
"theme": "bright-classroom",
"bundledFonts": true,
"reducedMotionPersisted": true,