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>
113 lines
14 KiB
JavaScript
113 lines
14 KiB
JavaScript
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';
|
|
import { LEVEL_THEMES, PIGMENTS, MOTION, themeWithColors } from './design-tokens.js';
|
|
import { LEVELS } from './levels.js';
|
|
import { Game, available, solveRotations, trace } from './core.js';
|
|
import { layout, hitCell } from './geometry.js';
|
|
import { CanvasPainter, drawScene, drawTile, exitDuration } from './renderer.js';
|
|
import { ICONS, ICON_SIZE, ICON_ATLAS } from './ui-skin.js';
|
|
import { TILE_ATLAS } from './sprites.js';
|
|
const taps=new TapQueue();
|
|
const $=id=>document.getElementById(id),canvas=$('preview'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
|
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
let boardViewport;
|
|
const regularLevel=createStudyLevel(3);
|
|
let studyLevel=createStudyLevel(5);
|
|
let colorCount=7;
|
|
let direction=artDirection(new URLSearchParams(location.search).get("design"));
|
|
let theme=themeWithColors(LEVEL_THEMES.find(t=>t.id===new URLSearchParams(location.search).get('theme'))||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(){
|
|
document.querySelectorAll('[data-icon]').forEach(el=>{
|
|
const f=ICONS[el.dataset.icon];
|
|
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>`;
|
|
});
|
|
}
|
|
$('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){
|
|
const url=new URL(location.href);url.searchParams.set('theme',next.id);history.replaceState(null,'',url);
|
|
$('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);
|
|
document.body.dataset.theme=theme.id;
|
|
$('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="${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?design=${direction.id}&theme=${theme.id}&colors=${colorCount}`;
|
|
paintSamples();draw();
|
|
}
|
|
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=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: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;}
|
|
function draw(now=performance.now()){
|
|
if(!view)return;
|
|
cancelAnimationFrame(frame);ctx.clearRect(0,0,canvas.width,canvas.height);
|
|
const state={theme,reduced,hint,queued:taps.ids};let done=false,rotated=false;
|
|
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};done=progress>=1;}
|
|
if(rotation){const t=Math.min(1,(now-rotation.start)/rotation.duration);state.boardAngle=-rotation.direction*Math.PI/3*Math.pow(1-t,3);rotated=t>=1;}
|
|
drawScene(painter,game.level,game.tiles,view,state);
|
|
if(done){const status=animation.route.status;animation=null;$('phone-status').textContent=game.won?'全部离场,漂亮!':status==='exit'?'成功离场 · 可以继续点击棋子':'发生碰撞 · 可撤销或转动底盘';$('motion-status').textContent=status==='exit'?'播放完成 · 再播一次可观察尾迹与碎片':'这条路线被阻挡了';controls();}
|
|
if(rotated){rotation=null;controls();const cb=afterRotation;afterRotation=null;if(cb){cb();return;}}
|
|
if(!animation&&!rotation){const next=taps.take(game.tiles);if(next){move(next);return;}}
|
|
if(animation||rotation)frame=requestAnimationFrame(draw);
|
|
}
|
|
function reset(){taps.clear();animation=null;rotation=null;afterRotation=null;hint=null;cancelAnimationFrame(frame);game=new Game(studyLevel);$('phone-status').textContent='点击棋子,沿轨道出发';$('motion-status').textContent='可反复播放,也可直接操作棋盘。';controls();draw();}
|
|
function move(tile){if(animation||rotation){if(taps.add(tile.id,animation?.route.tile.id))draw();return;}hint=null;const route=game.tap(tile.id);if(!['exit','blocked','loop'].includes(route.status))return;
|
|
if(route.status!=='exit')taps.clear();
|
|
const base=route.status==='exit'?exitDuration(route,theme.artStyle):MOTION.collisionMs;
|
|
animation={route,start:performance.now(),duration:reduced?150:base*($('slow').checked?2.5:1)};
|
|
$('phone-status').textContent=route.status==='exit'?'沿着轨道,找到出口…':'接触后沿原路返回';$('motion-status').textContent=route.status==='exit'?'正在播放:滑行 → 出口收束 → 六角余韵':'正在展示碰撞反馈';controls();draw();
|
|
}
|
|
function turn(direction,callback=null){if(animation||rotation)return;hint=null;const r=game.rotate(direction);if(r.status!=='rotated')return;afterRotation=callback;rotation={direction,start:performance.now(),duration:reduced?100:MOTION.rotationMs};$('phone-status').textContent='棋子不动,底盘旋转 60°';controls();draw();}
|
|
function demonstrate(){
|
|
if(animation||rotation)return;
|
|
if(game.won||game.moves<=0)reset();
|
|
const free=available(game.level,game.tiles);
|
|
if(free.length){free.sort((a,b)=>trace(game.level,b,game.tiles).steps.length-trace(game.level,a,game.tiles).steps.length);move(free[0]);return;}
|
|
const action=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves}).actions[0];
|
|
if(action?.type==='rotate')turn(action.direction,demonstrate);else{reset();$('motion-status').textContent='演示已重置,请再次播放。';}
|
|
}
|
|
$('demo').onclick=demonstrate;$('restart').onclick=reset;
|
|
$('left').onclick=()=>turn(-1);$('right').onclick=()=>turn(1);
|
|
$('undo').onclick=()=>{if(game.undo()){hint=null;controls();draw();$('phone-status').textContent='已撤销,状态恢复';}};
|
|
$('hint').onclick=()=>{const action=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves}).actions[0];if(action?.type==='tap'){hint=action.id;$('phone-status').textContent='点击标记的棋子,可以安全离场';draw();}else $('phone-status').textContent=action?.type==='rotate'?`试试${action.direction===1?'右':'左'}转底盘`:'全部完成,可重置演示';};
|
|
$('reset').onclick=()=>{taps.clear();$('study-dialog-content').innerHTML='<h2>选择关卡</h2><div class="study-levels">'+LEVELS.map((l,i)=>`<button data-level="${i}">${String(i+1).padStart(2,'0')}</button>`).join('')+'</div>';$('study-dialog').showModal();};
|
|
$('next-theme').onclick=()=>{taps.clear();$('study-dialog-content').innerHTML=`<h2>设置</h2><label><input id="study-slow" type="checkbox" ${$('slow').checked?'checked':''}> 慢速观察动画</label><button id="study-fit">恢复完整棋盘视图</button>`;$('study-dialog').showModal();};
|
|
$('study-close').onclick=()=>$('study-dialog').close();
|
|
$('study-dialog-content').onclick=e=>{const b=e.target.closest('[data-level]');if(b){studyLevel=LEVELS[Number(b.dataset.level)];document.querySelector('.phone-top strong').textContent=`LEVEL ${String(Number(b.dataset.level)+1).padStart(2,'0')}`;boardViewport.camera.reset();reset();resize();document.querySelectorAll('.density-card .mini-scene>span').forEach(el=>el.textContent=`${el.closest('[data-count]').dataset.count} 色 / ${studyLevel.tiles.length} 枚棋子`);$('study-dialog').close();}if(e.target.closest('#study-fit')){boardViewport.camera.reset();resize();$('study-dialog').close();}};
|
|
$('study-dialog-content').onchange=e=>{if(e.target.id==='study-slow')$('slow').checked=e.target.checked;};
|
|
boardViewport=attachBoardViewport(canvas,{changed:resize,tap:({x,y})=>{if(!view)return;const cell=hitCell(game.level.cells,view,x,y);const tile=cell&&game.tiles.find(t=>t.q===cell.q&&t.r===cell.r);if(tile)move(tile);}});
|
|
$('board-size').onchange=()=>{studyLevel=$('board-size').value==='regular'?regularLevel: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(direction.themes.find(t=>t.id===theme.id));reset();});
|
|
document.querySelector('.phone-top strong').textContent='测试关 01';
|
|
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);
|