diff --git a/labs/exit-timing/index.html b/labs/exit-timing/index.html index 7622c30..eacd6a9 100644 --- a/labs/exit-timing/index.html +++ b/labs/exit-timing/index.html @@ -54,10 +54,15 @@ canvas{width:100%;height:120px;display:block;background:var(--inset);border-radi

离场演出时长 · 对比台

-

同一条真实路线,左边是现状(不封顶,恒定速度),右边是候选设置。两边同时播放,直接看哪个手感对。路线取自当前关卡集,按长度挑了四条有代表性的。

+

同一条真实路线,左边是线上已发布的设定,右边是候选设定。两边同时播放,直接看哪个手感对。路线取自当前关卡集,按长度挑了四条有代表性的。
封顶(拐点 / 上限)之前已经定过,这一轮要定的是巡航速度——它决定 90% 的中短路线播多久。

+
+ + + 85ms +
@@ -72,10 +77,11 @@ canvas{width:100%;height:120px;display:block;background:var(--inset);border-radi
- - - - + + + + +
@@ -95,8 +101,10 @@ let routes = [], anims = []; // Pick four real routes spanning the length range, so the comparison is about // content the game actually ships rather than a synthetic straight line. +let population = []; async function loadRoutes() { - const res = await fetch('../../levels/prototypes/d5.json?t=' + Date.now()); + // The levels being shipped, not the prototype sweep they were selected from. + const res = await fetch('../../levels/campaign-50.json?t=' + Date.now()); const raw = await res.json(); const levels = Array.isArray(raw) ? raw : raw.levels; const all = []; @@ -112,6 +120,10 @@ async function loadRoutes() { } } all.sort((a, b) => a.distance - b.distance); + // The four rows are picked to be representative, not to be a sample you can + // average: they skew long, and long routes are cap-bound, so a speed change + // barely moves their total. The summary totals every route instead. + population = all; const at = p => all[Math.min(all.length - 1, Math.round(p * (all.length - 1)))]; return [ { label: '很短(贴边就走)', ...at(0.10) }, @@ -154,20 +166,23 @@ function draw(canvas, route, progress) { } function settings() { - return { knee: Number($('knee').value), maxMs: Number($('max').value) }; + return { msPerUnit: Number($('speed').value), knee: Number($('knee').value), maxMs: Number($('max').value) }; } +// The left column is what players have right now: every token at its shipped +// value, which is what exitTiming uses when given no overrides at all. +const SHIPPED = {}; function render() { const s = settings(); rowsEl.innerHTML = routes.map((r, i) => { - const now = exitTiming(r.route, { knee: Infinity, maxMs: Infinity }); + const now = exitTiming(r.route, SHIPPED); const next = exitTiming(r.route, s); return `

${r.label}

路径距离 ${r.distance.toFixed(1)} · 来自 ${r.level}
-
现状 · 不封顶
${(now.duration / 1000).toFixed(2)}s
- +
线上已发布
${(now.duration / 1000).toFixed(2)}s
+
`; }).join(''); @@ -177,9 +192,14 @@ function render() { } const s2 = settings(); const worst = routes[routes.length - 1]; - $('summary').textContent = `当前设置:拐点 ${(s2.knee / 1000).toFixed(1)}s、上限 ${(s2.maxMs / 1000).toFixed(1)}s。` - + `最长那条路线从 ${(exitTiming(worst.route, { knee: Infinity, maxMs: Infinity }).duration / 1000).toFixed(1)}s 压到 ` - + `${(exitTiming(worst.route, s2).duration / 1000).toFixed(1)}s。低于拐点的路线完全不受影响,手感和现在一样。`; + const sum = opts => population.reduce((n, r) => n + exitTiming(r.route, opts).duration, 0); + const total = sum(s2), totalNow = sum(SHIPPED); + const med = opts => { const v = population.map(r => exitTiming(r.route, opts).duration).sort((a, b) => a - b); return v[Math.floor(v.length / 2)]; }; + $('summary').textContent = `候选:巡航 ${s2.msPerUnit}ms/单位、拐点 ${(s2.knee / 1000).toFixed(1)}s、上限 ${(s2.maxMs / 1000).toFixed(1)}s。` + + `按全部 ${population.length} 条可离场路线算:中位单次 ${med(SHIPPED).toFixed(0)}ms → ${med(s2).toFixed(0)}ms,` + + `总时长 ${total < totalNow ? '快 ' + Math.round(100 - 100 * total / totalNow) + '%' : total > totalNow ? '慢 ' + Math.round(100 * total / totalNow - 100) + '%' : '一样'}。` + + `最长那条 ${(exitTiming(worst.route, SHIPPED).duration / 1000).toFixed(2)}s → ${(exitTiming(worst.route, s2).duration / 1000).toFixed(2)}s` + + `${exitTiming(worst.route, s2).capped ? '(被上限锁住,调速度动不了它,要动就改上限)' : ''}。`; } function play() { @@ -188,7 +208,7 @@ function play() { const s = settings(); const start = performance.now(); routes.forEach((r, i) => { - const now = exitTiming(r.route, { knee: Infinity, maxMs: Infinity }); + const now = exitTiming(r.route, SHIPPED); const next = exitTiming(r.route, s); const step = () => { const t = performance.now() - start; @@ -207,11 +227,15 @@ for (const id of ['knee', 'max']) { render(); }; } +$('speed').oninput = () => { + $('speed-v').textContent = $('speed').value + 'ms'; + document.querySelectorAll('#presets button').forEach(b => b.classList.remove('on')); + render(); +}; $('presets').onclick = e => { - const b = e.target.closest('button'); if (!b) return; - $('knee').value = b.dataset.k; $('max').value = b.dataset.m; - $('knee-v').textContent = (b.dataset.k / 1000).toFixed(1) + 's'; - $('max-v').textContent = (b.dataset.m / 1000).toFixed(1) + 's'; + const b = e.target.closest('button'); if (!b || !b.dataset.s) return; + $('speed').value = b.dataset.s; + $('speed-v').textContent = b.dataset.s + 'ms'; document.querySelectorAll('#presets button').forEach(x => x.classList.remove('on')); b.classList.add('on'); render(); diff --git a/scripts/prototype-d5.mjs b/scripts/prototype-d5.mjs index d7f31c5..17baa53 100644 --- a/scripts/prototype-d5.mjs +++ b/scripts/prototype-d5.mjs @@ -6,7 +6,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { Game, random } from '../src/core.js'; import { createRouteBoard, ROUTE_FAMILIES } from '../src/route-grammar.js'; -import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAudit, contrastStats, showStats } from '../src/deep-lock.js'; +import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAudit, contrastStats, showStats, openingShowFloor } from '../src/deep-lock.js'; const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; })); const SEEDS = Number(args.seeds ?? 8), TARGET = Number(args.target ?? 12), KEEP = Number(args.keep ?? 18); @@ -84,7 +84,10 @@ const grownEnough = rows.filter(r => r.stats.pieces >= MIN_PIECES); // an agent give up. It rejects exactly the levels every model stalled on. const MIN_RELEASE = Number(args.minRelease ?? 2); const MIN_CONTRAST = Number(args.minContrast ?? 12); -const MIN_OPENING_SHOW = Number(args.minOpeningShow ?? 800); +// Expressed at the speed it was calibrated at (120ms per unit) and scaled to +// whatever the speed dial is now, so re-tuning motion does not quietly reject +// levels that were fine. See openingShowFloor in deep-lock. +const MIN_OPENING_SHOW = openingShowFloor(Number(args.minOpeningShow ?? 800)); const MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3); const MIN_CRUISES = Number(args.minCruises ?? 3); // Rotation count is deliberately not capped: turning is free and it is the @@ -116,7 +119,7 @@ console.log(`\n【门槛】长成 ${grownEnough.length} → 首转双向 ${g1.le const g6 = g5.filter(r => r.contrast.contrast >= MIN_CONTRAST); const g7 = g6.filter(r => r.show.openingChoices >= MIN_OPENING_CHOICES); const g8 = g7.filter(r => r.show.openingBestMs >= MIN_OPENING_SHOW); -console.log(` → 开局可点≥${MIN_OPENING_CHOICES} ${g7.length} → 开局演出≥${(MIN_OPENING_SHOW / 1000).toFixed(1)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${playable.length}`); +console.log(` → 开局可点≥${MIN_OPENING_CHOICES} ${g7.length} → 开局演出≥${(MIN_OPENING_SHOW / 1000).toFixed(2)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${playable.length}`); console.log(`【候选池】旋转深度 ${JSON.stringify(playable.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}`); console.log(` 反差 中位 ${med(playable.map(r => r.contrast.contrast)).toFixed(1)} · 家族 ${JSON.stringify(playable.reduce((m, r) => (m[r.family] = (m[r.family] || 0) + 1, m), {}))}`); const KEEP_ALL = String(args.keep) === 'all'; diff --git a/src/art-directions.js b/src/art-directions.js index c852b30..4bcc20a 100644 --- a/src/art-directions.js +++ b/src/art-directions.js @@ -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,pad:1.1},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:.3},motion:{intro:720,pop:true,celebrate:720},hud:{level:'关卡',moves:'待解',tutorial:'入门'}, description:'奶油色世界上托一张白色软托盘,六角格像带白缝的软糖砖,藕紫轨道压在糖砖上。棋子是糖果色圆角六角,白色贴纸边,粗圆头白箭头。反馈用果糖色说话:路线点亮、糖砖出场、离场撒星。', principles:['白托盘加白缝,格与格靠留白分开,不画格线','十二色统一白色粗箭头,与白缝同一套白','状态用颜色和弹跳表达:路线点亮、碰撞橘红、出场从左上角一波弹开'], route:{blocked:'#F0663A',hint:'#F7C948'}, diff --git a/src/deep-lock.js b/src/deep-lock.js index 1cc78d2..6b1cd64 100644 --- a/src/deep-lock.js +++ b/src/deep-lock.js @@ -1,5 +1,6 @@ import { Game, available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js'; import { exitTiming } from './exit-animation.js'; +import { MOTION } from './design-tokens.js'; const positionKey = tile => `${tile.q},${tile.r}`; const countBends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length; @@ -262,6 +263,18 @@ export function legibility(level) { // route and leaving. So the show is measurable: how long each departure plays, // whether the level opens with one, and — the failure the design brief names // outright — how many actions can pass with nothing happening at all. +// What counts as a cruise, and what counts as an opening worth watching, were +// both picked in milliseconds while a piece crossed one unit in 120ms. Speed is +// a tuning dial, so holding those milliseconds fixed while the dial moves does +// not hold the experience fixed — it silently demands longer routes. Raising +// the speed once made 20 of 50 shipped levels "fail" an opening gate they had +// passed, purely from the unscaled number. They scale with the dial instead. +const SHOW_CALIBRATED_AT = 120; +const showScale = () => MOTION.exitMsPerUnit / SHOW_CALIBRATED_AT; +export const CRUISE_MS_AT_120 = 900; +const CRUISE_MS = CRUISE_MS_AT_120 * showScale(); +export const openingShowFloor = (msAt120 = 800) => msAt120 * showScale(); + export function showStats(level) { const solution = solveRotations(level); if (!solution.solvable || !solution.actions) return null; @@ -295,7 +308,7 @@ export function showStats(level) { medianMs: Math.round(sorted[Math.floor(sorted.length / 2)]), longestMs: Math.round(sorted[sorted.length - 1]), // A cruise: long enough that watching it is the point, not a blink. - cruises: shows.filter(ms => ms >= 900).length, + cruises: shows.filter(ms => ms >= CRUISE_MS).length, firstShowMs: firstShow ? Math.round(firstShow.ms) : null, firstShowAfter: firstShow ? firstShow.afterActions : null, maxDryActions: maxDry, diff --git a/src/design-tokens.js b/src/design-tokens.js index cea1958..315a193 100644 --- a/src/design-tokens.js +++ b/src/design-tokens.js @@ -38,7 +38,7 @@ export const SURFACES = Object.freeze({ disabled: 'disabled', tileBlue: 'tileBlue', tilePink: 'tilePink', tileYellow: 'tileYellow', tilePurple: 'tilePurple', }); export const TYPOGRAPHY = Object.freeze({level:28,moves:32,dialogTitle:24,body:14,label:12,caption:11}); -export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:800,rotationMs:240,exitMsPerUnit:120,exitRampMs:80,exitMinTravelMs:140,exitMaxTravelMs:2400,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:3200,exitLongStartCells:2,exitLongFullCells:5}); +export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:800,rotationMs:240,exitMsPerUnit:85,exitRampMs:80,exitMinTravelMs:140,exitMaxTravelMs:2400,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:3200,exitLongStartCells:2,exitLongFullCells:5}); export const RADII = Object.freeze({control:14,dialog:24,small:8}); export function luminance(hex){return hex.slice(1,7).match(/../g).map(v=>parseInt(v,16)/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((a,v,i)=>a+v*[.2126,.7152,.0722][i],0);} export function contrast(a,b){const x=luminance(a),y=luminance(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);} diff --git a/src/exit-animation.js b/src/exit-animation.js index 3b4175d..7bbd20a 100644 --- a/src/exit-animation.js +++ b/src/exit-animation.js @@ -5,12 +5,12 @@ import { routePoints } from './geometry.js'; // wait. Cruising speed is one constant for every piece — until the knee. Past // it the piece accelerates instead of taking longer, because uncapped linear // timing ran a single departure to seventeen seconds and reads as a stall -// rather than as a journey. Callers may override the ceiling to compare -// settings; labs/exit-timing does exactly that. -export function exitTiming(route,{knee=MOTION.exitMaxTravelMs,maxMs=MOTION.exitLongMaxMs}={}) { +// rather than as a journey. Callers may override the speed or the ceiling to +// compare settings; labs/exit-timing does exactly that. +export function exitTiming(route,{msPerUnit=MOTION.exitMsPerUnit,knee=MOTION.exitMaxTravelMs,maxMs=MOTION.exitLongMaxMs}={}) { const points=routePoints({...route,end:null},{size:1,ox:0,oy:0}); const distance=points.slice(1).reduce((sum,p,i)=>sum+Math.hypot(p.x-points[i].x,p.y-points[i].y),0); - const linear=distance*MOTION.exitMsPerUnit; + const linear=distance*msPerUnit; // Below the knee nothing changes: same speed as before, same feel. const base=linear<=knee?linear :Math.min(maxMs,knee+(linear-knee)*Math.max(0,(maxMs-knee))/Math.max(1,linear-knee)*(1-Math.exp(-(linear-knee)/Math.max(1,maxMs-knee)))); diff --git a/src/geometry.js b/src/geometry.js index 597afb0..a0b2ac9 100644 --- a/src/geometry.js +++ b/src/geometry.js @@ -1,6 +1,6 @@ import { position, PORTS, trackPoints } from './path-math.js'; export { hexPoints, trackPoints, PORTS } from './path-math.js'; -// pad adds that many cell radii of margin on every side (macaron keeps its dissolve inside the canvas with 1.1). +// pad adds that many cell radii of margin on every side (macaron keeps its exit dissolve inside the canvas with 0.3). 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; diff --git a/src/renderer.js b/src/renderer.js index 007707c..955b218 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -9,7 +9,7 @@ import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geome import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js'; export const COLORS=PIGMENTS.map(p=>p.color); // Macaron's exit ends in a bubble dissolve that needs more time than the shared 180 ms effect window. -const MACARON_DISSOLVE_MS=560; +const MACARON_DISSOLVE_MS=640; export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;} // Four-point sparkle star used by the macaron win celebration. const sparkle=(x,y,r)=>Array.from({length:8},(_,j)=>{const b=j*Math.PI/4-Math.PI/2,rr=j%2?r*.38:r;return {x:x+Math.cos(b)*rr,y:y+Math.sin(b)*rr};}); @@ -247,13 +247,14 @@ export function drawExit(p,route,view,progress,tile,reduced=false){ } } -// 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. +// Macaron exit: the token glides out through its port, slowing down as it crosses the tray edge (0.55s beyond the port +// over the first 45%), and dissolves mostly outside the board like a sugar cube: the solid face, rim and arrow fade +// while a lattice of dots in the token colour takes over; the dots erode from the rim inward, each shrinking, thinning +// towards white and drifting a little. Circles, polygons and alpha only; the noise is a fixed hash of the lattice position. 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 x=edge.x+dx*size*.06,y=edge.y+dy*size*.06,r=size*.90,solid=1-smoothMotion(t/.18); + const out=1-Math.pow(1-Math.min(1,t/.45),3),x=edge.x+dx*size*.55*out,y=edge.y+dy*size*.55*out,r=size*.90; + const solid=1-smoothMotion((t-.15)/.2); 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++){ @@ -262,12 +263,12 @@ function bubbleDissolve(p,edge,angle,size,tile,color,t){ 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; + const u=(t-(.16+.38*(1-rim)+.16*n1))/.28; if(u>=1)continue; - if(u<=0){p.circle(x+ox,y+oy,size*.115,base);continue;} + if(u<=0){if(solid<1)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); + const arrow=1-smoothMotion((t-.15)/.25); 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);} } diff --git a/tests/themes.test.js b/tests/themes.test.js index 73dc8fd..7f206a8 100644 --- a/tests/themes.test.js +++ b/tests/themes.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js'; +import { MOTION, PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js'; import { Game, clone, validateLevel, available } from '../src/core.js'; import { LEVELS } from '../src/levels.js'; import { drawExit, drawScene, CanvasPainter, exitGeometry } from '../src/renderer.js'; @@ -55,15 +55,19 @@ test('motion ramps gently at both ends, never reverses or overshoots',async()=>{ test('routes below the knee cruise at one speed; longer ones accelerate instead of dragging',async()=>{ const {exitTiming,travelProgress}=await import('../src/exit-animation.js'); // Below the knee the old guarantee still holds exactly: same cruising speed - // for every piece, no cap. distance*120 stays under 2400ms up to distance 20. + // for every piece, no cap. The speed itself is a tuning dial, so it is read + // from the token rather than written in — pinning the number here would make + // every speed change look like a broken guarantee. + const perUnit=MOTION.exitMsPerUnit; + assert.ok(20*perUnit