174 lines
30 KiB
HTML
174 lines
30 KiB
HTML
<!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> |