feat: integrate brighter A3 soft ribbon hints with cached target glow
This commit is contained in:
@@ -1,3 +1,14 @@
|
||||
## 2026-10-23:A3明亮软糖指路(包含6abbc37)
|
||||
|
||||
版本2026-10-23-soft-ribbon-a3-bright,以6abbc37为基础,包含iOS通关早点击负载优化、奇偶节点彩带、选关/规则/设置统一音效、设置冒号和“乐”字字重修复。独立包:../releases/NumberSwipe-main-A3明亮软糖指路-20261023.zip。旧包、旧解压目录不变;不上传、不推送。
|
||||
|
||||
- 按用户A3交接接入Canvas程序化软糖带,不使用PNG。保留0.235带宽比例(7–26逻辑像素)、圆润接缝、0.39pitch端点消隐;移除旧箭头、细白芯、双描边和目标圈。
|
||||
- 原参数对照保存在docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json;有效提亮参数在同目录hint-soft-ribbon-a3-bright.tokens.json及正式tokens.json的hintSoftRibbon命名空间。杏橙主体提亮、柔冠由低透明暖白增至0.24、棕影由0.15降至0.09;尺寸、速度和呼吸周期保持设计值。
|
||||
- 一段一个宽亮斑按真实弧长流动(60px/s,最短周期1800ms)。玩家落格不重置分段时钟;只过滤已走边,使用独立带面画布source-atop,帧帧从干净静态带面开始。
|
||||
- 目标取真实单元轮廓,材质/桥→目标柔光→实际数字/规则符号/墙门依次合成;目标缓慢呼吸(2200ms,0.65–1),不缩放、不复制或写死目标数字。玩家已走格及外晕在软糖和目标光两层都排除。
|
||||
- 弧长、合法圆角、静态带面和目标材质/符号层缓存;支持矩形、异形、孔洞和六边形。完成段、重开、换关和后台释放旧绘制状态;不支持离屏合成时保留静态带面。
|
||||
- 新增6项A3模块测试,并更新旧箭头视觉断言。200关指路填色像素一致;完整测试/200关触摸记录见qa/a3-check-20261023.txt。浏览器尺寸与样张验收入口为qa/bright-classroom.html的“A3尺寸与动画验收”。微信/iOS/Android真机帧率与混合效果仍需上传后复测。
|
||||
|
||||
## 2026-10-23:iOS通关早点击与界面反馈修复
|
||||
|
||||
版本:2026-10-23-ios-clear-ui-fix,基于0cefce4。独立新包:../releases/NumberSwipe-main-iOS通关切换与界面反馈修复-20261023.zip;旧包及旧包解压目录保持原样,不上传微信,不推送远端。
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
## 2026-10-23:A3明亮软糖指路(包含6abbc37)
|
||||
|
||||
版本2026-10-23-soft-ribbon-a3-bright,以6abbc37为基础,包含iOS通关早点击负载优化、奇偶节点彩带、选关/规则/设置统一音效、设置冒号和“乐”字字重修复。独立包:../releases/NumberSwipe-main-A3明亮软糖指路-20261023.zip。旧包、旧解压目录不变;不上传、不推送。
|
||||
|
||||
- 按用户A3交接接入Canvas程序化软糖带,不使用PNG。保留0.235带宽比例(7–26逻辑像素)、圆润接缝、0.39pitch端点消隐;移除旧箭头、细白芯、双描边和目标圈。
|
||||
- 原参数对照保存在docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json;有效提亮参数在同目录hint-soft-ribbon-a3-bright.tokens.json及正式tokens.json的hintSoftRibbon命名空间。杏橙主体提亮、柔冠由低透明暖白增至0.24、棕影由0.15降至0.09;尺寸、速度和呼吸周期保持设计值。
|
||||
- 一段一个宽亮斑按真实弧长流动(60px/s,最短周期1800ms)。玩家落格不重置分段时钟;只过滤已走边,使用独立带面画布source-atop,帧帧从干净静态带面开始。
|
||||
- 目标取真实单元轮廓,材质/桥→目标柔光→实际数字/规则符号/墙门依次合成;目标缓慢呼吸(2200ms,0.65–1),不缩放、不复制或写死目标数字。玩家已走格及外晕在软糖和目标光两层都排除。
|
||||
- 弧长、合法圆角、静态带面和目标材质/符号层缓存;支持矩形、异形、孔洞和六边形。完成段、重开、换关和后台释放旧绘制状态;不支持离屏合成时保留静态带面。
|
||||
- 新增6项A3模块测试,并更新旧箭头视觉断言。200关指路填色像素一致;完整测试/200关触摸记录见qa/a3-check-20261023.txt。浏览器尺寸与样张验收入口为qa/bright-classroom.html的“A3尺寸与动画验收”。微信/iOS/Android真机帧率与混合效果仍需上传后复测。
|
||||
|
||||
## 2026-10-23:iOS通关早点击与界面反馈修复
|
||||
|
||||
版本:2026-10-23-ios-clear-ui-fix,基于0cefce4。独立新包:../releases/NumberSwipe-main-iOS通关切换与界面反馈修复-20261023.zip;旧包及旧包解压目录保持原样,不上传微信,不推送远端。
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
## 2026-10-23:A3明亮软糖指路(包含6abbc37)
|
||||
|
||||
版本2026-10-23-soft-ribbon-a3-bright,以6abbc37为基础,包含iOS通关早点击负载优化、奇偶节点彩带、选关/规则/设置统一音效、设置冒号和“乐”字字重修复。独立包:../releases/NumberSwipe-main-A3明亮软糖指路-20261023.zip。旧包、旧解压目录不变;不上传、不推送。
|
||||
|
||||
- 按用户A3交接接入Canvas程序化软糖带,不使用PNG。保留0.235带宽比例(7–26逻辑像素)、圆润接缝、0.39pitch端点消隐;移除旧箭头、细白芯、双描边和目标圈。
|
||||
- 原参数对照保存在docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json;有效提亮参数在同目录hint-soft-ribbon-a3-bright.tokens.json及正式tokens.json的hintSoftRibbon命名空间。杏橙主体提亮、柔冠由低透明暖白增至0.24、棕影由0.15降至0.09;尺寸、速度和呼吸周期保持设计值。
|
||||
- 一段一个宽亮斑按真实弧长流动(60px/s,最短周期1800ms)。玩家落格不重置分段时钟;只过滤已走边,使用独立带面画布source-atop,帧帧从干净静态带面开始。
|
||||
- 目标取真实单元轮廓,材质/桥→目标柔光→实际数字/规则符号/墙门依次合成;目标缓慢呼吸(2200ms,0.65–1),不缩放、不复制或写死目标数字。玩家已走格及外晕在软糖和目标光两层都排除。
|
||||
- 弧长、合法圆角、静态带面和目标材质/符号层缓存;支持矩形、异形、孔洞和六边形。完成段、重开、换关和后台释放旧绘制状态;不支持离屏合成时保留静态带面。
|
||||
- 新增6项A3模块测试,并更新旧箭头视觉断言。200关指路填色像素一致;完整测试/200关触摸记录见qa/a3-check-20261023.txt。浏览器尺寸与样张验收入口为qa/bright-classroom.html的“A3尺寸与动画验收”。微信/iOS/Android真机帧率与混合效果仍需上传后复测。
|
||||
|
||||
## 2026-10-23:iOS通关早点击与界面反馈修复
|
||||
|
||||
版本:2026-10-23-ios-clear-ui-fix,基于0cefce4。独立新包:../releases/NumberSwipe-main-iOS通关切换与界面反馈修复-20261023.zip;旧包及旧包解压目录保持原样,不上传微信,不推送远端。
|
||||
|
||||
@@ -12,7 +12,7 @@ export function applyBrightClassroom(source) {
|
||||
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});
|
||||
brightScene=renderBrightBoard(ctx,level,path,width,height,{preview:previewOnly,...brightHintOptions(Date.now()),pixelRatio:dpr});
|
||||
({polygons,centers,unit}=brightScene);channelViews=brightScene.channels;cellViews=brightScene.views;
|
||||
const bad=violations(level,path);
|
||||
${tail}`;
|
||||
@@ -28,13 +28,15 @@ ${tail}`;
|
||||
// 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=`import {renderBrightBoard,hintGeometry,drawBarriers} from '../bright-classroom/board-renderer.mjs';\nlet brightScene=null,brightHintKey='',brightHintStarted=0;\n`+source;
|
||||
source+=`
|
||||
export function refreshBrightBoard(now){
|
||||
if(!brightScene?.animating)return;
|
||||
const options=brightHintOptions(now);if(!brightScene?.animating&&!options.hintTarget&&!brightScene?.hintLayers)return;
|
||||
const dpr=window.devicePixelRatio||1;ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
brightScene=renderBrightBoard(ctx,level,path,canvas.clientWidth,canvas.clientHeight,{now});
|
||||
brightScene=renderBrightBoard(ctx,level,path,canvas.clientWidth,canvas.clientHeight,{now,...options,pixelRatio:dpr});
|
||||
}
|
||||
function brightHintOptions(now){const h=previewOnly?null:segmentHints.view(level,solution,path),key=h?level.id+':'+h.start+':'+h.end:'';if(key!==brightHintKey){brightHintKey=key;brightHintStarted=now;}return {hintTarget:h?.route.at(-1)||null,hintElapsed:now-brightHintStarted};}
|
||||
export function resetBrightHint(){brightHintKey='';if(brightScene)brightScene.hintLayers=null;}
|
||||
export function brightOverlay(target){if(brightScene)drawBarriers(target,brightScene,path);}
|
||||
export function brightAnimating(){return Boolean(brightScene?.animating);}
|
||||
`;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -35,27 +35,27 @@ test('hint draws skip player edges and mask visited cells on their own layer; co
|
||||
const ctx=new Proxy({globalCompositeOperation:'source-over',save(){stack.push(this.globalCompositeOperation);},restore(){this.globalCompositeOperation=stack.pop();},beginPath(){from=to=null;},moveTo:(...p)=>{from=p;},lineTo:(...p)=>{to=p;},stroke(){if(from&&to)strokes.push([from,to]);},arc:(...p)=>arcs.push(p),fill(){events.push({kind:'fill',mode:this.globalCompositeOperation});}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
|
||||
const poly=[[0,0],[100,0],[100,100],[0,100]];
|
||||
drawHint(ctx,{route:['a','b','c'],visitedPolygons:[poly]},{a:[0,0],b:[100,0],c:[200,0]},50,300,['a','b']);
|
||||
assert.deepEqual(strokes,[[[100,0],[200,0]],[[100,0],[200,0]]],'only unwalked edge receives hint strokes');
|
||||
assert.equal(strokes.length,4);assert.ok(strokes.every(s=>JSON.stringify(s)===JSON.stringify([[100,0],[200,0]])),'only unwalked edge receives hint strokes');
|
||||
assert.ok(events.some(e=>e.kind==='fill'&&e.mode==='destination-out'));
|
||||
assert.equal(ctx.globalCompositeOperation,'source-over');
|
||||
assert.equal(arcs.length,1,'no moving light or rings over already walked edge');
|
||||
assert.equal(arcs.length,0,'A3 has no light beads or endpoint rings');
|
||||
});
|
||||
test('parity is a segment anchor; colour is not; flow moves continuously across cells',()=>{
|
||||
const l={cells:solution.map(id=>({id,...(id==='d'?{parity:'odd'}:{category:'circle'})}))};
|
||||
assert.deepEqual(hintSegment(l,solution,[]).route,['a','b','c','d']);
|
||||
const arcs=[],ctx=new Proxy({arc:(...p)=>arcs.push(p)},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
|
||||
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]);
|
||||
assert.equal(arcs.length,0,'static fallback has no moving beads');
|
||||
});
|
||||
|
||||
test('coral teaching ribbon preserves visited masks and offers direction in reduced motion',()=>{
|
||||
test('soft teaching ribbon preserves visited masks without arrows 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(strokes.some(s=>s.color==='rgba(255,185,126,0.72)'));
|
||||
assert.equal(strokes.filter(s=>s.color==='#C96E48').length,0,'no chevrons');
|
||||
assert.ok(fills.includes('destination-out'));
|
||||
assert.equal(ctx.globalCompositeOperation,'source-over');
|
||||
assert.deepEqual(path,['a']);
|
||||
|
||||
@@ -131,6 +131,10 @@
|
||||
{
|
||||
"type": "file",
|
||||
"value": "undo-next.test.mjs"
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"value": "soft-hint.test.mjs"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
PASS 28/28:A3四尺寸、七类真实关卡,静态带面未逐帧重建,目标呼吸使用缓存。浏览器验收,微信真机待验。 320×568 / 第4关;320×568 / 第28关;320×568 / 第9关;320×568 / 第65关;320×568 / 第29关;320×568 / 第66关;320×568 / 第163关;375×812 / 第4关;375×812 / 第28关;375×812 / 第9关;375×812 / 第65关;375×812 / 第29关;375×812 / 第66关;375×812 / 第163关;390×844 / 第4关;390×844 / 第28关;390×844 / 第9关;390×844 / 第65关;390×844 / 第29关;390×844 / 第66关;390×844 / 第163关;414×896 / 第4关;414×896 / 第28关;414×896 / 第9关;414×896 / 第65关;414×896 / 第29关;414×896 / 第66关;414×896 / 第163关
|
||||
@@ -0,0 +1,136 @@
|
||||
|
||||
> numberswipe-main@1.0.0 check
|
||||
> npm run build && npm test && npm run smoke
|
||||
|
||||
|
||||
> numberswipe-main@1.0.0 build
|
||||
> node build.mjs
|
||||
|
||||
Built campaign-200-r2: 200 levels
|
||||
|
||||
> numberswipe-main@1.0.0 test
|
||||
> node --test *.test.mjs
|
||||
|
||||
✔ 手机没有浏览器音频接口时,原生播放器仍能播放全部 36 步和六类反馈音 (8.9435ms)
|
||||
✔ 关闭音效停止播放;再次开启会试听并保存偏好;后台中断停止所有播放器 (1.1639ms)
|
||||
✔ 延迟通关音在静音或进入后台时取消,不在返回后突然播放 (47.0186ms)
|
||||
✔ 音频错误可诊断,不再静默吞掉 (0.8142ms)
|
||||
✔ 所有音频均为非静音 PCM;原版第13步比第1步高一个八度 (49.8985ms)
|
||||
✔ 教学和组合规则用具体动作及奇偶例子解释,没有未解释的术语 (39.0574ms)
|
||||
✔ 过关提示同步使用实际播放起点,非请求时间 (1.1057ms)
|
||||
✔ 过关短句使用用户提供的3秒WAV,不与旧和弦叠加 (7.5766ms)
|
||||
✔ jingle end, cancellation and playback error restore BGM without restarting (6.6654ms)
|
||||
✔ muted effects never duck BGM and unrelated effect errors cannot end jingle ducking (0.5332ms)
|
||||
✔ hide and music preference do not revive playback through a late jingle callback (0.5819ms)
|
||||
✔ BGMB is absent from the runtime manifest and music package (0.9783ms)
|
||||
✔ merged irregular outlines retain holes and never draw internal unit edges (187.7866ms)
|
||||
✔ all 200 boards fit four screen budgets with stable safe anchors and real shared entrances (1687.1491ms)
|
||||
✔ guides through every irregular block stay inside its occupied shape (67.4851ms)
|
||||
✔ visited, conflicting and hinted category cells retain their material identity (15.4878ms)
|
||||
✔ five font subsets match their manifest and fit the package budget (6.3089ms)
|
||||
✔ upstream adapter fails closed if the inspected render seams change (4.4174ms)
|
||||
✔ procedural cushion pixels preserve holes and light the upper bevel without a border (39.8189ms)
|
||||
✔ visible glyph bounds are centered independently of font ascent and side bearings (2.0616ms)
|
||||
✔ stage with zero depth casts alpha shadows without painting a solid backing (12.7032ms)
|
||||
✔ native width-only TextMetrics still centers the rasterized glyph ink (5.3524ms)
|
||||
✔ fixed channels remain anchored at their block centers after number enlargement (2251.9192ms)
|
||||
✔ checkpoint ribbons trigger on correct arrival only and never duplicate per frame (7.9589ms)
|
||||
✔ checkpoint pulse is small, centered in time and settles without looping (0.8892ms)
|
||||
✔ event batches preserve every landing and sequence but commit only highest audio and haptic (8.628ms)
|
||||
✔ completion waits at most 120ms and ignores late audio callbacks (0.7635ms)
|
||||
✔ light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse (0.9155ms)
|
||||
✔ unsupported graded haptic falls back to one short pulse, never long vibration (4.573ms)
|
||||
✔ haptic disable, preview and lifecycle cancellation suppress delayed or expired work (1.2133ms)
|
||||
✔ late failed haptic requests cannot revive old pulses and ungraded completion falls back once (16.0315ms)
|
||||
✔ rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls (58.0774ms)
|
||||
✔ one high-speed native move records all intermediate cells and completion without duplicate final node (333.9406ms)
|
||||
✔ legal but off-solution moves use public rules, while invalid clues only produce error (513.6096ms)
|
||||
✔ drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate (263.342ms)
|
||||
✔ tutorial, redraw, hint and restored saved routes do not generate player feedback (316.5441ms)
|
||||
✔ pressure and rebound have bounded amplitude and duration (0.7293ms)
|
||||
✔ interpolation cannot duplicate the same failed-cell event within one native movement (0.4654ms)
|
||||
✔ nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive (54.316ms)
|
||||
✔ audio fast moves merge to latest pitch and completion stops the whole four-channel pool (4.2573ms)
|
||||
✔ settings persist haptic preference independently and chapter cards always use three columns (196.5803ms)
|
||||
✔ original audio is preserved and high pitches lose energy rather than growing sharper (20.3321ms)
|
||||
✔ empty, partial and wrong routes reveal the first incomplete full segment (8.1753ms)
|
||||
✔ completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments (1.1961ms)
|
||||
✔ all 200 levels reveal consecutive segments without changing the player path (3772.7025ms)
|
||||
✔ hint draws skip player edges and mask visited cells on their own layer; compositing is restored (3.0586ms)
|
||||
✔ parity is a segment anchor; colour is not; flow moves continuously across cells (2.7027ms)
|
||||
✔ soft teaching ribbon preserves visited masks without arrows in reduced motion (3.0311ms)
|
||||
✔ hint rounds shared guide junctions and keeps separate runs disconnected (2.0014ms)
|
||||
✔ 实际教学关动画:basic (5.7751ms)
|
||||
✔ 实际教学关动画:walls (1.3457ms)
|
||||
✔ 实际教学关动画:straight (3.5357ms)
|
||||
✔ 实际教学关动画:vertical (5.928ms)
|
||||
✔ 实际教学关动画:corner (2.104ms)
|
||||
✔ 实际教学关动画:colour (1.229ms)
|
||||
✔ 实际教学关动画:parity (0.7272ms)
|
||||
✔ 实际教学关动画:rhythm-door (2.0488ms)
|
||||
✔ 实际教学关动画:hex (16.839ms)
|
||||
✔ 实际教学关动画:unknown-start (8.1501ms)
|
||||
✔ 200关的组合规则均能找到对应真实教学关 (146.9163ms)
|
||||
✔ 教学减速,结束停留1秒后自动循环 (3.789ms)
|
||||
✔ music loops independently, persists mute and cancels in background (2.5341ms)
|
||||
✔ old arrows retain distance, rush to endpoint and fresh arrows move at base speed (1.0276ms)
|
||||
✔ settings have exactly three independent preferences, and hint has its own cue (531.0682ms)
|
||||
✔ actual fixed numeric node uses checkpoint pitch, final cell uses complete chord (357.6796ms)
|
||||
✔ selected menu and BGMA loop are stereo PCM WAV with complete musical durations (12.9153ms)
|
||||
✔ selected runtime WAVs retain the original durations after sample-rate adaptation (46.8893ms)
|
||||
✔ gameplay ambient uses lower scene volume and keeps independent mute and resume behavior (0.7041ms)
|
||||
✔ all 200 levels: valid numeric, parity and direction arrivals play checkpoint pitch; final arrival only completes (53800.1232ms)
|
||||
✔ drag back and undo button both vibrate lightly; disabling vibration suppresses both (82.8582ms)
|
||||
✔ haptic cadence is approximately 15 percent denser without changing light or medium grades (3.0517ms)
|
||||
✔ regular and special scale settle at 90ms with a larger special peak (0.3485ms)
|
||||
✔ rendered undo target and nonnumeric checkpoint animate immediately without adding ribbons (9.0174ms)
|
||||
✔ quick undo cancels removed node rebound, including last-step removal (3.4522ms)
|
||||
✔ navigation buttons use the same selection cue and settings include uniform colons (63.494ms)
|
||||
✔ early next during confetti cancels warming; settled ceremony reuses its background (285.4005ms)
|
||||
✔ font corpus includes every settings label including music le glyph (1.8083ms)
|
||||
✔ first launch goes directly to gameplay (493.2614ms)
|
||||
✔ resume unfinished route after exit from gameplay (615.7597ms)
|
||||
✔ resume unfinished route after exit from selection (504.8706ms)
|
||||
✔ completed level resumes next unfinished, including leaving from cleared screen (183.7206ms)
|
||||
✔ replaying a cleared level does not replace the unfinished save (188.5401ms)
|
||||
✔ legacy saves, malformed data and full completion have valid startup targets (219.7827ms)
|
||||
✔ selection pages expose fifteen cards and fit phone safe areas (514.0614ms)
|
||||
✔ hint-only frames reuse static UI, and player input invalidates it (151.9144ms)
|
||||
✔ hint curves are cached and distance lookup is stable at boundaries (2.1228ms)
|
||||
✔ new-mechanic lesson reuses the board while the continuous cursor advances (130.4526ms)
|
||||
✔ BGMA stays continuous across level changes and completion redraws (2.7072ms)
|
||||
✔ subpackage completion never plays a stale scene after hiding, muting, or clearing (0.6587ms)
|
||||
✔ music load failure is silent and one real resume can retry; muted pending loads stay silent (0.6025ms)
|
||||
✔ runtime uses supplied WAV hashes and ships music as a subpackage with a small main bundle (266.2246ms)
|
||||
✔ title is centered and selection settings share the progress and level-range row (925.6168ms)
|
||||
✔ completion ducks BGMA until jingle ends and next level never restarts it (252.8562ms)
|
||||
✔ title artwork is a transparent, high resolution PNG packaged locally (12.136ms)
|
||||
✔ asynchronous native image load requests a repaint and draws the artwork without a font substitute (1.315ms)
|
||||
✔ artwork stays above progress pills and clear of settings; fifteen cards still fit all phone sizes (1184.7768ms)
|
||||
✔ A3 width, one wide patch and brighter body preserve original design proportions (7.0053ms)
|
||||
✔ ribbon caches its body, draws source-atop only offscreen and starts each frame clean (13.5722ms)
|
||||
✔ one patch travels at 60px/s on the full route and does not restart when player advances (0.5319ms)
|
||||
✔ reduced motion and missing canvas use a static ribbon without main-board compositing (0.6184ms)
|
||||
✔ real target contours and smoothed routes stay attached to actual 200-level geometry (2580.5724ms)
|
||||
✔ live target breath uses cached board layers, arrival and background stop obsolete visuals (273.3177ms)
|
||||
✔ undo button reverses seven played steps as 7,6,5,4,3,2,1 (723.4609ms)
|
||||
✔ drag reverses seven played steps as 7,6,5,4,3,2,1 (316.3682ms)
|
||||
✔ completion amplitude is reduced 15 percent while node, undo and restart volumes stay unchanged (2.476ms)
|
||||
✔ fast reverse coalesces to the latest descending pitch and cannot replay an old forward note (2.3642ms)
|
||||
✔ all prepared routes match the immutable level hash and public rules (32.8606ms)
|
||||
✔ entering next level draws the live board once and preserves the running BGMA player (2786.6811ms)
|
||||
✔ next-board warming advances one item per frame and cancels on deadline, replacement or hide (0.8861ms)
|
||||
✔ idle material warming never changes the player path or emits sound and vibration (113.63ms)
|
||||
ℹ tests 110
|
||||
ℹ suites 0
|
||||
ℹ pass 110
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 54956.9338
|
||||
|
||||
> numberswipe-main@1.0.0 smoke
|
||||
> node smoke.mjs
|
||||
|
||||
PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。
|
||||
@@ -0,0 +1 @@
|
||||
PASS:200/200关,点击A3指路前后和撤销重画后,玩家已走格子的实际Canvas填色像素一致。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,69 @@
|
||||
<!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}}
|
||||
header,.tools{display:none}body{width:375px}main{padding:0;display:block}.phone{border:0;border-radius:0;box-shadow:none}
|
||||
</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><label for="feedback-mode">反馈对照</label><select id="feedback-mode"><option value="enhanced">强化版</option><option value="original">原版</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">本页不触发触觉。原版/强化版切换用于音色和表现对照;iPhone、Android触觉与性能仍需真机验收。</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,feedbackMode='enhanced';
|
||||
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.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={createImage(){const i=new Image();return {get width(){return i.naturalWidth;},get height(){return i.naturalHeight;},set onload(fn){i.onload=fn;},set onerror(fn){i.onerror=fn;},set src(v){i.src=new URL('../'+v,location.href).href;},_native:i};},__feedbackPreview:true,__feedbackMode:typeof feedbackMode==='undefined'?'enhanced':feedbackMode,createCanvas(){const c=document.createElement('canvas');const p=c.getContext('2d'),draw=p.drawImage.bind(p);p.drawImage=(i,...args)=>draw(i?._native||i,...args);if(!canvases.length)canvases.push(c);return c;},loadFont:p=>p.split('/').at(-1).replace('.ttf',''),getWindowInfo:()=>({windowWidth:width,windowHeight:height,pixelRatio:2,safeArea:{top:24,bottom:height-24}}),getMenuButtonBoundingClientRect:()=>({bottom:42}),getPerformance:()=>({now:()=>now}),getStorageSync:k=>store.get(k)||'',setStorageSync:(k,v)=>store.set(k,v),removeStorageSync:k=>store.delete(k),setInnerAudioOption:sound.setInnerAudioOption,createInnerAudioContext:sound.createInnerAudioContext,showModal(){},env:{USER_DATA_PATH:'/preview'}};
|
||||
for(const n of ['TouchStart','TouchMove','TouchEnd','TouchCancel','Hide','Show','WindowResize'])wx['on'+n]=cb=>handlers[n]=cb;
|
||||
const global={};new Function('wx','GameGlobal','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout','Date',source)(wx,global,fn=>{frames.set(++frame,fn);return frame;},id=>frames.delete(id),later,cancel,Clock);
|
||||
const api=global.numberSwipeR2,canvas=canvases[0];canvas.style.width=width+'px';canvas.style.height=height+'px';phone.style.width=width+'px';phone.replaceChildren(canvas);
|
||||
function tick(ms=16){now+=ms;const callbacks=[...frames.values()];frames.clear();for(const fn of callbacks)fn();}
|
||||
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.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');if(api.snapshot().screen!=='select')button('选关');let n=0;while(!api.buttons().some(b=>b.label===String(index+1)||b.label===`${index+1} ✓`)){button(api.snapshot().selection.page>Math.floor(index/15)+1?'上一页':'下一页');if(++n>30)throw Error('分页失败');}button(api.buttons().some(b=>b.label===String(index+1))?String(index+1):`${index+1} ✓`);if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏');tick(300);}
|
||||
let down=false;
|
||||
const coords=e=>{const r=canvas.getBoundingClientRect();return [(e.clientX-r.left)*width/r.width,(e.clientY-r.top)*height/r.height];};
|
||||
canvas.onpointerdown=e=>{down=true;canvas.setPointerCapture(e.pointerId);touch('TouchStart',...coords(e));tick();};
|
||||
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}`;}
|
||||
document.querySelector('#feedback-mode').onchange=e=>{feedbackMode=e.target.value;show(Number(levelSelect.value));};
|
||||
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);
|
||||
|
||||
const settingsCheck=document.createElement('button');settingsCheck.textContent='设置文字验收';settingsCheck.onclick=()=>{runtime.button('设置');update();};document.querySelector('.tools').append(settingsCheck);
|
||||
const timingCheck=document.createElement('button');timingCheck.textContent='通关早点击验收';document.querySelector('.tools').append(timingCheck);
|
||||
timingCheck.onclick=()=>{const results=[];for(const i of [3,90,162,191])for(const delay of [0,40,110,300,3000]){runtime.select(i);for(const id of solve(levels[i])[0])runtime.cell(id);runtime.tick(delay);const start=performance.now();runtime.button('下一关 →');const elapsed=performance.now()-start;if(runtime.api.snapshot().displayOrder!==i+2||runtime.api.snapshot().cleared)throw Error('切关失败');results.push({level:i+1,delay,ms:Number(elapsed.toFixed(1))});}status.textContent='PASS 20/20:通关后0/40/110/300/3000ms切关,上一关演出均取消。真实Canvas浏览器数据,iOS真机待复测。\n'+JSON.stringify(results);};
|
||||
|
||||
const a3Sample=document.createElement('button');a3Sample.textContent='A3软糖样张';a3Sample.onclick=()=>{show(3);runtime.cell(solve(levels[3])[0][0]);runtime.button('指路');runtime.tick(900);update();};document.querySelector('.tools').append(a3Sample);
|
||||
const a3Check=document.createElement('button');a3Check.textContent='A3尺寸与动画验收';document.querySelector('.tools').append(a3Check);a3Check.onclick=()=>{
|
||||
const rows=[];for(const dimensions of ['320×568','375×812','390×844','414×896'])for(const [,index]of samples){if(index<0)continue;size.value=dimensions;show(index);runtime.cell(solve(levels[index])[0][0]);runtime.button('指路');runtime.tick(1800);const before=runtime.api.snapshot();for(let n=0;n<12;n++)runtime.tick(16);const after=runtime.api.snapshot();if(after.softHint.builds!==before.softHint.builds)throw Error('静态带面重复构建');if(after.hint&&after.softHint.targetCacheHits<=before.softHint.targetCacheHits)throw Error('目标暖光未使用缓存');rows.push(dimensions+' / 第'+(index+1)+'关');}
|
||||
status.textContent='PASS '+rows.length+'/'+rows.length+':A3四尺寸、七类真实关卡,静态带面未逐帧重建,目标呼吸使用缓存。浏览器验收,微信真机待验。\n'+rows.join(';');
|
||||
};
|
||||
size.value='375×812';a3Sample.onclick();
|
||||
</script></html>
|
||||
@@ -20,7 +20,7 @@ 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,feedbackMode='enhanced';
|
||||
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')]];
|
||||
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.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;
|
||||
@@ -58,4 +58,10 @@ show(3);function pump(){runtime.tick();requestAnimationFrame(pump);}requestAnima
|
||||
const settingsCheck=document.createElement('button');settingsCheck.textContent='设置文字验收';settingsCheck.onclick=()=>{runtime.button('设置');update();};document.querySelector('.tools').append(settingsCheck);
|
||||
const timingCheck=document.createElement('button');timingCheck.textContent='通关早点击验收';document.querySelector('.tools').append(timingCheck);
|
||||
timingCheck.onclick=()=>{const results=[];for(const i of [3,90,162,191])for(const delay of [0,40,110,300,3000]){runtime.select(i);for(const id of solve(levels[i])[0])runtime.cell(id);runtime.tick(delay);const start=performance.now();runtime.button('下一关 →');const elapsed=performance.now()-start;if(runtime.api.snapshot().displayOrder!==i+2||runtime.api.snapshot().cleared)throw Error('切关失败');results.push({level:i+1,delay,ms:Number(elapsed.toFixed(1))});}status.textContent='PASS 20/20:通关后0/40/110/300/3000ms切关,上一关演出均取消。真实Canvas浏览器数据,iOS真机待复测。\n'+JSON.stringify(results);};
|
||||
|
||||
const a3Sample=document.createElement('button');a3Sample.textContent='A3软糖样张';a3Sample.onclick=()=>{show(3);runtime.cell(solve(levels[3])[0][0]);runtime.button('指路');runtime.tick(900);update();};document.querySelector('.tools').append(a3Sample);
|
||||
const a3Check=document.createElement('button');a3Check.textContent='A3尺寸与动画验收';document.querySelector('.tools').append(a3Check);a3Check.onclick=()=>{
|
||||
const rows=[];for(const dimensions of ['320×568','375×812','390×844','414×896'])for(const [,index]of samples){if(index<0)continue;size.value=dimensions;show(index);runtime.cell(solve(levels[index])[0][0]);runtime.button('指路');runtime.tick(1800);const before=runtime.api.snapshot();for(let n=0;n<12;n++)runtime.tick(16);const after=runtime.api.snapshot();if(after.softHint.builds!==before.softHint.builds)throw Error('静态带面重复构建');if(after.hint&&after.softHint.targetCacheHits<=before.softHint.targetCacheHits)throw Error('目标暖光未使用缓存');rows.push(dimensions+' / 第'+(index+1)+'关');}
|
||||
status.textContent='PASS '+rows.length+'/'+rows.length+':A3四尺寸、七类真实关卡,静态带面未逐帧重建,目标呼吸使用缓存。浏览器验收,微信真机待验。\n'+rows.join(';');
|
||||
};
|
||||
</script></html>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"channel": "main",
|
||||
"version": "2026-10-23-ios-clear-ui-fix",
|
||||
"version": "2026-10-23-soft-ribbon-a3-bright",
|
||||
"date": "2026-10-23",
|
||||
"baseVersion": "2026-10-22-resume-chapter-lessons",
|
||||
"baseMainSHA256": "bb35d5edf8d96eb3253c78052d219b32f9a4241efd9971537f6c9c8896188125",
|
||||
"package": "NumberSwipe-main-iOS通关切换与界面反馈修复-20261023.zip",
|
||||
"package": "NumberSwipe-main-A3明亮软糖指路-20261023.zip",
|
||||
"promotedFrom": "2026-10-22-layered-feedback-music-flow",
|
||||
"previousVersion": "2026-10-23-descending-undo-fast-next"
|
||||
"previousVersion": "2026-10-23-ios-clear-ui-fix",
|
||||
"baseCommit": "6abbc37",
|
||||
"includes": [
|
||||
"ios-clear-ui-fix",
|
||||
"soft-ribbon-a3-bright"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
|
||||
import {SoftHintPainter,softHintTokens as T,softHighlightAt,targetBreath} from './src/soft-hint.mjs';
|
||||
import {prepareHintMotion} from './src/hint-motion.mjs';import {boot} from './qa/runtime-harness.mjs';
|
||||
import {renderBrightBoard,hintGeometry} from './src/bright-classroom/board-renderer.mjs';import {inside} from './src/bright-classroom/geometry.mjs';
|
||||
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id)),routes=JSON.parse(fs.readFileSync(new URL('./src/prepared-routes.json',import.meta.url))).routes;
|
||||
function context(events=[]){const stack=[];return new Proxy({globalCompositeOperation:'source-over',globalAlpha:1,measureText:()=>({width:20}),save(){stack.push([this.globalCompositeOperation,this.globalAlpha]);},restore(){[this.globalCompositeOperation,this.globalAlpha]=stack.pop();},createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}}),fill(){events.push(['fill',this.globalCompositeOperation]);},fillRect(){events.push(['rect',this.globalCompositeOperation]);},drawImage(){events.push(['image',this.globalCompositeOperation]);}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});}
|
||||
const hint={route:['a','b','c'],guides:[{a:'a',b:'b',points:[[30,30],[150,30]]},{a:'b',b:'c',points:[[150,30],[150,160]]}],keepouts:[],visitedShapes:[],boardWidth:200,boardHeight:220,dpr:2};
|
||||
test('A3 width, one wide patch and brighter body preserve original design proportions',()=>{const original=JSON.parse(fs.readFileSync(new URL('../docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json',import.meta.url)));assert.equal(T.body.widthRatio,.235);assert.equal(T.highlight.count,1);assert.equal(T.body.crownWidthRatio,.76);assert.equal(T.endpointFade.radiusRatio,.39);assert.equal(T.body.widthRatio,original.body.widthRatio);assert.notDeepEqual(T.body.gradient,original.body.gradient);for(const pitch of [20,36,104,200])assert.equal(prepareHintMotion(hint,pitch,[]).width,Math.max(7,Math.min(26,pitch*.235)));});
|
||||
test('ribbon caches its body, draws source-atop only offscreen and starts each frame clean',()=>{let canvases=0;const internal=[],out=[],painter=new SoftHintPainter(()=>{canvases++;return {width:0,height:0,getContext:()=>context(internal)};}),ctx=context(out);painter.draw(ctx,hint,50,300,[]);const base=painter.base;for(let i=0;i<60;i++)painter.draw(ctx,hint,50,300+i*16,[]);assert.equal(canvases,2);assert.equal(painter.base,base);assert.ok(internal.some(e=>e[1]==='source-atop'));assert.ok(out.every(e=>e[1]==='source-over'));painter.reset();assert.equal(painter.base,null);});
|
||||
test('one patch travels at 60px/s on the full route and does not restart when player advances',()=>{const motion=prepareHintMotion(hint,50,[]);const a=softHighlightAt(motion,300),b=softHighlightAt(motion,800);assert.ok(Math.abs(b.at-a.at-30)<.001);assert.equal(a.cycle,Math.max(1800,motion.total/60*1000));assert.ok(a.at<b.at);const rest=prepareHintMotion(hint,50,['a','b']);assert.equal(rest.guides.length,1);assert.equal(softHighlightAt(motion,800).at,b.at);});
|
||||
test('reduced motion and missing canvas use a static ribbon without main-board compositing',()=>{const log=[],p=new SoftHintPainter(()=>null),ctx=context(log);p.draw(ctx,{...hint,reducedMotion:true},50,0,[]);assert.ok(!log.some(e=>e[1]==='source-atop'));assert.equal(ctx.globalCompositeOperation,'source-over');assert.equal(targetBreath(0,true),1);assert.equal(targetBreath(0),.65);assert.equal(targetBreath(1100),1);});
|
||||
test('real target contours and smoothed routes stay attached to actual 200-level geometry',()=>{const ctx=context();for(const level of levels){const route=routes[level.id],scene=renderBrightBoard(ctx,level,[],375,468,{preview:true,now:0}),h=hintGeometry(scene,[],route),m=prepareHintMotion(h,scene.pitch,[]);assert.equal(h.targetId,route.at(-1));assert.equal(h.targetShape.rings,scene.byId.get(route.at(-1)).rings);assert.ok(m.runs.length);for(const run of m.runs)for(const p of run){const valid=h.routeShapes.some(s=>s.parts.some(r=>inside(p,r)));if(!valid)assert.ok(h.guides.some(g=>g.points.some(q=>Math.hypot(q[0]-p[0],q[1]-p[1])<scene.pitch*.03)),level.id+' escaped contour');}}});
|
||||
test('live target breath uses cached board layers, arrival and background stop obsolete visuals',()=>{const ui=boot();ui.select(3);ui.button('指路');ui.advance(400);const before=ui.api.snapshot();for(let i=0;i<10;i++)ui.advance(16);const after=ui.api.snapshot();assert.ok(after.softHint.targetCacheHits>before.softHint.targetCacheHits);assert.equal(after.softHint.builds,before.softHint.builds);const sequence=after.feedback.sequence;const h=after.hint;for(const id of routes[levels[3].id].slice(0,h.end+1))ui.cell(id);assert.equal(ui.api.snapshot().hint,null);ui.handlers.Hide();assert.equal(ui.frames.size,0);assert.ok(ui.api.snapshot().feedback.sequence>=sequence);});
|
||||
@@ -1,3 +1,4 @@
|
||||
import {drawSoftTarget,targetBreath} from '../soft-hint.mjs';
|
||||
import {feedback} from '../feedback.mjs';
|
||||
import {FEEDBACK} from '../feedback-params.mjs';
|
||||
import {violations,sideBetween,doorOpen,doorLatched} from '../upstream/rules.mjs';
|
||||
@@ -7,7 +8,7 @@ import {cushion} from './cushion.mjs';
|
||||
import {numberStamp} from './number-stamp.mjs';
|
||||
|
||||
const caches=new WeakMap();
|
||||
export const boardDiagnostics={liveRenders:0,previewRenders:0};
|
||||
export const boardDiagnostics={liveRenders:0,previewRenders:0,targetCacheHits:0};
|
||||
export const preferences={reducedMotion:false};
|
||||
const errorIds=bad=>new Set(bad.flatMap(b=>b.cells));
|
||||
|
||||
@@ -189,13 +190,16 @@ export function drawBarriers(ctx,scene,path,now=Date.now()){
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),preview=false}={}){
|
||||
export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),preview=false,hintTarget=null,hintElapsed=0,pixelRatio=1}={}){
|
||||
boardDiagnostics[preview?'previewRenders':'liveRenders']++;
|
||||
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('|');scene.now=now;
|
||||
const target=hintTarget&&!path.includes(hintTarget)&&!preview?hintTarget:null;
|
||||
const targetKey=JSON.stringify([pathKey,target,width,height,pixelRatio,feedback.epoch,feedback.sequence,preferences.reducedMotion]);
|
||||
if(target&&scene.hintLayers?.key===targetKey&&!scene.animating){boardDiagnostics.targetCacheHits++;compositeTarget(ctx,scene,hintElapsed);return scene;}
|
||||
if(scene.feedbackEpoch!==feedback.epoch){scene.bursts=[];scene.feedbackSequence=0;scene.feedbackEpoch=feedback.epoch;}
|
||||
scene.feedbackEvents=preview?[]:feedback.active(level,now);
|
||||
if(!preview&&feedback.enhanced){
|
||||
@@ -226,14 +230,24 @@ export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),pr
|
||||
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++)drawSymbols(ctx,scene,scene.cells[i],scene.views[i],path);
|
||||
drawCheckpointRibbons(ctx,scene,now);
|
||||
drawBarriers(ctx,scene,path,now);ctx.restore();
|
||||
let layers=null;
|
||||
if(target&&typeof wx!=='undefined'&&wx.createCanvas){
|
||||
layers=scene.hintLayers||{base:wx.createCanvas(),top:wx.createCanvas(),glow:wx.createCanvas()};
|
||||
for(const c of [layers.base,layers.top,layers.glow]){if(c.width!==Math.round(width*pixelRatio))c.width=Math.round(width*pixelRatio);if(c.height!==Math.round(height*pixelRatio))c.height=Math.round(height*pixelRatio);const p=c.getContext('2d');p.setTransform(pixelRatio,0,0,pixelRatio,0,0);p.clearRect(0,0,width,height);}
|
||||
layers.key=targetKey;scene.hintLayers=layers;
|
||||
}else scene.hintLayers=null;
|
||||
const base=layers?layers.base.getContext('2d'):ctx,top=layers?layers.top.getContext('2d'):ctx;
|
||||
base.clearRect(0,0,width,height);base.save();
|
||||
for(let i=0;i<scene.cells.length;i++)drawMaterial(base,scene.cells[i],scene.views[i],scene,true);
|
||||
drawBridges(base,scene,path,true);
|
||||
for(let i=0;i<scene.cells.length;i++)withCheckpointScale(base,scene,scene.cells[i],now,()=>drawMaterial(base,scene.cells[i],scene.views[i],scene));
|
||||
drawBridges(base,scene,path);base.restore();
|
||||
if(layers){const glow=layers.glow.getContext('2d');drawSoftTarget(glow,scene,target,0,true);glow.save();glow.globalCompositeOperation='destination-out';glow.fillStyle='#000';glow.strokeStyle='#000';for(const g of scene.cells.filter(g=>scene.steps.has(g.cell.id))){for(const dy of [0,scene.depth]){trace(glow,g.rings,g.radius,dy);glow.fill();glow.lineWidth=2*(clamp(3,scene.pitch*.065,7)+1);glow.stroke();}}glow.restore();}
|
||||
else if(target)drawSoftTarget(ctx,scene,target,0,true);
|
||||
top.save();
|
||||
for(let i=0;i<scene.cells.length;i++)drawSymbols(top,scene,scene.cells[i],scene.views[i],path);
|
||||
drawCheckpointRibbons(top,scene,now);drawBarriers(top,scene,path,now);top.restore();
|
||||
if(layers)compositeTarget(ctx,scene,hintElapsed);
|
||||
scene.animating=!preview&&!preferences.reducedMotion&&(scene.bursts.length>0||scene.feedbackEvents.some(e=>now-e.time<(e.kind==='checkpoint'||e.kind==='complete'?FEEDBACK.checkpointMs:FEEDBACK.moveMs))||[...scene.doorTransitions.values()].some(d=>d.from!==d.to&&now-d.started<T.motionMs.door));
|
||||
return scene;
|
||||
}
|
||||
@@ -249,7 +263,7 @@ export function hintGeometry(scene,path,route){
|
||||
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)]});
|
||||
}
|
||||
const value={guides,keepouts:scene.keepouts,visitedShapes:scene.cells.filter(g=>path.includes(g.cell.id)).map(g=>({rings:g.rings,radius:g.radius,depth:scene.depth,maskPad:clamp(3,scene.pitch*.065,7)+1})),reducedMotion:preferences.reducedMotion};
|
||||
const value={guides,targetId:route.at(-1),targetShape:scene.byId.get(route.at(-1))?{rings:scene.byId.get(route.at(-1)).rings,radius:scene.byId.get(route.at(-1)).radius}:null,routeShapes:route.map(id=>scene.byId.get(id)).filter(Boolean).map(g=>({parts:g.parts})),pitch:scene.pitch,boardWidth:scene.width,boardHeight:scene.height,keepouts:scene.keepouts,visitedShapes:scene.cells.filter(g=>path.includes(g.cell.id)).map(g=>({rings:g.rings,radius:g.radius,depth:scene.depth,maskPad:clamp(3,scene.pitch*.065,7)+1})),reducedMotion:preferences.reducedMotion};
|
||||
hintGeometryCache.set(scene,{key,keepouts:scene.keepouts,value});return value;
|
||||
}
|
||||
|
||||
@@ -289,3 +303,5 @@ export function* warmBoardMaterials(ctx,level,width,height){
|
||||
yield;
|
||||
for(const g of scene.cells){const view=appearance(g.cell,undefined,false);drawMaterial(ctx,g,view,scene);drawSymbols(ctx,scene,g,view,[]);yield;}
|
||||
}
|
||||
|
||||
function compositeTarget(ctx,scene,elapsed){const {width,height,hintLayers:l}=scene;ctx.clearRect(0,0,width,height);ctx.drawImage(l.base,0,0,width,height);ctx.save();ctx.globalAlpha=targetBreath(elapsed,preferences.reducedMotion);ctx.drawImage(l.glow,0,0,width,height);ctx.restore();ctx.drawImage(l.top,0,0,width,height);}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {distance,inside} from './bright-classroom/geometry.mjs';
|
||||
import {T,clamp} from './bright-classroom/theme.mjs';
|
||||
const preparedHints=new WeakMap();
|
||||
export function prepareHintMotion(hint,unit,path){
|
||||
const key=JSON.stringify([unit,path]),cached=preparedHints.get(hint.guides)||new Map();
|
||||
if(cached.has(key))return cached.get(key);
|
||||
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));
|
||||
let radius=unit*T.hintSoftRibbon.body.cornerRadiusRatio,runs=smoothHintRuns(guides,radius);
|
||||
if(hint.routeShapes?.length){const legal=p=>hint.routeShapes.some(s=>s.parts.some(r=>inside(p,r)))||guides.some(g=>g.points.slice(1).some((b,i)=>nearSegment(p,g.points[i],b)<=unit*.02));for(let n=0;n<8&&runs.some(r=>r.some(p=>!legal(p)));n++){radius=n===7?0:radius*.5;runs=smoothHintRuns(guides,radius);}}
|
||||
const width=clamp(T.hintSoftRibbon.body.widthMin,unit*T.hintSoftRibbon.body.widthRatio,T.hintSoftRibbon.body.widthMax);
|
||||
let total=0;
|
||||
const segments=runs.flatMap(points=>points.slice(1).map((b,i)=>({a:points[i],b,length:distance(points[i],b)}))).filter(s=>s.length>.01).map(s=>({...s,start:total,end:total+=s.length}));
|
||||
const value={guides,runs,width,segments,total};cached.set(key,value);if(cached.size>4)cached.delete(cached.keys().next().value);preparedHints.set(hint.guides,cached);return value;
|
||||
}
|
||||
export function hintPointAt(motion,at){
|
||||
const {segments}=motion;if(!segments.length)return null;
|
||||
let low=0,high=segments.length-1;
|
||||
while(low<high){const mid=(low+high)>>1;if(segments[mid].end<at)low=mid+1;else high=mid;}
|
||||
const segment=segments[low],t=Math.max(0,Math.min(1,(at-segment.start)/segment.length));
|
||||
return {segment,x:segment.a[0]+(segment.b[0]-segment.a[0])*t,y:segment.a[1]+(segment.b[1]-segment.a[1])*t};
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
||||
function nearSegment(p,a,b){const dx=b[0]-a[0],dy=b[1]-a[1],l=dx*dx+dy*dy,t=l?Math.max(0,Math.min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/l)):0;return Math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy);}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {C} from './bright-classroom/theme.mjs';
|
||||
import {trace,distance} from './bright-classroom/geometry.mjs';
|
||||
|
||||
import {drawSoftHint} from './soft-hint.mjs';
|
||||
export {prepareHintMotion,hintPointAt,smoothHintRuns} from './hint-motion.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,149 +24,10 @@ 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=[],arrows=null){
|
||||
if(!hint||hint.route.length<2)return;
|
||||
if(hint.guides){drawGeometryHint(ctx,hint,unit,elapsed,playerPath,arrows);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)));});
|
||||
const width=Math.max(4,Math.min(9,unit*.13));
|
||||
const progress=elapsed%Math.max(1200,(points.length-1)*240)/Math.max(1200,(points.length-1)*240)*(points.length-1);
|
||||
ctx.save();ctx.lineCap='round';ctx.lineJoin='round';
|
||||
const trace=(i,color,w)=>{if(covered[i])return;ctx.beginPath();ctx.moveTo(...points[i]);ctx.lineTo(...points[i+1]);ctx.strokeStyle=color;ctx.lineWidth=w;ctx.stroke();};
|
||||
for(let i=0;i<points.length-1;i++)trace(i,'rgba(35,36,58,.62)',width+4);
|
||||
for(let i=0;i<points.length-1;i++)trace(i,'rgba(255,255,255,.78)',width);
|
||||
for(let j=10;j>=0;j--){
|
||||
const t=progress-j*.045;if(t<0)continue;
|
||||
const k=Math.min(Math.floor(t),points.length-2),f=t-k;if(covered[k])continue;
|
||||
const a=points[k],b=points[k+1],x=a[0]+(b[0]-a[0])*f,y=a[1]+(b[1]-a[1])*f;
|
||||
ctx.beginPath();ctx.arc(x,y,width*(.45+(10-j)*.035),0,Math.PI*2);
|
||||
ctx.fillStyle=`rgba(255,255,255,${.12+(10-j)*.075})`;ctx.fill();
|
||||
if(j===0){ctx.strokeStyle='rgba(35,36,58,.9)';ctx.lineWidth=2;ctx.stroke();}
|
||||
}
|
||||
for(const i of [0,points.length-1]){
|
||||
if(visited.has(hint.route[i]))continue;
|
||||
ctx.beginPath();ctx.arc(...points[i],Math.max(8,unit*.23),0,Math.PI*2);
|
||||
ctx.strokeStyle='rgba(35,36,58,.72)';ctx.lineWidth=7;ctx.stroke();
|
||||
ctx.strokeStyle='rgba(255,255,255,.92)';ctx.lineWidth=3;ctx.stroke();
|
||||
}
|
||||
// Clear the hint inside every actually visited cell, including wrong routes.
|
||||
// The base board's filled cells, numbers and channel effects stay fully visible.
|
||||
ctx.globalCompositeOperation='destination-out';ctx.fillStyle='#000000';
|
||||
for(const p of hint.visitedPolygons||[]){
|
||||
ctx.beginPath();ctx.moveTo(...p[0]);for(const v of p.slice(1))ctx.lineTo(...v);ctx.closePath();ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
const preparedHints=new WeakMap();
|
||||
export function prepareHintMotion(hint,unit,path){
|
||||
const key=JSON.stringify([unit,path]),cached=preparedHints.get(hint.guides)||new Map();
|
||||
if(cached.has(key))return cached.get(key);
|
||||
const covered=(a,b)=>path.some((id,i)=>i>0&&((path[i-1]===a&&id===b)||(path[i-1]===b&&id===a)));
|
||||
const guides=hint.guides.filter(g=>!covered(g.a,g.b)),runs=smoothHintRuns(guides,unit*.16),width=Math.max(4,Math.min(8,unit*.12));
|
||||
let total=0;
|
||||
const segments=runs.flatMap(points=>points.slice(1).map((b,i)=>({a:points[i],b,length:distance(points[i],b)}))).filter(s=>s.length>.01).map(s=>({...s,start:total,end:total+=s.length}));
|
||||
const value={guides,runs,width,segments,total};cached.set(key,value);if(cached.size>4)cached.delete(cached.keys().next().value);preparedHints.set(hint.guides,cached);return value;
|
||||
}
|
||||
export function hintPointAt(motion,at){
|
||||
const {segments}=motion;if(!segments.length)return null;
|
||||
let low=0,high=segments.length-1;
|
||||
while(low<high){const mid=(low+high)>>1;if(segments[mid].end<at)low=mid+1;else high=mid;}
|
||||
const segment=segments[low],t=Math.max(0,Math.min(1,(at-segment.start)/segment.length));
|
||||
return {segment,x:segment.a[0]+(segment.b[0]-segment.a[0])*t,y:segment.a[1]+(segment.b[1]-segment.a[1])*t};
|
||||
}
|
||||
function drawGeometryHint(ctx,hint,unit,elapsed,path,arrows){
|
||||
const motion=prepareHintMotion(hint,unit,path),{guides,runs,width,total}=motion;
|
||||
ctx.save();ctx.lineCap='round';ctx.lineJoin='round';
|
||||
if(hint.reducedMotion)ctx.setLineDash([7,5]);
|
||||
const draw=(color,w)=>{ctx.strokeStyle=color;ctx.lineWidth=w;for(const points of runs){if(!points.length)continue;ctx.beginPath();ctx.moveTo(...points[0]);for(const p of points.slice(1))ctx.lineTo(...p);ctx.stroke();}};
|
||||
// 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 full=arrows?prepareHintMotion(hint,unit,[]):motion;
|
||||
let start=0;const first=runs[0]?.[0];if(first&&arrows){let nearest=Infinity;for(const s of full.segments){const d=distance(s.a,first);if(d<nearest){nearest=d;start=s.start;}}}
|
||||
const cycle=Math.max(1600,total/65*1000),head=(elapsed%cycle)/cycle*total;
|
||||
const heads=arrows?arrows.manager.heads(arrows,full,start):[head,(head+total/3)%total,(head+total*2/3)%total];
|
||||
// Three markers carry creamy tails along the same rounded path.
|
||||
for(const pulseHead of heads){
|
||||
for(let j=16;j>=0;j--){
|
||||
let at=pulseHead-j*Math.max(.9,width*.30);if(at<0)continue;
|
||||
const point=hintPointAt(arrows?full:motion,at);if(point){
|
||||
const {segment,x,y}=point;
|
||||
ctx.globalAlpha=(1-j/18)*.85;ctx.shadowColor='rgba(255,245,215,.65)';ctx.shadowBlur=j===0?width*1.2:0;
|
||||
ctx.beginPath();ctx.arc(x,y,width*(.23+(16-j)/16*.4),0,Math.PI*2);ctx.fillStyle=C.hintSpark;ctx.fill();
|
||||
if(j===0){ctx.globalAlpha=1;ctx.shadowBlur=0;hintArrow(ctx,segment.a,segment.b,x,y,width);}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
});
|
||||
// All compositing stays on the dedicated transparent hint canvas.
|
||||
export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
|
||||
if(!hint||hint.route.length<2)return;
|
||||
if(!hint.guides){const guides=hint.route.slice(1).flatMap((b,i)=>centers[hint.route[i]]&¢ers[b]?[{a:hint.route[i],b,points:[centers[hint.route[i]],centers[b]]}]:[]);hint={...hint,guides};}
|
||||
drawSoftHint(ctx,hint,hint.pitch||unit,elapsed,playerPath);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {IdleWarmup} from './idle-warmup.mjs';
|
||||
import {loadSelectionTitle,selectionRow} from './selection-title.mjs';
|
||||
import {Music} from './music.mjs';
|
||||
import {HintArrows} from './hint-arrows.mjs';
|
||||
import {resetSoftHint,softHintDiagnostics} from './soft-hint.mjs';
|
||||
import {feedback} from './feedback.mjs';
|
||||
import releaseInfo from '../release-info.json' with {type:'json'};
|
||||
import {chapterLayout,levelCard,selectionHeader} from './level-select.mjs';
|
||||
import {CLEAR_DURATION,clearProgress,drawClearCelebration} from './bright-classroom/celebration.mjs';
|
||||
import {stopAudio,audioDiagnostics} from './audio.mjs';
|
||||
import { mainCanvas, boardCanvas, document, window, PACK, setViewInvalidator } from './platform.mjs';
|
||||
import {renderLesson,hintView,resumeGameplay,refreshBrightBoard,brightOverlay,brightAnimating} from './upstream/ui.mjs';
|
||||
import {renderLesson,hintView,resumeGameplay,refreshBrightBoard,brightOverlay,brightAnimating,resetBrightHint} from './upstream/ui.mjs';
|
||||
import {drawHint} from './hints.mjs';
|
||||
import {lessonKeys,makeLesson,lessonFrame,loopLessonTime} from './lessons.mjs';
|
||||
import {tutorials} from './copy.mjs';
|
||||
@@ -22,7 +22,7 @@ const warmCanvas=wx.createCanvas();warmCanvas.width=1;warmCanvas.height=1;
|
||||
const warmup=new IdleWarmup({canRun:()=>visible&&pointer===null});let warmupKey='';
|
||||
let rulePage=0,ruleKey='',lessonBoardCache=null,lessonBoardRenders=0;
|
||||
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
|
||||
const music=new Music(wx),hintArrows=new HintArrows();
|
||||
const music=new Music(wx);
|
||||
let hintAnimation={key:'',started:0};
|
||||
let settingsOpen=false,pressedLabel='',pressedUntil=0;
|
||||
let celebrationSuppressed=false;
|
||||
@@ -38,7 +38,7 @@ function text(value, x, y, size = 16, color = C.ink) { label(ctx,value,x,y,size,
|
||||
function splitLines(value,maxWidth,size=16){textFont(ctx,size);const lines=[];for(const paragraph of String(value).split('\n')){let line='';for(const c of paragraph){if(line&&ctx.measureText(line+c).width>maxWidth){lines.push(line);line='';}line+=c;}lines.push(line);}return lines;}
|
||||
function wrapped(value,x,y,maxWidth,size=16){const lines=splitLines(value,maxWidth,size);lines.forEach((line,i)=>text(line,x,y+i*(size+9),size));return lines.length;}
|
||||
function button(label, x, y, w, h, run, { enabled = true, primary = false } = {}) { buttonArt(ctx,label,x,y,w,h,{enabled,primary,pressed:!preferences.reducedMotion&&pressedLabel===label&&Date.now()<pressedUntil}); buttons.push({ label, x, y, w, h, run, enabled }); }
|
||||
function click(id) { if(id==='btn-rules')ruleKey=''; $('board').handlers.pointerup?.(); pointer = null; $(id).click(); if(id==='hint'&&hintView())feedback.audio.play('hint'); }
|
||||
function click(id) { if(['restart','btn-back','next'].includes(id)){hintAnimation={key:'',started:0};resetSoftHint(hintContext);resetBrightHint();} if(id==='btn-rules')ruleKey=''; $('board').handlers.pointerup?.(); pointer = null; $(id).click(); if(id==='hint'&&hintView())feedback.audio.play('hint'); }
|
||||
function levelButtons() { return $('pack-list').children.flatMap(section => section.children[1].children); }
|
||||
function paintHintLayer(hint,boardState,d){
|
||||
|
||||
@@ -49,10 +49,10 @@ function paintHintLayer(hint,boardState,d){
|
||||
if(hintCanvas.width!==Math.round(width*d))hintCanvas.width=Math.round(width*d);
|
||||
if(hintCanvas.height!==Math.round(h*d))hintCanvas.height=Math.round(h*d);
|
||||
hintContext.setTransform(d,0,0,d,0,0);hintContext.clearRect(0,0,width,h);
|
||||
drawHint(hintContext,hint,boardState.centers,boardState.unit,Date.now()-hintAnimation.started,boardState.path,hintArrows.frame(key,Date.now(),feedback.events));
|
||||
drawHint(hintContext,{...hint,dpr:d},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};hintArrows.reset();}
|
||||
}else {hintAnimation={key:'',started:0};resetSoftHint(hintContext);resetBrightHint();}
|
||||
|
||||
}
|
||||
function draw() {
|
||||
@@ -80,7 +80,7 @@ function draw() {
|
||||
const cacheEligible=Boolean(animationHint)&&state.screen==='play'&&!state.cleared&&!settingsOpen&&$('rules-sheet').hidden&&!brightAnimating()&&Date.now()>=pressedUntil;
|
||||
const baseKey=JSON.stringify([state.level,state.path,width,height,d,preferences.reducedMotion]);
|
||||
if(cacheEligible&&hintBaseKey===baseKey){
|
||||
hintBaseHits++;ctx.drawImage(hintBaseCanvas,0,0,width,height);paintHintLayer(animationHint,state,d);
|
||||
hintBaseHits++;ctx.drawImage(hintBaseCanvas,0,0,width,height);refreshBrightBoard(Date.now());ctx.drawImage(boardCanvas,0,top+82,width,$('board').clientHeight);paintHintLayer(animationHint,state,d);
|
||||
if(!preferences.reducedMotion)requestPaint();return;
|
||||
}
|
||||
const currentLevel=ordered.find(l=>l.id===state.level);
|
||||
@@ -228,7 +228,7 @@ const release = e => { if (e.changedTouches.some(p => p.identifier === pointer))
|
||||
} };
|
||||
wx.onTouchEnd(release);
|
||||
wx.onTouchCancel(release);
|
||||
wx.onHide(() => { visible = false;clearBaseKey='';warmup.stop();warmupKey='';music.suspend();hintArrows.reset(); lessonStarted=Date.now(); celebrationSuppressed=window.variantSnapshot().cleared;clearCelebration=null;feedback.reset();stopAudio(); cancelAnimationFrame(raf);raf=0; pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
|
||||
wx.onHide(() => { visible = false;clearBaseKey='';warmup.stop();warmupKey='';music.suspend();hintAnimation={key:'',started:0};resetSoftHint(hintContext);resetBrightHint(); lessonStarted=Date.now(); celebrationSuppressed=window.variantSnapshot().cleared;clearCelebration=null;feedback.reset();stopAudio(); cancelAnimationFrame(raf);raf=0; pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
|
||||
wx.onShow(() => { if (!visible) {
|
||||
visible = true;lessonStarted=Date.now();settingsOpen=false;resumeGameplay();
|
||||
const resumed=window.variantSnapshot();music.scene(resumed.screen==='select'?'select':resumed.cleared?'complete':'play',resumed.level);music.resume();
|
||||
@@ -239,7 +239,7 @@ wx.onAudioInterruptionBegin?.(()=>{music.suspend();feedback.reset();stopAudio();
|
||||
wx.onAudioInterruptionEnd?.(()=>{if(visible){music.resume();requestPaint();}});
|
||||
wx.onWindowResize?.(resize);
|
||||
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
|
||||
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),theme:'bright-classroom',release:releaseInfo,fonts:{...fontDiagnostics},renderedFrames,clearBaseHits,warming: Boolean(warmup.pending),boardRenders:boardDiagnostics.liveRenders,hintBaseHits,lessonBoardRenders,music:music.snapshot(),selection:{page:page+1,pages:Math.ceil(ordered.length/15),pageSize:15,chapter:page+1,chapters:Math.ceil(ordered.length/15)},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},feedback:feedback.snapshot(),rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,finished:lessonState.finished,duration:lesson?.duration,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
|
||||
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),theme:'bright-classroom',release:releaseInfo,fonts:{...fontDiagnostics},renderedFrames,clearBaseHits,warming: Boolean(warmup.pending),boardRenders:boardDiagnostics.liveRenders,hintBaseHits,lessonBoardRenders,music:music.snapshot(),selection:{page:page+1,pages:Math.ceil(ordered.length/15),pageSize:15,chapter:page+1,chapters:Math.ceil(ordered.length/15)},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),softHint:{...softHintDiagnostics,targetCacheHits:boardDiagnostics.targetCacheHits},audio:{...audioDiagnostics},feedback:feedback.snapshot(),rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,finished:lessonState.finished,duration:lesson?.duration,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
|
||||
feedback.audio.beforeComplete=()=>music.duck();
|
||||
feedback.audio.afterComplete=()=>music.unduck();
|
||||
loadSelectionTitle(wx,requestPaint);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {T,clamp} from './bright-classroom/theme.mjs';
|
||||
import {trace} from './bright-classroom/geometry.mjs';
|
||||
import {prepareHintMotion,hintPointAt} from './hint-motion.mjs';
|
||||
|
||||
export const softHintDiagnostics={builds:0,frames:0,staticFallbacks:0};
|
||||
export const softHintTokens=T.hintSoftRibbon;
|
||||
const painters=new WeakMap();
|
||||
const gradient=(ctx,stops,height)=>{const g=ctx.createLinearGradient(0,0,0,height);if(!g?.addColorStop)return stops[1][1];for(const [at,color]of stops)g.addColorStop(at,color);return g;};
|
||||
function strokeRuns(ctx,runs,color,width){ctx.strokeStyle=color;ctx.lineWidth=width;ctx.lineCap='round';ctx.lineJoin='round';for(const points of runs){if(points.length<2)continue;ctx.beginPath();ctx.moveTo(...points[0]);for(const p of points.slice(1))ctx.lineTo(...p);ctx.stroke();}}
|
||||
function body(ctx,motion,height){
|
||||
const p=softHintTokens.body,w=motion.width;ctx.save();
|
||||
ctx.shadowColor=p.shadowColor;ctx.shadowBlur=p.shadowBlur;ctx.shadowOffsetY=p.shadowY;
|
||||
strokeRuns(ctx,motion.runs,p.edgeOuter,w+p.edgeOuterExtra);ctx.shadowBlur=0;ctx.shadowOffsetY=0;
|
||||
strokeRuns(ctx,motion.runs,p.edgeInner,w+p.edgeInnerExtra);
|
||||
strokeRuns(ctx,motion.runs,gradient(ctx,p.gradient,height),w);
|
||||
ctx.translate(...p.crownOffset);strokeRuns(ctx,motion.runs,p.crownColor,w*p.crownWidthRatio);ctx.restore();
|
||||
}
|
||||
export function softHighlightAt(motion,elapsed){
|
||||
if(!motion.total)return null;const h=softHintTokens.highlight,cycle=Math.max(h.minCycleMs,motion.total/h.speed*1000),at=((elapsed%cycle)+cycle)%cycle/cycle*motion.total;
|
||||
const point=hintPointAt(motion,at),before=hintPointAt(motion,Math.max(point.segment.start,at-3)),after=hintPointAt(motion,Math.min(point.segment.end,at+3));
|
||||
return {...point,at,cycle,angle:Math.atan2(after.y-before.y,after.x-before.x)};
|
||||
}
|
||||
function shine(ctx,motion,elapsed){
|
||||
const point=softHighlightAt(motion,elapsed);if(!point)return;const h=softHintTokens.highlight,r=motion.width*h.radiusRatioToWidth;
|
||||
ctx.save();ctx.globalCompositeOperation='source-atop';
|
||||
if(ctx.globalCompositeOperation!=='source-atop'){ctx.restore();return;}
|
||||
ctx.translate(point.x,point.y);ctx.rotate(point.angle);ctx.scale(h.stretchAlongPath,1);
|
||||
const g=ctx.createRadialGradient(0,-1,0,0,0,r);if(g?.addColorStop){for(const [at,color]of h.gradient)g.addColorStop(at,color);ctx.fillStyle=g;ctx.fillRect(-r,-r,2*r,2*r);}ctx.restore();
|
||||
}
|
||||
function mask(ctx,hint,motion,unit){
|
||||
ctx.save();ctx.globalCompositeOperation='destination-out';
|
||||
const p=softHintTokens.endpointFade;
|
||||
for(const pt of [motion.runs[0]?.[0],motion.runs.at(-1)?.at(-1)]){if(!pt)continue;const [x,y]=pt,r=unit*p.radiusRatio,g=ctx.createRadialGradient(x,y,0,x,y,r);if(g?.addColorStop){g.addColorStop(0,'#000');g.addColorStop(p.opaqueEraseUntil,'#000');g.addColorStop(1,'rgba(0,0,0,0)');ctx.fillStyle=g;ctx.fillRect(x-r,y-r,2*r,2*r);}}
|
||||
// Feather only outside the exact typography exclusion rectangle.
|
||||
for(const rect of hint.keepouts||[]){const pad=softHintTokens.keepoutFeather;ctx.fillStyle='#000';ctx.fillRect(rect.x,rect.y,rect.w,rect.h);for(let n=1;n<=pad;n++){ctx.strokeStyle='rgba(0,0,0,'+(.32*(1-n/(pad+1)))+')';ctx.lineWidth=1;ctx.strokeRect(rect.x-n,rect.y-n,rect.w+2*n,rect.h+2*n);}}
|
||||
ctx.fillStyle='#000';ctx.strokeStyle='#000';
|
||||
for(const shape of hint.visitedShapes||[]){for(const dy of [0,shape.depth||0]){trace(ctx,shape.rings,shape.radius,dy);ctx.fill();ctx.lineWidth=(shape.maskPad||2)*2;ctx.stroke();}}
|
||||
for(const points of hint.visitedPolygons||[]){trace(ctx,[points],0);ctx.fill();}
|
||||
ctx.restore();
|
||||
}
|
||||
export class SoftHintPainter{
|
||||
constructor(createCanvas=()=>typeof wx!=='undefined'?wx.createCanvas?.():null){this.createCanvas=createCanvas;}
|
||||
reset(){this.key=null;this.motion=null;this.base=null;this.frame=null;}
|
||||
draw(ctx,hint,unit,elapsed,path=[]){
|
||||
const motion=prepareHintMotion(hint,unit,path),width=hint.boardWidth||Math.max(1,...motion.runs.flat().map(p=>p[0]))+unit,height=hint.boardHeight||Math.max(1,...motion.runs.flat().map(p=>p[1]))+unit,dpr=hint.dpr||1;
|
||||
const key=JSON.stringify([hint.route,hint.guides,path,hint.keepouts,hint.visitedShapes,width,height,dpr,hint.reducedMotion]);
|
||||
if(this.key!==key){
|
||||
this.key=key;this.motion=motion;try{this.base??=this.createCanvas();this.frame??=this.createCanvas();}catch{this.base=this.frame=null;}softHintDiagnostics.builds++;
|
||||
if(this.base&&this.frame){for(const c of [this.base,this.frame]){c.width=Math.ceil(width*dpr);c.height=Math.ceil(height*dpr);}const p=this.base.getContext('2d');p.setTransform(dpr,0,0,dpr,0,0);p.clearRect(0,0,width,height);body(p,motion,height);}
|
||||
}
|
||||
softHintDiagnostics.frames++;
|
||||
if(!this.base||!this.frame){softHintDiagnostics.staticFallbacks++;ctx.save();body(ctx,motion,height);mask(ctx,hint,motion,unit);ctx.restore();return;}
|
||||
const p=this.frame.getContext('2d');p.setTransform(dpr,0,0,dpr,0,0);p.clearRect(0,0,width,height);p.drawImage(this.base,0,0,width,height);
|
||||
if(!hint.reducedMotion)shine(p,prepareHintMotion(hint,unit,[]),elapsed);
|
||||
mask(p,hint,motion,unit);ctx.drawImage(this.frame,0,0,width,height);
|
||||
}
|
||||
}
|
||||
export function drawSoftHint(ctx,hint,unit,elapsed,path=[]){let painter=painters.get(ctx);if(!painter){painter=new SoftHintPainter();painters.set(ctx,painter);}painter.draw(ctx,hint,unit,elapsed,path);}
|
||||
export function resetSoftHint(ctx){painters.get(ctx)?.reset();}
|
||||
|
||||
export function targetBreath(elapsed,reduced=false){const p=softHintTokens.target;return reduced?1:p.breathMin+(p.breathMax-p.breathMin)*(.5-.5*Math.cos(elapsed/p.breathCycleMs*Math.PI*2));}
|
||||
export function drawSoftTarget(ctx,scene,targetId,elapsed,reduced=false){
|
||||
const cell=scene.byId.get(targetId);if(!cell||scene.steps.has(targetId))return;
|
||||
const p=softHintTokens.target,[x,y]=cell.center;ctx.save();ctx.globalAlpha=targetBreath(elapsed,reduced);
|
||||
trace(ctx,cell.rings,cell.radius);ctx.shadowColor=p.shadowColor;ctx.shadowBlur=p.blur;ctx.fillStyle=p.faceColor;ctx.fill();ctx.shadowBlur=0;
|
||||
trace(ctx,cell.rings,cell.radius);ctx.clip();
|
||||
const g=ctx.createRadialGradient(x-8,y-12,0,x,y,scene.pitch*p.tintRadiusRatio);if(g?.addColorStop){g.addColorStop(0,p.tintCenterColor);g.addColorStop(1,'rgba(255,235,175,0)');ctx.fillStyle=g;ctx.fillRect(x-scene.pitch,y-scene.pitch,scene.pitch*2,scene.pitch*2);}ctx.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user