将分层反馈强化版提升为正式 main,统一协作入口并移除外部构建路径

This commit is contained in:
toshiocn
2026-10-23 11:04:37 +08:00
parent 83e2e40269
commit a156e8b5c6
173 changed files with 1181 additions and 498 deletions
+29 -14
View File
@@ -1,18 +1,33 @@
// 微信原生短音频播放:资源随包提供,不依赖浏览器振荡器。
const instances=new Set(),channelEvents=new WeakMap();
export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:0,completeRequestedAt:null,completeStartedAt:null};
import {FEEDBACK} from './feedback-params.mjs';
const instances=new Set();
export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:0,mergedMoves: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));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{}}}
constructor(host,storage,key){Object.assign(this,{storage,key,channels:[],cursor:0,timers:new Set(),lastEvent:null,lastAt:{},lastMove:-Infinity,serial:0,variant:'enhanced'});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=.75;a.onError?.(e=>this.error(e));this.channels.push(a);}return true;}catch(e){this.error(e);return false;}}
error(e){audioDiagnostics.lastError={errCode:e?.errCode||0,errMsg:e?.errMsg||String(e)};}
stop(){this.serial++;for(const t of this.timers)clearTimeout(t);this.timers.clear();this.moveTimer=null;this.pendingMove=null;this.lastMove=-Infinity;this.highestUntil=0;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';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();}
play(kind,delay=0,step=1){
if(!this.unlock())return;const now=Date.now();
if(kind==='error'&&now-(this.lastAt.error??-Infinity)<FEEDBACK.errorGapMs)return;this.lastAt[kind]=now;
if(delay>0){const serial=this.serial,t=setTimeout(()=>{this.timers.delete(t);if(serial===this.serial)this.play(kind,0,step);},delay*1000);this.timers.add(t);return;}
if(kind==='complete'||kind==='restart')this.stop();
if(kind!=='complete'&&now<(this.highestUntil||0))return;
if(kind!=='move'&&this.moveTimer){clearTimeout(this.moveTimer);this.timers.delete(this.moveTimer);this.moveTimer=null;this.pendingMove=null;}
if(kind==='move'&&this.variant!=='original'&&now-this.lastMove<FEEDBACK.audioGapMs){
this.pendingMove=step;audioDiagnostics.mergedMoves++;
if(!this.moveTimer){const serial=this.serial;const t=setTimeout(()=>{this.timers.delete(t);this.moveTimer=null;const latest=this.pendingMove;this.pendingMove=null;if(serial===this.serial&&latest!==null&&latest!==undefined)this.run('move',latest);},FEEDBACK.audioGapMs-(now-this.lastMove));this.moveTimer=t;this.timers.add(t);}return;
}
this.run(kind,step);
}
run(kind,step){
if(!this.enabled)return;const now=Date.now();if(kind==='move')this.lastMove=now;
const a=this.channels[kind==='move'?this.cursor++%3:3],serial=this.serial,file=kind==='move'||kind==='checkpoint'&&this.variant!=='original'?kind+'-'+Math.max(1,Math.min(36,Math.floor(step))):kind;
try{a.stop();if(a._feedbackPlay)a.offPlay?.(a._feedbackPlay);a.src=(this.variant==='original'&&!['ui','hint'].includes(kind)?'audio-original/':'audio/')+file+'.wav';
if(kind==='complete'){this.highestUntil=now+FEEDBACK.completeWaitMs;audioDiagnostics.completeRequestedAt=now;audioDiagnostics.completeStartedAt=typeof a.onPlay==='function'?null:now;}
const callback=()=>{if(serial===this.serial&&kind==='complete')audioDiagnostics.completeStartedAt=Date.now();};a._feedbackPlay=callback;a.onPlay?.(callback);
a.play();this.lastEvent=kind;audioDiagnostics.plays++;
}catch(e){this.error(e);}
}
}
+9
View File
@@ -0,0 +1,9 @@
export function inBacktrackCore(polygons,id,x,y,unit){
const pieces=polygons.filter(p=>p.id===id),inside=(p)=>{let hit=false;for(let i=0,j=p.length-1;i<p.length;j=i++){const [a,b]=p[i],[c,d]=p[j];if((b>y)!==(d>y)&&x<(c-a)*(y-b)/(d-b)+a)hit=!hit;}return hit;};
if(!pieces.some(p=>inside(p.p)))return false;
const edges=[];for(const piece of pieces)for(let i=0;i<piece.p.length;i++)edges.push([piece.p[i],piece.p[(i+1)%piece.p.length]]);
const same=(p,q)=>Math.hypot(p[0]-q[0],p[1]-q[1])<.01;
const boundary=edges.filter(([a,b],i)=>!edges.some(([c,d],j)=>i!==j&&same(a,d)&&same(b,c)));
const margin=Math.min(3,.06*unit);
return boundary.every(([a,b])=>{const dx=b[0]-a[0],dy=b[1]-a[1],length=dx*dx+dy*dy,t=length?Math.max(0,Math.min(1,((x-a[0])*dx+(y-a[1])*dy)/length)):0;return Math.hypot(x-a[0]-dx*t,y-a[1]-dy*t)>=margin;});
}
@@ -1,3 +1,5 @@
import {feedback} from '../feedback.mjs';
import {FEEDBACK} from '../feedback-params.mjs';
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';
@@ -28,11 +30,15 @@ function bridgePath(ctx,scene,edge){
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 links=path.slice(1).map((b,i)=>({a:path[i],b}));
if(feedback.enhanced&&!preferences.reducedMotion)for(const e of scene.feedbackEvents||[])if(e.kind==='undo'&&e.from&&scene.now-e.time<FEEDBACK.undoMs&&!links.some(l=>l.a===e.from&&l.b===e.cell))links.push({a:e.from,b:e.cell,undo:e});
for(const link of links){
const edge=edgeFor(scene,link.a,link.b);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 arrival=(scene.feedbackEvents||[]).findLast(e=>e.from===link.a&&e.cell===link.b&&['move','checkpoint'].includes(e.kind));
const progress=!feedback.enhanced||preferences.reducedMotion?1:link.undo?1-clamp(0,(scene.now-link.undo.time)/FEEDBACK.undoMs,1):arrival?clamp(0,(scene.now-arrival.time)/FEEDBACK.bridgeMs,1):1;
const halfLength=(scene.inset+1.2)*progress;if(halfLength<.01)continue;
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');
@@ -45,7 +51,7 @@ function drawBridges(ctx,scene,path,depth=false){
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.globalAlpha=clamp(0,(halfLength-Math.abs(mid))/Math.max(.1,Math.min(1.2,halfLength*.5)),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);
@@ -126,7 +132,7 @@ function drawSymbols(ctx,scene,g,view,path){
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 double=String(value).length>1,size=Math.min(u*(double?.22:.26),double?18:21),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);
@@ -187,16 +193,27 @@ export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),pr
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('|');
const scene=cached,pathKey=path.join('|');scene.now=now;
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){
for(const event of scene.feedbackEvents)if(event.sequence>(scene.feedbackSequence||0)){
if(event.kind==='complete')scene.bursts=[];
else if(event.kind==='undo')scene.bursts=scene.bursts.filter(b=>b.cell!==event.cell);
else if(event.kind==='checkpoint'&&!preferences.reducedMotion){const g=scene.byId.get(event.cell);if(g)scene.bursts.push({cell:event.cell,point:g.center,started:event.time,seed:event.step});}
scene.feedbackSequence=event.sequence;
}
scene.bursts=scene.bursts.slice(-FEEDBACK.maxRibbons);
}
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])){
if(!feedback.enhanced&&!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);
scene.bursts=scene.bursts.filter(b=>now-b.started<FEEDBACK.ribbonMs);
if(pathKey!==scene.pathKey){scene.pathKey=pathKey;scene.changedAt=now;}
if(!scene.doorTransitions)scene.doorTransitions=new Map();
for(const door of level.doors||[]){
@@ -212,10 +229,10 @@ export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),pr
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));
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();
scene.animating=!preview&&!preferences.reducedMotion&&(scene.bursts.length>0||[...scene.doorTransitions.values()].some(d=>d.from!==d.to&&now-d.started<T.motionMs.door));
scene.animating=!preview&&!preferences.reducedMotion&&(scene.bursts.length>0||scene.feedbackEvents.some(e=>now-e.time<(e.kind==='move'?FEEDBACK.moveMs:e.kind==='undo'?FEEDBACK.undoMs:0))||[...scene.doorTransitions.values()].some(d=>d.from!==d.to&&now-d.started<T.motionMs.door));
return scene;
}
@@ -239,7 +256,7 @@ function drawCheckpointRibbons(ctx,scene,now){
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;
const t=Math.max(0,(now-burst.started)/FEEDBACK.ribbonMs),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;
@@ -251,12 +268,15 @@ function drawCheckpointRibbons(ctx,scene,now){
}
export function checkpointScale(elapsed,reducedMotion=false){
if(reducedMotion||elapsed<=0||elapsed>=360)return 1;
return 1+.035*Math.sin(Math.PI*elapsed/360);
if(reducedMotion||elapsed<=0||elapsed>=FEEDBACK.checkpointMs)return 1;
return 1+(FEEDBACK.checkpointMaxScale-1)*Math.sin(Math.PI*elapsed/FEEDBACK.checkpointMs);
}
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;
const landing=feedback.enhanced&&scene.feedbackEvents.findLast(e=>e.cell===g.cell.id&&e.kind==='move');
const scale=preferences.reducedMotion?1:burst&&scene.steps.has(g.cell.id)?checkpointScale(now-burst.started):landing&&scene.steps.has(g.cell.id)?landingScale(now-landing.time):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();
}
export function landingScale(elapsed){return elapsed<0||elapsed>=FEEDBACK.moveMs?1:1-(1-FEEDBACK.moveMinScale)*Math.sin(Math.PI*elapsed/FEEDBACK.moveMs);}
@@ -0,0 +1,2 @@
export const FEEDBACK={moveMs:120,moveMinScale:.985,bridgeMs:100,undoMs:100,checkpointMs:260,checkpointMaxScale:1.035,ribbonMs:1500,maxRibbons:2,hapticGapMs:70,completeHapticDelayMs:90,audioGapMs:50,completeWaitMs:120,errorGapMs:180};
export const priority={move:1,undo:0,error:0,checkpoint:2,complete:3};
+34
View File
@@ -0,0 +1,34 @@
import {FEEDBACK,priority} from './feedback-params.mjs';
import {Haptics} from './haptics.mjs';
export class Feedback{
constructor({now=()=>Date.now()}={}){this.now=now;this.sequence=0;this.events=[];this.visuals=[];this.depth=0;this.pending=null;this.enhanced=true;this.epoch=0;}
attach(audio,api,storage){this.audio=audio;this.enhanced=api.__feedbackMode!=='original';audio.variant=this.enhanced?'enhanced':'original';this.haptics=new Haptics(api,storage,{preview:Boolean(api.__feedbackPreview)});}
begin(){if(!this.depth)this.batchErrors=new Map();this.depth++;}
end(){if(this.depth>0)this.depth--;if(!this.depth)this.flush();}
emit(kind,level,cell,step,extra={}){
const errorKey=JSON.stringify([level.id||level,cell,step]);
if(kind==='error'&&this.depth&&this.batchErrors?.has(errorKey))return this.batchErrors.get(errorKey);
const event={sequence:++this.sequence,kind,level:level.id||level,cell,step,time:this.now(),epoch:this.epoch,...extra};
if(kind==='error'&&this.depth)this.batchErrors.set(errorKey,event);
this.events.push(event);if(this.events.length>256)this.events.shift();
if(kind!=='complete'&&this.completion){this.completion=null;this.haptics?.stop();}
if(kind==='complete'){this.visuals=[];this.completion={event,start:null,locked:false};}
this.visuals.push(event);this.visuals=this.visuals.filter(e=>e.level===event.level&&event.time-e.time<FEEDBACK.ribbonMs).slice(-128);
if(!this.pending||(priority[kind]||0)>=(priority[this.pending.kind]||0))this.pending=event;
if(!this.depth)this.flush();return event;
}
flush(){const e=this.pending;this.pending=null;if(!e)return;
this.audio?.play(e.kind==='checkpoint'?'checkpoint':e.kind,0,e.step);
if(this.enhanced){if(e.kind==='undo'||e.kind==='error')this.haptics?.stop();else this.haptics?.play(e.kind);}
}
reset(){this.epoch++;this.pending=null;this.visuals=[];this.completion=null;this.audio?.stop();this.haptics?.stop();}
completionStart(actual,soundEnabled=true){
const c=this.completion;if(!c)return null;if(c.locked)return c.start;
if(soundEnabled&&actual!==null&&actual!==undefined&&actual>=c.event.time&&actual<=c.event.time+FEEDBACK.completeWaitMs){c.start=actual;c.locked=true;}
else if(!soundEnabled||this.now()-c.event.time>=FEEDBACK.completeWaitMs){c.start=c.event.time;c.locked=true;}
return c.start;
}
active(level,now=this.now()){return this.visuals.filter(e=>e.level===(level.id||level)&&now>=e.time&&now-e.time<FEEDBACK.ribbonMs);}
snapshot(){return {sequence:this.sequence,events:this.events.map(e=>({...e})),hapticsEnabled:this.haptics?.enabled??true,enhanced:this.enhanced,epoch:this.epoch};}
}
export const feedback=new Feedback();
+16
View File
@@ -0,0 +1,16 @@
import {FEEDBACK} from './feedback-params.mjs';
export const HAPTIC_KEY='number-swipe:haptics:v1';
export class Haptics{
constructor(api,storage,{now=()=>Date.now(),later=setTimeout,cancel=clearTimeout,preview=false}={}){Object.assign(this,{api,storage,now,later,cancel,preview});this.enabled=true;this.last=-Infinity;this.generation=0;this.timers=new Set();this.graded=true;try{this.enabled=storage.getItem(HAPTIC_KEY)!=='off';}catch{}}
setEnabled(value){this.enabled=value;try{this.storage.setItem(HAPTIC_KEY,value?'on':'off');}catch{}if(!value)this.stop();}
stop(){this.generation++;for(const t of this.timers)this.cancel(t);this.timers.clear();}
pulse(type,force=false){
if(!this.enabled||this.preview||typeof this.api?.vibrateShort!=='function'||!force&&this.now()-this.last<FEEDBACK.hapticGapMs)return;
this.last=this.now();const generation=this.generation,request=this.request=(this.request||0)+1,sentAt=this.last;
const fallback=()=>{if(generation!==this.generation||request!==this.request||this.now()-sentAt>FEEDBACK.hapticGapMs||!this.enabled)return;this.graded=false;try{this.api.vibrateShort({fail(){}});}catch{}};
try{this.api.vibrateShort(this.graded?{type,fail:fallback}:{fail(){}});}catch{if(this.graded)fallback();}
}
play(kind){if(!['move','checkpoint','complete'].includes(kind))return;this.pulse(kind==='move'?'light':'medium',kind!=='move');
if(kind==='complete'&&this.enabled&&this.graded&&!this.preview&&typeof this.api?.vibrateShort==='function'){const generation=this.generation;const timer=this.later(()=>{this.timers.delete(timer);if(generation===this.generation&&this.graded)this.pulse('light',true);},FEEDBACK.completeHapticDelayMs);this.timers.add(timer);}
}
}
@@ -0,0 +1,14 @@
// Distances live on the complete segment, independent of its shrinking visible ribbon.
export class HintArrows{
reset(){this.key=null;this.particles=[];}
frame(key,now,events){if(this.key!==key){this.key=key;this.particles=[];this.sequence=events.at(-1)?.sequence||0;this.last=now;this.spawnAt=now;}
const forwards=events.filter(e=>e.sequence>this.sequence&&(e.kind==='move'||e.kind==='checkpoint'));
this.sequence=events.at(-1)?.sequence||this.sequence;
return {manager:this,now,forward:forwards.length>0};}
heads(frame,motion,start){const now=frame.now,dt=Math.max(0,Math.min(100,now-this.last));this.last=now;
for(const p of this.particles){p.at+=p.rush?(motion.total-p.origin)*dt/180:65*dt/1000;}
if(frame.forward){for(const p of this.particles){p.rush=true;p.origin=p.at;}this.spawnAt=now;}
this.particles=this.particles.filter(p=>p.at<motion.total&&(p.rush||p.at>=start-2)).slice(-8);
if(now>=this.spawnAt){this.particles.push({at:start,rush:false});this.spawnAt=now+650;}
return this.particles.map(p=>p.at);}
}
+11 -9
View File
@@ -26,9 +26,9 @@ 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=[]){
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);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)));});
@@ -63,13 +63,13 @@ export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
const preparedHints=new WeakMap();
export function prepareHintMotion(hint,unit,path){
const key=JSON.stringify([unit,path]),cached=preparedHints.get(hint.guides);
if(cached?.key===key)return cached.value;
const 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};preparedHints.set(hint.guides,{key,value});return value;
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;
@@ -78,7 +78,7 @@ export function hintPointAt(motion,at){
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){
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]);
@@ -90,13 +90,15 @@ function drawGeometryHint(ctx,hint,unit,elapsed,path){
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(let pulse=0;pulse<3;pulse++){
const pulseHead=(head+pulse*total/3)%total;
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(motion,at);if(point){
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();
+5 -5
View File
@@ -1,8 +1,8 @@
import {C,label,panel,roundRect} from './bright-classroom/theme.mjs';
export function chapterLayout(width,height,top,bottom,count=10){
const columns=width>=360&&height>=700?2:3,rows=Math.ceil(count/columns),gap=14;
const y=top+108,available=height-bottom-114-y;
export function chapterLayout(width,height,top,bottom,count=15){
const columns=3,rows=Math.ceil(count/columns),gap=10;
const y=top+72,available=height-bottom-110-y;
const h=Math.min(76,(available-gap*(rows-1))/rows),w=Math.min(130,(width-48-gap*(columns-1))/columns);
return {columns,rows,gap,y,w,h,x:(width-(w*columns+gap*(columns-1)))/2};
}
@@ -10,6 +10,6 @@ export function levelCard(ctx,labelText,x,y,w,h,{completed,current}){
const fill=completed?'#D6EAD6':current?C.primary:C.surface;
panel(ctx,x,y,w,h-3,Math.min(18,h*.28),fill,3);
if(current){ctx.save();ctx.strokeStyle=completed?C.primary:'#FFFFFF';ctx.lineWidth=2;roundRect(ctx,x+4,y+4,w-8,h-11,Math.min(14,h*.22));ctx.stroke();ctx.restore();}
label(ctx,labelText,x+w/2,y+(h-3)/2,Math.min(24,h*.4),current&&!completed?C.onPrimary:completed?'#326443':C.ink,700);
if(completed){label(ctx,'✓',x+w-13,y+12,12,'#326443',700);}
label(ctx,labelText,x+w/2-(completed?8:0),y+(h-3)/2,Math.min(24,h*.4),current&&!completed?C.onPrimary:completed?'#326443':C.ink,700);
if(completed){const size=Math.min(24,h*.4),cx=x+w/2+String(labelText).length*size*.28+5,cy=y+(h-3)/2;ctx.save();ctx.strokeStyle='#326443';ctx.lineWidth=2.6;ctx.lineCap='round';ctx.lineJoin='round';ctx.beginPath();ctx.moveTo(cx-4,cy);ctx.lineTo(cx-1,cy+3);ctx.lineTo(cx+5,cy-4);ctx.stroke();ctx.restore();}
}
+37 -32
View File
@@ -1,3 +1,6 @@
import {Music} from './music.mjs';
import {HintArrows} from './hint-arrows.mjs';
import {feedback} from './feedback.mjs';
import releaseInfo from '../release-info.json' with {type:'json'};
import {chapterLayout,levelCard} from './level-select.mjs';
import {CLEAR_DURATION,clearProgress,drawClearCelebration} from './bright-classroom/celebration.mjs';
@@ -15,20 +18,22 @@ 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='',lessonBoardCache=null,lessonBoardRenders=0;
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
const music=new Music(wx),hintArrows=new HintArrows();
let hintAnimation={key:'',started:0};
let settingsOpen=false,pressedLabel='',pressedUntil=0;
let celebrationSuppressed=false;
let renderedFrames=0,clearCelebration=null,selectWasOpen=false;
function requestPaint(){if(visible&&!raf)raf=requestAnimationFrame(draw);}
const hintBaseCanvas=wx.createCanvas();let hintBaseKey='',hintBaseHits=0;
setViewInvalidator(()=>{hintBaseKey='';requestPaint();});
try{preferences.reducedMotion=wx.getStorageSync('number-swipe:reduced-motion')==='1';}catch{}
preferences.reducedMotion=false;
const ordered = PACK.playOrder.map(id => PACK.levels.find(l => l.id === id));
function resize() { hintBaseKey=''; const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); width = info.windowWidth; height = info.windowHeight; top = Math.max(info.safeArea?.top || 0, wx.getMenuButtonBoundingClientRect?.().bottom || 0) + 12; bottom = Math.max(16, height - (info.safeArea?.bottom || height)); window.devicePixelRatio = Math.min(info.pixelRatio || 1, 3); mainCanvas.width = width * window.devicePixelRatio; mainCanvas.height = height * window.devicePixelRatio; $('board').clientWidth = width; $('board').clientHeight = Math.max(140, height - top - bottom - 218); window.handlers.resize?.(); requestPaint(); }
function text(value, x, y, size = 16, color = C.ink) { label(ctx,value,x,y,size,color); }
function splitLines(value,maxWidth,size=16){textFont(ctx,size);const lines=[];for(const paragraph of String(value).split('\n')){let line='';for(const c of paragraph){if(line&&ctx.measureText(line+c).width>maxWidth){lines.push(line);line='';}line+=c;}lines.push(line);}return lines;}
function wrapped(value,x,y,maxWidth,size=16){const lines=splitLines(value,maxWidth,size);lines.forEach((line,i)=>text(line,x,y+i*(size+9),size));return lines.length;}
function button(label, x, y, w, h, run, { enabled = true, primary = false } = {}) { buttonArt(ctx,label,x,y,w,h,{enabled,primary,pressed:!preferences.reducedMotion&&pressedLabel===label&&Date.now()<pressedUntil}); buttons.push({ label, x, y, w, h, run, enabled }); }
function click(id) { if(id==='btn-rules')ruleKey=''; $('board').handlers.pointerup?.(); pointer = null; $(id).click(); }
function click(id) { 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){
@@ -39,10 +44,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);
drawHint(hintContext,hint,boardState.centers,boardState.unit,Date.now()-hintAnimation.started,boardState.path,hintArrows.frame(key,Date.now(),feedback.events));
ctx.drawImage(hintCanvas,0,top+82,width,h);
ctx.save();ctx.translate(0,top+82);brightOverlay(ctx);ctx.restore();
}else hintAnimation={key:'',started:0};
}else {hintAnimation={key:'',started:0};hintArrows.reset();}
}
function draw() {
@@ -52,13 +57,10 @@ function draw() {
renderedFrames++;
const d = window.devicePixelRatio;
ctx.setTransform(d, 0, 0, d, 0, 0);
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 state=window.variantSnapshot();music.scene(state.screen==='select'?'select':'play');
if(!state.cleared||state.screen!=='play'){clearCelebration=null;celebrationSuppressed=false;}
else if(!celebrationSuppressed&&(!clearCelebration||clearCelebration.key!==state.level))clearCelebration={key:state.level,started:Date.now(),requested:audioDiagnostics.completeRequestedAt};
if(clearCelebration)clearCelebration.started=feedback.completionStart(audioDiagnostics.completeStartedAt,state.soundEnabled);
const animationHint=hintView();
const cacheEligible=Boolean(animationHint)&&state.screen==='play'&&!state.cleared&&!settingsOpen&&$('rules-sheet').hidden&&!brightAnimating()&&Date.now()>=pressedUntil;
const baseKey=JSON.stringify([state.level,state.path,width,height,d,preferences.reducedMotion]);
@@ -76,17 +78,16 @@ function draw() {
text('总进度 '+$('progress').textContent, width / 2, top + 42, 12,C.inkSecondary);
button('设置',width-72,top+4,56,34,()=>settingsOpen=true);
const levels=levelButtons(),pages=PACK.chapters.length;
if(!selectWasOpen)page=Math.floor((state.displayOrder-1)/10);
const levels=levelButtons(),pages=Math.ceil(ordered.length/15);
if(!selectWasOpen)page=Math.floor((state.displayOrder-1)/15);
selectWasOpen=true;page=Math.max(0,Math.min(pages-1,page));
const chapter=PACK.chapters[page],chapterLevels=ordered.filter(l=>l.chapter===chapter.id);
const chapterLevels=ordered.slice(page*15,(page+1)*15);
const nodes=chapterLevels.map(l=>levels[ordered.indexOf(l)]);
const layout=chapterLayout(width,height,top,bottom,nodes.length);
text('第 '+(page+1)+' 章 · '+chapter.title.replace(/^第.*?章[·\s]*/,''),width/2,top+66,18,C.primary);
text(nodes.filter(n=>n.className.includes('cleared')).length+' / '+nodes.length+' 已通过',width/2,top+89,13,C.inkSecondary);
const layout=chapterLayout(width,height,top,bottom,15);
text('第 '+(page*15+1)+' — '+Math.min((page+1)*15,ordered.length)+' 关',width/2,top+60,16,C.primary);
nodes.forEach((node,j)=>{
const row=Math.floor(j/layout.columns),column=j%layout.columns,last=nodes.length-row*layout.columns;
const offset=last<layout.columns?(layout.columns-last)*(layout.w+layout.gap)/2:0;
const offset=0;
const x=layout.x+offset+column*(layout.w+layout.gap),y=layout.y+row*(layout.h+layout.gap);
const label=node.textContent+(node.className.includes('cleared')?' ✓':'');
levelCard(ctx,node.textContent,x,y,layout.w,layout.h,{completed:node.className.includes('cleared'),current:node.className.includes('current')});
@@ -96,14 +97,14 @@ function draw() {
text((page + 1) + ' / ' + pages, width / 2, height - bottom - 87);
button('下一页', width - 108, height - bottom - 106, 90, 38, () => page = Math.min(pages - 1, page + 1),{enabled:page<pages-1});
button($('btn-continue').textContent, 18, height - bottom - 56, width - 126, 42, () => click('btn-continue'), { primary: true });
button('反馈', width - 98, height - bottom - 56, 80, 42, feedback);
button('反馈', width - 98, height - bottom - 56, 80, 42, showFeedback);
}
else {
selectWasOpen=false;
button('选关', 16, top, 62, 36, () => click('btn-back'));
panel(ctx,width/2-60,top+2,120,32,16,C.coral,3);
text('第 ' + window.variantSnapshot().displayOrder + ' 关', width / 2, top + 18, 18,C.onPrimary);
button($('btn-sound').textContent, width - 94, top, 78, 36, () => click('btn-sound'));
button('设置', width - 94, top, 78, 36, () => settingsOpen=true);
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());
@@ -124,6 +125,7 @@ function draw() {
}
button('规则', 16, top + 43, 56, 30, () => click('btn-rules'));
}
if(clearCelebration&&clearCelebration.started===null&&!settingsOpen&&$('rules-sheet').hidden){const x=width/2,y=top+82+$('board').clientHeight*.45;panel(ctx,x-74,y-40,148,80,25,C.surface,2);text('完成!',x,y,24,C.success);}
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(67,56,47,.32)';
@@ -159,11 +161,12 @@ function 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);
const y=Math.max(top,height/2-185);panel(ctx,24,y,width-48,340,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});
button('音乐:'+(music.enabled?'开':'关'),44,y+65,width-88,48,()=>music.toggle());
button($('btn-sound').textContent,44,y+125,width-88,48,()=>click('btn-sound'));
button('振动:'+(feedback.haptics.enabled?'开':'关'),44,y+185,width-88,48,()=>feedback.haptics.setEnabled(!feedback.haptics.enabled));
button('完成',44,y+265,width-88,48,()=>settingsOpen=false,{primary:true});
}
if(cacheEligible){
hintBaseCanvas.width=mainCanvas.width;hintBaseCanvas.height=mainCanvas.height;
@@ -175,18 +178,19 @@ function draw() {
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) {
function showFeedback() { wx.showModal({ title: '试玩反馈', editable: true, placeholderText: '记录有趣、太难或规则不清楚的关卡', content: $('reasoning').value || '', success: r => { if (r.confirm) {
$('reasoning').value = r.content || '';
click('close-feedback');
wx.showModal({ title: '反馈已保存', content: '是否导出全部试玩记录?', success: r => { if (r.confirm)
click('export'); } });
} } }); }
function event(p) { return { clientX: p.clientX, clientY: p.clientY - top - 82, pointerId: p.identifier, pointerType: 'touch', preventDefault() { } }; }
wx.onTouchStart(e => { if (pointer !== null)
wx.onTouchStart(e => { music.resume();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){hintBaseKey='';pressedLabel=b.label;pressedUntil=Date.now()+120;
b.run();
if (b.enabled){music.resume();hintBaseKey='';pressedLabel=b.label;pressedUntil=Date.now()+120;
const uiCue=$('screen-play').className.includes('hidden')||settingsOpen;
b.run();if(uiCue)feedback.audio.play('ui');
requestPaint();
}
return;
@@ -201,16 +205,17 @@ const release = e => { if (e.changedTouches.some(p => p.identifier === pointer))
} };
wx.onTouchEnd(release);
wx.onTouchCancel(release);
wx.onHide(() => { visible = false; lessonStarted=Date.now(); stopAudio(); cancelAnimationFrame(raf);raf=0; pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
wx.onHide(() => { visible = false;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.onShow(() => { if (!visible) {
visible = true;lessonStarted=Date.now();settingsOpen=false;resumeGameplay();
visible = true;music.resume();lessonStarted=Date.now();settingsOpen=false;resumeGameplay();
resize();
requestPaint();
} });
wx.onAudioInterruptionBegin?.(stopAudio);
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,hintBaseHits,lessonBoardRenders,selection:{chapter:page+1,chapters:PACK.chapters.length},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,finished:lessonState.finished,duration:lesson?.duration,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),theme:'bright-classroom',release:releaseInfo,fonts:{...fontDiagnostics},renderedFrames,hintBaseHits,lessonBoardRenders,music:music.snapshot(),selection:{page:page+1,pages:Math.ceil(ordered.length/15),pageSize:15,chapter:page+1,chapters:Math.ceil(ordered.length/15)},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},feedback:feedback.snapshot(),rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,finished:lessonState.finished,duration:lesson?.duration,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
resize();
requestPaint();
+9
View File
@@ -0,0 +1,9 @@
export class Music{
constructor(api){this.api=api;this.enabled=true;this.paused=false;try{this.enabled=api.getStorageSync('number-swipe:music:v1')!=='off';}catch{}}
scene(name){this.name=name;if(!this.enabled||this.paused||this.playing===name)return;try{if(!this.audio){this.audio=this.api.createInnerAudioContext();this.audio.loop=true;this.audio.volume=.16;this.audio.obeyMuteSwitch=false;this.audio.onError?.(()=>{this.playing=null;this.failed=true;});}if(this.failed)return;this.audio.stop();this.audio.src='music/'+name+'.wav';this.audio.play();this.playing=name;}catch{this.failed=true;}}
stop(){this.audio?.stop();this.playing=null;}
suspend(){this.paused=true;this.stop();}
resume(){this.paused=false;this.failed=false;if(this.name)this.scene(this.name);}
toggle(){this.enabled=!this.enabled;try{this.api.setStorageSync('number-swipe:music:v1',this.enabled?'on':'off');}catch{}this.stop();if(this.enabled)this.resume();}
snapshot(){return {enabled:this.enabled,playing:this.playing||null};}
}