纠正仓库内容为衍生项目,标明 campaign-200-r2 最新版
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import {graph,units,solve,violations,canAppend,hint} from './rules.mjs';
|
||||
// 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))});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user