保存强化版提升前的主版本工作区

This commit is contained in:
toshiocn
2026-10-23 11:02:33 +08:00
parent 66b3ca4ea0
commit 83e2e40269
26 changed files with 452 additions and 142 deletions
@@ -219,15 +219,19 @@ export function renderBrightBoard(ctx,level,path,width,height,{now=Date.now(),pr
return scene;
}
const hintGeometryCache=new WeakMap();
export function hintGeometry(scene,path,route){
if(!scene)return {};
const key=JSON.stringify([path,route,preferences.reducedMotion]),cached=hintGeometryCache.get(scene);
if(cached?.key===key&&cached.keepouts===scene.keepouts)return cached.value;
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};
const value={guides,keepouts:scene.keepouts,visitedShapes:scene.cells.filter(g=>path.includes(g.cell.id)).map(g=>({rings:g.rings,radius:g.radius,depth:scene.depth,maskPad:clamp(3,scene.pitch*.065,7)+1})),reducedMotion:preferences.reducedMotion};
hintGeometryCache.set(scene,{key,keepouts:scene.keepouts,value});return value;
}
function drawCheckpointRibbons(ctx,scene,now){
+21 -8
View File
@@ -61,9 +61,25 @@ export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
ctx.restore();
}
function drawGeometryHint(ctx,hint,unit,elapsed,path){
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 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;
}
export function hintPointAt(motion,at){
const {segments}=motion;if(!segments.length)return null;
let low=0,high=segments.length-1;
while(low<high){const mid=(low+high)>>1;if(segments[mid].end<at)low=mid+1;else high=mid;}
const segment=segments[low],t=Math.max(0,Math.min(1,(at-segment.start)/segment.length));
return {segment,x:segment.a[0]+(segment.b[0]-segment.a[0])*t,y:segment.a[1]+(segment.b[1]-segment.a[1])*t};
}
function drawGeometryHint(ctx,hint,unit,elapsed,path){
const motion=prepareHintMotion(hint,unit,path),{guides,runs,width,total}=motion;
ctx.save();ctx.lineCap='round';ctx.lineJoin='round';
if(hint.reducedMotion)ctx.setLineDash([7,5]);
const draw=(color,w)=>{ctx.strokeStyle=color;ctx.lineWidth=w;for(const points of runs){if(!points.length)continue;ctx.beginPath();ctx.moveTo(...points[0]);for(const p of points.slice(1))ctx.lineTo(...p);ctx.stroke();}};
@@ -74,20 +90,17 @@ function drawGeometryHint(ctx,hint,unit,elapsed,path){
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;
const 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);
const point=hintPointAt(motion,at);if(point){
const {segment,x,y}=point;
ctx.globalAlpha=(1-j/18)*.85;ctx.shadowColor='rgba(255,245,215,.65)';ctx.shadowBlur=j===0?width*1.2:0;
ctx.beginPath();ctx.arc(x,y,width*(.23+(16-j)/16*.4),0,Math.PI*2);ctx.fillStyle=C.hintSpark;ctx.fill();
if(j===0){ctx.globalAlpha=1;ctx.shadowBlur=0;hintArrow(ctx,segment.a,segment.b,x,y,width);}
break;
}
}
}
@@ -0,0 +1,15 @@
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;
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};
}
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);}
}
+65 -34
View File
@@ -1,7 +1,9 @@
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';
import {stopAudio,audioDiagnostics} from './audio.mjs';
import { mainCanvas, boardCanvas, document, window, PACK, setViewInvalidator } from './platform.mjs';
import {renderLesson,hintView,refreshBrightBoard,brightOverlay,brightAnimating} from './upstream/ui.mjs';
import {renderLesson,hintView,resumeGameplay,refreshBrightBoard,brightOverlay,brightAnimating} from './upstream/ui.mjs';
import {drawHint} from './hints.mjs';
import {lessonKeys,makeLesson,lessonFrame,loopLessonTime} from './lessons.mjs';
import {tutorials} from './copy.mjs';
@@ -11,22 +13,38 @@ 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='';
let rulePage=0,ruleKey='',lessonBoardCache=null,lessonBoardRenders=0;
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
let hintAnimation={key:'',started:0};
let settingsOpen=false,pressedLabel='',pressedUntil=0;
let renderedFrames=0,clearCelebration=null;
let renderedFrames=0,clearCelebration=null,selectWasOpen=false;
function requestPaint(){if(visible&&!raf)raf=requestAnimationFrame(draw);}
setViewInvalidator(requestPaint);
const hintBaseCanvas=wx.createCanvas();let hintBaseKey='',hintBaseHits=0;
setViewInvalidator(()=>{hintBaseKey='';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?.(); requestPaint(); }
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 levelButtons() { return $('pack-list').children.flatMap(section => section.children[1].children); }
function paintHintLayer(hint,boardState,d){
if(hint){
const key=boardState.level+':'+hint.start+':'+hint.end;
if(hintAnimation.key!==key)hintAnimation={key,started:Date.now()};
const h=$('board').clientHeight;
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);
ctx.drawImage(hintCanvas,0,top+82,width,h);
ctx.save();ctx.translate(0,top+82);brightOverlay(ctx);ctx.restore();
}else hintAnimation={key:'',started:0};
}
function draw() {
raf=0;
if (!visible)
@@ -41,31 +59,47 @@ function draw() {
const actual=audioDiagnostics.completeStartedAt;
clearCelebration.started=actual??(Date.now()-clearCelebration.requested<500?null:clearCelebration.requested);
}
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]);
if(cacheEligible&&hintBaseKey===baseKey){
hintBaseHits++;ctx.drawImage(hintBaseCanvas,0,0,width,height);paintHintLayer(animationHint,state,d);
if(!preferences.reducedMotion)requestPaint();return;
}
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,C.primary);
text('每块一次 · 数字对步数', width / 2, top + 61, 16);
text('已完成 '+$('progress').textContent, width / 2, top + 90, 14,C.inkSecondary);
text('数字一笔', width / 2, top + 18, 30,C.primary);
text('总进度 '+$('progress').textContent, width / 2, top + 42, 12,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') });
}
button('上一页', 18, height - bottom - 106, 90, 38, () => page = Math.max(0, page - 1));
const levels=levelButtons(),pages=PACK.chapters.length;
if(!selectWasOpen)page=Math.floor((state.displayOrder-1)/10);
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 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);
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 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')});
buttons.push({label,x,y,w:layout.w,h:layout.h,run:()=>node.click(),enabled:true});
});
button('上一页', 18, height - bottom - 106, 90, 38, () => page = Math.max(0, page - 1),{enabled:page>0});
text((page + 1) + ' / ' + pages, width / 2, height - bottom - 87);
button('下一页', width - 108, height - bottom - 106, 90, 38, () => page = Math.min(pages - 1, page + 1));
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);
}
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);
@@ -74,18 +108,6 @@ function draw() {
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){
const key=boardState.level+':'+hint.start+':'+hint.end;
if(hintAnimation.key!==key)hintAnimation={key,started:Date.now()};
const h=$('board').clientHeight;
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);
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?$('status').textContent:'按住滑动连线 · 沿来时路逐格回退');
@@ -121,7 +143,10 @@ function draw() {
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,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);
const lessonRoute=lessonState.progress<1?lessonState.previous:lessonState.route;
const lessonBoardKey=JSON.stringify([lesson.id,lessonRoute,area.w,area.h,d]);
if(lessonBoardCache?.key!==lessonBoardKey){lessonBoardRenders++;lessonBoardCache={key:lessonBoardKey,view:renderLesson(lessonCanvas,lesson.level,lessonRoute,area.w,area.h)};}
const view=lessonBoardCache.view;
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=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;
@@ -140,6 +165,11 @@ function draw() {
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});
}
if(cacheEligible){
hintBaseCanvas.width=mainCanvas.width;hintBaseCanvas.height=mainCanvas.height;
hintBaseCanvas.getContext('2d').drawImage(mainCanvas,0,0);hintBaseKey=baseKey;
}else hintBaseKey='';
if(!settingsOpen&&$('rules-sheet').hidden&&state.screen==='play')paintHintLayer(animationHint,state,d);
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);
@@ -155,7 +185,7 @@ 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){pressedLabel=b.label;pressedUntil=Date.now()+120;
if (b.enabled){hintBaseKey='';pressedLabel=b.label;pressedUntil=Date.now()+120;
b.run();
requestPaint();
}
@@ -173,13 +203,14 @@ 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.onShow(() => { if (!visible) {
visible = true;lessonStarted=Date.now();
visible = true;lessonStarted=Date.now();settingsOpen=false;resumeGameplay();
resize();
requestPaint();
} });
wx.onAudioInterruptionBegin?.(stopAudio);
wx.onWindowResize?.(resize);
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
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 })) };
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 })) };
resize();
requestPaint();
+22
View File
@@ -0,0 +1,22 @@
export const RESUME_KEY='variant:campaign-200-r2:v1:resume';
export function readResume(storage){try{const value=JSON.parse(storage.getItem(RESUME_KEY)||'null');return value?.version===1&&typeof value.levelId==='string'?value:null;}catch{return null;}}
export function resumeTarget(levels,completed,saved,legacyId){
const done=new Set(completed),savedIndex=levels.findIndex(l=>l.id===saved?.levelId),legacy=levels.findIndex(l=>l.id===legacyId);
if(savedIndex>=0&&!done.has(levels[savedIndex].id))return {index:savedIndex,path:Array.isArray(saved.path)?saved.path:[]};
const from=savedIndex>=0?savedIndex:legacy;
if(from>=0&&!done.has(levels[from].id))return {index:from,path:[]};
for(let step=1;step<=levels.length;step++){const i=(Math.max(-1,from)+step)%levels.length;if(!done.has(levels[i].id))return {index:i,path:[]};}
return {index:Math.max(0,from),path:[]};
}
export function validResumePath(level,route,canAppend){
if(!Array.isArray(route)||route.length>level.cells.length)return [];
const restored=[];for(const id of route){if(typeof id!=='string'||!canAppend(level,restored,id))return [];restored.push(id);}return restored;
}
export function writeResume(storage,levels,completed,current,path,cleared){
const previous=readResume(storage),done=new Set(completed);
// Replaying a completed level must not replace the last unfinished level.
if(done.has(current.id)&&!cleared&&previous&&!done.has(previous.levelId)&&levels.some(l=>l.id===previous.levelId))return;
const next=cleared||done.has(current.id)?resumeTarget(levels,completed,null,current.id):{index:levels.indexOf(current),path:[...path]};
const value=JSON.stringify({version:1,levelId:levels[next.index].id,path:next.path});
try{if(storage.getItem(RESUME_KEY)!==value)storage.setItem(RESUME_KEY,value);}catch{}
}