feat(levels): gate the dead turn, and state the curve instead of hoping for it
Round 1 played all fifty levels and came back with the mechanics clean and one
complaint repeated across the reports: 短暂以为转错了方向 — a turn that changed
nothing. That one is mechanical rather than perceptual, so it can be gated.
The first attempt gated `doubleDead`, and it was wrong. That counts dead states,
which scales with the state space: three-turn levels have ~187 reachable stuck
states and five-turn ones ~739, so a raw count made deep levels look eight times
worse when the rate is 2% against 5%. It would have erased every level past
three turns — the opposite of the brief, where turning is the signature and is
not to be rationed. The gate is `maxDeadTurns`, the longest run of turns before
something opens, at 1: one beat of suspense, then a release. Eleven of the fifty
shipped at 2.
Selection could not express the curve either. Spreading picks evenly over the
ranked pool returns whatever the pool is made of, and the pool is 88%
three-turn, so no weighting moved the distribution off {3: 46}. A quota per
depth states the curve outright, deepest first so the scarce end picks before a
family cap is spent on levels the shallow end has hundreds of.
Same rule bot, both sets: blind rotations 0.24 → 0.12, worst blind streak 4 → 1,
decisions unchanged at 10.3. The same amount to do, half the wasted motion.
Rotation now runs 1.8 / 3.0 / 4.1 across the bands against 1.8 / 3.0 / 3.5.
Verdicts are keyed by the board rather than by its id, because a reselect
re-issues ids from the ordinal and `c50-01-v05` is not the same board before and
after — the review sheet was one reselect away from printing one level's verdict
under another's thumbnail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -117,7 +117,7 @@ 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);baseView=base;view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();placeRotation();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,mode==='edit'?0:requestedDesign.layout?.pad||0);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.
|
||||
|
||||
@@ -94,7 +94,7 @@ export const ART_DIRECTIONS=Object.freeze([
|
||||
[[0,2,5,3,1,8,6],[1,0,2,6,3,9,7],[3,6,1,0,4,10,2],[6,0,3,5,2,11,8],[2,5,0,6,1,9,3]],
|
||||
['晨雾蓝灰世界色配奶白卡片,靛蓝、松石与琥珀领色。','杏仁暖砂世界色,朱砂与靛蓝领色,像午后的纸面。','苔原灰绿世界色,紫藤与玫红提亮。','暮砂玫瑰灰世界色,玫红与靛蓝对照。','夜航深蓝灰世界色,奶白卡片像被灯照亮的图。']),
|
||||
pigments:makePigments(['#2F5FBF','#C4432F','#1C8378','#7A4EB8','#4A8235','#A8690C','#BF3A6B','#4A6B8A','#B0572F','#357358','#8A5C92','#5A6D7C'],['靛蓝','朱砂','松石','紫藤','苔绿','琥珀','玫红','钢蓝','赤陶','墨绿','藕紫','铁灰']).map(p=>({...p,arrow:'#FFFFFF'}))},
|
||||
{id:'macaron',code:'07',name:'马卡龙',en:'MACARON',tag:'参考 Meowdoku',style:'macaron',accent:'#E07416',paper:'#FBF6F0',journeyWorlds:true,layout:{rotation:'under-board',rim:.40},motion:{intro:720,pop:true,celebrate:720},hud:{level:'关卡',moves:'待解',tutorial:'入门'},
|
||||
{id:'macaron',code:'07',name:'马卡龙',en:'MACARON',tag:'参考 Meowdoku',style:'macaron',accent:'#E07416',paper:'#FBF6F0',journeyWorlds:true,layout:{rotation:'under-board',rim:.40,pad:1.1},motion:{intro:720,pop:true,celebrate:720},hud:{level:'关卡',moves:'待解',tutorial:'入门'},
|
||||
description:'奶油色世界上托一张白色软托盘,六角格像带白缝的软糖砖,藕紫轨道压在糖砖上。棋子是糖果色圆角六角,白色贴纸边,粗圆头白箭头。反馈用果糖色说话:路线点亮、糖砖出场、离场撒星。',
|
||||
principles:['白托盘加白缝,格与格靠留白分开,不画格线','十二色统一白色粗箭头,与白缝同一套白','状态用颜色和弹跳表达:路线点亮、碰撞橘红、出场从左上角一波弹开'],
|
||||
route:{blocked:'#F0663A',hint:'#F7C948'},
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import { position, PORTS, trackPoints } from './path-math.js';
|
||||
export { hexPoints, trackPoints, PORTS } from './path-math.js';
|
||||
export function layout(cells,width,height) {
|
||||
// pad adds that many cell radii of margin on every side (macaron keeps its dissolve inside the canvas with 1.1).
|
||||
export function layout(cells,width,height,pad=0) {
|
||||
const raw=cells.map(c=>position(c.q,c.r));
|
||||
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
|
||||
const minY=Math.min(...raw.map(p=>p.y))-.866,maxY=Math.max(...raw.map(p=>p.y))+.866;
|
||||
const cellLimit=Math.max(47,Math.min(76,Math.min(width,height)*.1));
|
||||
const size=Math.min((width-48)/(maxX-minX),(height-50)/(maxY-minY),cellLimit);
|
||||
const size=Math.min((width-48)/(maxX-minX+2*pad),(height-50)/(maxY-minY+2*pad),cellLimit);
|
||||
const ox=width/2-(minX+maxX)/2*size,oy=height/2-(minY+maxY)/2*size;
|
||||
return {size,ox,oy,bounds:{top:minY*size+oy,bottom:maxY*size+oy}};
|
||||
}
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+20
-20
@@ -247,27 +247,27 @@ export function drawExit(p,route,view,progress,tile,reduced=false){
|
||||
}
|
||||
}
|
||||
|
||||
// Macaron exit: the token reaches its port, swells like a bubble, pops, and drifts off as smaller bubbles with
|
||||
// highlights that fade out. Circles with alpha only; every bubble's offsets are fixed so all frames agree.
|
||||
// Macaron exit: the token stops at its port and dissolves like a sugar cube. The solid face, rim and arrow fade in the
|
||||
// first fifth while a lattice of dots in the token colour takes over; the dots then erode from the rim inward, each
|
||||
// shrinking, thinning towards white and drifting a little as it goes. Circles, polygons and alpha only; the noise is a
|
||||
// fixed hash of the lattice position, so every frame agrees.
|
||||
function bubbleDissolve(p,edge,angle,size,tile,color,t){
|
||||
const dx=Math.cos(angle),dy=Math.sin(angle),alphaHex=v=>Math.round(Math.max(0,Math.min(1,v))*255).toString(16).padStart(2,'0'),base=color.slice(0,7);
|
||||
const reach=smoothMotion(Math.min(1,t/.38)),at={x:edge.x+dx*size*.22*reach,y:edge.y+dy*size*.22*reach};
|
||||
const swell=t<.38?1+.14*smoothMotion(t/.38):Math.max(0,1.14*(1-(t-.38)/.10));
|
||||
if(swell>0)drawTile(p,at.x,at.y,size,tile,angle,swell);
|
||||
const pop=(t-.36)/.30;
|
||||
if(pop>0&&pop<1){const r=size*(.5+.9*(1-Math.pow(1-pop,3))),ring=Array.from({length:36},(_,i)=>({x:at.x+Math.cos(i/36*Math.PI*2)*r,y:at.y+Math.sin(i/36*Math.PI*2)*r}));p.line([...ring,ring[0]],tint(base,.45)+alphaHex(.8*(1-pop)),Math.max(.6,size*.09*(1-pop)));}
|
||||
// Ten bubbles leave the popped face together: three large, the rest small, spread across the face and drifting
|
||||
// outward past the port, upward, with a slow wobble, so the cloud stays on screen long enough to read.
|
||||
for(let i=0;i<10;i++){
|
||||
const s1=(i*7919%97)/97,s2=(i*104729%89)/89,s3=(i*15485863%83)/83;
|
||||
const born=.34+.12*s1,life=.48+.18*s2,u=(t-born)/life;
|
||||
if(u<=0||u>=1)continue;
|
||||
const side=(s3-.5)*2.4,along=(s1-.5)*1.4,r0=size*(i<3?.22+.12*s2:.11+.12*s2);
|
||||
const drift=size*(.05+(.35+.5*s2)*(1-Math.pow(1-u,2))),wobble=Math.sin(u*Math.PI*1.5+s1*6.28)*size*.12;
|
||||
const bx=at.x+dx*(along*size*.35+drift)-dy*(side*size*.5+wobble),by=at.y+dy*(along*size*.35+drift)+dx*(side*size*.5+wobble)-size*.42*u;
|
||||
const grow=Math.min(1,u/.18),fade=u>.6?1-(u-.6)/.4:1,r=r0*grow*fade;
|
||||
if(r<=0)continue;
|
||||
p.circle(bx,by,r,tint(base,.28)+alphaHex(.92*fade));
|
||||
p.circle(bx-r*.32,by-r*.34,r*.28,'#FFFFFF'+alphaHex(.9*fade));
|
||||
const x=edge.x+dx*size*.06,y=edge.y+dy*size*.06,r=size*.90,solid=1-smoothMotion(t/.18);
|
||||
if(solid>0){const face=roundedHex(x,y,r,.16);p.polygon(face,base+alphaHex(solid));p.line([...face,face[0]],'#FFFFFF'+alphaHex(solid),Math.max(1,r*.10));}
|
||||
const step=size*.17,inset=r*.82,rows=Math.ceil(inset/(step*.866))+1,cols=Math.ceil(inset/step)+1;
|
||||
for(let j=-rows;j<=rows;j++)for(let i=-cols;i<=cols;i++){
|
||||
const ox=(i+(j&1?.5:0))*step,oy=j*step*.866,ax=Math.abs(ox),ay=Math.abs(oy);
|
||||
if(ay>inset*.866||ax*.866+ay*.5>inset*.866)continue;
|
||||
let h=(i*374761393+j*668265263)|0;h=Math.imul(h^(h>>>13),1274126177);h^=h>>>16;
|
||||
const n1=((h>>>0)%1000)/1000,n2=(((h>>>10)>>>0)%1000)/1000;
|
||||
const rim=Math.max(ay,ax*.866+ay*.5)/(inset*.866);
|
||||
const u=(t-(.05+.40*(1-rim)+.20*n1))/.33;
|
||||
if(u>=1)continue;
|
||||
if(u<=0){p.circle(x+ox,y+oy,size*.115,base);continue;}
|
||||
const k=1-smoothMotion(u),drift=size*.14*u,a=n2*Math.PI*2;
|
||||
p.circle(x+ox+Math.cos(a)*drift,y+oy+Math.sin(a)*drift-size*.10*u,size*.115*k,tint(base,.45*u)+alphaHex(.95*(1-u*.6)));
|
||||
}
|
||||
const arrow=1-smoothMotion(t/.3);
|
||||
if(arrow>0){const rot=pts=>pts.map(([u,v])=>({x:x+r*(u*Math.cos(angle)-v*Math.sin(angle)),y:y+r*(u*Math.sin(angle)+v*Math.cos(angle))})),w=Math.max(1.5,r*.20),white='#FFFFFF'+alphaHex(arrow);p.line(rot([[-.34,0],[.30,0]]),white,w);p.line(rot([[.02,-.30],[.30,0],[.02,.30]]),white,w);}
|
||||
}
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ function paintSamples(){
|
||||
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 resize(){const {width,height}=fit(canvas);const base=layout(game.level.cells,width,height,direction.layout?.pad||0);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;
|
||||
|
||||
Reference in New Issue
Block a user