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', () => {