Files

196 lines
35 KiB
HTML
Raw Permalink 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":"hex-alternation","title":"六边融合:双色与墙","rule":"六向共边相邻,每格一步。蓝格 → 橙格,每步换颜色;黑黄永久墙另行封住共边。同色或有墙都不能通过。○是蓝格,△是橙格。","levels":[{"id":"01","mode":"hex-alternation","geometry":"hex","difficulty":"入门","title":"六向双色交替,再看一堵墙","caption":"六向共边相邻,每格一步。蓝格 → 橙格,每步换颜色;黑黄永久墙另行封住共边。同色或有墙都不能通过。○是蓝格,△是橙格。","cells":[{"id":"c0","q":0,"r":0,"category":"circle"},{"id":"c1","q":0,"r":1,"category":"triangle"},{"id":"c2","q":1,"r":0,"category":"triangle"},{"id":"c3","q":1,"r":1,"category":"circle"},{"id":"c4","q":2,"r":0,"clue":1,"category":"circle"},{"id":"c5","q":2,"r":1,"category":"triangle"}],"walls":[["c1","c3"]]},{"id":"02","mode":"hex-alternation","geometry":"hex","difficulty":"入门","title":"同色边与墙边是两种阻挡","caption":"六向共边相邻,每格一步。蓝格 → 橙格,每步换颜色;黑黄永久墙另行封住共边。同色或有墙都不能通过。○是蓝格,△是橙格。","cells":[{"id":"c0","q":0,"r":0,"category":"triangle"},{"id":"c1","q":0,"r":1,"category":"circle","clue":7},{"id":"c2","q":1,"r":0,"category":"circle"},{"id":"c3","q":1,"r":1,"category":"triangle"},{"id":"c4","q":2,"r":0,"category":"triangle"},{"id":"c5","q":2,"r":1,"category":"circle"},{"id":"c6","q":3,"r":0,"clue":1,"category":"circle"},{"id":"c7","q":3,"r":1,"category":"triangle"}],"walls":[["c2","c3"]]},{"id":"03","mode":"hex-alternation","geometry":"hex","difficulty":"普通","title":"颜色数量与墙共同决定端点","caption":"","cells":[{"id":"c0","q":0,"r":0,"clue":1,"category":"circle"},{"id":"c1","q":0,"r":1,"category":"triangle"},{"id":"c2","q":1,"r":0,"category":"circle"},{"id":"c3","q":1,"r":1,"category":"triangle"},{"id":"c4","q":2,"r":0,"category":"circle"},{"id":"c5","q":2,"r":1,"category":"triangle"},{"id":"c6","q":3,"r":0,"category":"circle"},{"id":"c7","q":3,"r":1,"category":"triangle"},{"id":"c8","q":4,"r":0,"category":"circle"},{"id":"c9","q":4,"r":1,"category":"circle"},{"id":"c10","q":5,"r":0,"category":"triangle"}],"walls":[["c7","c8"]]},{"id":"04","mode":"hex-alternation","geometry":"hex","difficulty":"普通","title":"两堵墙配合六向出口选择","caption":"","cells":[{"id":"c0","q":0,"r":0,"category":"triangle"},{"id":"c1","q":0,"r":1,"category":"circle"},{"id":"c2","q":1,"r":0,"category":"circle"},{"id":"c3","q":1,"r":1,"category":"triangle"},{"id":"c4","q":2,"r":0,"category":"triangle"},{"id":"c5","q":1,"r":2,"clue":1,"category":"circle"},{"id":"c6","q":3,"r":0,"category":"circle"},{"id":"c7","q":3,"r":1,"category":"triangle"},{"id":"c8","q":4,"r":0,"category":"triangle"},{"id":"c9","q":4,"r":1,"category":"circle"},{"id":"c10","q":5,"r":0,"category":"circle"},{"id":"c11","q":5,"r":1,"category":"triangle"}],"walls":[["c8","c9"],["c6","c8"]]},{"id":"05","mode":"hex-alternation","geometry":"hex","difficulty":"进阶","title":"区域绕行同时满足颜色和墙","caption":"","cells":[{"id":"c0","q":0,"r":0,"category":"triangle"},{"id":"c1","q":0,"r":1,"clue":1,"category":"circle"},{"id":"c2","q":0,"r":2,"category":"triangle"},{"id":"c3","q":1,"r":0,"category":"circle"},{"id":"c4","q":1,"r":1,"category":"circle"},{"id":"c5","q":1,"r":2,"category":"circle"},{"id":"c6","q":2,"r":0,"category":"triangle"},{"id":"c7","q":2,"r":1,"category":"triangle"},{"id":"c8","q":2,"r":2,"category":"triangle","clue":12},{"id":"c9","q":3,"r":0,"category":"circle"},{"id":"c10","q":3,"r":1,"category":"circle"},{"id":"c11","q":3,"r":2,"category":"circle"},{"id":"c12","q":4,"r":0,"category":"triangle"},{"id":"c13","q":4,"r":1,"category":"triangle"}],"walls":[["c8","c10"],["c6","c9"]]},{"id":"06","mode":"hex-alternation","geometry":"hex","difficulty":"进阶","title":"两处区域出口互相牵制","caption":"","cells":[{"id":"c0","q":0,"r":0,"category":"circle"},{"id":"c1","q":0,"r":1,"category":"triangle"},{"id":"c2","q":0,"r":2,"category":"triangle"},{"id":"c3","q":1,"r":0,"category":"triangle"},{"id":"c4","q":1,"r":1,"clue":1,"category":"circle"},{"id":"c5","q":1,"r":2,"category":"circle"},{"id":"c6","q":2,"r":0,"category":"circle"},{"id":"c7","q":2,"r":1,"category":"triangle"},{"id":"c8","q":2,"r":2,"category":"triangle"},{"id":"c9","q":3,"r":0,"category":"triangle"},{"id":"c10","q":3,"r":1,"category":"circle"},{"id":"c11","q":3,"r":2,"category":"circle"},{"id":"c12","q":4,"r":0,"category":"circle"},{"id":"c13","q":4,"r":1,"category":"circle"},{"id":"c14","q":4,"r":2,"category":"triangle"},{"id":"c15","q":5,"r":0,"category":"triangle"}],"walls":[["c9","c10"],["c2","c4"]]},{"id":"07","mode":"hex-alternation","geometry":"hex","difficulty":"难","title":"三片区域、颜色与多墙联动","caption":"","cells":[{"id":"c0","q":0,"r":0,"category":"triangle"},{"id":"c1","q":0,"r":1,"category":"circle"},{"id":"c2","q":0,"r":2,"category":"triangle"},{"id":"c3","q":1,"r":0,"category":"triangle"},{"id":"c4","q":1,"r":1,"category":"circle"},{"id":"c5","q":1,"r":2,"category":"circle"},{"id":"c6","q":2,"r":0,"category":"circle"},{"id":"c7","q":2,"r":1,"category":"triangle"},{"id":"c8","q":2,"r":2,"category":"triangle"},{"id":"c9","q":3,"r":0,"category":"triangle"},{"id":"c10","q":3,"r":1,"category":"circle"},{"id":"c11","q":3,"r":2,"category":"circle"},{"id":"c12","q":4,"r":0,"category":"circle"},{"id":"c13","q":4,"r":1,"category":"triangle"},{"id":"c14","q":4,"r":2,"category":"triangle"},{"id":"c15","q":5,"r":0,"clue":1,"category":"circle"},{"id":"c16","q":5,"r":1,"category":"triangle"},{"id":"c17","q":5,"r":2,"category":"circle"}],"walls":[["c11","c13"],["c7","c10"],["c1","c2"]]},{"id":"08","mode":"hex-alternation","geometry":"hex","difficulty":"难","title":"起点未知:用颜色与墙反推全路","caption":"","cells":[{"id":"c0","q":0,"r":0,"category":"triangle"},{"id":"c1","q":0,"r":1,"category":"circle"},{"id":"c2","q":0,"r":2,"category":"circle"},{"id":"c3","q":1,"r":0,"category":"circle"},{"id":"c4","q":1,"r":1,"category":"triangle"},{"id":"c5","q":1,"r":2,"category":"triangle","clue":12},{"id":"c6","q":2,"r":0,"category":"triangle"},{"id":"c7","q":2,"r":1,"category":"circle"},{"id":"c8","q":2,"r":2,"category":"circle"},{"id":"c9","q":3,"r":0,"category":"circle"},{"id":"c10","q":3,"r":1,"category":"triangle"},{"id":"c11","q":3,"r":2,"category":"triangle"},{"id":"c12","q":4,"r":0,"category":"triangle"},{"id":"c13","q":4,"r":1,"category":"circle"},{"id":"c14","q":4,"r":2,"category":"circle"},{"id":"c15","q":5,"r":0,"category":"circle"},{"id":"c16","q":5,"r":1,"category":"circle","clue":3},{"id":"c17","q":5,"r":2,"category":"triangle"},{"id":"c18","q":6,"r":0,"category":"triangle"}],"walls":[["c4","c7"],["c5","c7"],["c12","c13"]]}]};
// 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.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;
}
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));
const side=(x)=>x.x<b.x?'l':x.x>b.x?'r':x.y<b.y?'u':'d';
return [side(a),side(c)].sort().join('');
}
function doorOpen(door,visitedCount){return Boolean(door.initialOpen)!==Boolean(visitedCount%2);}
const directionNames={lr:'左右直行',du:'上下直行',ru:'上与右拐角',dr:'右与下拐角',dl:'下与左拐角',lu:'左与上拐角'};
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.length))return true;
return false;
}
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==='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,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==='direction'?'进出方向不符':r.kind==='wall'?'永久墙不能穿过':r.kind==='door'?'这一步门是关闭的':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(edgeBlocked(level,path,last,id))return false;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)&&!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;
}
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('盘面不连通');
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==='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.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(directionNames[c.direction]+' · 可反向',[c.id]);
if(level.mode.includes('alternation'))add('🔵 蓝 → 🟠 橙 → 🔵 蓝 · 每步换颜色',[]);
for(let i=0;i<(level.walls||[]).length;i++)add('黑黄墙 · 永久封闭',level.walls[i]);
for(const d of level.doors||[])add(doorOpen(d,path.length)?'门开 · 再落一格就关':'门关 · 再落一格就开',d.edge);
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=c.category==='circle'?'#64acf3':c.category==='triangle'?'#ffae55':badIds.has(c.id)?'#ffe0dc':visited?'#ddd8ff':'#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(const edge of level.walls||[])drawBarrier(edge,'wall');
for(const d of level.doors||[])drawBarrier(d.edge,doorOpen(d,path.length)?'open':'closed');
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='';if(c.category)symbol=c.category==='circle'?'○':'△';const gateIndex=(level.gates||[]).findIndex(g=>g.switchCell===c.id);if(gateIndex!==-1)symbol=`开${gateIndex+1}`;
if(c.direction)drawChannel(c.direction,x,y-(step?font*.72:0),Math.min(unit*.28,step?font*.4:20));
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(level.doors?.length)renderConstraints();
if(bad.length)setStatus(bad[0].message+',可以点已走格退回。');
$('undo').disabled=!path.length;$('next').hidden=!cleared||index===7;
}
function drawChannel(kind,x,y,r){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=9;ctx.strokeStyle='#c1daf7';ctx.stroke();ctx.lineWidth=4;ctx.strokeStyle='#15497b';ctx.stroke();}
function drawBarrier(edge,kind){const left=polygons.filter(p=>p.id===edge[0]),right=polygons.filter(p=>p.id===edge[1]);let common=null;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)common=[p,q];}if(!common)return;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=kind==='open'?'#23895f':kind==='wall'?'#272638':'#aa4d25';ctx.beginPath();if(kind==='open'){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 11px system-ui';ctx.textAlign='center';ctx.fillStyle=kind==='open'?'#12623f':'#993b1e';ctx.fillText(kind==='open'?'开':'关',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=>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?(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(!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)),doors:(level.doors||[]).map(d=>doorOpen(d,path.length))});
</script></html>