174 lines
14 KiB
JavaScript
174 lines
14 KiB
JavaScript
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(a<cell.tracks[a]) {
|
||
const points=trackPoints(x,y,view.size,a,cell.tracks[a]);ctx.beginPath();points.forEach((p,i)=>i?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<hintUntil){polygon(ctx,x,y,42+Math.sin(clock/100)*2);ctx.fillStyle='#fff8';ctx.fill();}
|
||
tile(ctx,x,y,view.size*.88,t.dir,colors[t.dir]);
|
||
}
|
||
if(motion) {
|
||
const t=Math.min(1,(clock-motion.start)/motion.duration);
|
||
const progress=motion.blocked?Math.sin(t*Math.PI)*.92:t*t;
|
||
const p=pointAlong(motion.points,progress);
|
||
const flip=motion.blocked||reduced?1:Math.cos(t*Math.PI*5);
|
||
// Exactly one polygon slab is drawn for the moving piece, including its back.
|
||
tile(ctx,p.x,p.y,view.size*.88,(p.angle+Math.PI/2)/(Math.PI/3),colors[motion.tile.dir],Math.sign(flip||1)*Math.max(.09,Math.abs(flip)));
|
||
if(t>=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;i<count;i++) {
|
||
const left=i%2===0;
|
||
particles.push({x:left?20:396,y:945,vx:(left?1:-1)*(40+Math.random()*150),vy:-250-Math.random()*400,age:0,life:1.5+Math.random()*1.5,color:['#ff4543','#ffe000','#10d9eb','#63df38','#3d93ff'][i%5],angle:Math.random()*6,spin:Math.random()*12-6,size:3+Math.random()*4});
|
||
}
|
||
}
|
||
function effects(dt) {
|
||
fx.setTransform(2,0,0,2,0,0);fx.clearRect(0,0,416,960);
|
||
particles=particles.filter(p=>p.age<p.life);
|
||
for(const p of particles){p.age+=dt;p.vy+=370*dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.angle+=p.spin*dt;fx.save();fx.globalAlpha=Math.min(1,(p.life-p.age)*2);fx.translate(p.x,p.y);fx.rotate(p.angle);fx.fillStyle=p.color;fx.fillRect(-p.size/2,-p.size,p.size*Math.cos(p.angle),p.size*1.8);fx.restore();}
|
||
flyingCoins=flyingCoins.filter(c=>clock-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='<p>轻触旋转按钮,让轨道转出新路</p><svg viewBox="0 0 240 175" aria-label="轨道旋转示意"><g fill="#acb2bd" stroke="#919aa8" stroke-width="2"><path d="M45 42h36l18 31-18 31H45L27 73Z"/><path d="M99 11h36l18 31-18 31H99L81 42Z"/><path d="M99 73h36l18 31-18 31H99L81 104Z"/><path d="M153 42h36l18 31-18 31h-36l-18-31Z"/></g><circle cx="117" cy="73" r="24" fill="#56595b"/><text x="117" y="89" text-anchor="middle" font-size="47" fill="#ff5425">↻</text><text x="63" y="82" text-anchor="middle" font-size="27" fill="white">↖</text><text x="171" y="82" text-anchor="middle" font-size="27" fill="white">↘</text></svg>';
|
||
$('modal-close').textContent='太棒了!';
|
||
} else {
|
||
$('modal-title').textContent='休息一下';$('modal-subtitle').textContent='设置';
|
||
$('modal-content').innerHTML=`<label><input type="checkbox" id="sound" ${muted?'':'checked'}> 开启合成音效</label><p>点击棋子试路,受阻会自动回弹。<br>提示寻找出口,撤销恢复上一步。</p>`;
|
||
$('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);
|