feat: version campaigns and document original puzzle design
Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
This commit is contained in:
+37
-18
@@ -3,9 +3,10 @@ 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 { icon, installUI, decorateUI } from './ui.js';
|
||||
import { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js';
|
||||
import { CAMPAIGN, LEVELS } from './levels.js';
|
||||
import { CAMPAIGN_VERSIONS, campaignVersion } from './campaign-versions.js';
|
||||
import { selectedCampaign, readCampaignProgress, writeCampaignProgress } from './campaign-progress.js';
|
||||
import { layout, hitCell } from './geometry.js';
|
||||
import { TILE_ATLAS } from './sprites.js';
|
||||
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
|
||||
@@ -13,10 +14,12 @@ const taps=new TapQueue();
|
||||
const $=id=>document.getElementById(id);
|
||||
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
||||
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<LEVELS.length):[]);
|
||||
let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<LEVELS.length?saved.current:0;
|
||||
let progressStorage;try{progressStorage=window.localStorage;}catch{}
|
||||
let saveUnavailable=!progressStorage;
|
||||
let campaign=selectedCampaign(progressStorage),LEVELS=campaign.levels;
|
||||
const sessionProgress=new Map();
|
||||
const saved=readCampaignProgress(progressStorage,campaign);
|
||||
let completed=new Set(saved.completed),current=saved.current;
|
||||
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;
|
||||
@@ -33,11 +36,19 @@ 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={bends:['平均弯道',22],longestRoute:['最多弯道',12],dependencies:['解锁依赖',18],blocked:['初始阻挡',28],sweptBlockers:['侧面阻挡',12],rotation:['必要旋转',8]};
|
||||
const chapters=CAMPAIGN.chapters,chapterSize=CAMPAIGN.chapterSize;
|
||||
let 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 persist(){const progress={current,completed:[...completed]};sessionProgress.set(campaign.id,progress);saveUnavailable=!writeCampaignProgress(progressStorage,campaign,progress);if(saveUnavailable)toast('浏览器未允许保存,进度仅保留在当前页面');}
|
||||
function switchCampaign(id){
|
||||
const next=campaignVersion(id);if(next.id===campaign.id)return;
|
||||
persist();
|
||||
campaign=next;LEVELS=campaign.levels;chapters=campaign.chapters;chapterSize=campaign.chapterSize;
|
||||
const progress=sessionProgress.get(campaign.id)||readCampaignProgress(progressStorage,campaign);
|
||||
completed=new Set(progress.completed);startLevel(progress.current);
|
||||
}
|
||||
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(){
|
||||
$('map-button').ariaLabel=`选择关卡,当前 ${campaign.id}`;
|
||||
$('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}%`;
|
||||
}
|
||||
@@ -58,7 +69,7 @@ function renderStats(){
|
||||
$('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.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?(!trial&&level.lesson?escapeHtml(level.lesson):'点击箭头离场 · 左右旋转免费,慢慢观察'):'暂时没有出口 · 可以继续免费旋转,观察路线')}`;
|
||||
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];
|
||||
@@ -86,9 +97,10 @@ function draw(now=performance.now()){
|
||||
}
|
||||
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 toast(message,duration=3200){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),duration);}
|
||||
function showLesson(){if(mode==='play'&&!trial&&game.initialLevel.lesson)toast(game.initialLevel.lesson,6500);}
|
||||
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 startLevel(i){if(!Number.isInteger(i)||i<0||i>=LEVELS.length)return;boardViewport?.camera.reset();current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();showLesson();}
|
||||
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=[];}
|
||||
@@ -110,7 +122,7 @@ function finish(){
|
||||
$('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 restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();showLesson();}
|
||||
function undo(){if(animation||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
|
||||
function playTile(tile){
|
||||
if(animation||rotationAnimation){
|
||||
@@ -124,10 +136,10 @@ function playTile(tile){
|
||||
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;
|
||||
taps.clear();path=null;
|
||||
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?'标记的棋子挡住了路线 · 可以先移走它':'标记的棋子也在等出口 · 试探免费,可以转盘换条路');
|
||||
toast(route.status==='loop'?'这条路线形成闭环 · 试探免费,可以转动底盘换条路':blockerCanExit?'前方棋子挡住了路线 · 可以先移走它':'前方棋子也在等出口 · 试探免费,可以转盘换条路');
|
||||
}
|
||||
renderStats();draw();if(!animation)finish();
|
||||
}
|
||||
@@ -204,19 +216,26 @@ 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){taps.clear();$('dialog-content').innerHTML=html;$('info-dialog').showModal();}
|
||||
function showDialog(html){taps.clear();$('dialog-content').innerHTML=html;decorateUI();if(!$('info-dialog').open)$('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 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>');}
|
||||
$('dialog-content').addEventListener('click',e=>{if(e.target.closest('#sound-toggle')){toggleSound();$('sound-toggle').textContent=`音效:${soundEnabled()?'开':'关'}`;}});
|
||||
$('dialog-content').addEventListener('click',e=>{
|
||||
if(e.target.closest('#sound-toggle')){toggleSound();$('sound-toggle').textContent=`音效:${soundEnabled()?'开':'关'}`;}
|
||||
const pick=e.target.closest('[data-pick]');if(pick){$('info-dialog').close();startLevel(Number(pick.dataset.pick));}
|
||||
});
|
||||
$('dialog-content').addEventListener('change',e=>{if(e.target.id==='campaign-version'){switchCampaign(e.target.value);showMapDialog();$('campaign-version').focus();}});
|
||||
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}" 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));}};};
|
||||
function showMapDialog(){
|
||||
showDialog(`<div class="eyebrow">CHOOSE YOUR NEXT TRAIL</div><h2>挑一关,慢慢解开</h2><label class="field campaign-picker">关卡版本<select id="campaign-version" aria-describedby="campaign-description">${CAMPAIGN_VERSIONS.map(c=>`<option value="${c.id}" ${c.id===campaign.id?'selected':''}>${c.id} · ${c.name} · ${c.levels.length} 关</option>`).join('')}</select></label><p class="campaign-description" id="campaign-description">${campaign.description}<span class="campaign-progress-note"><strong>${campaign.id} · 已完成 ${completed.size} / ${LEVELS.length} 关</strong> · ${chapters.length} 章</span></p><div class="level-dialog-grid">${LEVELS.map((l,i)=>`<button class="level-btn ${i===current?'current':''}" data-pick="${i}" aria-label="${campaign.id} 第 ${i+1} 关 ${escapeHtml(l.name)},${escapeHtml(l.chapterName||chapters[Math.floor(i/chapterSize)])}${completed.has(i)?',已完成':''}" ${i===current?'aria-current="true"':''}>${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).label}</small></button>`).join('')}</div><p>${saveUnavailable?'浏览器未允许保存:各版本进度仅在本页临时保留,刷新后会丢失。':'各版本分别保存通关进度与上次选关。'}切换版本会重新开始该版本上次选择的关卡。</p>`);
|
||||
}
|
||||
$('map-button').onclick=showMapDialog;
|
||||
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();
|
||||
if(new URLSearchParams(location.search).get('editor')==='1')enterEditor();else showLesson();
|
||||
|
||||
Reference in New Issue
Block a user