diff --git a/labs/exit-timing/index.html b/labs/exit-timing/index.html new file mode 100644 index 0000000..7622c30 --- /dev/null +++ b/labs/exit-timing/index.html @@ -0,0 +1,227 @@ + + + + + +离场演出时长 · 对比台 + + + +
+

离场演出时长 · 对比台

+

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

+ +
+
+
+ + + 2.4s +
+
+ + + 3.2s +
+ + +
+
+ + + + +
+
+ +
+ +

+
+ + + + diff --git a/src/design-tokens.js b/src/design-tokens.js index 0f3ca19..cea1958 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:800,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:1700,exitLongStartCells:2,exitLongFullCells:5}); +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 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 73df305..3b4175d 100644 --- a/src/exit-animation.js +++ b/src/exit-animation.js @@ -1,14 +1,23 @@ import { MOTION } from './design-tokens.js'; import { routePoints } from './geometry.js'; -// Timing follows actual path length, so a piece beside an exit has no fixed wait. -export function exitTiming(route) { +// Timing follows actual path length, so a piece beside an exit has no fixed +// 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}={}) { 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 rampMs=Math.min(MOTION.exitRampMs,distance*MOTION.exitMsPerUnit/3); - const travelMs=distance*MOTION.exitMsPerUnit+rampMs; + const linear=distance*MOTION.exitMsPerUnit; + // 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)))); + const rampMs=Math.min(MOTION.exitRampMs,base/3); + const travelMs=base+rampMs; const duration=travelMs+MOTION.exitEffectMs; - return {duration,travelMs,distance,travelFraction:travelMs/duration}; + return {duration,travelMs,distance,travelFraction:travelMs/duration,capped:linear>knee}; } // Zero endpoint velocity: a soft launch and a settled finish, without overshoot. diff --git a/tests/themes.test.js b/tests/themes.test.js index 25ebc8e..73dc8fd 100644 --- a/tests/themes.test.js +++ b/tests/themes.test.js @@ -52,9 +52,11 @@ test('motion ramps gently at both ends, never reverses or overshoots',async()=>{ }); -test('curved and long routes share the same cruising speed without a duration cap',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'); - for(const distance of [1,5,15,30]){ + // 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(const distance of [1,5,15]){ const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]}); assert.equal(timing.travelMs,distance*120+Math.min(80,distance*40)); const t=timing.travelMs*.5,dt=1; @@ -62,6 +64,21 @@ test('curved and long routes share the same cruising speed without a duration ca assert.ok(Math.abs(speed-1/120)<1e-9); assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1); assert.ok(Math.abs(timing.duration-timing.travelMs-180)<1e-9); + assert.equal(timing.capped,false); + } + // Past the knee the piece speeds up so the journey stays watchable. Uncapped + // linear timing put a single departure at seventeen seconds. + const long=exitTiming({travelPoints:[{x:0,y:0},{x:150,y:0}],steps:[]}); + assert.equal(long.capped,true); + assert.ok(long.duration<4200,`一条超长路线不该超过 4.2s,实际 ${long.duration}`); + assert.ok(long.duration>exitTiming({travelPoints:[{x:0,y:0},{x:20,y:0}],steps:[]}).duration, + '再长的路线仍然要比中等路线久,长度感不能丢'); + // Monotone: a longer route never plays shorter than a shorter one. + let prev=0; + for(const d of [1,5,15,25,40,80,150,400]){ + const ms=exitTiming({travelPoints:[{x:0,y:0},{x:d,y:0}],steps:[]}).duration; + assert.ok(ms>=prev,`距离 ${d} 的时长 ${ms} 不该短于更短的路线 ${prev}`); + prev=ms; } });