import { Game, available } from '../../src/core.js'; import { position, hexPoints, trackPoints } from '../../src/path-math.js'; import { routePoints, pointAlong, hitCell } from '../../src/geometry.js'; import { makeLabLevel } from './fixture.js'; const $ = id => document.getElementById(id); const canvas = $('board'), ctx = canvas.getContext('2d'), fx = $('fx').getContext('2d'); const colors = ['#388ef2','#62d62b','#ffbd00','#b741f3','#ff9018','#ed3737']; const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; const view = { size: 44, ox: 208, oy: 448 }; let game = new Game(makeLabLevel()), scene = 'play', speed = 1, auto = false; let motion = null, particles = [], flyingCoins = [], clock = 0, last = 0; let nextAuto = 0, winAt = 0, coins = 510, level = 2, collecting = false, hintId = null, hintUntil = 0; let rotation = null, muted = true, audioContext; const setStatus = text => { $('status').textContent = text; }; function polygon(context, x,y,r) { const points = hexPoints(x,y,r); context.beginPath(); points.forEach((p,i) => i ? context.lineTo(p.x,p.y) : context.moveTo(p.x,p.y)); context.closePath(); } function shade(hex, amount) { const n = parseInt(hex.slice(1),16); return `rgb(${[16,8,0].map(s => Math.max(0,Math.min(255,((n >> s)&255)+amount))).join(',')})`; } // One thin slab: 4px sidewall at the 416px reference resolution, no copies. function tile(context, x,y,r,dir,color,flip=1,alpha=1) { context.save(); context.globalAlpha = alpha; context.translate(x,y); context.scale(1,flip); context.shadowColor = '#26304435'; context.shadowBlur = 4; context.shadowOffsetY = 3; polygon(context,0,4,r); context.fillStyle = shade(color,-42); context.fill(); context.shadowColor = 'transparent'; polygon(context,0,0,r); const gradient = context.createLinearGradient(0,-r,0,r); gradient.addColorStop(0,shade(color,23)); gradient.addColorStop(1,color); context.fillStyle = gradient; context.fill(); context.strokeStyle = shade(color,-20); context.lineWidth = 1.5; context.stroke(); polygon(context,0,-1,r-3); context.strokeStyle = '#ffffff32'; context.lineWidth = 1.5; context.stroke(); if (flip > .23) { context.rotate(dir*Math.PI/3); context.strokeStyle = '#fff'; context.lineWidth = 5.5; context.lineCap = 'round'; context.lineJoin = 'round'; context.shadowColor = '#00000025'; context.shadowBlur = 1; context.shadowOffsetY = 1; context.beginPath(); context.moveTo(0,13); context.lineTo(0,-12); context.moveTo(-9,-3); context.lineTo(0,-12); context.lineTo(9,-3); context.stroke(); } context.restore(); } function board() { ctx.setTransform(2,0,0,2,0,0); ctx.clearRect(0,0,416,960); ctx.save(); if (rotation) { const t = Math.min(1,(clock-rotation.start)/420); ctx.translate(view.ox,view.oy); ctx.rotate((-Math.PI/3)*(1-(1-(1-t)**3))); ctx.translate(-view.ox,-view.oy); if(t===1)rotation=null; } for (const cell of game.level.cells) { const p = position(cell.q,cell.r,view.size), x = p.x+view.ox, y = p.y+view.oy; polygon(ctx,x,y+2,view.size-1);ctx.fillStyle='#9299a5';ctx.fill(); polygon(ctx,x,y,view.size-1);ctx.fillStyle='#adb2bf';ctx.fill();ctx.strokeStyle='#9ba2b0';ctx.lineWidth=1.2;ctx.stroke(); if ($('tracks').checked) for(let a=0;a<6;a++) if(ai?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.strokeStyle='#828b9e';ctx.lineWidth=2;ctx.stroke(); } } ctx.restore(); for (const t of game.tiles) { if(motion?.tile.id===t.id)continue; const p=position(t.q,t.r,view.size),x=p.x+view.ox,y=p.y+view.oy; if(t.id===hintId&&clock=1){const blocked=motion.blocked;motion=null;sync();if(!blocked){burst(26);if(game.won)winAt=clock+850;}} } } function sync() { $('moves').textContent=game.moves;$('level-number').textContent=level; $('undo').disabled=!game.history.length||!!motion;$('rotate').disabled=scene!=='play'||!!motion||!!rotation||game.won; $('tile-controls').replaceChildren(...game.tiles.map(t=>{ const button=document.createElement('button'),p=position(t.q,t.r,view.size); button.className='tile-key';button.style.left=`${p.x+view.ox-25}px`;button.style.top=`${p.y+view.oy-25}px`; button.setAttribute('aria-label',`棋子 ${t.id},方向 ${['上','右上','右下','下','左下','左上'][t.dir]}`); button.disabled=!!motion;button.onclick=()=>tap(t.id);return button; })); } function tone(success) { if(muted)return; audioContext ||= new AudioContext(); audioContext.resume(); const osc=audioContext.createOscillator(),gain=audioContext.createGain();osc.connect(gain);gain.connect(audioContext.destination); osc.frequency.setValueAtTime(success?650:180,audioContext.currentTime);osc.frequency.exponentialRampToValueAtTime(success?1100:100,audioContext.currentTime+.12); gain.gain.setValueAtTime(.045,audioContext.currentTime);gain.gain.exponentialRampToValueAtTime(.001,audioContext.currentTime+.15);osc.start();osc.stop(audioContext.currentTime+.16); } function tap(id) { if(scene!=='play'||motion||rotation||$('modal').open)return; const result=game.tap(id);if(!result.tile)return; const blocked=result.status!=='exit'; const points=routePoints(result,view,!blocked); if (!blocked) { const end=points.at(-1),prev=points.at(-2),dx=end.x-prev.x,dy=end.y-prev.y; const distance=Math.min(...[dx>0?(476-end.x)/dx:dx<0?(-60-end.x)/dx:Infinity,dy>0?(1020-end.y)/dy:dy<0?(-60-end.y)/dy:Infinity].filter(t=>t>0)); if(Number.isFinite(distance))points.push({x:end.x+dx*distance,y:end.y+dy*distance}); } motion={tile:result.tile,points,start:clock,duration:reduced?120:blocked?410:760,blocked}; hintId=null;tone(!blocked);setStatus(blocked?'前方有棋子:轻碰后回弹,操作数不减少。':'单片沿路径离场,露出灰色底板。');sync(); } canvas.addEventListener('pointerdown',event=>{ const bounds=canvas.getBoundingClientRect(),x=(event.clientX-bounds.left)*416/bounds.width,y=(event.clientY-bounds.top)*960/bounds.height; const cell=hitCell(game.tiles,view,x,y);if(cell)tap(cell.id); }); function burst(count=100) { if(reduced)count=Math.min(count,12); for(let i=0;ip.ageclock-c.start<1000); for(const c of flyingCoins){const t=Math.max(0,Math.min(1,(clock-c.start)/800)),u=1-t,x=u*u*c.x+2*u*t*c.cx+t*t*177,y=u*u*c.y+2*u*t*300+t*t*88;fx.save();fx.translate(x,y);fx.scale(Math.max(.15,Math.abs(Math.cos(t*12))),1);fx.beginPath();fx.ellipse(0,0,13,13,0,0,Math.PI*2);fx.fillStyle='#ffce16';fx.fill();fx.lineWidth=3;fx.strokeStyle='#ffe95c';fx.stroke();fx.restore();} if(collecting&&!flyingCoins.length){collecting=false;coins+=10;updateCoins();level++;$('collect').disabled=false;showScene('map');} } function updateCoins(){document.querySelectorAll('.coin-balance b').forEach(el=>el.textContent=coins);} function reset(){game=new Game(makeLabLevel());motion=null;rotation=null;auto=false;winAt=0;hintId=null;$('demo').textContent='▶ 自动演示';sync();setStatus('点击棋子,沿箭头方向离场。');} function showScene(next) { scene=next;auto=false;winAt=0;motion=null;rotation=null;particles=[];flyingCoins=[];collecting=false;$('collect').disabled=false; $('demo').textContent='▶ 自动演示'; for(const s of ['map','play','win'])$(`${s}-scene`).hidden=s!==next; document.querySelectorAll('[data-scene]').forEach(b=>b.classList.toggle('active',b.dataset.scene===next)); $('scene-label').textContent={map:'01 / LEVEL MAP',play:'02 / PLAY',win:'03 / REWARD'}[next]; $('reference-image').src=`reference/${next}.jpg`;$('reference-time').textContent={map:'00:00',play:'00:10',win:'00:42'}[next]; if(next==='win'){$('completed-level').textContent=level;burst(150);setStatus('胜利光芒与彩纸;点击「收集」查看金币飞入。');} if(next==='map'){buildMap();setStatus('选择关卡节点,点击「开始游戏」进入实验棋盘。');} if(next==='play'){if(game.won)reset();setStatus('点击棋子,沿箭头方向离场。');} sync(); } function buildMap() { $('map-nodes').replaceChildren(...Array.from({length:5},(_,i)=>{ const n=level+4-i,b=document.createElement('button');b.className=`map-node${n===level?' current':''}`;b.style.top=`${98+i*148}px`;b.textContent=n;b.setAttribute('aria-label',`选择关卡 ${n}`);b.onclick=()=>{level=n;buildMap();setStatus(`已选择关卡 ${n},实验室使用同一可解棋盘展示。`);};return b; })); } function openModal(kind) { auto=false;$('demo').textContent='▶ 自动演示'; if(kind==='unlock'){ $('modal-title').textContent='新功能!';$('modal-subtitle').textContent='旋转器'; $('modal-content').innerHTML='

轻触旋转按钮,让轨道转出新路

↻↖↘'; $('modal-close').textContent='太棒了!'; } else { $('modal-title').textContent='休息一下';$('modal-subtitle').textContent='设置'; $('modal-content').innerHTML=`

点击棋子试路,受阻会自动回弹。
提示寻找出口,撤销恢复上一步。

`; $('sound').onchange=e=>muted=!e.target.checked;$('modal-close').textContent='继续游戏'; } $('modal').showModal(); } $('modal-close').onclick=()=>$('modal').close(); document.querySelectorAll('.settings').forEach(b=>b.onclick=()=>openModal('settings')); document.querySelectorAll('[data-scene]').forEach(b=>b.onclick=()=>showScene(b.dataset.scene)); $('unlock').onclick=()=>openModal('unlock');$('confetti').onclick=()=>burst(130);$('reset').onclick=()=>{reset();showScene('play');}; $('demo').onclick=()=>{if(scene!=='play')showScene('play');if(game.won)reset();auto=!auto;nextAuto=clock;$('demo').textContent=auto?'Ⅱ 停止演示':'▶ 自动演示';}; $('speed').oninput=e=>{speed=Number(e.target.value);$('speed-value').textContent=`${speed.toFixed(2).replace(/0$/,'')}×`;}; $('reference-toggle').onchange=e=>$('reference').hidden=!e.target.checked; $('start').onclick=()=>{reset();showScene('play');}; $('hint').onclick=()=>{const free=available(game.level,game.tiles);if(free.length){hintId=free[0].id;hintUntil=clock+2400;setStatus('白色闪光的棋子可以离场。');}else setStatus('当前没有出口,试试旋转底盘。');}; $('undo').onclick=()=>{if(motion)return;auto=false;$('demo').textContent='▶ 自动演示';winAt=0;if(game.undo()){sync();setStatus('已恢复上一步。');}}; $('rotate').onclick=()=>{if(motion||rotation)return;const result=game.rotate();if(result.status==='rotated'){rotation={start:clock};sync();setStatus('底盘旋转 60°;棋子的位置与朝向保持不变。');}}; $('collect').onclick=()=>{if(collecting)return;collecting=true;$('collect').disabled=true;for(let i=0;i<10;i++)flyingCoins.push({x:180+Math.random()*60,y:490+Math.random()*30,cx:80+Math.random()*220,start:clock+i*60});}; for(let i=0;i<13;i++){const c=document.createElement('i');c.className='coin';c.style.left=`${[12,50,91,42,76][i%5]}px`;c.style.top=`${73-Math.floor(i/3)*12}px`;c.style.zIndex=i;$('treasure').append(c);} function frame(now) { const dt=Math.min((now-last)/1000||0,.04)*speed;last=now; if(!$('modal').open){clock+=dt*1000;if(scene==='play')board();effects(dt); if(auto&&!motion&&!rotation&&clock>=nextAuto&&scene==='play'&&!game.won){const free=available(game.level,game.tiles);if(free.length){tap(free[0].id);nextAuto=clock+950;}else{auto=false;$('demo').textContent='▶ 自动演示';setStatus('当前没有直接出口,可旋转或重置棋盘。');}} if(winAt&&clock>=winAt)showScene('win'); } requestAnimationFrame(frame); } new ResizeObserver(entries => { $('device').style.transform=`scale(${entries[0].contentRect.width/416})`; }).observe(document.querySelector('.device-wrap')); sync();buildMap();requestAnimationFrame(frame);