77 lines
8.0 KiB
JavaScript
77 lines
8.0 KiB
JavaScript
import { createState, act, solve } from './model.js';
|
||
import { position, hexPoints, trackPoints, PORTS } from '../../src/path-math.js';
|
||
const $ = id => document.getElementById(id), canvas = $('board'), ctx = canvas.getContext('2d');
|
||
const colors = ['#d66c4d', '#477fc0'];
|
||
const copy = {
|
||
color: ['分色归站', '橙色棋子进入橙色出口,蓝色棋子进入蓝色出口。外围短线就是终点;终点固定,旋转底盘改变去向。'],
|
||
switch: ['机关换轨', '带 A 标记的棋子离场,会切换 A 格的轨道连接。先观察它影响的路线,再安排出发顺序。'],
|
||
relay: ['接力停靠', '棋子经过中央 P 点时会停下,箭头沿后续轨道转向。再次点击继续出发,也可以先转盘或让其他棋子通过。']
|
||
};
|
||
let levels, mode = 'color', index = 0, state, history = [], animation = null, frame, size, scale, hint;
|
||
function poly(points, stroke, width = 1, fill) {
|
||
ctx.beginPath(); points.forEach((p,i) => i ? ctx.lineTo(p.x,p.y) : ctx.moveTo(p.x,p.y));
|
||
if(fill){ctx.closePath();ctx.fillStyle=fill;ctx.fill();}
|
||
if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=width;ctx.stroke();}
|
||
}
|
||
function piece(tile, point, alpha = 1) {
|
||
const p = point || position(tile.q,tile.r);ctx.save();ctx.globalAlpha=alpha;
|
||
poly(hexPoints(p.x,p.y,.88),hint===tile.id?'#fff':'#ffffffcc',hint===tile.id?.09:.035,colors[tile.color]);
|
||
ctx.save();ctx.translate(p.x,p.y);ctx.rotate(tile.dir*Math.PI/3);
|
||
poly([{x:0,y:.3},{x:0,y:-.3}], '#fff', .09);
|
||
poly([{x:-.17,y:-.08},{x:0,y:-.3},{x:.17,y:-.08}], '#fff', .09);ctx.restore();
|
||
ctx.fillStyle='#fff';ctx.font='bold .24px system-ui';ctx.textAlign='center';
|
||
ctx.fillText(tile.trigger || tile.id.slice(1)*1+1,p.x+.38,p.y+.37);ctx.restore();
|
||
}
|
||
function draw(now = performance.now()) {
|
||
if(!state)return;
|
||
ctx.setTransform(canvas.width/size,0,0,canvas.width/size,0,0);ctx.clearRect(0,0,size,size);
|
||
ctx.translate(size/2,size/2);ctx.scale(scale,scale);ctx.lineCap='round';ctx.lineJoin='round';
|
||
for(const c of state.cells){const p=position(c.q,c.r);poly(hexPoints(p.x,p.y,.97),'#d3dfe3',.025,'#fafbf7');}
|
||
for(const c of state.cells){const p=position(c.q,c.r);for(let a=0;a<6;a++)if(a<c.tracks[a])poly(trackPoints(p.x,p.y,1,a,c.tracks[a]),c.switchId?'#a97c39':'#b2c4ce',c.switchId?.085:.055);}
|
||
if(mode==='color')for(const g of state.gates){const p=position(g.q,g.r),v=PORTS[g.dir];poly([{x:p.x+v.x*1.14,y:p.y+v.y*1.14},{x:p.x+v.x*1.46,y:p.y+v.y*1.46}],colors[g.color],.16);}
|
||
for(const c of state.cells.filter(c=>c.switchId)){const p=position(c.q,c.r);ctx.fillStyle='#7d5824';ctx.font='bold .28px system-ui';ctx.textAlign='center';ctx.fillText('A',p.x,p.y+.1);}
|
||
if(mode==='relay')for(const c of state.stops){const p=position(c.q,c.r);ctx.beginPath();ctx.arc(p.x,p.y,.4,0,Math.PI*2);ctx.fillStyle='#e4edda';ctx.fill();ctx.strokeStyle='#65874d';ctx.lineWidth=.055;ctx.stroke();ctx.fillStyle='#507238';ctx.font='bold .35px system-ui';ctx.textAlign='center';ctx.fillText('P',p.x,p.y+.12);}
|
||
for(const t of state.tiles)if(animation?.tile.id!==t.id)piece(t);
|
||
if(animation){
|
||
let t=Math.min(1,(now-animation.start)/animation.duration);
|
||
const success=['exit','stop'].includes(animation.status);
|
||
const fraction=success?t:Math.sin(t*Math.PI);
|
||
const lengths=animation.lengths,total=lengths.at(-1),distance=fraction*total;
|
||
let i=1;while(i<lengths.length-1&&lengths[i]<distance)i++;
|
||
const a=animation.points[i-1],b=animation.points[i],u=(distance-lengths[i-1])/(lengths[i]-lengths[i-1]||1);
|
||
poly(animation.points,'#7399ad88',.07);
|
||
const moving={...animation.tile,dir:Math.atan2(b.x-a.x,-(b.y-a.y))*3/Math.PI};
|
||
piece(moving,{x:a.x+(b.x-a.x)*u,y:a.y+(b.y-a.y)*u},animation.status==='exit'?Math.min(1,(1-t)*5):1);
|
||
if(t>=1){animation=null;sync();}else frame=requestAnimationFrame(draw);
|
||
}
|
||
}
|
||
function resize(){size=canvas.getBoundingClientRect().width;scale=size/9.3;canvas.width=canvas.height=Math.round(size*Math.min(devicePixelRatio||1,2));draw();}
|
||
function sync(){
|
||
$('count').textContent=`剩余 ${state.tiles.length} 枚`;
|
||
const busy=!!animation;
|
||
for(const id of ['left','right','hint'])$(id).disabled=busy||!state.tiles.length;
|
||
$('undo').disabled=busy||!history.length;
|
||
$('next').hidden=busy||!!state.tiles.length||index===4;
|
||
$('pieces').replaceChildren(...state.tiles.map(t=>{const b=document.createElement('button');b.textContent=`${t.id.slice(1)*1+1} 号${t.trigger?' · 机关 A':''}`;b.disabled=busy;b.onclick=()=>perform({type:'tap',id:t.id});return b;}));
|
||
if(!state.tiles.length&&!busy)$('message').textContent=index===4?'这个模式的 5 关已完成,可以切换另一种玩法。':'全部到达!可以进入下一关。';
|
||
}
|
||
function load(){cancelAnimationFrame(frame);animation=null;history=[];hint=null;state=createState(levels.filter(l=>l.mode===mode)[index]);$('title').textContent=copy[mode][0];$('rule').textContent=copy[mode][1];$('message').textContent='点击箭头出发,遇阻可以免费试路或旋转。';$('level').value=index;sync();resize();}
|
||
function selectMode(next){mode=next;index=0;for(const b of document.querySelectorAll('[data-mode]'))b.setAttribute('aria-pressed',b.dataset.mode===mode);$('level').replaceChildren(...levels.filter(l=>l.mode===mode).map((l,i)=>new Option(`${i+1} · ${l.name}`,i)));load();}
|
||
function perform(action){
|
||
if(animation||!state.tiles.length)return;hint=null;
|
||
const tile=state.tiles.find(t=>t.id===action.id),result=act(state,action);
|
||
if(result.state!==state){history.push(state);state=result.state;}
|
||
const messages={rotate:'底盘已旋转,棋子与外围标记保持原位。',exit:tile?.trigger?'A 机关触发,金色轨道已经换路。':'已成功离场。',stop:'已停靠 P 点。可以转盘换路,再点它继续出发。',wrong:'出口颜色不匹配,试试旋转底盘。',blocked:'前方有棋子挡路,先让路或转盘再试。',loop:'这条路线绕回了原处,试试旋转底盘。'};
|
||
$('message').textContent=messages[result.status];
|
||
if(result.points?.length>1){const lengths=[0];for(let i=1;i<result.points.length;i++)lengths.push(lengths.at(-1)+Math.hypot(result.points[i].x-result.points[i-1].x,result.points[i].y-result.points[i-1].y));animation={...result,tile,points:result.points,lengths,start:performance.now(),duration:matchMedia('(prefers-reduced-motion: reduce)').matches?100:Math.min(1600,450+lengths.at(-1)*50)};}
|
||
sync();draw();
|
||
}
|
||
canvas.addEventListener('pointerup',e=>{const r=canvas.getBoundingClientRect(),x=(e.clientX-r.left-size/2)/scale,y=(e.clientY-r.top-size/2)/scale;const tile=state?.tiles.find(t=>{const p=position(t.q,t.r);return Math.hypot(x-p.x,y-p.y)<.85;});if(tile)perform({type:'tap',id:tile.id});});
|
||
$('left').onclick=()=>perform({type:'rotate',direction:-1});$('right').onclick=()=>perform({type:'rotate',direction:1});
|
||
$('reset').onclick=load;$('undo').onclick=()=>{if(!animation&&history.length){state=history.pop();hint=null;$('message').textContent='已恢复上一步,包括轨道机关与停靠位置。';sync();draw();}};
|
||
$('hint').onclick=()=>{const solution=solve(state);const action=solution.actions[0];if(!action){$('message').textContent=solution.exhausted?'搜索尚未完成,可以撤销再试。':'当前状态没有通关路径,请撤销或重来。';return;}hint=action.id;$('message').textContent=action.type==='rotate'?`向${action.direction===1?'右':'左'}旋转一次。`:`点击 ${action.id.slice(1)*1+1} 号棋子${state.tiles.find(t=>t.id===action.id)?.trigger?'(A 机关)':''}。`;draw();};
|
||
$('level').onchange=()=>{index=Number($('level').value);load();};$('next').onclick=()=>{index++;load();};
|
||
for(const b of document.querySelectorAll('[data-mode]'))b.onclick=()=>selectMode(b.dataset.mode);
|
||
window.addEventListener('resize',resize);
|
||
try{const response=await fetch('./levels.json');if(!response.ok)throw new Error(response.status);levels=await response.json();selectMode(new URLSearchParams(location.search).get('mode') in copy?new URLSearchParams(location.search).get('mode'):'color');}catch(error){$('message').textContent=`关卡加载失败,请通过 npm run dev 启动后刷新。${error.message}`;}
|