feat: add hex trail puzzle, level editor, and visual design guide

This commit is contained in:
cabinw
2026-09-08 21:45:44 +08:00
commit ef2724bb2e
63 changed files with 15036 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
import { icon, installUI } from './ui.js';
import { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js';
import { LEVELS } from './levels.js';
import { layout, hitCell } from './geometry.js';
import { TILE_ATLAS } from './sprites.js';
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
const $=id=>document.getElementById(id);
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
const STORE='hex-trails-v2';
let saved={};try{saved=JSON.parse(localStorage.getItem(STORE)||'{}')||{};}catch{}
let completed=new Set(Array.isArray(saved.completed)?saved.completed.filter(i=>Number.isInteger(i)&&i>=0&&i<24):[]);
let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<24?saved.current:0;
let game=new Game(LEVELS[current]),mode='play',trial=false,draft=null,editHistory=[],tool='tile',direction=1,color=0,selected=null,path=null,hint=null,animation=null,rotationAnimation=null,shake=null,toastTimer,view,width=0,height=0,frame=0;
let helpUsed=false;
let hitSoundTimer;
document.addEventListener('pointerdown',unlockAudio,{passive:true});
document.addEventListener('click',e=>{if(e.target.closest('button'))playSound('tap');});
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
const metricLabels={scale:['棋子规模',15],turns:['弯道追踪',20],depth:['解锁层数',20],blocked:['路线阻塞',15],rotation:['必要旋转',20],precision:['步数压力',10]};
const chapters=['转盘初体验','糖果回旋','碰撞大挑战'];
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function persist(){try{localStorage.setItem(STORE,JSON.stringify({current,completed:[...completed]}));}catch{toast('浏览器未允许保存,进度仅保留在当前页面');}}
function bars(a){return Object.entries(metricLabels).map(([k,[label,max]])=>`<div class="metric-row"><span>${label}</span><span class="metric-track"><i style="width:${a.parts[k]/max*100}%"></i></span><span>${a.parts[k].toFixed(0)}/${max}</span></div>`).join('');}
function renderMap(){
$('level-list').innerHTML=chapters.map((ch,c)=>`<div class="chapter-title">0${c+1} / ${ch}</div><div class="level-grid">${LEVELS.slice(c*8,c*8+8).map((l,j)=>{const i=c*8+j;return `<button class="level-btn ${i===current?'current':''} ${completed.has(i)?'done':''}" data-level="${i}" aria-label="第 ${i+1} 关 ${l.name}${completed.has(i)?',已通关':''}" ${i===current?'aria-current="true"':''}>${String(i+1).padStart(2,'0')}</button>`;}).join('')}</div>`).join('');
$('progress-label').textContent=`${completed.size} / 24`;$('campaign-progress').style.width=`${completed.size/24*100}%`;
}
function renderStats(){
const editing=mode==='edit',level=editing?draft:game.initialLevel,a=analyze(level);
$('level-name').textContent=level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':`LEVEL ${String(current+1).padStart(2,'0')}`;
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':`第${['一','二','三'][Math.floor(current/8)]}章 / ${chapters[Math.floor(current/8)]}`;
$('moves-title').textContent=editing?'步数上限':'剩余步数';$('remaining-title').textContent=editing?'已放置':'待离场';
$('moves').textContent=editing?level.moveLimit:game.moves;$('remaining').innerHTML=`${editing?level.tiles.length:game.tiles.length}<small> / ${editing?level.cells.length:level.tiles.length}</small>`;
$('mini-caption').innerHTML=editing?'MAKE YOUR<br>OWN WAY ↗':'FOLLOW<br>THE FLOW ↗';
$('difficulty-label').textContent=`${a.label} · ${a.score}`;$('score-value').textContent=a.score;$('score-name').textContent=a.label;$('score-bars').innerHTML=bars(a);$('score-summary').textContent=`${level.tiles.length} 枚棋子 · ${a.depth} 层解锁 · 平均 ${a.meanTurns.toFixed(1)} 次转弯`;
$('undo-button').disabled=game.history.length===0||!!animation||!!rotationAnimation;
$('orientation-title').textContent=editing?'最少旋转':'底盘朝向';$('orientation').innerHTML=editing?`${a.rotations}<small>次</small>`:`${game.orientation*60}<small>°</small>`;
for(const id of ['rotate-left','rotate-right'])$(id).disabled=!!animation||!!rotationAnimation||game.won||game.moves<=0||!canRotate(game.level);
$('hint-button').disabled=!!animation||!!rotationAnimation||game.won;
$('instruction').innerHTML=`<span class="pulse-dot"></span>${editing?({tile:'点击空格放置棋子;点击已有棋子更新方向和配色',rotate:'点击棋子,将箭头顺时针旋转 60°',track:'点击格子,循环切换 15 种轨道连接', 'rotate-track':'点击底板,整体旋转轨道 60°',erase:'点击棋子,将它从关卡中移除',inspect:'点击棋子,查看完整移动路线'}[tool]):(game.won?'所有棋子都找到出口了!':game.lost?'撤销一步,重新寻找出口':available(game.level,game.tiles).length?'点击箭头离场 · 拖动边缘或按按钮转动底盘':'暂时过不去?转一下底盘,换条路线!')}`;
if(!editing&&!animation&&!rotationAnimation&&!game.won&&game.moves>0&&!available(game.level,game.tiles).length){
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
const action=solution.actions[0];
if(solution.solvable&&action?.type==='rotate'){
const right=action.direction===1;
$(right?'rotate-right':'rotate-left').classList.add('hinted');
$('instruction').textContent=`这条路绕回了起点 · 点${right?'右':'左'}转 60°,再点击棋子`;
}else if(solution.reachable){$('instruction').textContent='步数不够了 · 撤销碰撞或重来,再按提示寻找出口';}
}
if(editing){$('editor-valid').textContent=level.tiles.length===0?'○ 等待放置棋子':a.exhausted?'… 尚未确定可解性':a.solvable?'✓ 已验证可解':'! 关卡暂不可解';$('editor-valid').classList.toggle('error',!a.solvable&&!a.exhausted&&level.tiles.length>0);$('editor-score').innerHTML=`${a.score}<small>/100</small>`;$('editor-analysis-text').textContent=`${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转 · ${a.minimumMoves??'—'} 步通关${a.exhausted?' · 搜索达到上限,尚未确定':!a.reachable?' · 存在无法避开的碰撞或闭环':!a.solvable?' · 步数不足':''}。`;$('editor-bars').innerHTML=bars(a);$('trial-button').disabled=!level.tiles.length;$('editor-undo').disabled=!editHistory.length;}
}
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);view=layout((mode==='edit'?draft:game.level).cells,width,height);draw();}
function draw(now=performance.now()){
if(!view)return;
ctx.clearRect(0,0,width,height);
const state={selected:mode==='edit'?selected:null,path,hint,reduced};
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};if(progress>=1){animation=null;renderStats();finish();}}
if(rotationAnimation){const t=Math.min(1,(now-rotationAnimation.start)/rotationAnimation.duration);state.boardAngle=-rotationAnimation.direction*Math.PI/3*(1-(1-Math.pow(1-t,3)));if(t>=1){rotationAnimation=null;renderStats();finish();}}
if(shake){const t=(now-shake.start)/550;if(t>=1)shake=null;else{state.shake=shake.id;state.shakeTime=t;}}
drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
if(animation||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
}
function toast(message){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),3200);}
function resetEffects(){clearTimeout(hitSoundTimer);if($('result').open)$('result').close();clearTimeout(toastTimer);$('board-toast').classList.remove('visible');$('board-toast').textContent='';animation=null;rotationAnimation=null;for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');shake=null;path=null;hint=null;cancelAnimationFrame(frame);$('result').hidden=true;}
function startLevel(i){current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();}
function applyMode(){const edit=mode==='edit';document.body.classList.toggle('editing',edit);$('play-tab').classList.toggle('active',!edit);$('edit-tab').classList.toggle('active',edit);$('play-inspector').hidden=edit;$('editor-panel').hidden=!edit;$('game-tools').hidden=edit;$('editor-board-tools').hidden=!edit;$('return-editor').hidden=!trial;$('map-button').hidden=edit;$('rotation-controls').hidden=edit;}
function enterEditor(){
if(!draft){draft=clone(game.level);draft.id='custom';draft.name='我的第一条轨迹';editHistory=[];}
mode='edit';trial=false;resetEffects();applyMode();syncEditor();renderStats();resize();
}
function syncEditor(){$('editor-name').value=draft.name;$('editor-moves').value=draft.moveLimit;$('editor-radius').value=draft.cells.length>19?'3':'2';}
function checkpoint(){editHistory.push(clone(draft));if(editHistory.length>100)editHistory.shift();}
function changed(){resetEffects();renderStats();resize();}
function openResult(){ $('result').hidden=false;$('result').showModal();playSound(game.won?'win':'lose');}
$('result').addEventListener('cancel',e=>e.preventDefault());
function finish(){
if($('result').open)return;
if(mode==='edit'||animation||rotationAnimation)return;
if(game.won){
if(!trial){completed.add(current);persist();renderMap();}
$('result').innerHTML=`<div class="result-emblem">${icon('star')}${icon('star')}${icon('star')}</div><div class="eyebrow">A LITTLE MOMENT OF JOY</div><h3>${trial?'这条轨迹,通了!':'漂亮,全部过关!'}</h3><p>${game.level.tiles.length} 枚棋子全部离场 · 转动 ${game.rotations} 次<br>${game.mistakes===0?'零失误,漂亮的路线。':`还有 ${game.moves} 步余量,做得不错。`}${helpUsed?'<br>在提示的陪伴下完成':''}</p><button class="primary" id="next-level">${trial?'返回关卡工坊':current===23?'完成旅程 · 回到第一关':'下一段轨迹'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再走一遍</button>`;
openResult();$('next-level').onclick=()=>trial?enterEditor():startLevel((current+1)%24);$('replay-level').onclick=restart;
}else if(game.lost){
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'休息一下,再试一次':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'步数用完了。撤销一步,重新寻找出口。':'剩余棋子互相阻塞,或轨道形成了闭环。'}</p><button class="primary" id="recover">${game.history.length?'撤销上一步':trial?'返回编辑关卡':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>game.history.length?undo():trial?enterEditor():restart();$('retry').onclick=restart;
}
}
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();}
function undo(){if(animation||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已撤销一步,步数已恢复');}}
function playTile(tile){
if(animation||rotationAnimation)return;
resetEffects();const route=game.tap(tile.id);
if(route.status==='exit'){playSound('exit');animation={route,start:performance.now(),duration:reduced?100:Math.min(1700,550+route.steps.length*85)};}
else if(route.status==='blocked'||route.status==='loop'){path=route;if(route.travelPoints){animation={route,start:performance.now(),duration:reduced?500:1250};hitSoundTimer=setTimeout(()=>playSound('hit'),(reduced?500:1250)*.42);}else shake={id:tile.id,start:performance.now()};toast(route.status==='loop'?'轨道形成闭环,试着旋转底盘':route.collision?'被这枚棋子挡住了 · 先移走它,或转动底盘 · −1 步':'这条路被挡住了,换个方向试试 · −1 步');}
renderStats();draw();if(!animation)finish();
}
const matchings=[];
function enumerate(ports,map=[]){if(!ports.length){matchings.push([...map]);return;}const a=ports[0];for(let i=1;i<ports.length;i++){const b=ports[i],next=[...map];next[a]=b;next[b]=a;enumerate(ports.filter(p=>p!==a&&p!==b),next);}}
enumerate([0,1,2,3,4,5]);
function editCell(cell){
selected={q:cell.q,r:cell.r};const tile=draft.tiles.find(t=>t.q===cell.q&&t.r===cell.r);
$('selection-info').textContent=`坐标 (${cell.q}, ${cell.r}) · 连接 ${cell.tracks.map((v,i)=>i<v?`${i}↔${v}`:null).filter(Boolean).join(' / ')}`;
if(tool==='inspect'){path=tile?trace(draft,tile):null;hint=null;toast(tile?(path.status==='exit'?'路径畅通,可以离场':path.status==='loop'?'这条路线是闭环':'路线被另一枚棋子阻挡'):'请选择一枚棋子查看路线');draw();return;}
if((tool==='erase'||tool==='rotate')&&!tile){toast('这里还没有棋子');draw();return;}
checkpoint();
if(tool==='tile'){if(tile){tile.dir=direction;tile.color=color;}else draft.tiles.push({id:`custom-${Date.now()}-${draft.tiles.length}`,q:cell.q,r:cell.r,dir:direction,color});}
if(tool==='rotate')tile.dir=(tile.dir+1)%6;
if(tool==='erase')draft.tiles=draft.tiles.filter(t=>t!==tile);
if(tool==='track'){const index=matchings.findIndex(m=>m.every((p,i)=>p===cell.tracks[i]));cell.tracks=[...matchings[(index+1)%matchings.length]];}
if(tool==='rotate-track')cell.tracks=rotateTracks(cell.tracks);
$('selection-info').textContent=`坐标 (${cell.q}, ${cell.r}) · 连接 ${cell.tracks.map((v,i)=>i<v?`${i}↔${v}`:null).filter(Boolean).join(' / ')}`;
changed();
}
function rotateGame(direction) {
if(animation||rotationAnimation||mode==='edit')return;
resetEffects();const result=game.rotate(direction);
if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;}
playSound('rotate');
rotationAnimation={direction,start:performance.now(),duration:reduced?100:420};
renderStats();draw();
}
$('rotate-left').onclick=()=>rotateGame(-1);$('rotate-right').onclick=()=>rotateGame(1);
let pointerStart=null;
canvas.addEventListener('pointerdown',e=>{if(!view)return;const r=canvas.getBoundingClientRect();pointerStart={x:e.clientX-r.left,y:e.clientY-r.top};canvas.setPointerCapture(e.pointerId);});
canvas.addEventListener('pointercancel',()=>{pointerStart=null;});
canvas.addEventListener('pointerup',e=>{
if(animation||rotationAnimation||!view){pointerStart=null;return;}
const rect=canvas.getBoundingClientRect(),x=e.clientX-rect.left,y=e.clientY-rect.top;
if(pointerStart&&Math.hypot(x-pointerStart.x,y-pointerStart.y)>14){
const start=pointerStart;pointerStart=null;
if(mode!=='edit'&&Math.hypot(start.x-view.ox,start.y-view.oy)>width*.28){
let delta=Math.atan2(y-view.oy,x-view.ox)-Math.atan2(start.y-view.oy,start.x-view.ox);
if(delta>Math.PI)delta-=Math.PI*2;if(delta<-Math.PI)delta+=Math.PI*2;
if(Math.abs(delta)>.18)rotateGame(delta>0?1:-1);
}
return;
}
pointerStart=null;
const cell=hitCell((mode==='edit'?draft:game.level).cells,view,x,y);
if(!cell)return;if(mode==='edit')editCell(cell);else{const tile=game.tiles.find(t=>t.q===cell.q&&t.r===cell.r);if(tile)playTile(tile);}
});
$('level-list').onclick=e=>{const b=e.target.closest('[data-level]');if(b)startLevel(Number(b.dataset.level));};
$('play-tab').onclick=()=>{if(mode==='edit'||trial)startLevel(current);};$('edit-tab').onclick=enterEditor;$('workshop-link').onclick=enterEditor;
$('undo-button').onclick=undo;$('reset-button').onclick=restart;
$('hint-button').onclick=()=>{
if(animation||rotationAnimation)return;
resetEffects();helpUsed=true;
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
if(!solution.reachable){toast(solution.exhausted?'路线较复杂,暂未找到提示':'当前关卡无法避开所有碰撞,请撤销或重新编辑');return;}
if(!solution.solvable){toast(`至少还需要 ${solution.minimumMoves} 步,建议撤销恢复步数`);return;}
const action=solution.actions[0];if(!action)return;
if(action.type==='rotate'){$(action.direction===1?'rotate-right':'rotate-left').classList.add('hinted');toast(`下一步:${action.direction===1?'右转':'左转'}底盘 60°,新的路线就打开了`);}
else{const tile=game.tiles.find(t=>t.id===action.id);hint=tile.id;path=trace(game.level,tile,game.tiles);toast('这枚棋子沿途不会碰撞,可以离场');}
draw();
};
$('tool-palette').onclick=e=>{const b=e.target.closest('[data-tool]');if(!b)return;tool=b.dataset.tool;document.querySelectorAll('[data-tool]').forEach(x=>x.classList.toggle('active',x===b));path=null;hint=null;renderStats();draw();};
$('directions').innerHTML=['↑','↗','↘','↓','↙','↖'].map((s,i)=>`<button data-direction="${i}" class="${i===direction?'active':''}" aria-label="方向 ${s}">${icon('arrow',`style="transform:rotate(${i*60}deg)"`)}</button>`).join('');
$('directions').onclick=e=>{const b=e.target.closest('[data-direction]');if(!b)return;direction=Number(b.dataset.direction);document.querySelectorAll('[data-direction]').forEach(x=>x.classList.toggle('active',x===b));};
$('swatches').innerHTML=COLORS.map((c,i)=>`<button style="background:${c}" data-color="${i}" aria-label="${['冰蓝','糖果粉','蜜橙','葡萄紫'][i]}" class="${i===color?'active':''}"></button>`).join('');
$('swatches').onclick=e=>{const b=e.target.closest('[data-color]');if(!b)return;color=Number(b.dataset.color);document.querySelectorAll('[data-color]').forEach(x=>x.classList.toggle('active',x===b));};
$('editor-name').onchange=()=>{checkpoint();draft.name=$('editor-name').value.trim()||'未命名轨迹';syncEditor();changed();};
$('editor-moves').onchange=()=>{checkpoint();draft.moveLimit=Math.max(1,Math.min(999,Math.round(Number($('editor-moves').value)||1)));syncEditor();changed();};
$('editor-radius').onchange=()=>{checkpoint();const cells=hexagon(Number($('editor-radius').value)),old=new Map(draft.cells.map(c=>[key(c.q,c.r),c]));draft.cells=cells.map(c=>old.get(key(c.q,c.r))||c);const keys=new Set(cells.map(c=>key(c.q,c.r)));draft.tiles=draft.tiles.filter(t=>keys.has(key(t.q,t.r)));selected=null;changed();};
function rotateDraft(direction){if(!canRotate(draft)){toast('不规则底板不能整体旋转');return;}checkpoint();draft=rotateBoard(draft,direction);changed();}
$('editor-turn-left').onclick=()=>rotateDraft(-1);$('editor-turn-right').onclick=()=>rotateDraft(1);
$('editor-undo').onclick=()=>{if(!editHistory.length)return;draft=editHistory.pop();selected=null;syncEditor();changed();};
$('generate-button').onclick=()=>{checkpoint();const radius=Number($('editor-radius').value);draft=rotateBoard(generateLevel(Math.floor(Math.random()*1e8),radius,radius===2?13:27,.7),1);draft.moveLimit=draft.tiles.length+12;draft.name='偶遇的轨迹';draft.id='custom';selected=null;syncEditor();changed();toast('已生成,并通过旋转与碰撞规则验证');};
$('blank-button').onclick=()=>{checkpoint();const radius=Number($('editor-radius').value);draft={version:1,id:'custom',name:'一条新的轨迹',cells:hexagon(radius),tiles:[],moveLimit:30};selected=null;syncEditor();changed();};
$('trial-button').onclick=()=>{const errors=validateLevel(draft);if(errors.length){toast(errors[0]);return;}mode='play';trial=true;game=new Game(draft);helpUsed=false;resetEffects();applyMode();renderStats();resize();finish();window.scrollTo({top:0,behavior:reduced?'instant':'smooth'});};
$('return-editor').onclick=enterEditor;
$('save-button').onclick=()=>{try{localStorage.setItem('hex-trails-draft',JSON.stringify(draft));toast('关卡已保存到此浏览器');}catch{toast('本机存储不可用,请导出 JSON 保存');}};
$('load-button').onclick=()=>{try{const data=localStorage.getItem('hex-trails-draft');if(!data){toast('还没有本机存档');return;}loadDraft(JSON.parse(data));toast('已读取本机关卡');}catch(e){toast(`读取失败:${e.message}`);}};
function loadDraft(data){const errors=validateLevel(data);if(errors.length)throw new Error(errors.join(';'));checkpoint();draft=clone(data);selected=null;syncEditor();changed();}
$('export-button').onclick=()=>{const errors=validateLevel(draft);if(errors.length){toast(errors[0]);return;}const blob=new Blob([JSON.stringify(draft,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='hex-trails-level.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('已导出通用 JSON 关卡');};
$('import-button').onclick=()=>$('import-file').click();
$('import-file').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>200000)throw new Error('文件不能超过 200 KB');loadDraft(JSON.parse(await file.text()));toast('导入成功,已重新计算可解性与难度');}catch(error){showDialog(`<div class="eyebrow">IMPORT CHECK</div><h2>这份关卡需要调整</h2><p>${escapeHtml(error.message)}</p><p>当前编辑内容已保留。</p>`);}e.target.value='';};
function showDialog(html){$('dialog-content').innerHTML=html;$('info-dialog').showModal();}
$('info-dialog').querySelector('.dialog-close').onclick=()=>$('info-dialog').close();
$('info-dialog').onclick=e=>{if(e.target===$('info-dialog')){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}};
function showScore(){const a=analyze(mode==='edit'?draft:game.initialLevel);showDialog(`<div class="eyebrow">LEVEL CHALLENGE</div><h2>这关有多难?</h2><p><b>${a.score}/100 · ${a.label}</b>,六项结构分相加后取整。</p>${bars(a)}<p>已计入沿途碰撞;最优解需要 ${a.rotations} 次旋转、${a.minimumMoves??'—'} 步。当前初始布局有 ${a.free} 枚棋子可以安全离场。</p><ul><li>棋子规模 15 分;轨道转弯 20 分。</li><li>解锁层次 20 分;初始碰撞比例 15 分。</li><li>必要旋转 20 分:最少旋转数 ÷ 4,封顶。</li><li>步数压力 10 分:按包含旋转的最短解计算余量。</li></ul><p class="dialog-note">评分是结构估计,未经真人通关率校准。不可解与搜索未确定会单独标识,不作为高难度关卡。</p>`);}
$('score-help').onclick=showScore;$('difficulty-button').onclick=showScore;
function showHelp(){showDialog(`<button id="sound-toggle" type="button">音效:${soundEnabled()?'开':'关'}</button>`+'<div class="eyebrow">HOW TO PLAY</div><h2>转转六角,轻松过关!</h2><p><b>① 点击箭头</b><br>棋子沿轨道出发,遇到弯道自动转向。</p><p><b>② 转动底盘</b><br>按左右按钮,或沿棋盘边缘拖动,每次旋转 60°。上层棋子位置和箭头不动,底下的轨道转到新位置,路线随之改变。</p><p><b>③ 小心碰撞</b><br>会检查棋子整段运动轨迹,包括弯道擦碰。棋子是有宽度的,不能穿过旁边的棋子;碰撞会让棋子退回原位。</p><p><b>④ 全部离场</b><br>点击棋子或转动底盘各花 1 步,碰撞也扣步。撤销可以恢复棋盘、方向与步数。没有倒计时。</p><p class="dialog-note">卡住了?提示既能找到安全棋子,也会告诉你什么时候该转盘。四种颜色只负责好看,不改变规则。</p>');}
$('dialog-content').addEventListener('click',e=>{if(e.target.closest('#sound-toggle')){toggleSound();$('sound-toggle').textContent=`音效:${soundEnabled()?'开':'关'}`;}});
for(const b of document.querySelectorAll('.help-trigger'))b.onclick=showHelp;
$('map-button').onclick=()=>{showDialog(`<div class="eyebrow">CHOOSE YOUR NEXT TRAIL</div><h2>挑一关,转起来</h2><div class="level-dialog-grid">${LEVELS.map((l,i)=>`<button class="level-btn ${i===current?'current':''}" data-pick="${i}">${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).score} / 100</small></button>`).join('')}</div><p>24 个旋转谜题 · 每关都已验证可以避开碰撞。</p>`);$('dialog-content').onclick=e=>{const b=e.target.closest('[data-pick]');if(b){$('info-dialog').close();startLevel(Number(b.dataset.pick));}};};
const atlas=new Image();atlas.onload=()=>{painter.atlas=atlas;draw();};atlas.onerror=()=>toast('棋子图片加载失败,已切换基础绘制');atlas.src=TILE_ATLAS;
new ResizeObserver(resize).observe(canvas);
applyMode();renderMap();renderStats();resize();
installUI();
if(new URLSearchParams(location.search).get('editor')==='1')enterEditor();
+15
View File
@@ -0,0 +1,15 @@
let ctx;let enabled=true;try{enabled=localStorage.getItem('hex-sound')!=='off';}catch{}
export function soundEnabled(){return enabled;}
export function toggleSound(){enabled=!enabled;try{localStorage.setItem('hex-sound',enabled?'on':'off');}catch{}return enabled;}
function tone(freq,when,duration,volume=.045,type='sine'){
if(!enabled||!ctx||ctx.state!=='running')return;const o=ctx.createOscillator(),g=ctx.createGain(),t=ctx.currentTime+when;o.type=type;o.frequency.setValueAtTime(freq,t);g.gain.setValueAtTime(0,t);g.gain.linearRampToValueAtTime(volume,t+.008);g.gain.exponentialRampToValueAtTime(.001,t+duration);o.connect(g);g.connect(ctx.destination);o.start(t);o.stop(t+duration+.02);
}
export function unlockAudio(){try{ctx??=new (window.AudioContext||window.webkitAudioContext)();if(ctx.state==='suspended')ctx.resume().catch(()=>{});}catch{}}
export function playSound(name){
if(name==='win'){[523,659,784].forEach((f,i)=>tone(f,.3+i*.19,.3,.055));return;}
if(name==='hit'){tone(145,0,.12,.075,'triangle');tone(85,.025,.16,.04);return;}
if(name==='rotate'){tone(330,0,.09);tone(440,.075,.13);return;}
if(name==='exit'){tone(660,0,.1);tone(880,.065,.13);return;}
if(name==='lose'){tone(294,0,.2);tone(220,.17,.25);return;}
tone(480,0,.06,.023);
}
+17
View File
@@ -0,0 +1,17 @@
import { segmentHexHit } from './path-math.js';
// Approach, readable contact pause, recoil. Used by both browser and Cocos.
export function collisionPhase(progress, reduced=false){
const t=Math.max(0,Math.min(1,progress));
const distance=t<.42?1-Math.pow(1-t/.42,2):t<.64?1:Math.pow(1-(t-.64)/.36,2);
const impact=t>=.42&&t<.64?Math.sin((t-.42)/.22*Math.PI):0;
return {distance,impact,kick:reduced?0:Math.sin(Math.max(0,t-.42)/.22*Math.PI*2)*impact};
}
// Stop the drawn silhouette at first contact, avoiding visible overlap from collider tolerance.
export function contactPath(points,blocker,radius){
if(!blocker)return points;
for(let i=1;i<points.length;i++){
const a=points[i-1],b=points[i],t=segmentHexHit(a,b,blocker,radius*2);
if(t!==null)return [...points.slice(0,i),{x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t}];
}
return points;
}
+259
View File
@@ -0,0 +1,259 @@
import { position, PORTS, pathPoints, sweepPath } from './path-math.js';
export { position } from './path-math.js';
// Engine-independent model. Coordinates: flat-top axial hexagons, y points down.
export const DIRS = [[0, -1], [1, -1], [1, 0], [0, 1], [-1, 1], [-1, 0]];
export const key = (q, r) => `${q},${r}`;
export const clone = value => JSON.parse(JSON.stringify(value));
export const opposite = d => (d + 3) % 6;
export function hexagon(radius) {
const cells = [];
for (let q = -radius; q <= radius; q++) {
for (let r = Math.max(-radius, -q - radius); r <= Math.min(radius, -q + radius); r++) {
cells.push({ q, r, tracks: [3, 4, 5, 0, 1, 2] });
}
}
return cells;
}
export function random(seed) {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function randomTracks(rng) {
const ports = [0, 1, 2, 3, 4, 5], result = [];
while (ports.length) {
const a = ports.shift(), b = ports.splice(Math.floor(rng() * ports.length), 1)[0];
result[a] = b;
result[b] = a;
}
return result;
}
export function rotateTracks(tracks) {
const next = [];
tracks.forEach((p, i) => { next[(i + 1) % 6] = (p + 1) % 6; });
return next;
}
export function validateLevel(input) {
const errors = [];
if (!input || typeof input !== 'object') return ['关卡必须是 JSON 对象'];
if (input.rotationEnabled !== undefined && typeof input.rotationEnabled !== 'boolean') errors.push('rotationEnabled 必须是布尔值');
if (input.version !== 1) errors.push('version 必须为 1');
if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 60) errors.push('名称须为 1–60 个字符');
if (!Array.isArray(input.cells) || input.cells.length < 1 || input.cells.length > 61) return [...errors, '底板须为 1–61 格'];
if (!Array.isArray(input.tiles) || input.tiles.length > input.cells.length) return [...errors, '棋子数量不能超过底板'];
const coords = new Set();
for (const c of input.cells) {
if (!c || !Number.isInteger(c.q) || !Number.isInteger(c.r) || Math.max(Math.abs(c.q), Math.abs(c.r), Math.abs(c.q + c.r)) > 4) {
errors.push('坐标必须为半径 4 内的轴向整数坐标'); continue;
}
const k = key(c.q, c.r);
if (coords.has(k)) errors.push(`底板重复:${k}`);
coords.add(k);
if (!Array.isArray(c.tracks) || c.tracks.length !== 6 || c.tracks.some((v, i) => !Number.isInteger(v) || v < 0 || v > 5 || v === i || c.tracks[v] !== i)) errors.push(`轨道 ${k} 必须两两配对六个端口`);
}
const occupied = new Set(), ids = new Set();
for (const t of input.tiles) {
if (!t || !coords.has(key(t.q, t.r))) { errors.push('棋子必须位于底板上'); continue; }
const k = key(t.q, t.r);
if (occupied.has(k)) errors.push(`棋子重叠:${k}`);
occupied.add(k);
if (typeof t.id !== 'string' || !t.id || ids.has(t.id)) errors.push('棋子 id 须为唯一非空字符串');
ids.add(t.id);
if (!Number.isInteger(t.dir) || t.dir < 0 || t.dir > 5) errors.push('方向必须为 0–5');
if (!Number.isInteger(t.color) || t.color < 0 || t.color > 3) errors.push('颜色必须为 0–3');
}
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('步数须为 1–999');
return [...new Set(errors)];
}
// At a crossing, tracks do not connect. Entering an occupied cell blocks movement.
export function trace(level, tile, tiles = level.tiles, ignoreOccupancy = false) {
const finish = route => {
if (ignoreOccupancy) return route;
const obstacles = tiles.filter(t => t.id !== tile.id).map(t => ({ ...position(t.q,t.r), id:t.id }));
const points=pathPoints(route);
// Continue the exit ray beyond holes/irregular edges, so escape animation cannot pass through a farther piece.
if(route.status==='exit'){const last=points[points.length-1],port=PORTS[route.steps[route.steps.length-1].exit];points.push({x:last.x+port.x*24,y:last.y+port.y*24});}
const collision = sweepPath(points, obstacles);
if (!collision) return route;
return { ...route, status:'blocked', blocker:collision.blocker, collision:true, collisionPoint:collision.point, travelPoints:collision.points };
};
const board = new Map(level.cells.map(c => [key(c.q, c.r), c]));
const occupied = new Map(tiles.filter(t => t.id !== tile.id).map(t => [key(t.q, t.r), t]));
let q = tile.q, r = tile.r, exit = tile.dir;
const steps = [{ q, r, enter: null, exit }], visited = new Set([`${q},${r},${exit}`]);
const blockers = new Set();
for (let n = 0; n < level.cells.length * 6 + 1; n++) {
const [dq, dr] = DIRS[exit]; q += dq; r += dr;
const cell = board.get(key(q, r));
if (!cell) return finish({ status: 'exit', steps, blockers: [...blockers], end: { q, r } });
const occupant = occupied.get(key(q, r));
if (occupant) {
blockers.add(occupant.id);
if (!ignoreOccupancy) return finish({ status: 'blocked', steps, blocker: occupant.id, blockers: [...blockers], end: { q, r } });
}
const enter = opposite(exit); exit = cell.tracks[enter];
steps.push({ q, r, enter, exit });
const state = `${q},${r},${exit}`;
if (visited.has(state)) return finish({ status: 'loop', steps, blockers: [...blockers] });
visited.add(state);
}
return finish({ status: 'loop', steps, blockers: [...blockers] });
}
export function available(level, tiles = level.tiles) {
return tiles.filter(t => trace(level, t, tiles).status === 'exit');
}
export function solve(level) {
let remaining = [...level.tiles];
const order = [], waves = [];
while (remaining.length) {
const free = available(level, remaining);
if (!free.length) break;
const ids = new Set(free.map(t => t.id));
waves.push(free.map(t => t.id)); order.push(...ids);
remaining = remaining.filter(t => !ids.has(t.id));
}
return { solvable: remaining.length === 0 && level.tiles.length > 0 && level.moveLimit >= level.tiles.length, order, waves, stuck: remaining.map(t => t.id) };
}
export function rotateBoard(level, turns = 1) {
let cells = clone(level.cells);
const count = ((turns % 6) + 6) % 6;
for (let i = 0; i < count; i++) {
cells = cells.map(c => ({ ...c, q: -c.r || 0, r: c.q + c.r || 0, tracks: rotateTracks(c.tracks) }));
}
return { ...level, cells };
}
export function canRotate(level) {
const coords = new Set(level.cells.map(c => key(c.q, c.r)));
return rotateBoard(level).cells.every(c => coords.has(key(c.q, c.r)));
}
// Rotation search: removing an available piece is always safe (occupancy only decreases).
// Exhaust available pieces at each orientation, then breadth-first search CW/CCW turns.
// Each edge costs one turn; total solution cost is tile count + shortest turn count.
export function solveRotations(level, maxStates = 20000) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const rotating = canRotate(level) && level.rotationEnabled !== false;
function drain(orientation, remaining, actions, waves) {
let pieces = remaining;
const nextActions = [...actions], nextWaves = [...waves];
while (pieces.length) {
const free = available(boards[orientation], pieces);
if (!free.length) break;
const ids = new Set(free.map(t => t.id));
nextActions.push(...free.map(t => ({ type: 'tap', id: t.id })));
nextWaves.push([...ids]);
pieces = pieces.filter(t => !ids.has(t.id));
}
return { orientation, remaining: pieces, actions: nextActions, waves: nextWaves };
}
const first = drain(0, [...level.tiles], [], []);
const queue = [{ ...first, rotations: 0 }], seen = new Set();
let visited = 0;
for (let head = 0; head < queue.length && visited < maxStates; head++) {
const node = queue[head];
const signature = node.orientation + ':' + JSON.stringify(node.remaining.map(t => t.id).sort());
if (seen.has(signature)) continue;
seen.add(signature); visited++;
if (!node.remaining.length && level.tiles.length) {
const minimumMoves = level.tiles.length + node.rotations;
return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, rotations: node.rotations, actions: node.actions, order: node.actions.filter(a => a.type === 'tap').map(a => a.id), waves: node.waves, stuck: [], states: visited, exhausted: false };
}
if (!rotating) continue;
for (const direction of [1, -1]) {
const orientation = (node.orientation + direction + 6) % 6;
const child = drain(orientation, node.remaining, [...node.actions, { type: 'rotate', direction }], node.waves);
const signature = child.orientation + ':' + JSON.stringify(child.remaining.map(t => t.id).sort());
if (!seen.has(signature)) queue.push({ ...child, rotations: node.rotations + 1 });
}
}
return { solvable: false, reachable: false, minimumMoves: null, rotations: 0, actions: [], order: [], waves: first.waves, stuck: first.remaining.map(t => t.id), states: visited, exhausted: visited >= maxStates };
}
export function analyze(level) {
const solution = solveRotations(level), n = level.tiles.length;
const routes = level.tiles.map(t => trace(level, t, level.tiles, true));
const meanTurns = n ? routes.reduce((sum, p) => sum + p.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length, 0) / n : 0;
const free = available(level).length;
const parts = {
scale: Math.min(1, n / 37) * 15,
turns: Math.min(1, meanTurns / 5) * 20,
depth: Math.min(1, Math.max(0, solution.waves.length - 1) / 9) * 20,
blocked: (n ? 1 - free / n : 0) * 15,
rotation: Math.min(1, solution.rotations / 4) * 20,
precision: (n ? Math.min(1, Math.max(0, 1 - (level.moveLimit - (solution.minimumMoves ?? n)) / Math.max(1, n * 0.4))) : 0) * 10,
};
const score = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
return { score, parts, meanTurns, free, depth: solution.waves.length, ...solution, label: score < 30 ? '轻松' : score < 50 ? '进阶' : score < 70 ? '烧脑' : '挑战' };
}
export function generateLevel(seed, radius = 3, target = 25, complexity = 0.7) {
const rng = random(seed);
const level = { version: 1, id: `seed-${seed}`, name: '新生轨迹', seed, cells: hexagon(radius), tiles: [], moveLimit: target + 4 };
level.cells.forEach(c => { c.tracks = randomTracks(rng); });
// Reverse construction: each inserted tile can leave before every earlier tile.
for (let i = 0; i < target; i++) {
const occupied = new Set(level.tiles.map(t => key(t.q, t.r))), candidates = [];
for (const c of level.cells) {
if (occupied.has(key(c.q, c.r))) continue;
for (let dir = 0; dir < 6; dir++) {
const t = { id: `t${i}`, q: c.q, r: c.r, dir, color: Math.floor(rng() * 4) };
const p = trace(level, t, [...level.tiles, t]);
if (p.status === 'exit') candidates.push({ t, weight: rng() + p.steps.length * complexity });
}
}
if (!candidates.length) break;
candidates.sort((a, b) => b.weight - a.weight);
const pick = Math.floor(rng() * Math.min(6, candidates.length));
level.tiles.push(candidates[pick].t);
}
level.moveLimit = level.tiles.length + Math.round(level.tiles.length * (1 - complexity) * 0.4) + 1;
return level;
}
export class Game {
constructor(level) {
this.initialLevel = clone(level); this.level = clone(level); this.tiles = clone(level.tiles);
this.moves = level.moveLimit; this.history = []; this.mistakes = 0; this.orientation = 0; this.rotations = 0;
}
get won() { return this.tiles.length === 0; }
get lost() {
if (this.won) return false;
if (this.moves <= 0) return true;
// Lack of immediate exits is not failure: the board may be rotated to open a route.
if (canRotate(this.level) && this.level.rotationEnabled !== false) return false;
return available(this.level, this.tiles).length === 0;
}
snapshot() {
this.history.push({ level: clone(this.level), tiles: clone(this.tiles), moves: this.moves, mistakes: this.mistakes, orientation: this.orientation, rotations: this.rotations });
}
rotate(direction = 1) {
if (direction !== 1 && direction !== -1) return { status: 'invalid-direction' };
if (this.won || this.lost) return { status: 'finished' };
if (!canRotate(this.level) || this.level.rotationEnabled === false) return { status: 'rotation-disabled' };
this.snapshot();
this.level = rotateBoard(this.level, direction);
this.orientation = (this.orientation + direction + 6) % 6;
this.rotations++; this.moves--;
return { status: 'rotated', direction, orientation: this.orientation };
}
tap(id) {
if (this.won || this.lost) return { status: 'finished' };
const tile = this.tiles.find(t => t.id === id);
if (!tile) return { status: 'missing' };
const route = trace(this.level, tile, this.tiles);
this.snapshot(); this.moves--;
if (route.status === 'exit') this.tiles = this.tiles.filter(t => t.id !== id);
else this.mistakes++;
return { ...route, tile: clone(tile) };
}
undo() {
const previous = this.history.pop();
if (!previous) return false;
Object.assign(this, previous); return true;
}
}
+54
View File
@@ -0,0 +1,54 @@
// Source of truth for the palette. Run npm run design after changing values.
// See /Design.md for semantic pairings, ratios and component rules.
export const THEME = Object.freeze({
background: '#A8BECF',
board: '#F2EEE5',
boardGrid: '#7C8B94',
track: '#6D808B',
trackHalo: '#DFE3E2',
surface: '#F7F6F2',
textPrimary: '#273B50',
textSecondary: '#314A5F',
primary: '#6652B8',
onPrimary: '#FFFFFF',
secondary: '#E4EAF0',
utility: '#52697E',
onUtility: '#FFFFFF',
selection: '#DFD9F3',
onSelection: '#3F327A',
disabled: '#CBD4DD',
onDisabled: '#526579',
input: '#FFFFFF',
reward: '#B9760C',
warning: '#A44825',
warningSurface: '#F9E3D2',
scrim: '#273B507A',
focus: '#3F327A',
// Approximate center pixels of the accepted, unchanged generated token art.
tileBlue: '#2392FD',
tilePink: '#FC3E80',
tileYellow: '#FDBC28',
tilePurple: '#874FFD',
arrowLight: '#FFFFFF',
arrowDark: '#273B50',
});
export const SURFACES = Object.freeze({
surface: 'surface', primary: 'primary', secondary: 'secondary', utility: 'utility',
warning: 'warningSurface', reward: 'reward', input: 'input', selection: 'selection',
disabled: 'disabled', tileBlue: 'tileBlue', tilePink: 'tilePink', tileYellow: 'tileYellow', tilePurple: 'tilePurple',
});
export const TYPOGRAPHY = Object.freeze({level:28,moves:32,dialogTitle:24,body:14,label:12,caption:11});
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:1250});
export const RADII = Object.freeze({control:14,dialog:24,small:8});
export function luminance(hex){return hex.slice(1,7).match(/../g).map(v=>parseInt(v,16)/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((a,v,i)=>a+v*[.2126,.7152,.0722][i],0);}
export function contrast(a,b){const x=luminance(a),y=luminance(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);}
export const CONTRAST_PAIRS = [
['正文 / 背景','textPrimary','background',4.5], ['辅助文字 / 背景','textSecondary','background',4.5],
['正文 / 弹窗','textPrimary','surface',4.5], ['辅助文字 / 弹窗','textSecondary','surface',4.5],
['主按钮文字','onPrimary','primary',4.5], ['工具图标','onUtility','utility',3],
['次按钮文字','textPrimary','secondary',4.5], ['选中标签','onSelection','selection',4.5],
['阻挡提示文字','warning','warningSurface',4.5], ['分格 / 棋盘','boardGrid','board',3],
['轨道 / 棋盘','track','board',3], ['轨道 / 浅色外缘','track','trackHalo',3],
['方向箭头 / 蓝棋子','arrowLight','tileBlue',3], ['方向箭头 / 粉棋子','arrowLight','tilePink',3],
['方向箭头 / 黄棋子','arrowDark','tileYellow',3], ['方向箭头 / 紫棋子','arrowLight','tilePurple',3],
];
+36
View File
@@ -0,0 +1,36 @@
import { position, PORTS, trackPoints } from './path-math.js';
export { hexPoints, trackPoints, PORTS } from './path-math.js';
export function layout(cells,width,height) {
const raw=cells.map(c=>position(c.q,c.r));
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
const minY=Math.min(...raw.map(p=>p.y))-.866,maxY=Math.max(...raw.map(p=>p.y))+.866;
const size=Math.min((width-48)/(maxX-minX),(height-50)/(maxY-minY),47);
return {size,ox:width/2-(minX+maxX)/2*size,oy:height/2-(minY+maxY)/2*size};
}
export function center(cell, view) { const p=position(cell.q,cell.r,view.size); return {x:p.x+view.ox,y:p.y+view.oy}; }
export function routePoints(route,view,extend=false) {
if(route.travelPoints)return route.travelPoints.map(p=>({x:p.x*view.size+view.ox,y:p.y*view.size+view.oy}));
const points=[];
route.steps.forEach(s=>{
const c=center(s,view), end=PORTS[s.exit];
if(s.enter===null) points.push(c,{x:c.x+end.x*view.size,y:c.y+end.y*view.size});
else points.push(...trackPoints(c.x,c.y,view.size,s.enter,s.exit));
});
if(route.end) {
const c=center(route.end,view);points.push(c);
if(extend&&route.status==='exit') {const port=PORTS[route.steps[route.steps.length-1].exit];points.push({x:c.x+port.x*120,y:c.y+port.y*120});}
}
return points;
}
export function pointAlong(points,ratio) {
const lengths=points.slice(1).map((p,i)=>Math.hypot(p.x-points[i].x,p.y-points[i].y));
let distance=lengths.reduce((a,b)=>a+b,0)*Math.max(0,Math.min(1,ratio));
for(let i=0;i<lengths.length;i++) {
if(distance<=lengths[i]&&lengths[i]>0) {const t=distance/lengths[i],a=points[i],b=points[i+1];return {x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,angle:Math.atan2(b.y-a.y,b.x-a.x)};}
distance-=lengths[i];
}
const a=points[points.length-2]||points[0],b=points[points.length-1];return {...b,angle:Math.atan2(b.y-a.y,b.x-a.x)};
}
export function hitCell(cells,view,x,y) {
return cells.find(cell=>{const c=center(cell,view),dx=Math.abs(x-c.x)/view.size,dy=Math.abs(y-c.y)/view.size;return dx<=1&&dy<=Math.sqrt(3)/2&&Math.sqrt(3)*dx+dy<=Math.sqrt(3);});
}
+2
View File
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
// Shared geometry used by drawing AND swept collision detection. No host dependencies.
export const PORTS = [0,1,2,3,4,5].map(d => ({x:Math.cos(-Math.PI/2+d*Math.PI/3)*Math.sqrt(3)/2,y:Math.sin(-Math.PI/2+d*Math.PI/3)*Math.sqrt(3)/2}));
export function position(q,r,size=1){return{x:1.5*q*size,y:Math.sqrt(3)*(r+q/2)*size};}
export function hexPoints(x,y,r){return Array.from({length:6},(_,i)=>({x:x+Math.cos(i*Math.PI/3)*r,y:y+Math.sin(i*Math.PI/3)*r}));}
export function trackPoints(x,y,r,a,b){
const p=PORTS[a],q=PORTS[b],points=[];
for(let i=0;i<=20;i++){const t=i/20,u=1-t;points.push({x:x+r*(u*u*u*p.x+3*u*u*t*p.x*.28+3*u*t*t*q.x*.28+t*t*t*q.x),y:y+r*(u*u*u*p.y+3*u*u*t*p.y*.28+3*u*t*t*q.y*.28+t*t*t*q.y)});}
return points;
}
export function pathPoints(route){
const points=[];
for(const s of route.steps){const c=position(s.q,s.r),p=PORTS[s.exit];if(s.enter===null)points.push(c,{x:c.x+p.x,y:c.y+p.y});else points.push(...trackPoints(c.x,c.y,1,s.enter,s.exit));}
if(route.end)points.push(position(route.end.q,route.end.r));
return points;
}
// Collider is the solid top face, not the decorative drop shadow or extruded side.
export const COLLIDER_RADIUS = 0.88;
const NORMALS=[{x:0,y:1},{x:Math.sqrt(3)/2,y:.5},{x:Math.sqrt(3)/2,y:-.5}];
// Exact swept intersection for each rendered line segment against a Minkowski hexagon.
// This avoids tunneling between animation frames, including grazing a neighboring tile.
export function segmentHexHit(a,b,center,radius=COLLIDER_RADIUS*2){
const limit=radius*Math.sqrt(3)/2;
let enter=0,exit=1;
for(const n of NORMALS){
const value=(a.x-center.x)*n.x+(a.y-center.y)*n.y,delta=(b.x-a.x)*n.x+(b.y-a.y)*n.y;
if(Math.abs(delta)<1e-10){if(Math.abs(value)>limit)return null;continue;}
let lo=(-limit-value)/delta,hi=(limit-value)/delta;if(lo>hi)[lo,hi]=[hi,lo];
enter=Math.max(enter,lo);exit=Math.min(exit,hi);if(enter>exit)return null;
}
return enter<=1&&exit>=0?Math.max(0,enter):null;
}
export function sweepPath(points,obstacles){
for(let i=1;i<points.length;i++){
const a=points[i-1],b=points[i];let first=null;
for(const o of obstacles){
if(o.x<Math.min(a.x,b.x)-1.8||o.x>Math.max(a.x,b.x)+1.8||o.y<Math.min(a.y,b.y)-1.8||o.y>Math.max(a.y,b.y)+1.8)continue;
const t=segmentHexHit(a,b,o);
if(t!==null&&(!first||t<first.t))first={t,blocker:o.id};
}
if(first){const point={x:a.x+(b.x-a.x)*first.t,y:a.y+(b.y-a.y)*first.t};return{...first,point,points:[...points.slice(0,i),point]};}
}
return null;
}
+104
View File
@@ -0,0 +1,104 @@
import { THEME } from './design-tokens.js';
import { DIRS, key } from './core.js';
import { tileSample, roundedHex } from './tile-shape.js';
import { collisionPhase, contactPath } from './collision-animation.js';
import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geometry.js';
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
export const COLORS=[THEME.tileBlue,THEME.tilePink,THEME.tileYellow,THEME.tilePurple];
const DARK=COLORS.map(()=>THEME.arrowDark);
// Scene commands remain engine-independent. sprite() is an optional atlas adapter.
export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) {
const r=size*TILE_RADIUS*scale;
const textured=p.sprite?.(tile.color,x,y,r)===true;
if(!textured){
p.polygon(roundedHex(x,y,r),COLORS[tile.color]);
}
const a=r*.58;
const shape=[[-.72,-.23],[.06,-.23],[.06,-.59],[.83,0],[.06,.59],[.06,.23],[-.72,.23]];
const arrow=shape.map(([u,v])=>({x:x+a*(u*Math.cos(angle)-v*Math.sin(angle)),y:y-1+a*(u*Math.sin(angle)+v*Math.cos(angle))}));
p.polygon(arrow,tile.color===2?THEME.arrowDark:THEME.arrowLight);
}
export function drawScene(p,level,tiles,view,state={}) {
const {size}=view;
const angle=state.boardAngle||0,cos=Math.cos(angle),sin=Math.sin(angle);
const rotate=points=>points.map(pt=>{const x=pt.x-view.ox,y=pt.y-view.oy;return {x:view.ox+x*cos-y*sin,y:view.oy+x*sin+y*cos};});
const base={polygon:(pts,c)=>p.polygon(rotate(pts),c),line:(pts,c,w)=>p.line(rotate(pts),c,w)};
const blocked=state.animation?.route.status==='blocked';
const blocker=blocked?tiles.find(t=>t.id===state.animation.route.blocker):null;
const target=blocker?center(blocker,view):null;
const phase=blocked?collisionPhase(state.animation.progress,state.reduced):null;
const movingPath=state.animation?contactPath(routePoints(state.animation.route,view,true),target,size*TILE_RADIUS):null;
const contact=movingPath?.[movingPath.length-1];
const impactAngle=target&&contact?Math.atan2(target.y-contact.y,target.x-contact.x):0;
const cellKeys=new Set(level.cells.map(c=>key(c.q,c.r)));
const sorted=[...level.cells].sort((a,b)=>center(a,view).y-center(b,view).y);
// Complete the contiguous board before drawing any rails. A tiny overlap
// prevents antialiasing seams between touching hexagons.
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2),THEME.board);}
// Subtle cell boundaries beneath the rails: retain the hex grid without
// opening background gaps or painting over connected track endpoints.
for(const cell of sorted){
const {x,y}=center(cell,view),edge=hexPoints(x,y,size);
base.line([...edge,edge[0]],THEME.boardGrid,Math.max(1.25,size*.038));
}
const rails=[];
for(const cell of sorted) {
const {x,y}=center(cell,view);
for(let a=0;a<6;a++) if(a<cell.tracks[a]) {
// Inset the centerline by half the widest stroke at each hex side.
// Rounded line caps must also remain inside the visible cell.
const trackWidth=Math.max(4,size*.16);
const visualRadius=Math.max(0,size*.965-trackWidth/Math.sqrt(3)-.5);
const b=cell.tracks[a],points=trackPoints(x,y,size,a,b);
const exterior=d=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
const first=points[0],last=points[points.length-1],shrink=1-visualRadius/size;
const dx0=exterior(a)?(first.x-x)*shrink:0,dy0=exterior(a)?(first.y-y)*shrink:0;
const dx1=exterior(b)?(last.x-x)*shrink:0,dy1=exterior(b)?(last.y-y)*shrink:0;
points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
rails.push(points);
}
}
for(const points of rails)base.line(points,THEME.trackHalo,Math.max(4,size*.16));
for(const points of rails)base.line(points,THEME.track,Math.max(2,size*.078));
if(state.path) p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));
if(state.selected) {
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
if(cell){const c=center(cell,view),pts=hexPoints(c.x,c.y,size*.98);p.line([...pts,pts[0]],THEME.reward,3);}
}
for(const tile of [...tiles].filter(t=>t.id!==state.animation?.route.tile.id).sort((a,b)=>center(a,view).y-center(b,view).y)) {
const c=center(tile,view);
if(tile.id===state.hint) {p.circle(c.x,c.y,size*1.13,THEME.reward);p.circle(c.x,c.y,size*1.03,THEME.warningSurface);}
let dx=0;
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
let dy=0;
if(tile.id===blocker?.id){
dx+=Math.cos(impactAngle)*phase.kick*size*.1;dy+=Math.sin(impactAngle)*phase.kick*size*.1;
if(phase.impact>0){const ring=hexPoints(c.x+dx,c.y+dy,size*1.04);p.line([...ring,ring[0]],THEME.warning,2.5);}
}
drawTile(p,c.x+dx,c.y+dy,size,tile);
}
if(state.animation) {
const {route,progress}=state.animation;
const blocked=route.status!=='exit';
const loc=pointAlong(movingPath,phase?phase.distance:blocked?Math.sin(progress*Math.PI):progress);
drawTile(p,loc.x,loc.y,size,route.tile,loc.angle,phase?1-phase.impact*.045:!blocked&&progress>.9?Math.max(.2,(1-progress)/.1):1);
if(target&&phase.impact>0){
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
for(const sign of [-1,1]){const a=impactAngle+sign*Math.PI/2,d=size*(.13+phase.impact*.12);p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+size*.15),y:y+Math.sin(a)*(d+size*.15)}],THEME.warning,2.5);}
}
}
}
export class CanvasPainter {
constructor(context) { this.ctx=context; this.atlas=null; }
sprite(index,x,y,r) {
if(!this.atlas?.complete||!this.atlas.naturalWidth)return false;
const f=tileSample(TILE_FRAMES[index]),w=r*2,h=w*TILE_ASPECT;
const c=this.ctx,shape=roundedHex(x,y,r);c.save();c.beginPath();shape.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.closePath();c.clip();
this.ctx.drawImage(this.atlas,f.x,f.y,f.w,f.h,x-w/2,y-h/2,w,h);
c.restore();
return true;
}
polygon(points,fill) {const c=this.ctx;c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.closePath();c.fillStyle=fill;c.fill();}
line(points,color,width) {if(!points.length)return;const c=this.ctx;c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.strokeStyle=color;c.lineWidth=width;c.lineCap='round';c.lineJoin='round';c.stroke();}
circle(x,y,r,fill) {const c=this.ctx;c.beginPath();c.arc(x,y,r,0,Math.PI*2);c.fillStyle=fill;c.fill();}
}
+10
View File
@@ -0,0 +1,10 @@
// Source rectangles in untouched 1293x1216 PNG; shared by browser and Cocos.
export const TILE_FRAMES = [
{x:98, y:99, w:540, h:465, anchorX:.5, anchorY:.5},
{x:680, y:99, w:540, h:465, anchorX:.5, anchorY:.5},
{x:98, y:653, w:540, h:465, anchorX:.5, anchorY:.5},
{x:680, y:653, w:540, h:465, anchorX:.5, anchorY:.5},
];
export const TILE_ATLAS='assets/tiles-matte-clean-v8.png';
export const TILE_RADIUS=.985;
export const TILE_ASPECT=Math.sqrt(3)/2;
+10
View File
@@ -0,0 +1,10 @@
// Shared clean silhouette: sample color inside the PNG; never sample its noisy cutout edge.
export function tileSample(f){return {x:Math.round(f.x+f.w*.4),y:Math.round(f.y+f.h*.4),w:Math.round(f.w*.2),h:Math.round(f.h*.2)};}
export function roundedHex(x,y,r){
const vertices=Array.from({length:6},(_,i)=>({x:x+Math.cos(i*Math.PI/3)*r,y:y+Math.sin(i*Math.PI/3)*r})),out=[];
for(let i=0;i<6;i++){
const v=vertices[i],prev=vertices[(i+5)%6],next=vertices[(i+1)%6],a={x:v.x+(prev.x-v.x)*.075,y:v.y+(prev.y-v.y)*.075},b={x:v.x+(next.x-v.x)*.075,y:v.y+(next.y-v.y)*.075};
for(let j=0;j<=6;j++){const t=j/6,u=1-t;out.push({x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y});}
}
return out;
}
+17
View File
@@ -0,0 +1,17 @@
import { SURFACES } from './design-tokens.js';
// Source rectangles shared by browser and Cocos; never substitute artwork silently.
export const PANEL_ATLAS='assets/ui-surfaces.png';
export const PANEL_SIZE={w:416,h:32};
export const ICON_ATLAS='assets/ui-icons-generated.png';
export const ICON_SIZE={w:1254,h:1254};
// Sample inside each 32px swatch so bilinear filtering cannot bleed adjacent roles.
export const PANELS=Object.fromEntries(Object.keys(SURFACES).map((name,i)=>[name,{x:i*32+2,y:2,w:28,h:28,cap:0}]));
// Measured bounds of the main artwork, padded to a square around its optical center.
const bounds=[
[64,80,281,289],[378,93,568,283],[719,101,828,282],[1005,99,1187,279],
[63,374,255,594],[377,398,592,594],[686,426,891,587],[1000,374,1193,594],
[86,679,235,902],[366,689,576,887],[687,689,890,893],[1018,706,1169,883],
[94,986,224,1189],[367,981,575,1177],[669,1005,892,1170],[1003,982,1184,1185],
];
export const ICONS=Object.fromEntries(['gear','grid','back','close','left','right','undo','restart','bulb','star','edit','play','arrow','erase','track','gem'].map((name,i)=>{const [l,t,r,b]=bounds[i],size=Math.max(r-l,b-t)+12;return[name,{x:Math.floor((l+r-size)/2),y:Math.floor((t+b-size)/2),w:size,h:size}];}));
export const UI_REFERENCE={width:390,height:844,corner:14,font:'Arial Rounded MT Bold',disabledOpacity:.55,pressedScale:.94};
+21
View File
@@ -0,0 +1,21 @@
import { PANELS, ICONS, PANEL_ATLAS, ICON_ATLAS, PANEL_SIZE, ICON_SIZE } from './ui-skin.js';
export const icon=(name,extra='')=>`<span class="ui-icon" data-icon="${name}" ${extra}></span>`;
function piece(url,f,fullW,fullH){const box=document.createElement('span');box.className='sprite-crop';const img=document.createElement('img');img.src=url;img.alt='';img.draggable=false;img.style.cssText=`width:${fullW/f.w*100}%;height:${fullH/f.h*100}%;left:${-f.x/f.w*100}%;top:${-f.y/f.h*100}%`;box.append(img);return box;}
function skin(el,name){if(el.dataset.skin===name&&el.querySelector(':scope > .ui-plate'))return;el.querySelector(':scope > .ui-plate')?.remove();el.dataset.skin=name;const f=PANELS[name],plate=document.createElement('span');plate.className='ui-plate';plate.ariaHidden='true';plate.append(piece(PANEL_ATLAS,f,PANEL_SIZE.w,PANEL_SIZE.h));el.prepend(plate);}
const svgNames={hex:'gem',grid:'grid',edit:'edit',arrow:'play',undo:'undo',reset:'restart',bulb:'bulb',close:'close',help:'gear',check:'star'};
export function decorateUI(){
document.querySelectorAll('svg:not(.icon-defs)').forEach(svg=>{const id=svg.querySelector('use')?.getAttribute('href')?.replace('#i-','');svg.outerHTML=icon(svgNames[id]||'gear');});
document.querySelectorAll('[data-icon]:not([data-ready])').forEach(el=>{const f=ICONS[el.dataset.icon];if(!f)return;el.append(piece(ICON_ATLAS,f,ICON_SIZE.w,ICON_SIZE.h));el.dataset.ready='1';el.ariaHidden='true';});
document.querySelectorAll('button,.hud-pill,.moves-bubble,.win-overlay,dialog,.editor-panel,.editor-analysis,.selection-info,.dialog-note,.tool-icon,.tool-icon small,.rotate-button small,.board-toast,.game-bottom,.metric-track,.metric-track i,.tool-palette').forEach(el=>{
if(el.closest('.compat')||el.matches('.board-tools>button,.rotate-button,.rotate-button small,.moves-bubble'))return;
let name='secondary';
if(el.matches('.win-overlay,dialog,.editor-panel,.editor-analysis,.dialog-note,.tool-palette,.game-bottom'))name='surface';
if(el.matches('.game-icon,.tool-icon,.tool-palette button,.directions button,.editor-rotation button,.editor-board-tools button,.editor-actions button'))name='utility';
if(el.matches('.primary,.level-btn.current,.tool-palette button.active,.directions button.active,.metric-track i'))name='primary';
if(el.matches('.board-toast,.selection-info'))name='warning';
if(el.matches('[data-color]'))name=['tileBlue','tilePink','tileYellow','tilePurple'][+el.dataset.color];
skin(el,name);
});
document.querySelectorAll('input:not([type=file]),select').forEach(el=>{if(el.parentElement.classList.contains('input-skin'))return;const wrap=document.createElement('span');wrap.className='input-skin';el.before(wrap);wrap.append(el);skin(wrap,'input');});
}
export function installUI(){decorateUI();let queued=false;new MutationObserver(records=>{if(!records.some(r=>r.type==='attributes'||[...r.addedNodes].some(n=>n.nodeType===1&&!n.matches?.('.ui-plate,.sprite-crop,img'))))return;if(!queued){queued=true;queueMicrotask(()=>{queued=false;decorateUI();});}}).observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['class']});}