From dc8f7f6ae753572cd35e4542f0647e59c7fd8573 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 14:12:01 +0700 Subject: [PATCH] feat(gacha): match the meteor glide to the source game's motion Measured from reference footage, the meteor sweeps in along a straight line from the upper left at about 23 degrees and eases out exponentially to a hover above the horizon, replacing the gravity arc. The trail is now a soft beam that narrows into the head with ribbons fanning along it, every tier gets the horizontal lens flare, and sparkles drift above the beam. --- renderer/src/remotion/GachaComposition.jsx | 110 ++++++++++++--------- renderer/src/remotion/gacha-timeline.js | 50 ++++++---- renderer/test/gacha-timeline.test.js | 52 +++++----- 3 files changed, 122 insertions(+), 90 deletions(-) diff --git a/renderer/src/remotion/GachaComposition.jsx b/renderer/src/remotion/GachaComposition.jsx index d3e3dfb..2c1c2fd 100644 --- a/renderer/src/remotion/GachaComposition.jsx +++ b/renderer/src/remotion/GachaComposition.jsx @@ -5,6 +5,7 @@ import { gachaTimeline, getLabelFontSize, getMeteorState, + meteorAngleDegrees, getRankLetter, getRarityPalette, getShakeOffset, @@ -19,9 +20,7 @@ const baseFont = const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); const skyStars = createStarfield(70, 7); -/** Seconds between motion-streak samples; the streak is the meteor's recent path. */ -const trailStep = 0.016; -const sparkLifetime = 0.55; +const sparkLifetime = 1; const meteorDuration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart; const starburstSpikes = 10; @@ -35,8 +34,8 @@ const meteorSparks = (() => { return Array.from({length: 80}, () => ({ born: random() * meteorDuration, inherit: 0.15 + random() * 0.3, - kickX: (random() - 0.5) * 0.5, - kickY: (random() - 0.7) * 0.5, + kickX: (random() - 0.6) * 0.3, + kickY: (random() - 0.85) * 0.35, size: 0.006 + random() * 0.012, })); })(); @@ -112,7 +111,9 @@ export const GachaComposition = (props) => { const meteorProgress = Math.min(1, Math.max(0, meteorElapsed / meteorDuration)); const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd; const head = getMeteorState(meteorElapsed, width, height); - const headRadius = height * interpolate(meteorProgress, [0, 1], [0.025, 0.085], clamp) * fx.headScale; + const headRadius = height * interpolate(meteorProgress, [0, 0.5], [0.02, 0.045], clamp) * fx.headScale; + // The beam reaches back past the frame edge, the way it trails in from off-screen. + const trailLength = width * 1.4; // Stretch the head along its velocity like motion blur, keeping its area. const headSpeed = Math.hypot(head.vx, head.vy); const headStretch = 1 + Math.min(0.9, headSpeed / (height * 1.6)); @@ -208,30 +209,49 @@ export const GachaComposition = (props) => { /> ) : null} - {meteorVisible - ? Array.from({length: fx.trailSamples}, (_, index) => { - const sampleElapsed = Math.min(meteorElapsed, meteorDuration) - index * trailStep; - if (sampleElapsed <= 0) { - return null; - } - const point = getMeteorState(sampleElapsed, width, height); - const fade = 1 - index / fx.trailSamples; - const radius = headRadius * (0.25 + 0.75 * fade ** 1.3); + {meteorVisible ? ( +
+
+ {Array.from({length: fx.ribbons}, (_, index) => { + const spread = fx.ribbons > 1 ? index / (fx.ribbons - 1) - 0.5 : 0; return (
); - }) - : null} + })} +
+ ) : null} {meteorVisible ? meteorSparks.slice(0, fx.sparks).map((spark, index) => { @@ -242,7 +262,7 @@ export const GachaComposition = (props) => { const origin = getMeteorState(spark.born, width, height); const life = age / sparkLifetime; const size = spark.size * height * (1 - life) * fx.headScale; - const sparkGravity = height * 0.9; + const sparkGravity = height * 0.25; const x = origin.x + (origin.vx * spark.inherit + spark.kickX * height) * age; const y = origin.y + (origin.vy * spark.inherit + spark.kickY * height) * age + 0.5 * sparkGravity * age * age; @@ -296,29 +316,27 @@ export const GachaComposition = (props) => { width: headRadius * 4, }} /> +
{props.rarity >= 4 ? ( - <> -
-
- +
) : null} ) : null} diff --git a/renderer/src/remotion/gacha-timeline.js b/renderer/src/remotion/gacha-timeline.js index 364ab3e..bc30fa6 100644 --- a/renderer/src/remotion/gacha-timeline.js +++ b/renderer/src/remotion/gacha-timeline.js @@ -59,16 +59,23 @@ export const getRarityPalette = (rarity) => palettes[rarity]; */ /** - * Initial vertical speed as a fraction of horizontal speed. Negative launches - * slightly upward, so gravity visibly bends the path over into a dive. + * Approach rate of the glide, per second. Fitted to reference footage of the + * source game: after a fast entry the meteor covers about 88% of the + * remaining distance in half a second, then hovers above the horizon. */ -const meteorEntrySlope = -0.3; +export const meteorApproachRate = 4.2; + +/** Glide direction: down and to the right, measured in the footage. */ +export const meteorAngleDegrees = 23; + +/** Where the meteor comes to hover, as fractions of the frame. */ +const meteorHover = {x: 0.55, y: 0.55}; /** - * Ballistic flight under constant gravity. Horizontal speed stays constant and - * vertical speed grows linearly, so the path is a true parabola that rises a - * touch, then bends over and steepens into a dive as the meteor speeds up. Gravity is solved so the - * meteor lands exactly at the impact point when the fall ends. + * Exponential ease-out glide along a straight line from off-screen upper left + * to the hover point, matching how the source game's meteor sweeps in fast + * and then slows to a near stop. The curve is normalised so the meteor is + * exactly at the hover point when the fall ends. * * @param {number} elapsed Seconds since launch; clamped to the fall. * @param {number} width @@ -78,16 +85,19 @@ const meteorEntrySlope = -0.3; export const getMeteorState = (elapsed, width, height) => { const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart; const t = Math.min(duration, Math.max(0, elapsed)); - const start = {x: width * 1.05, y: height * 0.1}; - const end = {x: width * 0.4, y: height * 0.72}; - const vx = (end.x - start.x) / duration; - const vy0 = Math.abs(vx) * meteorEntrySlope; - const gravity = (2 * (end.y - start.y - vy0 * duration)) / duration ** 2; + const angle = (meteorAngleDegrees * Math.PI) / 180; + const ux = Math.cos(angle); + const uy = Math.sin(angle); + const hover = {x: width * meteorHover.x, y: height * meteorHover.y}; + const distance = (hover.x + width * 0.15) / ux; + const norm = 1 - Math.exp(-meteorApproachRate * duration); + const remaining = 1 - (1 - Math.exp(-meteorApproachRate * t)) / norm; + const speed = (distance * meteorApproachRate * Math.exp(-meteorApproachRate * t)) / norm; return { - x: start.x + vx * t, - y: start.y + vy0 * t + 0.5 * gravity * t * t, - vx, - vy: vy0 + gravity * t, + x: hover.x - ux * distance * remaining, + y: hover.y - uy * distance * remaining, + vx: ux * speed, + vy: uy * speed, }; }; @@ -175,7 +185,7 @@ export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLet /** * @typedef {object} TierEffects * @property {number} headScale Meteor head size multiplier. - * @property {number} trailSamples Glow blobs drawn along the meteor trail. + * @property {number} ribbons Light ribbons fanning out along the trail beam. * @property {number} sparks Sparks shed by the meteor. * @property {boolean} halo Rainbow ring forming around the meteor before landing. * @property {number} skyFlood Peak opacity of the sky tint as the meteor lands. @@ -199,7 +209,7 @@ export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLet const tierEffects = { 3: { headScale: 1, - trailSamples: 22, + ribbons: 1, sparks: 12, halo: false, skyFlood: 0, @@ -215,7 +225,7 @@ const tierEffects = { }, 4: { headScale: 1.3, - trailSamples: 34, + ribbons: 3, sparks: 36, halo: false, skyFlood: 0.25, @@ -231,7 +241,7 @@ const tierEffects = { }, 5: { headScale: 1.75, - trailSamples: 48, + ribbons: 5, sparks: 80, halo: true, skyFlood: 0.7, diff --git a/renderer/test/gacha-timeline.test.js b/renderer/test/gacha-timeline.test.js index e598b22..f070d42 100644 --- a/renderer/test/gacha-timeline.test.js +++ b/renderer/test/gacha-timeline.test.js @@ -5,6 +5,7 @@ import { gachaTotalSeconds, getLabelFontSize, getMeteorState, + meteorAngleDegrees, getRankLetter, getRarityPalette, getShakeOffset, @@ -38,40 +39,43 @@ describe('gacha timeline', () => { expect(new Set(glows).size).toBe(3); }); - test('meteor follows a ballistic arc from off-screen upper right to the impact point', () => { + test('meteor glides in from off-screen upper left and comes to hover', () => { const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart; const start = getMeteorState(0, 640, 360); const end = getMeteorState(duration, 640, 360); - expect(start.x).toBeGreaterThan(640); - expect(start.vy).toBeLessThan(0); - expect(end.x).toBeCloseTo(640 * 0.4); - expect(end.y).toBeCloseTo(360 * 0.72); + expect(start.x).toBeLessThan(0); + expect(end.x).toBeCloseTo(640 * 0.55); + expect(end.y).toBeCloseTo(360 * 0.55); expect(getMeteorState(duration + 1, 640, 360)).toEqual(end); expect(getMeteorState(-1, 640, 360)).toEqual(start); }); - test('meteor keeps horizontal speed and gains vertical speed at a constant rate', () => { - const step = 0.2; - const samples = [0, 1, 2, 3, 4, 5].map((index) => getMeteorState(index * step, 640, 360)); - for (const sample of samples) { - expect(sample.vx).toBeCloseTo(samples[0]?.vx ?? NaN); - expect(sample.vx).toBeLessThan(0); - } - const gravity = ((samples[1]?.vy ?? 0) - (samples[0]?.vy ?? 0)) / step; - expect(gravity).toBeGreaterThan(0); - for (let index = 1; index < samples.length; index += 1) { - expect(((samples[index]?.vy ?? 0) - (samples[index - 1]?.vy ?? 0)) / step).toBeCloseTo(gravity); + test('meteor moves along a straight line at the measured angle', () => { + const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart; + const start = getMeteorState(0, 640, 360); + for (const t of [0.2, 0.5, 1, duration]) { + const point = getMeteorState(t, 640, 360); + const degrees = (Math.atan2(point.y - start.y, point.x - start.x) * 180) / Math.PI; + expect(degrees).toBeCloseTo(meteorAngleDegrees); } }); - test('meteor bends from a slight climb into a steep dive', () => { + test('meteor sweeps in fast and decelerates exponentially', () => { const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart; - const early = getMeteorState(0, 640, 360); - const late = getMeteorState(duration, 640, 360); - const climbDegrees = (Math.atan2(-early.vy, Math.abs(early.vx)) * 180) / Math.PI; - const diveDegrees = (Math.atan2(late.vy, Math.abs(late.vx)) * 180) / Math.PI; - expect(climbDegrees).toBeGreaterThan(10); - expect(diveDegrees).toBeGreaterThan(45); + const start = getMeteorState(0, 640, 360); + const end = getMeteorState(duration, 640, 360); + const half = getMeteorState(0.5, 640, 360); + const covered = (half.x - start.x) / (end.x - start.x); + expect(covered).toBeGreaterThan(0.85); + expect(covered).toBeLessThan(0.9); + const speeds = [0, 0.25, 0.5, 1, 2].map((t) => { + const state = getMeteorState(t, 640, 360); + return Math.hypot(state.vx, state.vy); + }); + for (let index = 1; index < speeds.length; index += 1) { + expect(speeds[index]).toBeLessThan(speeds[index - 1] ?? 0); + } + expect((speeds[0] ?? 0) / (speeds.at(-1) ?? 1)).toBeGreaterThan(1000); }); test('starfield is deterministic per seed', () => { @@ -91,7 +95,7 @@ describe('gacha timeline', () => { const [three, four, five] = [3, 4, 5].map((rarity) => getTierEffects(/** @type {3 | 4 | 5} */ (rarity))); for (const key of /** @type {const} */ ([ 'headScale', - 'trailSamples', + 'ribbons', 'sparks', 'shockwaves', 'raysOpacity',