From 004325d9470a49a25dfb67628126b4806a6832d3 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 13:58:38 +0700 Subject: [PATCH] feat(gacha): make the meteor fall under real gravity The meteor now follows a ballistic parabola: constant horizontal speed and vertical speed growing under gravity solved from the launch and impact points, so it climbs slightly, bends over, and dives while speeding up. The trail samples past positions in time so it lengthens with speed, the head stretches along its velocity, and sparks inherit the meteor's velocity and fall under their own gravity. --- renderer/src/remotion/GachaComposition.jsx | 62 +++++++++++++--------- renderer/src/remotion/gacha-timeline.js | 44 ++++++++++----- renderer/test/gacha-timeline.test.js | 43 +++++++++++---- 3 files changed, 102 insertions(+), 47 deletions(-) diff --git a/renderer/src/remotion/GachaComposition.jsx b/renderer/src/remotion/GachaComposition.jsx index 6f2f5a9..d3e3dfb 100644 --- a/renderer/src/remotion/GachaComposition.jsx +++ b/renderer/src/remotion/GachaComposition.jsx @@ -4,7 +4,7 @@ import { createStarfield, gachaTimeline, getLabelFontSize, - getMeteorPoint, + getMeteorState, getRankLetter, getRarityPalette, getShakeOffset, @@ -19,19 +19,24 @@ const baseFont = const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); const skyStars = createStarfield(70, 7); -const trailSpacing = 0.011; +/** Seconds between motion-streak samples; the streak is the meteor's recent path. */ +const trailStep = 0.016; +const sparkLifetime = 0.55; +const meteorDuration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart; const starburstSpikes = 10; /** - * Sparks shed by the meteor. Each is born at a point along the fall and - * drifts away from it while fading. Tiers draw a prefix of this pool. + * Sparks shed by the meteor. Each is born on the path, keeps part of the + * meteor's velocity plus a random kick, and then falls under gravity while + * fading. Tiers draw a prefix of this pool. */ const meteorSparks = (() => { const random = createRandom(11); return Array.from({length: 80}, () => ({ - born: random(), - driftX: (random() - 0.5) * 0.1, - driftY: (random() - 0.2) * 0.1, + born: random() * meteorDuration, + inherit: 0.15 + random() * 0.3, + kickX: (random() - 0.5) * 0.5, + kickY: (random() - 0.7) * 0.5, size: 0.006 + random() * 0.012, })); })(); @@ -103,13 +108,15 @@ export const GachaComposition = (props) => { const fx = getTierEffects(props.rarity); const tl = gachaTimeline; - const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], { - ...clamp, - easing: Easing.in(Easing.quad), - }); + const meteorElapsed = seconds - tl.meteorStart; + const meteorProgress = Math.min(1, Math.max(0, meteorElapsed / meteorDuration)); const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd; - const head = getMeteorPoint(meteorProgress, width, height); + const head = getMeteorState(meteorElapsed, width, height); const headRadius = height * interpolate(meteorProgress, [0, 1], [0.025, 0.085], clamp) * fx.headScale; + // 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)); + const headAngle = (Math.atan2(head.vy, head.vx) * 180) / Math.PI; const haloOpacity = fx.halo ? interpolate(meteorProgress, [0.45, 0.8], [0, 1], clamp) : 0; const skyFlood = interpolate(seconds, [tl.meteorStart + 1.2, tl.meteorEnd], [0, fx.skyFlood], clamp); @@ -117,7 +124,7 @@ export const GachaComposition = (props) => { ...clamp, easing: Easing.inOut(Easing.quad), }); - const impact = getMeteorPoint(1, width, height); + const impact = getMeteorState(meteorDuration, width, height); const bloomRadius = interpolate(seconds, [tl.flashStart, tl.flashPeak], [0.1, 2.2], clamp) * width; const revealed = seconds >= tl.revealStart; @@ -203,11 +210,11 @@ export const GachaComposition = (props) => { {meteorVisible ? Array.from({length: fx.trailSamples}, (_, index) => { - const sampleProgress = meteorProgress - index * trailSpacing; - if (sampleProgress <= 0) { + const sampleElapsed = Math.min(meteorElapsed, meteorDuration) - index * trailStep; + if (sampleElapsed <= 0) { return null; } - const point = getMeteorPoint(sampleProgress, width, height); + const point = getMeteorState(sampleElapsed, width, height); const fade = 1 - index / fx.trailSamples; const radius = headRadius * (0.25 + 0.75 * fade ** 1.3); return ( @@ -228,15 +235,17 @@ export const GachaComposition = (props) => { {meteorVisible ? meteorSparks.slice(0, fx.sparks).map((spark, index) => { - if (spark.born > meteorProgress) { + const age = Math.min(meteorElapsed, meteorDuration) - spark.born; + if (age < 0 || age >= sparkLifetime) { return null; } - const age = (meteorProgress - spark.born) * 4; - if (age >= 1) { - return null; - } - const origin = getMeteorPoint(spark.born, width, height); - const size = spark.size * height * (1 - age) * fx.headScale; + 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 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; return (
{ borderRadius: '50%', boxShadow: `0 0 ${size * 2}px ${palette.glow}`, height: size, - left: origin.x + spark.driftX * width * age, - opacity: 1 - age, + left: x, + opacity: 1 - life, position: 'absolute', - top: origin.y + spark.driftY * height * age, + top: y, width: size, }} /> @@ -283,6 +292,7 @@ export const GachaComposition = (props) => { left: head.x - headRadius * 2, position: 'absolute', top: head.y - headRadius * 2, + transform: `rotate(${headAngle}deg) scale(${headStretch}, ${1 / Math.sqrt(headStretch)})`, width: headRadius * 4, }} /> diff --git a/renderer/src/remotion/gacha-timeline.js b/renderer/src/remotion/gacha-timeline.js index a479e00..364ab3e 100644 --- a/renderer/src/remotion/gacha-timeline.js +++ b/renderer/src/remotion/gacha-timeline.js @@ -51,23 +51,43 @@ const palettes = { export const getRarityPalette = (rarity) => palettes[rarity]; /** - * Point on the meteor's curved fall from the upper right toward the lower - * left of the frame, as a quadratic Bézier. + * @typedef {object} MeteorState + * @property {number} x Pixels. + * @property {number} y Pixels. + * @property {number} vx Pixels per second. + * @property {number} vy Pixels per second. + */ + +/** + * Initial vertical speed as a fraction of horizontal speed. Negative launches + * slightly upward, so gravity visibly bends the path over into a dive. + */ +const meteorEntrySlope = -0.3; + +/** + * 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. * - * @param {number} progress 0 at launch, 1 at landing; values outside clamp. + * @param {number} elapsed Seconds since launch; clamped to the fall. * @param {number} width * @param {number} height - * @returns {{x: number, y: number}} + * @returns {MeteorState} */ -export const getMeteorPoint = (progress, width, height) => { - const t = Math.min(1, Math.max(0, progress)); - const start = {x: width * 1.08, y: -height * 0.18}; - const control = {x: width * 0.8, y: height * 0.3}; - const end = {x: width * 0.42, y: height * 0.6}; - const u = 1 - t; +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; return { - x: u * u * start.x + 2 * u * t * control.x + t * t * end.x, - y: u * u * start.y + 2 * u * t * control.y + t * t * end.y, + x: start.x + vx * t, + y: start.y + vy0 * t + 0.5 * gravity * t * t, + vx, + vy: vy0 + gravity * t, }; }; diff --git a/renderer/test/gacha-timeline.test.js b/renderer/test/gacha-timeline.test.js index 260b62b..e598b22 100644 --- a/renderer/test/gacha-timeline.test.js +++ b/renderer/test/gacha-timeline.test.js @@ -4,7 +4,7 @@ import { gachaTimeline, gachaTotalSeconds, getLabelFontSize, - getMeteorPoint, + getMeteorState, getRankLetter, getRarityPalette, getShakeOffset, @@ -38,15 +38,40 @@ describe('gacha timeline', () => { expect(new Set(glows).size).toBe(3); }); - test('meteor falls from off-screen upper right toward the lower left', () => { - const start = getMeteorPoint(0, 640, 360); - const end = getMeteorPoint(1, 640, 360); + test('meteor follows a ballistic arc from off-screen upper right to the impact point', () => { + 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.y).toBeLessThan(0); - expect(end.x).toBeLessThan(start.x); - expect(end.y).toBeGreaterThan(start.y); - expect(getMeteorPoint(2, 640, 360)).toEqual(end); - expect(getMeteorPoint(-1, 640, 360)).toEqual(start); + expect(start.vy).toBeLessThan(0); + expect(end.x).toBeCloseTo(640 * 0.4); + expect(end.y).toBeCloseTo(360 * 0.72); + 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 bends from a slight climb into a steep dive', () => { + 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); }); test('starfield is deterministic per seed', () => {