mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-15 02:17:35 +00:00
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:
1 parent
9b93c53647
commit
dccd524f9d
10 files changed
+251
-65
No files matched your search
@@ -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 ? (
|
||||
|
||||
@@ -38,6 +38,7 @@ const defaultGachaProps = {
|
||||
rarity: 5,
|
||||
fps: 24,
|
||||
width: 640,
|
||||
seed: 7,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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()};
|
||||
};
|
||||
Reference in new issue
Block a user