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:
cabinw
2026-09-09 13:17:30 +08:00
parent 70b021d4cc
commit e92cccba82
52 changed files with 76449 additions and 15076 deletions
+37 -18
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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();
+11 -6
View File
@@ -1,7 +1,7 @@
import { hexagon, random, randomTracks, rotateBoard, trace, available, analyze, key, opposite, Game } from './core.js';
export const CAMPAIGN_PLAN = {
id:'observe-v3', chapterSize:6,
id:'observe-v3', revision:2, chapterSize:6, minimumCurvedRatio:.65,
chapters:['看见出口','转出新路','连成节奏','轻巧解结','展开天地','从容变换','长路常新','自在回响'],
};
const STRAIGHT=[3,4,5,0,1,2];
@@ -26,7 +26,9 @@ export function generateLaneLevel(seed,{radius=3,target=18,axis=seed%3}={}) {
// 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}={}) {
// min/maxBaseTurns constrain the construction orientation, before startRotation.
// Published route usage is measured again in the final reference solution.
export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns=4,startRotation=0,allFree=false,clearBlockers=false,minBaseTurns=0,maxBaseTurns=maxTurns,curvePreference=0}={}) {
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];});
@@ -35,6 +37,7 @@ export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns
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;
if(turnCount(routes[0])<minBaseTurns||turnCount(routes[0])>maxBaseTurns)continue;
candidates.push({t,steps:routes[0].steps.length,turns:turnCount(routes[0])});
}
for(let n=0;n<target;n++){
@@ -50,7 +53,7 @@ export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns
})))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;
const weight=(allFree?rng()*2-c.steps*2:rng()*3+Math.min(c.steps,5)*.35-c.turns*.25)+Math.min(c.turns,2)*curvePreference;
choices.push({tile,weight});
}
if(!choices.length)break;
@@ -68,26 +71,28 @@ 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 game=new Game(level);let maxDryRotations=0,dry=0,maxUnlockDistance=0,hiddenBlockerStates=0,blockedStates=0,lowChoiceStates=0,noCurvedChoiceStates=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;
const freeCurved=routes.filter(r=>r.status==='exit'&&turnCount(r)>0).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++;
if(game.tiles.length>=4&&free>0&&!freeCurved)noCurvedChoiceStates++;
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});
samples.push({remaining:game.tiles.length,free,freeCurved,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};
return {analysis:a,independentLoops,maxSoloTurns,maxDryRotations,maxUnlockDistance,hiddenBlockerStates,blockedStates,lowChoiceStates,noCurvedChoiceStates,states,samples,won:game.won};
}
+29
View File
@@ -0,0 +1,29 @@
import { campaignVersion } from './campaign-versions.js';
export const CAMPAIGN_SELECTION_KEY = 'hex-trails-campaign-version';
export const campaignProgressKey = version=>`hex-trails-campaign-${version}`;
function getItem(storage,key) { try { return storage?.getItem(key)??null; } catch { return null; } }
export function selectedCampaign(storage) { return campaignVersion(getItem(storage,CAMPAIGN_SELECTION_KEY)); }
export function normalizeProgress(value,count) {
const valid=i=>Number.isInteger(i)&&i>=0&&i<count;
return {
current:valid(value?.current)?value.current:0,
completed:[...new Set(Array.isArray(value?.completed)?value.completed.filter(valid):[])],
};
}
export function readCampaignProgress(storage,campaign) {
// Migrate only if the new slot is absent, leaving the historical keys untouched.
const keys=[campaignProgressKey(campaign.id),...campaign.legacyStorageKeys];
const raw=keys.map(key=>getItem(storage,key)).find(value=>value!==null);
let value;try { value=JSON.parse(raw||'{}'); } catch {}
return normalizeProgress(value,campaign.levels.length);
}
export function writeCampaignProgress(storage,campaign,progress) {
try {
if(!storage)return false;
storage.setItem(campaignProgressKey(campaign.id),JSON.stringify(normalizeProgress(progress,campaign.levels.length)));
storage.setItem(CAMPAIGN_SELECTION_KEY,campaign.id);
return true;
} catch { return false; }
}
+26
View File
@@ -0,0 +1,26 @@
import { CAMPAIGN, LEVELS } from './levels.js';
import { ARCHIVED_LEVELS } from './levels-archive.js';
// Public layout versions are distinct from the level JSON schema's `version: 1`.
// Historical layouts stay intact; all editions share the current game rules.
export const CAMPAIGN_VERSIONS = [
{
id:'v0.1', name:'最初设计', description:'最初的 24 关,保留原始布局与顺序。',
chapters:['转盘初体验','糖果回旋','碰撞大挑战'], chapterSize:8,
levels:ARCHIVED_LEVELS['v0.1'], legacyStorageKeys:['hex-trails-v2'],
},
{
id:'v0.2', name:'直线较多版', description:'扩展为 48 关,直线路径较多,按舒展与挑战交替推进。',
chapters:['看见出口','转出新路','连成节奏','轻巧解结','展开天地','从容变换','长路常新','自在回响'], chapterSize:6,
levels:ARCHIVED_LEVELS['v0.2'], legacyStorageKeys:['hex-trails-observe-v3-r1','hex-trails-observe-v3'],
},
{
id:'v0.3', name:'弯轨观察版', description:'当前的 48 关,以沿弯轨观察出口为主,保留起伏节奏。',
chapters:CAMPAIGN.chapters, chapterSize:CAMPAIGN.chapterSize,
levels:LEVELS, legacyStorageKeys:[`hex-trails-${CAMPAIGN.id}-r${CAMPAIGN.revision||1}`],
},
];
export const DEFAULT_CAMPAIGN_VERSION = 'v0.3';
export function campaignVersion(id) {
return CAMPAIGN_VERSIONS.find(c=>c.id===id)||CAMPAIGN_VERSIONS.find(c=>c.id===DEFAULT_CAMPAIGN_VERSION);
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { segmentHexHit } from './path-math.js';
export function collisionPhase(progress, reduced=false){
const t=Math.max(0,Math.min(1,progress));
const distance=t<.42?1-Math.pow(1-t/.42,2):t<.64?1:Math.pow(1-(t-.64)/.36,2);
const impact=t>=.42&&t<.64?Math.sin((t-.42)/.22*Math.PI):0;
return {distance,impact,kick:reduced?0:Math.sin(Math.max(0,t-.42)/.22*Math.PI*2)*impact};
const impact=t>=.42&&t<.72?Math.sin((t-.42)/.30*Math.PI):0;
return {distance,impact,kick:reduced?0:Math.sin(Math.max(0,t-.42)/.58*Math.PI)*Math.exp(-Math.max(0,t-.42)*2)};
}
// Stop the drawn silhouette at first contact, avoiding visible overlap from collider tolerance.
export function contactPath(points,blocker,radius){
+32 -1
View File
@@ -175,6 +175,37 @@ export function solveRotations(level, maxStates = 20000) {
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 };
}
// Count the paths that successful pieces really travel in the reference plan.
// Decorative bends in unused cells do not count as route-reading practice.
export function solutionPathUsage(level, actions) {
let board = level, remaining = [...level.tiles], straightRun = 0;
const paths = [];
for (const action of actions) {
if (action.type === 'rotate') { board = rotateBoard(board, action.direction); continue; }
const tile = remaining.find(t => t.id === action.id);
if (!tile) continue;
const route = trace(board, tile, remaining);
if (route.status !== 'exit') continue;
const bends = route.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length;
straightRun = bends ? 0 : straightRun + 1;
paths.push({ id: tile.id, bends, cells: route.steps.length, straightRun });
remaining = remaining.filter(t => t.id !== tile.id);
}
const curvedRemovals = paths.filter(p => p.bends > 0).length;
return {
removals: paths.length,
curvedRemovals,
straightRemovals: paths.length - curvedRemovals,
curvedRatio: paths.length ? curvedRemovals / paths.length : 0,
singleBendRemovals: paths.filter(p => p.bends === 1).length,
multiBendRemovals: paths.filter(p => p.bends > 1).length,
totalBends: paths.reduce((sum, p) => sum + p.bends, 0),
maxBends: Math.max(0, ...paths.map(p => p.bends)),
maxConsecutiveStraight: Math.max(0, ...paths.map(p => p.straightRun)),
paths: paths.map(({ straightRun, ...path }) => path),
};
}
export function analyze(level) {
const solution = solveRotations(level), n = level.tiles.length;
const routes = level.tiles.map(t => trace(level, t, level.tiles, true));
@@ -204,7 +235,7 @@ export function analyze(level) {
// 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 ? '烧脑' : '挑战' };
return { score, parts, meanTurns, maxTurns, free, freeRatio, sweptOnlyBlockers, dependencyRatio, workload, pathUsage: solutionPathUsage(level, solution.actions), 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);
+1 -1
View File
@@ -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:650,rotationMs:240,exitMsPerUnit:60,exitMinTravelMs:140,exitMaxTravelMs:800,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:1700,exitLongStartCells:2,exitLongFullCells:5});
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:400,rotationMs:240,exitMsPerUnit:120,exitRampMs:80,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);}
+13 -7
View File
@@ -5,13 +5,10 @@ import { routePoints } from './geometry.js';
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};
const rampMs=Math.min(MOTION.exitRampMs,distance*MOTION.exitMsPerUnit/3);
const travelMs=distance*MOTION.exitMsPerUnit+rampMs;
const duration=travelMs+MOTION.exitEffectMs;
return {duration,travelMs,distance,travelFraction:travelMs/duration};
}
// Zero endpoint velocity: a soft launch and a settled finish, without overshoot.
@@ -19,3 +16,12 @@ export function smoothMotion(value) {
const t=Math.max(0,Math.min(1,value));
return t*t*(3-2*t);
}
// Fixed-duration acceleration and braking; the middle travels at a shared speed.
export function travelProgress(progress,travelMs){
const t=Math.max(0,Math.min(1,progress))*travelMs;
const ramp=Math.min(MOTION.exitRampMs,travelMs/4),area=travelMs-ramp;
if(t<ramp)return t*t/(2*ramp*area);
if(t>travelMs-ramp)return 1-(travelMs-t)**2/(2*ramp*area);
return (t-ramp/2)/area;
}
+2 -1
View File
@@ -4,7 +4,8 @@ export function layout(cells,width,height) {
const raw=cells.map(c=>position(c.q,c.r));
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
const minY=Math.min(...raw.map(p=>p.y))-.866,maxY=Math.max(...raw.map(p=>p.y))+.866;
const size=Math.min((width-48)/(maxX-minX),(height-50)/(maxY-minY),47);
const cellLimit=Math.max(47,Math.min(76,Math.min(width,height)*.1));
const size=Math.min((width-48)/(maxX-minX),(height-50)/(maxY-minY),cellLimit);
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}};
}
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+6 -7
View File
@@ -1,4 +1,4 @@
import { exitTiming, smoothMotion } from './exit-animation.js';
import { exitTiming, smoothMotion, travelProgress } 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';
@@ -77,8 +77,7 @@ export function drawScene(p,level,tiles,view,state={}) {
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
let dy=0;
if(tile.id===blocker?.id){
dx+=Math.cos(impactAngle)*phase.kick*size*.1;dy+=Math.sin(impactAngle)*phase.kick*size*.1;
if(phase.impact>0){const ring=hexPoints(c.x+dx,c.y+dy,size*1.04);p.line([...ring,ring[0]],THEME.warning,2.5);}
dx+=Math.cos(impactAngle)*phase.kick*size*.32;dy+=Math.sin(impactAngle)*phase.kick*size*.32;
}
drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile));
}
@@ -89,11 +88,11 @@ export function drawScene(p,level,tiles,view,state={}) {
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);
drawTile(p,loc.x,loc.y,size,visualTile(route.tile),loc.angle,phase&&!state.reduced?1-phase.impact*.12: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);}
for(const offset of [-1.95,-1.2,1.2,1.95]){const a=impactAngle+offset,d=size*(.12+phase.impact*.16),length=size*.26*phase.impact;p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+length),y:y+Math.sin(a)*(d+length)}],THEME.track,Math.max(1.5,size*.055));}
}
}
}
@@ -121,9 +120,9 @@ export function exitGeometry(route,view){
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;
const timing=exitTiming(route),travel=timing.travelFraction;
if(progress<travel){
const at=pointAlong(points,smoothMotion(progress/travel));
const at=pointAlong(points,travelProgress(progress/travel,timing.travelMs));
drawTile(p,at.x,at.y,size,tile,at.angle);return;
}
const t=Math.min(1,(progress-travel)/(1-travel)),ease=smoothMotion(t);
+6 -6
View File
@@ -1,10 +1,10 @@
import { attachBoardViewport } from './board-viewport.js';
import { generateLaneLevel } from './campaign-generator.js';
import { createStudyLevel } from './study-levels.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 { Game, available, solveRotations, trace } 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';
@@ -13,8 +13,8 @@ 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});
const regularLevel=createStudyLevel(3);
let studyLevel=createStudyLevel(5);
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();};
@@ -72,7 +72,7 @@ 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;}
if(free.length){free.sort((a,b)=>trace(game.level,b,game.tiles).steps.length-trace(game.level,a,game.tiles).steps.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='演示已重置,请再次播放。';}
}
@@ -86,7 +86,7 @@ $('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} 枚棋子`);};
$('board-size').onchange=()=>{studyLevel=$('board-size').value==='regular'?regularLevel:createStudyLevel(Number($('board-size').value));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';
+7
View File
@@ -0,0 +1,7 @@
import { generateFlowLevel } from './campaign-generator.js';
// Curved, fully removable studies with a longer route ready on the first screen.
export function createStudyLevel(radius=5){
const presets={3:{seed:15,target:12},5:{seed:5,target:24},6:{seed:4,target:32}};
const {seed,target}=presets[radius];
return generateFlowLevel(seed,{radius,target,bendRate:.55,maxTurns:24,minBaseTurns:1,curvePreference:4});
}