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',