From 073b682234ce2fa53085d6b450d1a972f36d7fbc Mon Sep 17 00:00:00 2001 From: cabinw Date: Mon, 14 Sep 2026 18:46:32 +0800 Subject: [PATCH] feat(motion): raise the cruising speed, and make the show gates follow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pieces still read as slow. Cruising speed goes from 120ms per unit to 85: the median departure across the shipped fifty drops 970ms → 763ms and a level's whole animation budget 15.6s → 12.7s, about 18% off. The longest departures do not move — the cap holds them at 3.46s — so if those still drag, the dial is the cap, which was settled separately and is left alone. The gates that guarantee a level opens with something worth watching are stated in milliseconds, and they were picked while a piece crossed a unit in 120ms. Held fixed while the speed changes, they stop meaning what they meant: this change alone made 20 of the 50 shipped levels "fail" an opening gate every one of them had passed, purely from the unscaled number. Scaled to the dial, none fail — the set is unchanged, and the alarm was an artifact of the constant. So the cruise threshold and the opening floor now derive from the speed, naming the speed they were calibrated at. exitTiming takes msPerUnit the way it already took knee and maxMs, and the comparison lab gets a speed slider, reads the levels actually shipping rather than the prototype sweep they were selected from, and totals every route in the campaign instead of the four on screen — those four skew long, where the cap flattens any speed change to 6%. The test that pins constant cruising speed now reads the dial from the token instead of hard-coding 120, so tuning motion no longer looks like a broken guarantee. Co-Authored-By: Claude Opus 5 --- labs/exit-timing/index.html | 60 ++++++++++++++++++++++++++----------- scripts/prototype-d5.mjs | 9 ++++-- src/art-directions.js | 2 +- src/deep-lock.js | 15 +++++++++- src/design-tokens.js | 2 +- src/exit-animation.js | 8 ++--- src/geometry.js | 2 +- src/renderer.js | 19 ++++++------ tests/themes.test.js | 14 +++++---- 9 files changed, 88 insertions(+), 43 deletions(-) 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