更新微信小游戏版:动画教学、分段高亮指路及发布包

This commit is contained in:
toshiocn
2026-10-20 18:05:48 +08:00
parent fdca4f67ea
commit 2d5a9e9553
75 changed files with 40842 additions and 2 deletions
@@ -0,0 +1,84 @@
import {graph,units,sideBetween,outlineUnits,solve,violations,canAppend,hint,doorOpen,doorLatched,directionNames} from './rules.mjs';
import {CasualAudio} from './audio.mjs';
// The build embeds this module together with the local rule file and JSON.
const pack=PACK;const playLevels=(pack.playOrder||pack.levels.map(l=>l.id)).map(id=>pack.levels.find(l=>l.id===id));const total=playLevels.length;
const BOARD_FONT='ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif';
const UI_THEME={cell:'#E6E7EC',ink:'#23243A',primary:'#6558F5',bright:'#7D72FF',danger:'#E5484D',hint:'#F5A524',ring:'#DFE1EC'};let toastTimer=null;let channelViews=[];let cellViews=[];let inputKind="mouse";
const $=id=>document.getElementById(id),canvas=$('board'),ctx=canvas.getContext('2d');
let level,solution,path=[],index=0,selected='入门',polygons=[],centers=new Map(),unit=1,focus=[],drag=false,lastPoint=null,cleared=false;
const memoryKey='variant:campaign-200-r2:v1';const sound=new CasualAudio(window,localStorage,memoryKey+':sound');let completed=[];try{completed=JSON.parse(localStorage.getItem(memoryKey)||'[]');}catch{}
const labels=new Map();let started=0,firstDrawAt=null,record=null;let records=[];try{records=JSON.parse(localStorage.getItem(memoryKey+':records')||'[]');}catch{}
function saveRecord(){if(!record)return;record.reasoning=$('reasoning').value;record.elapsedSeconds=Math.round((performance.now()-started)/1000);record.completed=cleared;const copy={...record,endedAt:new Date().toISOString()};const at=records.findIndex(r=>r.attemptId===copy.attemptId);if(at>=0)records[at]=copy;else records.push(copy);try{localStorage.setItem(memoryKey+':records',JSON.stringify(records.slice(-500)));}catch{}}
function setStatus(s){$('status').textContent=s;$('status').className='toast show';if(toastTimer)clearTimeout(toastTimer);toastTimer=setTimeout(()=>{$('status').className='toast';},1800);}function showPlay(){if(level.tutorial&&!completed.includes(level.id)){$('rule-text').textContent=level.caption;$('rules-sheet').hidden=false;}$('screen-select').className='screen hidden';$('screen-play').className='screen enter';draw();}function showSelect(){saveRecord();$('screen-select').className='screen enter';$('screen-play').className='screen hidden';renderSelectors();$('status').className='toast';}
function modeMarkers(){labels.clear();let ids=[...(level.relations||[]).flatMap(r=>[r.from,r.to]),...(level.orderChains||[]).flat()];for(const id of ids)if(!labels.has(id))labels.set(id,String.fromCharCode(65+labels.size));}
function makeRecord(){return {attemptId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,8),mode:pack.mode,level:level.id,contentRevision:level.contentRevision,order:level.order,chapter:level.chapter,title:level.title,startedAt:new Date().toISOString(),thinkingBeforeFirstDrawSeconds:null,elapsedSeconds:0,firstError:null,backtracks:0,restarts:0,hints:0,completed:false,reasoning:''};}
function start(i,open=true){if(record)saveRecord();index=i;level=playLevels[i];solution=solve(level)[0];if(!solution)throw Error('没有合法路线');path=[];focus=[];cleared=false;started=performance.now();firstDrawAt=null;record=makeRecord();$('reasoning').value='';modeMarkers();$('goal').textContent=level.title;$('caption').textContent=level.caption||'先观察,再画;错了可以退回。';$('title').textContent='数字一笔';$('rule-text').textContent=rulesFor(level);try{localStorage.setItem(memoryKey+':current',level.id);}catch{}setStatus('');renderConstraints();renderSelectors();if(open)showPlay();draw();}
function renderSelectors(){
const list=$('pack-list');list.replaceChildren();for(const chapter of pack.chapters){const section=document.createElement('section');section.className='pack';const head=document.createElement('div');head.className='pack-title';const title=document.createElement('span');title.textContent=chapter.title;const count=document.createElement('span');count.className='pack-progress';const chapterLevels=playLevels.filter(l=>l.chapter===chapter.id);count.textContent=chapterLevels.filter(l=>completed.includes(l.id)).length+' / '+chapterLevels.length;head.append(title,count);const grid=document.createElement('div');grid.className='level-grid';for(const l of chapterLevels){const i=playLevels.indexOf(l),button=document.createElement('button');button.levelId=l.id;button.textContent=String(playLevels.indexOf(l)+1);button.title=l.title;button.className='level-tile'+(completed.includes(l.id)?' cleared':'')+(i===index?' current':'');button.onclick=()=>start(i);grid.append(button);}section.append(head,grid);list.append(section);}
$('position').textContent=level.chapter+'-'+(playLevels.filter(l=>l.chapter===level.chapter).indexOf(level)+1);$('level-sub').textContent=level.chapterTitle+' · 第'+(index+1)+'关'+(level.tutorial?' · 引导':'');$('progress').textContent=completed.filter(id=>playLevels.some(l=>l.id===id)).length+' / '+total;$('overall-fill').style.width=(completed.filter(id=>playLevels.some(l=>l.id===id)).length/total*100)+'%';$('btn-continue').textContent='继续 · '+level.chapter+'-'+(playLevels.filter(l=>l.chapter===level.chapter).indexOf(level)+1);$('level-progress').replaceChildren();for(const l of playLevels.filter(l=>l.chapter===level.chapter)){const seg=document.createElement('i');seg.className=(completed.includes(l.id)?'cleared ':'')+(l.id===level.id?'current':'');$('level-progress').append(seg);}
}
function rulesFor(l){const a=['每格恰好经过一次,共边才能走;固定数字是访问步号。'];if(l.geometry==='blocks')a.push('大格和异形整块只算一步。');if(l.geometry==='hex')a.push('六边形有六个共边方向。');if(l.features.includes('direction'))a.push('通道连接哪两边,就从哪两边进出,可反向;通道不能是端点。');if(l.features.includes('alternation'))a.push('蓝→橙→蓝,每一步换颜色;蓝色与橙色交替。');if(l.features.includes('parity'))a.push('奇/偶标记要求该格访问步号的奇偶,不按面积计数。');if(l.features.includes('walls'))a.push('黑黄墙永久封闭这条共边。');if(l.features.includes('rhythm-door'))a.push('门空盘关,起点也切换,每落一格切换一次;开门时通过后保持开启。回退撤销穿门那一步或重开后,恢复切换。');return a.join(' ');}
function renderConstraints(){$('constraints').replaceChildren();}
function localGeometry(){
const raw=[],center=new Map();for(const c of level.cells){if(level.geometry==='hex'){
// Rotate the axial strip into portrait orientation; adjacency remains axial.
const x=1.5*c.r,y=Math.sqrt(3)*(c.q+c.r/2);center.set(c.id,[x,y]);let p=[];for(let a=0;a<6;a++){const angle=a*Math.PI/3;p.push([x+Math.cos(angle),y+Math.sin(angle)]);}raw.push({id:c.id,p});
}else{const u=units(c);center.set(c.id,[u.reduce((s,p)=>s+p[0]+.5,0)/u.length,u.reduce((s,p)=>s+p[1]+.5,0)/u.length]);for(const [x,y]of u)raw.push({id:c.id,p:[[x,y],[x+1,y],[x+1,y+1],[x,y+1]]});}}
return {raw,center};
}
function draw(){if(!level)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;const dpr=window.devicePixelRatio||1;canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
const {raw,center}=localGeometry(),all=raw.flatMap(r=>r.p),xmin=Math.min(...all.map(p=>p[0])),xmax=Math.max(...all.map(p=>p[0])),ymin=Math.min(...all.map(p=>p[1])),ymax=Math.max(...all.map(p=>p[1]));unit=Math.min(level.cells.length<=6?96:110,(width-20)/(xmax-xmin),(height-20-height*.06)/(ymax-ymin));const ox=(width-unit*(xmax-xmin))/2-unit*xmin,oy=(height-unit*(ymax-ymin))/2-unit*ymin-height*.03;const tr=([x,y])=>[ox+x*unit,oy+y*unit];polygons=raw.map(r=>({...r,p:r.p.map(tr)}));centers=new Map([...center].map(([id,p])=>[id,tr(p)]));
// Place irregular-block labels inside the nearest occupied unit, as in the original view.
if(level.geometry!=='hex')for(const c of level.cells)if(c.shape){const middle=center.get(c.id);const candidates=units(c).map(([x,y])=>[x+.5,y+.5]);candidates.sort((a,b)=>Math.hypot(a[0]-middle[0],a[1]-middle[1])-Math.hypot(b[0]-middle[0],b[1]-middle[1]));centers.set(c.id,tr(candidates[0]));}
const bad=violations(level,path);if(level.tutorial&&!bad.length&&!cleared)focus=path.length?[solution[path.length]]:level.cells.filter(c=>c.direction).map(c=>c.id);const badIds=new Set(bad.flatMap(b=>b.cells));channelViews=[];cellViews=[];
for(let i=1;i<path.length;i++)drawBridge(path[i-1],path[i]);
for(const c of level.cells){const visited=path.includes(c.id),start=c.clue===1,ring=c.clue!==undefined||c.parity||c.direction;const alternate=c.category!==undefined;let fill=badIds.has(c.id)?UI_THEME.danger:alternate?(c.category==='circle'?'#64acf3':'#ffae55'):cleared?UI_THEME.bright:visited||start?UI_THEME.primary:UI_THEME.cell;const foreground=badIds.has(c.id)||!alternate&&(visited||start)?'#FFFFFF':UI_THEME.ink;const cellUnit=level.geometry==='hex'?Math.sqrt(3)*unit:unit;cellViews.push({cell:c.id,fill,error:badIds.has(c.id),category:c.category||null,cellUnit});if(focus.includes(c.id))paintCell(c,cellUnit*.008,UI_THEME.hint);if(ring)paintCell(c,cellUnit*.045,visited||start?UI_THEME.ring:UI_THEME.ink);paintCell(c,cellUnit*.05+(ring?Math.max(2,cellUnit*.05):0),fill);
const [x,y]=centers.get(c.id),step=path.indexOf(c.id)+1;let value=step||c.clue;const ratio=value===1?.6:value>=10?.46:.52,font=Math.max(14,Math.min(52,cellUnit*ratio));ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=foreground;
if(c.direction){const radius=Math.min(unit*.26,28),state=directionState(c,badIds);drawDirectionPath(c,x,y);drawChannel(c.direction,x,y,radius,state);channelViews.push({cell:c.id,kind:c.direction,center:[x,y],radius,state});}
if(value&&c.direction){const size=Math.max(10,Math.min(18,unit*.22));ctx.fillStyle=visited||start?'#FFFFFF':UI_THEME.cell;roundedRect(x+unit*.105,y-unit*.38,unit*.28,unit*.28,unit*.07);ctx.fill();ctx.fillStyle=UI_THEME.ink;ctx.font='800 '+size+'px '+BOARD_FONT;ctx.fillText(String(value),x+unit*.245,y-unit*.24);}
else if(value){ctx.font='800 '+font+'px '+BOARD_FONT;ctx.fillText(String(value),x,y+(c.direction?unit*.09:c.parity?-unit*.06:0)+font*.04);if(c.parity){ctx.font='700 '+Math.max(10,unit*.19)+'px '+BOARD_FONT;ctx.fillText(c.parity==='odd'?'奇':'偶',x,y+unit*.27);}if(c.clue!==undefined&&step&&step!==c.clue){ctx.font='800 '+Math.max(10,unit*.19)+'px '+BOARD_FONT;ctx.fillText('需'+c.clue,x,y-unit*.3);}}
else if(c.parity){ctx.font='700 '+Math.max(16,unit*.44)+'px '+BOARD_FONT;ctx.fillText(c.parity==='odd'?'奇':'偶',x,y+unit*.015);}
}
for(const edge of level.walls||[])drawBarrier(edge,'wall');for(const d of level.doors||[])drawBarrier(d.edge,doorLatched(d,path)?'latched':doorOpen(d,path)?'open':'closed');
updateTutorial(bad);$('remaining-n').textContent=cleared?'完成':String(level.cells.length-path.length);$('remaining').className='remaining'+(cleared?' done':'');if(level.doors?.length)renderConstraints();if(bad.length)setStatus(bad[0].message+',沿来时路逐格退回。');$('undo').disabled=!path.length;$('next').hidden=!cleared||index===playLevels.length-1;
}
function roundedRect(x,y,w,h,r){r=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();}
function paintCell(c,inset,colour){ctx.fillStyle=colour;if(level.geometry==='hex'){const p=polygons.find(p=>p.id===c.id).p,[cx,cy]=centers.get(c.id),scale=1-inset/(unit*.86);const pts=p.map(([x,y])=>[cx+(x-cx)*scale,cy+(y-cy)*scale]);ctx.beginPath();for(let i=0;i<6;i++){const a=pts[(i+5)%6],b=pts[i],d=pts[(i+1)%6];const radius=Math.max(2,Math.sqrt(3)*unit*.18-inset*.2),tangent=Math.min(radius/Math.sqrt(3),Math.hypot(a[0]-b[0],a[1]-b[1])*.3);const toward=p=>{const length=Math.hypot(p[0]-b[0],p[1]-b[1]);return [b[0]+(p[0]-b[0])*tangent/length,b[1]+(p[1]-b[1])*tangent/length];};const before=toward(a),after=toward(d);if(i===0)ctx.moveTo(...before);else ctx.lineTo(...before);ctx.quadraticCurveTo(...b,...after);}ctx.closePath();ctx.fill();return;}
const polygonsHere=polygons.filter(p=>p.id===c.id),u=units(c),origin=[polygonsHere[0].p[0][0]-u[0][0]*unit,polygonsHere[0].p[0][1]-u[0][1]*unit],radius=Math.max(2,unit*.18-inset*.2);
if(!c.shape){roundedRect(origin[0]+c.x*unit+inset,origin[1]+c.y*unit+inset,c.w*unit-inset*2,c.h*unit-inset*2,radius);ctx.fill();return;}
const boundary=outlineUnits(c).map(([x,y])=>[origin[0]+x*unit,origin[1]+y*unit]);
const insetPoints=boundary.map((b,i)=>{const a=boundary[(i+boundary.length-1)%boundary.length],d=boundary[(i+1)%boundary.length];const n=([x,y],[xx,yy])=>{const len=Math.hypot(xx-x,yy-y);return [-(yy-y)/len,(xx-x)/len];};const n1=n(a,b),n2=n(b,d);return [b[0]+(n1[0]+n2[0])*inset,b[1]+(n1[1]+n2[1])*inset];});
ctx.beginPath();for(let i=0;i<insetPoints.length;i++){const a=insetPoints[(i+insetPoints.length-1)%insetPoints.length],b=insetPoints[i],d=insetPoints[(i+1)%insetPoints.length];const cross=(b[0]-a[0])*(d[1]-b[1])-(b[1]-a[1])*(d[0]-b[0]);const r=cross>0?Math.min(radius,Math.hypot(b[0]-a[0],b[1]-a[1])/2,Math.hypot(d[0]-b[0],d[1]-b[1])/2):0;const toward=(p)=>{const len=Math.hypot(p[0]-b[0],p[1]-b[1]);return [b[0]+(p[0]-b[0])*r/len,b[1]+(p[1]-b[1])*r/len];};const before=toward(a),after=toward(d);if(i===0)ctx.moveTo(...before);else ctx.lineTo(...before);ctx.quadraticCurveTo(...b,...after);}ctx.closePath();ctx.fill();}
function drawBridge(a,b){const segments=sharedSegments(a,b);if(!segments.length)return;const [p,q]=segments[0],cx=(p[0]+q[0])/2,cy=(p[1]+q[1])/2,len=Math.hypot(q[0]-p[0],q[1]-p[1]),nx=-(q[1]-p[1])/len,ny=(q[0]-p[0])/len;ctx.beginPath();ctx.moveTo(cx-nx*unit*.09,cy-ny*unit*.09);ctx.lineTo(cx+nx*unit*.09,cy+ny*unit*.09);ctx.lineWidth=(level.geometry==='hex'?Math.sqrt(3)*unit:unit)*.46;ctx.lineCap='butt';ctx.strokeStyle=cleared?UI_THEME.bright:UI_THEME.primary;ctx.stroke();}
function sharedSegments(a,b){const edges=[];for(const p of polygons.filter(p=>p.id===a))for(const q of polygons.filter(p=>p.id===b))for(let i=0;i<p.p.length;i++)for(let j=0;j<q.p.length;j++){const x=p.p[i],y=p.p[(i+1)%p.p.length],v=q.p[j],w=q.p[(j+1)%q.p.length];if(Math.hypot(x[0]-w[0],x[1]-w[1])<.01&&Math.hypot(y[0]-v[0],y[1]-v[1])<.01)edges.push([x,y]);}return edges;}
function sideOf(c,id){return sideBetween(level,c.id,id);}
function directionState(c,badIds=new Set()){const k=path.indexOf(c.id);if(k<0)return 'idle';if(k===0||badIds.has(c.id))return 'error';const entry=sideOf(c,path[k-1]);if(!c.direction.includes(entry))return 'error';if(k<path.length-1){const exit=sideOf(c,path[k+1]);if([entry,exit].sort().join('')!==c.direction)return 'error';}return 'correct';}
function drawDirectionPath(c,x,y){const k=path.indexOf(c.id);if(k<0)return;const vectors={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};ctx.beginPath();if(k>0){const [dx,dy]=(vectors[sideOf(c,path[k-1])]||[0,0]);ctx.moveTo(x+dx*unit*.44,y+dy*unit*.44);ctx.lineTo(x,y);}else ctx.moveTo(x,y);if(k<path.length-1){const [dx,dy]=(vectors[sideOf(c,path[k+1])]||[0,0]);ctx.lineTo(x+dx*unit*.44,y+dy*unit*.44);}ctx.lineCap='round';ctx.lineJoin='round';ctx.lineWidth=unit*.14;ctx.strokeStyle='#BEB8FF';ctx.stroke();}
function drawChannel(kind,x,y,r,state='idle'){const vector={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};const [a,b]=kind.split('').map(k=>vector[k]);ctx.beginPath();ctx.moveTo(x+a[0]*r,y+a[1]*r);ctx.lineTo(x,y);ctx.lineTo(x+b[0]*r,y+b[1]*r);ctx.lineCap='round';ctx.lineJoin='round';ctx.lineWidth=10;ctx.strokeStyle=state==='correct'?'#C8C2FF':state==='error'?'#FFD5D7':'#DFE1EC';ctx.stroke();ctx.lineWidth=5;ctx.strokeStyle=state==='correct'?'#FFFFFF':state==='error'?'#E5484D':UI_THEME.ink;ctx.stroke();}
function drawBarrier(edge,kind){const isOpen=kind==='open'||kind==='latched';const left=polygons.filter(p=>p.id===edge[0]),right=polygons.filter(p=>p.id===edge[1]);let commons=[];for(const a of left)for(const b of right)for(let i=0;i<a.p.length;i++)for(let j=0;j<b.p.length;j++){const p=a.p[i],q=a.p[(i+1)%a.p.length],r=b.p[j],t=b.p[(j+1)%b.p.length];if(Math.hypot(p[0]-t[0],p[1]-t[1])<.01&&Math.hypot(q[0]-r[0],q[1]-r[1])<.01)commons.push([p,q]);}for(const common of commons){const [a,b]=common;const point=f=>[a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f];ctx.lineCap='butt';ctx.lineWidth=9;ctx.strokeStyle=isOpen?'#23895f':kind==='wall'?'#272638':'#aa4d25';ctx.beginPath();if(isOpen){ctx.moveTo(...point(.05));ctx.lineTo(...point(.26));ctx.moveTo(...point(.74));ctx.lineTo(...point(.95));}else{ctx.moveTo(...point(.05));ctx.lineTo(...point(.95));}ctx.stroke();if(kind==='wall'){ctx.lineWidth=4;ctx.strokeStyle='#ffc542';ctx.setLineDash([5,4]);ctx.beginPath();ctx.moveTo(...point(.05));ctx.lineTo(...point(.95));ctx.stroke();ctx.setLineDash([]);}if(kind!=='wall'){const [x,y]=point(.5);ctx.font='700 '+Math.max(10,Math.min(12,unit*.3))+'px '+BOARD_FONT;ctx.textAlign='center';ctx.fillStyle=isOpen?'#12623f':'#993b1e';ctx.fillText(kind==='latched'?'已过':isOpen?'开':'关',x+10,y-8);}}}
function inside(x,y,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;}
function hit(x,y){return polygons.find(s=>{if(level.geometry!=='hex')return inside(x,y,s.p);const [cx,cy]=centers.get(s.id),scale=inputKind==='touch'? .92:.86;return inside(x,y,s.p.map(([px,py])=>[cx+(px-cx)*scale,cy+(py-cy)*scale]));})?.id;}
function markError(id,message){sound.play('error');if(!record.firstError)record.firstError={step:path.length+1,cell:id,message};setStatus(message);}
function advance(id){if(!id||cleared)return;const at=path.indexOf(id);if(at!==-1){if(path.length>=2&&at===path.length-2){record.backtracks++;path.pop();sound.play('undo');focus=[];setStatus('已回退,继续观察剩余格。');draw();}return;}
if(!canAppend(level,path,id)){const g=graph(level),adjacent=path.length&&g.adj[g.ids.get(path.at(-1))].includes(g.ids.get(id));if(adjacent||!path.length){markError(id,path.length?(level.walls?.some(e=>e.includes(path.at(-1))&&e.includes(id))?'这是永久墙,不能穿过。':level.doors?.some(d=>d.edge.includes(path.at(-1))&&d.edge.includes(id))?'门现在关闭;绕行落格后会切换。':'同色格不能连续经过,请换另一种颜色。'):'请从数字 1 开始。');}return;}
if(firstDrawAt===null){firstDrawAt=performance.now();record.thinkingBeforeFirstDrawSeconds=Math.round((firstDrawAt-started)/1000);}path.push(id);focus=[];const bad=violations(level,path);if(bad.length)markError(id,bad[0].message);else{const cell=level.cells.find(c=>c.id===id);sound.play(cell.clue!==undefined&&cell.clue===path.length?'correct':'move',0,path.length);setStatus('');}if(path.length===level.cells.length&&!bad.length){cleared=true;record.completed=true;record.elapsedSeconds=Math.round((performance.now()-started)/1000);if(!completed.includes(level.id)){completed.push(level.id);try{localStorage.setItem(memoryKey,JSON.stringify(completed));}catch{}}setStatus(index===playLevels.length-1?total+'关完成!':'完成!');sound.play('complete',.23);saveRecord();renderSelectors();}draw();}
function point(e){const r=canvas.getBoundingClientRect();return [e.clientX-r.left,e.clientY-r.top];}
canvas.addEventListener('pointerdown',e=>{e.preventDefault();sound.unlock();inputKind=e.pointerType||'mouse';drag=true;canvas.setPointerCapture(e.pointerId);lastPoint=point(e);advance(hit(...lastPoint));});
canvas.addEventListener('pointermove',e=>{if(!drag)return;e.preventDefault();const p=point(e),a=lastPoint||p,d=Math.hypot(p[0]-a[0],p[1]-a[1]),steps=Math.ceil(d/Math.max(3,unit*.1));for(let i=1;i<=steps;i++)advance(hit(a[0]+(p[0]-a[0])*i/steps,a[1]+(p[1]-a[1])*i/steps));lastPoint=p;});
function end(){drag=false;lastPoint=null;}canvas.addEventListener('pointerup',end);canvas.addEventListener('pointercancel',end);
$('undo').onclick=()=>{sound.unlock();if(path.length){path.pop();sound.play('undo');record.backtracks++;record.completed=false;cleared=false;focus=[];setStatus('已撤销一步。');draw();}};
$('restart').onclick=()=>{sound.unlock();sound.play('restart');path=[];cleared=false;focus=[];record.restarts++;record.completed=false;setStatus('已重开。');draw();};
$('hint').onclick=()=>{record.hints++;const h=hint(solution,path);focus=h.kind==='none'?[]:[h.cell];setStatus(h.kind==='backtrack'?'请沿来时路逐格退回黄色边框格,再继续。':h.kind==='restart'?'起点需要调整,请重开并从黄色边框格开始。':h.kind==='none'?'已经完成。':'下一步在黄色边框格;提示不会自动画路。');draw();};
$('next').onclick=()=>{if(index+1<playLevels.length)start(index+1);};
$('export').onclick=()=>{saveRecord();const payload={version:pack.version,exportedAt:new Date().toISOString(),clearedIds:completed,records};const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='NumberSwipe-DEMO200-试玩记录.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);setStatus('已导出全部试玩记录。');};
window.addEventListener('pagehide',saveRecord);
$('btn-back').onclick=showSelect;$('btn-continue').onclick=showPlay;$('btn-rules').onclick=()=>{$('rules-sheet').hidden=false;};$('close-rules').onclick=()=>{$('rules-sheet').hidden=true;};$('btn-feedback').onclick=()=>{$('feedback-sheet').hidden=false;};$('close-feedback').onclick=()=>{saveRecord();$('feedback-sheet').hidden=true;};
window.addEventListener('resize',draw);window.visualViewport?.addEventListener('resize',draw);window.addEventListener('orientationchange',draw);let initialId=null;try{initialId=localStorage.getItem(memoryKey+':current');}catch{}let initial=playLevels.findIndex(l=>l.id===initialId);start(initial>=0?initial:0,false);
function renderSoundButton(){$('btn-sound').textContent=sound.enabled?'音效开':'音效关';$('btn-sound').title=sound.enabled?'关闭音效':'开启音效';$('btn-sound').setAttribute?.('aria-pressed',String(sound.enabled));}
$('btn-sound').onclick=()=>{sound.toggle();renderSoundButton();};renderSoundButton();
function updateTutorial(bad){if(!level.tutorial)return;let text=level.caption;if(bad.length)text='规则不符,请沿来时路退回上一格再试。';else if(cleared)text='学会了!接下来试试正式关卡。';else if(level.tutorial==='colour')text=path.length===1?'下一步去橙色块;同色蓝块不能连续走。':path.length===2?'再换回蓝色,完成蓝 → 橙 → 蓝。':level.caption;else if(['straight','vertical','corner'].includes(level.tutorial)){const c=level.cells.find(c=>c.direction);const at=path.indexOf(c.id);if(at>=0&&at===path.length-1)text=level.tutorial==='straight'?'沿横线从另一侧离开,不能拐弯。':level.tutorial==='vertical'?'沿竖线从另一侧离开,不能向左右走。':'沿图中拐角连接的另一侧离开。';}$('caption').textContent=text;}
// Read-only snapshot used for interaction verification; never supplies an answer.
window.variantSnapshot=()=>({mode:pack.mode,level:level.id,order:level.order,displayOrder:index+1,tutorial:level.tutorial||null,soundEnabled:sound.enabled,lastSound:sound.lastEvent,chapter:level.chapter,records:records.length,cellViews,geometry:level.geometry,directionMarkers:channelViews,screen:$('screen-play').className.includes('hidden')?'select':'play',path:[...path],violations:violations(level,path),cleared,centers:Object.fromEntries(centers),unit,openedGates:(level.gates||[]).map(g=>path.includes(g.switchCell)),doors:(level.doors||[]).map(d=>doorOpen(d,path)),latchedDoors:(level.doors||[]).map(d=>doorLatched(d,path))});