feat: expand campaign and refine puzzle visuals and controls
This commit is contained in:
+90
-60
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+51
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;}
|
||||
}
|
||||
@@ -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');});
|
||||
|
||||
Reference in New Issue
Block a user