用参考图标题美术替换字体描边模拟

This commit is contained in:
toshiocn
2026-10-23 12:31:55 +08:00
parent 91ef37403e
commit eeff9373f2
22 changed files with 185 additions and 35 deletions
+11
View File
@@ -1,3 +1,14 @@
## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
- 移除普通字体描边模拟;使用参考图经imagegen提取的独立透明标题美术资源images/title-reference.png,保留定制橙紫字形、圆润奶油贴纸边、倒角高光及立体厚度。资源为2132×737,等比绘制,首加载完成自动刷新。
- 选关顶部按真实图片比例预留空间,小屏缩小网格间距,继续保持每页3×5关、触摸目标至少44px,进度与范围标签不被遮挡。
- 图片资源随发布包保存;浏览器预览与模拟宿主支持原生图片加载。
- 验收:83项模块测试、200关模拟触摸;375宽及320×568浏览器画面核对。最新温暖BGM资源哈希不变。真机显示仍需微信预览确认。
包:../releases/NumberSwipe-main-参考图标题还原版-20261023.zip,保留之前版本。
## 2026-10-23:温暖无节拍关卡BGM试听版
版本:2026-10-23-warm-ambient-bgm。用户选择试用温暖、无节拍、弱旋律方向;本轮仅替换关卡音乐,选关音乐保持原文件哈希。
+11
View File
@@ -1,3 +1,14 @@
## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
- 移除普通字体描边模拟;使用参考图经imagegen提取的独立透明标题美术资源images/title-reference.png,保留定制橙紫字形、圆润奶油贴纸边、倒角高光及立体厚度。资源为2132×737,等比绘制,首加载完成自动刷新。
- 选关顶部按真实图片比例预留空间,小屏缩小网格间距,继续保持每页3×5关、触摸目标至少44px,进度与范围标签不被遮挡。
- 图片资源随发布包保存;浏览器预览与模拟宿主支持原生图片加载。
- 验收:83项模块测试、200关模拟触摸;375宽及320×568浏览器画面核对。最新温暖BGM资源哈希不变。真机显示仍需微信预览确认。
包:../releases/NumberSwipe-main-参考图标题还原版-20261023.zip,保留之前版本。
## 2026-10-23:温暖无节拍关卡BGM试听版
版本:2026-10-23-warm-ambient-bgm。用户选择试用温暖、无节拍、弱旋律方向;本轮仅替换关卡音乐,选关音乐保持原文件哈希。
+11
View File
@@ -1,3 +1,14 @@
## 2026-10-23:参考图标题还原
版本:2026-10-23-reference-title-artwork。基于最新温暖无节拍BGM主版,本轮没有修改音乐、音效、震动、规则、存档或关卡。
- 移除普通字体描边模拟;使用参考图经imagegen提取的独立透明标题美术资源images/title-reference.png,保留定制橙紫字形、圆润奶油贴纸边、倒角高光及立体厚度。资源为2132×737,等比绘制,首加载完成自动刷新。
- 选关顶部按真实图片比例预留空间,小屏缩小网格间距,继续保持每页3×5关、触摸目标至少44px,进度与范围标签不被遮挡。
- 图片资源随发布包保存;浏览器预览与模拟宿主支持原生图片加载。
- 验收:83项模块测试、200关模拟触摸;375宽及320×568浏览器画面核对。最新温暖BGM资源哈希不变。真机显示仍需微信预览确认。
包:../releases/NumberSwipe-main-参考图标题还原版-20261023.zip,保留之前版本。
## 2026-10-23:温暖无节拍关卡BGM试听版
版本:2026-10-23-warm-ambient-bgm。用户选择试用温暖、无节拍、弱旋律方向;本轮仅替换关卡音乐,选关音乐保持原文件哈希。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
{
"source": "User supplied reference screenshot, title cutout preserved through imagegen editing",
"width": 2132,
"height": 737,
"alpha": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+1 -1
View File
@@ -3,7 +3,7 @@ $releaseDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'releases'
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
$releaseInfo = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'release-info.json') -Raw -Encoding UTF8 | ConvertFrom-Json
$archivePath = Join-Path $releaseDir $releaseInfo.package
$assets = @('game.js','game.json','project.config.json','audio','audio-original','music','fonts','release-info.json') | ForEach-Object { Join-Path $PSScriptRoot $_ }
$assets = @('game.js','game.json','project.config.json','audio','audio-original','music','images','fonts','release-info.json') | ForEach-Object { Join-Path $PSScriptRoot $_ }
Compress-Archive -LiteralPath $assets -DestinationPath $archivePath -Force
$hashStream = [IO.File]::OpenRead($archivePath)
try { $hasher = [Security.Cryptography.SHA256]::Create(); [BitConverter]::ToString($hasher.ComputeHash($hashStream)).Replace('-','') } finally { $hashStream.Dispose() }
@@ -107,6 +107,10 @@
{
"type": "file",
"value": "node-feedback.test.mjs"
},
{
"type": "file",
"value": "selection-title.test.mjs"
}
]
},
@@ -28,7 +28,7 @@ function boot(){
const later=(callback,delay)=>{const timer=setTimeout(()=>{timers.delete(timer);callback();},delay);timers.add(timer);return timer;};
const cancel=timer=>{clearTimeout(timer);timers.delete(timer);};
class Clock extends Date{static now(){return now;}}
const wx={__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,createCanvas(){const c=document.createElement('canvas');if(!canvases.length)canvases.push(c);return c;},loadFont:p=>p.split('/').at(-1).replace('.ttf',''),getWindowInfo:()=>({windowWidth:width,windowHeight:height,pixelRatio:2,safeArea:{top:24,bottom:height-24}}),getMenuButtonBoundingClientRect:()=>({bottom:42}),getPerformance:()=>({now:()=>now}),getStorageSync:k=>store.get(k)||'',setStorageSync:(k,v)=>store.set(k,v),removeStorageSync:k=>store.delete(k),setInnerAudioOption:sound.setInnerAudioOption,createInnerAudioContext:sound.createInnerAudioContext,showModal(){},env:{USER_DATA_PATH:'/preview'}};
const wx={createImage(){const i=new Image();return {get width(){return i.naturalWidth;},get height(){return i.naturalHeight;},set onload(fn){i.onload=fn;},set onerror(fn){i.onerror=fn;},set src(v){i.src=new URL('../'+v,location.href).href;},_native:i};},__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,createCanvas(){const c=document.createElement('canvas');const p=c.getContext('2d'),draw=p.drawImage.bind(p);p.drawImage=(i,...args)=>draw(i?._native||i,...args);if(!canvases.length)canvases.push(c);return c;},loadFont:p=>p.split('/').at(-1).replace('.ttf',''),getWindowInfo:()=>({windowWidth:width,windowHeight:height,pixelRatio:2,safeArea:{top:24,bottom:height-24}}),getMenuButtonBoundingClientRect:()=>({bottom:42}),getPerformance:()=>({now:()=>now}),getStorageSync:k=>store.get(k)||'',setStorageSync:(k,v)=>store.set(k,v),removeStorageSync:k=>store.delete(k),setInnerAudioOption:sound.setInnerAudioOption,createInnerAudioContext:sound.createInnerAudioContext,showModal(){},env:{USER_DATA_PATH:'/preview'}};
for(const n of ['TouchStart','TouchMove','TouchEnd','TouchCancel','Hide','Show','WindowResize'])wx['on'+n]=cb=>handlers[n]=cb;
const global={};new Function('wx','GameGlobal','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout','Date',source)(wx,global,fn=>{frames.set(++frame,fn);return frame;},id=>frames.delete(id),later,cancel,Clock);
const api=global.numberSwipeR2,canvas=canvases[0];canvas.style.width=width+'px';canvas.style.height=height+'px';phone.style.width=width+'px';phone.replaceChildren(canvas);
+2 -2
View File
@@ -16,9 +16,9 @@ const source=await(await fetch('../game.js')).text();
const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));
const canvases=[],handlers={},frames=new Map(),storage=new Map();let frameId=0,now=100000;
class Clock extends Date{static now(){return now;}}
const wx={__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,
const wx={createImage(){const i=new Image();return {get width(){return i.naturalWidth;},get height(){return i.naturalHeight;},set onload(fn){i.onload=fn;},set onerror(fn){i.onerror=fn;},set src(v){i.src=new URL('../'+v,location.href).href;},_native:i};},__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,
loadFont:p=>p.split('/').at(-1).replace('.ttf',''),
createCanvas(){const c=document.createElement('canvas');if(!canvases.length)canvases.push(c);return c;},
createCanvas(){const c=document.createElement('canvas');const p=c.getContext('2d'),draw=p.drawImage.bind(p);p.drawImage=(i,...args)=>draw(i?._native||i,...args);if(!canvases.length)canvases.push(c);return c;},
getWindowInfo:()=>({windowWidth:375,windowHeight:812,pixelRatio:1,safeArea:{top:44,bottom:778}}),
getMenuButtonBoundingClientRect:()=>({bottom:80}),getPerformance:()=>({now:()=>now}),
getStorageSync:k=>storage.get(k)||'',setStorageSync:(k,v)=>storage.set(k,v),removeStorageSync:k=>storage.delete(k),
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@@ -0,0 +1,109 @@
> 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 步和六类反馈音 (9.0119ms)
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (0.9311ms)
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (47.2484ms)
✔ 音频错误可诊断,不再静默吞掉 (0.5926ms)
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (77.8758ms)
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (18.6892ms)
✔ 过关提示同步使用实际播放起点,非请求时间 (0.6079ms)
✔ 过关和弦收尾持续衰减,不在后半段重新起音 (3.0069ms)
✔ merged irregular outlines retain holes and never draw internal unit edges (129.6431ms)
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (923.4894ms)
✔ guides through every irregular block stay inside its occupied shape (59.9688ms)
✔ visited, conflicting and hinted category cells retain their material identity (19.912ms)
✔ five font subsets match their manifest and fit the package budget (6.5877ms)
✔ upstream adapter fails closed if the inspected render seams change (1.9187ms)
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (30.7559ms)
✔ visible glyph bounds are centered independently of font ascent and side bearings (2.2727ms)
✔ stage with zero depth casts alpha shadows without painting a solid backing (7.147ms)
✔ native width-only TextMetrics still centers the rasterized glyph ink (3.0655ms)
✔ fixed channels remain anchored at their block centers after number enlargement (1269.7132ms)
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (4.1897ms)
✔ checkpoint pulse is small, centered in time and settles without looping (0.4945ms)
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (6.0019ms)
✔ completion waits at most 120ms and ignores late audio callbacks (1.2469ms)
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (1.9119ms)
✔ unsupported graded haptic falls back to one short pulse, never long vibration (1.3278ms)
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (0.7516ms)
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (0.4369ms)
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (15.9339ms)
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (267.092ms)
✔ legal but off-solution moves use public rules, while invalid clues only produce error (97.6956ms)
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (76.8074ms)
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (166.166ms)
✔ pressure and rebound have bounded amplitude and duration (0.4204ms)
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.4006ms)
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (35.9257ms)
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (3.0347ms)
✔ settings persist haptic preference independently and chapter cards always use three columns (81.7164ms)
✔ original audio is preserved and high pitches lose energy rather than growing sharper (9.9401ms)
✔ empty, partial and wrong routes reveal the first incomplete full segment (3.6375ms)
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (0.9455ms)
✔ all 200 levels reveal consecutive segments without changing the player path (2293.2505ms)
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (1.0414ms)
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (0.4072ms)
✔ coral teaching ribbon preserves visited masks and offers direction in reduced motion (1.7181ms)
✔ hint rounds shared guide junctions and keeps separate runs disconnected (0.4092ms)
✔ 实际教学关动画:basic (5.1841ms)
✔ 实际教学关动画:walls (1.1257ms)
✔ 实际教学关动画:straight (2.8009ms)
✔ 实际教学关动画:vertical (6.6101ms)
✔ 实际教学关动画:corner (2.6063ms)
✔ 实际教学关动画:colour (2.0917ms)
✔ 实际教学关动画:parity (0.8465ms)
✔ 实际教学关动画:rhythm-door (1.214ms)
✔ 实际教学关动画:hex (1.4503ms)
✔ 实际教学关动画:unknown-start (4.5831ms)
✔ 200关的组合规则均能找到对应真实教学关 (216.7609ms)
✔ 教学减速,结束停留1秒后自动循环 (8.4881ms)
✔ music loops independently, persists mute and cancels in background (4.1939ms)
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (1.1354ms)
✔ settings have exactly three independent preferences, and hint has its own cue (242.1052ms)
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (161.9424ms)
✔ two original music loops are quiet, nonempty and have a smooth loop seam (7.5788ms)
✔ warm gameplay ambient is 32 seconds, softly leveled and free from percussive envelope jumps (31.2164ms)
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (0.8613ms)
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (29378.3694ms)
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (40.0552ms)
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (1.0956ms)
✔ regular and special scale settle at 90ms with a larger special peak (0.1883ms)
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (6.2144ms)
✔ quick undo cancels removed node rebound, including last-step removal (1.9216ms)
✔ first launch goes directly to gameplay (136.5207ms)
✔ resume unfinished route after exit from gameplay (169.5016ms)
✔ resume unfinished route after exit from selection (204.0092ms)
✔ completed level resumes next unfinished, including leaving from cleared screen (102.4366ms)
✔ replaying a cleared level does not replace the unfinished save (95.3532ms)
✔ legacy saves, malformed data and full completion have valid startup targets (82.9585ms)
✔ selection pages expose fifteen cards and fit phone safe areas (433.5342ms)
✔ hint-only frames reuse static UI, and player input invalidates it (63.2693ms)
✔ hint curves are cached and distance lookup is stable at boundaries (1.0996ms)
✔ new-mechanic lesson reuses the board while the continuous cursor advances (46.259ms)
✔ title artwork is a transparent, high resolution PNG packaged locally (3.9438ms)
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (1.4977ms)
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (414.5903ms)
ℹ tests 83
ℹ suites 0
ℹ pass 83
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 29896.8672
> numberswipe-main@1.0.0 smoke
> node smoke.mjs
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
@@ -5,8 +5,8 @@ export function boot(storage = new Map(), width = 375, height = 812) {
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 = {vibrateShort:o=>vibrated.push({type:o.type,at:now}),setInnerAudioOption:()=>{},createInnerAudioContext:()=>({stop(){},onError(){},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}}), createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({source:image.source||null,x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
const wx = {createImage:()=>({set src(value){const b=fs.readFileSync(path.join(root,value));this.width=b.readUInt32BE(16);this.height=b.readUInt32BE(20);this.source=value;this.onload?.();}}),vibrateShort:o=>vibrated.push({type:o.type,at:now}),setInnerAudioOption:()=>{},createInnerAudioContext:()=>({stop(){},onError(){},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}}), createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
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;
+1 -1
View File
@@ -6,5 +6,5 @@ const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
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('.wav')?'audio/wav':file.endsWith('.json')?'application/json':'text/javascript; charset=utf-8');res.end(fs.readFileSync(file));}catch{res.writeHead(404).end();}
try{res.setHeader('Content-Type',file.endsWith('.html')?'text/html; charset=utf-8':file.endsWith('.png')?'image/png':file.endsWith('.wav')?'audio/wav':file.endsWith('.json')?'application/json':'text/javascript; charset=utf-8');res.end(fs.readFileSync(file));}catch{res.writeHead(404).end();}
}).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'));
+3 -3
View File
@@ -1,10 +1,10 @@
{
"channel": "main",
"version": "2026-10-23-warm-ambient-bgm",
"version": "2026-10-23-reference-title-artwork",
"date": "2026-10-23",
"baseVersion": "2026-10-22-resume-chapter-lessons",
"baseMainSHA256": "bb35d5edf8d96eb3253c78052d219b32f9a4241efd9971537f6c9c8896188125",
"package": "NumberSwipe-main-温暖无节拍BGM试听版-20261023.zip",
"package": "NumberSwipe-main-参考图标题还原版-20261023.zip",
"promotedFrom": "2026-10-22-layered-feedback-music-flow",
"previousVersion": "2026-10-23-responsive-nodes-sticker-header"
"previousVersion": "2026-10-23-warm-ambient-bgm"
}
@@ -0,0 +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';
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('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);}});
+2 -2
View File
@@ -19,8 +19,8 @@ function boot(storage = new Map(), width = 375, height = 812) {
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' } };
const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({source:image.source||null,x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
const wx = {createImage:()=>({set src(value){const b=fs.readFileSync(path.join(root,value));this.width=b.readUInt32BE(16);this.height=b.readUInt32BE(20);this.source=value;this.onload?.();}}),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;
+7 -19
View File
@@ -1,8 +1,9 @@
import {C,label,panel,roundRect,textFont,inkMetrics} from './bright-classroom/theme.mjs';
import {paintSelectionTitle,titleLayout} from './selection-title.mjs';
import {C,label,panel,roundRect} from './bright-classroom/theme.mjs';
export function chapterLayout(width,height,top,bottom,count=15){
const columns=3,rows=Math.ceil(count/columns),gap=10;
const y=top+72,available=height-bottom-110-y;
const columns=3,rows=Math.ceil(count/columns),gap=titleLayout(width,top).gap;
const y=top+titleLayout(width,top).gridOffset,available=height-bottom-110-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};
}
@@ -14,23 +15,10 @@ export function levelCard(ctx,labelText,x,y,w,h,{completed,current}){
if(completed){const size=Math.min(24,h*.4),cx=x+w/2+String(labelText).length*size*.28+5,cy=y+(h-3)/2;ctx.save();ctx.strokeStyle='#326443';ctx.lineWidth=2.6;ctx.lineCap='round';ctx.lineJoin='round';ctx.beginPath();ctx.moveTo(cx-4,cy);ctx.lineTo(cx-1,cy+3);ctx.lineTo(cx+5,cy-4);ctx.stroke();ctx.restore();}
}
// Soft sticker lettering stays sharp on native Canvas at every device density.
// Original reference artwork, rather than a font reconstruction.
export function selectionHeader(ctx,width,top,progress,first,last){
const size=width<350?36:41,cx=width/2-16,y=top+12,advance=size*1.02;
ctx.save();textFont(ctx,size,700);ctx.textAlign='left';ctx.textBaseline='alphabetic';ctx.lineJoin='round';ctx.lineCap='round';
for(const [i,char]of [...'数字一笔'].entries()){
const m=inkMetrics(ctx,char,size),x=cx+(i-1.5)*advance,baseline=Number.isFinite(m.actualBoundingBoxAscent)?(m.actualBoundingBoxAscent-m.actualBoundingBoxDescent)/2:size*.35;
ctx.save();ctx.translate(x,y);ctx.rotate([-.07,.04,-.025,.065][i]);
const left=Number.isFinite(m.actualBoundingBoxLeft)?(m.actualBoundingBoxLeft-m.actualBoundingBoxRight)/2:-m.width/2;
ctx.lineWidth=10;ctx.strokeStyle='#E8B45D';ctx.strokeText(char,left,baseline+4);
ctx.lineWidth=10;ctx.strokeStyle='#FFF6DA';ctx.strokeText(char,left,baseline+1);
ctx.lineWidth=7;ctx.strokeStyle='#FFFFFF';ctx.strokeText(char,left,baseline-1);
const gradient=ctx.createLinearGradient(0,-size/2,0,size/2),orange=i<2;
if(gradient?.addColorStop){gradient.addColorStop(0,orange?'#FF9564':'#9990FF');gradient.addColorStop(.45,orange?'#FF713F':'#6C61F4');gradient.addColorStop(1,orange?'#F2522D':'#4945D8');}
ctx.fillStyle=gradient?.addColorStop?gradient:orange?'#FF713F':'#6C61F4';ctx.lineWidth=1.7;ctx.strokeStyle=orange?'#FA6C3C':'#6757EE';ctx.strokeText(char,left,baseline);ctx.fillText(char,left,baseline);ctx.restore();
}
ctx.restore();
const gap=10,pw=132,rw=104,x=(width-pw-rw-gap)/2,py=top+42;
paintSelectionTitle(ctx,width,top);
const gap=10,pw=132,rw=104,x=(width-pw-rw-gap)/2,py=titleLayout(width,top).pillY;
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+pw+gap,py,rw,24,12,C.coral,2);label(ctx,'第 '+first+'–'+last+' 关',x+pw+gap+rw/2,py+12,12,C.onPrimary,700);
}
+2
View File
@@ -1,3 +1,4 @@
import {loadSelectionTitle} from './selection-title.mjs';
import {Music} from './music.mjs';
import {HintArrows} from './hint-arrows.mjs';
import {feedback} from './feedback.mjs';
@@ -216,6 +217,7 @@ wx.onAudioInterruptionEnd?.(()=>{if(visible){music.resume();requestPaint();}});
wx.onWindowResize?.(resize);
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),theme:'bright-classroom',release:releaseInfo,fonts:{...fontDiagnostics},renderedFrames,hintBaseHits,lessonBoardRenders,music:music.snapshot(),selection:{page:page+1,pages:Math.ceil(ordered.length/15),pageSize:15,chapter:page+1,chapters:Math.ceil(ordered.length/15)},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},feedback:feedback.snapshot(),rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,finished:lessonState.finished,duration:lesson?.duration,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
loadSelectionTitle(wx,requestPaint);
resize();
requestPaint();
@@ -0,0 +1,4 @@
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 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 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);}