feat(levels): assemble the 50-level campaign and the sheet to review it

The deep-lock construction cannot go below three turns, so it cannot supply an
opening band — every level it makes lands mid-difficulty and the curve comes
out flat. harvest-easy pulls the shallow end from the shipped v0.5 set, but
only the 3% of it that holds up: 568 of 1000 need no rotation at all and 51
more are clearable by tapping at random, which is what the monkey threshold is
for. 33 levels survive, at one and two turns.

select-50 scores candidates on mechanical features only, spreads the pick
evenly across that range, and caps any one route grammar at 36% so the campaign
cannot quietly become one template repeated — the shipped 1000 turned out to be
thirty. Weights are a flag, so refitting them from a round of agent ratings is
a re-run rather than an edit.

The result is a curve rather than a plateau: difficulty score 3.85 / 6.24 /
7.60 across the three bands, rotations 1.8 / 3.0 / 3.5, and every level keeps
three or more opening choices with a show of at least a second.

The 50 register as a playable campaign edition, so review happens in the real
game; docs/review-50.html is the handover sheet, one card per level with the
opening position drawn from the solver rather than sketched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 16:53:57 +08:00
co-authored by Claude Opus 5
parent 0aa080c7c3
commit 96e5e4c21f
15 changed files with 87896 additions and 47 deletions
+41 -17
View File
@@ -1,7 +1,6 @@
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';
import { PIGMENTS, LEVEL_THEMES, themeForScore, MOTION, themeWithColors } from './design-tokens.js';
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
import { icon, installUI, decorateUI } from './ui.js';
@@ -10,13 +9,13 @@ import { CAMPAIGN_VERSIONS, campaignVersion } from './campaign-versions.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 { layout, hitCell, center } from './geometry.js';
import { TILE_ATLAS } from './sprites.js';
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
import { CanvasPainter, COLORS, drawScene, exitDuration } from './renderer.js';
const taps=new TapQueue();
const $=id=>document.getElementById(id);
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
let boardViewport;
let boardViewport,baseView=null;
let progressStorage;try{progressStorage=window.localStorage;}catch{}
let saveUnavailable=!progressStorage;
const launch=campaignLaunch(location.search,CAMPAIGN_VERSIONS,selectedCampaign(progressStorage));
@@ -29,14 +28,16 @@ let tutorialProgress=readTutorialProgress(progressStorage,campaign.id,campaign.t
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 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,intro=null,celebrate=null,celebrated=false,lastMoves=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:directionTheme(requestedDesign,(requestedTheme||themeForScore(score)).id,Number(new URLSearchParams(location.search).get('colors'))||7);
// Journey designs (signpost, macaron) spread their five worlds evenly over the campaign, the last one at night.
const chapterTheme=requestedDesign.journeyWorlds?requestedDesign.themes[Math.min(requestedDesign.themes.length-1,Math.floor(current/Math.max(1,LEVELS.length)*requestedDesign.themes.length))]:null;
activeTheme=mode==='edit'||trial?null:directionTheme(requestedDesign,(requestedTheme||chapterTheme||themeForScore(score)).id,Number(new URLSearchParams(location.search).get('colors'))||7);
const theme=activeTheme||LEVEL_THEMES[0];
document.body.dataset.theme=theme.id;
document.body.style.setProperty('--color-background',theme.background);
@@ -87,8 +88,13 @@ function renderStats(){
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>`;
$('moves-title').textContent=editing?'离场额度':trial?'可用额度':(requestedDesign.hud?.moves||'待解棋子');$('remaining-title').textContent=editing?'已放置':'待离场';
// Stacked HUD (macaron): a small label over a big number, the reference game's 关卡 / 分数 columns.
if(requestedDesign.hud&&!editing&&!trial){const h=requestedDesign.hud;$('level-eyebrow').innerHTML=teaching?`<small>${h.tutorial}</small><b>${teaching.step}<i>/${campaign.tutorials.length}</i></b>`:`<small>${h.level}</small><b>${current+1}</b>`;}
const shownMoves=editing?level.moveLimit:trial?game.moves:game.tiles.length;$('moves').textContent=shownMoves;
// Macaron: the counter pill pops when a token leaves (the reference game's score-pill bump).
if(requestedDesign.motion?.pop&&lastMoves!==null&&shownMoves<lastMoves){const pill=$('moves').closest('.moves-bubble');if(pill){pill.classList.remove('pop');void pill.offsetWidth;pill.classList.add('pop');}}
lastMoves=shownMoves;$('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 ↗';
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:''}`;
@@ -111,7 +117,19 @@ function renderStats(){
}
if(editing){$('editor-valid').textContent=level.tiles.length===0?'○ 等待放置棋子':a.exhausted?'… 尚未确定可解性':a.solvable?'✓ 已验证可解':'! 关卡暂不可解';$('editor-valid').classList.toggle('error',!a.solvable&&!a.exhausted&&level.tiles.length>0);$('editor-score').innerHTML=`${a.score}<small>/100</small>`;$('editor-analysis-text').textContent=`观察难度 ${a.score}/100 · ${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转。工作量 ${a.minimumActions??'—'} 次操作,参考 ${a.workload.estimatedSeconds} 秒(估算)。仅成功离场使用 1 额度,旋转与试探免费${a.exhausted?' · 搜索达到上限,尚未确定':!a.reachable?' · 存在无法避开的碰撞或闭环':!a.solvable?' · 离场额度不足':''}。`;$('editor-bars').innerHTML=bars(a);$('trial-button').disabled=!level.tiles.length;$('editor-undo').disabled=!editHistory.length;}
}
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);const base=layout((mode==='edit'?draft:game.level).cells,width,height);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();draw();}
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);const base=layout((mode==='edit'?draft:game.level).cells,width,height);baseView=base;view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();placeRotation();draw();}
// Designs with layout.rotation 'under-board' (signpost, macaron) hang the rotate controls under the board's lower slanted
// edges: from each edge midpoint drop a vertical to the tool discs' top edge and centre the button on that line's midpoint.
// layout.rim is the design's own margin around the cells. Other designs keep their layout row.
function placeRotation(){
const card=document.querySelector?.('.game-card');if(!card)return;
if(requestedDesign.layout?.rotation!=='under-board'||mode==='edit'||!baseView){for(const k of ['--rot-x','--rot-x2','--rot-y'])card.style.removeProperty(k);return;}
const s=baseView.size,cs=game.level.cells.map(c=>center(c,baseView)),rim=Math.max(1.5,s*(requestedDesign.layout.rim??.06));
const oL=Math.min(...cs.map(c=>c.x))-s-rim,oR=Math.max(...cs.map(c=>c.x))+s+rim,oT=Math.min(...cs.map(c=>c.y))-s*Math.sqrt(3)/2-rim,oB=Math.max(...cs.map(c=>c.y))+s*Math.sqrt(3)/2+rim;
const cr=canvas.getBoundingClientRect(),kr=card.getBoundingClientRect(),tool=card.querySelector('.board-tools .item-face')?.getBoundingClientRect();
const midY=cr.top-kr.top+oT+(oB-oT)*.875,toolTop=tool?tool.top-kr.top:kr.height-100;
card.style.setProperty('--rot-x',`${(cr.left-kr.left+oL+(oR-oL)*.25).toFixed(1)}px`);card.style.setProperty('--rot-x2',`${(cr.left-kr.left+oL+(oR-oL)*.75).toFixed(1)}px`);card.style.setProperty('--rot-y',`${((midY+toolTop)/2).toFixed(1)}px`);
}
function draw(now=performance.now()){
if(!view)return;
ctx.clearRect(0,0,width,height);
@@ -126,21 +144,25 @@ function draw(now=performance.now()){
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;}}
if(intro){const t=Math.min(1,(now-intro.start)/intro.duration);state.intro=t;if(t>=1)intro=null;}
if(celebrate){const t=Math.min(1,(now-celebrate.start)/celebrate.duration);state.celebrate=t;if(t>=1)celebrate=null;}
drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
if(!rotationAnimation&&mode==='play'&&!$('info-dialog').open){
const next=taps.take(game.tiles);
if(next){playTile(next);return;}
finish();
}
if(animations.length||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
if(animations.length||rotationAnimation||shake||intro||celebrate){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(){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();}
// Macaron plays a board entrance on every fresh board; other designs have motion.intro unset.
function beginIntro(){lastMoves=null;intro=requestedDesign.motion?.intro&&!reduced?{start:performance.now(),duration:requestedDesign.motion.intro}:null;}
function resetEffects(){taps.clear();intro=null;celebrate=null;celebrated=false;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();beginIntro();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();
boardViewport?.camera.reset();tutorialIndex=i;tutorialProgress={...tutorialProgress,current:i};trial=false;mode='play';game=new Game(campaign.tutorials[i]);helpUsed=false;resetEffects();beginIntro();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(){
@@ -154,7 +176,9 @@ function openResult(){ $('result').hidden=false;$('result').showModal();playSoun
$('result').addEventListener('cancel',e=>e.preventDefault());
function finish(){
if($('result').open)return;
if(mode==='edit'||animations.length||rotationAnimation||$('info-dialog').open)return;
if(mode==='edit'||animations.length||rotationAnimation||celebrate||$('info-dialog').open)return;
// Macaron celebrates on the board first (motion.celebrate), then opens the result once the burst has settled.
if(game.won&&requestedDesign.motion?.celebrate&&!celebrated&&!reduced){celebrated=true;celebrate={start:performance.now(),duration:requestedDesign.motion.celebrate};draw();return;}
if(game.won){
const teaching=activeTutorial();
if(teaching){
@@ -170,7 +194,7 @@ function finish(){
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'这份关卡的离场额度不足':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'仍有棋子等待离场,可以返回工坊增加额度。旋转和试探都没有扣除额度。':'剩余棋子的路线被阻挡,或轨道形成了闭环。'}</p><button class="primary" id="recover">${trial?'返回编辑关卡':game.history.length?'撤销上一步':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>trial?enterEditor():game.history.length?undo():restart();$('retry').onclick=restart;
}
}
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();showLesson();}
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();beginIntro();renderStats();draw();finish();showLesson();}
function undo(){if(animations.length||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
function playTile(tile){
if(rotationAnimation){if(taps.add(tile.id))draw();return;}
@@ -180,7 +204,7 @@ function playTile(tile){
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');animations.push({route,start:performance.now(),duration:reduced?100:exitTiming(route).duration});
playSound('exit');animations.push({route,start:performance.now(),duration:reduced?100:exitDuration(route,activeTheme?.artStyle)});
const opened=available(game.level,game.tiles).filter(t=>!before.has(t.id));
if(opened.length){hint=opened[0].id;toast(`移开这一枚,${opened.length} 枚棋子的路通了!`);}
}
@@ -306,7 +330,7 @@ function showMapDialog(){
$('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();
beginIntro();applyMode();renderMap();renderStats();resize();
installUI();