记录清单对应的原始 CRLF 快照字节

This commit is contained in:
toshiocn
2026-10-23 11:06:05 +08:00
parent 357b046c87
commit a4a7102ced
6 changed files with 444 additions and 444 deletions
+10 -10
View File
@@ -1,10 +1,10 @@
// Small offline Web Audio cues: no network or audio-file dependency.
export class CasualAudio {
constructor(host,storage,key){this.host=host;this.storage=storage;this.key=key;this.context=null;this.lastEvent=null;this.lastAt={};try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}}
unlock(){if(!this.enabled)return false;try{const Audio=this.host.AudioContext||this.host.webkitAudioContext;if(!Audio)return false;if(!this.context)this.context=new Audio();if(this.context.state==='suspended')this.context.resume()?.catch(()=>{});return true;}catch{return false;}}
toggle(){this.enabled=!this.enabled;try{this.storage.setItem(this.key,this.enabled?'on':'off');}catch{}if(this.enabled)this.unlock();return this.enabled;}
play(kind,delay=0,step=1){if(!this.unlock())return;const ctx=this.context,now=ctx.currentTime;const gap=kind==='error'?.18:0;if(this.lastAt[kind]!==undefined&&now-this.lastAt[kind]<gap)return;this.lastAt[kind]=now;this.lastEvent=kind;
const cues={move:[[523*Math.pow(2,(Math.max(1,step)-1)/12),.07,0]],correct:[[659,.09,0],[880,.14,.075]],error:[[330,.10,0],[247,.15,.09]],undo:[[587,.07,0],[440,.10,.055]],restart:[[523,.075,0],[392,.09,.065],[523,.11,.14]],complete:[[523,.12,0],[659,.12,.10],[784,.14,.20],[1046,.25,.32]]};
for(const [frequency,duration,offset]of cues[kind]||[]){try{const start=now+delay+offset,osc=ctx.createOscillator(),gain=ctx.createGain();osc.type='sine';osc.frequency.setValueAtTime(frequency,start);gain.gain.setValueAtTime(.0001,start);gain.gain.linearRampToValueAtTime(kind==='error'?.055:.075,start+.008);gain.gain.exponentialRampToValueAtTime(.0001,start+duration);osc.connect(gain);gain.connect(ctx.destination);osc.onended=()=>{osc.disconnect();gain.disconnect();};osc.start(start);osc.stop(start+duration+.015);}catch{}}
}
}
// Small offline Web Audio cues: no network or audio-file dependency.
export class CasualAudio {
constructor(host,storage,key){this.host=host;this.storage=storage;this.key=key;this.context=null;this.lastEvent=null;this.lastAt={};try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}}
unlock(){if(!this.enabled)return false;try{const Audio=this.host.AudioContext||this.host.webkitAudioContext;if(!Audio)return false;if(!this.context)this.context=new Audio();if(this.context.state==='suspended')this.context.resume()?.catch(()=>{});return true;}catch{return false;}}
toggle(){this.enabled=!this.enabled;try{this.storage.setItem(this.key,this.enabled?'on':'off');}catch{}if(this.enabled)this.unlock();return this.enabled;}
play(kind,delay=0,step=1){if(!this.unlock())return;const ctx=this.context,now=ctx.currentTime;const gap=kind==='error'?.18:0;if(this.lastAt[kind]!==undefined&&now-this.lastAt[kind]<gap)return;this.lastAt[kind]=now;this.lastEvent=kind;
const cues={move:[[523*Math.pow(2,(Math.max(1,step)-1)/12),.07,0]],correct:[[659,.09,0],[880,.14,.075]],error:[[330,.10,0],[247,.15,.09]],undo:[[587,.07,0],[440,.10,.055]],restart:[[523,.075,0],[392,.09,.065],[523,.11,.14]],complete:[[523,.12,0],[659,.12,.10],[784,.14,.20],[1046,.25,.32]]};
for(const [frequency,duration,offset]of cues[kind]||[]){try{const start=now+delay+offset,osc=ctx.createOscillator(),gain=ctx.createGain();osc.type='sine';osc.frequency.setValueAtTime(frequency,start);gain.gain.setValueAtTime(.0001,start);gain.gain.linearRampToValueAtTime(kind==='error'?.055:.075,start+.008);gain.gain.exponentialRampToValueAtTime(.0001,start+duration);osc.connect(gain);gain.connect(ctx.destination);osc.onended=()=>{osc.disconnect();gain.disconnect();};osc.start(start);osc.stop(start+duration+.015);}catch{}}
}
}
+128 -128
View File
@@ -1,128 +1,128 @@
// Pure rules / geometry / solver. Each mode receives its own standalone copy.
export function units(c){return c.shape?c.shape.map(([x,y])=>[c.x+x,c.y+y]):Array.from({length:(c.w||1)*(c.h||1)},(_,i)=>[c.x+i%(c.w||1),c.y+Math.floor(i/(c.w||1))]);}
export function outlineUnits(c){
const occupied=new Set(units(c).map(p=>p.join(','))),edges=[];
for(const [x,y]of units(c))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(!occupied.has((x+dx)+','+(y+dy)))edges.push([a,b]);
const next=new Map(edges.map(([a,b])=>[a.join(','),b])),start=edges[0][0],points=[start];let at=next.get(start.join(','));
while(at.join(',')!==start.join(',')){points.push(at);at=next.get(at.join(','));if(points.length>edges.length)throw Error('Invalid block contour');}
return points.filter((b,i)=>{const a=points[(i+points.length-1)%points.length],d=points[(i+1)%points.length];return (b[0]-a[0])*(d[1]-b[1])!==(b[1]-a[1])*(d[0]-b[0]);});
}
export function graph(level){
const cells=level.cells, ids=new Map(cells.map((c,i)=>[c.id,i]));
if(ids.size!==cells.length)throw Error('重复格 id');
const adj=cells.map(()=>[]), occupancy=new Map();
if(level.geometry!=='hex') for(let i=0;i<cells.length;i++)for(const [x,y]of units(cells[i])){const k=x+','+y;if(occupancy.has(k))throw Error('拼块重叠');occupancy.set(k,i);}
for(let i=0;i<cells.length;i++)for(let j=i+1;j<cells.length;j++){
let connected=false;
if(level.geometry==='hex'){let dq=cells[j].q-cells[i].q,dr=cells[j].r-cells[i].r;connected=[[1,0],[-1,0],[0,1],[0,-1],[1,-1],[-1,1]].some(([a,b])=>a===dq&&b===dr);}
else connected=units(cells[i]).some(([x,y])=>[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>occupancy.get((x+a)+','+(y+b))===j));
if(connected){adj[i].push(j);adj[j].push(i);}
}
const clues=new Map();for(const c of cells)if(c.clue!==undefined){if(!Number.isInteger(c.clue)||c.clue<1||c.clue>cells.length||clues.has(c.clue))throw Error('数字范围或重复');clues.set(c.clue,ids.get(c.id));}
return {cells,ids,adj,clues};
}
export function constraints(level){
const result=[];
for(let i=0;i<(level.relations||[]).length;i++)result.push({key:'relation:'+i,kind:'relation',...level.relations[i]});
for(let i=0;i<(level.orderChains||[]).length;i++)for(let j=0;j<level.orderChains[i].length-1;j++)result.push({key:'order:'+i+':'+j,kind:'order',from:level.orderChains[i][j],to:level.orderChains[i][j+1]});
for(let i=0;i<(level.windows||[]).length;i++)result.push({key:'window:'+i,kind:'window',...level.windows[i]});
for(const c of level.cells)if(c.parity)result.push({key:'parity:'+c.id,kind:'parity',cell:c.id,value:c.parity});
for(const c of level.cells)if(c.direction)result.push({key:'direction:'+c.id,kind:'direction',cell:c.id,value:c.direction});
if((level.features||[]).includes('alternation'))result.push({key:'alternation',kind:'alternation'});
for(let i=0;i<(level.walls||[]).length;i++)result.push({key:'wall:'+i,kind:'wall',edge:level.walls[i]});
for(let i=0;i<(level.doors||[]).length;i++)result.push({key:'door:'+i,kind:'door',...level.doors[i]});
for(let i=0;i<(level.gates||[]).length;i++)result.push({key:'gate:'+i,kind:'gate',...level.gates[i]});
return result;
}
export function sideBetween(level,cellId,neighborId){
const c=level.cells.find(c=>c.id===cellId),n=level.cells.find(c=>c.id===neighborId);const occupied=new Set(units(n).map(p=>p.join(',')));const sides=new Set();for(const [x,y]of units(c))for(const [dx,dy,s]of [[-1,0,'l'],[1,0,'r'],[0,-1,'u'],[0,1,'d']])if(occupied.has((x+dx)+','+(y+dy)))sides.add(s);return sides.size===1?[...sides][0]:null;
}
export function directionAt(level,path,k){if(k===0||k===path.length-1)return null;const a=sideBetween(level,path[k],path[k-1]),b=sideBetween(level,path[k],path[k+1]);return a&&b?[a,b].sort().join(''):null;}
export function doorLatched(door,path){
for(let i=1;i<path.length;i++)if(door.edge.includes(path[i-1])&&door.edge.includes(path[i])&&(Boolean(door.initialOpen)!==Boolean(i%2)))return true;
return false;
}
export function doorOpen(door,path){return Array.isArray(path)?doorLatched(door,path)||(Boolean(door.initialOpen)!==Boolean(path.length%2)):Boolean(door.initialOpen)!==Boolean(path%2);}
export const directionNames={lr:'左右直行',du:'上下直行',ru:'上与右拐角',dr:'右与下拐角',dl:'下与左拐角',lu:'左与上拐角'};
export function edgeBlocked(level,path,a,b,{omit=null}={}){
for(let i=0;i<(level.walls||[]).length;i++)if(omit!=='wall:'+i&&level.walls[i].includes(a)&&level.walls[i].includes(b))return true;
for(let i=0;i<(level.doors||[]).length;i++)if(omit!=='door:'+i&&level.doors[i].edge.includes(a)&&level.doors[i].edge.includes(b)&&!doorOpen(level.doors[i],path))return true;
return false;
}
export function violations(level,path,{omit=null,complete=path.length===level.cells.length,ignoreClues=false}={}){
const positions=new Map(path.map((id,i)=>[id,i+1])),bad=[];
if(!ignoreClues)for(const c of level.cells)if(c.clue!==undefined){const at=positions.get(c.id);if(at!==undefined&&at!==c.clue||path.length>=c.clue&&path[c.clue-1]!==c.id)bad.push({key:'clue:'+c.id,cells:[c.id,path[c.clue-1]].filter(Boolean),message:'固定数字步号不符'});}
for(const r of constraints(level)){
if(r.key===omit)continue;
let fail=false,where=[];
if(r.kind==='relation'){
const a=positions.get(r.from),b=positions.get(r.to);where=[r.from,r.to];
fail=b!==undefined&&(a===undefined||b-a!==r.delta)||a!==undefined&&b===undefined&&path.length>=a+r.delta;
}else if(r.kind==='order'){where=[r.from,r.to];fail=positions.has(r.to)&&(!positions.has(r.from)||positions.get(r.from)>positions.get(r.to));}
else if(r.kind==='window'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined?(p<r.min||p>r.max):path.length>=r.max;}
else if(r.kind==='parity'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined&&(p%2===1)!==(r.value==='odd');}
else if(r.kind==='direction'){where=[r.cell];const p=positions.get(r.cell);fail=p===1||p!==undefined&&p<path.length&&directionAt(level,path,p-1)!==r.value||complete&&p===path.length;}
else if(r.kind==='alternation'){for(let i=1;i<path.length;i++){const a=level.cells.find(c=>c.id===path[i-1]),b=level.cells.find(c=>c.id===path[i]);if(a.category===b.category){fail=true;where.push(a.id,b.id);}}}
else if(r.kind==='wall'||r.kind==='door')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])&&(r.kind==='wall'||!doorOpen(r,path.slice(0,i)))){fail=true;where=r.edge;}}
else if(r.kind==='gate')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])){const s=positions.get(r.switchCell);if(s===undefined||s>i){fail=true;where=r.edge;}}}
if(fail)bad.push({key:r.key,cells:where,message:r.kind==='relation'?'相对步差不符':r.kind==='order'?'标记次序不符':r.kind==='window'?'到达窗口不符':r.kind==='parity'?'该格步号奇偶不符':r.kind==='direction'?'进出方向不符':r.kind==='wall'?'永久墙不能穿过':r.kind==='door'?'这一步门是关闭的':r.kind==='gate'?'开关尚未打开此门':'蓝橙颜色必须交替'});
}
return bad;
}
export function canAppend(level,path,id){
const g=graph(level);if(path.includes(id)||!g.ids.has(id))return false;
if(!path.length)return !g.clues.has(1)||g.cells[g.clues.get(1)].id===id;
const last=path.at(-1);if(edgeBlocked(level,path,last,id))return false;if(!g.adj[g.ids.get(last)].includes(g.ids.get(id)))return false;
if((level.features||[]).includes('alternation')&&g.cells[g.ids.get(last)].category===g.cells[g.ids.get(id)].category)return false;
for(const gate of level.gates||[])if(gate.edge.includes(last)&&gate.edge.includes(id)&&!path.includes(gate.switchCell))return false;
return true;
}
export function solve(level,{limit=2,omit=null,ignoreClues=false,prune=true,nodeLimit=Infinity}={}){
const g=graph(level),n=g.cells.length,results=[],path=[],seen=new Set();let nodes=0;
const active=constraints(level).filter(r=>r.key!==omit),alternating=active.some(r=>r.kind==='alternation');
const adj=g.adj.map((a,i)=>a.filter(j=>(!alternating||g.cells[i].category!==g.cells[j].category)&&!active.some(r=>r.kind==='wall'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id))));
const forced=ignoreClues?new Map():g.clues;
function connected(cur){const reached=new Set([cur]),queue=[cur];for(let k=0;k<queue.length;k++)for(const j of adj[queue[k]])if(!seen.has(j)&&!reached.has(j)){reached.add(j);queue.push(j);}return reached.size===n-path.length+1;}
function visit(i){if(++nodes>nodeLimit)throw Error('求解节点预算耗尽,不能视为唯一');seen.add(i);path.push(g.cells[i].id);
const k=path.length;
if(!violations(level,path,{omit,ignoreClues}).length){
if(k===n)results.push([...path]);
else if(!prune||connected(i)){
const f=forced.get(k+1);let next=adj[i].filter(j=>!seen.has(j)&&(f===undefined||j===f));
next.sort((a,b)=>adj[a].filter(x=>!seen.has(x)).length-adj[b].filter(x=>!seen.has(x)).length);
for(const j of next){if(!ignoreClues&&g.cells[j].clue!==undefined&&g.cells[j].clue!==k+1)continue;
let blocked=edgeBlocked(level,path,g.cells[i].id,g.cells[j].id,{omit});for(const r of active)if(r.kind==='gate'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id)&&!path.includes(r.switchCell))blocked=true;
if(!blocked)visit(j);if(results.length>=limit)break;
}
}
}
path.pop();seen.delete(i);
}
let starts=forced.has(1)?[forced.get(1)]:g.cells.map((_,i)=>i);
for(const i of starts){if(!ignoreClues&&g.cells[i].clue!==undefined&&g.cells[i].clue!==1)continue;visit(i);if(results.length>=limit)break;}
return results;
}
export function hint(solution,path){let k=0;while(k<path.length&&path[k]===solution[k])k++;if(k<path.length)return {kind:k?'backtrack':'restart',cell:k?path[k-1]:solution[0]};return k===solution.length?{kind:'none'}:{kind:k?'next':'start',cell:solution[k]};}
export function assertStructure(level){
const g=graph(level),n=g.cells.length;if(level.features?.includes('direction')&&(level.geometry==='hex'||level.cells.some(c=>c.direction&&units(c).length!==1)))throw Error('方向标记放在方形单位格,可与矩形/异形拼块混用');if(n<3||n>40)throw Error('格数须为3–40');
if(level.geometry==='hex'){const keys=new Set();for(const c of g.cells){if(!Number.isInteger(c.q)||!Number.isInteger(c.r))throw Error('六边坐标');const key=c.q+','+c.r;if(keys.has(key))throw Error('六边重叠');keys.add(key);}}
else for(const c of g.cells){if(!Number.isInteger(c.x)||!Number.isInteger(c.y))throw Error('坐标');const u=units(c);if(!u.length||new Set(u.map(p=>p.join(','))).size!==u.length)throw Error('异形重复');const seen=new Set([u[0].join(',')]);for(let changed=true;changed;){changed=false;for(const [x,y]of u)if(!seen.has(x+','+y)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>seen.has((x+a)+','+(y+b)))){seen.add(x+','+y);changed=true;}}if(seen.size!==u.length)throw Error('异形不连通');}
const reach=new Set([0]),queue=[0];for(let k=0;k<queue.length;k++)for(const j of g.adj[queue[k]])if(!reach.has(j)){reach.add(j);queue.push(j);}if(reach.size!==n)throw Error('盘面不连通');
if((level.doors||[]).length>1)throw Error('步进门只允许一道门');
const physicalEdges=[...(level.walls||[]),...(level.doors||[]).map(d=>d.edge)].map(e=>[...e].sort().join('|'));if(new Set(physicalEdges).size!==physicalEdges.length)throw Error('墙门共边重复');
const pairs=[];
for(const r of constraints(level)){
for(const id of [r.cell,r.from,r.to,r.switchCell,...(r.edge||[])].filter(Boolean))if(!g.ids.has(id))throw Error('约束引用不存在');
if(r.kind==='relation'&&(!Number.isInteger(r.delta)||r.delta<1||r.delta>=n||r.from===r.to))throw Error('步差');
if(r.kind==='order')pairs.push([r.from,r.to]);
if(r.kind==='window'&&(!Number.isInteger(r.min)||!Number.isInteger(r.max)||r.min<1||r.max>n||r.max-r.min<2||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('窗口需允许至少两个同奇偶步号且不与数字重叠');
if(r.kind==='parity'&&(!['odd','even'].includes(r.value)||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('奇偶字段或同格数字冲突');
if(r.kind==='direction'&&!Object.keys(directionNames).includes(r.value))throw Error('方向');
if((r.kind==='wall'||r.kind==='door')&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('墙或门不在共边');
if(r.kind==='gate'&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('门不在共边');
}
if((level.gates||[]).length>3||new Set((level.gates||[]).map(r=>r.switchCell)).size!==(level.gates||[]).length)throw Error('最多三门且开关一对一');
if((level.features||[]).includes('alternation')&&g.cells.some(c=>!['circle','triangle'].includes(c.category)))throw Error('缺少类别');
function cycle(v,stack=new Set(),done=new Set()){if(stack.has(v))throw Error('先后链循环');if(done.has(v))return;stack.add(v);for(const [a,b]of pairs)if(a===v)cycle(b,stack,done);stack.delete(v);done.add(v);}for(const [a]of pairs)cycle(a);
return g;
}
// Pure rules / geometry / solver. Each mode receives its own standalone copy.
export function units(c){return c.shape?c.shape.map(([x,y])=>[c.x+x,c.y+y]):Array.from({length:(c.w||1)*(c.h||1)},(_,i)=>[c.x+i%(c.w||1),c.y+Math.floor(i/(c.w||1))]);}
export function outlineUnits(c){
const occupied=new Set(units(c).map(p=>p.join(','))),edges=[];
for(const [x,y]of units(c))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(!occupied.has((x+dx)+','+(y+dy)))edges.push([a,b]);
const next=new Map(edges.map(([a,b])=>[a.join(','),b])),start=edges[0][0],points=[start];let at=next.get(start.join(','));
while(at.join(',')!==start.join(',')){points.push(at);at=next.get(at.join(','));if(points.length>edges.length)throw Error('Invalid block contour');}
return points.filter((b,i)=>{const a=points[(i+points.length-1)%points.length],d=points[(i+1)%points.length];return (b[0]-a[0])*(d[1]-b[1])!==(b[1]-a[1])*(d[0]-b[0]);});
}
export function graph(level){
const cells=level.cells, ids=new Map(cells.map((c,i)=>[c.id,i]));
if(ids.size!==cells.length)throw Error('重复格 id');
const adj=cells.map(()=>[]), occupancy=new Map();
if(level.geometry!=='hex') for(let i=0;i<cells.length;i++)for(const [x,y]of units(cells[i])){const k=x+','+y;if(occupancy.has(k))throw Error('拼块重叠');occupancy.set(k,i);}
for(let i=0;i<cells.length;i++)for(let j=i+1;j<cells.length;j++){
let connected=false;
if(level.geometry==='hex'){let dq=cells[j].q-cells[i].q,dr=cells[j].r-cells[i].r;connected=[[1,0],[-1,0],[0,1],[0,-1],[1,-1],[-1,1]].some(([a,b])=>a===dq&&b===dr);}
else connected=units(cells[i]).some(([x,y])=>[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>occupancy.get((x+a)+','+(y+b))===j));
if(connected){adj[i].push(j);adj[j].push(i);}
}
const clues=new Map();for(const c of cells)if(c.clue!==undefined){if(!Number.isInteger(c.clue)||c.clue<1||c.clue>cells.length||clues.has(c.clue))throw Error('数字范围或重复');clues.set(c.clue,ids.get(c.id));}
return {cells,ids,adj,clues};
}
export function constraints(level){
const result=[];
for(let i=0;i<(level.relations||[]).length;i++)result.push({key:'relation:'+i,kind:'relation',...level.relations[i]});
for(let i=0;i<(level.orderChains||[]).length;i++)for(let j=0;j<level.orderChains[i].length-1;j++)result.push({key:'order:'+i+':'+j,kind:'order',from:level.orderChains[i][j],to:level.orderChains[i][j+1]});
for(let i=0;i<(level.windows||[]).length;i++)result.push({key:'window:'+i,kind:'window',...level.windows[i]});
for(const c of level.cells)if(c.parity)result.push({key:'parity:'+c.id,kind:'parity',cell:c.id,value:c.parity});
for(const c of level.cells)if(c.direction)result.push({key:'direction:'+c.id,kind:'direction',cell:c.id,value:c.direction});
if((level.features||[]).includes('alternation'))result.push({key:'alternation',kind:'alternation'});
for(let i=0;i<(level.walls||[]).length;i++)result.push({key:'wall:'+i,kind:'wall',edge:level.walls[i]});
for(let i=0;i<(level.doors||[]).length;i++)result.push({key:'door:'+i,kind:'door',...level.doors[i]});
for(let i=0;i<(level.gates||[]).length;i++)result.push({key:'gate:'+i,kind:'gate',...level.gates[i]});
return result;
}
export function sideBetween(level,cellId,neighborId){
const c=level.cells.find(c=>c.id===cellId),n=level.cells.find(c=>c.id===neighborId);const occupied=new Set(units(n).map(p=>p.join(',')));const sides=new Set();for(const [x,y]of units(c))for(const [dx,dy,s]of [[-1,0,'l'],[1,0,'r'],[0,-1,'u'],[0,1,'d']])if(occupied.has((x+dx)+','+(y+dy)))sides.add(s);return sides.size===1?[...sides][0]:null;
}
export function directionAt(level,path,k){if(k===0||k===path.length-1)return null;const a=sideBetween(level,path[k],path[k-1]),b=sideBetween(level,path[k],path[k+1]);return a&&b?[a,b].sort().join(''):null;}
export function doorLatched(door,path){
for(let i=1;i<path.length;i++)if(door.edge.includes(path[i-1])&&door.edge.includes(path[i])&&(Boolean(door.initialOpen)!==Boolean(i%2)))return true;
return false;
}
export function doorOpen(door,path){return Array.isArray(path)?doorLatched(door,path)||(Boolean(door.initialOpen)!==Boolean(path.length%2)):Boolean(door.initialOpen)!==Boolean(path%2);}
export const directionNames={lr:'左右直行',du:'上下直行',ru:'上与右拐角',dr:'右与下拐角',dl:'下与左拐角',lu:'左与上拐角'};
export function edgeBlocked(level,path,a,b,{omit=null}={}){
for(let i=0;i<(level.walls||[]).length;i++)if(omit!=='wall:'+i&&level.walls[i].includes(a)&&level.walls[i].includes(b))return true;
for(let i=0;i<(level.doors||[]).length;i++)if(omit!=='door:'+i&&level.doors[i].edge.includes(a)&&level.doors[i].edge.includes(b)&&!doorOpen(level.doors[i],path))return true;
return false;
}
export function violations(level,path,{omit=null,complete=path.length===level.cells.length,ignoreClues=false}={}){
const positions=new Map(path.map((id,i)=>[id,i+1])),bad=[];
if(!ignoreClues)for(const c of level.cells)if(c.clue!==undefined){const at=positions.get(c.id);if(at!==undefined&&at!==c.clue||path.length>=c.clue&&path[c.clue-1]!==c.id)bad.push({key:'clue:'+c.id,cells:[c.id,path[c.clue-1]].filter(Boolean),message:'固定数字步号不符'});}
for(const r of constraints(level)){
if(r.key===omit)continue;
let fail=false,where=[];
if(r.kind==='relation'){
const a=positions.get(r.from),b=positions.get(r.to);where=[r.from,r.to];
fail=b!==undefined&&(a===undefined||b-a!==r.delta)||a!==undefined&&b===undefined&&path.length>=a+r.delta;
}else if(r.kind==='order'){where=[r.from,r.to];fail=positions.has(r.to)&&(!positions.has(r.from)||positions.get(r.from)>positions.get(r.to));}
else if(r.kind==='window'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined?(p<r.min||p>r.max):path.length>=r.max;}
else if(r.kind==='parity'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined&&(p%2===1)!==(r.value==='odd');}
else if(r.kind==='direction'){where=[r.cell];const p=positions.get(r.cell);fail=p===1||p!==undefined&&p<path.length&&directionAt(level,path,p-1)!==r.value||complete&&p===path.length;}
else if(r.kind==='alternation'){for(let i=1;i<path.length;i++){const a=level.cells.find(c=>c.id===path[i-1]),b=level.cells.find(c=>c.id===path[i]);if(a.category===b.category){fail=true;where.push(a.id,b.id);}}}
else if(r.kind==='wall'||r.kind==='door')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])&&(r.kind==='wall'||!doorOpen(r,path.slice(0,i)))){fail=true;where=r.edge;}}
else if(r.kind==='gate')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])){const s=positions.get(r.switchCell);if(s===undefined||s>i){fail=true;where=r.edge;}}}
if(fail)bad.push({key:r.key,cells:where,message:r.kind==='relation'?'相对步差不符':r.kind==='order'?'标记次序不符':r.kind==='window'?'到达窗口不符':r.kind==='parity'?'该格步号奇偶不符':r.kind==='direction'?'进出方向不符':r.kind==='wall'?'永久墙不能穿过':r.kind==='door'?'这一步门是关闭的':r.kind==='gate'?'开关尚未打开此门':'蓝橙颜色必须交替'});
}
return bad;
}
export function canAppend(level,path,id){
const g=graph(level);if(path.includes(id)||!g.ids.has(id))return false;
if(!path.length)return !g.clues.has(1)||g.cells[g.clues.get(1)].id===id;
const last=path.at(-1);if(edgeBlocked(level,path,last,id))return false;if(!g.adj[g.ids.get(last)].includes(g.ids.get(id)))return false;
if((level.features||[]).includes('alternation')&&g.cells[g.ids.get(last)].category===g.cells[g.ids.get(id)].category)return false;
for(const gate of level.gates||[])if(gate.edge.includes(last)&&gate.edge.includes(id)&&!path.includes(gate.switchCell))return false;
return true;
}
export function solve(level,{limit=2,omit=null,ignoreClues=false,prune=true,nodeLimit=Infinity}={}){
const g=graph(level),n=g.cells.length,results=[],path=[],seen=new Set();let nodes=0;
const active=constraints(level).filter(r=>r.key!==omit),alternating=active.some(r=>r.kind==='alternation');
const adj=g.adj.map((a,i)=>a.filter(j=>(!alternating||g.cells[i].category!==g.cells[j].category)&&!active.some(r=>r.kind==='wall'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id))));
const forced=ignoreClues?new Map():g.clues;
function connected(cur){const reached=new Set([cur]),queue=[cur];for(let k=0;k<queue.length;k++)for(const j of adj[queue[k]])if(!seen.has(j)&&!reached.has(j)){reached.add(j);queue.push(j);}return reached.size===n-path.length+1;}
function visit(i){if(++nodes>nodeLimit)throw Error('求解节点预算耗尽,不能视为唯一');seen.add(i);path.push(g.cells[i].id);
const k=path.length;
if(!violations(level,path,{omit,ignoreClues}).length){
if(k===n)results.push([...path]);
else if(!prune||connected(i)){
const f=forced.get(k+1);let next=adj[i].filter(j=>!seen.has(j)&&(f===undefined||j===f));
next.sort((a,b)=>adj[a].filter(x=>!seen.has(x)).length-adj[b].filter(x=>!seen.has(x)).length);
for(const j of next){if(!ignoreClues&&g.cells[j].clue!==undefined&&g.cells[j].clue!==k+1)continue;
let blocked=edgeBlocked(level,path,g.cells[i].id,g.cells[j].id,{omit});for(const r of active)if(r.kind==='gate'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id)&&!path.includes(r.switchCell))blocked=true;
if(!blocked)visit(j);if(results.length>=limit)break;
}
}
}
path.pop();seen.delete(i);
}
let starts=forced.has(1)?[forced.get(1)]:g.cells.map((_,i)=>i);
for(const i of starts){if(!ignoreClues&&g.cells[i].clue!==undefined&&g.cells[i].clue!==1)continue;visit(i);if(results.length>=limit)break;}
return results;
}
export function hint(solution,path){let k=0;while(k<path.length&&path[k]===solution[k])k++;if(k<path.length)return {kind:k?'backtrack':'restart',cell:k?path[k-1]:solution[0]};return k===solution.length?{kind:'none'}:{kind:k?'next':'start',cell:solution[k]};}
export function assertStructure(level){
const g=graph(level),n=g.cells.length;if(level.features?.includes('direction')&&(level.geometry==='hex'||level.cells.some(c=>c.direction&&units(c).length!==1)))throw Error('方向标记放在方形单位格,可与矩形/异形拼块混用');if(n<3||n>40)throw Error('格数须为3–40');
if(level.geometry==='hex'){const keys=new Set();for(const c of g.cells){if(!Number.isInteger(c.q)||!Number.isInteger(c.r))throw Error('六边坐标');const key=c.q+','+c.r;if(keys.has(key))throw Error('六边重叠');keys.add(key);}}
else for(const c of g.cells){if(!Number.isInteger(c.x)||!Number.isInteger(c.y))throw Error('坐标');const u=units(c);if(!u.length||new Set(u.map(p=>p.join(','))).size!==u.length)throw Error('异形重复');const seen=new Set([u[0].join(',')]);for(let changed=true;changed;){changed=false;for(const [x,y]of u)if(!seen.has(x+','+y)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>seen.has((x+a)+','+(y+b)))){seen.add(x+','+y);changed=true;}}if(seen.size!==u.length)throw Error('异形不连通');}
const reach=new Set([0]),queue=[0];for(let k=0;k<queue.length;k++)for(const j of g.adj[queue[k]])if(!reach.has(j)){reach.add(j);queue.push(j);}if(reach.size!==n)throw Error('盘面不连通');
if((level.doors||[]).length>1)throw Error('步进门只允许一道门');
const physicalEdges=[...(level.walls||[]),...(level.doors||[]).map(d=>d.edge)].map(e=>[...e].sort().join('|'));if(new Set(physicalEdges).size!==physicalEdges.length)throw Error('墙门共边重复');
const pairs=[];
for(const r of constraints(level)){
for(const id of [r.cell,r.from,r.to,r.switchCell,...(r.edge||[])].filter(Boolean))if(!g.ids.has(id))throw Error('约束引用不存在');
if(r.kind==='relation'&&(!Number.isInteger(r.delta)||r.delta<1||r.delta>=n||r.from===r.to))throw Error('步差');
if(r.kind==='order')pairs.push([r.from,r.to]);
if(r.kind==='window'&&(!Number.isInteger(r.min)||!Number.isInteger(r.max)||r.min<1||r.max>n||r.max-r.min<2||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('窗口需允许至少两个同奇偶步号且不与数字重叠');
if(r.kind==='parity'&&(!['odd','even'].includes(r.value)||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('奇偶字段或同格数字冲突');
if(r.kind==='direction'&&!Object.keys(directionNames).includes(r.value))throw Error('方向');
if((r.kind==='wall'||r.kind==='door')&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('墙或门不在共边');
if(r.kind==='gate'&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('门不在共边');
}
if((level.gates||[]).length>3||new Set((level.gates||[]).map(r=>r.switchCell)).size!==(level.gates||[]).length)throw Error('最多三门且开关一对一');
if((level.features||[]).includes('alternation')&&g.cells.some(c=>!['circle','triangle'].includes(c.category)))throw Error('缺少类别');
function cycle(v,stack=new Set(),done=new Set()){if(stack.has(v))throw Error('先后链循环');if(done.has(v))return;stack.add(v);for(const [a,b]of pairs)if(a===v)cycle(b,stack,done);stack.delete(v);done.add(v);}for(const [a]of pairs)cycle(a);
return g;
}
+84 -84
View File
@@ -1,84 +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))});
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))});