更新技术美术明亮教具室版:字体、指路、教学与庆祝效果

This commit is contained in:
toshiocn
2026-10-22 16:37:43 +08:00
parent 2d5a9e9553
commit 66b3ca4ea0
113 changed files with 7363 additions and 150 deletions
+2
View File
@@ -0,0 +1,2 @@
.font-sources/
.font-tools/
+58 -1
View File
@@ -1,5 +1,62 @@
2026-10-22 数字和六边形优化:通道一位数按格宽36%、两位数按30%绘制,上限分别28/25逻辑像素;调整通道及数字牌间距,小屏按比例缩放以避免越界。双色玩法移除圆/三角角标,数字在方块中央,保留奇偶规则文字。六边形厚度1.2–2.2像素,投影透明度0.09,减小偏移、倒角暗部及暖色外晕。发布包:releases/NumberSwipe-campaign-200-r2-明亮教具室-大数字双色轻六边版-20261022.zip。
2026-10-22 指路重新设计:参考任天堂官方《灵活脑学校 一起伸展大脑》玩法画面中的奶油色承托、珊瑚橙强调和清楚的方向标识,结合本游戏圆润积木材质,自行程序化绘制奶油色圆边+浅珊瑚色教学引导带。三个圆角方向标沿圆弧移动,带短柔光尾;终点有贴纸式目标环;减少动态保持静态方向。已走方块、规则文字继续避让。参考图片仅用于分析,未作为游戏资源。连接桥没有改动。
参考:https://www.nintendo.com/hk/switch/azlsa/stretch/index.html
官方示例:https://www.nintendo.com/hk/switch/azlsa/assets/img/stretch/genre5-img3.png
当前包:releases/NumberSwipe-campaign-200-r2-明亮教具室-教具指路设计版-20261022.zip。
2026-10-22 更新:连接桥缩短到格子边缘,仅保留1.2逻辑像素衔接;指路合并连续单元路径,并用二次曲线采样圆滑转角。光点及渐隐拖尾沿同一条曲线按弧长向目标移动,过弯保持平稳速度。减少动态模式保持静态指引。新增圆弧拼接与断段隔离回归测试。当前包:releases/NumberSwipe-campaign-200-r2-明亮教具室-短桥圆弧流光版-20261022.zip。
本轮连接桥与指路精修(2026-10-21):连接桥去掉端部封口、端部倒角和实体厚边,只有侧面保留柔和倒角,两端延伸并渐隐到已走块体内。指路改为暖金色光带、浅金高光、移动光点和方向箭头;减少动态模式显示静态虚线及箭头。提示继续独立绘制并避让已走方块和规则文字。
验收:34项模块测试通过;200关模拟微信触摸回归通过;浏览器200关提示前后及撤销重画的玩家填色像素一致。新增截图:qa/bright-classroom-bridge-v4.png、qa/bright-classroom-hint-v4.png。发布包:releases/NumberSwipe-campaign-200-r2-明亮教具室-连接桥与指路精修版-20261021.zip(位于项目根目录)。
# 数字一笔 · campaign-200-r2 微信小游戏
## 明亮教具室程序化美术(2026-10-21)
### 投影、数字与按钮精修(v3,当前版本)
根据参考图再次区分两类效果:棋块外沿使用黄棕色近边晕染+短投影,底盘则没有实体下沿,只由实际面轮廓alpha投出软阴影。底盘暗部改为淡暖色,避免灰黑底沿看起来像第二块垫子。
数字以真实字形边界定位;原生Canvas若只返回文字宽度,会通过一次离屏alpha测量获得字形边界。普通格数字在视觉中心,大块/异形数字位于左上占用单元区域,输入中心与规则不变。白字增加短暗边和柔光,深色数字使用淡渐变与固定微颗粒;生成缓存不作为外部图片资源。
按钮重新调整实体图标轮廓、视觉尺寸与文字留白,短标签使用随包的站酷快乐体改名子集 `NumberSwipePlayful.ttf`,字体许可见 `fonts/zcoolkuaile-OFL.txt`。规则和正文继续用Noto Sans SC。当前有5个字体子集、3份许可;字体源及输出哈希见manifest。
33项模块测试、200关模拟触摸与200关浏览器提示像素检查通过。新版包为 `../releases/NumberSwipe-campaign-200-r2-明亮教具室-投影字效精修版-20261021.zip`;微信手机的显示、字体和生成材质耗时仍需新包真机预览。
以下为历史修订记录。
### 参考图圆润材质修订(v2)
按用户随后提供的效果图重做主游戏区:浅紫 `#8178F4`、暖奶油 `#F9EDD5`、金黄背景;移除固定数字框、四角框、双色访问内框和常驻块轮廓线。现在使用 `src/bright-classroom/cushion.mjs` 从几何距离场生成柔光倒角、下缘暗部和软投影,所有缓存像素均在运行时生成,未引入图片资源。按钮使用同一圆润材质和实心玩具图标;桥接缩细,简单盘面按可用面积放大。
本次用户参考图优先于早期报告的薄描边与低渐变建议,对应参数集中在设计令牌 `referenceMatch`。密集盘的小号白数字仍采用较深紫面。规则所需的墙、通道、类别符号继续保留,操作和关卡不变。
新版导入包:`../releases/NumberSwipe-campaign-200-r2-明亮教具室-参考图圆润版-20261021.zip`。以下为第一版说明及历史记录;当前执行30项模块测试。
当前 `game.js` 已应用暖黄背景、奶油舞台、轻厚度块体、紫色路径、程序图标和圆角按钮。方形、大块异形、六边、通道、双色、奇偶与墙门共用新渲染器;规则教学同步换皮。所有游戏美术均由Canvas路径生成,不使用图片资源。字体为随包提供的Nunito数字800与Noto Sans SC中文500/600/700子集,许可证、字集和SHA-256见 `fonts/`。
200关内容、顺序、原规则、微信存档key、逐格回退、撤销、分段提示及手动下一关保持原行为。双色已走保留蓝橙面与圆/三角;提示使用真实共边和块内引导,遮罩避让玩家块面与关键标注。新增选关页“设置”中的减少动态开关,保存于本地;静态页面和无动画的思考阶段停止RAF。
直接导入本目录可运行,或解压 `../releases/NumberSwipe-campaign-200-r2-明亮教具室-20261021.zip` 后导入。包含 `game.js`、两个配置文件、`audio/`、`fonts/`,无需构建npm。不要使用旧二维码判断新皮肤是否生效,应重新编译并生成预览。
开发预览:在本目录执行 `node qa/serve.mjs`,打开 [明亮教具室试玩](http://127.0.0.1:17859/qa/bright-classroom.html),可切换全部200关和五种屏幕尺寸;画前几步/完成本关是验收页工具,正式游戏不提供自动解题按钮。
验证命令:
```powershell
node build.mjs
node --test bright-classroom.test.mjs hints.test.mjs lessons.test.mjs audio-copy.test.mjs
node smoke.mjs
```
29项模块测试通过;模拟微信200关唯一解与触摸通关通过,覆盖教学隔离、四种手机尺寸、减少动态存档、静态停止绘制和字体装载调用。浏览器200关已走格采样像素在提示前后及撤销重画后保持一致。自动检查与浏览器预览不代表微信手机实测;仍需手机验收字体加载、密集关手势、音频与帧率。
美术接入在 `bright-classroom-build.mjs` 与 `src/bright-classroom/`,上游四份快照未修改。重建依赖本仓库的 `docs/design/bright-classroom/tokens.json` 与原型内esbuild安装。字体无需在普通构建时重做;补充字集时用 `build-fonts.py`,读取 `.font-sources/` 的上游字体(构建专用),生成改名子集及manifest。来源:[Nunito](https://github.com/google/fonts/tree/main/ofl/nunito)、[Noto Sans SC](https://github.com/google/fonts/tree/main/ofl/notosanssc),许可随资源保留。
以下为此前版本的变更记录。
本包源于 G:\项目\NumberSwipe-Variants\campaign-200-r2,版本 scored-2026-10-15-r2。完整保留原版 200 关及 playOrder,不混入原 NumberSwipe 项目关卡,也不重新生成关卡。
## 导入
@@ -48,4 +105,4 @@ validation.json:200/200 唯一解及模拟微信触摸通关,撤销/重开/
提示使用独立透明 Canvas,每帧只清空提示层,并从中擦除玩家已经走过的格子区域;已画的连接段不再叠加提示。玩家棋盘及整格填色、数字、方向反馈继续由原绘制代码负责,双色关保留原有蓝/橙格色。撤销、重开会按实际路线恢复相应提示。
构建:node build.mjs。检查:node --test hints.test.mjs lessons.test.mjs audio-copy.test.mjs(23项),node smoke.mjs(200关,并比较点击指路前后玩家填色数据)。浏览器像素回归:node qa/serve.mjs 后打开 http://127.0.0.1:17859/qa/hints.html,检查200关前两步的已走格填色采样像素在开启提示、撤销再画前后相同;qa/hint-player-fill.png 为浏览器检查截图,不能作为微信真机截图。独立导入包通过 ./package.ps1 生成,只含运行必需文件。真机显示仍需手机预览确认。
构建:node build.mjs。检查:node --test hints.test.mjs lessons.test.mjs audio-copy.test.mjs(23项),node smoke.mjs(200关,并比较点击指路前后玩家填色数据)。浏览器像素回归:node qa/serve.mjs 后打开 http://127.0.0.1:17859/qa/hints.html,检查200关前两步的已走格填色采样像素在开启提示、撤销再画前后相同;qa/hint-player-fill.png 为浏览器检查截图,不能作为微信真机截图。独立导入包通过 ./package.ps1 生成,只含运行必需文件。真机显示仍需手机预览确认。
+5 -3
View File
@@ -1,4 +1,6 @@
import fs from 'node:fs';import path from 'node:path';
// 与 r2 的半音上行、其余五类提示音采用相同的频率和时序。
export function buildAudio(root){const out=path.join(root,'audio');fs.mkdirSync(out,{recursive:true});const cues={correct:[[659,.09,0],[880,.14,.075]],error:[[330,.10,0],[247,.15,.09]],undo:[[587,.07,0],[440,.10,.055]],restart:[[523,.075,0],[392,.09,.065],[523,.11,.14]],complete:[[523,.12,0],[659,.12,.10],[784,.14,.20],[1046,.25,.32]]};for(let n=1;n<=36;n++)cues['move-'+n]=[[523*Math.pow(2,(n-1)/12),.07,0]];
for(const [name,tones]of Object.entries(cues)){const rate=22050,length=Math.ceil((Math.max(...tones.map(([,d,t])=>t+d))+.03)*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);for(let i=0;i<length;i++){const t=i/rate;let sample=0;for(const [f,d,offset]of tones){const u=t-offset;if(u>=0&&u<d){const gain=name==='error'?.24:.32;const env=u<.008?u/.008:Math.pow(.0001/gain,(u-.008)/(d-.008));sample+=Math.sin(2*Math.PI*f*u)*gain*env;}}b.writeInt16LE(Math.round(Math.max(-1,Math.min(1,sample))*32767),44+i*2);}fs.writeFileSync(path.join(out,name+'.wav'),b);}}
// 原版音效保持不变;路径点新增同音色的短C大调庆祝音。
export function buildAudio(root){const out=path.join(root,'audio');fs.mkdirSync(out,{recursive:true});const cues={checkpoint:[[523,.065,0],[784,.38,.05],[392,.25,.065]],correct:[[659,.09,0],[880,.14,.075]],error:[[330,.10,0],[247,.15,.09]],undo:[[587,.07,0],[440,.10,.055]],restart:[[523,.075,0],[392,.09,.065],[523,.11,.14]],complete:[[392,.10,0],[494,.10,.075],[587,.13,.15],[523,.65,.26],[659,.55,.28],[784,.48,.30],[196,.18,0,.28],[261.5,.22,.075,.25],[392,.24,.15,.25]]};for(let n=1;n<=36;n++)cues['move-'+n]=[[523*Math.pow(2,(n-1)/12),.07,0]];
for(const [name,tones]of Object.entries(cues)){const rate=22050,length=Math.ceil((Math.max(...tones.map(([,d,t])=>t+d))+.03)*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);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){const themed=name==='checkpoint'||name==='complete',gain=themed?.20:name==='error'?.24:.32;
const env=u<.008?u/.008:Math.pow(.0001/gain,(u-.008)/(d-.008));
sample+=(Math.sin(2*Math.PI*f*u)+(themed?.08*Math.sin(4*Math.PI*f*u)*Math.exp(-u/.05):0))*gain*env*weight;}}b.writeInt16LE(Math.round(Math.max(-1,Math.min(1,sample))*32767),44+i*2);}fs.writeFileSync(path.join(out,name+'.wav'),b);}}
+13 -3
View File
@@ -1,9 +1,19 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {CasualAudio,stopAudio,audioDiagnostics} from './src/audio.mjs';import {tutorials,rulesFor,playerText} from './src/copy.mjs';
const root=path.dirname(fileURLToPath(import.meta.url));
function setup(){const played=[],contexts=[],storage=new Map(),options=[];globalThis.wx={setInnerAudioOption:o=>options.push(o),showToast:()=>{},createInnerAudioContext:()=>{const a={stops:0,stop(){this.stops++;},play(){assert.ok(fs.existsSync(path.join(root,this.src)));played.push(this.src);},onError(fn){this.error=fn;}};contexts.push(a);return a;}};const sound=new CasualAudio({}, {getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v)},'sound');return {sound,played,contexts,storage,options};}
test('手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和五类反馈音',()=>{const h=setup();for(let step=1;step<=36;step++)h.sound.play('move',0,step);for(const kind of ['correct','error','undo','restart','complete'])h.sound.play(kind);assert.equal(h.played.length,41);assert.equal(h.contexts.length,4);assert.ok(h.options.every(o=>o.obeyMuteSwitch===false));assert.ok(h.contexts.every(a=>a.obeyMuteSwitch===false));});
function setup(){const played=[],contexts=[],storage=new Map(),options=[];globalThis.wx={setInnerAudioOption:o=>options.push(o),showToast:()=>{},createInnerAudioContext:()=>{const a={stops:0,stop(){this.stops++;},play(){assert.ok(fs.existsSync(path.join(root,this.src)));played.push(this.src);},onError(fn){this.error=fn;},onPlay(fn){this.playing=fn;}};contexts.push(a);return a;}};const sound=new CasualAudio({}, {getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v)},'sound');return {sound,played,contexts,storage,options};}
test('手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音',()=>{const h=setup();for(let step=1;step<=36;step++)h.sound.play('move',0,step);for(const kind of ['correct','error','undo','restart','complete','checkpoint'])h.sound.play(kind);assert.equal(h.played.length,42);assert.equal(h.contexts.length,4);assert.ok(h.options.every(o=>o.obeyMuteSwitch===false));assert.ok(h.contexts.every(a=>a.obeyMuteSwitch===false));});
test('关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器',()=>{const h=setup();h.sound.play('move');h.sound.toggle();const count=h.played.length;h.sound.play('move');assert.equal(h.played.length,count);assert.equal(h.storage.get('sound'),'off');h.sound.toggle();assert.equal(h.played.at(-1),'audio/correct.wav');assert.equal(h.storage.get('sound'),'on');const stops=h.contexts.map(a=>a.stops);stopAudio();assert.ok(h.contexts.every((a,i)=>a.stops>stops[i]));});
test('延迟通关音在静音或进入后台时取消,不在返回后突然播放',async()=>{const h=setup();h.sound.play('complete',.02);h.sound.toggle();await new Promise(resolve=>setTimeout(resolve,40));assert.equal(h.played.length,0);});
test('音频错误可诊断,不再静默吞掉',()=>{const h=setup();h.sound.unlock();h.contexts[0].error({errCode:10003,errMsg:'test missing audio'});assert.equal(audioDiagnostics.lastError.errCode,10003);});
test('所有音频均为非静音 PCM;第 13 步比第 1 步高一个八度',()=>{const crossings=[];for(const file of fs.readdirSync(path.join(root,'audio'))){const b=fs.readFileSync(path.join(root,'audio',file));assert.equal(b.toString('ascii',0,4),'RIFF');assert.equal(b.readUInt32LE(24),22050);assert.equal(b.readUInt16LE(22),1);let energy=0,z=0;for(let i=44;i<b.length;i+=2){const x=b.readInt16LE(i);energy+=x*x;if(i>44&&x>=0&&b.readInt16LE(i-2)<0)z++;}assert.ok(energy>100000,file+' must not be silent');if(file==='move-1.wav')crossings[0]=z;if(file==='move-13.wav')crossings[1]=z;}assert.ok(Math.abs(crossings[1]/crossings[0]-2)<.1);});
test('所有音频均为非静音 PCM;原版第13步比第1步高一个八度',()=>{
const crossings=[];for(const file of fs.readdirSync(path.join(root,'audio'))){const b=fs.readFileSync(path.join(root,'audio',file));assert.equal(b.toString('ascii',0,4),'RIFF');assert.equal(b.readUInt32LE(24),22050);assert.equal(b.readUInt16LE(22),1);let energy=0,z=0;for(let i=44;i<b.length;i+=2){const x=b.readInt16LE(i);energy+=x*x;if(i>44&&x>=0&&b.readInt16LE(i-2)<0)z++;}assert.ok(energy>100000,file+' must not be silent');if(file==='move-1.wav')crossings[0]=z;if(file==='move-13.wav')crossings[1]=z;}assert.ok(Math.abs(crossings[1]/crossings[0]-2)<.1);
});
test('教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语',()=>{const levels=JSON.parse(fs.readFileSync(path.join(root,'src/upstream/levels.json'))).levels;for(const l of levels){const text=rulesFor(l);assert.ok(!/共边|落格|访问步号/.test(text),l.id);assert.ok(text.includes('每个格子只能经过一次'));}assert.ok(tutorials.basic.includes('不能斜着走'));assert.ok(tutorials.parity.includes('1、3、5'));assert.ok(tutorials['rhythm-door'].includes('起点也算一步'));assert.ok(!playerText('认清异形共边').includes('共边'));});
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 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.
Binary file not shown.
@@ -0,0 +1,42 @@
// Checked adapter: upstream snapshots remain byte-for-byte unchanged.
export function applyBrightClassroom(source) {
const start=source.indexOf('function draw(){');
const end=source.indexOf('function roundedRect(',start);
if(start<0||end<0||source.indexOf('function draw(){',start+1)>=0)throw Error('Bright Classroom: upstream draw seam changed');
const original=source.slice(start,end);
const tail=original.slice(original.indexOf(' if(previewOnly)return;updateTutorial(bad);'));
if(!tail.startsWith(' if(previewOnly)return;'))throw Error('Bright Classroom: UI writeback seam changed');
const draw=`function draw(){
if(!level)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;
const dpr=window.devicePixelRatio||1;
if(canvas.width!==Math.round(width*dpr))canvas.width=Math.round(width*dpr);
if(canvas.height!==Math.round(height*dpr))canvas.height=Math.round(height*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
brightScene=renderBrightBoard(ctx,level,path,width,height,{preview:previewOnly});
({polygons,centers,unit}=brightScene);channelViews=brightScene.channels;cellViews=brightScene.views;
const bad=violations(level,path);
${tail}`;
source=source.slice(0,start)+draw+source.slice(end);
const checkpointCue="sound.play(cell.clue!==undefined&&cell.clue===path.length?'correct':'move',0,path.length)";
if(!source.includes(checkpointCue))throw Error('Checkpoint audio seam changed');
source=source.replace(checkpointCue,"if(path.length<level.cells.length)sound.play(cell.clue!==undefined&&cell.clue===path.length?(cell.clue>1?'checkpoint':'correct'):'move',0,path.length)");
if(!source.includes("sound.play('complete',.23)"))throw Error('Complete audio timing seam changed');
source=source.replace("sound.play('complete',.23)","sound.play('complete',0)");
const hint='visitedPolygons:polygons.filter(p=>path.includes(p.id)).map(p=>p.p.map(v=>[...v]))';
if(!source.includes(hint))throw Error('Bright Classroom: hint seam changed');
source=source.replace(hint,`${hint},...hintGeometry(brightScene,path,h.route)`);
// Preview rendering must restore every reference used by the live hint layer.
source=source.replace('const saved={canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews};','const saved={canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews,brightScene};');
source=source.replace('({canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews}=saved)','({canvas,ctx,level,solution,path,polygons,centers,unit,focus,cleared,channelViews,cellViews,brightScene}=saved)');
source=`import {renderBrightBoard,hintGeometry,drawBarriers} from '../bright-classroom/board-renderer.mjs';\nlet brightScene=null;\n`+source;
source+=`
export function refreshBrightBoard(now){
if(!brightScene?.animating)return;
const dpr=window.devicePixelRatio||1;ctx.setTransform(dpr,0,0,dpr,0,0);
brightScene=renderBrightBoard(ctx,level,path,canvas.clientWidth,canvas.clientHeight,{now});
}
export function brightOverlay(target){if(brightScene)drawBarriers(target,brightScene,path);}
export function brightAnimating(){return Boolean(brightScene?.animating);}
`;
return source;
}
@@ -0,0 +1,135 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import crypto from 'node:crypto';
import {outlines,buildGeometry,interiorGuide,edgeFor,distance,inside} from './src/bright-classroom/geometry.mjs';
import {renderBrightBoard,hintGeometry} from './src/bright-classroom/board-renderer.mjs';
import {solve,units} from './src/upstream/rules.mjs';
import {applyBrightClassroom} from './bright-classroom-build.mjs';
import {C,label} from './src/bright-classroom/theme.mjs';
import {cushion} from './src/bright-classroom/cushion.mjs';
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));
const area=p=>p.reduce((n,a,i)=>{const b=p[(i+1)%p.length];return n+a[0]*b[1]-b[0]*a[1];},0)/2;
function context(){const ctx=new Proxy({measureText:s=>({width:String(s).length*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16)*.6})},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});return ctx;}
test('merged irregular outlines retain holes and never draw internal unit edges',()=>{
const donut=[];for(let y=0;y<3;y++)for(let x=0;x<3;x++)if(x!==1||y!==1)donut.push([x,y]);
const rings=outlines(donut);assert.equal(rings.length,2);assert.equal(rings.reduce((n,p)=>n+area(p),0),8);assert.ok(rings.some(p=>area(p)<0));
for(const l of pack.levels.filter(l=>l.geometry!=='hex'))for(const c of l.cells){const u=units(c),r=outlines(u);assert.equal(r.reduce((n,p)=>n+area(p),0),u.length,l.id+':'+c.id);}
});
test('all 200 boards fit four screen budgets with stable safe anchors and real shared entrances',()=>{
for(const [w,h]of [[320,220],[375,313],[390,442],[414,494]])for(const level of pack.levels){
const scene=buildGeometry(level,w,h);assert.ok(scene.pitch<=104*Math.min(1,w/375)+.001);
for(const g of scene.cells){assert.ok(g.parts.some(p=>inside(g.center,p)),level.id+':'+g.cell.id);assert.ok(g.parts.some(p=>inside(g.labelAnchor,p)),'label must stay on its block '+level.id+':'+g.cell.id);for(const [x,y]of g.parts.flat()){assert.ok(x>=scene.margin-1&&x<=w-scene.margin+1,level.id);assert.ok(y>=0&&y<=h,level.id);}}
for(const e of scene.edges.values()){const [a,b]=e.segments[0];assert.ok(distance(a,b)>0);assert.ok(Math.abs(distance(a,e.entry)+distance(e.entry,b)-distance(a,b))<.001);}
}
});
test('guides through every irregular block stay inside its occupied shape',()=>{
for(const level of pack.levels.filter(l=>l.cells.some(c=>c.shape))){
const scene=buildGeometry(level,375,400);
for(const g of scene.cells.filter(g=>g.cell.shape))for(const edge of scene.edges.values()){
if(edge.a!==g.cell.id&&edge.b!==g.cell.id)continue;
const guide=interiorGuide(g,g.center,edge.entry,scene);assert.ok(guide.length>=2);
for(let i=1;i<guide.length;i++)for(let t=0;t<=1;t+=.1){const a=guide[i-1],b=guide[i],p=[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t];assert.ok(g.parts.some(poly=>p[0]>=poly[0][0]-.001&&p[0]<=poly[2][0]+.001&&p[1]>=poly[0][1]-.001&&p[1]<=poly[2][1]+.001),level.id+':'+g.cell.id);}
}
}
});
test('visited, conflicting and hinted category cells retain their material identity',()=>{
const level=pack.levels.find(l=>l.features.includes('alternation')&&l.features.includes('parity')),route=solve(level)[0],ctx=context();
for(const path of [[],route.slice(0,4),route.slice(0,4).reverse(),route]){
const scene=renderBrightBoard(ctx,level,path,375,400),before=JSON.stringify(scene.views);
const hint=hintGeometry(scene,path,route.slice(0,4));
assert.equal(JSON.stringify(scene.views),before);assert.equal(hint.visitedShapes.length,path.length);
for(const view of scene.views){const c=level.cells.find(c=>c.id===view.cell);assert.equal(view.fill,c.category==='circle'?C.categoryBlueFace:C.categoryOrangeFace);}
}
});
test('five font subsets match their manifest and fit the package budget',()=>{
const manifest=JSON.parse(fs.readFileSync(new URL('./fonts/manifest.json',import.meta.url)));
assert.equal(manifest.outputs.length,5);
for(const item of manifest.outputs){const bytes=fs.readFileSync(new URL('./fonts/'+item.file,import.meta.url));assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'),item.sha256);assert.equal(bytes.length,item.bytes);}
assert.ok(manifest.outputs.reduce((n,o)=>n+o.bytes,0)<500000);
});
test('upstream adapter fails closed if the inspected render seams change',()=>{
assert.throws(()=>applyBrightClassroom('function draw(){}'),/seam changed/);
assert.throws(()=>applyBrightClassroom('function draw(){foo()} function roundedRect(){}'),/writeback seam changed/);
});
test('procedural cushion pixels preserve holes and light the upper bevel without a border',()=>{
const before=globalThis.wx,images=[];
globalThis.wx={createCanvas:()=>({width:0,height:0,getContext:()=>new Proxy({createImageData:(w,h)=>({width:w,height:h,data:new Uint8ClampedArray(w*h*4)}),putImageData:image=>images.push(image)},{get:(o,k)=>o[k]??(()=>{})})})};
try{
const outer=[[0,0],[61,0],[61,61],[0,61]],hole=[[20,20],[20,41],[41,41],[41,20]],original=JSON.stringify([outer,hole]);
assert.ok(cushion(context(),[outer,hole],5,{face:'#8178F4',depthColor:'#5750BA',depth:5,bevel:6,shadow:3,resolution:1}));
const image=images.at(-1),pixel=(x,y)=>Array.from(image.data.slice((y*image.width+x)*4,(y*image.width+x)*4+4));
assert.equal(pixel(31,31)[3],0,'hole must remain transparent');
assert.equal(pixel(10,10)[3],255,'occupied face must remain opaque');
assert.ok(pixel(10,2)[0]>pixel(10,59)[0],'upper bevel must be lighter than lower bevel');
assert.equal(JSON.stringify([outer,hole]),original,'material must not mutate logical rings');
}finally{if(before===undefined)delete globalThis.wx;else globalThis.wx=before;}
});
test('visible glyph bounds are centered independently of font ascent and side bearings',()=>{
let draw;
const ctx={measureText:()=>({width:28,actualBoundingBoxLeft:2,actualBoundingBoxRight:20,actualBoundingBoxAscent:30,actualBoundingBoxDescent:2}),fillText:(value,x,y)=>draw={value,x,y}};
label(ctx,'1',100,200,48,C.onPrimary,800,true);
assert.equal(draw.x+(20-2)/2,100);
assert.equal(draw.y+(2-30)/2,200);
const level=pack.levels.find(l=>l.id===pack.playOrder[3]),scene=buildGeometry(level,375,450),big=scene.cells.find(g=>g.cell.w===2&&g.cell.h===2);
assert.notDeepEqual(big.labelAnchor,big.center,'reference large-block label is separate from logical input center');
const first=big.parts[0];assert.ok(inside(big.labelAnchor,first));
});
test('stage with zero depth casts alpha shadows without painting a solid backing',()=>{
const before=globalThis.wx,events=[];
globalThis.wx={createCanvas:()=>({width:0,height:0,getContext:()=>new Proxy({createImageData:(w,h)=>({width:w,height:h,data:new Uint8ClampedArray(w*h*4)}),fill:()=>events.push('solid'),drawImage:()=>events.push('image')},{get:(o,k)=>o[k]??(()=>{})})})};
try{
assert.ok(cushion(context(),[[[0,0],[62,0],[62,80],[0,80]]],8,{face:C.stage,depthColor:'#E9D5A9',depth:0,bevel:6,shadow:12,shadowY:4,resolution:1}));
assert.equal(events.filter(e=>e==='solid').length,0,'no duplicated yellow slab below the stage');
assert.ok(events.filter(e=>e==='image').length>=2,'face alpha is reused for a soft cast shadow');
}finally{if(before===undefined)delete globalThis.wx;else globalThis.wx=before;}
});
test('native width-only TextMetrics still centers the rasterized glyph ink',()=>{
const before=globalThis.wx;let draw;
globalThis.wx={createCanvas(){
const canvas={width:0,height:0};let originX=0,baseline=0;
canvas.getContext=()=>({setTransform(){},fillText(text,x,y){originX=x;baseline=y;},getImageData(){
const data=new Uint8ClampedArray(canvas.width*canvas.height*4);
for(let y=(baseline-18)*2;y<(baseline-2)*2;y++)for(let x=(originX+2)*2;x<(originX+10)*2;x++)data[(y*canvas.width+x)*4+3]=255;
return {data};
}});return canvas;
}};
try{
const ctx={measureText:()=>({width:25}),fillText:(text,x,y)=>draw={x,y}};
label(ctx,'8',100,200,29,C.ink,800,true);
assert.equal(draw.x+6,100);assert.equal(draw.y-10,200);
}finally{if(before===undefined)delete globalThis.wx;else globalThis.wx=before;}
});
test('fixed channels remain anchored at their block centers after number enlargement',()=>{
for(const level of pack.levels.filter(l=>l.cells.some(c=>c.direction))){
const path=solve(level)[0],scene=renderBrightBoard(context(),level,path,320,300);
for(const channel of scene.channels)assert.deepEqual(channel.center,scene.byId.get(channel.cell).center,level.id+':'+channel.cell);
}
});
test('checkpoint ribbons trigger on correct arrival only and never duplicate per frame',()=>{
const level=pack.levels.find(l=>l.cells.some(c=>c.clue>1&&c.clue<l.cells.length)),route=solve(level)[0],n=level.cells.find(c=>c.clue>1&&c.clue<level.cells.length).clue,ctx=context();
renderBrightBoard(ctx,level,route.slice(0,n-1),375,400,{now:1000});
let scene=renderBrightBoard(ctx,level,route.slice(0,n),375,400,{now:1100});
assert.equal(scene.bursts.length,1);assert.equal(scene.animating,true);
scene=renderBrightBoard(ctx,level,route.slice(0,n),375,400,{now:1200});assert.equal(scene.bursts.length,1);
scene=renderBrightBoard(ctx,level,route.slice(0,n),375,400,{now:2800});assert.equal(scene.bursts.length,0);
const wrong=context();renderBrightBoard(wrong,level,[],375,400,{now:1000});
scene=renderBrightBoard(wrong,level,[route[n-1]],375,400,{now:1100});assert.equal(scene.bursts.length,0);
});
test('checkpoint pulse is small, centered in time and settles without looping',async()=>{
const {checkpointScale}=await import('./src/bright-classroom/board-renderer.mjs');
assert.equal(checkpointScale(0),1);assert.equal(checkpointScale(180),1.035);assert.equal(checkpointScale(360),1);assert.equal(checkpointScale(1000),1);assert.equal(checkpointScale(180,true),1);
});
+67
View File
@@ -0,0 +1,67 @@
"""Offline font subset builder. Sources and fonttools are build-only, not shipped.
Run with fonttools on PYTHONPATH (e.g. .font-tools), from this directory.
Source downloads: https://github.com/google/fonts/tree/main/ofl/{nunito,notosanssc}
"""
from pathlib import Path
from hashlib import sha256
import json
from fontTools.ttLib import TTFont
from fontTools import subset
from fontTools.varLib.instancer import instantiateVariableFont
root = Path(__file__).resolve().parent
corpus = ''.join(p.read_text(encoding='utf-8') for p in (root / 'src').rglob('*')
if p.suffix in ('.mjs', '.json'))
chars = ''.join(sorted(set(corpus + ''.join(chr(i) for i in range(32, 127)) + '✓→…!:·')))
manifest = {'generator': 'fonttools', 'sources': [], 'outputs': [], 'characters': chars}
for family, weights in [('notosanssc', [500, 600, 700]), ('nunito', [800])]:
source = root / '.font-sources' / (family + '.ttf')
manifest['sources'].append({'family': family, 'sha256': sha256(source.read_bytes()).hexdigest(),
'url': 'https://github.com/google/fonts/tree/main/ofl/' + family})
for weight in weights:
font = TTFont(source)
options = subset.Options()
options.name_IDs = ['*']
sub = subset.Subsetter(options=options)
text = chars if family == 'notosanssc' else '0123456789'
sub.populate(text=text)
sub.subset(font)
font = instantiateVariableFont(font, {'wght': weight}, inplace=True)
name = ('NumberSwipeSans' if family == 'notosanssc' else 'NumberSwipeDigits') + str(weight)
for record in font['name'].names:
if record.nameID in (1, 2, 3, 4, 6, 16, 17):
value = 'Regular' if record.nameID in (2, 17) else name
record.string = value.encode(record.getEncoding(), errors='replace')
target = root / 'fonts' / (name + '.ttf')
font.save(target)
cmap = font.getBestCmap()
missing = sorted({c for c in text if not c.isspace() and ord(c) not in cmap})
# Source fonts don't contain code symbols like private-use/non-printing chars;
# all player-visible Han characters and digits must be present.
critical = [c for c in missing if '\u4e00' <= c <= '\u9fff' or c.isdigit()]
if critical:
raise ValueError('Missing glyphs: ' + ''.join(critical))
manifest['outputs'].append({'file': target.name, 'weight': weight,
'sha256': sha256(target.read_bytes()).hexdigest(),
'bytes': target.stat().st_size, 'glyphs': len(cmap)})
print(target.name, target.stat().st_size)
source = root / '.font-sources' / 'zcoolkuaile.ttf'
font = TTFont(source)
options = subset.Options()
options.name_IDs = ['*']
sub = subset.Subsetter(options=options)
playful_chars = '重开撤销指路已走步还剩块第关0123456789 ·'
sub.populate(text=playful_chars)
sub.subset(font)
for record in font['name'].names:
if record.nameID in (1, 2, 3, 4, 6, 16, 17):
record.string = ('Regular' if record.nameID in (2, 17) else 'NumberSwipePlayful').encode(record.getEncoding(), errors='replace')
target = root / 'fonts' / 'NumberSwipePlayful.ttf'
font.save(target)
manifest['sources'].append({'family': 'zcoolkuaile', 'sha256': sha256(source.read_bytes()).hexdigest(),
'url': 'https://github.com/google/fonts/tree/main/ofl/zcoolkuaile'})
manifest['outputs'].append({'file': target.name, 'weight': 400, 'sha256': sha256(target.read_bytes()).hexdigest(),
'bytes': target.stat().st_size, 'glyphs': len(font.getBestCmap()), 'characters': playful_chars})
print(target.name, target.stat().st_size)
(root / 'fonts' / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
+4 -2
View File
@@ -1,4 +1,5 @@
import {buildAudio} from './audio-assets.mjs';
import {applyBrightClassroom} from './bright-classroom-build.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -16,7 +17,7 @@ await build({ absWorkingDir: root, entryPoints: ['src/main.mjs'], outfile: 'game
contents=contents.replace(" updateTutorial(bad);", " if(previewOnly)return;updateTutorial(bad);");
const hintHandler=contents.split('\n').find(line=>line.startsWith("$('hint').onclick="));
if(!hintHandler)throw Error('上游指路入口已变化');
contents=contents.replace(hintHandler, `$('hint').onclick=()=>{record.hints++;focus=[];const segment=segmentHints.request(level,solution,path);setStatus(segment?'沿半透明光路连接两端;走偏可撤销或重开,正确到达后提示消失。':'已经完成。');draw();};`);
contents=contents.replace(hintHandler, `$('hint').onclick=()=>{record.hints++;focus=[];const segment=segmentHints.request(level,solution,path);setStatus(segment?'沿引导带连接两端;走偏可撤销或重开,正确到达后提示消失。':'已经完成。');draw();};`);
contents=contents.replace("function start(i,open=true){", "function start(i,open=true){segmentHints.clear();");
contents=contents.replace("if(level.tutorial&&!bad.length&&!cleared)focus=", "if(false)focus=");
contents+=`\nexport function hintView(){const h=segmentHints.view(level,solution,path);return h?{...h,visitedPolygons:polygons.filter(p=>path.includes(p.id)).map(p=>p.p.map(v=>[...v]))}:null;}\n`;
@@ -32,8 +33,9 @@ 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=applyBrightClassroom(contents);
return { contents: "import {PACK,document,window,localStorage,exportRecords,performance} from '../platform.mjs';\n" + contents, loader: 'js' };
});
} }] });
console.log('Built campaign-200-r2: 200 levels');
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,59 @@
{
"generator": "fonttools",
"sources": [
{
"family": "notosanssc",
"sha256": "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
"url": "https://github.com/google/fonts/tree/main/ofl/notosanssc"
},
{
"family": "nunito",
"sha256": "bb55a5ca5c2042335b3991af27c4d0705d0ef41cac6164ac737fd8f2a1e85207",
"url": "https://github.com/google/fonts/tree/main/ofl/nunito"
},
{
"family": "zcoolkuaile",
"sha256": "812a6fc1fe54b6d73a419245c32dfeba8aa33104d5be90d1cf6af082007cb71d",
"url": "https://github.com/google/fonts/tree/main/ofl/zcoolkuaile"
}
],
"outputs": [
{
"file": "NumberSwipeSans500.ttf",
"weight": 500,
"sha256": "a02c66351825f2af3e15de47e332d0dff11c60854ce37b0c0c8e2ae3de52aca5",
"bytes": 121952,
"glyphs": 503
},
{
"file": "NumberSwipeSans600.ttf",
"weight": 600,
"sha256": "6bd45b2532ee4afed2ff8c1bbb43cac6cc8490983df8c7312a3b57aa53241a9f",
"bytes": 121940,
"glyphs": 503
},
{
"file": "NumberSwipeSans700.ttf",
"weight": 700,
"sha256": "cf4834799e8c9277902654f90ca9b86165eb8afcf62e8ce08a3b0c33f09eba35",
"bytes": 121972,
"glyphs": 503
},
{
"file": "NumberSwipeDigits800.ttf",
"weight": 800,
"sha256": "7ee176cbb7311f4f8e794fe660a7f58148479eba0528b9ec97563354b0360b79",
"bytes": 4652,
"glyphs": 10
},
{
"file": "NumberSwipePlayful.ttf",
"weight": 400,
"sha256": "a376431996b69449de38604a73d4fc7253092316bea8e9f1774b9ef768388c7b",
"bytes": 4832,
"glyphs": 26,
"characters": "重开撤销指路已走步还剩块第关0123456789 ·"
}
],
"characters": "\n !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~·×–“”…→✓、。一三上下不与且两个中为久么之也习了交亮从他以们会位住余作使例供依侧保信修候值偶允先入全六共关其具再冲准减几出分切划则刚别到前剩力加动包区单卡即原去又双反发变叠口另只可右号合同后向否启和哪唯喜器回园围固图在坐块域堵墙处备复多大太失奇好如始子字存学它完定察对导封小少尚就尽尾局峰工左差己已布并序应座开异式引弯当录形往径循微必态怎思恢恰想戏成或战所才打找把拍拐择拼持指按挑挡挨振换接推提撤播支收改放效教数整斜新方旁时是显替最有未束条来果枢标核格框桥楚横橙次欢正此步段每永求没沿法浏深混清渐游源滑点版状玩环现理生用画留略的盘直相看着知矩短确示离种积称移空穿突窗竖章端笔符第算类索红约纹纽线练终经绕继续综缺置者耗能脚自至色节花范荡落蓝行表被要观规视览角解计订认让记许设访评试该误请读调败资赖走起越趣跨路转载边达过近返还这进连退选逐通道那邻部都配里重链销错门闭问间随难需面音页顺须预频题颜馈验黄黑!,/:;?"
}
@@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2018 The ZCOOL KuaiLe Project Authors (https://github.com/googlefonts/zcool-kuaile)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because one or more lines are too long
+24
View File
@@ -47,3 +47,27 @@ test('parity is a segment anchor; colour is not; flow moves continuously across
drawHint(ctx,{route:['a','b','c']},{a:[0,0],b:[100,0],c:[100,100]},50,300);
assert.deepEqual(arcs.at(-3).slice(0,2),[50,0]);
});
test('coral teaching ribbon preserves visited masks and offers direction in reduced motion',()=>{
for(const reducedMotion of [false,true]){
const strokes=[],fills=[],stack=[];
const ctx=new Proxy({globalCompositeOperation:'source-over',save(){stack.push(this.globalCompositeOperation);},restore(){this.globalCompositeOperation=stack.pop();},stroke(){strokes.push({color:this.strokeStyle,mode:this.globalCompositeOperation});},fill(){fills.push(this.globalCompositeOperation);}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
const path=['a'];
drawHint(ctx,{route:['a','b'],guides:[{a:'a',b:'b',points:[[0,0],[100,0]]}],visitedShapes:[{rings:[[[0,0],[20,0],[20,20],[0,20]]],radius:3,depth:3,maskPad:4}],keepouts:[{x:40,y:0,w:12,h:15}],reducedMotion},{},50,300,path);
assert.ok(strokes.some(s=>s.color==='#F49B75'));
assert.ok(strokes.filter(s=>s.color==='#C96E48'&&s.mode==='source-over').length>=1,'directional chevron');
assert.ok(fills.includes('destination-out'));
assert.equal(ctx.globalCompositeOperation,'source-over');
assert.deepEqual(path,['a']);
}
});
test('hint rounds shared guide junctions and keeps separate runs disconnected',async()=>{
const {smoothHintRuns}=await import('./src/hints.mjs');
const guides=[{a:'a',b:'b',points:[[0,0],[50,0]]},{a:'b',b:'c',points:[[50,0],[50,50]]},{a:'e',b:'f',points:[[100,0],[150,0]]}];
const runs=smoothHintRuns(guides,10);
assert.equal(runs.length,2);assert.deepEqual(runs[0][0],[0,0]);assert.deepEqual(runs[0].at(-1),[50,50]);
assert.ok(!runs[0].some(p=>p[0]===50&&p[1]===0),'sharp junction removed');
assert.ok(runs[0].some(p=>p[0]>40&&p[0]<50&&p[1]>0&&p[1]<10),'rounded transition');
assert.deepEqual(runs[1],[[100,0],[150,0]]);
});
+3 -3
View File
@@ -1,6 +1,6 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {makeLesson,lessonFrame,lessonKeys} from './src/lessons.mjs';import {violations,doorLatched,doorOpen} from './src/upstream/rules.mjs';
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {makeLesson,lessonFrame,lessonKeys,loopLessonTime} from './src/lessons.mjs';import {violations,doorLatched,doorOpen} from './src/upstream/rules.mjs';
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));
for(const l of pack.levels.filter(l=>l.tutorial))test('实际教学关动画:'+l.tutorial,()=>{const lesson=makeLesson(pack,l.tutorial);assert.equal(lesson.level.id,l.id);assert.equal(violations(l,lesson.solution).length,0);for(let i=0;i<lesson.beats.length-1;i++){const frame=lessonFrame(lesson,(i+.5)*lesson.duration/(lesson.beats.length-1));assert.ok(frame.message.length);assert.ok(frame.route.every(id=>l.cells.some(c=>c.id===id)));if(violations(l,frame.route).length)assert.match(frame.message,/错误示例/);}if(['straight','vertical','corner','parity'].includes(l.tutorial))assert.ok(lesson.beats.some(b=>b.message?.includes('错误示例')));if(l.tutorial==='rhythm-door'){const states=lesson.beats.map(b=>[doorOpen(l.doors[0],b.route),doorLatched(l.doors[0],b.route)]);assert.ok(states.some(([open])=>!open));assert.ok(states.some(([,latched])=>latched));assert.equal(states.at(-1)[1],false);}});
test('200关的组合规则均能找到对应真实教学关',()=>{for(const l of pack.levels)for(const key of lessonKeys(l))assert.ok(makeLesson(pack,key));});
test('200关的组合规则均能找到对应真实教学关',()=>{for(const l of pack.levels)for(const key of lessonKeys(l))assert.ok(makeLesson(pack,key));});
test('整段演示一秒内连续完成,结束后停留并支持从头重播',()=>{for(const level of pack.levels.filter(l=>l.tutorial)){const lesson=makeLesson(pack,level.tutorial);assert.equal(lesson.duration,1000);for(let i=0;i<lesson.beats.length-1;i++){const time=(i+.4)*1000/(lesson.beats.length-1);const f=lessonFrame(lesson,time);assert.ok(Math.abs(f.progress-.4)<1e-8);assert.equal(f.finished,false);}const end=lessonFrame(lesson,1000);assert.equal(end.finished,true);assert.equal(end.progress,1);assert.deepEqual(lessonFrame(lesson,20000).route,end.route);assert.equal(lessonFrame(lesson,0).finished,false);}});
test('教学减速,结束停留1秒后自动循环',()=>{for(const level of pack.levels.filter(l=>l.tutorial)){const lesson=makeLesson(pack,level.tutorial);assert.ok(lesson.duration>=3600);for(let i=0;i<lesson.beats.length-1;i++){const time=(i+.4)*lesson.duration/(lesson.beats.length-1);const f=lessonFrame(lesson,time);assert.ok(Math.abs(f.progress-.4)<1e-8);assert.equal(f.finished,false);}const end=lessonFrame(lesson,lesson.duration);assert.equal(end.finished,true);assert.equal(end.progress,1);assert.deepEqual(lessonFrame(lesson,20000).route,end.route);assert.equal(lessonFrame(lesson,0).finished,false);assert.equal(loopLessonTime(lesson,lesson.duration+999),lesson.duration);assert.equal(loopLessonTime(lesson,lesson.duration+1000),0);}});
+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-高亮指路-保留玩家填色-20261020.zip'
$assets = @('game.js','game.json','project.config.json','audio') | 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') | ForEach-Object { Join-Path $PSScriptRoot $_ }
Compress-Archive -LiteralPath $assets -DestinationPath $archivePath -Force
Get-FileHash -LiteralPath $archivePath -Algorithm SHA256
+76 -71
View File
@@ -1,71 +1,76 @@
{
"setting": {
"es6": false,
"minified": false,
"urlCheck": false
},
"compileType": "game",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [
{
"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"
}
]
},
"isGameTourist": false,
"appid": "wx7a14d0d70b5dd13d",
"editorSetting": {},
"projectname": "NumberSwipe-campaign-200-r2",
"description": "数字一笔200关评分修订版,源自campaign-200-r2",
"miniprogramRoot": "./"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,56 @@
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>数字一笔 · 明亮教具室试玩</title>
<style>
@font-face{font-family:NumberSwipeSans500;src:url(../fonts/NumberSwipeSans500.ttf)}
@font-face{font-family:NumberSwipeSans600;src:url(../fonts/NumberSwipeSans600.ttf)}
@font-face{font-family:NumberSwipeSans700;src:url(../fonts/NumberSwipeSans700.ttf)}
@font-face{font-family:NumberSwipeDigits800;src:url(../fonts/NumberSwipeDigits800.ttf)}
@font-face{font-family:NumberSwipePlayful;src:url(../fonts/NumberSwipePlayful.ttf)}
*{box-sizing:border-box}body{margin:0;background:#fff6df;color:#43382f;font:15px NumberSwipeSans500,sans-serif}header{padding:28px 32px 20px;border-bottom:1px solid #e5d7ba}h1{margin:0 0 8px;font:25px NumberSwipeSans700}p{margin:8px 0;line-height:1.7;color:#71604f}main{display:flex;gap:32px;padding:28px 32px;align-items:flex-start}.tools{width:270px;position:sticky;top:24px}.tools h2{font-size:17px;margin:24px 0 10px}.tools button,select{font:14px NumberSwipeSans600;cursor:pointer;border:1px solid #d8c4a0;background:#fffbf2;color:#43382f;border-radius:12px;padding:12px;margin:0 6px 8px 0}.tools button:hover{border-color:#6552d9;color:#5140b7}.tools select{width:100%}.phone{border:1px solid #d8c4a0;border-radius:24px;overflow:hidden;box-shadow:0 12px 34px #43382f14;background:#ffe69a}canvas{display:block;touch-action:none}.note{font-size:13px}#status{background:#ece7ff;color:#5140b7;border-radius:12px;padding:12px;margin-top:16px}a{color:#5140b7}@media(max-width:750px){main{padding:16px;flex-direction:column}.tools{width:100%;position:static}.phone{align-self:center}header{padding:20px}}
</style>
<header><h1>数字一笔 · 明亮教具室</h1><p>200关实际游戏包的 Canvas 预览。全部画面由程序绘制,字体随包提供。</p></header>
<main><aside class="tools"><label for="size">屏幕尺寸</label><select id="size"><option>375×812</option><option>320×568</option><option>375×667</option><option>390×844</option><option>414×896</option></select><h2>真实关卡样张</h2><div id="samples"></div><h2>指定关卡</h2><select id="level"></select><button id="partial">画前几步</button><button id="hint">显示指路</button><button id="clear">完成本关</button><button id="select">返回选关</button><p class="note">在右侧直接点击或滑动。减少动态与音效开关在游戏选关页的“设置”中。辅助演示按钮仅属于此验收页。</p><div id="status">加载字体与游戏包…</div><p class="note" id="audio-status" role="status">音效:点击游戏后播放</p><p class="note">这是浏览器预览,微信手机上的字体、触摸与性能仍需真机验收。</p><p><a href="hints.html" target="_blank">运行200关提示像素检查</a></p></aside><div class="phone" id="phone"></div></main>
<script type="module">
import {solve} from '../src/upstream/rules.mjs';
import {browserAudio} from './browser-audio.mjs';
await Promise.all(['NumberSwipeSans500','NumberSwipeSans600','NumberSwipeSans700','NumberSwipeDigits800','NumberSwipePlayful'].map(f=>document.fonts.load('16px '+f)));
const source=await(await fetch('../game.js')).text(),pack=await(await fetch('../src/upstream/levels.json')).json();
const levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));
const size=document.querySelector('#size'),levelSelect=document.querySelector('#level'),status=document.querySelector('#status'),phone=document.querySelector('#phone');
let runtime;
for(const [i,l] of levels.entries()){const o=document.createElement('option');o.value=i;o.textContent=`第 ${i+1} 关 · ${l.title}`;levelSelect.append(o);}
const samples=[['基础大块',3],['异形拼块',levels.findIndex(l=>l.cells.some(c=>c.shape)&&l.cells.length>=12)],['通道与数字',levels.findIndex(l=>l.features.includes('direction')&&l.cells.some(c=>c.clue&&c.direction))],['双色与奇偶',levels.findIndex(l=>l.features.includes('alternation')&&l.features.includes('parity'))],['节奏门',levels.findIndex(l=>l.doors?.length)],['六边墙',levels.findIndex(l=>l.geometry==='hex'&&l.walls?.length)],['密集36块',levels.findIndex(l=>l.id==='r2-163')]];
for(const [name,index] of samples){const b=document.createElement('button');b.textContent=name;b.onclick=()=>show(index);document.querySelector('#samples').append(b);}
function boot(){
const [width,height]=size.value.split('×').map(Number),handlers={},frames=new Map(),canvases=[],store=new Map();let frame=0,now=100000;
const timers=new Set(),sound=browserAudio(message=>{document.querySelector('#audio-status').textContent=message;});
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={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'}};
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);
function tick(ms=16){now+=ms;const entry=frames.entries().next().value;if(entry){frames.delete(entry[0]);entry[1]();}}
function touch(kind,x,y){handlers[kind]({changedTouches:[{identifier:1,clientX:x,clientY:y}],touches:[{identifier:1,clientX:x,clientY:y}]});}
function tap(x,y){touch('TouchStart',x,y);touch('TouchEnd',x,y);tick();}
function 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);}
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();};
canvas.onpointermove=e=>{if(down){touch('TouchMove',...coords(e));tick();}};
canvas.onpointerup=e=>{down=false;touch('TouchEnd',...coords(e));tick();};canvas.onpointercancel=e=>{down=false;touch('TouchCancel',...coords(e));tick();};
tick();return {tick,api,button,cell,select,dispose(){sound.dispose();for(const timer of timers)clearTimeout(timer);timers.clear();}};
}
function show(index){runtime?.dispose();runtime=boot();runtime.select(index);levelSelect.value=index;update();}
function update(){const s=runtime.api.snapshot();status.textContent=`第 ${s.displayOrder} 关 · ${s.path.length} / ${levels.find(l=>l.id===s.level).cells.length} 步 · ${s.theme}`;}
levelSelect.onchange=()=>show(Number(levelSelect.value));size.onchange=()=>show(Number(levelSelect.value));
document.querySelector('#partial').onclick=()=>{const l=levels.find(l=>l.id===runtime.api.snapshot().level);runtime.button('重开');for(const id of solve(l)[0].slice(0,Math.min(5,l.cells.length-1)))runtime.cell(id);runtime.tick(300);update();};
document.querySelector('#hint').onclick=()=>{runtime.button('指路');update();};
document.querySelector('#clear').onclick=()=>{const l=levels.find(l=>l.id===runtime.api.snapshot().level);runtime.button('重开');for(const id of solve(l)[0])runtime.cell(id);runtime.tick(600);update();};
document.querySelector('#select').onclick=()=>{runtime.button('选关');update();};
show(3);function pump(){runtime.tick();requestAnimationFrame(pump);}requestAnimationFrame(pump);
</script></html>
@@ -0,0 +1,31 @@
// Browser-only adapter for the packaged WeChat WAV sound effects.
export function browserAudio(report){
const players=new Set();let disposed=false,plays=0;
return {
setInnerAudioOption(){},
createInnerAudioContext(){
const player=new Audio();player.preload='auto';players.add(player);
let errorHandler=()=>{},playHandler=()=>{},generation=0;
const error=reason=>{
if(disposed||reason?.name==='AbortError')return;
report('音效播放失败:'+(reason?.message||'请检查页面声音权限'));
errorHandler({errMsg:reason?.message||'浏览器音频加载失败'});
};
player.addEventListener('error',()=>error(player.error));
return {
set src(path){player.src=new URL('../'+path,location.href).href;},
set volume(value){player.volume=value;},
set obeyMuteSwitch(value){},
onError(callback){errorHandler=callback;},
onPlay(callback){playHandler=callback;},
stop(){generation++;player.pause();if(player.readyState)player.currentTime=0;},
play(){
const current=generation;
player.play().then(()=>{if(!disposed&&current===generation){playHandler();report('音效已播放 · '+(++plays)+' 次');}})
.catch(reason=>{if(current===generation)error(reason);});
}
};
},
dispose(){disposed=true;for(const player of players){player.pause();player.removeAttribute('src');player.load();}players.clear();}
};
}
+14 -4
View File
@@ -1,15 +1,24 @@
<!doctype html><meta charset="utf-8"><title>指路与玩家填色像素回归</title>
<style>body{font:16px sans-serif;background:#eee}#views{display:flex;gap:12px}canvas{width:375px;height:812px;background:white}h1{font-size:20px}</style>
<style>
@font-face{font-family:NumberSwipeSans500;src:url(../fonts/NumberSwipeSans500.ttf)}
@font-face{font-family:NumberSwipeSans600;src:url(../fonts/NumberSwipeSans600.ttf)}
@font-face{font-family:NumberSwipeSans700;src:url(../fonts/NumberSwipeSans700.ttf)}
@font-face{font-family:NumberSwipeDigits800;src:url(../fonts/NumberSwipeDigits800.ttf)}
@font-face{font-family:NumberSwipePlayful;src:url(../fonts/NumberSwipePlayful.ttf)}
</style>
<h1>指路与玩家填色像素回归</h1><p id="result">检查中…</p><div id="views"></div>
<script type="module">
import {solve} from '../src/upstream/rules.mjs';
await Promise.all(['NumberSwipeSans500','NumberSwipeSans600','NumberSwipeSans700','NumberSwipeDigits800','NumberSwipePlayful'].map(f=>document.fonts.load('16px '+f)));
const pack=await(await fetch('../src/upstream/levels.json')).json();
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={
createCanvas(){const c=document.createElement('canvas');canvases.push(c);return c;},
loadFont:p=>p.split('/').at(-1).replace('.ttf',''),
createCanvas(){const c=document.createElement('canvas');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),
@@ -19,13 +28,13 @@ for(const n of ['TouchStart','TouchMove','TouchEnd','TouchCancel','Hide','Show',
const global={};
new Function('wx','GameGlobal','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout','Date',source)(wx,global,cb=>{frames.set(++frameId,cb);return frameId;},id=>frames.delete(id),()=>1,()=>{},Clock);
const api=global.numberSwipeR2;
const tick=(ms=16)=>{now+=ms;const [id,fn]=frames.entries().next().value;frames.delete(id);fn();};tick();
const tick=(ms=16)=>{now+=ms;const entry=frames.entries().next().value;if(!entry)return;const [id,fn]=entry;frames.delete(id);fn();};tick();
const 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 [x,y]=api.snapshot().centers[id];tap(x,y+174);};
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 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]+174+s.unit*.18),5,5).data);});};
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++){
@@ -38,6 +47,7 @@ try{
button('撤销');cell(route[n-1]);tick(300);
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));}
if(i===3){document.querySelector('#views').replaceChildren(base,capture());}
}
result.textContent=`PASS:${checked}/200 关,点击指路前后和撤销重画后,玩家已走格子的实际 Canvas 填色像素一致。左图无提示,右图开启高对比提示。`;
Binary file not shown.
Binary file not shown.
+17 -6
View File
@@ -19,19 +19,20 @@ 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)*(parseFloat(ctx.font)||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
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 [id, fn] = frames.entries().next().value; frames.delete(id);text.length=0;images.length=0; fn(); };
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 [x, y] = api.snapshot().centers[id]; tap(x, y + 174); };
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('选关'); const label = String(i + 1); let steps = 0; while (!api.buttons().some(b => b.label === label || b.label === label + ' ✓')) {
button('下一页');
@@ -40,6 +41,7 @@ function boot(storage = new Map(), width = 375, height = 812) {
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 };
}
const wallLesson=boot();wallLesson.select(4);wallLesson.button('规则');wallLesson.advance(1200);assert.equal(wallLesson.api.snapshot().lesson.finished,false);assert.ok(wallLesson.frames.size>0,'教学超过1.1秒仍需继续刷新');wallLesson.advance(wallLesson.api.snapshot().lesson.duration-1200+50);assert.equal(wallLesson.api.snapshot().lesson.finished,true,'墙壁教学必须播到完成');wallLesson.advance(900);assert.equal(wallLesson.api.snapshot().lesson.finished,true);wallLesson.advance(100);assert.equal(wallLesson.api.snapshot().lesson.finished,false,'停留1秒后自动重播');wallLesson.button('返回游戏');wallLesson.advance(6000);assert.equal(wallLesson.api.snapshot().lesson,null,'离开后停止教学');
const ui = boot(), rows = [];
for (let i = 0; i < 200; i++) {
const l = ordered[i];
@@ -69,7 +71,8 @@ for (let i = 0; i < 200; i++) {
assert.ok(hinted?.route.length>=2);
assert.equal(ui.api.snapshot().path.length,0);
for (const [step,id] of solution.entries()) {
ui.cell(id);
const playedBefore=ui.played.length;ui.cell(id);
if(step===solution.length-1)assert.deepEqual(ui.played.slice(playedBefore),['audio/complete.wav'],'最后一步只播放一次过关音,不叠加路径点音');
if(step===1)assert.equal(JSON.stringify(ui.api.snapshot().cellViews),originalFills,'点击指路前后玩家填色必须一致 '+l.id);
assert.equal(Boolean(ui.api.snapshot().hint),step<hinted.end,'到达段末后消失');
}
@@ -111,7 +114,15 @@ 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 result = {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 idle=boot();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);
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 };
fs.writeFileSync(path.join(root, 'validation.json'), JSON.stringify(result, null, 2));
console.log('PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。');
+3 -3
View File
@@ -21,6 +21,6 @@
"sha256": "7cecb4bae026919131325d86e970bb7bee55d4569d574af78032714e7bb1cf04"
}
],
"adaptationRevision": "2026-10-19-animated-lessons-one-second",
"notes": "上游四文件保持原样;微信宿主加载时替换音频实现,优化玩家可见文案,不改变200关布局、约束及顺序。"
}
"adaptationRevision": "2026-10-21-bright-classroom-shadow-glyph-polish-v3",
"notes": "上游四文件保持原样;区分棋块暖晕与底盘alpha投影,按字形定位数字,新增白字柔光与深字纹理、按钮显示字体;关卡、输入几何、规则、顺序、存档key不变。"
}
+6 -4
View File
@@ -1,16 +1,18 @@
// 微信原生短音频播放:资源随包提供,不依赖浏览器振荡器。
const instances=new Set();
export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:0};
const instances=new Set(),channelEvents=new WeakMap();
export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:0,completeRequestedAt:null,completeStartedAt:null};
export function stopAudio(){for(const instance of instances)instance.stop();}
export class CasualAudio{
constructor(host,storage,key){this.storage=storage;this.key=key;this.channels=[];this.cursor=0;this.timers=new Set();this.lastEvent=null;this.lastAt={};this.warned=false;try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}instances.add(this);}
unlock(){if(!this.enabled||!wx.createInnerAudioContext)return false;
try{wx.setInnerAudioOption?.({obeyMuteSwitch:false,mixWithOther:true});if(!this.channels.length)for(let i=0;i<4;i++){const a=wx.createInnerAudioContext();a.obeyMuteSwitch=false;a.volume=.85;a.onError?.(error=>this.error(error));this.channels.push(a);}return true;}catch(error){this.error(error);return false;}}
try{wx.setInnerAudioOption?.({obeyMuteSwitch:false,mixWithOther:true});if(!this.channels.length)for(let i=0;i<4;i++){const a=wx.createInnerAudioContext();a.obeyMuteSwitch=false;a.volume=.85;a.onError?.(error=>this.error(error));const event={kind:null};channelEvents.set(a,event);a.onPlay?.(()=>{if(event.kind==='complete')audioDiagnostics.completeStartedAt=Date.now();});this.channels.push(a);}return true;}catch(error){this.error(error);return false;}}
error(error){audioDiagnostics.lastError={errCode:error?.errCode||0,errMsg:error?.errMsg||String(error)};if(!this.warned){this.warned=true;wx.showToast?.({title:'音效加载失败,请重新进入游戏',icon:'none'});}}
stop(){for(const timer of this.timers)clearTimeout(timer);this.timers.clear();for(const a of this.channels){try{a.stop();}catch{}}}
toggle(){this.enabled=!this.enabled;try{this.storage.setItem(this.key,this.enabled?'on':'off');}catch{}if(this.enabled)this.play('correct');else this.stop();return this.enabled;}
play(kind,delay=0,step=1){if(!this.unlock())return;const now=Date.now();if(kind==='error'&&now-(this.lastAt.error||0)<180)return;this.lastAt[kind]=now;
if(kind==='restart')this.stop();const file=kind==='move'?'move-'+Math.max(1,Math.min(36,Math.floor(step))):kind;
const run=()=>{if(!this.enabled)return;const a=this.channels[this.cursor++%this.channels.length];try{a.stop();a.src='audio/'+file+'.wav';a.play();this.lastEvent=kind;audioDiagnostics.plays++;}catch(error){this.error(error);}};
const run=()=>{if(!this.enabled)return;const a=this.channels[this.cursor++%this.channels.length];try{a.stop();a.src='audio/'+file+'.wav';channelEvents.get(a).kind=kind;
if(kind==='complete'){audioDiagnostics.completeRequestedAt=Date.now();audioDiagnostics.completeStartedAt=typeof a.onPlay==='function'?null:Date.now();}
a.play();this.lastEvent=kind;audioDiagnostics.plays++;}catch(error){this.error(error);}};
if(delay>0){const timer=setTimeout(()=>{this.timers.delete(timer);run();},delay*1000);this.timers.add(timer);}else run();}
}
@@ -0,0 +1,258 @@
import {violations,sideBetween,doorOpen,doorLatched} from '../upstream/rules.mjs';
import {C,T,clamp,label,roundRect} from './theme.mjs';
import {buildGeometry,trace,edgeFor,distance,midpoint,interiorGuide} from './geometry.mjs';
import {cushion} from './cushion.mjs';
import {numberStamp} from './number-stamp.mjs';
const caches=new WeakMap();
export const preferences={reducedMotion:false};
const errorIds=bad=>new Set(bad.flatMap(b=>b.cells));
function appearance(cell,step,error){
const category=cell.category!==undefined;
return {
fill:category?(cell.category==='circle'?C.categoryBlueFace:C.categoryOrangeFace):error?C.danger:step?C.primary:C.cellFace,
depth:category||!step&&!error?C.cellDepth:error?C.dangerDepth:C.primaryDepth,
ink:!category&&(step||error)?C.onPrimary:C.ink,
category
};
}
function bridgePath(ctx,scene,edge){
const [a,b]=edge.segments[0],len=distance(a,b),[x,y]=edge.entry;
const nx=-(b[1]-a[1])/len,ny=(b[0]-a[0])/len;
ctx.beginPath();ctx.moveTo(x-nx*(scene.inset+1.5),y-ny*(scene.inset+1.5));ctx.lineTo(x+nx*(scene.inset+1.5),y+ny*(scene.inset+1.5));
ctx.lineCap='butt';ctx.lineWidth=Math.min(scene.pitch*T.layout.bridgeThicknessRatio,len*.68);
}
function drawBridges(ctx,scene,path,depth=false){
if(depth)return;
ctx.save();
for(let i=1;i<path.length;i++){
const edge=edgeFor(scene,path[i-1],path[i]);if(!edge)continue;
const [a,b]=edge.segments[0],len=distance(a,b),tx=(b[0]-a[0])/len,ty=(b[1]-a[1])/len,nx=-ty,ny=tx,[x,y]=edge.entry;
const halfWidth=Math.min(scene.pitch*T.referenceMatch.bridgeRatio,len*.6)/2;
const halfLength=scene.inset+1.2;
const gradient=ctx.createLinearGradient(x-tx*halfWidth,y-ty*halfWidth,x+tx*halfWidth,y+ty*halfWidth);
if(gradient?.addColorStop){
gradient.addColorStop(0,'#7D70EF');gradient.addColorStop(.12,'#9386F8');
gradient.addColorStop(.34,'#ABA0FF');gradient.addColorStop(.55,'#978BFA');
gradient.addColorStop(.80,C.primary);gradient.addColorStop(1,'#786BEC');
}
ctx.fillStyle=gradient?.addColorStop?gradient:C.primary;
// Side bevels only. Open ends sink into the faces and fade out before
// reaching the labels; no terminal cap, outline or extrusion seam.
const slices=24,step=halfLength*2/slices;
for(let k=0;k<slices;k++){
const t=-halfLength+k*step,mid=t+step/2;
ctx.globalAlpha=clamp(0,(halfLength-Math.abs(mid))/(halfLength-scene.inset),1);
ctx.beginPath();
ctx.moveTo(x-tx*halfWidth+nx*t,y-ty*halfWidth+ny*t);
ctx.lineTo(x+tx*halfWidth+nx*t,y+ty*halfWidth+ny*t);
ctx.lineTo(x+tx*halfWidth+nx*(t+step+.02),y+ty*halfWidth+ny*(t+step+.02));
ctx.lineTo(x-tx*halfWidth+nx*(t+step+.02),y-ty*halfWidth+ny*(t+step+.02));
ctx.closePath();ctx.fill();
}
ctx.globalAlpha=1;
}
ctx.restore();
}
function drawMaterial(ctx,g,view,scene,base=false){
const {depth,pitch}=scene,hex=scene.level.geometry==='hex';
if(base)return;
const face=scene.pitch<44&&scene.steps.has(g.cell.id)&&!view.category&&!view.error?T.color.primary:view.fill;
if(cushion(ctx,g.rings,g.radius,{face,depthColor:view.depth,depth,bevel:hex?clamp(2,pitch*.045,4):clamp(3,pitch*T.referenceMatch.bevelRatio,9),shadeStrength:hex?.45:1,shadow:hex?1.8:clamp(3,pitch*.055,6),shadowColor:hex?'rgba(112,76,35,.1035)':'rgba(112,76,35,.22)',shadowY:hex?.7:2,glow:hex?3:clamp(3,pitch*.065,7),glowColor:hex?'rgba(204,151,64,.24)':'rgba(204,151,64,.38)',shadeColor:face===C.primary?'#4F4AEC':face===C.cellFace?'#89602A':null}))return;
ctx.save();
if(base){
// Small translated contours approximate a contact shadow without per-frame blur.
ctx.globalAlpha=.055;trace(ctx,g.rings,g.radius,depth+2);ctx.fillStyle=C.ink;ctx.fill();
ctx.globalAlpha=1;trace(ctx,g.rings,g.radius,depth);ctx.fillStyle=view.depth;ctx.fill();
}else{
if(!g.paints)g.paints=new Map();
if(!g.paints.has(view.fill)){
const ys=g.rings.flat().map(p=>p[1]);
const gradient=ctx.createLinearGradient(0,Math.min(...ys),0,Math.max(...ys));
if(gradient&&typeof gradient.addColorStop==='function'){
const shift=d=>'#'+[1,3,5].map(i=>clamp(0,parseInt(view.fill.slice(i,i+2),16)+d,255).toString(16).padStart(2,'0')).join('');
gradient.addColorStop(0,shift(3));gradient.addColorStop(.25,view.fill);gradient.addColorStop(.75,view.fill);gradient.addColorStop(1,shift(-3));
g.paints.set(view.fill,gradient);
}else g.paints.set(view.fill,view.fill);
}
trace(ctx,g.rings,g.radius);ctx.fillStyle=g.paints.get(view.fill);ctx.fill();
// Light follows actual contour orientation; no white ring around the whole block.
ctx.save();trace(ctx,g.rings,g.radius);ctx.clip();
ctx.lineCap='round';ctx.lineWidth=T.material.bevelWidth;ctx.strokeStyle=scene.steps.has(g.cell.id)&&!view.category?C.primaryEdge:C.cellBevel;
for(const ring of g.rings)for(let i=0;i<ring.length;i++){
const a=ring[i],b=ring[(i+1)%ring.length],len=distance(a,b);
const nx=(b[1]-a[1])/len,ny=-(b[0]-a[0])/len;
if(nx+ny>=-.25)continue;
const trim=Math.min(g.radius,len*.3);
ctx.beginPath();ctx.moveTo(a[0]+(b[0]-a[0])*trim/len-nx*.7,a[1]+(b[1]-a[1])*trim/len-ny*.7);
ctx.lineTo(b[0]-(b[0]-a[0])*trim/len-nx*.7,b[1]-(b[1]-a[1])*trim/len-ny*.7);ctx.stroke();
}
ctx.restore();
}
ctx.restore();
}
function boxLabel(ctx,scene,value,x,y,size,color,weight=800,numeric=false){
ctx.save();
if(numeric)numberStamp(ctx,value,x,y,size,color);
else label(ctx,value,x,y,size,color,weight);
const w=ctx.measureText(String(value)).width;
ctx.restore();
scene.keepouts.push({x:x-w/2-3,y:y-size*.6-2,w:w+6,h:size*1.2+4});
}
function channelState(level,path,cell,errors){
const k=path.indexOf(cell.id);if(k<0)return 'idle';
if(k===0||errors.has(cell.id))return 'error';
const entry=sideBetween(level,cell.id,path[k-1]);
if(!cell.direction.includes(entry))return 'error';
if(k<path.length-1&&[entry,sideBetween(level,cell.id,path[k+1])].sort().join('')!==cell.direction)return 'error';
return 'correct';
}
function drawSymbols(ctx,scene,g,view,path){
const c=g.cell,[x,y]=g.labelAnchor,u=scene.pitch,step=scene.steps.get(c.id),value=step||c.clue;
const foreground=view.ink;
if(c.direction){
const cx=x,cy=y,radius=Math.min(u*.25,24),state=channelState(scene.level,path,c,scene.errors),vectors={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};
const [a,b]=c.direction.split('').map(s=>vectors[s]);
ctx.beginPath();ctx.moveTo(cx+a[0]*radius,cy+a[1]*radius);ctx.lineTo(cx,cy);ctx.lineTo(cx+b[0]*radius,cy+b[1]*radius);
ctx.lineCap='round';ctx.lineJoin='round';ctx.lineWidth=clamp(7,u*.17,10);ctx.strokeStyle=state==='error'?C.dangerSoft:step?C.primaryEdge:C.surfaceMuted;ctx.stroke();
ctx.lineWidth=clamp(3.5,u*.085,5);ctx.strokeStyle=state==='error'?C.danger:foreground;ctx.stroke();
scene.channels.push({cell:c.id,kind:c.direction,center:[cx,cy],radius,state});
scene.keepouts.push({x:cx-radius-4,y:cy-radius-4,w:radius*2+8,h:radius*2+8});
if(value){
const double=String(value).length>1,size=Math.min(u*(double?.30:.34),double?25:28),w=size*(double?1.35:.85),pad=Math.min(1.8,u*.018);
const bx=x+u*.21,by=y-u*.25;
roundRect(ctx,bx-w/2-pad,by-size*.55-pad,w+pad*2,size*1.1+pad*2,Math.min(4,u*.05));ctx.fillStyle=step&&!view.category?C.primaryPressed:C.surface;ctx.fill();
boxLabel(ctx,scene,value,bx,by,size,foreground,800,true);
}
}else{
const size=value?u*(value>=10?.44:.50):u*.42;
const yy=y+(!view.category&&c.parity&&value?-u*.10:0);
if(value)boxLabel(ctx,scene,value,x,yy,size,foreground,800,true);
if(c.parity)boxLabel(ctx,scene,c.parity==='odd'?'奇':'偶',x,value?y+u*.26:y,value?clamp(11,u*.20,13):size,foreground,700);
}
if(scene.errors.has(c.id)){
const bx=x-u*.32,by=y-u*.31;
ctx.beginPath();ctx.arc(bx,by,7,0,Math.PI*2);ctx.fillStyle=C.danger;ctx.fill();
boxLabel(ctx,scene,'!',bx,by,11,C.onPrimary,800);
}
}
function doorOpenness(record,now){
if(!record)return 0;
const t=preferences.reducedMotion?1:clamp(0,(now-record.started)/T.motionMs.door,1);
return record.from+(record.to-record.from)*(1-(1-t)**3);
}
export function drawBarriers(ctx,scene,path,now=Date.now()){
ctx.save();
const barrier=(ids,kind)=>{
const edge=edgeFor(scene,...ids);if(!edge)return;
const open=kind==='open'||kind==='latched';
for(const [a,b] of edge.segments){
const len=distance(a,b),point=t=>[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t];
ctx.lineCap='butt';ctx.lineWidth=clamp(5,scene.pitch*.13,8);ctx.strokeStyle=kind==='wall'?C.wallInk:open?C.doorOpen:C.doorClosed;
ctx.beginPath();
const record=scene.doorTransitions?.get(ids.join('|'));
const gap=kind==='wall'?0:doorOpenness(record,now)*.5;
if(gap>0){ctx.moveTo(...point(.06));ctx.lineTo(...point(.5-gap/2));ctx.moveTo(...point(.5+gap/2));ctx.lineTo(...point(.94));}
else{ctx.moveTo(...point(.06));ctx.lineTo(...point(.94));}ctx.stroke();
if(kind==='wall'){
// Individual short diagonal hatches keep the underlying band continuous.
const nx=-(b[1]-a[1])/len,ny=(b[0]-a[0])/len,tx=(b[0]-a[0])/len,ty=(b[1]-a[1])/len;
ctx.strokeStyle=C.wallStripe;ctx.lineWidth=2;
for(let s=7;s<len-5;s+=9){const [x,y]=point(s/len);ctx.beginPath();ctx.moveTo(x-nx*2-tx*1.4,y-ny*2-ty*1.4);ctx.lineTo(x+nx*2+tx*1.4,y+ny*2+ty*1.4);ctx.stroke();}
}else{
const [x,y]=midpoint(a,b),nx=-(b[1]-a[1])/len,ny=(b[0]-a[0])/len;
const title=kind==='latched'?'已过':open?'开':'关';
const bx=x+nx*11,by=y+ny*11;
roundRect(ctx,bx-(title.length===2?13:8),by-8,title.length===2?26:16,16,4);ctx.fillStyle=C.stage;ctx.fill();
label(ctx,title,bx,by,11,open?C.doorOpen:C.doorClosed,700);
}
}
};
for(const edge of scene.level.walls||[])barrier(edge,'wall');
for(const d of scene.level.doors||[])barrier(d.edge,doorLatched(d,path)?'latched':doorOpen(d,path)?'open':'closed');
ctx.restore();
}
export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),preview=false}={}){
let cached=caches.get(ctx);
if(!cached||cached.level!==level||cached.width!==width||cached.height!==height){
cached={...buildGeometry(level,width,height),level,width,height,pathKey:null,changedAt:now};caches.set(ctx,cached);
}
const scene=cached,pathKey=path.join('|');
if(!scene.bursts)scene.bursts=[];
const previous=scene.lastPath||[];
if(!preview&&!preferences.reducedMotion&&path.length===previous.length+1&&previous.every((id,i)=>id===path[i])){
const arrived=scene.byId.get(path.at(-1));
if(arrived?.cell.clue>1&&arrived.cell.clue===path.length&&!violations(level,path).length)
scene.bursts.push({cell:arrived.cell.id,point:arrived.center,started:now,seed:path.length});
}
scene.lastPath=[...path];
scene.bursts=scene.bursts.filter(b=>now-b.started<1500);
if(pathKey!==scene.pathKey){scene.pathKey=pathKey;scene.changedAt=now;}
if(!scene.doorTransitions)scene.doorTransitions=new Map();
for(const door of level.doors||[]){
const key=door.edge.join('|'),to=doorOpen(door,path)?1:0,old=scene.doorTransitions.get(key);
if(!old)scene.doorTransitions.set(key,{from:to,to,started:now});
else if(old.to!==to)scene.doorTransitions.set(key,{from:doorOpenness(old,now),to,started:now});
}
scene.depth=level.geometry==='hex'?clamp(1.2,scene.pitch*.028,2.2):clamp(3,scene.pitch*T.referenceMatch.cellDepthRatio,T.referenceMatch.cellDepthMax);
scene.steps=new Map(path.map((id,i)=>[id,i+1]));scene.errors=errorIds(violations(level,path));scene.keepouts=[];scene.channels=[];
scene.views=scene.cells.map(g=>({cell:g.cell.id,...appearance(g.cell,scene.steps.get(g.cell.id),scene.errors.has(g.cell.id)),error:scene.errors.has(g.cell.id),category:g.cell.category||null,cellUnit:scene.pitch}));
ctx.clearRect(0,0,width,height);ctx.save();
for(let i=0;i<scene.cells.length;i++)drawMaterial(ctx,scene.cells[i],scene.views[i],scene,true);
drawBridges(ctx,scene,path,true);
for(let i=0;i<scene.cells.length;i++)withCheckpointScale(ctx,scene,scene.cells[i],now,()=>drawMaterial(ctx,scene.cells[i],scene.views[i],scene));
drawBridges(ctx,scene,path);
for(let i=0;i<scene.cells.length;i++)withCheckpointScale(ctx,scene,scene.cells[i],now,()=>drawSymbols(ctx,scene,scene.cells[i],scene.views[i],path));
drawCheckpointRibbons(ctx,scene,now);
drawBarriers(ctx,scene,path,now);ctx.restore();
scene.animating=!preview&&!preferences.reducedMotion&&(scene.bursts.length>0||[...scene.doorTransitions.values()].some(d=>d.from!==d.to&&now-d.started<T.motionMs.door));
return scene;
}
export function hintGeometry(scene,path,route){
if(!scene)return {};
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};
}
function drawCheckpointRibbons(ctx,scene,now){
if(preferences.reducedMotion)return;
const colors=['#FF986B','#A093FF','#FFD45C','#B9DBB2','#FFF9E8'];
ctx.save();ctx.lineCap='round';
for(const burst of scene.bursts){
const t=Math.max(0,(now-burst.started)/1500),travel=1-(1-t)**2;
for(let i=0;i<22;i++){
const angle=(i/22)*Math.PI*2,span=scene.pitch*(.38+(i%5)*.12);
const x=burst.point[0]+Math.cos(angle)*span*travel,y=burst.point[1]+Math.sin(angle)*span*travel+scene.pitch*.16*t*t;
ctx.globalAlpha=Math.min(1,(1-t)*2.2);ctx.strokeStyle=colors[(i+burst.seed)%colors.length];ctx.lineWidth=Math.max(2,scene.pitch*.035);
ctx.beginPath();ctx.moveTo(x-2,y-3);ctx.quadraticCurveTo(x+Math.sin(i+t*8)*4,y,x+2,y+3);ctx.stroke();
}
}
ctx.restore();
}
export function checkpointScale(elapsed,reducedMotion=false){
if(reducedMotion||elapsed<=0||elapsed>=360)return 1;
return 1+.035*Math.sin(Math.PI*elapsed/360);
}
function withCheckpointScale(ctx,scene,g,now,paint){
const burst=[...scene.bursts].reverse().find(b=>b.cell===g.cell.id);
const scale=burst&&scene.steps.has(g.cell.id)?checkpointScale(now-burst.started,preferences.reducedMotion):1;
if(scale===1){paint();return;}
ctx.save();ctx.translate(...g.center);ctx.scale(scale,scale);ctx.translate(-g.center[0],-g.center[1]);paint();ctx.restore();
}
@@ -0,0 +1,34 @@
import {C,label,panel} from './theme.mjs';
export const CLEAR_DURATION=2600;
export function clearProgress(elapsed){return Math.max(0,Math.min(1,elapsed/CLEAR_DURATION));}
export function drawClearCelebration(ctx,width,boardY,boardHeight,elapsed){
const t=clearProgress(elapsed);if(t>=1)return;
const alpha=Math.min(1,elapsed/80,(CLEAR_DURATION-elapsed)/350),x=width/2,y=boardY+boardHeight*.45;
ctx.save();ctx.globalAlpha=alpha;
// One cream completion stamp; no full-screen curtain or input lock.
ctx.translate(x,y);const scale=.94+.06*(1-(1-Math.min(1,t/.25))**3);ctx.scale(scale,scale);
const colors=[C.coral,C.primary,'#F3C346','#B3DDAA','#FFF8E3'];
// Three radial paper-fireworks bloom across the board, then float down.
const blooms=[[0,-12,0],[width*.20,-boardHeight*.13,220],[-width*.20,-boardHeight*.09,430]];
for(let burst=0;burst<blooms.length;burst++){
const [ox,oy,delay]=blooms[burst],age=(elapsed-delay)/1800;
if(age<0||age>=1)continue;
const spread=1-(1-age)**3;
ctx.globalAlpha=alpha*Math.min(1,age/.07,(1-age)/.3);
for(let i=0;i<20;i++){
const angle=i/20*Math.PI*2+burst*.45;
const radius=width*(.19+(i%4)*.035)*spread;
const px=ox+Math.cos(angle)*radius,py=oy+Math.sin(angle)*radius+boardHeight*.24*age*age;
const length=8+(i%4)*3,sway=Math.sin(i*1.7+age*9)*5;
ctx.strokeStyle=colors[(i+burst)%colors.length];ctx.lineWidth=3.5+(i%3);
ctx.beginPath();ctx.moveTo(px-Math.cos(angle)*length,py-Math.sin(angle)*length);
ctx.quadraticCurveTo(px+sway,py-sway,px,py);ctx.stroke();
}
}
ctx.globalAlpha=alpha;
panel(ctx,-74,-40,148,80,25,C.surface,2);
ctx.beginPath();ctx.moveTo(-37,-2);ctx.lineTo(-30,5);ctx.lineTo(-17,-10);
ctx.strokeStyle=C.success;ctx.lineWidth=5;ctx.lineCap='round';ctx.lineJoin='round';ctx.stroke();
label(ctx,'完成!',25,0,24,C.ink,700);
ctx.restore();
}
@@ -0,0 +1,115 @@
import {trace} from './geometry.mjs';
// All pixels below are generated from geometry. No image/texture is loaded.
// Analytic rounded boxes and sampled Bezier contours share the same soft bevel.
const fields=new Map(),textures=new Map();
let fieldBytes=0,textureBytes=0;
const FIELD_LIMIT=10*1024*1024,TEXTURE_LIMIT=20*1024*1024;
const clamp=(v,a=0,b=1)=>Math.max(a,Math.min(b,v));
const rgb=hex=>[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16));
function remember(map,key,value,bytes,kind){
map.set(key,{value,bytes});
if(kind==='field')fieldBytes+=bytes;else textureBytes+=bytes;
while((kind==='field'?fieldBytes:textureBytes)>(kind==='field'?FIELD_LIMIT:TEXTURE_LIMIT)&&map.size>1){
const first=map.keys().next().value,old=map.get(first);map.delete(first);
if(kind==='field')fieldBytes-=old.bytes;else textureBytes-=old.bytes;
}
return value;
}
function read(map,key){const item=map.get(key);if(!item)return null;map.delete(key);map.set(key,item);return item.value;}
function flatten(rings,radius){
return rings.map(ring=>{
const points=[];
for(let i=0;i<ring.length;i++){
const a=ring[(i+ring.length-1)%ring.length],b=ring[i],c=ring[(i+1)%ring.length];
const d1=Math.hypot(a[0]-b[0],a[1]-b[1]),d2=Math.hypot(c[0]-b[0],c[1]-b[1]);
const cross=(b[0]-a[0])*(c[1]-b[1])-(b[1]-a[1])*(c[0]-b[0]);
const r=Math.min(cross>0?radius:radius/3,d1*.35,d2*.35);
const p=[b[0]+(a[0]-b[0])*r/d1,b[1]+(a[1]-b[1])*r/d1];
const q=[b[0]+(c[0]-b[0])*r/d2,b[1]+(c[1]-b[1])*r/d2];
for(let j=0;j<=6;j++){const t=j/6,v=1-t;points.push([v*v*p[0]+2*v*t*b[0]+t*t*q[0],v*v*p[1]+2*v*t*b[1]+t*t*q[1]]);}
}
return points;
});
}
function fieldFor(rings,radius,w,h,res,bevel,key){
const cached=read(fields,key);if(cached)return cached;
const iw=Math.ceil(w*res)+2,ih=Math.ceil(h*res)+2;
const data=new Float32Array(iw*ih*3);
const rect=rings.length===1&&rings[0].length===4&&rings[0].every((p,i)=>{const q=rings[0][(i+1)%4];return Math.abs(p[0]-q[0])<.001||Math.abs(p[1]-q[1])<.001;});
const lines=rect?[]:flatten(rings,radius).flatMap(p=>p.map((a,i)=>{const b=p[(i+1)%p.length],dx=b[0]-a[0],dy=b[1]-a[1];return {a,b,dx,dy,len:dx*dx+dy*dy};}));
for(let py=0;py<ih;py++)for(let px=0;px<iw;px++){
const x=(px-.5)/res,y=(py-.5)/res;let d,nx,ny;
if(rect){
const xx=x-w/2,yy=y-h/2,qx=Math.abs(xx)-(w/2-radius),qy=Math.abs(yy)-(h/2-radius);
const ax=Math.max(qx,0),ay=Math.max(qy,0),len=Math.hypot(ax,ay);
d=-(len+Math.min(Math.max(qx,qy),0)-radius);
if(qx>0&&qy>0){nx=-Math.sign(xx)*ax/len;ny=-Math.sign(yy)*ay/len;}
else if(qx>qy){nx=-Math.sign(xx);ny=0;}else{nx=0;ny=-Math.sign(yy);}
}else{
let min=Infinity,cx=0,cy=0,inside=false;
for(const s of lines){
if((s.a[1]>y)!==(s.b[1]>y)&&x<s.dx*(y-s.a[1])/s.dy+s.a[0])inside=!inside;
const t=s.len?clamp(((x-s.a[0])*s.dx+(y-s.a[1])*s.dy)/s.len):0;
const qx=s.a[0]+t*s.dx,qy=s.a[1]+t*s.dy,dist=(x-qx)**2+(y-qy)**2;
if(dist<min){min=dist;cx=qx;cy=qy;}
}
const len=Math.sqrt(min);d=inside?len:-len;
nx=(x-cx)/Math.max(.001,len)*(inside?1:-1);ny=(y-cy)/Math.max(.001,len)*(inside?1:-1);
}
const index=(py*iw+px)*3,t=clamp(d/bevel),edge=(1-t)**2;
const directional=nx*.55+ny*.75;
const rim=Math.exp(-(((d-.8)/1.1)**2))*Math.max(0,directional)*.20;
data[index]=clamp(d*res+.5);
data[index+1]=clamp(Math.max(0,directional)*edge*.32+rim);
data[index+2]=clamp(Math.max(0,-directional)*edge*.35+edge*.025);
}
return remember(fields,key,{data,iw,ih},data.byteLength,'field');
}
export function cushion(ctx,rings,radius,{face,depthColor,depth=4,bevel=5,shadow=5,resolution=2,glow=0,glowColor='rgba(202,145,55,.33)',shadowColor='rgba(112,76,35,.22)',shadowY=shadow*.4,shadeColor=null,shadeStrength=1}={}){
const points=rings.flat(),xmin=Math.min(...points.map(p=>p[0])),ymin=Math.min(...points.map(p=>p[1]));
const w=Math.max(...points.map(p=>p[0]))-xmin,h=Math.max(...points.map(p=>p[1]))-ymin;
const local=rings.map(r=>r.map(([x,y])=>[x-xmin,y-ymin]));
const shapeKey=JSON.stringify([local.map(r=>r.map(p=>p.map(n=>Math.round(n*100)/100))),Math.round(radius*100),resolution,bevel]);
const key=shapeKey+'|'+[face,depthColor,depth,shadow,glow,glowColor,shadowColor,shadowY,shadeColor,shadeStrength].join('|');
let texture=read(textures,key);
if(!texture){
if(typeof wx==='undefined'||!wx.createCanvas)return false;
const canvas=wx.createCanvas(),paint=canvas.getContext('2d');
const probe=paint.createImageData?.(1,1);
if(!probe?.data)return false;
const res=resolution,pad=Math.ceil(Math.max(shadow,glow)*2+Math.max(depth,shadowY)+2);
canvas.width=Math.ceil((w+pad*2)*res);canvas.height=Math.ceil((h+pad*2+depth)*res);
paint.setTransform(res,0,0,res,pad*res,pad*res);
const f=fieldFor(local,radius,w,h,res,bevel,shapeKey),image=paint.createImageData(f.iw,f.ih),base=rgb(face),shade=shadeColor?rgb(shadeColor):[0,0,0];
for(let i=0,j=0;i<f.data.length;i+=3,j+=4){
const a=f.data[i];if(a<=0)continue;
const light=f.data[i+1],dark=f.data[i+2]*shadeStrength;
// Stable, sub-pixel grain is generated, not sampled from a texture.
const grain=(((j*1664525+1013904223)>>>8)%17-8)*.055;
for(let c=0;c<3;c++)image.data[j+c]=clamp((base[c]+(shade[c]-base[c])*dark+(255-base[c])*light+grain)/255)*255;
image.data[j+3]=a*255;
}
const faceCanvas=wx.createCanvas();faceCanvas.width=f.iw;faceCanvas.height=f.ih;
faceCanvas.getContext('2d').putImageData(image,0,0);
const faceImage=()=>paint.drawImage(faceCanvas,-1/res,-1/res,f.iw/res,f.ih/res);
// Both shadows use the actual face alpha, not an extra colored silhouette.
// The stage requests depth=0, so nothing solid extends beneath its face.
if(glow>0){paint.shadowColor=glowColor;paint.shadowBlur=glow*res;paint.shadowOffsetY=0;faceImage();}
paint.shadowColor=shadowColor;paint.shadowBlur=shadow*res;paint.shadowOffsetY=shadowY*res;faceImage();
paint.shadowBlur=0;paint.shadowOffsetY=0;paint.shadowColor='rgba(0,0,0,0)';
if(depth>0){trace(paint,local,radius,depth);paint.fillStyle=depthColor;paint.fill();}
paint.drawImage(faceCanvas,-1/res,-1/res,f.iw/res,f.ih/res);
texture={canvas,pad,res};remember(textures,key,texture,canvas.width*canvas.height*4,'texture');
}
ctx.drawImage(texture.canvas,xmin-texture.pad,ymin-texture.pad,texture.canvas.width/texture.res,texture.canvas.height/texture.res);
return true;
}
export function cushionRect(ctx,x,y,w,h,r,options){
return cushion(ctx,[[[x,y],[x+w,y],[x+w,y+h],[x,y+h]]],r,options);
}
@@ -0,0 +1,140 @@
import {units} from '../upstream/rules.mjs';
import {T, clamp} from './theme.mjs';
const key = p => p.map(v => Math.round(v * 10000)).join(',');
const cross = (a, b, c) => (b[0]-a[0])*(c[1]-b[1])-(b[1]-a[1])*(c[0]-b[0]);
export const distance = (a,b) => Math.hypot(a[0]-b[0],a[1]-b[1]);
export const midpoint = (a,b) => [(a[0]+b[0])/2,(a[1]+b[1])/2];
// Keep every closed ring (including holes); shared unit edges never reach paint.
export function outlines(occupied) {
const cells = new Set(occupied.map(p=>p.join(','))), edges=[];
for(const [x,y] of occupied) for(const [dx,dy,a,b] of [
[0,-1,[x,y],[x+1,y]],[1,0,[x+1,y],[x+1,y+1]],
[0,1,[x+1,y+1],[x,y+1]],[-1,0,[x,y+1],[x,y]]
]) if(!cells.has([x+dx,y+dy].join(','))) edges.push({a,b,used:false});
const starts=new Map();
for(const e of edges){const k=key(e.a);if(!starts.has(k))starts.set(k,[]);starts.get(k).push(e);}
const rings=[];
for(const first of edges){
if(first.used)continue;
const points=[];let e=first;
do{
e.used=true;points.push(e.a);
if(key(e.b)===key(first.a))break;
const candidates=(starts.get(key(e.b))||[]).filter(n=>!n.used);
// At a corner contact follow the boundary of the same occupied region.
candidates.sort((a,b)=>cross(e.a,e.b,b.b)-cross(e.a,e.b,a.b));
e=candidates[0];if(!e)throw Error('Open block boundary');
if(points.length>edges.length)throw Error('Invalid block boundary');
}while(true);
rings.push(points.filter((p,i)=>Math.abs(cross(points[(i+points.length-1)%points.length],p,points[(i+1)%points.length]))>1e-8));
}
return rings;
}
export function inside(p, ring) {
let hit=false;
for(let i=0,j=ring.length-1;i<ring.length;j=i++){
const [x,y]=ring[i],[a,b]=ring[j];
if((y>p[1])!==(b>p[1])&&p[0]<(a-x)*(p[1]-y)/(b-y)+x)hit=!hit;
}
return hit;
}
export function insetRing(ring, amount) {
return ring.map((b,i)=>{
const a=ring[(i+ring.length-1)%ring.length],c=ring[(i+1)%ring.length];
const l1=distance(a,b),l2=distance(b,c);
const n1=[-(b[1]-a[1])/l1,(b[0]-a[0])/l1],n2=[-(c[1]-b[1])/l2,(c[0]-b[0])/l2];
const denom=1+n1[0]*n2[0]+n1[1]*n2[1];
return [b[0]+amount*(n1[0]+n2[0])/denom,b[1]+amount*(n1[1]+n2[1])/denom];
});
}
// Both filling and masking replay this exact path, without a Path2D dependency.
export function trace(ctx, rings, radius, dy=0) {
ctx.beginPath();
for(const ring of rings){
for(let i=0;i<ring.length;i++){
const a=ring[(i+ring.length-1)%ring.length],b=ring[i],c=ring[(i+1)%ring.length];
const r=Math.min(cross(a,b,c)>0?radius:radius/3,distance(a,b)*.35,distance(b,c)*.35);
const towards=p=>{const d=distance(p,b);return [b[0]+(p[0]-b[0])*r/d,b[1]+(p[1]-b[1])*r/d+dy];};
const before=towards(a),after=towards(c);
if(i===0)ctx.moveTo(...before);else ctx.lineTo(...before);
ctx.quadraticCurveTo(b[0],b[1]+dy,...after);
}
ctx.closePath();
}
}
export function sharedSegments(a,b) {
const result=[];
for(const p of a.parts)for(const q of b.parts)for(let i=0;i<p.length;i++)for(let j=0;j<q.length;j++){
const x=p[i],y=p[(i+1)%p.length],v=q[j],w=q[(j+1)%q.length];
if(distance(x,w)<.01&&distance(y,v)<.01)result.push([x,y]);
}
// Merge contiguous collinear segments; preserve separate entrances.
let merged=true;
while(merged){merged=false;outer:for(let i=0;i<result.length;i++)for(let j=i+1;j<result.length;j++){
const [a,b]=result[i],[c,d]=result[j];
if(Math.abs(cross(a,b,c))>.01||Math.abs(cross(a,b,d))>.01)continue;
if(distance(b,c)<.01){result[i]=[a,d];result.splice(j,1);merged=true;break outer;}
if(distance(d,a)<.01){result[i]=[c,b];result.splice(j,1);merged=true;break outer;}
}}
return result.sort((a,b)=>distance(...b)-distance(...a)||a[0][0]-b[0][0]||a[0][1]-b[0][1]);
}
export function buildGeometry(level,width,height) {
const hex=level.geometry==='hex',raw=[],bounds=[];
for(const cell of level.cells){
let parts,center,rings,occupied;
if(hex){
center=[1.5*cell.r,Math.sqrt(3)*(cell.q+cell.r/2)];
rings=[Array.from({length:6},(_,i)=>[center[0]+Math.cos(i*Math.PI/3),center[1]+Math.sin(i*Math.PI/3)])];parts=rings;
}else{
occupied=units(cell);parts=occupied.map(([x,y])=>[[x,y],[x+1,y],[x+1,y+1],[x,y+1]]);rings=outlines(occupied);
center=[occupied.reduce((s,p)=>s+p[0]+.5,0)/occupied.length,occupied.reduce((s,p)=>s+p[1]+.5,0)/occupied.length];
if(cell.shape)center=occupied.map(([x,y])=>[x+.5,y+.5]).sort((a,b)=>distance(a,center)-distance(b,center)||a[1]-b[1]||a[0]-b[0])[0];
}
raw.push({cell,parts,rings,center,occupied});bounds.push(...parts.flat());
}
const xmin=Math.min(...bounds.map(p=>p[0])),xmax=Math.max(...bounds.map(p=>p[0]));
const ymin=Math.min(...bounds.map(p=>p[1])),ymax=Math.max(...bounds.map(p=>p[1]));
const dense=(!hex&&xmax-xmin>=7)||(level.features||[]).length>=3;
const margin=T.referenceMatch.stageMargin,padding=dense?8:16;
const maxPitch=T.referenceMatch.pitchMax*Math.min(1,width/375);
const unit=Math.min(maxPitch/(hex?Math.sqrt(3):1),(width-2*(margin+padding))/(xmax-xmin),(height-2*(padding+8))/(ymax-ymin));
const ox=(width-(xmax-xmin)*unit)/2-xmin*unit,oy=(height-(ymax-ymin)*unit)/2-ymin*unit-2;
const transform=([x,y])=>[ox+x*unit,oy+y*unit];
const pitch=unit*(hex?Math.sqrt(3):1),inset=pitch*T.layout.tileInsetRatio;
const radius=hex?pitch*T.radius.hexMaxRatio:clamp(T.radius.cellMin,pitch*T.radius.cellRatio,T.radius.cellMax);
const cells=raw.map(g=>{
const large=!hex&&!g.cell.direction&&g.occupied.length>1;
const first=large?[...g.occupied].sort((a,b)=>a[1]-b[1]||a[0]-b[0])[0]:null;
const labelAnchor=first?transform([first[0]+.5,first[1]+.5]):transform(g.center);
return {...g,parts:g.parts.map(p=>p.map(transform)),rings:g.rings.map(p=>insetRing(p.map(transform),inset)),center:transform(g.center),labelAnchor,radius};
});
const byId=new Map(cells.map(c=>[c.cell.id,c])),edges=new Map();
for(let i=0;i<cells.length;i++)for(let j=i+1;j<cells.length;j++){
const segments=sharedSegments(cells[i],cells[j]);if(segments.length){const edge={a:cells[i].cell.id,b:cells[j].cell.id,segments,entry:midpoint(...segments[0])};edges.set([edge.a,edge.b].sort().join('|'),edge);}
}
return {cells,byId,edges,unit,pitch,inset,radius,dense,width,height,margin,centers:new Map(cells.map(g=>[g.cell.id,g.center])),polygons:cells.flatMap(g=>g.parts.map(p=>({id:g.cell.id,p}))),transform};
}
export function edgeFor(scene,a,b){return scene.edges.get([a,b].sort().join('|'));}
// A drawing guide inside an irregular block follows occupied units, never a hole.
export function interiorGuide(g,from,to,scene) {
if(!g.cell.shape)return [from,to];
const points=g.occupied.map(([x,y])=>scene.transform([x+.5,y+.5]));
const nearest=p=>points.reduce((best,v,i)=>distance(v,p)<distance(points[best],p)?i:best,0);
const start=nearest(from),end=nearest(to),queue=[start],prev=new Map([[start,null]]);
for(let k=0;k<queue.length&&!prev.has(end);k++){
const i=queue[k],a=g.occupied[i];
for(let j=0;j<points.length;j++){const b=g.occupied[j];if(Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])===1&&!prev.has(j)){prev.set(j,i);queue.push(j);}}
}
if(!prev.has(end))return [];
const route=[];for(let at=end;at!==null;at=prev.get(at))route.unshift(points[at]);
return [from,...route,to];
}
@@ -0,0 +1,53 @@
import {label,textFont,inkMetrics,C} from './theme.mjs';
const stamps=new Map();let bytes=0;
const LIMIT=4*1024*1024;
// Runtime glyphs, not pre-rendered resources. Ink bounds are centered, not em boxes.
export function numberStamp(ctx,value,x,y,size,color){
textFont(ctx,size,800,true);ctx.textAlign='left';ctx.textBaseline='alphabetic';
const text=String(value),m=inkMetrics(ctx,text,size),white=color===C.onPrimary;
if(!Number.isFinite(m.actualBoundingBoxAscent)||!Number.isFinite(m.actualBoundingBoxRight)||typeof wx==='undefined'){
ctx.save();if(white){ctx.shadowColor='rgba(255,255,255,.5)';ctx.shadowBlur=2;}
label(ctx,text,x,y,size,color,800,true);ctx.restore();return;
}
const key=[text,size.toFixed(3),color,ctx.font].join('|');
let stamp=stamps.get(key);
if(stamp){stamps.delete(key);stamps.set(key,stamp);}
else{
const res=2,w=m.actualBoundingBoxLeft+m.actualBoundingBoxRight,h=m.actualBoundingBoxAscent+m.actualBoundingBoxDescent;
const halo=white?Math.max(.8,Math.min(2.6,size*.055)):0,pad=Math.ceil(halo*3+3);
const face=wx.createCanvas();face.width=Math.ceil((w+2*pad)*res);face.height=Math.ceil((h+2*pad)*res);
const ink=face.getContext('2d');ink.setTransform(res,0,0,res,0,0);textFont(ink,size,800,true);ink.textAlign='left';ink.textBaseline='alphabetic';
const gradient=ink.createLinearGradient(0,pad,0,pad+h);
if(gradient&&typeof gradient.addColorStop==='function'){
gradient.addColorStop(0,white?'#FFFFFF':'#65583F');gradient.addColorStop(.48,white?'#FFFDFE':color);gradient.addColorStop(1,white?'#F0EEFF':'#4C402D');ink.fillStyle=gradient;
}else ink.fillStyle=color;
ink.fillText(text,pad+m.actualBoundingBoxLeft,pad+m.actualBoundingBoxAscent);
if(!white){
try{
const image=ink.getImageData(0,0,face.width,face.height);
if(image?.data){
for(let py=0;py<face.height;py++)for(let px=0;px<face.width;px++){
const i=(py*face.width+px)*4;if(!image.data[i+3])continue;
const noise=(((px*1973+py*9277+text.length*26699)^((px+py)*7919))>>>0)%17-8;
for(let c=0;c<3;c++)image.data[i+c]+=noise*.32;
}
ink.putImageData(image,0,0);
}
}catch{/* Native Canvas without readback retains the quiet ink gradient. */}
}
const canvas=wx.createCanvas();canvas.width=face.width;canvas.height=face.height;
const paint=canvas.getContext('2d');
if(white){
paint.shadowColor='rgba(61,43,146,.46)';paint.shadowBlur=1.2*res;paint.shadowOffsetY=.7*res;
paint.drawImage(face,0,0);
paint.shadowColor='rgba(255,255,255,.58)';paint.shadowBlur=halo*res;paint.shadowOffsetY=0;paint.drawImage(face,0,0);
paint.shadowColor='rgba(0,0,0,0)';paint.shadowBlur=0;
}
paint.drawImage(face,0,0);
stamp={canvas,pad,w,h,res};stamps.set(key,stamp);bytes+=canvas.width*canvas.height*4;
while(bytes>LIMIT&&stamps.size>1){const k=stamps.keys().next().value,old=stamps.get(k);stamps.delete(k);bytes-=old.canvas.width*old.canvas.height*4;}
}
ctx.drawImage(stamp.canvas,x-stamp.w/2-stamp.pad,y-stamp.h/2-stamp.pad,stamp.canvas.width/stamp.res,stamp.canvas.height/stamp.res);
}
@@ -0,0 +1,88 @@
import tokens from '../../../docs/design/bright-classroom/tokens.json' with {type:'json'};
import {cushionRect} from './cushion.mjs';
// The report's JSON is the only source of production art constants.
export const T = tokens;
export const C = {...tokens.color,...tokens.referenceMatch.color};
const fallback='"Microsoft YaHei", sans-serif';
export const fontDiagnostics={};
function localFont(name){
try{
const family=typeof wx!=='undefined'&&wx.loadFont?.('fonts/'+name+'.ttf');
fontDiagnostics[name]=family?'loaded':'fallback';
return family?'"'+family.replace(/"/g,'')+'", '+fallback:null;
}catch{fontDiagnostics[name]='fallback';return null;}
}
const faces={500:localFont('NumberSwipeSans500'),600:localFont('NumberSwipeSans600'),700:localFont('NumberSwipeSans700')};
export const FONT = faces[600]||fallback;
export const NUMBER_FONT = localFont('NumberSwipeDigits800')||FONT;
export const PLAYFUL_FONT = localFont('NumberSwipePlayful')||faces[700]||FONT;
export const clamp = (lo, v, hi) => Math.max(lo, Math.min(v, hi));
const metricCache=new Map();
export function inkMetrics(ctx,text,size){
const key=ctx.font+'|'+text;
if(metricCache.has(key))return metricCache.get(key);
let m=ctx.measureText(text);
if(!Number.isFinite(m.actualBoundingBoxAscent)||!Number.isFinite(m.actualBoundingBoxLeft)){
try{
// Some native Canvas hosts report only advance width. Measure the
// actual glyph alpha once instead of reverting to an em-box center.
if(typeof wx!=='undefined'&&wx.createCanvas){
const canvas=wx.createCanvas(),res=2,pad=Math.ceil(size),baseline=pad+size;
canvas.width=Math.ceil((m.width+2*pad)*res);canvas.height=Math.ceil(size*3*res);
const p=canvas.getContext('2d');p.setTransform(res,0,0,res,0,0);p.font=ctx.font;p.textAlign='left';p.textBaseline='alphabetic';p.fillStyle='#000';p.fillText(text,pad,baseline);
const image=p.getImageData(0,0,canvas.width,canvas.height);
if(image?.data){
let xmin=canvas.width,xmax=-1,ymin=canvas.height,ymax=-1;
for(let y=0;y<canvas.height;y++)for(let x=0;x<canvas.width;x++)if(image.data[(y*canvas.width+x)*4+3]>8){xmin=Math.min(xmin,x);xmax=Math.max(xmax,x);ymin=Math.min(ymin,y);ymax=Math.max(ymax,y);}
if(xmax>=xmin)m={width:m.width,actualBoundingBoxLeft:pad-xmin/res,actualBoundingBoxRight:(xmax+1)/res-pad,actualBoundingBoxAscent:baseline-ymin/res,actualBoundingBoxDescent:(ymax+1)/res-baseline};
}
}
}catch{/* Minimal hosts retain their ordinary text fallback. */}
}
metricCache.set(key,m);if(metricCache.size>512)metricCache.delete(metricCache.keys().next().value);
return m;
}
export function roundRect(ctx, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath();
}
export function textFont(ctx,size=16,weight=600,numeric=false){
// Subsets are static weights, avoiding fake-bold differences between hosts.
const family=numeric?NUMBER_FONT:(faces[weight>=700?700:weight<=500?500:600]||FONT);
ctx.font = `${numeric?'normal':faces[weight>=700?700:weight<=500?500:600]?'normal':weight} ${size}px ${family}`;
}
export function label(ctx, value, x, y, size = 16, color = C.ink, weight = 600, numeric = false) {
textFont(ctx,size,weight,numeric);
centeredText(ctx,value,x,y,color);
}
export function centeredText(ctx,value,x,y,color){
ctx.fillStyle=color;ctx.textAlign='left';ctx.textBaseline='alphabetic';
const s=String(value),m=inkMetrics(ctx,s,Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16);
if(Number.isFinite(m.actualBoundingBoxAscent)&&Number.isFinite(m.actualBoundingBoxDescent)&&Number.isFinite(m.actualBoundingBoxLeft)&&Number.isFinite(m.actualBoundingBoxRight)){
ctx.fillText(s,x+(m.actualBoundingBoxLeft-m.actualBoundingBoxRight)/2,y+(m.actualBoundingBoxAscent-m.actualBoundingBoxDescent)/2);
}else{ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(s,x,y);}
}
export function playfulLabel(ctx,value,x,y,size,color=C.ink){
ctx.font=`normal ${size}px ${PLAYFUL_FONT}`;centeredText(ctx,value,x,y,color);
}
export function panel(ctx, x, y, w, h, radius = 24, face = C.surface, depth = 3) {
const purple=face===C.primary||face===C.primaryPressed;
const stage=face===C.stage;
if(cushionRect(ctx,x,y,w,h,radius,{face,depthColor:purple?C.primaryDepth:face===C.coral?'#DF6634':C.cellDepth,depth:stage?0:depth,bevel:Math.min(8,h*.15),shadow:stage?12:Math.min(7,h*.10),shadowY:stage?4:2,shadowColor:stage?'rgba(116,77,28,.20)':'rgba(116,77,28,.18)',shadeColor:purple?'#4F4AEC':stage?'#B79B6C':face===C.coral?'#D54C24':'#89602A',shadeStrength:stage?.35:1}))return;
ctx.save();
if(!stage){roundRect(ctx, x, y + depth, w, h, radius); ctx.fillStyle = C.cellDepth; ctx.fill();}
roundRect(ctx, x, y, w, h, radius); ctx.fillStyle = face; ctx.fill();
ctx.restore();
}
@@ -0,0 +1,83 @@
import {C,T,label,panel,roundRect,textFont,playfulLabel} from './theme.mjs';
export function background(ctx,w,h,quiet=false){
const gradient=ctx.createLinearGradient(0,0,w,h);
if(gradient&&typeof gradient.addColorStop==='function'){
gradient.addColorStop(0,'#FFE898');gradient.addColorStop(.45,C.background);gradient.addColorStop(1,C.backgroundBottom);ctx.fillStyle=gradient;
}else ctx.fillStyle=C.background;
ctx.fillRect(0,0,w,h);
if(quiet)return;
ctx.save();ctx.strokeStyle=C.pattern;ctx.globalAlpha=.30;ctx.lineWidth=3;ctx.lineJoin='round';
ctx.beginPath();ctx.arc(8,h*.18,37,0,Math.PI*2);ctx.stroke();
ctx.beginPath();ctx.moveTo(w-24,h*.10);ctx.lineTo(w+9,h*.18);ctx.lineTo(w-56,h*.16);ctx.closePath();ctx.stroke();
ctx.save();ctx.translate(8,h*.73);ctx.rotate(-.3);roundRect(ctx,-20,-20,44,44,8);ctx.stroke();ctx.restore();
ctx.beginPath();ctx.arc(w+2,h*.87,27,0,Math.PI*1.65);ctx.stroke();ctx.restore();
}
export function icon(ctx,name,x,y,size=24,color=C.ink,light=C.hint){
ctx.save();ctx.translate(x,y);ctx.scale(size/24,size/24);ctx.strokeStyle=color;ctx.fillStyle=color;ctx.lineWidth=2.3;ctx.lineCap='round';ctx.lineJoin='round';ctx.beginPath();
if(name==='重开'){
ctx.arc(0,0,8,-Math.PI*.75,Math.PI*.85);ctx.stroke();ctx.beginPath();ctx.moveTo(-7,-8);ctx.lineTo(-7,-2);ctx.lineTo(-1,-3);ctx.stroke();
}else if(name==='撤销'){
ctx.moveTo(-9,-2);ctx.lineTo(-3,-8);ctx.moveTo(-9,-2);ctx.lineTo(-3,4);ctx.moveTo(-8,-2);ctx.lineTo(2,-2);ctx.quadraticCurveTo(10,-2,8,8);ctx.stroke();
}else if(name==='指路'){
ctx.arc(0,-3,6,Math.PI*.15,Math.PI*2.85);ctx.stroke();ctx.beginPath();ctx.moveTo(-3,3);ctx.lineTo(-3,7);ctx.lineTo(3,7);ctx.lineTo(3,3);ctx.moveTo(-2,10);ctx.lineTo(2,10);ctx.stroke();
ctx.strokeStyle=light;ctx.beginPath();for(const a of [-Math.PI,-Math.PI*.75,-Math.PI*.5,-Math.PI*.25,0]){ctx.moveTo(Math.cos(a)*9,-3+Math.sin(a)*9);ctx.lineTo(Math.cos(a)*12,-3+Math.sin(a)*12);}ctx.stroke();
}else if(name==='check'){
ctx.moveTo(-7,0);ctx.lineTo(-2,5);ctx.lineTo(8,-6);ctx.stroke();
}
ctx.restore();
}
function toyIcon(ctx,name,x,y,size,enabled){
ctx.save();ctx.translate(x,y);ctx.scale(size/28,size/28);if(!enabled)ctx.globalAlpha=.38;
ctx.lineCap='round';ctx.lineJoin='round';ctx.shadowColor='rgba(130,79,23,.22)';ctx.shadowBlur=1.5;ctx.shadowOffsetY=.8;
if(name==='指路'){
ctx.strokeStyle='#FFD74E';ctx.lineWidth=2;ctx.beginPath();
for(const a of [-3.05,-2.45,-1.80,-1.12,-.50,.15]){ctx.moveTo(Math.cos(a)*9.4,-2+Math.sin(a)*9.4);ctx.lineTo(Math.cos(a)*12.2,-2+Math.sin(a)*12.2);}ctx.stroke();
ctx.beginPath();ctx.arc(0,-2,6.8,0,Math.PI*2);ctx.fillStyle='#FFD34B';ctx.fill();
ctx.shadowBlur=0;ctx.strokeStyle='#F0B330';ctx.lineWidth=.75;ctx.stroke();
ctx.beginPath();ctx.arc(-.8,-2.8,4.4,Math.PI*1.05,Math.PI*1.65);ctx.strokeStyle='#FFF4BD';ctx.lineWidth=1.6;ctx.stroke();
roundRect(ctx,-3.2,3.5,6.4,4.8,1.3);ctx.fillStyle='#FFFAE4';ctx.fill();
ctx.beginPath();ctx.moveTo(-2.3,6);ctx.lineTo(2.3,6);ctx.strokeStyle='#D8D4C9';ctx.lineWidth=.8;ctx.stroke();
roundRect(ctx,-2.3,8,4.6,1.8,.8);ctx.fillStyle='#D5CEC5';ctx.fill();
}else if(name==='重开'){
const arc=()=>{ctx.beginPath();ctx.arc(0,0,7.1,-Math.PI*.28,Math.PI*1.25);};
arc();ctx.lineWidth=5.1;ctx.strokeStyle='#EB633A';ctx.stroke();
ctx.shadowBlur=0;ctx.save();ctx.translate(0,-.55);arc();ctx.lineWidth=4.3;ctx.strokeStyle='#FF8557';ctx.stroke();ctx.restore();
ctx.beginPath();ctx.moveTo(-4.8,-10.4);ctx.quadraticCurveTo(-5.6,-11,-6.4,-10.1);ctx.lineTo(-10,-5.8);ctx.quadraticCurveTo(-10.7,-4.8,-9.4,-4.3);ctx.lineTo(-3.7,-2.8);ctx.quadraticCurveTo(-2.6,-2.7,-2.8,-4);ctx.closePath();ctx.fillStyle='#FF8355';ctx.fill();
ctx.strokeStyle='#FFAA7A';ctx.lineWidth=.65;ctx.stroke();
}else if(name==='撤销'){
ctx.beginPath();ctx.moveTo(-10,-2);ctx.lineTo(-3,-8);ctx.quadraticCurveTo(-1.5,-9,-1.5,-6);ctx.lineTo(-1.5,-4);ctx.lineTo(2,-4);ctx.quadraticCurveTo(10,-4,10,4);ctx.lineTo(10,7);ctx.quadraticCurveTo(10,9,7.8,9);ctx.quadraticCurveTo(5.8,9,5.8,7);ctx.lineTo(5.8,5);ctx.quadraticCurveTo(5.8,1,2,1);ctx.lineTo(-1.5,1);ctx.lineTo(-1.5,3.5);ctx.quadraticCurveTo(-1.5,6,-3.5,4.4);ctx.closePath();ctx.fillStyle='#FFBE40';ctx.fill();
ctx.shadowBlur=0;ctx.strokeStyle='#F2A323';ctx.lineWidth=.8;ctx.stroke();
ctx.beginPath();ctx.moveTo(-7.7,-2.2);ctx.lineTo(-3.3,-6);ctx.strokeStyle='#FFE6A1';ctx.lineWidth=.8;ctx.stroke();
}
ctx.restore();
}
export function buttonArt(ctx,labelText,x,y,w,h,{enabled=true,primary=false,pressed=false}={}){
const r=Math.min(w*.24,h*T.referenceMatch.buttonRadiusRatio),dy=pressed?2:0;
ctx.save();
panel(ctx,x,y+dy,w,h-4,r,enabled?(primary?(pressed?C.primaryPressed:C.primary):C.surface):C.surfaceMuted,enabled?(pressed?2:5):3);
const color=enabled?(primary?C.onPrimary:C.ink):C.disabledInk;
if(['重开','撤销','指路'].includes(labelText)&&h>=50){
const opticalSize=labelText==='重开'?38:labelText==='撤销'?36:35;
toyIcon(ctx,labelText,x+w/2,y+h*.30+dy,opticalSize*(h>=64?1:.79),enabled);
ctx.shadowColor=primary?'rgba(255,255,255,.40)':'rgba(255,255,255,.6)';ctx.shadowOffsetY=primary?0:.6;ctx.shadowBlur=primary?2:.8;
playfulLabel(ctx,labelText,x+w/2,y+h*.72+dy,h>=64?18.5:16,color);
}else label(ctx,labelText,x+w/2,y+h/2-1+dy,primary?16:14,color,700);
ctx.restore();
}
export function stage(ctx,width,y,height,dense=false){
const margin=T.referenceMatch.stageMargin;
panel(ctx,margin,y,width-margin*2,height,dense?24:30,C.stage,dense?4:6);
}
export function statusPill(ctx,x,y,w,h,walked,remaining){
panel(ctx,x,y,w,h,h/2,C.surface,3);
const runs=[['已走 ',C.ink],[''+walked,C.primary],[' 步 · 还剩 ',C.ink],[''+remaining,C.primary],[' 块',C.ink]];
const size=w<240?13:15;textFont(ctx,size,700);
const widths=runs.map(([s])=>ctx.measureText(s).width),total=widths.reduce((a,b)=>a+b,0);let at=x+(w-total)/2;
runs.forEach(([s,c],i)=>{label(ctx,s,at+widths[i]/2,y+h/2-1,size,c,700);at+=widths[i];});
}
+100
View File
@@ -1,3 +1,6 @@
import {C} from './bright-classroom/theme.mjs';
import {trace,distance} from './bright-classroom/geometry.mjs';
export function hintSegment(level, solution, path) {
const cells=new Map(level.cells.map(c=>[c.id,c]));
const anchors=solution.flatMap((id,i)=>{const c=cells.get(id);return i===0||i===solution.length-1||c.clue!==undefined||c.direction||c.parity?[i]:[];});
@@ -25,6 +28,7 @@ export function createHintController(){
// Draw only on the dedicated transparent hint canvas, never on the player board.
export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
if(!hint||hint.route.length<2)return;
if(hint.guides){drawGeometryHint(ctx,hint,unit,elapsed,playerPath);return;}
const points=hint.route.map(id=>centers[id]);if(points.some(p=>!p))return;
const visited=new Set(playerPath);
const covered=hint.route.slice(1).map((b,i)=>{const a=hint.route[i];return playerPath.some((id,j)=>j>0&&((playerPath[j-1]===a&&id===b)||(playerPath[j-1]===b&&id===a)));});
@@ -56,3 +60,99 @@ export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
}
ctx.restore();
}
function drawGeometryHint(ctx,hint,unit,elapsed,path){
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));
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();}};
// Cream-backed coral teaching ribbon: soft toy material, no metallic rail.
ctx.shadowColor='rgba(148,99,55,.18)';ctx.shadowBlur=unit*.045;ctx.shadowOffsetY=1.2;
draw(C.hintRibbonLight,width+5);ctx.shadowBlur=0;ctx.shadowOffsetY=0;
draw(C.hintRibbon,width);
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;
// 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);
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;
}
}
}
ctx.globalAlpha=1;ctx.shadowBlur=0;
}
if(hint.reducedMotion)for(const g of guides){
const b=g.points.at(-1),a=g.points.at(-2);if(a&&b)hintArrow(ctx,a,b,(a[0]+b[0])/2,(a[1]+b[1])/2,width);
}
// Fade the entire ribbon, highlight and moving light before painting the
// endpoint sticker. The line disappears at its inner edge, not at the
// rectangular typography keepout, so no hard stub protrudes past it.
ctx.save();ctx.globalCompositeOperation='destination-out';
for(const point of [runs[0]?.[0],runs.at(-1)?.at(-1)]){
if(!point)continue;
const radius=Math.max(17,unit*.46),[x,y]=point;
const fade=ctx.createRadialGradient(x,y,0,x,y,radius);
if(!fade?.addColorStop)continue;
fade.addColorStop(0,'rgba(0,0,0,1)');
fade.addColorStop(.55,'rgba(0,0,0,1)');
fade.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=fade;ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
}
ctx.restore();
// Destination sticker surrounds the target without hiding its clue.
const target=runs.at(-1)?.at(-1);
if(target){
const r=Math.max(9,unit*.25);
ctx.beginPath();ctx.arc(...target,r,0,Math.PI*2);ctx.strokeStyle=C.hintRibbonLight;ctx.lineWidth=5;ctx.stroke();
ctx.strokeStyle=C.hintRibbon;ctx.lineWidth=2.5;ctx.stroke();
}
// Nothing in the hint layer may obscure player materials or rule typography.
ctx.globalCompositeOperation='destination-out';ctx.fillStyle='#000';ctx.strokeStyle='#000';
for(const shape of hint.visitedShapes||[]){
for(const dy of [0,shape.depth]){trace(ctx,shape.rings,shape.radius,dy);ctx.fill();ctx.lineWidth=shape.maskPad?shape.maskPad*2:3;ctx.stroke();}
}
for(const rect of hint.keepouts||[])ctx.fillRect(rect.x,rect.y,rect.w,rect.h);
ctx.restore();
}
function hintArrow(ctx,a,b,x,y,width){
const len=distance(a,b);if(len<1)return;
const dx=(b[0]-a[0])/len,dy=(b[1]-a[1])/len,r=width*.65;
ctx.beginPath();ctx.moveTo(x-dx*r-dy*r,y-dy*r+dx*r);ctx.lineTo(x+dx*r,y+dy*r);ctx.lineTo(x-dx*r+dy*r,y-dy*r-dx*r);
ctx.strokeStyle=C.hintRibbonDepth;ctx.lineWidth=Math.max(2,width*.38);ctx.stroke();
}
// Round the complete route, including turns where two cell guides meet.
export function smoothHintRuns(guides,radius){
const raw=[];let last;
for(const g of guides){
if(!g.points.length)continue;
if(last&&last.b===g.a&&distance(raw.at(-1).at(-1),g.points[0])<.1)raw.at(-1).push(...g.points.slice(1));
else raw.push([...g.points]);
last=g;
}
return raw.map(points=>{
const p=points.filter((v,i)=>!i||distance(v,points[i-1])>.01);
if(p.length<3)return p;
const out=[p[0]];
for(let i=1;i<p.length-1;i++){
const a=p[i-1],b=p[i],c=p[i+1],ab=distance(a,b),bc=distance(b,c),r=Math.min(radius,ab*.45,bc*.45);
const u=[b[0]+(a[0]-b[0])*r/ab,b[1]+(a[1]-b[1])*r/ab],v=[b[0]+(c[0]-b[0])*r/bc,b[1]+(c[1]-b[1])*r/bc];
out.push(u);
for(let k=1;k<=10;k++){const t=k/10,q=1-t;out.push([q*q*u[0]+2*q*t*b[0]+t*t*v[0],q*q*u[1]+2*q*t*b[1]+t*t*v[1]]);}
}
out.push(p.at(-1));return out;
});
}
+5 -2
View File
@@ -7,5 +7,8 @@ if(key==='colour') {const first=level.cells.find(c=>c.id===solution[0]);const ot
if(['straight','vertical','corner','parity'].includes(key)){for(let n=1;n<solution.length;n++){const prefix=solution.slice(0,n);const bad=g.adj[g.ids.get(prefix.at(-1))].map(i=>g.cells[i].id).find(id=>!prefix.includes(id)&&violations(level,[...prefix,id]).some(v=>v.key.startsWith((key==='parity'?'parity':'direction')+':')));if(bad){examples.push({after:n,route:[...prefix,bad],message:key==='parity'?'错误示例:奇偶与当前步数不符,格子变红':'错误示例:没有沿图中的两端进出,图标变红'});break;}}}
if(key==='parity'){const start=solution[0];for(const j of g.adj[g.ids.get(start)])for(const k of g.adj[j]){const route=[start,g.cells[j].id,g.cells[k].id];if(new Set(route).size===3&&violations(level,route).some(v=>v.key.startsWith('parity:'))&&!examples.length)examples.push({after:1,route,message:'错误示例:第 3 步走到“偶”格,格子变红'});}}
const beats=[];routes.forEach((route,i)=>{beats.push({route});if(i<solution.length)for(const e of examples.filter(e=>e.after===i)){beats.push(e);for(let back=e.route.length-1;back>route.length;back--)beats.push({route:e.route.slice(0,back),message:'沿来时路逐格退回,再按规则走'});beats.push({route,message:e.blocked?'改走符合规则的相邻格子':'沿来时路退回,重新按规则走'});}});
const continuous=[];for(const beat of beats){const previous=continuous.at(-1);if(previous&&JSON.stringify(previous.route)===JSON.stringify(beat.route)&&!beat.blocked&&!previous.blocked)continue;if(previous&&beat.route.length>previous.route.length+1){for(let n=previous.route.length+1;n<beat.route.length;n++)continuous.push({route:beat.route.slice(0,n)});}continuous.push(beat);}return {key,level,solution,routes,beats:continuous,copy:tutorials[key],duration:1000};}
export function lessonFrame(lesson,elapsed){const duration=lesson.duration,segments=lesson.beats.length-1,position=Math.max(0,Math.min(1,elapsed/duration))*segments,index=Math.min(segments,Math.floor(position)+1),beat=lesson.beats[Math.min(index,lesson.beats.length-1)],route=beat.route,previous=lesson.beats[Math.max(0,Math.min(index-1,lesson.beats.length-1))].route;const n=route.length,key=lesson.key,c=lesson.level.cells.find(c=>c.id===route.at(-1));let message=n?'第 '+n+' 步':'先观察格子,再开始连线';if(key==='basic')message=n?'第 '+n+' 步 · 每个格子只经过一次':'从数字 1 开始';if(key==='colour'&&n)message+=' · '+(c.category==='circle'?'蓝色;下一步换橙色':'橙色;下一步换蓝色');if(key==='parity'&&n)message+=' · '+(n%2?'奇数步:1、3、5…':'偶数步:2、4、6…');if(['straight','vertical','corner'].includes(key)){const at=route.findIndex(id=>lesson.level.cells.find(c=>c.id===id)?.direction);message=at<0?'观察线条连接的两端':at===n-1?'从线的一端进入,下一步沿另一端离开':'沿指定的线穿过,图标亮起';}if(key==='rhythm-door'){const d=lesson.level.doors[0];message+=' · '+(doorLatched(d,route)?'已经穿过:门保持打开':doorOpen(d,route)?'门开:现在可以穿过':'门关:此时不能穿过');if(previous.length>n)message='回退到第 '+n+' 步 · '+(doorLatched(d,route)?'尚未退过门,保持打开':'已撤销穿门,恢复开关切换');}if(key==='walls')message=n?'沿其他格子绕过去,黑黄墙始终不能穿越':'找到黑黄墙:这条边不能走';if(key==='hex')message=n?'第 '+n+' 步 · 沿相邻的整条边移动':'观察六边形相邻的边';if(key==='unknown-start')message=n?'第 '+n+' 步 · 核对是否按时到达数字格':'没有数字 1:观察其他数字,选择起点';message=beat.message||message;return {route,previous,message,blocked:beat.blocked,progress:elapsed>=duration?1:position-Math.floor(position),finished:elapsed>=duration,index};}
const continuous=[];for(const beat of beats){const previous=continuous.at(-1);if(previous&&JSON.stringify(previous.route)===JSON.stringify(beat.route)&&!beat.blocked&&!previous.blocked)continue;if(previous&&beat.route.length>previous.route.length+1){for(let n=previous.route.length+1;n<beat.route.length;n++)continuous.push({route:beat.route.slice(0,n)});}continuous.push(beat);}return {key,level,solution,routes,beats:continuous,copy:tutorials[key],duration:Math.max(3600,(continuous.length-1)*550)};}
export function lessonFrame(lesson,elapsed){const duration=lesson.duration,segments=lesson.beats.length-1,position=Math.max(0,Math.min(1,elapsed/duration))*segments,index=Math.min(segments,Math.floor(position)+1),beat=lesson.beats[Math.min(index,lesson.beats.length-1)],route=beat.route,previous=lesson.beats[Math.max(0,Math.min(index-1,lesson.beats.length-1))].route;const n=route.length,key=lesson.key,c=lesson.level.cells.find(c=>c.id===route.at(-1));let message=n?'第 '+n+' 步':'先观察格子,再开始连线';if(key==='basic')message=n?'第 '+n+' 步 · 每个格子只经过一次':'从数字 1 开始';if(key==='colour'&&n)message+=' · '+(c.category==='circle'?'蓝色;下一步换橙色':'橙色;下一步换蓝色');if(key==='parity'&&n)message+=' · '+(n%2?'奇数步:1、3、5…':'偶数步:2、4、6…');if(['straight','vertical','corner'].includes(key)){const at=route.findIndex(id=>lesson.level.cells.find(c=>c.id===id)?.direction);message=at<0?'观察线条连接的两端':at===n-1?'从线的一端进入,下一步沿另一端离开':'沿指定的线穿过,图标亮起';}if(key==='rhythm-door'){const d=lesson.level.doors[0];message+=' · '+(doorLatched(d,route)?'已经穿过:门保持打开':doorOpen(d,route)?'门开:现在可以穿过':'门关:此时不能穿过');if(previous.length>n)message='回退到第 '+n+' 步 · '+(doorLatched(d,route)?'尚未退过门,保持打开':'已撤销穿门,恢复开关切换');}if(key==='walls')message=n?'沿其他格子绕过去,黑黄墙始终不能穿越':'找到黑黄墙:这条边不能走';if(key==='hex')message=n?'第 '+n+' 步 · 沿相邻的整条边移动':'观察六边形相邻的边';if(key==='unknown-start')message=n?'第 '+n+' 步 · 核对是否按时到达数字格':'没有数字 1:观察其他数字,选择起点';message=beat.message||message;return {route,previous,message,blocked:beat.blocked,progress:elapsed>=duration?1:position-Math.floor(position),finished:elapsed>=duration,index};}
export const LESSON_PAUSE=1000;
export function loopLessonTime(lesson,elapsed){const cycle=lesson.duration+LESSON_PAUSE;return Math.min(lesson.duration,Math.max(0,elapsed)%cycle);}
+77 -39
View File
@@ -1,39 +1,60 @@
import {CLEAR_DURATION,clearProgress,drawClearCelebration} from './bright-classroom/celebration.mjs';
import {stopAudio,audioDiagnostics} from './audio.mjs';
import { mainCanvas, boardCanvas, document, window, PACK } from './platform.mjs';
import {renderLesson,hintView} from './upstream/ui.mjs';
import { mainCanvas, boardCanvas, document, window, PACK, setViewInvalidator } from './platform.mjs';
import {renderLesson,hintView,refreshBrightBoard,brightOverlay,brightAnimating} from './upstream/ui.mjs';
import {drawHint} from './hints.mjs';
import {lessonKeys,makeLesson,lessonFrame} from './lessons.mjs';
import {lessonKeys,makeLesson,lessonFrame,loopLessonTime} from './lessons.mjs';
import {tutorials} from './copy.mjs';
import {C,textFont,label,panel,fontDiagnostics} from './bright-classroom/theme.mjs';
import {background,buttonArt,stage,statusPill} from './bright-classroom/ui.mjs';
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='';
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
let hintAnimation={key:'',started:0};
let settingsOpen=false,pressedLabel='',pressedUntil=0;
let renderedFrames=0,clearCelebration=null;
function requestPaint(){if(visible&&!raf)raf=requestAnimationFrame(draw);}
setViewInvalidator(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?.(); }
function text(value, x, y, size = 16, color = '#23243A') { ctx.fillStyle = color; ctx.font = size + 'px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(value), x, y); }
function splitLines(value,maxWidth,size=16){ctx.font=size+'px sans-serif';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 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 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 } = {}) { ctx.fillStyle = enabled ? (primary ? '#6558F5' : '#ECECF3') : '#F4F4F6'; ctx.fillRect(x, y, w, h); text(label, x + w / 2, y + h / 2, 15, enabled ? (primary ? '#FFFFFF' : '#23243A') : '#AAAAAA'); buttons.push({ label, x, y, w, h, run, enabled }); }
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 draw() {
raf=0;
if (!visible)
return;
renderedFrames++;
const d = window.devicePixelRatio;
ctx.setTransform(d, 0, 0, d, 0, 0);
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
const state=window.variantSnapshot();
if(!state.cleared||state.screen!=='play')clearCelebration=null;
else if(!clearCelebration||clearCelebration.key!==state.level)clearCelebration={key:state.level,started:Date.now(),requested:audioDiagnostics.completeRequestedAt};
if(clearCelebration&&state.soundEnabled&&audioDiagnostics.completeRequestedAt===clearCelebration.requested){
const actual=audioDiagnostics.completeStartedAt;
clearCelebration.started=actual??(Date.now()-clearCelebration.requested<500?null:clearCelebration.requested);
}
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);
text('200关 · 评分修订版', width / 2, top + 61, 16);
text($('progress').textContent, width / 2, top + 90, 14);
text('数字一笔', width / 2, top + 24, 30,C.primary);
text('每块一次 · 数字对步数', width / 2, top + 61, 16);
text('已完成 '+$('progress').textContent, width / 2, top + 90, 14,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') });
@@ -46,9 +67,12 @@ function draw() {
}
else {
button('选关', 16, top, 62, 36, () => click('btn-back'));
text('第 ' + window.variantSnapshot().displayOrder + ' 关', width / 2, top + 18, 20);
panel(ctx,width/2-60,top+2,120,32,16,C.coral,3);
text('第 ' + window.variantSnapshot().displayOrder + ' 关', width / 2, top + 18, 18,C.onPrimary);
button($('btn-sound').textContent, width - 94, top, 78, 36, () => click('btn-sound'));
text($('level-sub').textContent, width / 2, top + 59, 14);
statusPill(ctx,84,top+43,width-100,31,state.path.length,currentLevel.cells.length-state.path.length);
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){
@@ -60,29 +84,30 @@ function draw() {
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:'按住滑动连线 · 沿来时路逐格回退');
const message=cleared?(window.variantSnapshot().displayOrder===ordered.length?'200 关全部完成!':'完成!准备好就进入下一关。'):($('status').className.includes('show')&&$('status').textContent?$('status').textContent:'按住滑动连线 · 沿来时路逐格回退');
const messageLines=splitLines(message,width-36,13);
messageLines.slice(0,2).forEach((line,i)=>text(i===1&&messageLines.length>2?line.slice(0,-1)+'…':line,width/2,foot-12+i*22,13,cleared?'#6558F5':'#777789'));
messageLines.slice(0,2).forEach((line,i)=>text(i===1&&messageLines.length>2?line.slice(0,-1)+'…':line,width/2,foot-12+i*22,13,cleared?C.success:C.inkSecondary));
const actionHeight=height<700?54:70;
if(cleared){
button(!$('next').hidden?'下一关 →':'返回选关',16,foot+22,width-32,44,()=>click(!$('next').hidden?'next':'btn-back'),{primary:true});
button(!$('next').hidden?'下一关 →':'返回选关',16,foot+22,width-32,actionHeight,()=>click(!$('next').hidden?'next':'btn-back'),{primary:true});
}else{
const bw=(width-48)/3;
button('重开',16,foot+22,bw,44,()=>click('restart'));
button('撤销',24+bw,foot+22,bw,44,()=>click('undo'),{enabled:!$('undo').disabled});
button('指路',32+2*bw,foot+22,bw,44,()=>click('hint'));
button('重开',16,foot+22,bw,actionHeight,()=>click('restart'));
button('撤销',24+bw,foot+22,bw,actionHeight,()=>click('undo'),{enabled:!$('undo').disabled});
button('指路',32+2*bw,foot+22,bw,actionHeight,()=>click('hint'),{primary:true});
}
button('规则', 16, top + 43, 56, 30, () => click('btn-rules'));
text('剩余 ' + $('remaining-n').textContent, width - 52, top + 58, 14);
}
if(clearCelebration&&clearCelebration.started!==null&&!preferences.reducedMotion&&!settingsOpen&&$('rules-sheet').hidden)drawClearCelebration(ctx,width,top+82,$('board').clientHeight,Date.now()-clearCelebration.started);
if (!$('rules-sheet').hidden) {
ctx.fillStyle = 'rgba(25,24,44,.55)';
ctx.fillStyle = 'rgba(67,56,47,.32)';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#FFFFFF';
const panelTop=top+12,panelHeight=Math.max(240,height-panelTop-bottom-20);
ctx.fillRect(16,panelTop,width-32,panelHeight);
panel(ctx,16,panelTop,width-32,panelHeight,24,C.surface,5);
buttons=[];
text('怎么玩',width/2,panelTop+28,23);
const key=window.variantSnapshot().level+':'+$('rule-text').textContent;
@@ -94,20 +119,31 @@ function draw() {
rulePage=Math.max(0,Math.min(pages.length-1,rulePage));const part=pages[rulePage];
const lessonKey=key+':'+rulePage;
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,Date.now()-lessonStarted);
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);
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='#E5484D';ctx.lineWidth=4;ctx.stroke();ctx.setLineDash([]);text('×',area.x+(a[0]+b[0])/2,area.y+(a[1]+b[1])/2,26,'#E5484D');}
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;
if(end){const t=lessonState.progress,x=area.x+start[0]+(end[0]-start[0])*t,y=area.y+start[1]+(end[1]-start[1])*t;if(start!==end){ctx.beginPath();ctx.moveTo(area.x+start[0],area.y+start[1]);ctx.lineTo(x,y);ctx.strokeStyle='#6558F5';ctx.lineWidth=6;ctx.lineCap='round';ctx.stroke();}ctx.beginPath();ctx.arc(x,y,10,0,Math.PI*2);ctx.strokeStyle='#F5A524';ctx.lineWidth=3;ctx.stroke();}
const msg=splitLines(lessonState.message,width-76,14);msg.forEach((line,i)=>text(line,width/2,area.y+area.h+16+i*20,14,'#6558F5'));
if(end){const t=lessonState.progress,x=area.x+start[0]+(end[0]-start[0])*t,y=area.y+start[1]+(end[1]-start[1])*t;if(start!==end){ctx.beginPath();ctx.moveTo(area.x+start[0],area.y+start[1]);ctx.lineTo(x,y);ctx.strokeStyle=C.primary;ctx.lineWidth=6;ctx.lineCap='round';ctx.stroke();}ctx.beginPath();ctx.arc(x,y,10,0,Math.PI*2);ctx.strokeStyle=C.hint;ctx.lineWidth=3;ctx.stroke();}
const msg=splitLines(lessonState.message,width-76,14);msg.forEach((line,i)=>text(line,width/2,area.y+area.h+16+i*20,14,C.primary));
part.lines.forEach((line,i)=>text(line,width/2,area.y+area.h+65+i*23,15));
button('重播',width/2-32,panelTop+panelHeight-144,64,30,()=>{lessonStarted=Date.now();});
if(pages.length>1){button('上一段',38,panelTop+panelHeight-102,80,30,()=>{rulePage--;lesson=null;},{enabled:rulePage>0});text((rulePage+1)+' / '+pages.length,width/2,panelTop+panelHeight-87,14);button('下一段',width-118,panelTop+panelHeight-102,80,30,()=>{rulePage++;lesson=null;},{enabled:rulePage<pages.length-1});}
button('返回游戏',40,panelTop+panelHeight-60,width-80,42,()=>click('close-rules'),{primary:true});
}
raf = requestAnimationFrame(draw);
if(settingsOpen){
ctx.fillStyle='rgba(67,56,47,.32)';ctx.fillRect(0,0,width,height);buttons=[];
const y=Math.max(top,height/2-150);panel(ctx,24,y,width-48,280,24,C.surface,5);
text('设置',width/2,y+32,24);
button($('btn-sound').textContent,44,y+65,width-88,48,()=>click('btn-sound'));
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});
}
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);
if(!preferences.reducedMotion&&Date.now()<pressedUntil||liveLesson||liveClear||liveBoard&&(brightAnimating()||!preferences.reducedMotion&&Boolean(hintView())))requestPaint();
}
function feedback() { wx.showModal({ title: '试玩反馈', editable: true, placeholderText: '记录有趣、太难或规则不清楚的关卡', content: $('reasoning').value || '', success: r => { if (r.confirm) {
$('reasoning').value = r.content || '';
@@ -119,29 +155,31 @@ 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)
if (b.enabled){pressedLabel=b.label;pressedUntil=Date.now()+120;
b.run();
requestPaint();
}
return;
} if (!$('rules-sheet').hidden || $('screen-play').className.includes('hidden'))
} if (settingsOpen || !$('rules-sheet').hidden || $('screen-play').className.includes('hidden'))
return; if (p.clientY < top + 82 || p.clientY > top + 82 + $('board').clientHeight)
return; pointer = p.identifier; $('board').handlers.pointerdown?.(event(p)); });
wx.onTouchMove(e => { const p = e.touches.find(p => p.identifier === pointer); if (p)
$('board').handlers.pointermove?.(event(p)); });
return; pointer = p.identifier; $('board').handlers.pointerdown?.(event(p)); requestPaint(); });
wx.onTouchMove(e => { const p = e.touches.find(p => p.identifier === pointer); if (p){
$('board').handlers.pointermove?.(event(p));requestPaint();} });
const release = e => { if (e.changedTouches.some(p => p.identifier === pointer)) {
$('board').handlers.pointerup?.();
pointer = null;
} };
wx.onTouchEnd(release);
wx.onTouchCancel(release);
wx.onHide(() => { visible = false; lessonStarted=Date.now(); stopAudio(); cancelAnimationFrame(raf); pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
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();
window.handlers.resize?.();
raf = requestAnimationFrame(draw);
resize();
requestPaint();
} });
wx.onAudioInterruptionBegin?.(stopAudio);
wx.onWindowResize?.(resize);
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),hint:hintView(),audio:{...audioDiagnostics},rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,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',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 })) };
resize();
raf = requestAnimationFrame(draw);
requestPaint();
+9 -1
View File
@@ -8,10 +8,18 @@ if (!Array.prototype.at)
export const mainCanvas = wx.createCanvas();
const boardCanvas = wx.createCanvas();
const elements = new Map();
let invalidateView=()=>{};
export function setViewInvalidator(fn){invalidateView=fn;}
class Element {
constructor(id = '') { this.id = id; this.children = []; this.handlers = {}; this.value = ''; this.style = {}; this.className = ''; this.textContent = ''; this.hidden = false; this.disabled = false; }
set textContent(value){this._text=playerText(value);}
set textContent(value){const next=playerText(value);if(next!==this._text){this._text=next;invalidateView();}}
get textContent(){return this._text||'';}
set className(value){if(value!==this._className){this._className=value;invalidateView();}}
get className(){return this._className||'';}
set hidden(value){if(value!==this._hidden){this._hidden=value;invalidateView();}}
get hidden(){return this._hidden||false;}
set disabled(value){if(value!==this._disabled){this._disabled=value;invalidateView();}}
get disabled(){return this._disabled||false;}
replaceChildren(...children) { this.children = children; }
append(...children) { this.children.push(...children); }
addEventListener(type, fn) { this.handlers[type] = fn; }
+4
View File
@@ -1,4 +1,8 @@
{
"theme": "bright-classroom",
"bundledFonts": true,
"reducedMotionPersisted": true,
"idleRenderingStops": true,
"segmentHints": true,
"playerFillUnchangedAfterHint": true,
"animatedLessons": 10,