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 { exitTiming } from './exit-animation.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 } 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[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=``; }); } $('item-study-content').innerHTML=itemStudy(); $('theme-tabs').innerHTML=direction.themes.map(t=>``).join(''); $('theme-grid').innerHTML=direction.themes.map(t=>``).join(''); $('pigment-grid').innerHTML=PIGMENTS.map((p,i)=>`
${p.name}${p.color}
`).join(''); $('quick-theme').innerHTML=LEVEL_THEMES.map(t=>``).join(''); $('quick-theme').onchange=()=>setTheme(direction.themes.find(t=>t.id===$('quick-theme').value)); function setTheme(next){ $('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=>``).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'?exitTiming(route).duration: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='

选择关卡

'+LEVELS.map((l,i)=>``).join('')+'
';$('study-dialog').showModal();}; $('next-theme').onclick=()=>{taps.clear();$('study-dialog-content').innerHTML=`

设置

`;$('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=>``).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);