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:
+41
-17
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user