Files
NumberSwipe_New/archive/v1/relative/play.html
T

174 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>相对步差 · 衍生玩法验证</title><style>*{box-sizing:border-box}body{margin:0;background:#f6f5fb;color:#292a43;font-family:system-ui,'Microsoft YaHei',sans-serif}main{max-width:620px;margin:auto;padding:20px 18px 32px}a{color:#6650bf}header{display:flex;justify-content:space-between;align-items:center}h1{font-size:26px;margin:10px 0 4px}h2{font-size:18px;margin:14px 0 8px}p{line-height:1.6;margin:8px 0;color:#66677e}.eyebrow{font-size:12px;letter-spacing:.1em;color:#7e728f}button{font:inherit;min-height:44px;border:1px solid #d8d5e5;border-radius:12px;background:white;color:#47405f;padding:9px 15px;cursor:pointer}button:focus-visible,a:focus-visible,textarea:focus-visible{outline:3px solid #a586ed}button.active{background:#6c56c8;border-color:#6c56c8;color:white}button:disabled{opacity:.4}nav,.controls,#constraints{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}#difficulties{display:grid;grid-template-columns:repeat(4,1fr)}#levels{margin-bottom:18px}.card{padding:14px;border:1px solid #e4e0ef;border-radius:20px;background:white;box-shadow:0 6px 26px #41336508}.meta{display:flex;justify-content:space-between;align-items:center;font-size:13px;color:#767286}#remaining{background:#ece7ff;color:#6650bf;padding:6px 10px;border-radius:20px;font-weight:700}canvas{display:block;width:100%;height:470px;touch-action:none}#status{min-height:48px;font-size:14px;color:#554b70;line-height:1.7}#caption{font-size:13px}#constraints{margin-bottom:0}.constraint{font-size:13px;min-height:36px;padding:6px 10px;background:#f7f4ff}.controls button{flex:1;padding:10px 8px}.controls button.primary{background:#6c56c8;color:white}details{margin-top:16px;padding:12px;background:#eeebf6;border-radius:12px}summary{cursor:pointer;font-weight:600;min-height:28px}textarea{width:100%;min-height:80px;font:inherit;margin:8px 0;border:1px solid #d5cce6;border-radius:8px;padding:10px}.small{font-size:12px}.cards{display:grid;gap:14px}.cards a{display:block;text-decoration:none;color:inherit}.cards .card:hover{border-color:#8d78cf}.pill{font-size:12px;color:#7c659a}footer{margin-top:22px;font-size:12px;color:#857d93}@media(max-width:400px){main{padding:12px}canvas{height:420px}h1{font-size:23px}button{padding:8px 10px}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}</style><main><header><a href="../../index.html">← 全部玩法</a><span class="eyebrow">路径推理实验室</span></header><h1 id="title"></h1><p class="small">四个难度 · 每档两关 · 全部开放</p><nav id="difficulties" aria-label="难度"></nav><nav id="levels" aria-label="关卡"></nav><section class="card"><div class="meta"><span id="position"></span><span id="remaining" aria-live="polite"></span></div><h2 id="goal"></h2><p id="caption"></p><div id="constraints" aria-label="本关条件"></div><canvas id="board" aria-label="关卡盘面;拖动或点按相邻格画路"></canvas><div id="status" role="status" aria-live="polite"></div></section><div class="controls"><button id="undo">撤销</button><button id="restart">重开</button><button id="hint">指路</button><button id="next" class="primary" hidden>下一关</button></div><p class="small">点按或拖动相邻格 · 松手可继续 · 点已走格回退</p><details open><summary>玩法规则</summary><p id="rule-text"></p><p class="small">固定数字必须在对应步到达。所有格各走一次。门和类别限制会阻止移动;其他条件画错可继续,错误处标红。</p></details><details><summary>记录本关体验</summary><p class="small">用自己的话说明关键分叉为什么只能这样走。导出记录含首次错误、回退和提示次数。</p><textarea id="reasoning" aria-label="推理与试玩反馈" placeholder="我先排除了哪条路线?依据是什么?"></textarea><button id="export">导出试玩记录</button></details><footer>原创候选关 · 难度等待真人试玩校准 · 离线可用</footer></main><script>const PACK={"mode":"relative","title":"相对步差","rule":"每格一次。A → B +d:B 的步号减去 A 的步号等于 d;+4 是四次移动,中间有三格。","levels":[{"id":"01","mode":"relative","geometry":"square","difficulty":"入门","title":"认识差值与中间格数量","caption":"每格一次。A → B +d:B 的步号减去 A 的步号等于 d;+4 是四次移动,中间有三格。","cells":[{"id":"c0","x":0,"y":0,"w":1,"h":1},{"id":"c1","x":1,"y":0,"w":1,"h":1},{"id":"c2","x":2,"y":0,"w":1,"h":1,"clue":1},{"id":"c3","x":0,"y":1,"w":1,"h":1},{"id":"c4","x":1,"y":1,"w":1,"h":1},{"id":"c5","x":2,"y":1,"w":1,"h":1}],"relations":[{"from":"c1","to":"c4","delta":3}]},{"id":"02","mode":"relative","geometry":"square","difficulty":"入门","title":"用间隔排除直达路线","caption":"每格一次。A → B +d:B 的步号减去 A 的步号等于 d;+4 是四次移动,中间有三格。","cells":[{"id":"c0","x":0,"y":0,"w":1,"h":1},{"id":"c1","x":1,"y":0,"w":1,"h":1,"clue":1},{"id":"c2","x":2,"y":0,"w":1,"h":1},{"id":"c3","x":0,"y":1,"w":1,"h":1},{"id":"c4","x":1,"y":1,"w":1,"h":1},{"id":"c5","x":2,"y":1,"w":1,"h":1},{"id":"c6","x":0,"y":2,"w":1,"h":1},{"id":"c7","x":1,"y":2,"w":1,"h":1}],"relations":[{"from":"c3","to":"c4","delta":3}]},{"id":"03","mode":"relative","geometry":"square","difficulty":"普通","title":"两端绝对步号都未知","caption":"","cells":[{"id":"c0","x":0,"y":0,"w":1,"h":1,"clue":1},{"id":"c1","x":1,"y":0,"w":1,"h":1},{"id":"c2","x":2,"y":0,"w":1,"h":1},{"id":"c3","x":3,"y":0,"w":1,"h":1},{"id":"c4","x":0,"y":1,"w":1,"h":1},{"id":"c5","x":1,"y":1,"w":1,"h":1},{"id":"c6","x":2,"y":1,"w":1,"h":1},{"id":"c7","x":3,"y":1,"w":1,"h":1},{"id":"c8","x":1,"y":2,"w":1,"h":1},{"id":"c9","x":2,"y":2,"w":1,"h":1}],"relations":[{"from":"c5","to":"c6","delta":5}]},{"id":"04","mode":"relative","geometry":"square","difficulty":"普通","title":"两条独立关系共同选路","caption":"","cells":[{"id":"c0","x":0,"y":0,"w":1,"h":1},{"id":"c1","x":1,"y":0,"w":1,"h":1},{"id":"c2","x":2,"y":0,"w":1,"h":1},{"id":"c3","x":3,"y":0,"w":1,"h":1,"clue":1},{"id":"c4","x":0,"y":1,"w":1,"h":1},{"id":"c5","x":1,"y":1,"w":1,"h":1},{"id":"c6","x":2,"y":1,"w":1,"h":1},{"id":"c7","x":3,"y":1,"w":1,"h":1},{"id":"c8","x":0,"y":2,"w":1,"h":1},{"id":"c9","x":1,"y":2,"w":1,"h":1},{"id":"c10","x":2,"y":2,"w":1,"h":1},{"id":"c11","x":3,"y":2,"w":1,"h":1}],"relations":[{"from":"c1","to":"c9","delta":4},{"from":"c10","to":"c6","delta":3}]},{"id":"05","mode":"relative","geometry":"square","difficulty":"进阶","title":"共享端点的关系联动","caption":"","cells":[{"id":"c0","x":0,"y":0,"w":1,"h":1},{"id":"c1","x":1,"y":0,"w":1,"h":1},{"id":"c2","x":2,"y":0,"w":1,"h":1},{"id":"c3","x":3,"y":0,"w":1,"h":1},{"id":"c4","x":0,"y":1,"w":1,"h":1},{"id":"c5","x":1,"y":1,"w":1,"h":1},{"id":"c6","x":2,"y":1,"w":1,"h":1},{"id":"c7","x":3,"y":1,"w":1,"h":1},{"id":"c8","x":0,"y":2,"w":1,"h":1,"clue":1},{"id":"c9","x":1,"y":2,"w":1,"h":1},{"id":"c10","x":2,"y":2,"w":1,"h":1},{"id":"c11","x":3,"y":2,"w":1,"h":1},{"id":"c12","x":1,"y":3,"w":1,"h":1},{"id":"c13","x":2,"y":3,"w":1,"h":1}],"relations":[{"from":"c0","to":"c11","delta":5},{"from":"c0","to":"c12","delta":10}]},{"id":"06","mode":"relative","geometry":"square","difficulty":"进阶","title":"间隔决定先处理哪个区域","caption":"","cells":[{"id":"c0","x":0,"y":0,"w":1,"h":1},{"id":"c1","x":1,"y":0,"w":1,"h":1},{"id":"c2","x":2,"y":0,"w":1,"h":1},{"id":"c3","x":3,"y":0,"w":1,"h":1},{"id":"c4","x":0,"y":1,"w":1,"h":1},{"id":"c5","x":1,"y":1,"w":1,"h":1},{"id":"c6","x":2,"y":1,"w":1,"h":1},{"id":"c7","x":3,"y":1,"w":1,"h":1},{"id":"c8","x":0,"y":2,"w":1,"h":1,"clue":1},{"id":"c9","x":1,"y":2,"w":1,"h":1},{"id":"c10","x":2,"y":2,"w":1,"h":1},{"id":"c11","x":3,"y":2,"w":1,"h":1},{"id":"c12","x":0,"y":3,"w":1,"h":1},{"id":"c13","x":1,"y":3,"w":1,"h":1},{"id":"c14","x":2,"y":3,"w":1,"h":1},{"id":"c15","x":3,"y":3,"w":1,"h":1}],"relations":[{"from":"c13","to":"c3","delta":7},{"from":"c11","to":"c5","delta":7}]},{"id":"07","mode":"relative","geometry":"square","difficulty":"难","title":"三条关系跨区联动","caption":"","cells":[{"id":"c0","x":0,"y":0,"w":1,"h":1},{"id":"c1","x":1,"y":0,"w":1,"h":1},{"id":"c2","x":2,"y":0,"w":1,"h":1},{"id":"c3","x":3,"y":0,"w":1,"h":1},{"id":"c4","x":0,"y":1,"w":1,"h":1},{"id":"c5","x":1,"y":1,"w":1,"h":1},{"id":"c6","x":2,"y":1,"w":1,"h":1},{"id":"c7","x":3,"y":1,"w":1,"h":1},{"id":"c8","x":0,"y":2,"w":1,"h":1},{"id":"c9","x":1,"y":2,"w":1,"h":1},{"id":"c10","x":2,"y":2,"w":1,"h":1},{"id":"c11","x":3,"y":2,"w":1,"h":1},{"id":"c12","x":0,"y":3,"w":1,"h":1},{"id":"c13","x":1,"y":3,"w":1,"h":1},{"id":"c14","x":2,"y":3,"w":1,"h":1},{"id":"c15","x":3,"y":3,"w":1,"h":1,"clue":1},{"id":"c16","x":1,"y":4,"w":1,"h":1},{"id":"c17","x":2,"y":4,"w":1,"h":1}],"relations":[{"from":"c11","to":"c1","delta":8},{"from":"c3","to":"c17","delta":13},{"from":"c1","to":"c14","delta":8}]},{"id":"08","mode":"relative","geometry":"square","difficulty":"难","title":"无固定起点,联合推断关系位置","caption":"","cells":[{"id":"c0","x":0,"y":0,"w":1,"h":1},{"id":"c1","x":1,"y":0,"w":1,"h":1},{"id":"c2","x":2,"y":0,"w":1,"h":1},{"id":"c3","x":3,"y":0,"w":1,"h":1},{"id":"c4","x":0,"y":1,"w":1,"h":1},{"id":"c5","x":1,"y":1,"w":1,"h":1},{"id":"c6","x":2,"y":1,"w":1,"h":1},{"id":"c7","x":3,"y":1,"w":1,"h":1},{"id":"c8","x":0,"y":2,"w":1,"h":1},{"id":"c9","x":1,"y":2,"w":1,"h":1},{"id":"c10","x":2,"y":2,"w":1,"h":1,"clue":15},{"id":"c11","x":3,"y":2,"w":1,"h":1},{"id":"c12","x":0,"y":3,"w":1,"h":1},{"id":"c13","x":1,"y":3,"w":1,"h":1},{"id":"c14","x":2,"y":3,"w":1,"h":1},{"id":"c15","x":3,"y":3,"w":1,"h":1},{"id":"c16","x":0,"y":4,"w":1,"h":1},{"id":"c17","x":1,"y":4,"w":1,"h":1},{"id":"c18","x":2,"y":4,"w":1,"h":1}],"relations":[{"from":"c3","to":"c13","delta":7},{"from":"c8","to":"c17","delta":9}]}]};
// Pure rules / geometry / solver. Each mode receives its own standalone copy.
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))]);}
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};
}
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.direction)result.push({key:'direction:'+c.id,kind:'direction',cell:c.id,value:c.direction});
if(level.mode.includes('alternation'))result.push({key:'alternation',kind:'alternation'});
for(let i=0;i<(level.gates||[]).length;i++)result.push({key:'gate:'+i,kind:'gate',...level.gates[i]});
return result;
}
function directionAt(level,path,k){
if(k===0||k===path.length-1)return null;
const [a,b,c]=[path[k-1],path[k],path[k+1]].map(id=>level.cells.find(x=>x.id===id));
return b.x-a.x===c.x-b.x&&b.y-a.y===c.y-b.y?'straight':'turn';
}
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==='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==='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==='direction'?'进出方向不符':r.kind==='gate'?'开关尚未打开此门':'类别没有交替'});
}
return bad;
}
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(!g.adj[g.ids.get(last)].includes(g.ids.get(id)))return false;
if(level.mode.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;
}
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));
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=false;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;
}
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]};}
function assertStructure(level){
const g=graph(level),n=g.cells.length;if(n<3||n>20)throw Error('格数须为3–20');
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('盘面不连通');
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==='direction'&&!['straight','turn'].includes(r.value))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.mode.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;
}
// The build embeds this module together with the local rule file and JSON.
const pack=PACK;
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:'+pack.mode;let completed=[];try{completed=JSON.parse(localStorage.getItem(memoryKey)||'[]');}catch{}
const labels=new Map();const metrics={};let started=0,record=null;
function setStatus(s){$('status').textContent=s;}
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 {mode:pack.mode,level:level.id,difficulty:level.difficulty,title:level.title,startedAt:new Date().toISOString(),elapsedSeconds:0,firstError:null,backtracks:0,restarts:0,hints:0,completed:false,reasoning:''};}
function start(i){index=i;level=pack.levels[i];solution=solve(level)[0];if(!solution)throw Error('没有合法路线');path=[];focus=[];cleared=false;started=performance.now();record=makeRecord();$('reasoning').value='';modeMarkers();$('goal').textContent=level.title;$('caption').textContent=level.caption||'先观察,再画;错了可以退回。';$('title').textContent=pack.title;$('rule-text').textContent=pack.rule;setStatus('从数字 1 开始;没有 1 时,可尝试任意格。');renderConstraints();renderSelectors();draw();}
function renderSelectors(){
$('difficulties').replaceChildren();for(const d of ['入门','普通','进阶','难']){const b=document.createElement('button');b.textContent=d;b.className=d===selected?'active':'';b.onclick=()=>{selected=d;start(pack.levels.findIndex(l=>l.difficulty===d));};$('difficulties').append(b);}
$('levels').replaceChildren();for(let i=0;i<pack.levels.length;i++){const l=pack.levels[i];if(l.difficulty!==selected)continue;const b=document.createElement('button');b.textContent=`第 ${i%2+1} 关${completed.includes(l.id)?' ✓':''}`;b.className=i===index?'active':'';b.onclick=()=>start(i);$('levels').append(b);}
$('position').textContent=`${level.difficulty} · ${index%2+1}/2`;
}
function renderConstraints(){const list=$('constraints');list.replaceChildren();
const add=(text,ids)=>{const b=document.createElement('button');b.className='constraint';b.textContent=text;b.onclick=()=>{focus=ids;draw();};list.append(b);};
for(const r of level.relations||[])add(`${labels.get(r.from)} → ${labels.get(r.to)} +${r.delta}`,[r.from,r.to]);
for(const chain of level.orderChains||[])add(chain.map(id=>labels.get(id)).join(' < '),chain);
for(const w of level.windows||[])add(`窗口 ${w.min}–${w.max}`,[w.cell]);
for(const c of level.cells)if(c.direction)add(c.direction==='straight'?'↔ 必须直行':'↱ 必须转弯',[c.id]);
if(level.mode.includes('alternation'))add('○ → △ → ○ · 每步换一类',[]);
for(let i=0;i<(level.gates||[]).length;i++)add(`开关 ${i+1} → 门 ${i+1}`,[level.gates[i].switchCell,...level.gates[i].edge]);
}
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,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((width-28)/(xmax-xmin),(height-24)/(ymax-ymin));const ox=(width-unit*(xmax-xmin))/2-unit*xmin,oy=(height-unit*(ymax-ymin))/2-unit*ymin;
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)]));
const bad=violations(level,path),badIds=new Set(bad.flatMap(b=>b.cells));
for(const shape of polygons){const c=level.cells.find(c=>c.id===shape.id),visited=path.includes(c.id);ctx.beginPath();shape.p.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y));ctx.closePath();ctx.fillStyle=badIds.has(c.id)?'#ffe0dc':visited?'#ddd8ff':c.category==='circle'?'#e4f3ff':c.category==='triangle'?'#fff0d4':'#f0f0f6';ctx.fill();}
// Draw only external unit edges, preserving the outline of an entire large block.
const edgeCounts=new Map();for(const shape of polygons)for(let i=0;i<shape.p.length;i++){let a=shape.p[i],b=shape.p[(i+1)%shape.p.length];let key=shape.id+':'+[a.map(v=>v.toFixed(3)).join(','),b.map(v=>v.toFixed(3)).join(',')].sort().join('|');edgeCounts.set(key,(edgeCounts.get(key)||0)+1);}
for(const shape of polygons)for(let i=0;i<shape.p.length;i++){const a=shape.p[i],b=shape.p[(i+1)%shape.p.length],key=shape.id+':'+[a.map(v=>v.toFixed(3)).join(','),b.map(v=>v.toFixed(3)).join(',')].sort().join('|');if(edgeCounts.get(key)>1)continue;ctx.beginPath();ctx.moveTo(...a);ctx.lineTo(...b);ctx.strokeStyle=focus.includes(shape.id)?'#f59c00':badIds.has(shape.id)?'#c74537':path.includes(shape.id)?'#7762db':'#b4b6cb';ctx.lineWidth=focus.includes(shape.id)?4:2;ctx.stroke();}
if(path.length){ctx.beginPath();path.forEach((id,i)=>{const p=centers.get(id);i?ctx.lineTo(...p):ctx.moveTo(...p);});ctx.lineWidth=5;ctx.lineCap='round';ctx.lineJoin='round';ctx.strokeStyle='#7562db';ctx.globalAlpha=.32;ctx.stroke();ctx.globalAlpha=1;}
for(let i=0;i<(level.gates||[]).length;i++){const gate=level.gates[i],a=centers.get(gate.edge[0]),b=centers.get(gate.edge[1]),mx=(a[0]+b[0])/2,my=(a[1]+b[1])/2,dx=b[0]-a[0],dy=b[1]-a[1],len=Math.hypot(dx,dy),open=path.includes(gate.switchCell);ctx.save();ctx.translate(mx,my);ctx.rotate(Math.atan2(dy,dx));ctx.beginPath();ctx.moveTo(0,-unit*.34);ctx.lineTo(0,unit*.34);ctx.lineWidth=7;ctx.strokeStyle=open?'#349375':'#d98123';if(open)ctx.setLineDash([3,5]);ctx.stroke();ctx.restore();ctx.fillStyle=open?'#25745c':'#8c4917';ctx.font='bold 12px system-ui';ctx.textAlign='center';ctx.fillText(String(i+1),mx+9,my-8);}
for(const c of level.cells){const [x,y]=centers.get(c.id),step=path.indexOf(c.id)+1;const font=Math.max(14,Math.min(24,unit*.34));let symbol=labels.get(c.id)||'';const w=(level.windows||[]).find(w=>w.cell===c.id);if(w)symbol=`${w.min}–${w.max}`;if(c.direction)symbol=c.direction==='straight'?'↔':'↱';if(c.category)symbol=c.category==='circle'?'○':'△';const gateIndex=(level.gates||[]).findIndex(g=>g.switchCell===c.id);if(gateIndex!==-1)symbol=`开${gateIndex+1}`;
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=badIds.has(c.id)?'#a33127':'#30304e';ctx.font=`700 ${font}px system-ui`;if(step){ctx.fillText(String(step),x,y+ (symbol?5:0));if(symbol){ctx.font=`600 ${Math.max(11,font*.65)}px system-ui`;ctx.fillText(symbol,x,y-font*.65);}}else if(symbol)ctx.fillText(symbol,x,y);
if(c.clue!==undefined){if(!step&&!symbol)ctx.fillText(String(c.clue),x,y);else{ctx.font=`700 ${Math.max(11,font*.65)}px system-ui`;ctx.fillText('需'+c.clue,x,y+font*.8);}}
}
$('remaining').textContent=cleared?'完成 ✓':`剩余 ${level.cells.length-path.length} 格`;
if(bad.length)setStatus(bad[0].message+',可以点已走格退回。');
$('undo').disabled=!path.length;$('next').hidden=!cleared||index===7;
}
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=>inside(x,y,s.p))?.id;}
function markError(id,message){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(at<path.length-1){record.backtracks++;path=path.slice(0,at+1);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?'此处类别相同或门尚未打开。':'请从数字 1 开始。');}return;}
if(!path.length)started=performance.now();path.push(id);focus=[];const bad=violations(level,path);if(bad.length)markError(id,bad[0].message);else 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('完成!可以进入下一关,或记录你的推理。');renderSelectors();}draw();}
function point(e){const r=canvas.getBoundingClientRect();return [e.clientX-r.left,e.clientY-r.top];}
canvas.addEventListener('pointerdown',e=>{e.preventDefault();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=()=>{if(path.length){path.pop();record.backtracks++;record.completed=false;cleared=false;focus=[];setStatus('已撤销一步。');draw();}};
$('restart').onclick=()=>{path=[];cleared=false;focus=[];record.restarts++;record.completed=false;started=performance.now();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=()=>{selected=pack.levels[index+1].difficulty;start(index+1);};
$('export').onclick=()=>{record.elapsedSeconds=record.completed?record.elapsedSeconds:Math.round((performance.now()-started)/1000);record.reasoning=$('reasoning').value;const blob=new Blob([JSON.stringify(record,null,2)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=pack.mode+'-'+level.id+'-试玩记录.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);};
window.addEventListener('resize',draw);start(0);
// Read-only snapshot used for interaction verification; never supplies an answer.
window.variantSnapshot=()=>({mode:pack.mode,level:level.id,path:[...path],violations:violations(level,path),cleared,centers:Object.fromEntries(centers),unit,openedGates:(level.gates||[]).map(g=>path.includes(g.switchCell))});
</script></html>