mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(gacha): match the meteor glide to the source game's motion
Measured from reference footage, the meteor sweeps in along a straight line from the upper left at about 23 degrees and eases out exponentially to a hover above the horizon, replacing the gravity arc. The trail is now a soft beam that narrows into the head with ribbons fanning along it, every tier gets the horizontal lens flare, and sparkles drift above the beam.
This commit is contained in:
1 parent
4fa0e10384
commit
dc8f7f6ae7
3 files changed
+122
-90
No files matched your search
@@ -5,6 +5,7 @@ import {
|
|||||||
gachaTimeline,
|
gachaTimeline,
|
||||||
getLabelFontSize,
|
getLabelFontSize,
|
||||||
getMeteorState,
|
getMeteorState,
|
||||||
|
meteorAngleDegrees,
|
||||||
getRankLetter,
|
getRankLetter,
|
||||||
getRarityPalette,
|
getRarityPalette,
|
||||||
getShakeOffset,
|
getShakeOffset,
|
||||||
@@ -19,9 +20,7 @@ const baseFont =
|
|||||||
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
|
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
|
||||||
|
|
||||||
const skyStars = createStarfield(70, 7);
|
const skyStars = createStarfield(70, 7);
|
||||||
/** Seconds between motion-streak samples; the streak is the meteor's recent path. */
|
const sparkLifetime = 1;
|
||||||
const trailStep = 0.016;
|
|
||||||
const sparkLifetime = 0.55;
|
|
||||||
const meteorDuration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
|
const meteorDuration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
|
||||||
const starburstSpikes = 10;
|
const starburstSpikes = 10;
|
||||||
|
|
||||||
@@ -35,8 +34,8 @@ const meteorSparks = (() => {
|
|||||||
return Array.from({length: 80}, () => ({
|
return Array.from({length: 80}, () => ({
|
||||||
born: random() * meteorDuration,
|
born: random() * meteorDuration,
|
||||||
inherit: 0.15 + random() * 0.3,
|
inherit: 0.15 + random() * 0.3,
|
||||||
kickX: (random() - 0.5) * 0.5,
|
kickX: (random() - 0.6) * 0.3,
|
||||||
kickY: (random() - 0.7) * 0.5,
|
kickY: (random() - 0.85) * 0.35,
|
||||||
size: 0.006 + random() * 0.012,
|
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 meteorProgress = Math.min(1, Math.max(0, meteorElapsed / meteorDuration));
|
||||||
const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd;
|
const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd;
|
||||||
const head = getMeteorState(meteorElapsed, width, height);
|
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.
|
// Stretch the head along its velocity like motion blur, keeping its area.
|
||||||
const headSpeed = Math.hypot(head.vx, head.vy);
|
const headSpeed = Math.hypot(head.vx, head.vy);
|
||||||
const headStretch = 1 + Math.min(0.9, headSpeed / (height * 1.6));
|
const headStretch = 1 + Math.min(0.9, headSpeed / (height * 1.6));
|
||||||
@@ -208,30 +209,49 @@ export const GachaComposition = (props) => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{meteorVisible
|
{meteorVisible ? (
|
||||||
? Array.from({length: fx.trailSamples}, (_, index) => {
|
<div
|
||||||
const sampleElapsed = Math.min(meteorElapsed, meteorDuration) - index * trailStep;
|
style={{
|
||||||
if (sampleElapsed <= 0) {
|
height: 0,
|
||||||
return null;
|
left: head.x,
|
||||||
}
|
position: 'absolute',
|
||||||
const point = getMeteorState(sampleElapsed, width, height);
|
top: head.y,
|
||||||
const fade = 1 - index / fx.trailSamples;
|
transform: `rotate(${meteorAngleDegrees}deg)`,
|
||||||
const radius = headRadius * (0.25 + 0.75 * fade ** 1.3);
|
width: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(90deg, transparent 0%, ${withAlpha(palette.deep, 0.35)} 35%, ${withAlpha(palette.glow, 0.75)} 85%, ${palette.core} 100%)`,
|
||||||
|
clipPath: 'polygon(0% 0%, 100% 42%, 100% 58%, 0% 100%)',
|
||||||
|
filter: `blur(${Math.max(1, headRadius * 0.3)}px)`,
|
||||||
|
height: headRadius * 6,
|
||||||
|
left: -trailLength,
|
||||||
|
position: 'absolute',
|
||||||
|
top: -headRadius * 3,
|
||||||
|
width: trailLength,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Array.from({length: fx.ribbons}, (_, index) => {
|
||||||
|
const spread = fx.ribbons > 1 ? index / (fx.ribbons - 1) - 0.5 : 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`trail-${index}`}
|
key={`ribbon-${index}`}
|
||||||
style={{
|
style={{
|
||||||
background: `radial-gradient(circle, ${withAlpha(palette.glow, 0.85 * fade ** 1.4)} 0%, transparent 70%)`,
|
background: `linear-gradient(90deg, transparent 0%, ${withAlpha(palette.glow, 0.5)} 55%, ${withAlpha(palette.core, 0.95)} 100%)`,
|
||||||
height: radius * 2,
|
height: Math.max(1.5, headRadius * 0.16),
|
||||||
left: point.x - radius,
|
left: -trailLength * (0.75 + 0.25 * (1 - Math.abs(spread))),
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: point.y - radius,
|
top: -Math.max(0.75, headRadius * 0.08),
|
||||||
width: radius * 2,
|
transform: `rotate(${spread * 11}deg)`,
|
||||||
|
transformOrigin: '100% 50%',
|
||||||
|
width: trailLength * (0.75 + 0.25 * (1 - Math.abs(spread))),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
: null}
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{meteorVisible
|
{meteorVisible
|
||||||
? meteorSparks.slice(0, fx.sparks).map((spark, index) => {
|
? meteorSparks.slice(0, fx.sparks).map((spark, index) => {
|
||||||
@@ -242,7 +262,7 @@ export const GachaComposition = (props) => {
|
|||||||
const origin = getMeteorState(spark.born, width, height);
|
const origin = getMeteorState(spark.born, width, height);
|
||||||
const life = age / sparkLifetime;
|
const life = age / sparkLifetime;
|
||||||
const size = spark.size * height * (1 - life) * fx.headScale;
|
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 x = origin.x + (origin.vx * spark.inherit + spark.kickX * height) * age;
|
||||||
const y =
|
const y =
|
||||||
origin.y + (origin.vy * spark.inherit + spark.kickY * height) * age + 0.5 * sparkGravity * age * age;
|
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,
|
width: headRadius * 4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(90deg, transparent, ${withAlpha(palette.core, 0.95)}, transparent)`,
|
||||||
|
height: Math.max(2, headRadius * 0.14),
|
||||||
|
left: head.x - headRadius * 4 * fx.headScale,
|
||||||
|
position: 'absolute',
|
||||||
|
top: head.y - Math.max(1, headRadius * 0.07),
|
||||||
|
width: headRadius * 8 * fx.headScale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{props.rarity >= 4 ? (
|
{props.rarity >= 4 ? (
|
||||||
<>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
background: `linear-gradient(180deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
|
||||||
background: `linear-gradient(90deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
|
height: headRadius * 2.5 * fx.headScale,
|
||||||
height: Math.max(2, headRadius * 0.12),
|
left: head.x - Math.max(1, headRadius * 0.06),
|
||||||
left: head.x - headRadius * (props.rarity === 5 ? 6 : 3.5),
|
position: 'absolute',
|
||||||
position: 'absolute',
|
top: head.y - headRadius * 1.25 * fx.headScale,
|
||||||
top: head.y - Math.max(1, headRadius * 0.06),
|
width: Math.max(2, headRadius * 0.12),
|
||||||
width: headRadius * (props.rarity === 5 ? 12 : 7),
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(180deg, transparent, ${withAlpha(palette.core, 0.9)}, transparent)`,
|
|
||||||
height: headRadius * (props.rarity === 5 ? 8 : 4.5),
|
|
||||||
left: head.x - Math.max(1, headRadius * 0.06),
|
|
||||||
position: 'absolute',
|
|
||||||
top: head.y - headRadius * (props.rarity === 5 ? 4 : 2.25),
|
|
||||||
width: Math.max(2, headRadius * 0.12),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -59,16 +59,23 @@ export const getRarityPalette = (rarity) => palettes[rarity];
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initial vertical speed as a fraction of horizontal speed. Negative launches
|
* Approach rate of the glide, per second. Fitted to reference footage of the
|
||||||
* slightly upward, so gravity visibly bends the path over into a dive.
|
* 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
|
* Exponential ease-out glide along a straight line from off-screen upper left
|
||||||
* vertical speed grows linearly, so the path is a true parabola that rises a
|
* to the hover point, matching how the source game's meteor sweeps in fast
|
||||||
* touch, then bends over and steepens into a dive as the meteor speeds up. Gravity is solved so the
|
* and then slows to a near stop. The curve is normalised so the meteor is
|
||||||
* meteor lands exactly at the impact point when the fall ends.
|
* exactly at the hover point when the fall ends.
|
||||||
*
|
*
|
||||||
* @param {number} elapsed Seconds since launch; clamped to the fall.
|
* @param {number} elapsed Seconds since launch; clamped to the fall.
|
||||||
* @param {number} width
|
* @param {number} width
|
||||||
@@ -78,16 +85,19 @@ const meteorEntrySlope = -0.3;
|
|||||||
export const getMeteorState = (elapsed, width, height) => {
|
export const getMeteorState = (elapsed, width, height) => {
|
||||||
const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
|
const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
|
||||||
const t = Math.min(duration, Math.max(0, elapsed));
|
const t = Math.min(duration, Math.max(0, elapsed));
|
||||||
const start = {x: width * 1.05, y: height * 0.1};
|
const angle = (meteorAngleDegrees * Math.PI) / 180;
|
||||||
const end = {x: width * 0.4, y: height * 0.72};
|
const ux = Math.cos(angle);
|
||||||
const vx = (end.x - start.x) / duration;
|
const uy = Math.sin(angle);
|
||||||
const vy0 = Math.abs(vx) * meteorEntrySlope;
|
const hover = {x: width * meteorHover.x, y: height * meteorHover.y};
|
||||||
const gravity = (2 * (end.y - start.y - vy0 * duration)) / duration ** 2;
|
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 {
|
return {
|
||||||
x: start.x + vx * t,
|
x: hover.x - ux * distance * remaining,
|
||||||
y: start.y + vy0 * t + 0.5 * gravity * t * t,
|
y: hover.y - uy * distance * remaining,
|
||||||
vx,
|
vx: ux * speed,
|
||||||
vy: vy0 + gravity * t,
|
vy: uy * speed,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,7 +185,7 @@ export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLet
|
|||||||
/**
|
/**
|
||||||
* @typedef {object} TierEffects
|
* @typedef {object} TierEffects
|
||||||
* @property {number} headScale Meteor head size multiplier.
|
* @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 {number} sparks Sparks shed by the meteor.
|
||||||
* @property {boolean} halo Rainbow ring forming around the meteor before landing.
|
* @property {boolean} halo Rainbow ring forming around the meteor before landing.
|
||||||
* @property {number} skyFlood Peak opacity of the sky tint as the meteor lands.
|
* @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 = {
|
const tierEffects = {
|
||||||
3: {
|
3: {
|
||||||
headScale: 1,
|
headScale: 1,
|
||||||
trailSamples: 22,
|
ribbons: 1,
|
||||||
sparks: 12,
|
sparks: 12,
|
||||||
halo: false,
|
halo: false,
|
||||||
skyFlood: 0,
|
skyFlood: 0,
|
||||||
@@ -215,7 +225,7 @@ const tierEffects = {
|
|||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
headScale: 1.3,
|
headScale: 1.3,
|
||||||
trailSamples: 34,
|
ribbons: 3,
|
||||||
sparks: 36,
|
sparks: 36,
|
||||||
halo: false,
|
halo: false,
|
||||||
skyFlood: 0.25,
|
skyFlood: 0.25,
|
||||||
@@ -231,7 +241,7 @@ const tierEffects = {
|
|||||||
},
|
},
|
||||||
5: {
|
5: {
|
||||||
headScale: 1.75,
|
headScale: 1.75,
|
||||||
trailSamples: 48,
|
ribbons: 5,
|
||||||
sparks: 80,
|
sparks: 80,
|
||||||
halo: true,
|
halo: true,
|
||||||
skyFlood: 0.7,
|
skyFlood: 0.7,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
gachaTotalSeconds,
|
gachaTotalSeconds,
|
||||||
getLabelFontSize,
|
getLabelFontSize,
|
||||||
getMeteorState,
|
getMeteorState,
|
||||||
|
meteorAngleDegrees,
|
||||||
getRankLetter,
|
getRankLetter,
|
||||||
getRarityPalette,
|
getRarityPalette,
|
||||||
getShakeOffset,
|
getShakeOffset,
|
||||||
@@ -38,40 +39,43 @@ describe('gacha timeline', () => {
|
|||||||
expect(new Set(glows).size).toBe(3);
|
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 duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
|
||||||
const start = getMeteorState(0, 640, 360);
|
const start = getMeteorState(0, 640, 360);
|
||||||
const end = getMeteorState(duration, 640, 360);
|
const end = getMeteorState(duration, 640, 360);
|
||||||
expect(start.x).toBeGreaterThan(640);
|
expect(start.x).toBeLessThan(0);
|
||||||
expect(start.vy).toBeLessThan(0);
|
expect(end.x).toBeCloseTo(640 * 0.55);
|
||||||
expect(end.x).toBeCloseTo(640 * 0.4);
|
expect(end.y).toBeCloseTo(360 * 0.55);
|
||||||
expect(end.y).toBeCloseTo(360 * 0.72);
|
|
||||||
expect(getMeteorState(duration + 1, 640, 360)).toEqual(end);
|
expect(getMeteorState(duration + 1, 640, 360)).toEqual(end);
|
||||||
expect(getMeteorState(-1, 640, 360)).toEqual(start);
|
expect(getMeteorState(-1, 640, 360)).toEqual(start);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('meteor keeps horizontal speed and gains vertical speed at a constant rate', () => {
|
test('meteor moves along a straight line at the measured angle', () => {
|
||||||
const step = 0.2;
|
const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
|
||||||
const samples = [0, 1, 2, 3, 4, 5].map((index) => getMeteorState(index * step, 640, 360));
|
const start = getMeteorState(0, 640, 360);
|
||||||
for (const sample of samples) {
|
for (const t of [0.2, 0.5, 1, duration]) {
|
||||||
expect(sample.vx).toBeCloseTo(samples[0]?.vx ?? NaN);
|
const point = getMeteorState(t, 640, 360);
|
||||||
expect(sample.vx).toBeLessThan(0);
|
const degrees = (Math.atan2(point.y - start.y, point.x - start.x) * 180) / Math.PI;
|
||||||
}
|
expect(degrees).toBeCloseTo(meteorAngleDegrees);
|
||||||
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', () => {
|
test('meteor sweeps in fast and decelerates exponentially', () => {
|
||||||
const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
|
const duration = gachaTimeline.meteorEnd - gachaTimeline.meteorStart;
|
||||||
const early = getMeteorState(0, 640, 360);
|
const start = getMeteorState(0, 640, 360);
|
||||||
const late = getMeteorState(duration, 640, 360);
|
const end = getMeteorState(duration, 640, 360);
|
||||||
const climbDegrees = (Math.atan2(-early.vy, Math.abs(early.vx)) * 180) / Math.PI;
|
const half = getMeteorState(0.5, 640, 360);
|
||||||
const diveDegrees = (Math.atan2(late.vy, Math.abs(late.vx)) * 180) / Math.PI;
|
const covered = (half.x - start.x) / (end.x - start.x);
|
||||||
expect(climbDegrees).toBeGreaterThan(10);
|
expect(covered).toBeGreaterThan(0.85);
|
||||||
expect(diveDegrees).toBeGreaterThan(45);
|
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', () => {
|
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)));
|
const [three, four, five] = [3, 4, 5].map((rarity) => getTierEffects(/** @type {3 | 4 | 5} */ (rarity)));
|
||||||
for (const key of /** @type {const} */ ([
|
for (const key of /** @type {const} */ ([
|
||||||
'headScale',
|
'headScale',
|
||||||
'trailSamples',
|
'ribbons',
|
||||||
'sparks',
|
'sparks',
|
||||||
'shockwaves',
|
'shockwaves',
|
||||||
'raysOpacity',
|
'raysOpacity',
|
||||||
|
|||||||
Reference in new issue
Block a user