feat: add hex trail puzzle, level editor, and visual design guide
This commit is contained in:
+192
@@ -0,0 +1,192 @@
|
||||
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
|
||||
import { icon, installUI } from './ui.js';
|
||||
import { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js';
|
||||
import { LEVELS } from './levels.js';
|
||||
import { layout, hitCell } from './geometry.js';
|
||||
import { TILE_ATLAS } from './sprites.js';
|
||||
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
|
||||
const $=id=>document.getElementById(id);
|
||||
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
||||
const STORE='hex-trails-v2';
|
||||
let saved={};try{saved=JSON.parse(localStorage.getItem(STORE)||'{}')||{};}catch{}
|
||||
let completed=new Set(Array.isArray(saved.completed)?saved.completed.filter(i=>Number.isInteger(i)&&i>=0&&i<24):[]);
|
||||
let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<24?saved.current:0;
|
||||
let game=new Game(LEVELS[current]),mode='play',trial=false,draft=null,editHistory=[],tool='tile',direction=1,color=0,selected=null,path=null,hint=null,animation=null,rotationAnimation=null,shake=null,toastTimer,view,width=0,height=0,frame=0;
|
||||
let helpUsed=false;
|
||||
let hitSoundTimer;
|
||||
document.addEventListener('pointerdown',unlockAudio,{passive:true});
|
||||
document.addEventListener('click',e=>{if(e.target.closest('button'))playSound('tap');});
|
||||
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const metricLabels={scale:['棋子规模',15],turns:['弯道追踪',20],depth:['解锁层数',20],blocked:['路线阻塞',15],rotation:['必要旋转',20],precision:['步数压力',10]};
|
||||
const chapters=['转盘初体验','糖果回旋','碰撞大挑战'];
|
||||
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function persist(){try{localStorage.setItem(STORE,JSON.stringify({current,completed:[...completed]}));}catch{toast('浏览器未允许保存,进度仅保留在当前页面');}}
|
||||
function bars(a){return Object.entries(metricLabels).map(([k,[label,max]])=>`<div class="metric-row"><span>${label}</span><span class="metric-track"><i style="width:${a.parts[k]/max*100}%"></i></span><span>${a.parts[k].toFixed(0)}/${max}</span></div>`).join('');}
|
||||
function renderMap(){
|
||||
$('level-list').innerHTML=chapters.map((ch,c)=>`<div class="chapter-title">0${c+1} / ${ch}</div><div class="level-grid">${LEVELS.slice(c*8,c*8+8).map((l,j)=>{const i=c*8+j;return `<button class="level-btn ${i===current?'current':''} ${completed.has(i)?'done':''}" data-level="${i}" aria-label="第 ${i+1} 关 ${l.name}${completed.has(i)?',已通关':''}" ${i===current?'aria-current="true"':''}>${String(i+1).padStart(2,'0')}</button>`;}).join('')}</div>`).join('');
|
||||
$('progress-label').textContent=`${completed.size} / 24`;$('campaign-progress').style.width=`${completed.size/24*100}%`;
|
||||
}
|
||||
function renderStats(){
|
||||
const editing=mode==='edit',level=editing?draft:game.initialLevel,a=analyze(level);
|
||||
$('level-name').textContent=level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':`LEVEL ${String(current+1).padStart(2,'0')}`;
|
||||
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':`第${['一','二','三'][Math.floor(current/8)]}章 / ${chapters[Math.floor(current/8)]}`;
|
||||
$('moves-title').textContent=editing?'步数上限':'剩余步数';$('remaining-title').textContent=editing?'已放置':'待离场';
|
||||
$('moves').textContent=editing?level.moveLimit:game.moves;$('remaining').innerHTML=`${editing?level.tiles.length:game.tiles.length}<small> / ${editing?level.cells.length:level.tiles.length}</small>`;
|
||||
$('mini-caption').innerHTML=editing?'MAKE YOUR<br>OWN WAY ↗':'FOLLOW<br>THE FLOW ↗';
|
||||
$('difficulty-label').textContent=`${a.label} · ${a.score}`;$('score-value').textContent=a.score;$('score-name').textContent=a.label;$('score-bars').innerHTML=bars(a);$('score-summary').textContent=`${level.tiles.length} 枚棋子 · ${a.depth} 层解锁 · 平均 ${a.meanTurns.toFixed(1)} 次转弯`;
|
||||
$('undo-button').disabled=game.history.length===0||!!animation||!!rotationAnimation;
|
||||
$('orientation-title').textContent=editing?'最少旋转':'底盘朝向';$('orientation').innerHTML=editing?`${a.rotations}<small>次</small>`:`${game.orientation*60}<small>°</small>`;
|
||||
for(const id of ['rotate-left','rotate-right'])$(id).disabled=!!animation||!!rotationAnimation||game.won||game.moves<=0||!canRotate(game.level);
|
||||
$('hint-button').disabled=!!animation||!!rotationAnimation||game.won;
|
||||
$('instruction').innerHTML=`<span class="pulse-dot"></span>${editing?({tile:'点击空格放置棋子;点击已有棋子更新方向和配色',rotate:'点击棋子,将箭头顺时针旋转 60°',track:'点击格子,循环切换 15 种轨道连接', 'rotate-track':'点击底板,整体旋转轨道 60°',erase:'点击棋子,将它从关卡中移除',inspect:'点击棋子,查看完整移动路线'}[tool]):(game.won?'所有棋子都找到出口了!':game.lost?'撤销一步,重新寻找出口':available(game.level,game.tiles).length?'点击箭头离场 · 拖动边缘或按按钮转动底盘':'暂时过不去?转一下底盘,换条路线!')}`;
|
||||
if(!editing&&!animation&&!rotationAnimation&&!game.won&&game.moves>0&&!available(game.level,game.tiles).length){
|
||||
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
|
||||
const action=solution.actions[0];
|
||||
if(solution.solvable&&action?.type==='rotate'){
|
||||
const right=action.direction===1;
|
||||
$(right?'rotate-right':'rotate-left').classList.add('hinted');
|
||||
$('instruction').textContent=`这条路绕回了起点 · 点${right?'右':'左'}转 60°,再点击棋子`;
|
||||
}else if(solution.reachable){$('instruction').textContent='步数不够了 · 撤销碰撞或重来,再按提示寻找出口';}
|
||||
}
|
||||
if(editing){$('editor-valid').textContent=level.tiles.length===0?'○ 等待放置棋子':a.exhausted?'… 尚未确定可解性':a.solvable?'✓ 已验证可解':'! 关卡暂不可解';$('editor-valid').classList.toggle('error',!a.solvable&&!a.exhausted&&level.tiles.length>0);$('editor-score').innerHTML=`${a.score}<small>/100</small>`;$('editor-analysis-text').textContent=`${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转 · ${a.minimumMoves??'—'} 步通关${a.exhausted?' · 搜索达到上限,尚未确定':!a.reachable?' · 存在无法避开的碰撞或闭环':!a.solvable?' · 步数不足':''}。`;$('editor-bars').innerHTML=bars(a);$('trial-button').disabled=!level.tiles.length;$('editor-undo').disabled=!editHistory.length;}
|
||||
}
|
||||
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);view=layout((mode==='edit'?draft:game.level).cells,width,height);draw();}
|
||||
function draw(now=performance.now()){
|
||||
if(!view)return;
|
||||
ctx.clearRect(0,0,width,height);
|
||||
const state={selected:mode==='edit'?selected:null,path,hint,reduced};
|
||||
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};if(progress>=1){animation=null;renderStats();finish();}}
|
||||
if(rotationAnimation){const t=Math.min(1,(now-rotationAnimation.start)/rotationAnimation.duration);state.boardAngle=-rotationAnimation.direction*Math.PI/3*(1-(1-Math.pow(1-t,3)));if(t>=1){rotationAnimation=null;renderStats();finish();}}
|
||||
if(shake){const t=(now-shake.start)/550;if(t>=1)shake=null;else{state.shake=shake.id;state.shakeTime=t;}}
|
||||
drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
|
||||
if(animation||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
|
||||
}
|
||||
function toast(message){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),3200);}
|
||||
function resetEffects(){clearTimeout(hitSoundTimer);if($('result').open)$('result').close();clearTimeout(toastTimer);$('board-toast').classList.remove('visible');$('board-toast').textContent='';animation=null;rotationAnimation=null;for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');shake=null;path=null;hint=null;cancelAnimationFrame(frame);$('result').hidden=true;}
|
||||
function startLevel(i){current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();}
|
||||
function applyMode(){const edit=mode==='edit';document.body.classList.toggle('editing',edit);$('play-tab').classList.toggle('active',!edit);$('edit-tab').classList.toggle('active',edit);$('play-inspector').hidden=edit;$('editor-panel').hidden=!edit;$('game-tools').hidden=edit;$('editor-board-tools').hidden=!edit;$('return-editor').hidden=!trial;$('map-button').hidden=edit;$('rotation-controls').hidden=edit;}
|
||||
function enterEditor(){
|
||||
if(!draft){draft=clone(game.level);draft.id='custom';draft.name='我的第一条轨迹';editHistory=[];}
|
||||
mode='edit';trial=false;resetEffects();applyMode();syncEditor();renderStats();resize();
|
||||
}
|
||||
function syncEditor(){$('editor-name').value=draft.name;$('editor-moves').value=draft.moveLimit;$('editor-radius').value=draft.cells.length>19?'3':'2';}
|
||||
function checkpoint(){editHistory.push(clone(draft));if(editHistory.length>100)editHistory.shift();}
|
||||
function changed(){resetEffects();renderStats();resize();}
|
||||
function openResult(){ $('result').hidden=false;$('result').showModal();playSound(game.won?'win':'lose');}
|
||||
$('result').addEventListener('cancel',e=>e.preventDefault());
|
||||
function finish(){
|
||||
if($('result').open)return;
|
||||
if(mode==='edit'||animation||rotationAnimation)return;
|
||||
if(game.won){
|
||||
if(!trial){completed.add(current);persist();renderMap();}
|
||||
$('result').innerHTML=`<div class="result-emblem">${icon('star')}${icon('star')}${icon('star')}</div><div class="eyebrow">A LITTLE MOMENT OF JOY</div><h3>${trial?'这条轨迹,通了!':'漂亮,全部过关!'}</h3><p>${game.level.tiles.length} 枚棋子全部离场 · 转动 ${game.rotations} 次<br>${game.mistakes===0?'零失误,漂亮的路线。':`还有 ${game.moves} 步余量,做得不错。`}${helpUsed?'<br>在提示的陪伴下完成':''}</p><button class="primary" id="next-level">${trial?'返回关卡工坊':current===23?'完成旅程 · 回到第一关':'下一段轨迹'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再走一遍</button>`;
|
||||
openResult();$('next-level').onclick=()=>trial?enterEditor():startLevel((current+1)%24);$('replay-level').onclick=restart;
|
||||
}else if(game.lost){
|
||||
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'休息一下,再试一次':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'步数用完了。撤销一步,重新寻找出口。':'剩余棋子互相阻塞,或轨道形成了闭环。'}</p><button class="primary" id="recover">${game.history.length?'撤销上一步':trial?'返回编辑关卡':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>game.history.length?undo():trial?enterEditor():restart();$('retry').onclick=restart;
|
||||
}
|
||||
}
|
||||
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();}
|
||||
function undo(){if(animation||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已撤销一步,步数已恢复');}}
|
||||
function playTile(tile){
|
||||
if(animation||rotationAnimation)return;
|
||||
resetEffects();const route=game.tap(tile.id);
|
||||
if(route.status==='exit'){playSound('exit');animation={route,start:performance.now(),duration:reduced?100:Math.min(1700,550+route.steps.length*85)};}
|
||||
else if(route.status==='blocked'||route.status==='loop'){path=route;if(route.travelPoints){animation={route,start:performance.now(),duration:reduced?500:1250};hitSoundTimer=setTimeout(()=>playSound('hit'),(reduced?500:1250)*.42);}else shake={id:tile.id,start:performance.now()};toast(route.status==='loop'?'轨道形成闭环,试着旋转底盘':route.collision?'被这枚棋子挡住了 · 先移走它,或转动底盘 · −1 步':'这条路被挡住了,换个方向试试 · −1 步');}
|
||||
renderStats();draw();if(!animation)finish();
|
||||
}
|
||||
const matchings=[];
|
||||
function enumerate(ports,map=[]){if(!ports.length){matchings.push([...map]);return;}const a=ports[0];for(let i=1;i<ports.length;i++){const b=ports[i],next=[...map];next[a]=b;next[b]=a;enumerate(ports.filter(p=>p!==a&&p!==b),next);}}
|
||||
enumerate([0,1,2,3,4,5]);
|
||||
function editCell(cell){
|
||||
selected={q:cell.q,r:cell.r};const tile=draft.tiles.find(t=>t.q===cell.q&&t.r===cell.r);
|
||||
$('selection-info').textContent=`坐标 (${cell.q}, ${cell.r}) · 连接 ${cell.tracks.map((v,i)=>i<v?`${i}↔${v}`:null).filter(Boolean).join(' / ')}`;
|
||||
if(tool==='inspect'){path=tile?trace(draft,tile):null;hint=null;toast(tile?(path.status==='exit'?'路径畅通,可以离场':path.status==='loop'?'这条路线是闭环':'路线被另一枚棋子阻挡'):'请选择一枚棋子查看路线');draw();return;}
|
||||
if((tool==='erase'||tool==='rotate')&&!tile){toast('这里还没有棋子');draw();return;}
|
||||
checkpoint();
|
||||
if(tool==='tile'){if(tile){tile.dir=direction;tile.color=color;}else draft.tiles.push({id:`custom-${Date.now()}-${draft.tiles.length}`,q:cell.q,r:cell.r,dir:direction,color});}
|
||||
if(tool==='rotate')tile.dir=(tile.dir+1)%6;
|
||||
if(tool==='erase')draft.tiles=draft.tiles.filter(t=>t!==tile);
|
||||
if(tool==='track'){const index=matchings.findIndex(m=>m.every((p,i)=>p===cell.tracks[i]));cell.tracks=[...matchings[(index+1)%matchings.length]];}
|
||||
if(tool==='rotate-track')cell.tracks=rotateTracks(cell.tracks);
|
||||
$('selection-info').textContent=`坐标 (${cell.q}, ${cell.r}) · 连接 ${cell.tracks.map((v,i)=>i<v?`${i}↔${v}`:null).filter(Boolean).join(' / ')}`;
|
||||
changed();
|
||||
}
|
||||
function rotateGame(direction) {
|
||||
if(animation||rotationAnimation||mode==='edit')return;
|
||||
resetEffects();const result=game.rotate(direction);
|
||||
if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;}
|
||||
playSound('rotate');
|
||||
rotationAnimation={direction,start:performance.now(),duration:reduced?100:420};
|
||||
renderStats();draw();
|
||||
}
|
||||
$('rotate-left').onclick=()=>rotateGame(-1);$('rotate-right').onclick=()=>rotateGame(1);
|
||||
let pointerStart=null;
|
||||
canvas.addEventListener('pointerdown',e=>{if(!view)return;const r=canvas.getBoundingClientRect();pointerStart={x:e.clientX-r.left,y:e.clientY-r.top};canvas.setPointerCapture(e.pointerId);});
|
||||
canvas.addEventListener('pointercancel',()=>{pointerStart=null;});
|
||||
canvas.addEventListener('pointerup',e=>{
|
||||
if(animation||rotationAnimation||!view){pointerStart=null;return;}
|
||||
const rect=canvas.getBoundingClientRect(),x=e.clientX-rect.left,y=e.clientY-rect.top;
|
||||
if(pointerStart&&Math.hypot(x-pointerStart.x,y-pointerStart.y)>14){
|
||||
const start=pointerStart;pointerStart=null;
|
||||
if(mode!=='edit'&&Math.hypot(start.x-view.ox,start.y-view.oy)>width*.28){
|
||||
let delta=Math.atan2(y-view.oy,x-view.ox)-Math.atan2(start.y-view.oy,start.x-view.ox);
|
||||
if(delta>Math.PI)delta-=Math.PI*2;if(delta<-Math.PI)delta+=Math.PI*2;
|
||||
if(Math.abs(delta)>.18)rotateGame(delta>0?1:-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
pointerStart=null;
|
||||
const cell=hitCell((mode==='edit'?draft:game.level).cells,view,x,y);
|
||||
if(!cell)return;if(mode==='edit')editCell(cell);else{const tile=game.tiles.find(t=>t.q===cell.q&&t.r===cell.r);if(tile)playTile(tile);}
|
||||
});
|
||||
$('level-list').onclick=e=>{const b=e.target.closest('[data-level]');if(b)startLevel(Number(b.dataset.level));};
|
||||
$('play-tab').onclick=()=>{if(mode==='edit'||trial)startLevel(current);};$('edit-tab').onclick=enterEditor;$('workshop-link').onclick=enterEditor;
|
||||
$('undo-button').onclick=undo;$('reset-button').onclick=restart;
|
||||
$('hint-button').onclick=()=>{
|
||||
if(animation||rotationAnimation)return;
|
||||
resetEffects();helpUsed=true;
|
||||
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
|
||||
if(!solution.reachable){toast(solution.exhausted?'路线较复杂,暂未找到提示':'当前关卡无法避开所有碰撞,请撤销或重新编辑');return;}
|
||||
if(!solution.solvable){toast(`至少还需要 ${solution.minimumMoves} 步,建议撤销恢复步数`);return;}
|
||||
const action=solution.actions[0];if(!action)return;
|
||||
if(action.type==='rotate'){$(action.direction===1?'rotate-right':'rotate-left').classList.add('hinted');toast(`下一步:${action.direction===1?'右转':'左转'}底盘 60°,新的路线就打开了`);}
|
||||
else{const tile=game.tiles.find(t=>t.id===action.id);hint=tile.id;path=trace(game.level,tile,game.tiles);toast('这枚棋子沿途不会碰撞,可以离场');}
|
||||
draw();
|
||||
};
|
||||
$('tool-palette').onclick=e=>{const b=e.target.closest('[data-tool]');if(!b)return;tool=b.dataset.tool;document.querySelectorAll('[data-tool]').forEach(x=>x.classList.toggle('active',x===b));path=null;hint=null;renderStats();draw();};
|
||||
$('directions').innerHTML=['↑','↗','↘','↓','↙','↖'].map((s,i)=>`<button data-direction="${i}" class="${i===direction?'active':''}" aria-label="方向 ${s}">${icon('arrow',`style="transform:rotate(${i*60}deg)"`)}</button>`).join('');
|
||||
$('directions').onclick=e=>{const b=e.target.closest('[data-direction]');if(!b)return;direction=Number(b.dataset.direction);document.querySelectorAll('[data-direction]').forEach(x=>x.classList.toggle('active',x===b));};
|
||||
$('swatches').innerHTML=COLORS.map((c,i)=>`<button style="background:${c}" data-color="${i}" aria-label="${['冰蓝','糖果粉','蜜橙','葡萄紫'][i]}" class="${i===color?'active':''}"></button>`).join('');
|
||||
$('swatches').onclick=e=>{const b=e.target.closest('[data-color]');if(!b)return;color=Number(b.dataset.color);document.querySelectorAll('[data-color]').forEach(x=>x.classList.toggle('active',x===b));};
|
||||
$('editor-name').onchange=()=>{checkpoint();draft.name=$('editor-name').value.trim()||'未命名轨迹';syncEditor();changed();};
|
||||
$('editor-moves').onchange=()=>{checkpoint();draft.moveLimit=Math.max(1,Math.min(999,Math.round(Number($('editor-moves').value)||1)));syncEditor();changed();};
|
||||
$('editor-radius').onchange=()=>{checkpoint();const cells=hexagon(Number($('editor-radius').value)),old=new Map(draft.cells.map(c=>[key(c.q,c.r),c]));draft.cells=cells.map(c=>old.get(key(c.q,c.r))||c);const keys=new Set(cells.map(c=>key(c.q,c.r)));draft.tiles=draft.tiles.filter(t=>keys.has(key(t.q,t.r)));selected=null;changed();};
|
||||
function rotateDraft(direction){if(!canRotate(draft)){toast('不规则底板不能整体旋转');return;}checkpoint();draft=rotateBoard(draft,direction);changed();}
|
||||
$('editor-turn-left').onclick=()=>rotateDraft(-1);$('editor-turn-right').onclick=()=>rotateDraft(1);
|
||||
$('editor-undo').onclick=()=>{if(!editHistory.length)return;draft=editHistory.pop();selected=null;syncEditor();changed();};
|
||||
$('generate-button').onclick=()=>{checkpoint();const radius=Number($('editor-radius').value);draft=rotateBoard(generateLevel(Math.floor(Math.random()*1e8),radius,radius===2?13:27,.7),1);draft.moveLimit=draft.tiles.length+12;draft.name='偶遇的轨迹';draft.id='custom';selected=null;syncEditor();changed();toast('已生成,并通过旋转与碰撞规则验证');};
|
||||
$('blank-button').onclick=()=>{checkpoint();const radius=Number($('editor-radius').value);draft={version:1,id:'custom',name:'一条新的轨迹',cells:hexagon(radius),tiles:[],moveLimit:30};selected=null;syncEditor();changed();};
|
||||
$('trial-button').onclick=()=>{const errors=validateLevel(draft);if(errors.length){toast(errors[0]);return;}mode='play';trial=true;game=new Game(draft);helpUsed=false;resetEffects();applyMode();renderStats();resize();finish();window.scrollTo({top:0,behavior:reduced?'instant':'smooth'});};
|
||||
$('return-editor').onclick=enterEditor;
|
||||
$('save-button').onclick=()=>{try{localStorage.setItem('hex-trails-draft',JSON.stringify(draft));toast('关卡已保存到此浏览器');}catch{toast('本机存储不可用,请导出 JSON 保存');}};
|
||||
$('load-button').onclick=()=>{try{const data=localStorage.getItem('hex-trails-draft');if(!data){toast('还没有本机存档');return;}loadDraft(JSON.parse(data));toast('已读取本机关卡');}catch(e){toast(`读取失败:${e.message}`);}};
|
||||
function loadDraft(data){const errors=validateLevel(data);if(errors.length)throw new Error(errors.join(';'));checkpoint();draft=clone(data);selected=null;syncEditor();changed();}
|
||||
$('export-button').onclick=()=>{const errors=validateLevel(draft);if(errors.length){toast(errors[0]);return;}const blob=new Blob([JSON.stringify(draft,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='hex-trails-level.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('已导出通用 JSON 关卡');};
|
||||
$('import-button').onclick=()=>$('import-file').click();
|
||||
$('import-file').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>200000)throw new Error('文件不能超过 200 KB');loadDraft(JSON.parse(await file.text()));toast('导入成功,已重新计算可解性与难度');}catch(error){showDialog(`<div class="eyebrow">IMPORT CHECK</div><h2>这份关卡需要调整</h2><p>${escapeHtml(error.message)}</p><p>当前编辑内容已保留。</p>`);}e.target.value='';};
|
||||
function showDialog(html){$('dialog-content').innerHTML=html;$('info-dialog').showModal();}
|
||||
$('info-dialog').querySelector('.dialog-close').onclick=()=>$('info-dialog').close();
|
||||
$('info-dialog').onclick=e=>{if(e.target===$('info-dialog')){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}};
|
||||
function showScore(){const a=analyze(mode==='edit'?draft:game.initialLevel);showDialog(`<div class="eyebrow">LEVEL CHALLENGE</div><h2>这关有多难?</h2><p><b>${a.score}/100 · ${a.label}</b>,六项结构分相加后取整。</p>${bars(a)}<p>已计入沿途碰撞;最优解需要 ${a.rotations} 次旋转、${a.minimumMoves??'—'} 步。当前初始布局有 ${a.free} 枚棋子可以安全离场。</p><ul><li>棋子规模 15 分;轨道转弯 20 分。</li><li>解锁层次 20 分;初始碰撞比例 15 分。</li><li>必要旋转 20 分:最少旋转数 ÷ 4,封顶。</li><li>步数压力 10 分:按包含旋转的最短解计算余量。</li></ul><p class="dialog-note">评分是结构估计,未经真人通关率校准。不可解与搜索未确定会单独标识,不作为高难度关卡。</p>`);}
|
||||
$('score-help').onclick=showScore;$('difficulty-button').onclick=showScore;
|
||||
function showHelp(){showDialog(`<button id="sound-toggle" type="button">音效:${soundEnabled()?'开':'关'}</button>`+'<div class="eyebrow">HOW TO PLAY</div><h2>转转六角,轻松过关!</h2><p><b>① 点击箭头</b><br>棋子沿轨道出发,遇到弯道自动转向。</p><p><b>② 转动底盘</b><br>按左右按钮,或沿棋盘边缘拖动,每次旋转 60°。上层棋子位置和箭头不动,底下的轨道转到新位置,路线随之改变。</p><p><b>③ 小心碰撞</b><br>会检查棋子整段运动轨迹,包括弯道擦碰。棋子是有宽度的,不能穿过旁边的棋子;碰撞会让棋子退回原位。</p><p><b>④ 全部离场</b><br>点击棋子或转动底盘各花 1 步,碰撞也扣步。撤销可以恢复棋盘、方向与步数。没有倒计时。</p><p class="dialog-note">卡住了?提示既能找到安全棋子,也会告诉你什么时候该转盘。四种颜色只负责好看,不改变规则。</p>');}
|
||||
$('dialog-content').addEventListener('click',e=>{if(e.target.closest('#sound-toggle')){toggleSound();$('sound-toggle').textContent=`音效:${soundEnabled()?'开':'关'}`;}});
|
||||
for(const b of document.querySelectorAll('.help-trigger'))b.onclick=showHelp;
|
||||
$('map-button').onclick=()=>{showDialog(`<div class="eyebrow">CHOOSE YOUR NEXT TRAIL</div><h2>挑一关,转起来</h2><div class="level-dialog-grid">${LEVELS.map((l,i)=>`<button class="level-btn ${i===current?'current':''}" data-pick="${i}">${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).score} / 100</small></button>`).join('')}</div><p>24 个旋转谜题 · 每关都已验证可以避开碰撞。</p>`);$('dialog-content').onclick=e=>{const b=e.target.closest('[data-pick]');if(b){$('info-dialog').close();startLevel(Number(b.dataset.pick));}};};
|
||||
const atlas=new Image();atlas.onload=()=>{painter.atlas=atlas;draw();};atlas.onerror=()=>toast('棋子图片加载失败,已切换基础绘制');atlas.src=TILE_ATLAS;
|
||||
new ResizeObserver(resize).observe(canvas);
|
||||
applyMode();renderMap();renderStats();resize();
|
||||
|
||||
installUI();
|
||||
|
||||
if(new URLSearchParams(location.search).get('editor')==='1')enterEditor();
|
||||
Reference in New Issue
Block a user