Files
HexTrails/labs/variants/app.js

77 lines
8.0 KiB
JavaScript
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.
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}`;}