feat(gacha): fly into the rank emblem with a rainbow sunburst and twinkling sky

The meteor now flies in from the upper left and lands on the rank emblem,
so the impact burst becomes the badge reveal. The 5-star rainbow ring is
replaced by a rainbow sunburst of counter-rotating rays. Sky stars twinkle
with short bright flares and cross glints, and each roll draws a fresh
layout from a per-request seed that the route picks when the caller sends
none.
This commit is contained in:
tiennm99 committed 2026-10-01 14:43:46 +07:00
1 parent 9b93c53647
commit dccd524f9d
10 files changed
+251 -65

No files matched your search

+6 -4
View File
@@ -51,15 +51,17 @@ Authorization: Bearer change-me
```
Renders a 7-second wish animation in the style of a gacha game: a meteor
coloured by rarity (blue 3★, purple 4★, gold 5★) falls across a night sky,
lands in a white flash, and the label is revealed beside a rank emblem (`B`,
coloured by rarity (blue 3★, purple 4★, gold 5★) flies in from the left across
a night sky and bursts in a white flash where the rank emblem appears, and the label is revealed beside a rank emblem (`B`,
`A`, `S`) with its stars popping in. Each tier is louder than the one below:
4★ adds a bigger meteor, a lens flare, impact shake, and a double shockwave;
5★ adds a rainbow halo before landing, a gold sky flood, a starburst, counter
5★ adds a rainbow sunburst before landing, a gold sky flood, a starburst, counter
rotating rays, falling sparkles, and a sheen across the emblem. The per-tier
table lives in `src/remotion/gacha-timeline.js`.
`rarity` is required; `fps` is `24` or `30`; `width` is `640` (360 tall) or
`854` (480 tall). The caller chooses the result and its rarity — the service
`854` (480 tall). Optional `seed` (integer, `0` to `2147483647`) lays out the
twinkling stars and particles; when omitted the service picks a random one, so
every roll draws a different sky. The caller chooses the result and its rarity — the service
only draws it.
Response is a silent H.264 `video/mp4` (Telegram plays it as an animation)
+3 -3
View File
@@ -50,9 +50,9 @@ await mkdir(fixturesDir, {recursive: true});
/** @type {{name: string, request: import('../src/schemas/gacha-request.js').GachaRenderRequest}[]} */
const gachaFixtures = [
{name: 'gacha-5-star', request: {label: 'Pizza', rarity: 5, fps: 24, width: 640}},
{name: 'gacha-4-star', request: {label: 'Phở bò tái nạm gầu', rarity: 4, fps: 24, width: 640}},
{name: 'gacha-3-star', request: {label: 'Cơm tấm', rarity: 3, fps: 24, width: 854}},
{name: 'gacha-5-star', request: {label: 'Pizza', rarity: 5, fps: 24, width: 640, seed: 7}},
{name: 'gacha-4-star', request: {label: 'Phở bò tái nạm gầu', rarity: 4, fps: 24, width: 640, seed: 11}},
{name: 'gacha-3-star', request: {label: 'Cơm tấm', rarity: 3, fps: 24, width: 854, seed: 23}},
];
/**
+145 -40
View File
@@ -1,14 +1,17 @@
import {useMemo} from 'react';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
import {
createRandom,
createStarfield,
gachaTimeline,
getLabelFontSize,
emblemCenter,
getMeteorPoint,
getRankLetter,
getRarityPalette,
getShakeOffset,
getStarRevealTimes,
getTwinkle,
getTierEffects,
starColor,
} from './gacha-timeline.js';
@@ -18,27 +21,46 @@ const baseFont =
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const skyStars = createStarfield(70, 7);
const trailSpacing = 0.011;
const starburstSpikes = 10;
/**
* Every decorative particle for one roll. The request seed picks the layout,
* so each roll scatters its stars differently while every frame of one
* render stays consistent.
*
* @param {number} seed
*/
const createParticles = (seed) => ({
skyStars: createStarfield(70, seed),
meteorSparks: createMeteorSparks(seed + 1),
revealMotes: createRevealMotes(seed + 2),
rainSparkles: createRainSparkles(seed + 3),
});
/**
* 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.
*
* @param {number} seed
*/
const meteorSparks = (() => {
const random = createRandom(11);
function createMeteorSparks(seed) {
const random = createRandom(seed);
return Array.from({length: 80}, () => ({
born: random(),
driftX: (random() - 0.5) * 0.1,
driftY: (random() - 0.2) * 0.1,
size: 0.006 + random() * 0.012,
}));
})();
}
/** Motes that float upward behind the revealed item. */
const revealMotes = (() => {
const random = createRandom(23);
/**
* Motes that float upward behind the revealed item.
*
* @param {number} seed
*/
function createRevealMotes(seed) {
const random = createRandom(seed);
return Array.from({length: 56}, () => ({
x: random(),
y: random(),
@@ -46,11 +68,15 @@ const revealMotes = (() => {
size: 0.004 + random() * 0.009,
phase: random() * Math.PI * 2,
}));
})();
}
/** Sparkles that rain down across the 5★ reveal. */
const rainSparkles = (() => {
const random = createRandom(31);
/**
* Sparkles that rain down across the 5★ reveal.
*
* @param {number} seed
*/
function createRainSparkles(seed) {
const random = createRandom(seed);
return Array.from({length: 40}, () => ({
x: random(),
offset: random(),
@@ -58,7 +84,31 @@ const rainSparkles = (() => {
size: 0.025 + random() * 0.03,
spin: (random() - 0.5) * 360,
}));
})();
}
const rainbowConic = 'conic-gradient(from 0deg, #ff6b6b, #ffd36b, #8cff9a, #6bd6ff, #b48cff, #ff8cd8, #ff6b6b)';
/** Fades the 5★ rainbow sunburst from a bright core out to its ray tips. */
const sunburstFade = 'radial-gradient(circle, black 0%, black 18%, rgba(0, 0, 0, 0.6) 40%, transparent 70%)';
/**
* Two ray layers turning in opposite directions; each ray takes the rainbow
* hue of the angle it points at.
*/
const sunburstLayers = [
{
rays: 16,
spin: 40,
opacity: 1,
mask: 'repeating-conic-gradient(from 0deg, black 0deg 6deg, transparent 6deg 22.5deg)',
},
{
rays: 24,
spin: -60,
opacity: 0.7,
mask: 'repeating-conic-gradient(from 4deg, black 0deg 2deg, transparent 2deg 15deg)',
},
];
/**
* @param {string} hex
@@ -102,6 +152,7 @@ export const GachaComposition = (props) => {
const palette = getRarityPalette(props.rarity);
const fx = getTierEffects(props.rarity);
const tl = gachaTimeline;
const {skyStars, meteorSparks, revealMotes, rainSparkles} = useMemo(() => createParticles(props.seed), [props.seed]);
const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], {
...clamp,
@@ -123,8 +174,8 @@ export const GachaComposition = (props) => {
const revealed = seconds >= tl.revealStart;
const shake = getShakeOffset(seconds, tl.revealStart, fx.shake * height);
const emblemSize = height * 0.52;
const emblemX = width * 0.66;
const emblemY = height * 0.47;
const emblemX = width * emblemCenter.x;
const emblemY = height * emblemCenter.y;
const emblemScale = interpolate(
seconds,
[tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled],
@@ -175,21 +226,61 @@ export const GachaComposition = (props) => {
background: `radial-gradient(ellipse 80% 40% at 50% 100%, ${withAlpha('#9bb4ff', 0.35)} 0%, transparent 70%)`,
}}
/>
{skyStars.map((star, index) => (
<div
key={`sky-${index}`}
style={{
background: '#ffffff',
borderRadius: '50%',
height: star.radius * 2 * height,
left: star.x * width,
opacity: 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(seconds * 2.4 + star.phase)),
position: 'absolute',
top: star.y * height,
width: star.radius * 2 * height,
}}
/>
))}
{skyStars.map((star, index) => {
const twinkle = getTwinkle(seconds, star);
const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle);
const glint = star.glint ? size * 6 * twinkle : 0;
return (
<div
key={`sky-${index}`}
style={{
height: 0,
left: star.x * width,
opacity: 0.2 + 0.8 * twinkle,
position: 'absolute',
top: star.y * height,
width: 0,
}}
>
<div
style={{
background: '#ffffff',
borderRadius: '50%',
boxShadow: twinkle > 0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined,
height: size,
left: -size / 2,
position: 'absolute',
top: -size / 2,
width: size,
}}
/>
{glint > 1 ? (
<>
<div
style={{
background: 'linear-gradient(90deg, transparent, #ffffff, transparent)',
height: 1,
left: -glint / 2,
position: 'absolute',
top: -0.5,
width: glint,
}}
/>
<div
style={{
background: 'linear-gradient(180deg, transparent, #ffffff, transparent)',
height: glint,
left: -0.5,
position: 'absolute',
top: -glint / 2,
width: 1,
}}
/>
</>
) : null}
</div>
);
})}
{skyFlood > 0 ? (
<AbsoluteFill
@@ -259,19 +350,33 @@ export const GachaComposition = (props) => {
{meteorVisible && haloOpacity > 0 ? (
<div
style={{
background: 'conic-gradient(from 0deg, #ff6b6b, #ffd36b, #8cff9a, #6bd6ff, #b48cff, #ff8cd8, #ff6b6b)',
borderRadius: '50%',
height: headRadius * 6,
left: head.x - headRadius * 3,
maskImage: 'radial-gradient(circle, transparent 52%, black 58%, black 64%, transparent 72%)',
opacity: haloOpacity * 0.9,
height: headRadius * 9,
left: head.x - headRadius * 4.5,
maskImage: sunburstFade,
opacity: haloOpacity,
position: 'absolute',
top: head.y - headRadius * 3,
transform: `rotate(${seconds * 240}deg) scale(${0.7 + 0.3 * haloOpacity})`,
WebkitMaskImage: 'radial-gradient(circle, transparent 52%, black 58%, black 64%, transparent 72%)',
width: headRadius * 6,
top: head.y - headRadius * 4.5,
transform: `scale(${0.5 + 0.5 * haloOpacity})`,
WebkitMaskImage: sunburstFade,
width: headRadius * 9,
}}
/>
>
{sunburstLayers.map((layer) => (
<div
key={layer.rays}
style={{
background: rainbowConic,
borderRadius: '50%',
inset: 0,
maskImage: layer.mask,
opacity: layer.opacity,
position: 'absolute',
transform: `rotate(${seconds * layer.spin}deg)`,
WebkitMaskImage: layer.mask,
}}
/>
))}
</div>
) : null}
{meteorVisible ? (
+1
View File
@@ -38,6 +38,7 @@ const defaultGachaProps = {
rarity: 5,
fps: 24,
width: 640,
seed: 7,
};
/**
+25 -6
View File
@@ -34,6 +34,9 @@ export const gachaTimeline = Object.freeze({
starStep: 0.18,
});
/** Centre of the rank emblem on the reveal, as fractions of the frame. */
export const emblemCenter = Object.freeze({x: 0.66, y: 0.47});
/** Every rarity reveals its stars in the same gold, as in the source game. */
export const starColor = '#ffcc33';
@@ -51,8 +54,9 @@ 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.
* Point on the meteor's curved flight from the upper left toward the rank
* emblem on the right, as a quadratic Bézier. It lands exactly on the emblem,
* so the impact burst becomes the badge reveal.
*
* @param {number} progress 0 at launch, 1 at landing; values outside clamp.
* @param {number} width
@@ -61,9 +65,9 @@ export const getRarityPalette = (rarity) => palettes[rarity];
*/
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 start = {x: -width * 0.08, y: -height * 0.18};
const control = {x: width * 0.22, y: height * 0.38};
const end = {x: width * emblemCenter.x, y: height * emblemCenter.y};
const u = 1 - t;
return {
x: u * u * start.x + 2 * u * t * control.x + t * t * end.x,
@@ -104,6 +108,8 @@ export const createRandom = (seed) => {
* @property {number} y Fraction of height.
* @property {number} radius Fraction of height.
* @property {number} phase Twinkle phase offset in radians.
* @property {number} speed Twinkle speed in radians per second.
* @property {boolean} glint Draws a cross-shaped glint when it flares.
*/
/**
@@ -118,9 +124,22 @@ export const createStarfield = (count, seed) => {
y: random() * 0.8,
radius: 0.002 + random() * 0.004,
phase: random() * Math.PI * 2,
speed: 2 + random() * 5,
glint: random() < 0.18,
}));
};
/**
* Twinkle brightness in [0, 1]. Cubing the sine keeps a star dim most of the
* time with short bright flares, which reads as twinkling rather than a slow
* pulse.
*
* @param {number} seconds
* @param {SkyStar} star
* @returns {number}
*/
export const getTwinkle = (seconds, star) => (0.5 + 0.5 * Math.sin(seconds * star.speed + star.phase)) ** 3;
/**
* Font size for the revealed name, shrinking long labels so they fit within
* two lines on the left half of the frame.
@@ -157,7 +176,7 @@ export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLet
* @property {number} headScale Meteor head size multiplier.
* @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 {boolean} halo Rainbow sunburst bursting from the meteor before landing.
* @property {number} skyFlood Peak opacity of the sky tint as the meteor lands.
* @property {number} shake Impact camera shake amplitude, as a fraction of height.
* @property {number} shockwaves Rings bursting from the emblem on reveal.
+5 -2
View File
@@ -1,7 +1,8 @@
import {randomInt} from 'node:crypto';
import {ZodError} from 'zod';
import {isAuthorized} from '../lib/bearer-auth.js';
import {isRenderTimeoutError} from '../lib/render-errors.js';
import {parseGachaRequest} from '../schemas/gacha-request.js';
import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
/**
* @typedef {import('../config.js').AppConfig} AppConfig
@@ -30,7 +31,9 @@ export const registerGachaRoute = async (app, deps) => {
let gachaRequest;
try {
gachaRequest = parseGachaRequest(request.body, {maxOptionChars: deps.config.maxOptionChars});
gachaRequest = parseGachaRequest(request.body, {maxOptionChars: deps.config.maxOptionChars}, () =>
randomInt(maxGachaSeed),
);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({error: 'invalid_request', issues: error.issues});
+12 -1
View File
@@ -1,5 +1,7 @@
import {z} from 'zod';
export const maxGachaSeed = 2 ** 31 - 1;
/** Landscape frame sizes; heights keep 16:9 rounded to even pixels for H.264. */
export const gachaFrameSizes = Object.freeze({640: 360, 854: 480});
@@ -14,6 +16,7 @@ export const gachaFrameSizes = Object.freeze({640: 360, 854: 480});
* @property {3 | 4 | 5} rarity
* @property {24 | 30} fps
* @property {640 | 854} width
* @property {number} seed Lays out the stars and particles for this roll.
*/
/**
@@ -29,12 +32,20 @@ export const createGachaRequestSchema = (limits) =>
rarity: z.union([z.literal(3), z.literal(4), z.literal(5)]),
fps: z.union([z.literal(24), z.literal(30)]).default(24),
width: z.union([z.literal(640), z.literal(854)]).default(640),
seed: z.number().int().min(0).max(maxGachaSeed).optional(),
})
.strict();
/**
* Parses a request, picking a fresh seed when the caller sends none so every
* roll draws a different sky.
*
* @param {unknown} input
* @param {GachaRequestLimits} limits
* @param {() => number} pickSeed
* @returns {GachaRenderRequest}
*/
export const parseGachaRequest = (input, limits) => createGachaRequestSchema(limits).parse(input);
export const parseGachaRequest = (input, limits, pickSeed) => {
const parsed = createGachaRequestSchema(limits).parse(input);
return {...parsed, seed: parsed.seed ?? pickSeed()};
};
+10 -4
View File
@@ -5,13 +5,17 @@ const limits = {maxOptionChars: 12};
describe('parseGachaRequest', () => {
test('trims the label and applies defaults', () => {
const request = parseGachaRequest({label: ' Pizza ', rarity: 5}, limits);
const request = parseGachaRequest({label: ' Pizza ', rarity: 5}, limits, () => 42);
expect(request).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640});
expect(request).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640, seed: 42});
});
test('keeps a caller-provided seed', () => {
expect(parseGachaRequest({label: 'Pizza', rarity: 5, seed: 9}, limits, () => 42).seed).toBe(9);
});
test('accepts the larger frame and fps', () => {
const request = parseGachaRequest({label: 'Pho', rarity: 4, fps: 30, width: 854}, limits);
const request = parseGachaRequest({label: 'Pho', rarity: 4, fps: 30, width: 854}, limits, () => 1);
expect(request.fps).toBe(30);
expect(request.width).toBe(854);
@@ -26,7 +30,9 @@ describe('parseGachaRequest', () => {
{label: 'Pizza', rarity: 3, width: 512},
{label: 'Pizza', rarity: 3, fps: 15},
{label: 'Pizza', rarity: 3, extra: true},
{label: 'Pizza', rarity: 3, seed: -1},
{label: 'Pizza', rarity: 3, seed: 1.5},
])('rejects %o', (input) => {
expect(() => parseGachaRequest(input, limits)).toThrow();
expect(() => parseGachaRequest(input, limits, () => 0)).toThrow();
});
});
+26 -1
View File
@@ -47,7 +47,32 @@ describe('POST /api/gacha', () => {
expect(response.headers['content-type']).toBe('video/mp4');
expect(response.headers['x-gacha-rarity']).toBe('5');
expect(response.rawPayload.subarray(4, 8).toString()).toBe('ftyp');
expect(rendered).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640});
expect(rendered).toMatchObject({label: 'Pizza', rarity: 5, fps: 24, width: 640});
expect(Number.isInteger(/** @type {{seed: number}} */ (rendered).seed)).toBe(true);
await app.close();
});
test('picks a different seed per roll unless one is given', async () => {
/** @type {number[]} */
const seeds = [];
const app = await build(
{},
{
renderGacha: async (request) => {
seeds.push(request.seed);
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
},
},
);
for (let index = 0; index < 3; index += 1) {
await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5}});
}
await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5, seed: 5}});
expect(new Set(seeds.slice(0, 3)).size).toBeGreaterThan(1);
expect(seeds[3]).toBe(5);
await app.close();
});
+18 -4
View File
@@ -4,12 +4,14 @@ import {
gachaTimeline,
gachaTotalSeconds,
getLabelFontSize,
emblemCenter,
getMeteorPoint,
getRankLetter,
getRarityPalette,
getShakeOffset,
getStarRevealTimes,
getTierEffects,
getTwinkle,
} from '../src/remotion/gacha-timeline.js';
describe('gacha timeline', () => {
@@ -38,13 +40,13 @@ describe('gacha timeline', () => {
expect(new Set(glows).size).toBe(3);
});
test('meteor falls from off-screen upper right toward the lower left', () => {
test('meteor flies in from off-screen upper left and lands on the rank emblem', () => {
const start = getMeteorPoint(0, 640, 360);
const end = getMeteorPoint(1, 640, 360);
expect(start.x).toBeGreaterThan(640);
expect(start.x).toBeLessThan(0);
expect(start.y).toBeLessThan(0);
expect(end.x).toBeLessThan(start.x);
expect(end.y).toBeGreaterThan(start.y);
expect(end.x).toBeCloseTo(640 * emblemCenter.x);
expect(end.y).toBeCloseTo(360 * emblemCenter.y);
expect(getMeteorPoint(2, 640, 360)).toEqual(end);
expect(getMeteorPoint(-1, 640, 360)).toEqual(start);
});
@@ -54,6 +56,18 @@ describe('gacha timeline', () => {
expect(createStarfield(5, 7)).not.toEqual(createStarfield(5, 8));
});
test('stars twinkle with short bright flares', () => {
const [star] = createStarfield(1, 3);
if (!star) {
throw new Error('expected a star');
}
const samples = Array.from({length: 200}, (_, index) => getTwinkle(index / 50, star));
expect(Math.max(...samples)).toBeGreaterThan(0.95);
expect(Math.min(...samples)).toBeLessThan(0.05);
const bright = samples.filter((value) => value > 0.5).length / samples.length;
expect(bright).toBeLessThan(0.4);
});
test('long labels get smaller text', () => {
expect(getLabelFontSize('Pizza', 360)).toBeGreaterThan(getLabelFontSize('x'.repeat(40), 360));
});