diff --git a/renderer/src/remotion/GachaComposition.jsx b/renderer/src/remotion/GachaComposition.jsx
index 2c1c2fd..6f2f5a9 100644
--- a/renderer/src/remotion/GachaComposition.jsx
+++ b/renderer/src/remotion/GachaComposition.jsx
@@ -4,8 +4,7 @@ import {
createStarfield,
gachaTimeline,
getLabelFontSize,
- getMeteorState,
- meteorAngleDegrees,
+ getMeteorPoint,
getRankLetter,
getRarityPalette,
getShakeOffset,
@@ -20,22 +19,19 @@ const baseFont =
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const skyStars = createStarfield(70, 7);
-const sparkLifetime = 1;
-const meteorDuration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
+const trailSpacing = 0.011;
const starburstSpikes = 10;
/**
- * 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.
+ * 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.
*/
const meteorSparks = (() => {
const random = createRandom(11);
return Array.from({length: 80}, () => ({
- born: random() * meteorDuration,
- inherit: 0.15 + random() * 0.3,
- kickX: (random() - 0.6) * 0.3,
- kickY: (random() - 0.85) * 0.35,
+ born: random(),
+ driftX: (random() - 0.5) * 0.1,
+ driftY: (random() - 0.2) * 0.1,
size: 0.006 + random() * 0.012,
}));
})();
@@ -107,17 +103,13 @@ export const GachaComposition = (props) => {
const fx = getTierEffects(props.rarity);
const tl = gachaTimeline;
- const meteorElapsed = seconds - tl.meteorStart;
- const meteorProgress = Math.min(1, Math.max(0, meteorElapsed / meteorDuration));
+ const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], {
+ ...clamp,
+ easing: Easing.in(Easing.quad),
+ });
const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd;
- const head = getMeteorState(meteorElapsed, width, height);
- 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));
- const headAngle = (Math.atan2(head.vy, head.vx) * 180) / Math.PI;
+ const head = getMeteorPoint(meteorProgress, width, height);
+ const headRadius = height * interpolate(meteorProgress, [0, 1], [0.025, 0.085], clamp) * fx.headScale;
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);
@@ -125,7 +117,7 @@ export const GachaComposition = (props) => {
...clamp,
easing: Easing.inOut(Easing.quad),
});
- const impact = getMeteorState(meteorDuration, width, height);
+ const impact = getMeteorPoint(1, width, height);
const bloomRadius = interpolate(seconds, [tl.flashStart, tl.flashPeak], [0.1, 2.2], clamp) * width;
const revealed = seconds >= tl.revealStart;
@@ -209,63 +201,42 @@ export const GachaComposition = (props) => {
/>
) : null}
- {meteorVisible ? (
-
-
- {Array.from({length: fx.ribbons}, (_, index) => {
- const spread = fx.ribbons > 1 ? index / (fx.ribbons - 1) - 0.5 : 0;
+ {meteorVisible
+ ? Array.from({length: fx.trailSamples}, (_, index) => {
+ const sampleProgress = meteorProgress - index * trailSpacing;
+ if (sampleProgress <= 0) {
+ return null;
+ }
+ const point = getMeteorPoint(sampleProgress, width, height);
+ const fade = 1 - index / fx.trailSamples;
+ const radius = headRadius * (0.25 + 0.75 * fade ** 1.3);
return (
);
- })}
-
- ) : null}
+ })
+ : null}
{meteorVisible
? meteorSparks.slice(0, fx.sparks).map((spark, index) => {
- const age = Math.min(meteorElapsed, meteorDuration) - spark.born;
- if (age < 0 || age >= sparkLifetime) {
+ if (spark.born > meteorProgress) {
return null;
}
- const origin = getMeteorState(spark.born, width, height);
- const life = age / sparkLifetime;
- const size = spark.size * height * (1 - life) * fx.headScale;
- 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;
+ 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;
return (
{
borderRadius: '50%',
boxShadow: `0 0 ${size * 2}px ${palette.glow}`,
height: size,
- left: x,
- opacity: 1 - life,
+ left: origin.x + spark.driftX * width * age,
+ opacity: 1 - age,
position: 'absolute',
- top: y,
+ top: origin.y + spark.driftY * height * age,
width: size,
}}
/>
@@ -312,31 +283,32 @@ 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,
}}
/>
-
{props.rarity >= 4 ? (
-
+ <>
+
+
+ >
) : null}
>
) : null}
diff --git a/renderer/src/remotion/gacha-timeline.js b/renderer/src/remotion/gacha-timeline.js
index bc30fa6..a479e00 100644
--- a/renderer/src/remotion/gacha-timeline.js
+++ b/renderer/src/remotion/gacha-timeline.js
@@ -51,53 +51,23 @@ const palettes = {
export const getRarityPalette = (rarity) => palettes[rarity];
/**
- * @typedef {object} MeteorState
- * @property {number} x Pixels.
- * @property {number} y Pixels.
- * @property {number} vx Pixels per second.
- * @property {number} vy Pixels per second.
- */
-
-/**
- * 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.
- */
-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};
-
-/**
- * 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.
+ * Point on the meteor's curved fall from the upper right toward the lower
+ * left of the frame, as a quadratic Bézier.
*
- * @param {number} elapsed Seconds since launch; clamped to the fall.
+ * @param {number} progress 0 at launch, 1 at landing; values outside clamp.
* @param {number} width
* @param {number} height
- * @returns {MeteorState}
+ * @returns {{x: number, y: number}}
*/
-export const getMeteorState = (elapsed, width, height) => {
- const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
- const t = Math.min(duration, Math.max(0, elapsed));
- 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;
+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;
return {
- x: hover.x - ux * distance * remaining,
- y: hover.y - uy * distance * remaining,
- vx: ux * speed,
- vy: uy * speed,
+ 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,
};
};
@@ -185,7 +155,7 @@ export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLet
/**
* @typedef {object} TierEffects
* @property {number} headScale Meteor head size multiplier.
- * @property {number} ribbons Light ribbons fanning out along the trail beam.
+ * @property {number} trailSamples Glow blobs drawn along the meteor trail.
* @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.
@@ -209,7 +179,7 @@ export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLet
const tierEffects = {
3: {
headScale: 1,
- ribbons: 1,
+ trailSamples: 22,
sparks: 12,
halo: false,
skyFlood: 0,
@@ -225,7 +195,7 @@ const tierEffects = {
},
4: {
headScale: 1.3,
- ribbons: 3,
+ trailSamples: 34,
sparks: 36,
halo: false,
skyFlood: 0.25,
@@ -241,7 +211,7 @@ const tierEffects = {
},
5: {
headScale: 1.75,
- ribbons: 5,
+ trailSamples: 48,
sparks: 80,
halo: true,
skyFlood: 0.7,
diff --git a/renderer/test/gacha-timeline.test.js b/renderer/test/gacha-timeline.test.js
index f070d42..260b62b 100644
--- a/renderer/test/gacha-timeline.test.js
+++ b/renderer/test/gacha-timeline.test.js
@@ -4,8 +4,7 @@ import {
gachaTimeline,
gachaTotalSeconds,
getLabelFontSize,
- getMeteorState,
- meteorAngleDegrees,
+ getMeteorPoint,
getRankLetter,
getRarityPalette,
getShakeOffset,
@@ -39,43 +38,15 @@ describe('gacha timeline', () => {
expect(new Set(glows).size).toBe(3);
});
- 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).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 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 sweeps in fast and decelerates exponentially', () => {
- const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
- 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('meteor falls from off-screen upper right toward the lower left', () => {
+ const start = getMeteorPoint(0, 640, 360);
+ const end = getMeteorPoint(1, 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);
});
test('starfield is deterministic per seed', () => {
@@ -95,7 +66,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',
- 'ribbons',
+ 'trailSamples',
'sparks',
'shockwaves',
'raysOpacity',