feat: expand campaign and refine puzzle visuals and controls

This commit is contained in:
cabinw
2026-09-09 11:02:45 +08:00
parent 9f8990b579
commit 70b021d4cc
66 changed files with 41108 additions and 10553 deletions
+90 -60
View File
@@ -1,75 +1,100 @@
import { attachBoardViewport } from './board-viewport.js';
import { TapQueue } from './tap-queue.js';
import { exitTiming } from './exit-animation.js';
import { PIGMENTS, LEVEL_THEMES, themeForScore, MOTION, themeWithColors } from './design-tokens.js';
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 { CAMPAIGN, LEVELS } from './levels.js';
import { layout, hitCell } from './geometry.js';
import { TILE_ATLAS } from './sprites.js';
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
const taps=new TapQueue();
const $=id=>document.getElementById(id);
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
const STORE='hex-trails-v2';
let boardViewport;
const STORE=`hex-trails-${CAMPAIGN.id}`;
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 completed=new Set(Array.isArray(saved.completed)?saved.completed.filter(i=>Number.isInteger(i)&&i>=0&&i<LEVELS.length):[]);
let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<LEVELS.length?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 activeTheme=null;
const requestedTheme=LEVEL_THEMES.find(t=>t.id===new URLSearchParams(location.search).get('theme'));
function applyTheme(score){
activeTheme=mode==='edit'||trial?null:themeWithColors(requestedTheme||themeForScore(score),Number(new URLSearchParams(location.search).get('colors'))||7);
const theme=activeTheme||LEVEL_THEMES[0];
document.body.style.setProperty('--color-background',theme.background);
document.body.style.setProperty('--scene-ink',theme.ink);
$('theme-caption').textContent=activeTheme?`${requestedTheme?'配色预览':theme.label} · ${theme.name}`:'自由配色 · 12 色棋子库';
}
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 metricLabels={bends:['平均弯道',22],longestRoute:['最多弯道',12],dependencies:['解锁依赖',18],blocked:['初始阻挡',28],sweptBlockers:['侧面阻挡',12],rotation:['必要旋转',8]};
const chapters=CAMPAIGN.chapters,chapterSize=CAMPAIGN.chapterSize;
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 bars(a){return Object.entries(metricLabels).filter(([k])=>Number.isFinite(a.parts[k])).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}%`;
$('level-list').innerHTML=chapters.map((ch,c)=>`<div class="chapter-title">${String(c+1).padStart(2,'0')} / ${escapeHtml(ch)}</div><div class="level-grid">${LEVELS.slice(c*chapterSize,(c+1)*chapterSize).map((l,j)=>{const i=c*chapterSize+j;return `<button class="level-btn ${i===current?'current':''} ${completed.has(i)?'done':''}" data-level="${i}" aria-label="第 ${i+1} 关 ${escapeHtml(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} / ${LEVELS.length}`;$('campaign-progress').style.width=`${completed.size/LEVELS.length*100}%`;
}
function rotationHint(direction){
const turn=direction===1?'右':'左',exits=available(rotateBoard(game.level,direction),game.tiles).length;
return exits?`免费${turn}转 60°,${exits} 枚棋子的出口就打开了`:`先免费${turn}转 60°,再继续观察路线`;
}
function renderStats(){
const editing=mode==='edit',level=editing?draft:game.initialLevel,a=analyze(level);
applyTheme(a.score);
$('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>`;
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':`第 ${Math.floor(current/chapterSize)+1} 章 / ${level.chapterName||chapters[Math.floor(current/chapterSize)]}`;
$('moves-title').textContent=editing?'离场额度':trial?'可用额度':'待解棋子';$('remaining-title').textContent=editing?'已放置':'待离场';
$('moves').textContent=editing?level.moveLimit:trial?game.moves:game.tiles.length;$('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)} 次转弯`;
$('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=`观察难度 ${a.score}/100 · ${level.tiles.length} 枚棋子 · 约 ${a.workload.estimatedSeconds} 秒体验(估算,无计时限制)`;
$('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);
for(const id of ['rotate-left','rotate-right'])$(id).disabled=!!animation||!!rotationAnimation||game.won||game.lost||!canRotate(game.level)||game.level.rotationEnabled===false;
$('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?'点击箭头离场 · 拖动边缘或按按钮转动底盘':'暂时过不去?转一下底盘,换条路线!')}`;
$('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='步数不够了 · 撤销碰撞或重来,再按提示寻找出口';}
$('instruction').textContent=rotationHint(action.direction);
}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;}
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.score}/100 · ${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转。工作量 ${a.minimumActions??'—'} 次操作,参考 ${a.workload.estimatedSeconds} 秒(估算)。仅成功离场使用 1 额度,旋转与试探免费${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 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);const base=layout((mode==='edit'?draft:game.level).cells,width,height);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();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();}}
const state={selected:mode==='edit'?selected:null,path,hint,reduced,theme:activeTheme,queued:taps.ids};
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};if(progress>=1){animation=null;renderStats();}}
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();}}
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){
const next=taps.take(game.tiles);
if(next&&mode==='play'&&!$('info-dialog').open){playTile(next);return;}
finish();
}
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 resetEffects(preserveTaps=false){if(!preserveTaps)taps.clear();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){boardViewport?.camera.reset();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 syncEditor(){$('editor-name').value=draft.name;$('editor-moves').value=draft.moveLimit;$('editor-radius').value=String(Math.max(...draft.cells.map(c=>Math.max(Math.abs(c.q),Math.abs(c.r),Math.abs(c.q+c.r)))));}
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');}
@@ -79,19 +104,31 @@ function finish(){
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;
$('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} 枚棋子全部找到出口<br>一条接一条,你清空了整片棋盘。${helpUsed?'<br>在提示的陪伴下完成':''}</p><button class="primary" id="next-level">${trial?'返回关卡工坊':current===LEVELS.length-1?'完成旅程 · 回到第一关':'下一段轨迹'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再走一遍</button>`;
openResult();$('next-level').onclick=()=>trial?enterEditor():startLevel((current+1)%LEVELS.length);$('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;
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'这份关卡的离场额度不足':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'仍有棋子等待离场,可以返回工坊增加额度。旋转和试探都没有扣除额度。':'剩余棋子的路线被阻挡,或轨道形成了闭环。'}</p><button class="primary" id="recover">${trial?'返回编辑关卡':game.history.length?'撤销上一步':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>trial?enterEditor():game.history.length?undo():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 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 步');}
if(animation||rotationAnimation){
if(taps.add(tile.id,animation?.route.tile.id)){draw();}
return;
}
resetEffects(true);const before=new Set(available(game.level,game.tiles).map(t=>t.id)),route=game.tap(tile.id);
if(route.status==='exit'){
playSound('exit');animation={route,start:performance.now(),duration:reduced?100:exitTiming(route).duration};
const opened=available(game.level,game.tiles).filter(t=>!before.has(t.id));
if(opened.length){hint=opened[0].id;toast(`移开这一枚,${opened.length} 枚棋子的路通了!`);}
}
else if(route.status==='blocked'||route.status==='loop'){
taps.clear();path=route;if(route.status==='blocked')hint=route.blocker;
if(route.travelPoints){animation={route,start:performance.now(),duration:reduced?150:MOTION.collisionMs};hitSoundTimer=setTimeout(()=>playSound('hit'),(reduced?150:MOTION.collisionMs)*.42);}else shake={id:tile.id,start:performance.now()};
const blocker=game.tiles.find(t=>t.id===route.blocker),blockerCanExit=blocker&&trace(game.level,blocker,game.tiles).status==='exit';
toast(route.status==='loop'?'这条路线形成闭环 · 试探免费,可以转动底盘换条路':blockerCanExit?'标记的棋子挡住了路线 · 可以先移走它':'标记的棋子也在等出口 · 试探免费,可以转盘换条路');
}
renderStats();draw();if(!animation)finish();
}
const matchings=[];
@@ -113,32 +150,25 @@ function editCell(cell){
}
function rotateGame(direction) {
if(animation||rotationAnimation||mode==='edit')return;
resetEffects();const result=game.rotate(direction);
resetEffects();const before=new Set(available(game.level,game.tiles).map(t=>t.id)),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};
const after=available(game.level,game.tiles),opened=after.filter(t=>!before.has(t.id));
if(opened.length){hint=opened[0].id;toast(`转一转,${opened.length} 枚棋子的出口打开了!`);}
else if(!after.length)toast('路线换好了 · 可以继续免费旋转,慢慢观察');
rotationAnimation={direction,start:performance.now(),duration:reduced?100:MOTION.rotationMs};
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);
boardViewport=attachBoardViewport(canvas,{changed:resize,tap:({x,y})=>{
if(!view)return;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);}
});
},drag:(start,end)=>{
if(mode==='edit'||!view||Math.hypot(start.x-view.ox,start.y-view.oy)<=width*.28)return;
let delta=Math.atan2(end.y-view.oy,end.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);
}});
$('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;
@@ -147,16 +177,16 @@ $('hint-button').onclick=()=>{
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;}
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°,新的路线就打开了`);}
if(action.type==='rotate'){$(action.direction===1?'rotate-right':'rotate-left').classList.add('hinted');toast(rotationHint(action.direction));}
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').innerHTML=COLORS.map((c,i)=>`<button style="background:${c}" data-color="${i}" aria-label="${PIGMENTS[i].name}" 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();};
@@ -174,15 +204,15 @@ function loadDraft(data){const errors=validateLevel(data);if(errors.length)throw
$('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();}
function showDialog(html){taps.clear();$('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>`);}
function showScore(){const level=mode==='edit'?draft:game.initialLevel,a=analyze(level);showDialog(`<div class="eyebrow">OBSERVE & ENJOY</div><h2>观察难度与游玩节奏</h2><p><b>${a.score}/100 · ${a.label}</b>,反映读懂路线和解开依赖所需的观察。棋盘大小、棋子总数和旋转额度都不加难度分。</p>${bars(a)}<p>初始有 ${a.free} 枚棋子可以直接离场。工作量为 ${level.tiles.length} 次成功离场,加上最少 ${a.rotations} 次免费旋转;共 ${a.minimumActions??'—'} 次操作,参考时长约 ${a.workload.estimatedSeconds} 秒。大棋盘可以简单而充实,小棋盘也可以更需要观察。</p><ul><li>平均弯道 22 分:平均转弯数 ÷ 4;最多弯道 12 分:单条路线最多转弯数 ÷ 8。</li><li>解锁依赖 18 分:解锁波次按棋子数量归一化。</li><li>初始阻挡比例 28 分;仅由侧面擦碰形成的阻挡比例 12 分。</li><li>必要旋转 8 分:最少旋转数 ÷ 3。各项分别封顶。</li></ul><p class="dialog-note">分数和时长都是设计估计,尚未经真人试玩校准。参考时长不是倒计时。${a.exhausted?'搜索尚未确定这份关卡能否完成。':!a.reachable?'这份关卡存在无法解开的路线,需要调整。':!a.solvable?'这份关卡的成功离场额度不足,需要在工坊增加。':'所有路线已验证可解。'}</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>');}
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));}};};
$('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}" aria-label="第 ${i+1} 关 ${escapeHtml(l.name)},${escapeHtml(l.chapterName||chapters[Math.floor(i/chapterSize)])}${completed.has(i)?',已完成':''}">${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).label}</small></button>`).join('')}</div><p>${LEVELS.length} 个轨道谜题 · ${chapters.length} 章舒展与挑战交替的旅程 · 每关已验证可解。</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();
+38
View File
@@ -0,0 +1,38 @@
export class BoardViewport {
constructor(){this.reset();}
reset(){this.scale=1;this.x=0;this.y=0;}
fit(view,width,height){
this.bounds=view.bounds;
this.maxScale=this.bounds?Math.max(1,Math.min(4,height/(this.bounds.bottom-this.bounds.top))):4;
this.zoom(1,width/2,height/2,width,height);
return this.apply(view);
}
apply(view){return {...view,size:view.size*this.scale,ox:view.ox*this.scale+this.x,oy:view.oy*this.scale+this.y};}
constrain(width,height){this.x=Math.max(width*(1-this.scale),Math.min(0,this.x));this.y=this.bounds?Math.max(-this.bounds.top*this.scale,Math.min(height-this.bounds.bottom*this.scale,this.y)):Math.max(height*(1-this.scale),Math.min(0,this.y));}
zoom(factor,x,y,width,height){const next=Math.max(1,Math.min(this.maxScale??4,this.scale*factor)),ratio=next/this.scale;this.x=x-(x-this.x)*ratio;this.y=y-(y-this.y)*ratio;this.scale=next;this.constrain(width,height);}
pan(x,y,width,height){this.x+=x;this.y+=y;this.constrain(width,height);}
}
export function attachBoardViewport(canvas,{changed,tap,drag}){
const camera=new BoardViewport(),pointers=new Map();let gesture=false,start=null,last=null;
const point=e=>{const r=canvas.getBoundingClientRect();return {x:e.clientX-r.left,y:e.clientY-r.top};};
const size=()=>canvas.getBoundingClientRect();
const pair=()=>{const [a,b]=[...pointers.values()];return {x:(a.x+b.x)/2,y:(a.y+b.y)/2,d:Math.max(1,Math.hypot(a.x-b.x,a.y-b.y))};};
canvas.addEventListener('pointerdown',e=>{const p=point(e);if(!pointers.size){gesture=false;start=p;}pointers.set(e.pointerId,p);canvas.setPointerCapture(e.pointerId);last=pointers.size===2?pair():p;if(pointers.size>1)gesture=true;});
canvas.addEventListener('pointermove',e=>{
if(!pointers.has(e.pointerId))return;const p=point(e),old=pointers.get(e.pointerId),r=size();pointers.set(e.pointerId,p);
if(pointers.size===2){const next=pair();camera.zoom(next.d/last.d,last.x,last.y,r.width,r.height);camera.pan(next.x-last.x,next.y-last.y,r.width,r.height);last=next;gesture=true;changed();}
else if(pointers.size===1&&camera.scale>1){if(gesture||Math.hypot(p.x-start.x,p.y-start.y)>8){gesture=true;camera.pan(p.x-old.x,p.y-old.y,r.width,r.height);changed();}}
});
function end(e,cancel=false){if(!pointers.has(e.pointerId))return;const p=point(e);pointers.delete(e.pointerId);
if(!pointers.size){if(!cancel&&!gesture&&start){if(Math.hypot(p.x-start.x,p.y-start.y)>14)drag?.(start,p);else tap(p);}start=null;last=null;gesture=false;}
else{gesture=true;last=pointers.size===2?pair():[...pointers.values()][0];}
}
canvas.addEventListener('pointerup',e=>end(e));canvas.addEventListener('pointercancel',e=>end(e,true));
canvas.addEventListener('lostpointercapture',e=>{if(pointers.has(e.pointerId))end(e,true);});
canvas.addEventListener('wheel',e=>{if(!e.ctrlKey&&!e.metaKey)return;e.preventDefault();const p=point(e),r=size();camera.zoom(Math.exp(-e.deltaY*.01),p.x,p.y,r.width,r.height);changed();},{passive:false});
const toolbar=document.createElement('div');toolbar.className='board-zoom';toolbar.innerHTML='<button type="button" aria-label="缩小棋盘">−</button><button type="button" aria-label="重置棋盘视图">100%</button><button type="button" aria-label="放大棋盘">+</button><span>双指缩放 · 放大后拖动</span>';canvas.parentElement.insertBefore(toolbar,canvas.nextSibling);
const buttons=toolbar.querySelectorAll('button');
const update=()=>{buttons[1].textContent=`${Math.round(camera.scale*100)}%`;};
buttons.forEach((b,i)=>b.onclick=()=>{const r=size();if(i===1)camera.reset();else camera.zoom(i===0?1/1.35:1.35,r.width/2,r.height/2,r.width,r.height);changed();update();});
return {camera,update};
}
+93
View File
@@ -0,0 +1,93 @@
import { hexagon, random, randomTracks, rotateBoard, trace, available, analyze, key, opposite, Game } from './core.js';
export const CAMPAIGN_PLAN = {
id:'observe-v3', chapterSize:6,
chapters:['看见出口','转出新路','连成节奏','轻巧解结','展开天地','从容变换','长路常新','自在回响'],
};
const STRAIGHT=[3,4,5,0,1,2];
const turnCount=route=>route.steps.filter(s=>s.enter!==null&&opposite(s.enter)!==s.exit).length;
// Spacious parallel lanes create long, readable release levels. Alternate
// columns leave enough physical clearance to avoid off-route grazing blockers;
// each lane splits outwards, so several independent choices stay available.
export function generateLaneLevel(seed,{radius=3,target=18,axis=seed%3}={}) {
const rng=random(seed),cells=hexagon(radius);
const sets=[0,1].map(parity=>cells.filter(c=>Math.abs(c.q)%2===parity));
let pool=sets[seed%2];if(pool.length<target)pool=sets[1-seed%2];
const selected=pool.map(c=>({c,w:rng()})).sort((a,b)=>a.w-b.w).slice(0,target).map(({c})=>c);
const tiles=selected.map((c,i)=>{
let q=c.q,r=c.r,dir=c.r+c.q/2<0?0:3;
for(let n=0;n<axis;n++){[q,r]=[-r||0,q+r||0];dir=(dir+1)%6;}
return{id:`t${i}`,q,r,dir,color:i%4};
});
return{version:1,id:`lanes-${seed}`,name:'从容的长路',seed,rotationEnabled:true,cells,tiles,moveLimit:tiles.length};
}
// Independent board size, route vocabulary and piece count. Reverse insertion
// makes the unrotated board solvable; a selected starting rotation may add a
// small routing decision. No route is allowed to hide a solitary-piece loop.
export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns=4,startRotation=0,allFree=false,clearBlockers=false}={}) {
const rng=random(seed);
let level={version:1,id:`flow-${seed}`,name:'流动的轨迹',seed,rotationEnabled:true,cells:hexagon(radius),tiles:[],moveLimit:target};
level.cells.forEach(c=>{c.tracks=rng()<bendRate?randomTracks(rng):[...STRAIGHT];});
const boards=Array.from({length:6},(_,i)=>rotateBoard(level,i)),candidates=[];
for(const cell of level.cells)for(let dir=0;dir<6;dir++){
const t={id:'candidate',q:cell.q,r:cell.r,dir,color:0};
const routes=boards.map(b=>trace(b,t,[t],true));
if(routes.some(r=>r.status!=='exit'||turnCount(r)>maxTurns))continue;
candidates.push({t,steps:routes[0].steps.length,turns:turnCount(routes[0])});
}
for(let n=0;n<target;n++){
const occupied=new Set(level.tiles.map(t=>key(t.q,t.r))),choices=[];
for(const c of candidates){
if(occupied.has(key(c.t.q,c.t.r)))continue;
const tile={...c.t,id:`t${n}`,color:n%4},tiles=[...level.tiles,tile];
if(trace(level,tile,tiles).status!=='exit')continue;
if(allFree&&tiles.some(t=>trace(level,t,tiles).status!=='exit'))continue;
if(clearBlockers&&level.tiles.some(other=>[tile,other].some((t,i)=>{
const r=trace(level,t,[tile,other]);
return r.status==='blocked'&&!trace(level,t,[tile,other],true).blockers.includes((i?tile:other).id);
})))continue;
// Avoid filling the board with one-step edge exits; give readable routes
// a small preference, while random tie-breaking varies spatial patterns.
const weight=allFree?rng()*2-c.steps*2:rng()*3+Math.min(c.steps,5)*.35-c.turns*.25;
choices.push({tile,weight});
}
if(!choices.length)break;
choices.sort((a,b)=>b.weight-a.weight);
level.tiles.push(choices[0].tile);
}
level=rotateBoard(level,startRotation);
level.moveLimit=level.tiles.length;
return level;
}
// These are structural checks, not playtest claims. Observe every solver state
// in all six orientations to find long no-progress stretches before shipping.
export function inspectFlowLevel(level) {
const a=analyze(level),boards=Array.from({length:6},(_,i)=>rotateBoard(level,i));
let independentLoops=0,maxSoloTurns=0;
for(const board of boards)for(const tile of level.tiles){const r=trace(board,tile,[tile],true);if(r.status!=='exit')independentLoops++;maxSoloTurns=Math.max(maxSoloTurns,turnCount(r));}
const game=new Game(level);let maxDryRotations=0,dry=0,maxUnlockDistance=0,hiddenBlockerStates=0,blockedStates=0,lowChoiceStates=0,states=0;
const samples=[];
function inspectState(){
if(!game.tiles.length)return;
const routes=game.tiles.map(t=>trace(game.level,t,game.tiles));
const free=routes.filter(r=>r.status==='exit').length;
for(let i=0;i<routes.length;i++)if(routes[i].status==='blocked'){
blockedStates++;const base=trace(game.level,game.tiles[i],game.tiles,true);
if(!base.blockers.includes(routes[i].blocker))hiddenBlockerStates++;
}
if(game.tiles.length>=6&&free<2)lowChoiceStates++;
let distance=0;
if(!free){distance=3;for(let turns=1;turns<=3;turns++)if(available(rotateBoard(game.level,turns),game.tiles).length||available(rotateBoard(game.level,-turns),game.tiles).length){distance=turns;break;}}
maxUnlockDistance=Math.max(maxUnlockDistance,distance);states++;
samples.push({remaining:game.tiles.length,free,rotationDistance:distance});
}
for(const action of a.actions){
inspectState();
if(action.type==='rotate'){dry++;maxDryRotations=Math.max(maxDryRotations,dry);game.rotate(action.direction);}
else{dry=0;game.tap(action.id);}
}
return {analysis:a,independentLoops,maxSoloTurns,maxDryRotations,maxUnlockDistance,hiddenBlockerStates,blockedStates,lowChoiceStates,states,samples,won:game.won};
}
+36 -21
View File
@@ -44,12 +44,12 @@ export function validateLevel(input) {
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.cells) || input.cells.length < 1 || input.cells.length > 127) return [...errors, '底板须为 1–127 格'];
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;
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)) > 6) {
errors.push('坐标必须为半径 6 内的轴向整数坐标'); continue;
}
const k = key(c.q, c.r);
if (coords.has(k)) errors.push(`底板重复:${k}`);
@@ -65,9 +65,9 @@ export function validateLevel(input) {
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(t.color) || t.color < 0 || t.color > 11) errors.push('颜色必须为 0–11');
}
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('步数须为 1–999');
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('成功离场额度须为 1–999');
return [...new Set(errors)];
}
@@ -135,7 +135,7 @@ export function canRotate(level) {
// 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.
// Each search edge costs one turn, but only successful removals spend the move budget.
export function solveRotations(level, maxStates = 20000) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const rotating = canRotate(level) && level.rotationEnabled !== false;
@@ -161,8 +161,8 @@ export function solveRotations(level, maxStates = 20000) {
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 };
const minimumMoves = level.tiles.length, minimumActions = minimumMoves + node.rotations;
return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, minimumActions, 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]) {
@@ -172,24 +172,39 @@ export function solveRotations(level, maxStates = 20000) {
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 };
return { solvable: false, reachable: false, minimumMoves: null, minimumActions: 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 routeTurns = routes.map(p => p.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length);
const totalTurns = routeTurns.reduce((sum, turns) => sum + turns, 0);
const meanTurns = n ? totalTurns / n : 0, maxTurns = Math.max(0, ...routeTurns);
const occupiedRoutes = level.tiles.map(t => trace(level, t));
const free = occupiedRoutes.filter(p => p.status === 'exit').length, freeRatio = n ? free / n : 0;
const sweptOnlyBlockers = occupiedRoutes.filter((p, i) => p.collision && !routes[i].blockers.includes(p.blocker)).length;
const dependencyRatio = n > 1 ? Math.max(0, solution.waves.length - 1) / (n - 1) : 0;
// Cognitive difficulty measures route reading and dependencies, never board area,
// piece count or a penalty for experimenting. Workload is reported separately.
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,
bends: Math.min(1, meanTurns / 4) * 22,
longestRoute: Math.min(1, maxTurns / 8) * 12,
dependencies: Math.min(1, dependencyRatio) * 18,
blocked: (n ? 1 - freeRatio : 0) * 28,
sweptBlockers: (n ? sweptOnlyBlockers / n : 0) * 12,
rotation: Math.min(1, solution.rotations / 3) * 8,
};
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 ? '烧脑' : '挑战' };
const workload = {
removals: n,
rotations: solution.rotations,
minimumActions: solution.minimumActions,
routeCells: routes.reduce((sum, p) => sum + p.steps.length, 0),
// A pacing heuristic for comparing authored levels, not measured playtest time.
estimatedSeconds: Math.round(n * 3 + totalTurns * 0.75 + solution.rotations * 4),
};
return { score, parts, meanTurns, maxTurns, free, freeRatio, sweptOnlyBlockers, dependencyRatio, workload, 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);
@@ -238,7 +253,7 @@ export class Game {
this.snapshot();
this.level = rotateBoard(this.level, direction);
this.orientation = (this.orientation + direction + 6) % 6;
this.rotations++; this.moves--;
this.rotations++;
return { status: 'rotated', direction, orientation: this.orientation };
}
tap(id) {
@@ -246,8 +261,8 @@ export class Game {
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);
this.snapshot();
if (route.status === 'exit') { this.tiles = this.tiles.filter(t => t.id !== id); this.moves--; }
else this.mistakes++;
return { ...route, tile: clone(tile) };
}
+30 -4
View File
@@ -3,9 +3,9 @@
export const THEME = Object.freeze({
background: '#A8BECF',
board: '#F2EEE5',
boardGrid: '#7C8B94',
track: '#6D808B',
trackHalo: '#DFE3E2',
boardGrid: '#89867D',
track: '#435F78',
trackHalo: '#F2EEE5',
surface: '#F7F6F2',
textPrimary: '#273B50',
textSecondary: '#314A5F',
@@ -38,7 +38,7 @@ export const SURFACES = Object.freeze({
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 MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:650,rotationMs:240,exitMsPerUnit:60,exitMinTravelMs:140,exitMaxTravelMs:800,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:1700,exitLongStartCells:2,exitLongFullCells:5});
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);}
@@ -52,3 +52,29 @@ export const CONTRAST_PAIRS = [
['方向箭头 / 蓝棋子','arrowLight','tileBlue',3], ['方向箭头 / 粉棋子','arrowLight','tilePink',3],
['方向箭头 / 黄棋子','arrowDark','tileYellow',3], ['方向箭头 / 紫棋子','arrowLight','tilePurple',3],
];
// V2: global pigment library. A level uses only its curated subset.
export const PIGMENTS = Object.freeze([
{name:'晴空蓝', color:THEME.tileBlue}, {name:'莓果粉', color:THEME.tilePink},
{name:'日光黄', color:THEME.tileYellow}, {name:'葡萄紫', color:THEME.tilePurple},
{name:'湖水青', color:'#008B83'}, {name:'珊瑚橙', color:'#EF704F'},
{name:'嫩芽绿', color:'#91C95B'}, {name:'鸢尾蓝', color:'#5267CF'},
{name:'玫瑰红', color:'#C83F66'}, {name:'蜜桃杏', color:'#F5AA83'},
{name:'冰川青', color:'#58C9D5'}, {name:'兰花紫', color:'#AE64C7'},
].map(p=>Object.freeze({...p, arrow:contrast(THEME.arrowLight,p.color)>=3?THEME.arrowLight:THEME.arrowDark})));
export const LEVEL_THEMES = Object.freeze([
{id:'lagoon',name:'薄荷浅湾',label:'轻松',range:'0–29',background:'#C5E4DF',ink:'#273B50',palette:[0,4,2],description:'冷润的浅湾,配蓝、青与一点日光黄。'},
{id:'apricot',name:'杏色午后',label:'进阶',range:'30–49',background:'#F1D7B8',ink:'#273B50',palette:[5,7,4,9],description:'温暖杏底,以珊瑚、鸢尾蓝和湖水青平衡。'},
{id:'lavender',name:'鸢尾花园',label:'烧脑',range:'50–69',background:'#D8D0EB',ink:'#273B50',palette:[3,6,8,10],description:'柔紫空间,用嫩芽绿与冰川青拉开层次。'},
{id:'rose',name:'玫瑰暮色',label:'挑战',range:'70–100',background:'#E7C3CE',ink:'#273B50',palette:[8,7,2,11],description:'低饱和玫瑰底,深色棋子与日光黄形成节奏。'},
{id:'midnight',name:'午夜极光',label:'每周挑战',range:'特别主题',background:'#293956',ink:'#F7F6F2',palette:[10,11,2,1],description:'深靛蓝舞台,冰川青、兰花紫和明亮暖色。'},
].map(t=>Object.freeze({...t,palette:Object.freeze(t.palette)})));
export function themeForScore(score){return LEVEL_THEMES[score<30?0:score<50?1:score<70?2:3];}
export function pigmentIndex(color,theme){return theme?theme.palette[color%theme.palette.length]:color;}
// Keep each world's anchor colors, then add distinct hues before close relatives.
export function themeWithColors(theme,count=7){
const limit=[4,7,9,12].includes(count)?count:7;
const order=[...new Set([...theme.palette,0,1,2,3,4,5,6,7,8,9,10,11])];
return {...theme,palette:order.slice(0,limit)};
}
+21
View File
@@ -0,0 +1,21 @@
import { MOTION } from './design-tokens.js';
import { routePoints } from './geometry.js';
// Timing follows actual path length, so a piece beside an exit has no fixed wait.
export function exitTiming(route) {
const points=routePoints({...route,end:null},{size:1,ox:0,oy:0});
const distance=points.slice(1).reduce((sum,p,i)=>sum+Math.hypot(p.x-points[i].x,p.y-points[i].y),0);
const travelMs=Math.max(MOTION.exitMinTravelMs,Math.min(MOTION.exitMaxTravelMs,distance*MOTION.exitMsPerUnit));
const shortDuration=travelMs+MOTION.exitEffectMs;
const longDuration=Math.min(MOTION.exitLongMaxMs,MOTION.exitLongBaseMs+route.steps.length*MOTION.exitLongCellMs);
// Blend into the original long-route pacing without slowing edge departures.
const blend=smoothMotion((route.steps.length-MOTION.exitLongStartCells)/(MOTION.exitLongFullCells-MOTION.exitLongStartCells));
const duration=shortDuration+(longDuration-shortDuration)*blend;
return {duration,travelFraction:(duration-MOTION.exitEffectMs)/duration};
}
// Zero endpoint velocity: a soft launch and a settled finish, without overshoot.
export function smoothMotion(value) {
const t=Math.max(0,Math.min(1,value));
return t*t*(3-2*t);
}
+2 -1
View File
@@ -5,7 +5,8 @@ export function layout(cells,width,height) {
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};
const ox=width/2-(minX+maxX)/2*size,oy=height/2-(minY+maxY)/2*size;
return {size,ox,oy,bounds:{top:minY*size+oy,bottom:maxY*size+oy}};
}
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) {
+3 -2
View File
File diff suppressed because one or more lines are too long
+51 -10
View File
@@ -1,11 +1,11 @@
import { THEME } from './design-tokens.js';
import { exitTiming, smoothMotion } from './exit-animation.js';
import { THEME, PIGMENTS, pigmentIndex } 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);
export const COLORS=PIGMENTS.map(p=>p.color);
// 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;
@@ -16,10 +16,11 @@ export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,sca
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);
p.polygon(arrow,PIGMENTS[tile.color]?.arrow||THEME.arrowDark);
}
export function drawScene(p,level,tiles,view,state={}) {
const {size}=view;
const visualTile=tile=>({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)});
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)};
@@ -39,7 +40,7 @@ export function drawScene(p,level,tiles,view,state={}) {
// 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));
base.line([...edge,edge[0]],THEME.boardGrid,Math.max(1,size*.024));
}
const rails=[];
for(const cell of sorted) {
@@ -59,7 +60,7 @@ export function drawScene(p,level,tiles,view,state={}) {
}
}
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));
for(const points of rails)base.line(points,THEME.track,Math.max(2.5,size*.10));
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);
@@ -68,6 +69,10 @@ export function drawScene(p,level,tiles,view,state={}) {
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);}
if(state.queued?.includes(tile.id)){
const ring=hexPoints(c.x,c.y,size*1.03);
p.line([...ring,ring[0]],THEME.primary,3);
}
let dx=0;
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
let dy=0;
@@ -75,13 +80,17 @@ export function drawScene(p,level,tiles,view,state={}) {
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);
drawTile(p,c.x+dx,c.y+dy,size,visualTile(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(!blocked){
drawExit(p,route,view,progress,visualTile(route.tile),state.reduced);
}else{
const loc=pointAlong(movingPath,phase?phase.distance:Math.sin(progress*Math.PI));
drawTile(p,loc.x,loc.y,size,visualTile(route.tile),loc.angle,phase?1-phase.impact*.045: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);}
@@ -91,7 +100,7 @@ export function drawScene(p,level,tiles,view,state={}) {
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;
if(!TILE_FRAMES[index]||!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);
@@ -102,3 +111,35 @@ export class CanvasPainter {
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();}
}
export function exitGeometry(route,view){
const points=routePoints({...route,end:null},view);
const edge=points[points.length-1],before=points[points.length-2];
const angle=Math.atan2(edge.y-before.y,edge.x-before.x);
return {points,edge,angle};
}
export function drawExit(p,route,view,progress,tile,reduced=false){
const {points,edge,angle}=exitGeometry(route,view),size=view.size;
if(reduced){if(progress<.8){const at=pointAlong(points,progress/.8);drawTile(p,at.x,at.y,size,tile,at.angle);}return;}
const travel=exitTiming(route).travelFraction;
if(progress<travel){
const at=pointAlong(points,smoothMotion(progress/travel));
drawTile(p,at.x,at.y,size,tile,at.angle);return;
}
const t=Math.min(1,(progress-travel)/(1-travel)),ease=smoothMotion(t);
const color=COLORS[tile.color],dx=Math.cos(angle),dy=Math.sin(angle);
// Local exit plume: short enough to remain visible in the canvas gutter.
const at={x:edge.x+dx*size*.48*ease,y:edge.y+dy*size*.48*ease};
for(let i=3;i>=1;i--){
const d=size*(.10+i*.13)*ease,scale=(1-smoothMotion(t))*(.14-i*.025);
if(scale>0)p.circle(at.x-dx*d,at.y-dy*d,size*scale,color);
}
if(t<.82)drawTile(p,at.x,at.y,size,tile,angle,1-smoothMotion(t/.82));
const burst=Math.max(0,(t-.18)/.82);
if(burst>0&&burst<1){
for(let i=0;i<6;i++){
const a=angle+(i-2.5)*.58,d=size*(.12+.40*(1-Math.pow(1-burst,3))),r=size*.075*(1-smoothMotion(burst));
p.polygon(hexPoints(edge.x+dx*size*.18+Math.cos(a)*d,edge.y+dy*size*.18+Math.sin(a)*d,r),color);
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import { attachBoardViewport } from './board-viewport.js';
import { generateLaneLevel } from './campaign-generator.js';
import { TapQueue } from './tap-queue.js';
import { exitTiming } from './exit-animation.js';
import { LEVEL_THEMES, PIGMENTS, MOTION, themeWithColors } from './design-tokens.js';
import { LEVELS } from './levels.js';
import { Game, available, solveRotations } from './core.js';
import { layout, hitCell } from './geometry.js';
import { CanvasPainter, drawScene, drawTile } from './renderer.js';
import { ICONS, ICON_SIZE, ICON_ATLAS } from './ui-skin.js';
import { TILE_ATLAS } from './sprites.js';
const taps=new TapQueue();
const $=id=>document.getElementById(id),canvas=$('preview'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
let boardViewport;
const regularLevel=LEVELS.find(l=>l.tiles.length>=16)||LEVELS.find(l=>l.tiles.length>=12)||LEVELS[0];
let studyLevel=generateLaneLevel(606,{radius:5,target:42,axis:0});
let colorCount=7;
let theme=themeWithColors(LEVEL_THEMES[0],colorCount),game=new Game(studyLevel),animation=null,rotation=null,frame=0,view=null,hint=null,afterRotation=null;
const atlas=new Image();atlas.src=TILE_ATLAS;atlas.onload=()=>{painter.atlas=atlas;draw();paintSamples();};
function decorateIcons(){
document.querySelectorAll('[data-icon]').forEach(el=>{
const f=ICONS[el.dataset.icon];
el.innerHTML=`<span class="art-icon" aria-hidden="true"><img src="${ICON_ATLAS}" alt="" style="width:${ICON_SIZE.w/f.w*100}%;height:${ICON_SIZE.h/f.h*100}%;left:${-f.x/f.w*100}%;top:${-f.y/f.h*100}%"></span>`;
});
}
$('theme-tabs').innerHTML=LEVEL_THEMES.map(t=>`<button data-theme="${t.id}" aria-pressed="false"><i style="background:${t.background}"></i>${t.label}</button>`).join('');
$('theme-grid').innerHTML=LEVEL_THEMES.map(t=>`<button class="theme-card" data-theme="${t.id}" aria-pressed="false"><div class="mini-scene" style="--bg:${t.background};--ink:${t.ink}"><span>${t.label} / ${t.range}</span><canvas data-sample="${t.id}" aria-label="${t.name}配色"></canvas></div><strong>${t.name}</strong><small>${t.palette.length} 色棋子 · ${t.background}</small></button>`).join('');
$('pigment-grid').innerHTML=PIGMENTS.map((p,i)=>`<div class="pigment-item"><canvas data-pigment="${i}" aria-label="${p.name}棋子"></canvas><strong>${p.name}</strong><small>${p.color}</small></div>`).join('');
function setTheme(next){
theme=themeWithColors(next,colorCount);$('phone').style.setProperty('--scene-bg',theme.background);$('density-grid').style.setProperty('--bg',theme.background);$('density-grid').style.setProperty('--ink',theme.ink);$('phone').style.setProperty('--scene-ink',theme.ink);
$('scene-label').textContent=`${theme.label} / ${theme.range}`;$('scene-name').textContent=theme.name;
$('scene-description').textContent=`${theme.description} 当前展示 ${colorCount} 色扩展搭配。`;$('phone-label').textContent=`${theme.label} · ${theme.name}`;
$('background-value').textContent=theme.background;$('background-chip').style.background=theme.background;
$('palette-count').textContent=`${theme.palette.length} / 12`;
$('local-palette').innerHTML=theme.palette.map(i=>`<span title="${PIGMENTS[i].name}" style="background:${PIGMENTS[i].color}"></span>`).join('');
document.querySelectorAll('[data-theme]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.theme===theme.id)));
for(const id of ['play-theme','game-link'])$(id).href=`index.html?theme=${theme.id}&colors=${colorCount}`;
paintSamples();draw();
}
document.querySelectorAll('[data-theme]').forEach(b=>b.onclick=()=>setTheme(LEVEL_THEMES.find(t=>t.id===b.dataset.theme)));
function fit(c){const rect=c.getBoundingClientRect(),w=rect.width,h=c===canvas?rect.height:w,dpr=Math.min(devicePixelRatio||1,2);c.width=Math.round(w*dpr);c.height=Math.round(h*dpr);const context=c.getContext('2d');context.setTransform(dpr,0,0,dpr,0,0);return {context,width:w,height:h};}
function paintSamples(){
document.querySelectorAll('[data-theme].theme-card>small').forEach(el=>{const t=LEVEL_THEMES.find(t=>t.id===el.parentElement.dataset.theme);el.textContent=`${colorCount} 色棋子 · ${t.background}`;});
document.querySelectorAll('[data-density]').forEach(c=>{const {context,width}=fit(c),p=new CanvasPainter(context);p.atlas=atlas;drawScene(p,studyLevel,studyLevel.tiles,layout(studyLevel.cells,width,width),{theme:themeWithColors(theme,Number(c.dataset.density))});});
document.querySelectorAll('[data-sample]').forEach(c=>{const {context,width}=fit(c),p=new CanvasPainter(context);p.atlas=atlas;const v=layout(studyLevel.cells,width,width);drawScene(p,studyLevel,studyLevel.tiles,v,{theme:themeWithColors(LEVEL_THEMES.find(t=>t.id===c.dataset.sample),colorCount)});});
document.querySelectorAll('[data-pigment]').forEach(c=>{const {context,width}=fit(c),p=new CanvasPainter(context);p.atlas=atlas;drawTile(p,width/2,width/2,width*.46,{color:Number(c.dataset.pigment),dir:1});});
}
function resize(){const {width,height}=fit(canvas);const base=layout(game.level.cells,width,height);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();draw();paintSamples();}
function controls(){const busy=!!animation||!!rotation;for(const id of ['demo','left','right','undo','hint'])$(id).disabled=busy; $('undo').disabled=busy||!game.history.length;$('moves').textContent=game.moves;}
function draw(now=performance.now()){
if(!view)return;
cancelAnimationFrame(frame);ctx.clearRect(0,0,canvas.width,canvas.height);
const state={theme,reduced,hint,queued:taps.ids};let done=false,rotated=false;
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};done=progress>=1;}
if(rotation){const t=Math.min(1,(now-rotation.start)/rotation.duration);state.boardAngle=-rotation.direction*Math.PI/3*Math.pow(1-t,3);rotated=t>=1;}
drawScene(painter,game.level,game.tiles,view,state);
if(done){const status=animation.route.status;animation=null;$('phone-status').textContent=game.won?'全部离场,漂亮!':status==='exit'?'成功离场 · 可以继续点击棋子':'发生碰撞 · 可撤销或转动底盘';$('motion-status').textContent=status==='exit'?'播放完成 · 再播一次可观察尾迹与碎片':'这条路线被阻挡了';controls();}
if(rotated){rotation=null;controls();const cb=afterRotation;afterRotation=null;if(cb){cb();return;}}
if(!animation&&!rotation){const next=taps.take(game.tiles);if(next){move(next);return;}}
if(animation||rotation)frame=requestAnimationFrame(draw);
}
function reset(){taps.clear();animation=null;rotation=null;afterRotation=null;hint=null;cancelAnimationFrame(frame);game=new Game(studyLevel);$('phone-status').textContent='点击棋子,沿轨道出发';$('motion-status').textContent='可反复播放,也可直接操作棋盘。';controls();draw();}
function move(tile){if(animation||rotation){if(taps.add(tile.id,animation?.route.tile.id))draw();return;}hint=null;const route=game.tap(tile.id);if(!['exit','blocked','loop'].includes(route.status))return;
if(route.status!=='exit')taps.clear();
const base=route.status==='exit'?exitTiming(route).duration:MOTION.collisionMs;
animation={route,start:performance.now(),duration:reduced?150:base*($('slow').checked?2.5:1)};
$('phone-status').textContent=route.status==='exit'?'沿着轨道,找到出口…':'接触后沿原路返回';$('motion-status').textContent=route.status==='exit'?'正在播放:滑行 → 出口收束 → 六角余韵':'正在展示碰撞反馈';controls();draw();
}
function turn(direction,callback=null){if(animation||rotation)return;hint=null;const r=game.rotate(direction);if(r.status!=='rotated')return;afterRotation=callback;rotation={direction,start:performance.now(),duration:reduced?100:MOTION.rotationMs};$('phone-status').textContent='棋子不动,底盘旋转 60°';controls();draw();}
function demonstrate(){
if(animation||rotation)return;
if(game.won||game.moves<=0)reset();
const free=available(game.level,game.tiles);
if(free.length){move(free[0]);return;}
const action=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves}).actions[0];
if(action?.type==='rotate')turn(action.direction,demonstrate);else{reset();$('motion-status').textContent='演示已重置,请再次播放。';}
}
$('demo').onclick=demonstrate;$('restart').onclick=reset;
$('left').onclick=()=>turn(-1);$('right').onclick=()=>turn(1);
$('undo').onclick=()=>{if(game.undo()){hint=null;controls();draw();$('phone-status').textContent='已撤销,状态恢复';}};
$('hint').onclick=()=>{const action=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves}).actions[0];if(action?.type==='tap'){hint=action.id;$('phone-status').textContent='点击标记的棋子,可以安全离场';draw();}else $('phone-status').textContent=action?.type==='rotate'?`试试${action.direction===1?'右':'左'}转底盘`:'全部完成,可重置演示';};
$('reset').onclick=()=>{taps.clear();$('study-dialog-content').innerHTML='<h2>选择关卡</h2><div class="study-levels">'+LEVELS.map((l,i)=>`<button data-level="${i}">${String(i+1).padStart(2,'0')}</button>`).join('')+'</div>';$('study-dialog').showModal();};
$('next-theme').onclick=()=>{taps.clear();$('study-dialog-content').innerHTML=`<h2>设置</h2><label><input id="study-slow" type="checkbox" ${$('slow').checked?'checked':''}> 慢速观察动画</label><button id="study-fit">恢复完整棋盘视图</button>`;$('study-dialog').showModal();};
$('study-close').onclick=()=>$('study-dialog').close();
$('study-dialog-content').onclick=e=>{const b=e.target.closest('[data-level]');if(b){studyLevel=LEVELS[Number(b.dataset.level)];document.querySelector('.phone-top strong').textContent=`LEVEL ${String(Number(b.dataset.level)+1).padStart(2,'0')}`;boardViewport.camera.reset();reset();resize();document.querySelectorAll('.density-card .mini-scene>span').forEach(el=>el.textContent=`${el.closest('[data-count]').dataset.count} 色 / ${studyLevel.tiles.length} 枚棋子`);$('study-dialog').close();}if(e.target.closest('#study-fit')){boardViewport.camera.reset();resize();$('study-dialog').close();}};
$('study-dialog-content').onchange=e=>{if(e.target.id==='study-slow')$('slow').checked=e.target.checked;};
boardViewport=attachBoardViewport(canvas,{changed:resize,tap:({x,y})=>{if(!view)return;const cell=hitCell(game.level.cells,view,x,y);const tile=cell&&game.tiles.find(t=>t.q===cell.q&&t.r===cell.r);if(tile)move(tile);}});
$('board-size').onchange=()=>{studyLevel=$('board-size').value==='regular'?regularLevel:generateLaneLevel(606,{radius:Number($('board-size').value),target:$('board-size').value==='5'?42:60,axis:0});boardViewport.camera.reset();reset();resize();document.querySelectorAll('.density-card .mini-scene>span').forEach(el=>el.textContent=`${el.closest('[data-count]').dataset.count} 色 / ${studyLevel.tiles.length} 枚棋子`);};
$('density-grid').innerHTML=[4,7,9,12].map(n=>`<button class="theme-card density-card" data-count="${n}" aria-pressed="${n===colorCount}"><div class="mini-scene"><span>${n} 色 / ${studyLevel.tiles.length} 枚棋子</span><canvas data-density="${n}" aria-label="${n}色棋盘对照"></canvas></div><strong>${n===4?'简洁':n===7?'丰富':n===9?'缤纷':'满色'} · ${n} 色</strong><small>${n===7?'建议优先体验':n===12?'观察相近色带来的复杂感':'同一布局,比较视觉密度'}</small></button>`).join('');
document.querySelectorAll('[data-count]').forEach(b=>b.onclick=()=>{colorCount=Number(b.dataset.count);document.querySelectorAll('[data-count]').forEach(x=>x.setAttribute('aria-pressed',String(Number(x.dataset.count)===colorCount)));setTheme(LEVEL_THEMES.find(t=>t.id===theme.id));reset();});
document.querySelector('.phone-top strong').textContent='测试关 01';
decorateIcons();setTheme(theme);controls();new ResizeObserver(resize).observe(canvas);
+7
View File
@@ -0,0 +1,7 @@
// Store identities, not coordinates: re-check each move against the latest board.
export class TapQueue {
constructor(){this.ids=[];}
add(id,activeId){if(id===activeId||this.ids.includes(id))return false;this.ids.push(id);return true;}
take(tiles){while(this.ids.length){const id=this.ids.shift(),tile=tiles.find(t=>t.id===id);if(tile)return tile;}return null;}
clear(){this.ids.length=0;}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { PIGMENTS } from './design-tokens.js';
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;}
@@ -13,7 +14,7 @@ export function decorateUI(){
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];
if(el.matches('[data-color]')){el.style.setProperty('--pigment',PIGMENTS[+el.dataset.color].color);if(!el.classList.contains('pigment-swatch'))el.classList.add('pigment-swatch');name='secondary';}
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');});