From a250baba539141d8af75f5a95775310d7eb52019 Mon Sep 17 00:00:00 2001 From: cabinw Date: Mon, 14 Sep 2026 16:41:06 +0800 Subject: [PATCH] fix(motion): stop a long departure from playing for seventeen seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit timing was linear in path length with no ceiling, so the longest route in the shipped set animated for 17.4s and 19% of all departures ran past two seconds. MOTION already carried exitMaxTravelMs and exitLongMaxMs for this; nothing read them. Below the knee nothing changes — every piece still cruises at the same speed, which is the property the old test protected and the reason motion reads as physical. Past it the piece accelerates rather than taking longer, approaching a ceiling. Length is still legible: longer routes still play longer, just with a compressing tail. The test now states the rule it is protecting instead of "no duration cap": constant speed below the knee, a bounded and monotone tail above it. labs/exit-timing plays the same real route side by side, uncapped against a candidate setting, with knee and ceiling on sliders — the numbers say 3.5s but only watching it says whether that is right. Defaults are provisional at 2.4s / 3.2s pending that call. Co-Authored-By: Claude Opus 5 --- labs/exit-timing/index.html | 227 ++++++++++++++++++++++++++++++++++++ src/design-tokens.js | 2 +- src/exit-animation.js | 19 ++- tests/themes.test.js | 21 +++- 4 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 labs/exit-timing/index.html 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; } });