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