Files
HexTrails/labs/hex-video/lab.js
T

174 lines
14 KiB
JavaScript
Raw 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 { 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);