mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(gacha): make the meteor fall under real gravity
The meteor now follows a ballistic parabola: constant horizontal speed and vertical speed growing under gravity solved from the launch and impact points, so it climbs slightly, bends over, and dives while speeding up. The trail samples past positions in time so it lengthens with speed, the head stretches along its velocity, and sparks inherit the meteor's velocity and fall under their own gravity.
This commit is contained in:
1 parent
adc2b94ae9
commit
004325d947
3 files changed
+102
-47
No files matched your search
@@ -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 (
|
||||
<div
|
||||
key={`spark-${index}`}
|
||||
@@ -245,10 +254,10 @@ export const GachaComposition = (props) => {
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user