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