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

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
+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; }