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

+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()};
};