feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+115 -44
View File
@@ -1,3 +1,4 @@
import { artDirection, directionTheme } from './art-directions.js';
import { attachBoardViewport } from './board-viewport.js';
import { TapQueue } from './tap-queue.js';
import { exitTiming } from './exit-animation.js';
@@ -6,7 +7,9 @@ import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.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_VERSIONS, campaignVersion } from './campaign-versions.js';
import { selectedCampaign, readCampaignProgress, writeCampaignProgress } from './campaign-progress.js';
import { CAMPAIGN_SELECTION_KEY, selectedCampaign, readCampaignProgress, writeCampaignProgress } from './campaign-progress.js';
import { campaignLaunch, campaignPage, levelIndexFromInput, productionPresentation, createAnalysisCache } from './campaign-browser.js';
import { readTutorialProgress, writeTutorialProgress, tutorialLaunch } from './tutorial-progress.js';
import { layout, hitCell } from './geometry.js';
import { TILE_ATLAS } from './sprites.js';
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
@@ -16,22 +19,29 @@ const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ct
let boardViewport;
let progressStorage;try{progressStorage=window.localStorage;}catch{}
let saveUnavailable=!progressStorage;
let campaign=selectedCampaign(progressStorage),LEVELS=campaign.levels;
const launch=campaignLaunch(location.search,CAMPAIGN_VERSIONS,selectedCampaign(progressStorage));
let campaign=launch.campaign,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 completed=new Set(saved.completed),current=launch.levelIndex??saved.current;
const sessionTutorialProgress=new Map();
let tutorialProgress=readTutorialProgress(progressStorage,campaign.id,campaign.tutorials?.length||0);
let tutorialIndex=tutorialLaunch(location.search,campaign.tutorials?.length||0,tutorialProgress,saved);
const cachedAnalysis=createAnalysisCache(analyze);
let mapChapter=Math.floor(current/campaign.chapterSize),lastSolution=null;
let game=new Game(tutorialIndex===null?LEVELS[current]:campaign.tutorials[tutorialIndex]),mode='play',trial=false,draft=null,editHistory=[],tool='tile',direction=1,color=0,selected=null,path=null,hint=null,animations=[],rotationAnimation=null,shake=null,toastTimer,view,width=0,height=0,frame=0;
let helpUsed=false;
let activeTheme=null;
const requestedDesign=artDirection(new URLSearchParams(location.search).get('design'));
document.body.dataset.design=requestedDesign.id;
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);
activeTheme=mode==='edit'||trial?null:directionTheme(requestedDesign,(requestedTheme||themeForScore(score)).id,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;
@@ -39,17 +49,28 @@ const metricLabels={bends:['平均弯道',22],longestRoute:['最多弯道',12],d
let chapters=campaign.chapters,chapterSize=campaign.chapterSize;
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function persist(){const progress={current,completed:[...completed]};sessionProgress.set(campaign.id,progress);saveUnavailable=!writeCampaignProgress(progressStorage,campaign,progress);if(saveUnavailable)toast('浏览器未允许保存,进度仅保留在当前页面');}
function persistTutorial(){sessionTutorialProgress.set(campaign.id,tutorialProgress);if(!writeTutorialProgress(progressStorage,campaign.id,campaign.tutorials.length,tutorialProgress))saveUnavailable=true;}
function activeTutorial(){return tutorialIndex!==null&&mode==='play'&&!trial?game.initialLevel.tutorial:null;}
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);
tutorialProgress=sessionTutorialProgress.get(campaign.id)||readTutorialProgress(progressStorage,campaign.id,campaign.tutorials?.length||0);
completed=new Set(progress.completed);current=progress.current;
try{progressStorage?.setItem(CAMPAIGN_SELECTION_KEY,campaign.id);}catch{saveUnavailable=true;}
const nextTutorial=tutorialLaunch('',campaign.tutorials?.length||0,tutorialProgress,progress);
if(nextTutorial===null)startLevel(current);else startTutorial(nextTutorial);
}
function stateSolution(){
if(!lastSolution||lastSolution.level!==game.level||lastSolution.tiles!==game.tiles||lastSolution.moves!==game.moves)lastSolution={level:game.level,tiles:game.tiles,moves:game.moves,value:solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves})};
return lastSolution.value;
}
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('');
const page=campaignPage(campaign,Math.floor(current/chapterSize));
$('level-list').innerHTML=`<div class="chapter-title">${String(page.chapter+1).padStart(2,'0')} / ${escapeHtml(page.title)}</div><div class="level-grid">${page.levels.map(({level:l,index:i})=>`<button class="level-btn ${tutorialIndex===null&&i===current?'current':''} ${completed.has(i)?'done':''}" data-level="${i}" aria-label="第 ${i+1} 关 ${escapeHtml(productionPresentation(l)?.displayName||l.name)}${completed.has(i)?',已通关':''}" ${tutorialIndex===null&&i===current?'aria-current="true"':''}>${String(i+1).padStart(2,'0')}</button>`).join('')}</div>`;
$('progress-label').textContent=`${completed.size} / ${LEVELS.length}`;$('campaign-progress').style.width=`${completed.size/LEVELS.length*100}%`;
}
function rotationHint(direction){
@@ -57,21 +78,29 @@ function rotationHint(direction){
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/chapterSize)+1} 章 / ${level.chapterName||chapters[Math.floor(current/chapterSize)]}`;
document.body.dataset.campaign=campaign.id;
const teaching=activeTutorial();
canvas.setAttribute('aria-label',teaching&&teaching.step<3?'教学轨道棋盘,点击棋子沿弯路离场':'六角轨道棋盘,点击棋子沿轨道离场,拖动边缘旋转底盘');
const editing=mode==='edit',level=editing?draft:game.initialLevel,production=!editing&&!trial?productionPresentation(level):null;
const a=production?null:editing?analyze(level):cachedAnalysis(level);
applyTheme(production?production.themeScore:a.score);
$('level-name').textContent=production?.displayName||level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':teaching?`入门 ${teaching.step} / ${campaign.tutorials.length}`:`LEVEL ${String(current+1).padStart(2,'0')}`;
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':teaching?`入门序章 / ${teaching.title}`:`第 ${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=`观察难度 ${a.score}/100 · ${level.tiles.length} 枚棋子 · 约 ${a.workload.estimatedSeconds} 秒体验(估算,无计时限制)`;
$('undo-button').disabled=game.history.length===0||!!animation||!!rotationAnimation;
if(production){
$('difficulty-label').textContent=`${production.label} · 预估`;$('score-value').textContent=`D${production.tier}`;$('score-name').textContent=production.label;$('score-bars').innerHTML='';$('score-summary').textContent=`${production.estimate} · ${level.tiles.length} 枚棋子${production.reward?' · '+production.reward:''}`;
}else{
$('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||animations.length>0||!!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.lost||!canRotate(game.level)||game.level.rotationEnabled===false;
$('hint-button').disabled=!!animation||!!rotationAnimation||game.won;
for(const id of ['rotate-left','rotate-right'])$(id).disabled=animations.length>0||!!rotationAnimation||game.won||game.lost||!canRotate(game.level)||game.level.rotationEnabled===false;
$('hint-button').disabled=animations.length>0||!!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?(!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});
if(!editing&&!animations.length&&!rotationAnimation&&!game.won&&game.moves>0&&!available(game.level,game.tiles).length){
const solution=stateSolution();
const action=solution.actions[0];
if(solution.solvable&&action?.type==='rotate'){
const right=action.direction===1;
@@ -86,24 +115,35 @@ function draw(now=performance.now()){
if(!view)return;
ctx.clearRect(0,0,width,height);
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();}}
const activeCount=animations.length;
state.animations=animations.map(a=>{
const progress=Math.min(1,(now-a.start)/a.duration);
if(a.route.status==='blocked'&&!a.sounded&&progress>=.42){a.sounded=true;playSound('hit');}
return {route:a.route,progress};
});
animations=animations.filter(a=>now-a.start<a.duration);
if(animations.length!==activeCount)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){
if(!rotationAnimation&&mode==='play'&&!$('info-dialog').open){
const next=taps.take(game.tiles);
if(next&&mode==='play'&&!$('info-dialog').open){playTile(next);return;}
if(next){playTile(next);return;}
finish();
}
if(animation||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
if(animations.length||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
}
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){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 resetEffects(){taps.clear();if($('result').open)$('result').close();clearTimeout(toastTimer);$('board-toast').classList.remove('visible');$('board-toast').textContent='';animations=[];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){if(!Number.isInteger(i)||i<0||i>=LEVELS.length)return;boardViewport?.camera.reset();tutorialIndex=null;current=i;mapChapter=Math.floor(i/chapterSize);trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();showLesson();}
function startTutorial(i){
if(!Number.isInteger(i)||!campaign.tutorials?.[i])return;
boardViewport?.camera.reset();tutorialIndex=i;tutorialProgress={...tutorialProgress,current:i};trial=false;mode='play';game=new Game(campaign.tutorials[i]);helpUsed=false;resetEffects();applyMode();persistTutorial();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=[];}
if(!draft){draft=clone(activeTutorial()?LEVELS[current]: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=String(Math.max(...draft.cells.map(c=>Math.max(Math.abs(c.q),Math.abs(c.r),Math.abs(c.q+c.r)))));}
@@ -113,8 +153,15 @@ function openResult(){ $('result').hidden=false;$('result').showModal();playSoun
$('result').addEventListener('cancel',e=>e.preventDefault());
function finish(){
if($('result').open)return;
if(mode==='edit'||animation||rotationAnimation)return;
if(mode==='edit'||animations.length||rotationAnimation||$('info-dialog').open)return;
if(game.won){
const teaching=activeTutorial();
if(teaching){
tutorialProgress={current:Math.min(tutorialIndex+1,campaign.tutorials.length-1),completed:[...new Set([...tutorialProgress.completed,tutorialIndex])]};persistTutorial();
const last=tutorialIndex===campaign.tutorials.length-1;
$('result').innerHTML=`<div class="result-emblem">${icon('star')}${icon('star')}${icon('star')}</div><div class="eyebrow">入门 ${teaching.step} / ${campaign.tutorials.length}</div><h3>${escapeHtml(teaching.title)}</h3><p>${escapeHtml(teaching.completion)}</p><button class="primary" id="next-level">${last?'开始第 1 关':'继续下一课'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再试一次</button>`;
openResult();$('next-level').onclick=()=>last?startLevel(0):startTutorial(tutorialIndex+1);$('replay-level').onclick=restart;return;
}
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} 枚棋子全部找到出口<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;
@@ -123,25 +170,26 @@ function 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 undo(){if(animations.length||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
function playTile(tile){
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(rotationAnimation){if(taps.add(tile.id))draw();return;}
if(animations.some(a=>a.route.tile.id===tile.id)||!game.tiles.some(t=>t.id===tile.id))return;
clearTimeout(toastTimer);$('board-toast').classList.remove('visible');
path=null;hint=null;shake=null;
for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');
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};
playSound('exit');animations.push({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=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()};
path=null;
if(route.travelPoints){animations.push({route,start:performance.now(),duration:reduced?150:MOTION.collisionMs});}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();
renderStats();draw();if(!animations.length)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);}}
@@ -161,7 +209,7 @@ function editCell(cell){
changed();
}
function rotateGame(direction) {
if(animation||rotationAnimation||mode==='edit')return;
if(animations.length||rotationAnimation||mode==='edit')return;
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');
@@ -185,9 +233,9 @@ $('level-list').onclick=e=>{const b=e.target.closest('[data-level]');if(b)startL
$('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;
if(animations.length||rotationAnimation)return;
resetEffects();helpUsed=true;
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
const solution=stateSolution();
if(!solution.reachable){toast(solution.exhausted?'路线较复杂,暂未找到提示':'当前关卡无法避开所有碰撞,请撤销或重新编辑');return;}
if(!solution.solvable){toast(`还有 ${solution.minimumMoves} 枚棋子,需要在工坊增加离场额度`);return;}
const action=solution.actions[0];if(!action)return;
@@ -217,21 +265,44 @@ $('export-button').onclick=()=>{const errors=validateLevel(draft);if(errors.leng
$('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;decorateUI();if(!$('info-dialog').open)$('info-dialog').showModal();}
$('info-dialog').addEventListener('close',()=>draw());
$('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>`);}
function showScore(){const level=mode==='edit'?draft:game.initialLevel,production=mode==='play'&&!trial?productionPresentation(level):null;
if(activeTutorial()){showDialog(`<div class="eyebrow">入门序章</div><h2>${escapeHtml(level.tutorial.title)}</h2><p>${escapeHtml(level.lesson)}</p><p>让所有棋子沿轨道离场,就完成这一课。没有倒计时,点错了也可以继续尝试。</p>`);return;}
if(production){showDialog(`<div class="eyebrow">OBSERVE & ENJOY</div><h2>${escapeHtml(production.experience)}</h2><p><b>${escapeHtml(production.label)} · 结构预估</b></p><p>${escapeHtml(production.intent)}</p><p>${level.tiles.length} 枚棋子${production.reward?' · '+escapeHtml(production.reward):''}。路线数据来自完整清盘回放,实际游玩路线会随操作选择变化。</p><p class="dialog-note">难度依据放行关系与换路要求估计,尚待真人试玩校准。长路和转弯描述观看内容,不直接增加难度。旋转与试探免费,没有倒计时。</p><p><a href="labs/campaign-review/index.html?level=${current+1}">查看这关的制作记录</a></p>`);return;}
const a=mode==='edit'?analyze(level):cachedAnalysis(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()?'开':'关'}`;}
const pick=e.target.closest('[data-pick]');if(pick){$('info-dialog').close();startLevel(Number(pick.dataset.pick));}
const teaching=e.target.closest('[data-tutorial-pick]');if(teaching){$('info-dialog').close();startTutorial(Number(teaching.dataset.tutorialPick));}
});
$('dialog-content').addEventListener('change',e=>{
if(e.target.id==='campaign-version'){switchCampaign(e.target.value);showMapDialog();$('campaign-version').focus();}
if(e.target.id==='campaign-chapter'){mapChapter=Number(e.target.value);showMapDialog();$('campaign-chapter').focus();}
});
$('dialog-content').addEventListener('click',e=>{
const page=e.target.closest('[data-chapter-step]');if(page){mapChapter+=Number(page.dataset.chapterStep);showMapDialog();}
if(e.target.closest('[data-current-chapter]')){mapChapter=Math.floor(current/chapterSize);showMapDialog();}
});
$('dialog-content').addEventListener('submit',e=>{
if(e.target.id!=='campaign-jump')return;e.preventDefault();
const index=levelIndexFromInput($('campaign-level-number').value,LEVELS.length);
if(index===null){$('campaign-jump-feedback').textContent=`请输入 1 至 ${LEVELS.length} 的关卡编号`;return;}
$('info-dialog').close();startLevel(index);
});
$('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;
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>`);
function tutorialChoices(){
if(!campaign.tutorials?.length)return '';
return `<div class="chapter-title">入门序章</div><div class="level-dialog-grid">${campaign.tutorials.map((level,i)=>`<button type="button" class="level-btn ${tutorialIndex===i?'current':''} ${tutorialProgress.completed.includes(i)?'done':''}" data-tutorial-pick="${i}" aria-label="教学 ${i+1}:${escapeHtml(level.tutorial.title)}" ${tutorialIndex===i?'aria-current="true"':''}>${String(i+1).padStart(2,'0')}<small>${escapeHtml(level.tutorial.title)}</small></button>`).join('')}</div>`;
}
$('map-button').onclick=showMapDialog;
function showMapDialog(){
const page=campaignPage(campaign,mapChapter);mapChapter=page.chapter;
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">${escapeHtml(campaign.description)}<span class="campaign-progress-note"><strong>${campaign.id} · 已完成 ${completed.size} / ${LEVELS.length} 关</strong> · ${chapters.length} 章</span></p>${tutorialChoices()}<label class="field">选择章节<select id="campaign-chapter">${chapters.map((name,i)=>`<option value="${i}" ${i===page.chapter?'selected':''}>第 ${i+1} 章 · ${escapeHtml(name)} · ${i*chapterSize+1}–${Math.min((i+1)*chapterSize,LEVELS.length)} 关</option>`).join('')}</select></label><form id="campaign-jump" class="campaign-jump"><label class="field">直接前往<input id="campaign-level-number" type="number" inputmode="numeric" min="1" max="${LEVELS.length}" step="1" required placeholder="1–${LEVELS.length}" aria-describedby="campaign-jump-feedback"></label><button type="submit">前往</button><button type="button" data-current-chapter>当前章节</button><span id="campaign-jump-feedback" role="status"></span></form><div class="campaign-page-controls"><button data-chapter-step="-1" ${page.chapter===0?'disabled':''}>上一章</button><span>${page.chapter+1} / ${page.count}</span><button data-chapter-step="1" ${page.chapter===page.count-1?'disabled':''}>下一章</button></div><div class="level-dialog-grid">${page.levels.map(({level:l,index:i})=>`<button class="level-btn ${tutorialIndex===null&&i===current?'current':''} ${completed.has(i)?'done':''}" data-pick="${i}" aria-label="${campaign.id} 第 ${i+1} 关 ${escapeHtml(productionPresentation(l)?.displayName||l.name)},${escapeHtml(l.chapterName||page.title)}${completed.has(i)?',已完成':''}" ${tutorialIndex===null&&i===current?'aria-current="true"':''}>${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${escapeHtml(productionPresentation(l)?.label||(l.experience?l.beat:cachedAnalysis(l).label))}</small></button>`).join('')}</div><p>${saveUnavailable?'浏览器未允许保存:各版本进度仅在本页临时保留,刷新后会丢失。':'各版本分别保存通关进度与上次选关。'}切换版本会重新开始该版本上次选择的关卡。</p>${campaign.id==='v0.5'?`<p><a href="labs/campaign-review/index.html?level=${current+1}">浏览千关制作记录</a></p>`:''}`);
}
$('map-button').onclick=()=>{mapChapter=Math.floor(current/chapterSize);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();
+72
View File
@@ -0,0 +1,72 @@
import { LEVEL_THEMES, PIGMENTS, contrast, themeWithColors } from './design-tokens.js';
const makePigments=(colors,labels)=>colors.map((color,i)=>({name:labels?.[i]||PIGMENTS[i].name,color,arrow:contrast('#FFFFFF',color)>=4.5?'#FFFFFF':'#172D39'}));
const names=['轻松','进阶','烧脑','挑战','每周挑战'];
const themes=(backgrounds,titles,materials,palettes,descriptions)=>backgrounds.map((background,i)=>({
...LEVEL_THEMES[i],name:titles[i],background,ink:contrast('#12202A',background)>=4.5?'#12202A':'#FFFFFF',
description:descriptions?.[i]||`${names[i]}主题 · ${titles[i]}。`,materials:Array.isArray(materials)?materials[i]:materials,
palette:palettes?.[i]||LEVEL_THEMES[i].palette,
}));
export const ART_DIRECTIONS=Object.freeze([
{id:'calm',code:'01',name:'柔和原色',en:'QUIET TRAILS',tag:'现有方案',style:'classic',accent:'#6652B8',paper:'#F7F6F2',
description:'低饱和环境与平面六角棋子,让注意力留在轨道和方向。',
principles:['哑光色块,克制装饰','蓝灰细轨道,保持视觉留白','金色奖励,柔和浮现'],
themes:LEVEL_THEMES,pigments:PIGMENTS},
{id:'vivid',code:'02',name:'高彩游乐场',en:'CHROMA CLUB',tag:'高饱和',style:'vivid',accent:'#4A24E8',paper:'#FFFCF0',
description:'鲜明撞色、粗线条与贴纸式棋子。像一张能玩的彩色海报,明快、有力量。',
principles:['高纯度主色,深色轮廓统一画面','轨道像印刷线,棋子像立起的贴纸','方圆按钮与错位硬阴影'],
themes:themes(
['#B8F34A','#FF771B','#3E42EE','#FF3588','#1B104D'],
['青柠派对','爆橙热浪','钴蓝电场','荧粉冲刺','黑光嘉年华'],
[
{board:'#FFFBE7',boardGrid:'#AB9C82',track:'#30264C',trackHalo:'#FFFBE7'},
{board:'#FFEF45',boardGrid:'#B78335',track:'#462654',trackHalo:'#FFEF45'},
{board:'#C9F849',boardGrid:'#789344',track:'#252269',trackHalo:'#C9F849'},
{board:'#FFF4CE',boardGrid:'#C491A1',track:'#502369',trackHalo:'#FFF4CE'},
{board:'#35225D',boardGrid:'#837B9F',track:'#D9FF55',trackHalo:'#35225D'},
],
[[0,4,2,1,3,5,6],[7,3,10,8,2,0,11],[2,9,1,10,6,5,8],[0,6,10,2,7,4,9],[10,2,6,1,9,11,5]],
['保留青柠大色块,深色轮廓与蓝、青、黄色贴纸。','爆橙背景与亮黄棋盘,钴蓝、红紫和电青正面撞色。','纯钴蓝包围荧光青柠棋盘,用暖橙与亮粉制造冷暖冲撞。','荧光粉舞台,明黄与蓝绿棋子形成强烈互补。','深紫黑底配荧光绿轨道,电青、柠黄与荧粉像黑光贴纸。']),
pigments:makePigments(['#2455E8','#E52879','#FFE13E','#7136D8','#007C70','#FF622C','#BBE929','#074DFF','#EB174E','#FF9500','#08E8E1','#A92BFF'],['电蓝','热粉','柠檬黄','电紫','深青','火橙','荧光绿','钴蓝','警示红','亮橘','电光青','激光紫'])},
{id:'toy',code:'03',name:'积木轨道屋',en:'LITTLE PLAYWORKS',tag:'玩具质感',style:'toy',accent:'#245EAF',paper:'#FFFAEE',
description:'圆润积木、原色塑胶与压下去有回弹的按钮。棋子像可以拿在手里的小零件。',
principles:['同色深浅叠层,形成柔软厚度','红黄蓝绿积木配木色底板','圆角控件、凸起边缘与轻微高光'],
themes:themes(
['#F4C548','#56AFE0','#EA743E','#439A73','#244584'],
['积木黄盒','蓝色装配台','橘红工坊','绿毯小城','太空玩具柜'],
[
{board:'#FFF7DE',boardGrid:'#B5A184',track:'#765238',trackHalo:'#FFF7DE'},
{board:'#F5DFB6',boardGrid:'#B09C7C',track:'#386A8D',trackHalo:'#F5DFB6'},
{board:'#FFEFCB',boardGrid:'#BBA27B',track:'#695037',trackHalo:'#FFEFCB'},
{board:'#F2D6A6',boardGrid:'#AC987A',track:'#476556',trackHalo:'#F2D6A6'},
{board:'#D4E4E6',boardGrid:'#8DA4B2',track:'#35516F',trackHalo:'#D4E4E6'},
],
[[0,2,3,5,4,8,10],[1,0,4,3,9,6,11],[2,3,1,7,10,5,8],[0,1,2,4,6,9,11],[1,4,0,6,10,3,8]],
['经典红黄蓝积木,放在暖黄玩具盒里;用奶油底板隔开主色。','亮蓝工作台配原木色插板,红黄橙零件像一套拼装工具。','橘红桌面与木糖底板,绿蓝零件形成互补。','绿色游戏垫与木色道路,红黄积木像散落的小房子。','太空蓝收藏柜配浅灰塑胶板,黄橙零件像小飞船。']),
pigments:makePigments(['#DF4033','#F2C52E','#2478C5','#32945C','#EF862D','#7652B2','#EFE4C4','#227B87','#B63845','#745B40','#73BFDC','#A7BA56'],['积木红','积木黄','积木蓝','积木绿','零件橙','拼装紫','奶油白','工具青','砖红','木棕','玻璃蓝','草绿'])},
{id:'animals',code:'04',name:'动物弯弯镇',en:'PAW PARADE',tag:'卡通动物',style:'animals',accent:'#396D51',paper:'#FFFCF1',
description:'猫咪、小熊、兔子与青蛙沿轨道出游。用表情与耳朵增加性格,方向箭头独立保留。',
principles:['圆脸、短耳与小表情,外形保持在六角范围内','箭头留在脸部下方,不靠动物朝向猜方向','草地、木棕与奶油毛色,搭配森林小径'],
themes:themes(
['#A9CD69','#E7B947','#72BEB2','#C67B47','#183F36'],
['青草野餐','橡果秋游','溪边捉迷藏','红土探险','萤火森林'],
[
{board:'#FFF2D2',boardGrid:'#A9AC80',track:'#527345',trackHalo:'#FFF2D2'},
{board:'#FFEDB5',boardGrid:'#B6A076',track:'#79572E',trackHalo:'#FFEDB5'},
{board:'#ECF1D8',boardGrid:'#9CA987',track:'#3B7262',trackHalo:'#ECF1D8'},
{board:'#F6DCB2',boardGrid:'#B19169',track:'#684835',trackHalo:'#F6DCB2'},
{board:'#355C43',boardGrid:'#879969',track:'#E6E9AE',trackHalo:'#355C43'},
],
[[0,1,2,3,6,7,10],[1,4,8,5,2,6,11],[7,3,6,10,0,4,9],[8,5,4,2,7,10,1],[6,10,2,4,11,0,9]],
['草地绿、奶油白与动物毛色,不使用糖果蓝紫。','秋叶黄配橡果棕、麦芽金和浅奶油,像一次林间秋游。','溪水青配苔绿与暖棕,保留自然界的冷暖关系。','红土与沙色小径,棕熊、奶油兔和绿蛙像野外探险队。','深森林绿配萤火浅黄,浅毛色角色从夜色中浮现。']),
pigments:makePigments(['#D9934F','#A4683B','#E1C58B','#6C9D40','#F1B869','#73503F','#F1DCC1','#448A67','#B78157','#BD784F','#F4E5CF','#87B76B'],['姜黄猫','栗棕熊','麦芽兔','苔绿蛙','奶橘猫','可可熊','奶油兔','溪绿蛙','榛果猫','赤陶熊','雪白兔','嫩叶蛙'])},
]);
export function artDirection(id){return ART_DIRECTIONS.find(d=>d.id===id)||ART_DIRECTIONS[0];}
export function directionTheme(direction,themeId,count=7){
const source=direction.themes.find(t=>t.id===themeId)||direction.themes[0];
return {...themeWithColors(source,count),artStyle:direction.style,pigments:direction.pigments};
}
export function appearanceTile(tile,theme){
const color=theme?.pigments?.[tile.color];
return {...tile,artStyle:theme?.artStyle,fill:color?.color,arrow:color?.arrow};
}
+65
View File
@@ -0,0 +1,65 @@
// Chapter navigation is independent of storage, the DOM, and puzzle solving.
export function campaignLaunch(search, campaigns, fallback) {
const params=new URLSearchParams(search);
const campaign=campaigns.find(item=>item.id===params.get('campaign'))||fallback;
const raw=params.get('level');
const number=raw&&/^\d+$/.test(raw)?Number(raw):NaN;
return {campaign,levelIndex:Number.isSafeInteger(number)&&number>=1&&number<=campaign.levels.length?number-1:null};
}
export function campaignPage(campaign, requestedChapter=0) {
const count=Math.ceil(campaign.levels.length/campaign.chapterSize);
const chapter=Math.max(0,Math.min(count-1,Number.isInteger(requestedChapter)?requestedChapter:0));
const start=chapter*campaign.chapterSize,end=Math.min(start+campaign.chapterSize,campaign.levels.length);
return {chapter,count,start,end,title:campaign.chapters[chapter]||`第 ${chapter+1} 章`,
levels:campaign.levels.slice(start,end).map((level,index)=>({level,index:start+index}))};
}
export function levelIndexFromInput(value, count) {
const raw=String(value).trim();
if(!/^\d+$/.test(raw))return null;
const number=Number(raw);
return Number.isSafeInteger(number)&&number>=1&&number<=count?number-1:null;
}
export function productionPresentation(level) {
const production=level.production;
if(!production?.difficulty)return null;
const difficulty=production.difficulty,reward=production.reward||{};
const tier=Math.max(1,Math.min(5,Number(difficulty.tier)||1));
const label=difficulty.label||['轻松','舒展','进阶','挑战','深度观察'][tier-1];
const rewardParts=[];
if(Number.isFinite(reward.longestSteps))rewardParts.push(`最长巡游 ${reward.longestSteps} 格`);
if(Number.isFinite(reward.longestBends))rewardParts.push(`${reward.longestBends} 次转弯`);
if(Number.isFinite(reward.longRoutes))rewardParts.push(`${reward.longRoutes} 条长路`);
return {displayName:String(level.name||'未命名轨迹').replace(/ · 路线草图/g,''),tier,label,themeScore:[12,32,48,62,78][tier-1],
experience:level.beat||level.experience?.kind||'沿轨巡游',
reward:rewardParts.join(' · '),intent:production.intent||level.lesson||'',
estimate:'结构预估,尚待真人试玩校准'};
}
export function createAnalysisCache(analyze) {
const cache=new WeakMap();
return level=>{
if(!cache.has(level))cache.set(level,analyze(level));
return cache.get(level);
};
}
const emphasisCache=new WeakMap();
export function featuredTrackEmphasis(level, turns=0) {
if(!level.routeDesign?.featured?.length)return null;
const rotation=((turns%6)+6)%6;
if(!emphasisCache.has(level))emphasisCache.set(level,new Map());
const cache=emphasisCache.get(level);
if(!cache.has(rotation)){
const pairs=new Set(level.routeDesign.featured.map(pair=>{
let {q,r}=pair;
for(let i=0;i<rotation;i++)[q,r]=[-r||0,q+r||0];
const a=(pair.a+rotation)%6,b=(pair.b+rotation)%6;
return `${q},${r}:${Math.min(a,b)}-${Math.max(a,b)}`;
}));
cache.set(rotation,(cell,a,b)=>pairs.has(`${cell.q},${cell.r}:${Math.min(a,b)}-${Math.max(a,b)}`));
}
return cache.get(rotation);
}
+295
View File
@@ -0,0 +1,295 @@
import { Game, canRotate, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
import { countBends, pointsInward } from './experience-generator.js';
// These are repeatable structural observations, not player difficulty or enjoyment
// measurements. Six policies sample sensible choices; they do not enumerate play.
export const CAMPAIGN_EVIDENCE_VERSION = 2;
const LONG_BENDS = 6;
const POLICY_NAMES = ['outer-first', 'short-first', 'long-first', 'blocker-first', 'rotate-early', 'seeded-random'];
const radius = tile => Math.max(Math.abs(tile.q), Math.abs(tile.r), Math.abs(tile.q + tile.r));
const rounded = value => Math.round(value * 1000) / 1000;
const fraction = (n, total) => total ? rounded(n / total) : null;
function hashSeed(text) {
let hash = 2166136261;
for (const character of text) hash = Math.imul(hash ^ character.charCodeAt(0), 16777619);
return hash >>> 0;
}
// Keep the pivot fixed: translating only the artwork can change rotation legality.
// A reflection maps d to -d; a clockwise whole-level turn then adds one to d.
export function canonicalLevelSignature(level) {
const variants = [];
for (const reflected of [false, true]) for (let turns = 0; turns < 6; turns++) {
const direction = d => ((reflected ? -d : d) + turns + 6) % 6;
const coordinate = source => {
let q = reflected ? -source.q : source.q;
let r = reflected ? source.q + source.r : source.r;
for (let i = 0; i < turns; i++) [q, r] = [-r, q + r];
return [q || 0, r || 0];
};
const cells = level.cells.map(cell => {
const tracks = [];
cell.tracks.forEach((partner, port) => { tracks[direction(port)] = direction(partner); });
return [...coordinate(cell), ...tracks];
}).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const tiles = level.tiles.map(tile => [...coordinate(tile), direction(tile.dir)])
.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
// Spare moves have no gameplay effect; insufficient removal allowance does.
variants.push(JSON.stringify([level.version, level.rotationEnabled !== false,
Math.min(level.moveLimit, level.tiles.length), cells, tiles]));
}
return variants.sort()[0];
}
function createContext(level) {
const boards = Array.from({ length: 6 }, (_, turn) => rotateBoard(level, turn));
const indices = new Map(level.tiles.map((tile, index) => [tile.id, index]));
const cache = new Map();
const mask = tiles => tiles.reduce((value, tile) => value | (1n << BigInt(indices.get(tile.id))), 0n).toString(36);
function routes(orientation, tiles) {
const state = `${orientation}:${mask(tiles)}`;
if (!cache.has(state)) {
cache.set(state, tiles.map(tile => ({ tile, route: trace(boards[orientation], tile, tiles) })));
}
return cache.get(state);
}
return { boards, mask, routes, rotating: canRotate(level) && level.rotationEnabled !== false };
}
function createReplay(level, context) {
const game = new Game(level);
const paths = [], reveals = [], unlocks = [], turns = [], hidden = [], actions = [];
const depth = new Map(), initial = context.routes(0, game.tiles);
for (const { tile, route } of initial) if (route.status === 'exit') depth.set(tile.id, 0);
let dry = 0, maxDryRotations = 0, stage = 0, maxUnlockDepth = 0, error = null;
function execute(action) {
const index = actions.length;
const before = context.routes(game.orientation, game.tiles);
const previousTiles = game.tiles;
for (const { tile, route } of before) {
if (route.status === 'blocked' && !trace(game.level, tile, game.tiles, true).blockers.includes(route.blocker)) {
hidden.push({ action: index, id: tile.id, blocker: route.blocker });
}
}
if (!action || (action.type !== 'tap' && action.type !== 'rotate')) {
error = { action: index, status: 'invalid-action' };
return false;
}
const result = action.type === 'rotate' ? game.rotate(action.direction) : game.tap(action.id);
if (result.status !== (action.type === 'rotate' ? 'rotated' : 'exit')) {
error = { action: index, status: result.status };
return false;
}
actions.push(action.type === 'rotate' ? { type: 'rotate', direction: action.direction } : { type: 'tap', id: action.id });
const after = context.routes(game.orientation, game.tiles);
const afterById = new Map(after.map(entry => [entry.tile.id, entry.route]));
if (action.type === 'rotate') {
dry++;
maxDryRotations = Math.max(maxDryRotations, dry);
stage++;
const opened = [];
for (const { tile, route } of before) {
const next = afterById.get(tile.id);
if (route.status !== 'exit' && next?.status === 'exit') {
opened.push(tile.id);
depth.set(tile.id, 0);
if (pointsInward(tile)) reveals.push({ action: index, id: tile.id, direction: action.direction, orientation: game.orientation,
before: route.status, bends: countBends(next), cells: next.steps.length });
}
}
turns.push({ action: index, direction: action.direction, remaining: game.tiles.length,
opened, free: after.filter(entry => entry.route.status === 'exit').map(entry => entry.tile.id) });
} else {
dry = 0;
paths.push({ id: action.id, action: index, stage, orientation: game.orientation,
bends: countBends(result), cells: result.steps.length });
const nextDepth = (depth.get(action.id) || 0) + 1;
for (const { tile, route } of before) {
const next = afterById.get(tile.id);
if (route.status === 'blocked' && next?.status === 'exit') {
depth.set(tile.id, nextDepth);
maxUnlockDepth = Math.max(maxUnlockDepth, nextDepth);
unlocks.push({ action: index, removed: action.id, id: tile.id, orientation: game.orientation, bends: countBends(next), cells: next.steps.length,
onRoute: trace(game.level, tile, previousTiles, true).blockers.includes(action.id) });
}
}
}
return true;
}
function summary(name, stopped = null) {
const fulfilled = events => events.filter(event => paths.some(path => path.id === event.id &&
path.action > event.action && path.orientation === event.orientation && path.cells === event.cells && path.bends === event.bends));
return { ...(name ? { name } : {}), won: game.won && !error, valid: !error, error,
stopped: error ? 'invalid-action' : game.won ? null : stopped || 'incomplete', actions,
paths, reveals, unlocks, fulfilledReveals: fulfilled(reveals), fulfilledUnlocks: fulfilled(unlocks),
turns, maxDryRotations, hidden, maxUnlockDepth,
maxBends: Math.max(0, ...paths.map(path => path.bends)),
longPaths: paths.filter(path => path.bends >= LONG_BENDS).length,
routeCells: paths.reduce((total, path) => total + path.cells, 0),
totalBends: paths.reduce((total, path) => total + path.bends, 0),
initialFree: initial.filter(entry => entry.route.status === 'exit').length,
remaining: game.tiles.map(tile => tile.id) };
}
return { game, execute, summary, actionCount: () => actions.length };
}
function policyReplay(level, context, name, seed) {
const replay = createReplay(level, context), { game } = replay;
const rng = random(seed), earlyConsidered = new Set();
const limit = level.tiles.length * 8 + 12;
let stopped = 'step-limit';
while (!game.won && replay.actionCount() < limit) {
const entries = context.routes(game.orientation, game.tiles);
const free = entries.filter(entry => entry.route.status === 'exit');
const occupancy = context.mask(game.tiles);
const randomPolicy = name.startsWith('seeded-random');
if (context.rotating && !earlyConsidered.has(occupancy) && (name === 'rotate-early' || (randomPolicy && rng() < .25))) {
earlyConsidered.add(occupancy);
const currentIds = new Set(free.map(entry => entry.tile.id));
const adjacent = [1, -1].map(direction => {
const candidates = context.routes((game.orientation + direction + 6) % 6, game.tiles)
.filter(entry => entry.route.status === 'exit');
return { direction, candidates, opened: candidates.filter(entry => !currentIds.has(entry.tile.id)).length };
}).filter(option => option.candidates.length && (randomPolicy || option.opened));
adjacent.sort((a, b) => b.opened - a.opened);
if (adjacent.length) {
if (!replay.execute({ type: 'rotate', direction: adjacent[randomPolicy ? Math.floor(rng() * adjacent.length) : 0].direction })) break;
continue;
}
}
if (!free.length) {
if (!context.rotating) { stopped = 'no-available-route'; break; }
let turn = null;
const preferred = randomPolicy && rng() < .5 ? -1 : 1;
for (let distance = 1; distance <= 3 && !turn; distance++) for (const direction of [preferred, -preferred]) {
const orientation = (game.orientation + distance * direction + 6) % 6;
if (context.routes(orientation, game.tiles).some(entry => entry.route.status === 'exit')) {
turn = { direction, distance };
break;
}
}
if (!turn) { stopped = 'no-available-orientation'; break; }
let valid = true;
for (let i = 0; i < turn.distance; i++) if (!replay.execute({ type: 'rotate', direction: turn.direction })) { valid = false; break; }
if (!valid) break;
// The required turn has already offered this occupancy its exploration.
earlyConsidered.add(occupancy);
continue;
}
const blockers = new Map();
for (const { route } of entries) if (route.status === 'blocked') blockers.set(route.blocker, (blockers.get(route.blocker) || 0) + 1);
const score = entry => {
if (name === 'outer-first') return radius(entry.tile);
if (name === 'short-first') return -entry.route.steps.length;
if (name === 'long-first') return countBends(entry.route) * 1000 + entry.route.steps.length;
if (name === 'blocker-first') return (blockers.get(entry.tile.id) || 0) * 1000 - entry.route.steps.length;
return radius(entry.tile);
};
free.sort((a, b) => score(b) - score(a) || a.tile.q - b.tile.q || a.tile.r - b.tile.r || a.tile.dir - b.tile.dir);
const chosen = free[randomPolicy ? Math.floor(rng() * free.length) : 0];
if (!replay.execute({ type: 'tap', id: chosen.tile.id })) break;
}
return replay.summary(name, stopped);
}
// Optional authored actions are audited as supplied. They never replace the core
// solver's independent solvability finding; inspect reference.won before publish.
export function assessLevel(level, { actions, policyRuns = 6 } = {}) {
const errors = validateLevel(level);
if (errors.length) return { schemaVersion: CAMPAIGN_EVIDENCE_VERSION, valid: false, errors,
solvable: false, exhausted: false, referenceActions: [], reference: null, policies: [],
difficulty: null, reward: null, workload: null };
if (!Number.isInteger(policyRuns) || policyRuns < 0 || policyRuns > 64) throw new RangeError('policyRuns must be an integer from 0 to 64');
const solution = solveRotations(level);
const context = createContext(level), replay = createReplay(level, context);
const referenceActions = actions ?? solution.actions;
for (const action of referenceActions) if (!replay.execute(action)) break;
const reference = replay.summary();
let solverReference = reference;
if (JSON.stringify(referenceActions) !== JSON.stringify(solution.actions)) {
const solverReplay = createReplay(level, context);
for (const action of solution.actions) if (!solverReplay.execute(action)) break;
solverReference = solverReplay.summary();
}
const signature = canonicalLevelSignature(level), seed = hashSeed(signature);
const policies = Array.from({ length: policyRuns }, (_, index) => policyReplay(level, context,
POLICY_NAMES[index] || `seeded-random-${index - 4}`, seed + index * 104729));
const initialFreeRatio = level.tiles.length ? reference.initialFree / level.tiles.length : 0;
const hiddenIds = new Set(solverReference.hidden.map(event => event.id));
const directionChanges = reference.turns.slice(1).filter((turn, i) => turn.direction !== reference.turns[i].direction).length;
const parts = {
requiredRotations: Math.min(4, solution.rotations) * 15,
releaseDepth: Math.min(4, solverReference.maxUnlockDepth) * 6,
openingConstraint: (1 - initialFreeRatio) * 12,
sweptBlockers: hiddenIds.size ? 8 + 10 * hiddenIds.size / Math.max(1, level.tiles.length) : 0,
};
const score = Math.round(Object.values(parts).reduce((sum, value) => sum + value, 0));
const tier = score < 12 ? 1 : score < 30 ? 2 : score < 48 ? 3 : score < 66 ? 4 : 5;
const heroId = level.experience?.feature?.id || level.experience?.primaryPiece || level.production?.primaryPiece ||
[...reference.paths].sort((a, b) => b.bends - a.bends || b.cells - a.cells)[0]?.id || null;
const hero = reference.paths.find(path => path.id === heroId) || null;
const referenceContrasts = new Set(reference.fulfilledReveals.map(event => event.id));
const longSeen = policies.filter(policy => policy.longPaths > 0).length;
const heroSeen = policies.filter(policy => policy.paths.some(path => path.id === heroId && path.bends >= LONG_BENDS)).length;
const contrastSeen = policies.filter(policy => policy.fulfilledReveals.some(event => referenceContrasts.has(event.id))).length;
const heroContrast = event => event.id === heroId && event.bends >= LONG_BENDS;
const heroGate = event => event.id === heroId && event.bends >= LONG_BENDS && event.onRoute;
const heroContrastSeen = policies.filter(policy => policy.fulfilledReveals.some(heroContrast)).length;
const heroGateSeen = policies.filter(policy => policy.fulfilledUnlocks.some(heroGate)).length;
return {
schemaVersion: CAMPAIGN_EVIDENCE_VERSION, valid: true, errors: [], solvable: solution.solvable,
exhausted: solution.exhausted, solverStates: solution.states, referenceActions, reference,
solverActions: solution.actions, solverReference, policies,
difficulty: { tier, label: ['轻松', '入门组合', '进阶', '挑战', '高挑战'][tier - 1],
basis: 'structural-estimate', playerCalibrated: false, score,
parts: Object.fromEntries(Object.entries(parts).map(([name, value]) => [name, rounded(value)])),
minimumRotations: solution.solvable ? solution.rotations : null,
releaseDepth: solverReference.maxUnlockDepth, initialFreeRatio: rounded(initialFreeRatio),
sweptBlockerPieces: hiddenIds.size, referenceDirectionChanges: directionChanges },
reward: { longThresholdBends: LONG_BENDS, thresholdBasis: 'diagnostic-band-not-quality-guarantee', heroId,
referenceHero: hero, referenceLongPaths: reference.longPaths, referenceMaxBends: reference.maxBends,
policyRuns: policies.length, wonRuns: policies.filter(policy => policy.won).length,
longExposure: { seen: longSeen, total: policies.length, rate: fraction(longSeen, policies.length) },
heroLongExposure: { seen: heroSeen, total: policies.length, rate: fraction(heroSeen, policies.length) },
referenceContrastIds: [...referenceContrasts],
contrastExposure: { seen: contrastSeen, total: policies.length,
rate: referenceContrasts.size ? fraction(contrastSeen, policies.length) : null, scope: 'any-reference-contrast-piece' },
// General contrast above can be another inward piece or a short route.
// These two fields require the designated hero to actually finish a long
// route after its own opening event. Gate exposure also requires the
// removed blocker to occupy a cell on that route, rather than a side graze.
heroContrastExposure: { seen: heroContrastSeen, total: policies.length, rate: fraction(heroContrastSeen, policies.length),
scope: 'hero-fulfilled-long-rotation', thresholdBends: LONG_BENDS,
referenceEligible: reference.fulfilledReveals.some(heroContrast) },
heroGateExposure: { seen: heroGateSeen, total: policies.length, rate: fraction(heroGateSeen, policies.length),
scope: 'hero-fulfilled-on-route-long-release', thresholdBends: LONG_BENDS,
referenceEligible: reference.fulfilledUnlocks.some(heroGate) },
contrastBypasses: referenceContrasts.size ? policies.filter(policy => policy.won && !policy.fulfilledReveals.some(event => referenceContrasts.has(event.id))).map(policy => policy.name) : [],
firstLongAction: reference.paths.find(path => path.bends >= LONG_BENDS)?.action ?? null,
policyMaxBendsRange: policies.length ? [Math.min(...policies.map(policy => policy.maxBends)), Math.max(...policies.map(policy => policy.maxBends))] : null,
coverage: 'bounded-policies-not-exhaustive-player-behavior' },
workload: { removals: level.tiles.length, minimumActions: solution.minimumActions,
referenceActions: reference.actions.length, referenceRotations: reference.turns.length,
routeCells: reference.routeCells, totalBends: reference.totalBends,
basis: 'counts-not-measured-duration', animationSeconds: null, playerSeconds: null },
};
}
// A deliberately coarse pacing key. Equal keys are a reason to inspect adjacent
// choices, never proof that geometrically different levels should be discarded.
export function experienceSignature(assessment) {
if (!assessment.valid || !assessment.reference) return 'invalid';
const { reference, difficulty, reward, workload } = assessment;
const mode = reference.turns.length ? reference.unlocks.length ? 'combined' : 'rotation' : reference.unlocks.length ? 'release' : 'direct';
const band = (value, cuts) => cuts.filter(cut => value >= cut).length;
const stages = [...new Set(reference.paths.map(path => path.stage))].map(stage => {
const paths = reference.paths.filter(path => path.stage === stage);
return [band(paths.length, [3, 7, 12]), band(Math.max(0, ...paths.map(path => path.bends)), [1, 6, 12, 20])];
});
return JSON.stringify([mode, difficulty.tier, band(difficulty.releaseDepth, [1, 3, 5]),
band(workload.removals, [5, 10, 16, 24]), band(reward.referenceMaxBends, [1, 6, 12, 20]),
Boolean(reference.reveals.length), stages]);
}
+15 -2
View File
@@ -1,5 +1,8 @@
import { CAMPAIGN, LEVELS } from './levels.js';
import { ARCHIVED_LEVELS } from './levels-archive.js';
import { EXPERIENCE_CAMPAIGN, EXPERIENCE_LEVELS } from './levels-v04.js';
import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from './levels-v05.js';
import { TUTORIAL_LEVELS } from './tutorial-levels.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.
@@ -15,12 +18,22 @@ export const CAMPAIGN_VERSIONS = [
levels:ARCHIVED_LEVELS['v0.2'], legacyStorageKeys:['hex-trails-observe-v3-r1','hex-trails-observe-v3'],
},
{
id:'v0.3', name:'弯轨观察版', description:'当前的 48 关,以沿弯轨观察出口为主,保留起伏节奏。',
id:'v0.3', name:'弯轨观察版', description:'48 关弯轨观察版本,以单弯与连续消除为主。',
chapters:CAMPAIGN.chapters, chapterSize:CAMPAIGN.chapterSize,
levels:LEVELS, legacyStorageKeys:[`hex-trails-${CAMPAIGN.id}-r${CAMPAIGN.revision||1}`],
},
{
id:'v0.4', name:'长路与惊喜', description:'20 关全新体验:看棋子沿长路巡游,转一下放出朝内棋子,再逐层解开整盘。',
chapters:EXPERIENCE_CAMPAIGN.chapters, chapterSize:EXPERIENCE_CAMPAIGN.chapterSize,
levels:EXPERIENCE_LEVELS, legacyStorageKeys:[],
},
{
id:'v0.5', name:'千段轨迹', description:'1000 关新旅程:沿长路巡游、清挡放行,转出花瓣与交织的出口。难度为结构预估,持续通过试玩打磨。',
chapters:PRODUCTION_CAMPAIGN.chapters, chapterSize:PRODUCTION_CAMPAIGN.chapterSize,
levels:PRODUCTION_LEVELS, tutorials:TUTORIAL_LEVELS, legacyStorageKeys:[],
},
];
export const DEFAULT_CAMPAIGN_VERSION = 'v0.3';
export const DEFAULT_CAMPAIGN_VERSION = 'v0.5';
export function campaignVersion(id) {
return CAMPAIGN_VERSIONS.find(c=>c.id===id)||CAMPAIGN_VERSIONS.find(c=>c.id===DEFAULT_CAMPAIGN_VERSION);
}
+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: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 MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:800,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);}
+26
View File
@@ -0,0 +1,26 @@
import { ART_DIRECTIONS, directionTheme, appearanceTile } from './art-directions.js';
import { drawTile, CanvasPainter } from './renderer.js';
const STORE='hex-trails-art-review-v1';
export function mountDirectionReview(current,onSelect){
let review={selected:[],notes:{}};
try{const saved=JSON.parse(localStorage.getItem(STORE));if(saved)review={selected:Array.isArray(saved.selected)?saved.selected.filter(id=>ART_DIRECTIONS.some(d=>d.id===id)):[],notes:saved.notes&&typeof saved.notes==='object'?saved.notes:{}};}catch{}
const picker=document.querySelector('#direction-picker'),detail=document.querySelector('#direction-detail'),quick=document.querySelector('#quick-design');
quick.innerHTML=ART_DIRECTIONS.map(d=>`<option value="${d.id}">${d.name}</option>`).join('');
quick.onchange=()=>select(ART_DIRECTIONS.find(d=>d.id===quick.value));
picker.innerHTML=ART_DIRECTIONS.map(d=>`<article class="direction-card" data-card="${d.id}"><button class="direction-switch" data-direction="${d.id}" aria-pressed="false"><canvas width="480" height="200" aria-hidden="true"></canvas><span class="direction-card-title"><span>${d.code} / ${d.name}</span><small>${d.tag}</small></span></button><label class="keep-design"><input type="checkbox" value="${d.id}"> 保留这套方案</label></article>`).join('');
function save(){try{localStorage.setItem(STORE,JSON.stringify(review));document.querySelector('#review-saved').textContent='审核记录已保存在此浏览器';}catch{document.querySelector('#review-saved').textContent='当前浏览器无法保存,请导出审核记录';}summary();}
function summary(){document.querySelector('#review-summary').textContent=review.selected.length?`已保留 ${review.selected.length} 套:${ART_DIRECTIONS.filter(d=>review.selected.includes(d.id)).map(d=>d.name).join('、')}`:'可保留多套设计,再分别记录调整意见。';}
function select(d){
current=d;quick.value=d.id;document.body.dataset.design=d.id;
picker.querySelectorAll('[data-direction]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.direction===d.id)));
detail.innerHTML=`<div><span class="eyebrow">${d.en} / ${d.code}</span><h2>${d.name}</h2><p>${d.description}</p><ul>${d.principles.map(p=>`<li>${p}</li>`).join('')}</ul><a class="sheet-link" href="design/directions/${d.id}.html" target="_blank" rel="noopener">查看这套完整设计稿 ↗</a></div><label class="review-note">这套方案的审核意见<textarea id="direction-note" rows="3" placeholder="例如:保留棋子造型,轨道再轻一些……"></textarea><small id="review-saved">意见与多选结果保存在此浏览器</small></label>`;
const note=detail.querySelector('textarea');note.value=typeof review.notes[d.id]==='string'?review.notes[d.id]:'';note.oninput=()=>{review.notes[d.id]=note.value;save();};
const url=new URL(location.href);url.searchParams.set('design',d.id);history.replaceState(null,'',url);
onSelect(d);
}
picker.querySelectorAll('[data-direction]').forEach(b=>b.onclick=()=>select(ART_DIRECTIONS.find(d=>d.id===b.dataset.direction)));
picker.querySelectorAll('input').forEach(input=>{input.checked=review.selected.includes(input.value);input.onchange=()=>{review.selected=input.checked?[...new Set([...review.selected,input.value])]:review.selected.filter(id=>id!==input.value);save();};});
for(const d of ART_DIRECTIONS){const c=picker.querySelector(`[data-card="${d.id}"] canvas`),ctx=c.getContext('2d'),p=new CanvasPainter(ctx),theme=directionTheme(d,'lagoon');ctx.fillStyle=d.themes[0].background;ctx.fillRect(0,0,480,200);for(let i=0;i<3;i++)drawTile(p,160+i*80,100+(i===1?-9:9),58,appearanceTile({color:i,dir:i===1?0:1},theme));}
document.querySelector('#export-review').onclick=()=>{const blob=new Blob([JSON.stringify({date:new Date().toISOString(),...review},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='hex-trails-design-review.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
summary();select(current);
}
+12
View File
@@ -0,0 +1,12 @@
import { artDirection, directionTheme, appearanceTile } from './art-directions.js';
import { drawScene, drawTile, CanvasPainter } from './renderer.js';
import { createStudyLevel } from './study-levels.js';
import { layout } from './geometry.js';
import { TILE_ATLAS } from './sprites.js';
const direction=artDirection(document.body.dataset.design),level=createStudyLevel(3),atlas=new Image();
function paint(){
const c=document.querySelector('#sheet-tokens'),p=new CanvasPainter(c.getContext('2d'));p.atlas=atlas;p.ctx.clearRect(0,0,c.width,c.height);
for(let i=0;i<4;i++)drawTile(p,105+i*165,135+(i%2?-12:12),83,appearanceTile({color:i,dir:1},directionTheme(direction,'lagoon')));
document.querySelectorAll('[data-sheet-theme]').forEach(c=>{const p=new CanvasPainter(c.getContext('2d'));p.atlas=atlas;drawScene(p,level,level.tiles,layout(level.cells,c.width,c.height),{theme:directionTheme(direction,c.dataset.sheetTheme)});});
}
atlas.onload=paint;atlas.src=TILE_ATLAS.startsWith('assets/')?'../../'+TILE_ATLAS:TILE_ATLAS;paint();
+82
View File
@@ -0,0 +1,82 @@
import { hexagon, random, randomTracks, trace, key, opposite, rotateBoard, analyze, available, Game, DIRS } from './core.js';
export const countBends = route => route.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length;
const radiusAt = (q, r) => Math.max(Math.abs(q), Math.abs(r), Math.abs(q + r));
export function pointsInward(tile) {
const [dq, dr] = DIRS[tile.dir];
return radiusAt(tile.q + dq, tile.r + dr) < radiusAt(tile.q, tile.r);
}
// Reverse insertion preserves a clearing order while favoring complete journeys.
// Only the construction orientation is constrained; rotating can change a route.
export function generateExperienceLevel(seed, { radius = 3, target = 12, allFree = false, maxBends = 18, minHeroBends = 6, clearBlockers = false, startRotation = 0, placementTurns = [] } = {}) {
const rng = random(seed);
let level = { version: 1, id: `experience-${seed}`, name: '弯路巡游', seed, rotationEnabled: true, cells: hexagon(radius), tiles: [], moveLimit: target };
level.cells.forEach(c => { c.tracks = randomTracks(rng); });
const boards = new Map(), pools = new Map();
for (const turns of new Set([0, ...placementTurns])) {
const board = rotateBoard(level, turns), candidates = [];
boards.set(turns, board);
for (const cell of level.cells) for (let dir = 0; dir < 6; dir++) {
const t = { q: cell.q, r: cell.r, dir };
const route = trace(board, t, [], true), bends = countBends(route);
if (route.status === 'exit' && bends <= maxBends) candidates.push({ t, bends, cells: route.steps.length });
}
pools.set(turns, candidates);
}
for (let n = 0; n < target; n++) {
const occupied = new Set(level.tiles.map(t => key(t.q, t.r))), choices = [];
const turns = placementTurns[n] || 0, board = boards.get(turns);
for (const c of pools.get(turns)) {
if (occupied.has(key(c.t.q, c.t.r)) || (n === 0 && c.bends < minHeroBends)) continue;
const tile = { ...c.t, id: `t${n}`, color: n % 4 }, tiles = [...level.tiles, tile];
if (trace(board, tile, tiles).status !== 'exit') continue;
if (allFree && tiles.some(t => trace(board, t, tiles).status !== 'exit')) continue;
if (clearBlockers && tiles.some(t => {
const route = trace(board, t, tiles);
return route.status === 'blocked' && !trace(board, t, tiles, true).blockers.includes(route.blocker);
})) continue;
choices.push({ tile, weight: rng() * 4 + c.bends * .55 + c.cells * .15 });
}
if (!choices.length) break;
choices.sort((a, b) => b.weight - a.weight);
level.tiles.push(choices[Math.floor(rng() * Math.min(3, choices.length))].tile);
}
level = rotateBoard(level, startRotation);
level.moveLimit = level.tiles.length;
return level;
}
// Structural evidence for a specific complete replay, not a player enjoyment score.
export function inspectExperience(level, actions = analyze(level).actions) {
const game = new Game(level), paths = [], reveals = [], unlocks = [], turns = [], hidden = [];
let dry = 0, maxDryRotations = 0, stage = 0;
for (let index = 0; index < actions.length; index++) {
const action = actions[index], before = game.tiles.map(t => ({ t, route: trace(game.level, t, game.tiles) }));
for (const { t, route } of before) if (route.status === 'blocked' && !trace(game.level, t, game.tiles, true).blockers.includes(route.blocker)) hidden.push({ action: index, id: t.id, blocker: route.blocker });
if (action.type === 'rotate') {
const result = game.rotate(action.direction);
if (result.status !== 'rotated') throw new Error(`Invalid rotation at ${index}`);
dry++; maxDryRotations = Math.max(maxDryRotations, dry); stage++;
const opened = [];
for (const { t, route } of before) {
const after = trace(game.level, t, game.tiles);
if (route.status !== 'exit' && after.status === 'exit') {
opened.push(t.id);
if (pointsInward(t)) reveals.push({ action: index, id: t.id, direction: action.direction, before: route.status, bends: countBends(after), cells: after.steps.length });
}
}
turns.push({ action: index, direction: action.direction, remaining: game.tiles.length, opened, free: available(game.level, game.tiles).map(t => t.id) });
} else {
dry = 0;
const result = game.tap(action.id);
if (result.status !== 'exit') throw new Error(`Invalid removal ${action.id} at ${index}`);
paths.push({ id: action.id, action: index, stage, bends: countBends(result), cells: result.steps.length });
for (const { t, route } of before) if (route.status === 'blocked' && trace(game.level, t, game.tiles).status === 'exit') {
const after = trace(game.level, t, game.tiles);
unlocks.push({ action: index, removed: action.id, id: t.id, bends: countBends(after), cells: after.steps.length, onRoute: trace(game.level, t, [...game.tiles, result.tile], true).blockers.includes(action.id) });
}
}
}
return { won: game.won, paths, reveals, unlocks, turns, maxDryRotations, hidden, maxBends: Math.max(0, ...paths.map(p => p.bends)), longPaths: paths.filter(p => p.bends >= 6).length };
}
+98
View File
@@ -0,0 +1,98 @@
import { hexagon, DIRS, key, rotateTracks } from './core.js';
const rotatePoint = ([q, r], turns) => {
for (let i = 0; i < (turns % 6 + 6) % 6; i++) [q, r] = [-r || 0, q + r || 0];
return [q, r];
};
const direction = (a, b) => DIRS.findIndex(([q, r]) => b[0] - a[0] === q && b[1] - a[1] === r);
function ring(radius, [q, r] = [0, 0]) {
const points = [];
let at = [q, r + radius];
for (let d = 0; d < 6; d++) for (let i = 0; i < radius; i++) {
points.push(at);
at = at.map((v, axis) => v + DIRS[(d + 5) % 6][axis]);
}
return points;
}
// Two nested petals per sector. Neighbouring outer petals cross without joining.
const PETALS = [ring(2, [0, -3]), ring(1, [0, -3])];
const featured = [];
function createFlower() {
const cells = hexagon(5).map(c => ({ ...c, tracks: Array(6).fill(null) }));
const board = new Map(cells.map(c => [key(c.q, c.r), c]));
function pair(at, a, b) {
const cell = board.get(key(...at));
if ((cell.tracks[a] !== null && cell.tracks[a] !== b) || (cell.tracks[b] !== null && cell.tracks[b] !== a)) throw new Error('Flower tracks conflict');
cell.tracks[a] = b; cell.tracks[b] = a;
featured.push({ q: at[0], r: at[1], a, b });
}
for (let petal = 0; petal < 6; petal++) {
for (const shape of PETALS) {
const loop = shape.map(pt => rotatePoint(pt, petal));
for (let i = 0; i < loop.length; i++) {
const at = loop[i], a = direction(at, loop[(i + loop.length - 1) % loop.length]), b = direction(at, loop[(i + 1) % loop.length]);
// Open each root: outer petal → inner petal → outward vein.
if (i === 0) { pair(at, a, (3 + petal) % 6); pair(at, b, petal); }
else pair(at, a, b);
}
}
for (const distance of [3, 4, 5]) pair(rotatePoint([0, -distance], petal), petal, (petal + 3) % 6);
}
for (const a of [0, 2, 4]) pair([0, 0], a, a + 1);
for (const radius of [1, 2, 3]) {
const loop = ring(radius);
for (let i = 0; i < loop.length; i++) pair(loop[i], direction(loop[i], loop[(i + loop.length - 1) % loop.length]), direction(loop[i], loop[(i + 1) % loop.length]));
}
// Fill the remaining ports in rotational orbits, preserving a regular pattern.
const done = new Set(['0,0']);
for (const cell of cells) {
if (done.has(key(cell.q, cell.r))) continue;
const free = cell.tracks.map((v, i) => v === null ? i : -1).filter(i => i >= 0);
while (free.length) {
const a = free.shift(), opposite = (a + 3) % 6;
const b = free.splice(free.includes(opposite) ? free.indexOf(opposite) : 0, 1)[0];
cell.tracks[a] = b; cell.tracks[b] = a;
}
let tracks = [...cell.tracks];
for (let turn = 0; turn < 6; turn++) {
const at = rotatePoint([cell.q, cell.r], turn), target = board.get(key(...at));
for (let d = 0; d < 6; d++) if (target.tracks[d] !== null && target.tracks[d] !== tracks[d]) throw new Error('Flower symmetry conflicts');
target.tracks = [...tracks]; done.add(key(...at)); tracks = rotateTracks(tracks);
}
}
// Splice ALL three rings into the petal routes at their six radial crossings.
// Re-pairing these same featured ports creates one woven, open network; no
// emphasized ring is left as an ornamental loop that pieces cannot escape.
for (const [distance, wiring] of [[1, [1, 0, 3, 2, 5, 4]], [2, [1, 0, 3, 2, 5, 4]], [3, [2, 5, 0, 4, 3, 1]]]) {
let tracks = wiring;
for (let turn = 0; turn < 6; turn++) {
board.get(key(...rotatePoint([0, -distance], turn))).tracks = [...tracks];
tracks = rotateTracks(tracks);
}
}
const tiles = Array.from({ length: 6 }, (_, i) => {
const [q, r] = rotatePoint([0, -5], i);
return { id: `petal-${i}`, q, r, dir: [0, 2].includes(i) ? i : (i + 3) % 6, color: i };
});
// Four interior departures introduce the weaving before the full-length runs.
for (const [i, [q, r, dir]] of [[-2, 1, 1], [1, 0, 2], [0, -2, 2], [0, 2, 2]].entries()) {
tiles.push({ id: `bloom-${i}`, q, r, dir, color: 6 + i });
}
return {
version: 1, id: 'study-flower-01', name: '花有出路 · 重瓣', rotationEnabled: true,
cells, tiles, moveLimit: tiles.length,
lesson: '先给出口让路,再解开花心中的棋子;转动底盘,把朝内的箭头接上另一片花瓣。',
};
}
export const FLOWER_LEVEL = createFlower();
export const FLOWER_PETALS = PETALS.flatMap(shape => Array.from({ length: 6 }, (_, i) => shape.map(pt => rotatePoint(pt, i))));
export function flowerTrackEmphasis(turns = 0) {
const keys = new Set(featured.flatMap(p => {
const [q, r] = rotatePoint([p.q, p.r], turns);
return [p.a, p.b].map(d => `${q},${r}:${(d + turns % 6 + 6) % 6}`);
}));
return (cell, port) => keys.has(`${cell.q},${cell.r}:${port}`);
}
+19
View File
@@ -0,0 +1,19 @@
import { ICONS, ICON_SIZE, ICON_ATLAS } from './ui-skin.js';
// Static design samples only. Inventory and rewarded ads are not game features.
export function itemIcon(name, atlas=ICON_ATLAS) {
const f=ICONS[name];
return `<span class="item-glyph art-icon" aria-hidden="true"><img src="${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>`;
}
export function itemRow(icon=itemIcon, future=false) {
const items=[['undo','undo','撤销'],['restart','restart','重来'],['hint','bulb','提示']];
return `<div class="item-samples">${items.map(([tool,glyph,label])=>{
const badge=future&&tool==='undo'?'<span class="item-badge" aria-label="剩余 3 次,示意">3</span>':future&&tool==='hint'?'<span class="item-badge ad" aria-label="观看广告获取,示意">AD</span>':'';
return `<div class="game-tool" data-tool="${tool}"><span class="item-face">${icon(glyph)}${badge}</span><b>${label}</b></div>`;
}).join('')}</div>`;
}
export function itemStudy(icon=itemIcon) {
return `${itemRow(icon)}<p class="item-spec">道具底座 64–68px,图标 32px,标签 13px。用独立造型、大小和功能色,与顶部导航按钮区分;纯色平面,无阴影或渐变高光。</p><div class="item-future"><span class="eyebrow">FUTURE STATES / 仅设计示意</span><h3>为次数与广告留出位置</h3>${itemRow(icon,true)}<p>右上角展示剩余次数,或播放符号 + AD。无角标表示普通状态;数字和获取方式均为样式示例,当前游戏不启用。</p></div>`;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+173
View File
@@ -0,0 +1,173 @@
import { Game, available, random, trace, key, rotateBoard, solveRotations, validateLevel } from './core.js';
import { countBends, inspectExperience, pointsInward } from './experience-generator.js';
import { createRouteBoard } from './route-grammar.js';
import { createStageSeed } from './stage-seed.js';
import { EXPERIENCE_KINDS, PRODUCTION_PLAN_VERSION } from './production-plan.js';
const radiusAt = t => Math.max(Math.abs(t.q), Math.abs(t.r), Math.abs(t.q + t.r));
function rotateActions(from, to) {
let delta = (to - from + 6) % 6;
const direction = delta > 3 ? -1 : 1;
if (delta > 3) delta = 6 - delta;
return Array.from({ length: delta }, () => ({ type: 'rotate', direction }));
}
function validDeparture(board, tile, tiles) { return trace(board, tile, tiles).status === 'exit'; }
function routePairs(route, orientation) {
const pairs = [];
for (const s of route.steps) if (s.enter !== null) {
let q = s.q, r = s.r;
for (let n = 0; n < (6 - orientation) % 6; n++) [q, r] = [-r || 0, q + r || 0];
const a = (s.enter - orientation + 6) % 6, b = (s.exit - orientation + 6) % 6;
pairs.push(`${q},${r}:${Math.min(a, b)}-${Math.max(a, b)}`);
}
return [...new Set(pairs)];
}
// Reverse construction stores a witness as it adds pieces. Shape construction
// and placement are separate; each attempted insertion uses formal swept rules.
export function constructProductionCandidate(slot, attempt = 0) {
const seed = slot.seed + attempt * 104729;
const rng = random(seed), base = createRouteBoard(seed, { family: slot.family, radius: slot.radius, variant: slot.variant + Math.floor(attempt / 4) });
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(base, i));
const featuredSet = new Set(base.routeDesign.featured.map(p => `${p.q},${p.r}:${Math.min(p.a, p.b)}-${Math.max(p.a, p.b)}`));
const pools = new Map();
const pool = turn => {
if (!pools.has(turn)) {
const candidates = [];
for (const cell of base.cells) for (let dir = 0; dir < 6; dir++) {
const tile = { q: cell.q, r: cell.r, dir };
const route = trace(boards[turn], tile, [], true), bends = countBends(route);
if (route.status === 'exit' && bends <= slot.maxHeroBends) {
const pairs = routePairs(route, turn), featured = pairs.filter(p => featuredSet.has(p)).length;
candidates.push({ tile, bends, cells: route.steps.length, featured, featuredRatio: featured / Math.max(1, pairs.length) });
}
}
pools.set(turn, candidates);
}
return pools.get(turn);
};
const contrast = slot.kind === 'contrast', turn = slot.kind === 'turn', home = contrast || turn ? 1 : 0;
const stageSeed = slot.kind === 'stages' ? createStageSeed(base, { seed, minHeroBends: slot.minHeroBends, maxHeroBends: slot.maxHeroBends }) : null;
if (slot.kind === 'stages' && !stageSeed) return { rejected: 'no-two-stage-lock' };
const heroChoices = pool(home).filter(c => c.bends >= slot.minHeroBends && c.featured >= Math.min(6, slot.minHeroBends) && c.featuredRatio >= .6 && (!contrast || pointsInward(c.tile)));
if (!heroChoices.length) return { rejected: 'no-hero-route' };
const heroChoice = stageSeed ? { tile: stageSeed.hero, ...stageSeed.heroRoute } : heroChoices[Math.floor(rng() * heroChoices.length)];
const hero = { ...heroChoice.tile, id: 't0', color: 0 };
const tiles = stageSeed ? [...stageSeed.tiles] : [hero], placements = stageSeed ? [...stageSeed.placements] : [home];
for (let n = tiles.length; n < slot.target; n++) {
let orientation = home;
if (turn && n > 1) orientation = 0;
const occupied = new Set(tiles.map(t => key(t.q, t.r)));
let choices = pool(orientation).filter(c => !occupied.has(key(c.tile.q, c.tile.r)));
const wantsGate = slot.kind === 'gate' && n === 1;
// Sample a broad band rather than always maximizing length. Short departures
// may be meaningful gates; spare pieces should not all repeat the hero path.
choices = choices.map(c => ({ ...c, rank: rng() * 18 + (n % 3 === 0 ? c.bends * .3 : -c.bends * .06) }));
choices.sort((a, b) => b.rank - a.rank);
let chosen;
for (const c of choices) {
const tile = { ...c.tile, id: `t${n}`, color: n % 7 }, proposed = [...tiles, tile];
if (!validDeparture(boards[orientation], tile, proposed)) continue;
if (slot.kind === 'tour' && tiles.some(t => !validDeparture(boards[home], t, proposed))) continue;
if ((contrast || turn) && !validDeparture(boards[home], hero, proposed)) continue;
if (wantsGate) {
const before = trace(boards[home], hero, proposed);
if (before.status !== 'blocked' || before.blocker !== tile.id || !trace(boards[home], hero, proposed, true).blockers.includes(tile.id)) continue;
if (c.bends > Math.max(4, heroChoice.bends / 2)) continue;
}
if (slot.kind === 'gate' && n > 1) {
const gate = proposed.find(t => t.id === 't1');
if (!validDeparture(boards[home], gate, proposed) || !validDeparture(boards[home], hero, proposed.filter(t => t.id !== 't1'))) continue;
}
if (contrast && n === 1) {
const before = trace(boards[0], hero, proposed);
if (before.status !== 'blocked' || !trace(boards[0], hero, proposed, true).blockers.includes(tile.id)) continue;
}
if ((turn || contrast) && n === 1) {
// A mutual lock at 0 survives every peripheral removal. At +1 both
// core pieces are free, so the intended hero cannot be greedily taken
// down a short route before the meaningful turn.
if (trace(boards[0], hero, proposed).blocker !== tile.id || trace(boards[0], tile, proposed).blocker !== hero.id) continue;
if (!trace(boards[0], hero, proposed, true).blockers.includes(tile.id) || !trace(boards[0], tile, proposed, true).blockers.includes(hero.id)) continue;
}
chosen = tile; break;
}
if (!chosen) {
if (n < Math.max(3, slot.target - 2) || (n === 1 && (wantsGate || contrast))) return { rejected: 'placement-capacity' };
break;
}
tiles.push(chosen); placements.push(orientation);
}
const level = {
...base, id: `trail-v05-${String(slot.number).padStart(4, '0')}`, seed,
name: `${(base.name || slot.family).replace(' · 路线草图', '')} · ${EXPERIENCE_KINDS[slot.kind].name}`,
chapter: slot.chapter, chapterName: slot.chapterName, beat: EXPERIENCE_KINDS[slot.kind].name,
lesson: EXPERIENCE_KINDS[slot.kind].lesson, designIntent: EXPERIENCE_KINDS[slot.kind].intent,
tiles, moveLimit: tiles.length,
experience: { kind: slot.kind, feature: { id: hero.id, ...(contrast ? { direction: 1 } : {}) } },
production: { planVersion: PRODUCTION_PLAN_VERSION, rulesVersion: 'core-v1-immediate-departure', family: slot.family, variant: base.routeDesign?.variant ?? slot.variant, phase: slot.phase, intent: EXPERIENCE_KINDS[slot.kind].intent, source: { seed, attempt, slot, placements }, review: { automatic: 'pending', visual: 'pending', player: 'not-tested' } },
};
const witness = [], order = [...tiles.keys()].reverse().filter(n => !stageSeed || n >= 3);
let orientation = 0;
if (contrast) { witness.push({ type: 'rotate', direction: 1 }, { type: 'tap', id: hero.id }); orientation = 1; order.pop(); }
if (slot.kind === 'gate') {
witness.push({ type: 'tap', id: 't1' }, { type: 'tap', id: hero.id });
order.splice(order.indexOf(1), 1); order.splice(order.indexOf(0), 1);
}
if (slot.kind === 'tour') { witness.push({ type: 'tap', id: hero.id }); order.pop(); }
for (const n of order) {
witness.push(...rotateActions(orientation, placements[n]), { type: 'tap', id: tiles[n].id });
orientation = placements[n];
}
if (stageSeed) witness.push(...stageSeed.actions);
level.experience.actions = witness;
const errors = validateLevel(level);
if (errors.length) throw new Error(errors.join('; '));
return { level };
}
export function inspectCandidate(level, slot) {
const solution = solveRotations(level);
if (!solution.solvable || solution.exhausted) return { rejected: solution.exhausted ? 'solver-exhausted' : 'unsolved' };
const reference = inspectExperience(level, solution.actions), authored = inspectExperience(level, level.experience.actions);
if (!reference.won || !authored.won) throw new Error(`Broken construction witness ${level.id}`);
if (reference.maxBends < slot.minHeroBends) return { rejected: 'reference-misses-long-route' };
if (reference.maxDryRotations > 2) return { rejected: 'dry-rotation-run' };
const hero = level.tiles[0], feature = level.experience.feature;
if (slot.kind === 'tour' && solution.rotations !== 0) return { rejected: 'tour-needs-turn' };
if (slot.kind === 'gate') {
const unlock = authored.unlocks.find(u => u.id === hero.id && u.removed === 't1' && u.onRoute);
if (!unlock) return { rejected: 'gate-not-visible' };
Object.assign(feature, unlock);
if (!validDeparture(level, level.tiles.find(t => t.id === 't1'), level.tiles) || !validDeparture(level, hero, level.tiles.filter(t => t.id !== 't1'))) return { rejected: 'gate-not-immediate' };
}
if (slot.kind === 'contrast') {
const before = trace(level, hero), after = trace(rotateBoard(level), hero);
if (!pointsInward(hero) || before.status !== 'blocked' || after.status !== 'exit' || countBends(after) < slot.minHeroBends) return { rejected: 'contrast-not-immediate' };
Object.assign(feature, { before: before.status, bends: countBends(after), cells: after.steps.length });
if (solution.rotations < 1) return { rejected: 'contrast-bypassed-without-turn' };
}
if (['turn', 'stages'].includes(slot.kind) && solution.rotations < 1) return { rejected: 'rotation-not-needed' };
if (slot.kind === 'stages' && reference.turns.length < 2) return { rejected: 'stages-collapse' };
if (reference.hidden.length > level.tiles.length * 2) return { rejected: 'too-many-hidden-blockers' };
// Keep geometric route usage as evidence rather than calling all drawn lines
// gameplay. Rotation changes both cell coordinates and port directions.
const game = new Game(level), used = new Set();
let heroPairs = [], heroFeatured = 0;
const featured = level.routeDesign?.featured || [];
const featuredSet = new Set(featured.map(p => `${p.q},${p.r}:${Math.min(p.a, p.b)}-${Math.max(p.a, p.b)}`));
for (const action of level.experience.actions) {
if (action.type === 'rotate') { game.rotate(action.direction); continue; }
const route = game.tap(action.id);
const pairs = routePairs(route, game.orientation);
for (const pair of pairs) used.add(pair);
if (action.id === hero.id) {
heroPairs = pairs;
heroFeatured = pairs.filter(p => featuredSet.has(p)).length;
}
}
const matches = featured.filter(p => used.has(`${p.q},${p.r}:${Math.min(p.a, p.b)}-${Math.max(p.a, p.b)}`)).length;
if (featured.length && matches < 4) return { rejected: 'shape-not-traveled' };
if (heroFeatured < Math.min(6, slot.minHeroBends) || heroFeatured / Math.max(1, heroPairs.length) < .6) return { rejected: 'hero-misses-shape' };
return { solution, reference, authored, shapeUsage: { featuredPairs: featured.length, traveledPairs: matches, ratio: featured.length ? matches / featured.length : 0, heroFeaturedPairs: heroFeatured, heroRoutePairs: heroPairs.length, heroFeaturedRatio: heroFeatured / Math.max(1, heroPairs.length) }, opening: { free: available(level).length, inward: level.tiles.filter(pointsInward).length, outer: level.tiles.filter(t => radiusAt(t) === slot.radius).length } };
}
+59
View File
@@ -0,0 +1,59 @@
import { random } from './core.js';
export const PRODUCTION_PLAN_VERSION = '2026-09-12-r1';
export const PRODUCTION_CAMPAIGN = {
id: 'v0.5', revision: 1, chapterSize: 50,
chapters: ['初见弯路', '朝内的出口', '绕行花园', '轻轻让路', '环里的风景', '花瓣初开', '交织之间', '两岸来信', '回旋的发现', '层层放行', '走进花心', '曲径相逢', '另一侧的出口', '编织新路', '旋开的花园', '从容穿行', '旧路新发现', '花与回声', '千回百转', '一路盛放'],
};
export const EXPERIENCE_KINDS = {
tour: { name: '长路巡游', lesson: '点一枚棋子,跟着它沿弯路找到出口。', intent: '直接享受完整行进;多个出口提供连续点击的空间。' },
gate: { name: '清挡放行', lesson: '先让挡路的棋子离开,再放出里面的长路。', intent: '移走清楚可见的挡子,为后面的长路打开出口。' },
contrast: { name: '轻巧反差', lesson: '朝内也有出路,试着转动底盘再出发。', intent: '朝内的目标通过一次旋转接上长路;位置和箭头保持不变。' },
turn: { name: '旋转换路', lesson: '先放出能走的棋子,再转出另一条路。', intent: '棋盘初步释放后,转动轨道建立新的离场机会。' },
stages: { name: '分段释放', lesson: '一片一片清开,让不同区域的路线接连畅通。', intent: '清挡和换路分段展开,经过至少两次换路后放出主路线。' },
};
const families = ['petals', 'orbits', 'spiral', 'switchback', 'weave', 'crossing'];
// Teaching introduces a small set of actions. Later blocks vary their beat
// length/order; chapter boundaries are production units, not difficulty cliffs.
const introduction = [
['switchback', 'tour', 2, 3], ['orbits', 'tour', 2, 4],
['petals', 'gate', 2, 5], ['spiral', 'contrast', 2, 5],
['weave', 'tour', 2, 4], ['crossing', 'gate', 2, 6],
['orbits', 'turn', 2, 6], ['petals', 'tour', 3, 5],
['switchback', 'gate', 3, 7], ['spiral', 'turn', 3, 7],
];
const beats = [
['tour', 'gate', 'contrast', 'tour', 'turn', 'gate', 'stages', 'tour', 'gate'],
['gate', 'tour', 'turn', 'contrast', 'gate', 'tour', 'stages', 'turn'],
['contrast', 'gate', 'tour', 'turn', 'stages', 'tour', 'gate'],
];
export function productionSlot(index) {
if (!Number.isInteger(index) || index < 0 || index >= 1000) throw new RangeError('Production slot must be 0–999');
const chapter = Math.floor(index / 50), local = index % 50;
const rng = random(55012026 + index * 7919);
const phrase = beats[(chapter + Math.floor(local / 9)) % beats.length];
let kind = phrase[(local + chapter * 3) % phrase.length];
let family = families[(local + Math.floor(local / 6) * 2 + chapter * 5) % families.length];
let radius = chapter < 2 ? 3 : chapter < 5 ? 3 + (rng() > .65 ? 1 : 0) : 3 + Math.floor(rng() * 3);
let target = (kind === 'tour' ? 5 : 8) + Math.min(6, Math.floor(chapter / 3)) + Math.floor(rng() * 4);
if (local === 49) { family = 'petals'; kind = chapter < 2 ? 'tour' : 'stages'; radius = chapter < 2 ? 3 : 5; target = chapter < 2 ? 7 : 14; }
if (index < introduction.length) [family, kind, radius, target] = introduction[index];
if (index < 30 && kind === 'stages') kind = 'turn';
// A long open route reserves real space. Familiarity permits new relations,
// but does not create room for arbitrary extra pieces on a small board.
const capacity = ['contrast', 'turn'].includes(kind) ? { 2: 6, 3: 9, 4: 12, 5: 14 } : { 2: 6, 3: 11, 4: 14, 5: 17 };
target = Math.min(target, capacity[radius]);
// Chapter-sheet review found 849 and 850 too similar in both silhouette and
// release rhythm. A woven breather makes the final layered flower distinct.
if (index === 848) { family = 'weave'; kind = 'tour'; radius = 4; target = 8; }
return {
index, number: index + 1, chapter: chapter + 1, chapterName: PRODUCTION_CAMPAIGN.chapters[chapter],
family, kind, radius, target, variant: Math.floor(index / 6),
minHeroBends: index === 0 ? 3 : radius === 2 ? 5 : 7,
maxHeroBends: radius === 2 ? 22 : radius === 3 ? 36 : 70,
seed: 55000000 + index * 1009,
phase: chapter < 2 ? 'learn' : chapter < 6 ? 'practice' : chapter < 12 ? 'combine' : 'fluent',
};
}
+31 -19
View File
@@ -1,3 +1,5 @@
import { drawStyledToken } from './token-art.js';
import { appearanceTile } from './art-directions.js';
import { exitTiming, smoothMotion, travelProgress } from './exit-animation.js';
import { THEME, PIGMENTS, pigmentIndex } from './design-tokens.js';
import { DIRS, key } from './core.js';
@@ -9,6 +11,7 @@ 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;
if(drawStyledToken(p,x,y,r,tile,angle))return;
const textured=p.sprite?.(tile.color,x,y,r)===true;
if(!textured){
p.polygon(roundedHex(x,y,r),COLORS[tile.color]);
@@ -20,27 +23,34 @@ export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,sca
}
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 visualTile=tile=>appearanceTile({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)},state.theme);
const materials={...THEME,...state.theme?.materials};
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)};
const blocked=state.animation?.route.status==='blocked';
const blocker=blocked?tiles.find(t=>t.id===state.animation.route.blocker):null;
const target=blocker?center(blocker,view):null;
const phase=blocked?collisionPhase(state.animation.progress,state.reduced):null;
const movingPath=state.animation?contactPath(routePoints(state.animation.route,view,true),target,size*TILE_RADIUS):null;
const contact=movingPath?.[movingPath.length-1];
const impactAngle=target&&contact?Math.atan2(target.y-contact.y,target.x-contact.x):0;
// Each accepted tap keeps its own path and clock. Legacy adapters may still
// supply one animation; the browser supplies all simultaneous departures.
const motions=(state.animations??(state.animation?[state.animation]:[])).map(animation=>{
const blocked=animation.route.status==='blocked';
const blocker=blocked?tiles.find(t=>t.id===animation.route.blocker):null;
const target=blocker?center(blocker,view):null;
const phase=blocked?collisionPhase(animation.progress,state.reduced):null;
const movingPath=contactPath(routePoints(animation.route,view,true),target,size*TILE_RADIUS);
const contact=movingPath[movingPath.length-1];
const impactAngle=target&&contact?Math.atan2(target.y-contact.y,target.x-contact.x):0;
return {...animation,blocker,target,phase,movingPath,contact,impactAngle};
});
const movingIds=new Set(motions.map(a=>a.route.tile.id));
const cellKeys=new Set(level.cells.map(c=>key(c.q,c.r)));
const sorted=[...level.cells].sort((a,b)=>center(a,view).y-center(b,view).y);
// Complete the contiguous board before drawing any rails. A tiny overlap
// prevents antialiasing seams between touching hexagons.
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2),THEME.board);}
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2),materials.board);}
// Subtle cell boundaries beneath the rails: retain the hex grid without
// 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,size*.024));
base.line([...edge,edge[0]],materials.boardGrid,Math.max(1,size*.024));
}
const rails=[];
for(const cell of sorted) {
@@ -56,17 +66,20 @@ export function drawScene(p,level,tiles,view,state={}) {
const dx0=exterior(a)?(first.x-x)*shrink:0,dy0=exterior(a)?(first.y-y)*shrink:0;
const dx1=exterior(b)?(last.x-x)*shrink:0,dy1=exterior(b)?(last.y-y)*shrink:0;
points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
rails.push(points);
rails.push({points,primary:!state.trackEmphasis||state.trackEmphasis(cell,a,b)});
}
}
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.5,size*.10));
// Pattern studies can emphasize real routes while retaining the other paired
// connections as secondary lines. Ordinary levels keep the same drawing passes.
for(const {points,primary} of rails)if(!primary)base.line(points,state.secondaryTrackColor||materials.boardGrid,Math.max(1,size*.025));
for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,Math.max(4,size*.16));
for(const {points,primary} of rails)if(primary)base.line(points,materials.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);
if(cell){const c=center(cell,view),pts=hexPoints(c.x,c.y,size*.98);p.line([...pts,pts[0]],THEME.reward,3);}
}
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)) {
for(const tile of [...tiles].filter(t=>!movingIds.has(t.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)){
@@ -76,13 +89,12 @@ export function drawScene(p,level,tiles,view,state={}) {
let dx=0;
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
let dy=0;
if(tile.id===blocker?.id){
for(const {blocker,impactAngle,phase} of motions)if(tile.id===blocker?.id){
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));
}
if(state.animation) {
const {route,progress}=state.animation;
for(const {route,progress,target,phase,movingPath,contact,impactAngle} of motions) {
const blocked=route.status!=='exit';
if(!blocked){
drawExit(p,route,view,progress,visualTile(route.tile),state.reduced);
@@ -92,7 +104,7 @@ export function drawScene(p,level,tiles,view,state={}) {
}
if(target&&phase.impact>0){
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
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));}
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)}],materials.track,Math.max(1.5,size*.055));}
}
}
}
@@ -126,7 +138,7 @@ export function drawExit(p,route,view,progress,tile,reduced=false){
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);
const color=tile.fill||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--){
+313
View File
@@ -0,0 +1,313 @@
import { DIRS, hexagon, key, opposite, random } from './core.js';
import { FLOWER_LEVEL, flowerTrackEmphasis } from './flower-study.js';
// Each grammar reserves real port pairs before the unused ports are completed.
// The seed moves gates and folds within that grammar; it never draws random rails.
export const ROUTE_FAMILIES = [
{ id: 'petals', name: '花瓣', description: '叶瓣回到花心,再沿另一片叶瓣离场。' },
{ id: 'orbits', name: '环绕', description: '同心环接入开放的径向通道,逐圈展开。' },
{ id: 'spiral', name: '螺旋', description: '连续深入的螺旋接回出盘通道。' },
{ id: 'switchback', name: '回折', description: '长通道在相邻分区折返,局部闸口改变顺序。' },
{ id: 'weave', name: '编织', description: '连续交替转向的带状路线跨过盘面。' },
{ id: 'crossing', name: '双叶跨区', description: '两片回环在中央交织,共用远端开放出口。' },
];
const mod = (value, base = 6) => (value % base + base) % base;
const rotate = ([q, r], turns) => {
for (let i = 0; i < mod(turns); i++) [q, r] = [-r || 0, q + r || 0];
return [q, r];
};
const direction = (a, b) => DIRS.findIndex(([q, r]) => b[0] - a[0] === q && b[1] - a[1] === r);
const step = ([q, r], d) => [q + DIRS[d][0], r + DIRS[d][1]];
const pairKey = (q, r, a, b) => `${q},${r}:${Math.min(a, b)},${Math.max(a, b)}`;
function ring(radius, [q, r] = [0, 0]) {
const result = [];
let at = [q, r + radius];
for (let d = 0; d < 6; d++) for (let i = 0; i < radius; i++) {
result.push(at);
at = step(at, (d + 5) % 6);
}
return result;
}
function draft(radius) {
const cells = hexagon(radius).map(cell => ({ ...cell, tracks: Array(6).fill(null) }));
const board = new Map(cells.map(cell => [key(cell.q, cell.r), cell]));
const featuredPorts = new Set();
function pair(at, a, b) {
const cell = board.get(key(...at));
if (!cell || a < 0 || b < 0 || a === b) throw new Error(`Invalid route pair at ${at}: ${a},${b}`);
if ((cell.tracks[a] !== null && cell.tracks[a] !== b) || (cell.tracks[b] !== null && cell.tracks[b] !== a)) {
throw new Error(`Route grammar overlaps a port at ${at}: ${a},${b}`);
}
cell.tracks[a] = b; cell.tracks[b] = a;
featuredPorts.add(`${at}:${a}`); featuredPorts.add(`${at}:${b}`);
}
function replace(at, pairs) {
const cell = board.get(key(...at));
for (const port of pairs.flat()) {
const partner = cell.tracks[port];
if (partner !== null) cell.tracks[partner] = null;
cell.tracks[port] = null;
}
for (const [a, b] of pairs) pair(at, a, b);
}
function path(points, entry, exit) {
for (let i = 0; i < points.length; i++) {
pair(points[i], i ? direction(points[i], points[i - 1]) : entry,
i + 1 < points.length ? direction(points[i], points[i + 1]) : exit);
}
}
function loop(points) {
for (let i = 0; i < points.length; i++) pair(points[i], direction(points[i], points[mod(i - 1, points.length)]), direction(points[i], points[(i + 1) % points.length]));
}
function ray(at, dir) {
for (let next = step(at, dir); board.has(key(...next)); next = step(next, dir)) pair(next, opposite(dir), dir);
}
return { cells, board, featuredPorts, pair, replace, path, loop, ray };
}
const centerWirings = [[1, 0, 3, 2, 5, 4], [3, 4, 5, 0, 1, 2], [1, 0, 4, 5, 2, 3]];
function petals(design, radius, variant) {
if (radius === 5) {
const emphasized = flowerTrackEmphasis();
for (const source of FLOWER_LEVEL.cells) {
const cell = design.board.get(key(source.q, source.r));
cell.tracks = [...source.tracks];
for (let port = 0; port < 6; port++) if (emphasized(source, port)) design.featuredPorts.add(`${source.q},${source.r}:${port}`);
}
const wiring = centerWirings[mod(variant, centerWirings.length)];
design.replace([0, 0], wiring.flatMap((b, a) => a < b ? [[a, b]] : []));
// One gate can be rethreaded while all six petal outlines remain intact.
if (mod(variant, 4) > 1) {
const at = rotate([0, -2], mod(variant, 6));
const cell = design.board.get(key(...at));
const pairs = cell.tracks.flatMap((b, a) => a < b ? [[a, b]] : []);
const [[a, b], [c, d]] = pairs;
design.replace(at, [[a, c], [b, d]]);
}
return;
}
if (radius === 2) {
// Six triangular petals: every drawn lobe belongs to one open tour.
const circuit = [];
for (let sector = 0; sector < 6; sector++) circuit.push(...[[0, -1], [0, -2], [1, -2], [0, -1]].map(point => rotate(point, sector)));
const first = 1;
const points = [...circuit.slice(first), ...circuit.slice(0, first), circuit[first]];
design.path(points, 0, 1);
for (let sector = 0; sector < 6; sector++) if (variant & (1 << sector)) {
// Reverse one lobe through its two gates, preserving the open full tour.
design.replace(rotate([0, -1], sector), [[4, 1], [0, 2]].map(pair => pair.map(port => mod(port + sector))));
}
return;
}
const rootDistance = radius - 2;
for (let sector = 0; sector < 6; sector++) {
const lobe = ring(1, [0, -(radius - 1)]).map(point => rotate(point, sector));
design.loop(lobe);
for (let distance = 1; distance <= radius; distance++) {
const at = rotate([0, -distance], sector);
design.pair(at, sector, (sector + 3) % 6);
}
const root = rotate([0, -rootDistance], sector);
const gate = variant & (1 << sector) ? [[1, 0], [5, 3]] : [[1, 3], [5, 0]];
design.replace(root, gate.map(pair => pair.map(port => mod(port + sector))));
}
const wiring = centerWirings[mod(variant, centerWirings.length)];
for (let a = 0; a < 6; a++) if (a < wiring[a]) design.pair([0, 0], a, wiring[a]);
}
function orbits(design, radius, variant) {
for (let distance = 1; distance <= radius; distance++) design.loop(ring(distance));
for (let r = -radius; r <= radius; r++) design.pair([0, r], 0, 3);
for (let distance = 1; distance <= radius; distance++) {
// Switching one ring's gate to the opposite side changes traversal order.
const south = mod(variant, 2 ** radius) & (1 << (distance - 1));
const turn = south ? 3 : 0;
const at = rotate([0, -distance], turn);
const pairs = mod(variant + distance, 3) === 0 ? [[0, 4], [3, 2]] : [[0, 2], [3, 4]];
design.replace(at, pairs.map(pair => pair.map(port => mod(port + turn))));
}
}
// Complete a partly reserved route through unused ports. A cell is visited only
// once in this extension, so no new pair can consume its own earlier exit port.
function openExtension(design, start, entry, rng) {
const queue = [{ at: start, entry, points: [start] }], seen = new Set([key(...start)]);
for (let head = 0; head < queue.length; head++) {
const node = queue[head], cell = design.board.get(key(...node.at));
const options = [0, 1, 2, 3, 4, 5].filter(port => port !== node.entry && cell.tracks[port] === null);
const phase = Math.floor(rng() * options.length);
options.push(...options.splice(0, phase));
for (const exit of options) {
const next = step(node.at, exit), nextCell = design.board.get(key(...next));
if (!nextCell) return { points: node.points, exit };
if (nextCell.tracks[opposite(exit)] !== null || seen.has(key(...next))) continue;
seen.add(key(...next));
queue.push({ at: next, entry: opposite(exit), points: [...node.points, next] });
}
}
return null;
}
function spiral(design, radius, rng, variant) {
const points = ring(radius);
for (let distance = radius - 1; distance >= 1; distance--) {
const next = ring(distance), last = points[points.length - 1];
const gates = next.flatMap((point, index) => direction(last, point) >= 0 ? [index] : []);
const gate = gates[mod(variant + Math.floor(rng() * gates.length), gates.length)];
points.push(...next.slice(gate), ...next.slice(0, gate));
}
points.push([0, 0]);
// Reserve the inward tour, leaving its last entry to be paired with an exit.
for (let i = 0; i + 1 < points.length; i++) design.pair(points[i], i ? direction(points[i], points[i - 1]) : 3, direction(points[i], points[i + 1]));
const entry = direction([0, 0], points[points.length - 2]);
const extension = openExtension(design, [0, 0], entry, rng);
if (!extension) throw new Error('Spiral has no free outward connector');
design.path(extension.points, entry, extension.exit);
}
// Reversing a short strip moves its two gates without changing cell coverage.
// This preserves the recognisable lanes and gives a true route-order variant.
function shiftFolds(points, rng, variant, radius) {
for (let change = 0; change < mod(variant, 4); change++) {
const candidates = [];
for (let i = 0; i + 4 < points.length; i++) for (let j = i + 3; j < Math.min(points.length - 1, i + radius * 4 + 4); j++) {
if (direction(points[i], points[j]) >= 0 && direction(points[i + 1], points[j + 1]) >= 0) candidates.push([i, j]);
}
if (candidates.length) {
const [i, j] = candidates[Math.floor(rng() * candidates.length)];
points.splice(i + 1, j - i, ...points.slice(i + 1, j + 1).reverse());
} else {
// A triangular corner lets the connection enter the next lane one cell
// earlier. The omitted cell keeps complete secondary tracks below.
const corners = points.flatMap((point, i) => i > 0 && i + 1 < points.length && direction(points[i - 1], points[i + 1]) >= 0 ? [i] : []);
if (!corners.length) break;
points.splice(corners[Math.floor(rng() * corners.length)], 1);
}
}
return points;
}
function boundaryPort(design, at, preferred) {
return [preferred, ...[0, 1, 2, 3, 4, 5].filter(port => port !== preferred)].find(port => !design.board.has(key(...step(at, port))));
}
function switchback(design, radius, rng, variant) {
const points = [];
for (let q = -radius; q <= radius; q++) {
const lane = design.cells.filter(cell => cell.q === q).map(cell => [cell.q, cell.r]);
if (mod(q + radius, 2)) lane.reverse();
points.push(...lane);
}
shiftFolds(points, rng, variant, radius);
design.path(points, boundaryPort(design, points[0], 5), boundaryPort(design, points[points.length - 1], 2));
}
function weave(design, radius, rng, variant) {
const points = [], bottom = -Math.ceil(radius / 2), top = Math.floor(radius / 2);
for (let row = bottom; row <= top; row++) {
const lane = [];
for (let q = -radius; q <= radius; q++) lane.push([q, row - Math.floor(q / 2)]);
if (mod(row - bottom, 2)) lane.reverse();
points.push(...lane);
}
shiftFolds(points, rng, variant, radius);
design.path(points, boundaryPort(design, points[0], 5), boundaryPort(design, points[points.length - 1], 2));
}
function crossing(design, radius, variant) {
const north = Math.floor(radius / 2), south = radius >= 4 && mod(variant, 3) === 1 ? 1 : north;
design.loop(ring(north, [0, -north]));
design.loop(ring(south, [0, south]));
design.replace([0, 0], mod(variant, 2) ? [[1, 4], [5, 2]] : [[1, 2], [5, 4]]);
const gate = [0, -north * 2];
design.replace(gate, [[2, 0], [4, 1]]);
design.ray(gate, 0); design.ray(gate, 1);
}
function completePorts(design, family, variant) {
for (const cell of design.cells) {
const free = cell.tracks.flatMap((value, port) => value === null ? [port] : []);
// Periodic completion keeps secondary rails ordered instead of random noise.
const phase = mod(cell.q - cell.r + variant, 3);
while (free.length) {
const a = free.shift();
const preferred = family === 'weave' && phase === 0 ? mod(a + 1) : opposite(a);
const b = free.splice(free.includes(preferred) ? free.indexOf(preferred) : mod(phase, free.length), 1)[0];
cell.tracks[a] = b; cell.tracks[b] = a;
}
}
}
function describeRoutes(design) {
const candidates = [];
const reachablePairs = new Set();
for (const start of design.cells) for (let dir = 0; dir < 6; dir++) {
let q = start.q, r = start.r, exit = dir, bends = 0, steps = 1;
const seen = new Set([`${q},${r}:${exit}`]), pairs = [];
let status = 'loop';
for (let count = 0; count <= design.cells.length * 6; count++) {
q += DIRS[exit][0]; r += DIRS[exit][1];
const cell = design.board.get(key(q, r));
if (!cell) { status = 'exit'; break; }
const enter = opposite(exit); exit = cell.tracks[enter];
if (opposite(enter) !== exit) bends++;
steps++;
const signature = `${q},${r}:${exit}`;
if (seen.has(signature)) break;
seen.add(signature);
if (design.featuredPorts.has(`${q},${r}:${enter}`) && design.featuredPorts.has(`${q},${r}:${exit}`)) pairs.push(pairKey(q, r, enter, exit));
}
if (status !== 'exit') continue;
for (const pair of pairs) reachablePairs.add(pair);
if (pairs.length) {
const uniquePairs = [...new Set(pairs)];
candidates.push({ q: start.q, r: start.r, dir, steps, bends, featuredPairs: uniquePairs.length, pairs: uniquePairs });
}
}
const featured = design.cells.flatMap(cell => cell.tracks.flatMap((b, a) => a < b && reachablePairs.has(pairKey(cell.q, cell.r, a, b)) ? [{ q: cell.q, r: cell.r, a, b }] : []));
candidates.sort((a, b) => b.featuredPairs - a.featuredPairs || b.bends - a.bends || b.steps - a.steps || a.q - b.q || a.r - b.r || a.dir - b.dir);
// Keep witnesses for every open branch, rather than filling the list with
// offsets along just the longest branch of a multi-petal design.
const selected = [], uncovered = new Set(reachablePairs);
while (uncovered.size) {
let best = null, gain = 0;
for (const candidate of candidates) {
const extra = candidate.pairs.reduce((count, pair) => count + Number(uncovered.has(pair)), 0);
if (extra > gain) { best = candidate; gain = extra; }
}
if (!best) break;
selected.push(best);
for (const pair of best.pairs) uncovered.delete(pair);
}
for (const candidate of candidates) {
if (selected.length >= 36) break;
if (!selected.includes(candidate)) selected.push(candidate);
}
return { featured, heroStarts: selected.map(({ pairs, ...candidate }) => candidate) };
}
export function createRouteBoard(seed, { family = 'petals', radius = 3, variant = 0 } = {}) {
const spec = ROUTE_FAMILIES.find(candidate => candidate.id === family);
if (!spec) throw new RangeError(`Unknown route family: ${family}`);
if (!Number.isInteger(radius) || radius < 2 || radius > 5) throw new RangeError('Route radius must be 2–5');
if (!Number.isInteger(variant)) throw new RangeError('Route variant must be an integer');
const rng = random(seed), design = draft(radius);
const gateVariant = mod(variant + (seed >>> 0), 96);
if (family === 'petals') petals(design, radius, gateVariant);
if (family === 'orbits') orbits(design, radius, gateVariant);
if (family === 'spiral') spiral(design, radius, rng, gateVariant);
if (family === 'switchback') switchback(design, radius, rng, gateVariant);
if (family === 'weave') weave(design, radius, rng, gateVariant);
if (family === 'crossing') crossing(design, radius, gateVariant);
completePorts(design, family, gateVariant);
const { featured, heroStarts } = describeRoutes(design);
return {
version: 1, id: `route-${family}-${radius}-${seed}-${variant}`, name: `${spec.name} · 路线草图`,
cells: design.cells, tiles: [], moveLimit: 1, rotationEnabled: true,
routeDesign: { family, variant, seed, radius, gateVariant, featured, heroStarts },
};
}
+29 -11
View File
@@ -1,3 +1,6 @@
import { ART_DIRECTIONS, artDirection, directionTheme, appearanceTile } from './art-directions.js';
import { mountDirectionReview } from './direction-review.js';
import { itemStudy } from './item-study.js';
import { attachBoardViewport } from './board-viewport.js';
import { createStudyLevel } from './study-levels.js';
import { TapQueue } from './tap-queue.js';
@@ -16,6 +19,7 @@ let boardViewport;
const regularLevel=createStudyLevel(3);
let studyLevel=createStudyLevel(5);
let colorCount=7;
let direction=artDirection(new URLSearchParams(location.search).get("design"));
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(){
@@ -24,27 +28,32 @@ function decorateIcons(){
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('');
$('item-study-content').innerHTML=itemStudy();
$('theme-tabs').innerHTML=direction.themes.map(t=>`<button data-theme="${t.id}" aria-pressed="false"><i style="background:${t.background}"></i>${t.label}</button>`).join('');
$('theme-grid').innerHTML=direction.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('');
$('quick-theme').innerHTML=LEVEL_THEMES.map(t=>`<option value="${t.id}">${t.label}</option>`).join('');
$('quick-theme').onchange=()=>setTheme(direction.themes.find(t=>t.id===$('quick-theme').value));
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);
$('quick-theme').value=next.id;
theme=directionTheme(direction,next.id,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);
$('item-study-content').style.setProperty('--scene-bg',theme.background);$('item-study-content').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('');
$('local-palette').innerHTML=theme.palette.map(i=>`<span title="${direction.pigments[i].name}" style="background:${direction.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}`;
for(const id of ['play-theme','game-link'])$(id).href=`index.html?design=${direction.id}&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)));
document.querySelectorAll('[data-theme]').forEach(b=>b.onclick=()=>setTheme(direction.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-theme].theme-card>small').forEach(el=>{const t=direction.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});});
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:directionTheme(direction,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,appearanceTile({color:Number(c.dataset.pigment),dir:1},theme));});
}
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;}
@@ -88,6 +97,15 @@ $('study-dialog-content').onchange=e=>{if(e.target.id==='study-slow')$('slow').c
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: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.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(direction.themes.find(t=>t.id===theme.id));reset();});
document.querySelector('.phone-top strong').textContent='测试关 01';
decorateIcons();setTheme(theme);controls();new ResizeObserver(resize).observe(canvas);
decorateIcons();controls();
mountDirectionReview(direction,next=>{
direction=next;
document.querySelectorAll('[data-theme]').forEach(b=>{const t=direction.themes.find(t=>t.id===b.dataset.theme);const scene=b.querySelector('.mini-scene');if(scene){scene.style.setProperty('--bg',t.background);scene.style.setProperty('--ink',t.ink);b.querySelector('strong').textContent=t.name;b.querySelector('canvas').setAttribute('aria-label',t.name+'配色');}const dot=b.querySelector('i');if(dot)dot.style.background=t.background;});
document.querySelectorAll('.pigment-item').forEach((el,i)=>{el.querySelector('small').textContent=direction.pigments[i].color;el.querySelector('strong').textContent=direction.pigments[i].name;el.querySelector('canvas').setAttribute('aria-label',direction.pigments[i].name+'棋子');});
$('phone').style.setProperty('--design-accent',direction.accent);
document.querySelector('.theme-collection h2').textContent=`${direction.name} · 五种关卡主题`;
setTheme(direction.themes.find(t=>t.id===theme.id)||direction.themes[0]);
});
new ResizeObserver(resize).observe(canvas);
+96
View File
@@ -0,0 +1,96 @@
import { opposite, random, rotateBoard, solveRotations, trace } from './core.js';
const positionKey = tile => `${tile.q},${tile.r}`;
const trackKey = (q, r, a, b) => `${q},${r}:${Math.min(a, b)},${Math.max(a, b)}`;
const countBends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length;
function shuffled(items, rng) {
const result = [...items];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
function referenceBends(level, solution) {
let board = level, remaining = [...level.tiles], maximum = 0;
for (const action of solution.actions) {
if (action.type === 'rotate') { board = rotateBoard(board, action.direction); continue; }
const tile = remaining.find(candidate => candidate.id === action.id);
const route = trace(board, tile, remaining);
if (route.status !== 'exit') return -1;
maximum = Math.max(maximum, countBends(route));
remaining = remaining.filter(candidate => candidate.id !== tile.id);
}
return maximum;
}
// A/B/C are a small state-dependent lock, not three independent long routes.
// At orientation 0 all three are blocked. At +1 only B can leave; A and C
// still block each other. Returning to 0 frees A, and then C. Additional pieces
// may be reverse-inserted at orientation 0 before this witness is played.
export function createStageSeed(base, {
seed = 0, minHeroBends = 7, maxHeroBends = 70, maxChecks = 1500000,
minFeaturedPairs = Math.min(6, minHeroBends), minFeaturedRatio = .6, requireReferenceLong = true,
} = {}) {
const rng = random(seed), turned = rotateBoard(base), pool = [];
const featured = new Set((base.routeDesign?.featured || []).map(pair => trackKey(pair.q, pair.r, pair.a, pair.b)));
for (const cell of base.cells) for (let dir = 0; dir < 6; dir++) {
const tile = { q: cell.q, r: cell.r, dir };
const route0 = trace(base, tile, [], true), route1 = trace(turned, tile, [], true);
if (route0.status !== 'exit' || route1.status !== 'exit') continue;
const matched = new Set(route0.steps.filter(step => step.enter !== null)
.map(step => trackKey(step.q, step.r, step.enter, step.exit)).filter(pair => featured.has(pair)));
pool.push({ tile, position: positionKey(tile), bends: countBends(route0), cells: route0.steps.length,
featuredPairs: matched.size, featuredRatio: matched.size / Math.max(1, route0.steps.length - 1),
at0: new Set(route0.steps.slice(1).map(positionKey)),
at1: new Set(route1.steps.slice(1).map(positionKey)) });
}
const candidates = shuffled(pool, rng);
const heroes = candidates.filter(candidate => candidate.bends >= minHeroBends && candidate.bends <= maxHeroBends
&& (!featured.size || (candidate.featuredPairs >= minFeaturedPairs && candidate.featuredRatio >= minFeaturedRatio)));
let checks = 0, formalChecks = 0;
for (const a of heroes) {
// B occupies A's current route but has a departure at +1 that avoids A.
const gates = candidates.filter(b => b.position !== a.position && a.at0.has(b.position) && !b.at1.has(a.position));
// C creates the second lock at +1 and does not obstruct A's return route.
const tails = candidates.filter(c => c.position !== a.position && !a.at0.has(c.position)
&& a.at1.has(c.position) && c.at1.has(a.position));
for (const b of gates) for (const c of tails) {
if (++checks > maxChecks) return null;
if (b.position === c.position || b.at1.has(c.position)
|| !(b.at0.has(a.position) || b.at0.has(c.position))
|| !(c.at0.has(a.position) || c.at0.has(b.position))) continue;
const tiles = [a, b, c].map((candidate, i) => ({ ...candidate.tile, id: `t${i}`, color: i }));
const [hero, gate, tail] = tiles;
formalChecks++;
// Grid membership proposes a lock; the production swept tracer certifies
// the actual contact and all departures, including the cross-track gaps.
if (trace(base, hero, tiles).blocker !== gate.id
|| trace(base, gate, tiles).status !== 'blocked'
|| trace(base, tail, tiles).status !== 'blocked'
|| trace(turned, gate, tiles).status !== 'exit'
|| trace(turned, hero, [hero, tail]).blocker !== tail.id
|| trace(turned, tail, [hero, tail]).blocker !== hero.id
|| trace(base, hero, [hero, tail]).status !== 'exit'
|| trace(base, tail, [tail]).status !== 'exit') continue;
const level = { ...base, tiles, moveLimit: tiles.length };
const solution = solveRotations(level);
if (!solution.solvable || solution.exhausted || solution.rotations !== 2) continue;
// The solver may choose +2 instead of returning to 0. Keep a long route
// in that independent witness as well as in our explicitly authored one.
if (requireReferenceLong && referenceBends(level, solution) < minHeroBends) continue;
return {
tiles, placements: [0, 1, 0], hero,
heroRoute: { bends: a.bends, cells: a.cells, featuredPairs: a.featuredPairs, featuredRatio: a.featuredRatio },
actions: [{ type: 'rotate', direction: 1 }, { type: 'tap', id: gate.id },
{ type: 'rotate', direction: -1 }, { type: 'tap', id: hero.id }, { type: 'tap', id: tail.id }],
reverseInsertion: [{ id: tail.id, orientation: 0 }, { id: hero.id, orientation: 0 }, { id: gate.id, orientation: 1 }],
minimumRotations: solution.rotations, solution,
search: { checks, formalChecks, heroCandidates: heroes.length },
};
}
}
return null;
}
+43
View File
@@ -0,0 +1,43 @@
import { roundedHex } from './tile-shape.js';
export function tint(hex,amount){return '#'+hex.slice(1).match(/../g).map(v=>Math.round(amount>=0?parseInt(v,16)+(255-parseInt(v,16))*amount:parseInt(v,16)*(1+amount)).toString(16).padStart(2,'0')).join('');}
export function drawStyledToken(p,x,y,r,tile,angle){
const fill=tile.fill,ink='#263A3A';
if(!fill||!tile.artStyle||tile.artStyle==='classic')return false;
const face=roundedHex(x,y,r*.94),closed=[...face,face[0]];
let arrowY=y,arrowScale=.58;
if(tile.artStyle==='vivid'){
p.polygon(roundedHex(x+r*.04,y+r*.07,r*.94),'#30264C');p.polygon(face,fill);p.line(closed,'#30264C',r*.065);
p.line([{x:x-r*.45,y:y-r*.49},{x:x-r*.18,y:y-r*.64}],'#FFFFFF',r*.055);
}else if(tile.artStyle==='toy'){
p.polygon(roundedHex(x,y+r*.09,r*.94),tint(fill,-.3));
p.polygon(face,tint(fill,-.08));p.polygon(roundedHex(x,y-r*.06,r*.83),fill);
const rim=roundedHex(x,y-r*.06,r*.75);p.line(rim.slice(9,18),tint(fill,.5),r*.045);
p.circle(x,y-r*.04,r*.50,tint(fill,.11));
}else{
p.polygon(face,'#FFFCED');p.line(closed,'#526F5744',r*.035);
const species=tile.color%4,earY=y-r*.40;
const oval=(cx,cy,rx,ry)=>Array.from({length:24},(_,i)=>({x:cx+Math.cos(i*Math.PI/12)*rx,y:cy+Math.sin(i*Math.PI/12)*ry}));
if(species===0){
p.polygon([{x:x-r*.58,y:y-r*.1},{x:x-r*.53,y:y-r*.73},{x:x-r*.08,y:y-r*.4}],fill);
p.polygon([{x:x+r*.58,y:y-r*.1},{x:x+r*.53,y:y-r*.73},{x:x+r*.08,y:y-r*.4}],fill);
}else if(species===2){
for(const sign of [-1,1]){p.polygon(oval(x+sign*r*.24,y-r*.48,r*.13,r*.32),fill);p.polygon(oval(x+sign*r*.24,y-r*.53,r*.055,r*.19),tint(fill,.55));}
}else if(species===1){
for(const sign of [-1,1]){p.circle(x+sign*r*.40,earY,r*.23,fill);p.circle(x+sign*r*.40,earY,r*.12,tint(fill,.4));}
}else{
for(const sign of [-1,1])p.circle(x+sign*r*.26,y-r*.43,r*.19,fill);
}
p.circle(x,y-r*.04,r*.51,fill);
for(const sign of [-1,1]){
const eyeY=species===3?y-r*.42:y-r*.19;
if(species===3)p.circle(x+sign*r*.21,eyeY,r*.10,'#FFFFFF');
p.circle(x+sign*r*.21,eyeY,r*.058,ink);p.circle(x+sign*r*.32,y+r*.02,r*.078,'#EF958B');
}
p.line([{x:x-r*.085,y:y-r*.035},{x,y:y+r*.025},{x:x+r*.085,y:y-r*.035}],ink,r*.035);
p.circle(x,y+r*.43,r*.36,'#FFFFFF');arrowY=y+r*.43;arrowScale=.44;
}
const a=r*arrowScale;
const shape=[[-.72,-.23],[.06,-.23],[.06,-.59],[.83,0],[.06,.59],[.06,.23],[-.72,.23]];
p.polygon(shape.map(([u,v])=>({x:x+a*(u*Math.cos(angle)-v*Math.sin(angle)),y:arrowY+a*(u*Math.sin(angle)+v*Math.cos(angle))})),tile.artStyle==='animals'?ink:tile.arrow);
return true;
}
+73
View File
@@ -0,0 +1,73 @@
import { hexagon, rotateBoard } from './core.js';
// Teaching keeps the complete 19-cell board used by the first campaign level.
// Only the number of pieces is reduced; every paired rail remains visible.
const scallopRight = [1, 0, 3, 2, 5, 4];
const scallopLeft = [5, 2, 1, 4, 3, 0];
const vertical = [3, 2, 1, 0, 5, 4];
// A central spine and repeating rounded side lobes form an orderly pattern.
// All three pairs in every cell are non-crossing. Turning this complete board
// opens two mirrored five-bend routes without changing the pieces' arrows.
const gardenBoard = () => hexagon(2).map(cell => ({
...cell,
tracks: [...(cell.q < 0 ? scallopLeft : cell.q > 0 ? scallopRight : vertical)],
}));
const openGarden = () => rotateBoard({ cells: gardenBoard() }, 1).cells;
function introduction(step, title, lesson, completion, cells, tiles, solution) {
return {
version: 1,
id: `trail-tutorial-${String(step).padStart(2, '0')}`,
name: title,
lesson,
rotationEnabled: step === 3,
moveLimit: tiles.length,
cells,
tiles,
solution,
tutorial: {
step,
title,
lesson,
completion,
nextLabel: step === 3 ? '进入第 1 关' : '下一课',
},
};
}
export const TUTORIAL_LEVELS = [
introduction(
1,
'点一下,出发',
'点一下棋子,看它沿着轨道离开棋盘。',
'箭头决定出发方向,棋子会沿轨道转弯。清空棋盘就过关。',
openGarden(),
[{ id: 'traveler', q: 0, r: -1, dir: 3, color: 0 }],
[{ type: 'tap', id: 'traveler' }],
),
introduction(
2,
'先给它让路',
'先点下方的棋子,再点上方的棋子,给它让出通路。',
'前方有棋子就走不通。让路后可以立刻点下一枚,不用等动画结束。',
openGarden(),
[
{ id: 'traveler', q: 0, r: -1, dir: 3, color: 0 },
{ id: 'gate', q: -1, r: 1, dir: 5, color: 1 },
],
[{ type: 'tap', id: 'gate' }, { type: 'tap', id: 'traveler' }],
),
introduction(
3,
'转一下,路就通了',
'点「左转」或「右转」,为两枚棋子换条路。',
'旋转只改变底盘轨道,棋子的位置和箭头不变。新路线接通,就能出发。',
gardenBoard(),
[
{ id: 'north', q: 0, r: -1, dir: 3, color: 0 },
{ id: 'south', q: 0, r: 1, dir: 0, color: 1 },
],
[{ type: 'rotate', direction: 1 }, { type: 'tap', id: 'north' }, { type: 'tap', id: 'south' }],
),
];
+54
View File
@@ -0,0 +1,54 @@
export const tutorialProgressKey = campaignId => `hex-trails-tutorial-v1-${campaignId}`;
function tutorialCount(count) {
return Number.isInteger(count) && count > 0 ? count : 0;
}
function normalizeProgress(value, count) {
const valid = index => Number.isInteger(index) && index >= 0 && index < count;
return {
current: valid(value?.current) ? value.current : 0,
completed: [...new Set(Array.isArray(value?.completed) ? value.completed.filter(valid) : [])],
};
}
export function readTutorialProgress(storage, campaignId, count) {
count = tutorialCount(count);
let value;
if (count) {
try { value = JSON.parse(storage?.getItem(tutorialProgressKey(campaignId)) ?? '{}'); } catch {}
}
return normalizeProgress(value, count);
}
export function writeTutorialProgress(storage, campaignId, count, progress) {
count = tutorialCount(count);
if (!count || !storage) return false;
try {
storage.setItem(tutorialProgressKey(campaignId), JSON.stringify(normalizeProgress(progress, count)));
return true;
} catch { return false; }
}
export function tutorialLaunch(search, count, progress, campaignProgress) {
count = tutorialCount(count);
if (!count) return null;
const params = new URLSearchParams(search);
const requested = params.get('tutorial');
if (/^\d+$/.test(requested ?? '')) {
const index = Number(requested) - 1;
if (Number.isInteger(index) && index >= 0 && index < count) return index;
}
// Existing level links keep their destination, including links to level 1.
if (params.has('level')) return null;
if ((Number.isInteger(campaignProgress?.current) && campaignProgress.current > 0)
|| (Array.isArray(campaignProgress?.completed) && campaignProgress.completed.length > 0)) return null;
const normalized = normalizeProgress(progress, count);
const completed = new Set(normalized.completed);
for (let offset = 0; offset < count; offset += 1) {
const index = (normalized.current + offset) % count;
if (!completed.has(index)) return index;
}
return null;
}