From adc2b94ae95d461f9b451f35b63314ce6f60822c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 13:25:06 +0700 Subject: [PATCH] feat(gacha): escalate the wish animation by rarity with B/A/S rank emblems Every beat now scales from a per-tier effects table: 4-star adds a bigger meteor, lens flare, impact shake, and double shockwave; 5-star adds a rainbow halo before landing, a gold sky flood, a starburst, counter-rotating rays, falling sparkles, and an emblem sheen. The emblem shows the rank letter instead of the label's first character. --- renderer/README.md | 7 +- renderer/src/remotion/GachaComposition.jsx | 275 ++++++++++++++++----- renderer/src/remotion/gacha-timeline.js | 111 ++++++++- renderer/test/gacha-timeline.test.js | 42 +++- 4 files changed, 358 insertions(+), 77 deletions(-) diff --git a/renderer/README.md b/renderer/README.md index b4c3a1c..a2f6f73 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -52,7 +52,12 @@ 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 with its stars popping in. +lands in a white flash, 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 +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 only draws it. diff --git a/renderer/src/remotion/GachaComposition.jsx b/renderer/src/remotion/GachaComposition.jsx index 549d604..6f2f5a9 100644 --- a/renderer/src/remotion/GachaComposition.jsx +++ b/renderer/src/remotion/GachaComposition.jsx @@ -3,11 +3,13 @@ import { createRandom, createStarfield, gachaTimeline, - getEmblemGlyph, getLabelFontSize, getMeteorPoint, + getRankLetter, getRarityPalette, + getShakeOffset, getStarRevealTimes, + getTierEffects, starColor, } from './gacha-timeline.js'; @@ -17,35 +19,47 @@ const baseFont = const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); const skyStars = createStarfield(70, 7); -const trailSamples = 34; -const trailSpacing = 0.014; +const trailSpacing = 0.011; +const starburstSpikes = 10; /** * Sparks shed by the meteor. Each is born at a point along the fall and - * drifts away from it while fading. + * drifts away from it while fading. Tiers draw a prefix of this pool. */ const meteorSparks = (() => { const random = createRandom(11); - return Array.from({length: 40}, () => ({ + return Array.from({length: 80}, () => ({ born: random(), - driftX: (random() - 0.5) * 0.08, - driftY: (random() - 0.2) * 0.08, - size: 0.006 + random() * 0.01, + 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); - return Array.from({length: 36}, () => ({ + return Array.from({length: 56}, () => ({ x: random(), y: random(), - speed: 0.03 + random() * 0.06, - size: 0.004 + random() * 0.008, + speed: 0.03 + random() * 0.07, + 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); + return Array.from({length: 40}, () => ({ + x: random(), + offset: random(), + speed: 0.25 + random() * 0.35, + size: 0.025 + random() * 0.03, + spin: (random() - 0.5) * 360, + })); +})(); + /** * @param {string} hex * @param {number} alpha @@ -76,7 +90,8 @@ const StarIcon = ({size}) => ( /** * Wish animation: a meteor coloured by rarity falls across a night sky, lands - * in a white flash, and the result is revealed with its stars popping in. + * in a white flash, and the result is revealed with its rank letter and stars. + * Every beat scales with the tier's effects so higher rarities are louder. * * @param {GachaRenderRequest} props */ @@ -85,6 +100,7 @@ export const GachaComposition = (props) => { const {fps, width, height} = useVideoConfig(); const seconds = frame / fps; const palette = getRarityPalette(props.rarity); + const fx = getTierEffects(props.rarity); const tl = gachaTimeline; const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], { @@ -93,7 +109,9 @@ export const GachaComposition = (props) => { }); const meteorVisible = seconds >= tl.meteorStart && seconds < tl.flashHoldEnd; const head = getMeteorPoint(meteorProgress, width, height); - const headRadius = height * interpolate(meteorProgress, [0, 1], [0.025, 0.085], clamp); + const headRadius = height * interpolate(meteorProgress, [0, 1], [0.025, 0.085], clamp) * fx.headScale; + 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); const flashOpacity = interpolate(seconds, [tl.flashStart, tl.flashPeak, tl.flashHoldEnd, tl.flashEnd], [0, 1, 1, 0], { ...clamp, @@ -103,25 +121,38 @@ export const GachaComposition = (props) => { const bloomRadius = interpolate(seconds, [tl.flashStart, tl.flashPeak], [0.1, 2.2], clamp) * width; 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 emblemScale = interpolate(seconds, [tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled], [0.72, 1.06, 1], { + const emblemScale = interpolate( + seconds, + [tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled], + [0.72, 1 + 0.04 * fx.headScale, 1], + {...clamp, easing: Easing.out(Easing.cubic)}, + ); + const silhouette = interpolate(seconds, [tl.revealStart + 0.2, tl.emblemSettled], [1, 0], clamp); + const raysOpacity = interpolate(seconds, [tl.revealStart, tl.emblemSettled], [0, fx.raysOpacity], clamp); + const starburstScale = interpolate(seconds, [tl.revealStart, tl.emblemSettled + 0.3], [0.2, 1], { ...clamp, easing: Easing.out(Easing.cubic), }); - const silhouette = interpolate(seconds, [tl.revealStart + 0.2, tl.emblemSettled], [1, 0], clamp); - const ringProgress = interpolate(seconds, [tl.revealStart, tl.revealStart + 0.9], [0, 1], { - ...clamp, - easing: Easing.out(Easing.quad), - }); - const raysOpacity = interpolate( - seconds, - [tl.revealStart, tl.emblemSettled], - [0, props.rarity === 3 ? 0.22 : 0.4], - clamp, - ); const emblemPulse = 0.5 + 0.5 * Math.sin(seconds * 3); + const sheenProgress = ((((seconds - tl.emblemSettled) / 1.8) % 1) + 1) % 1; + const rankLetter = getRankLetter(props.rarity); + const rankLetterStyle = + props.rarity === 5 + ? { + background: 'linear-gradient(180deg, #ffffff 0%, #fff6d0 55%, #ffd36b 100%)', + color: 'transparent', + filter: `drop-shadow(0 0 ${height * 0.006}px #8a4b00) drop-shadow(0 0 ${height * 0.006}px #8a4b00) drop-shadow(0 0 ${height * 0.03}px ${palette.glow})`, + WebkitBackgroundClip: 'text', + backgroundClip: 'text', + } + : { + color: '#ffffff', + textShadow: `0 2px ${height * 0.01}px rgba(0, 0, 0, 0.45), 0 0 ${height * 0.03}px ${palette.glow}`, + }; const nameProgress = interpolate(seconds, [tl.nameIn, tl.nameSettled], [0, 1], { ...clamp, @@ -129,6 +160,7 @@ export const GachaComposition = (props) => { }); const starSize = height * 0.09; const starTimes = getStarRevealTimes(props.rarity); + const rayMask = 'radial-gradient(circle, black 8%, transparent 55%)'; return ( @@ -159,14 +191,24 @@ export const GachaComposition = (props) => { /> ))} + {skyFlood > 0 ? ( + + ) : null} + {meteorVisible - ? Array.from({length: trailSamples}, (_, index) => { + ? Array.from({length: fx.trailSamples}, (_, index) => { const sampleProgress = meteorProgress - index * trailSpacing; if (sampleProgress <= 0) { return null; } const point = getMeteorPoint(sampleProgress, width, height); - const fade = 1 - index / trailSamples; + const fade = 1 - index / fx.trailSamples; const radius = headRadius * (0.25 + 0.75 * fade ** 1.3); return (
{ : null} {meteorVisible - ? meteorSparks.map((spark, index) => { + ? meteorSparks.slice(0, fx.sparks).map((spark, index) => { if (spark.born > meteorProgress) { return null; } @@ -194,7 +236,7 @@ export const GachaComposition = (props) => { return null; } const origin = getMeteorPoint(spark.born, width, height); - const size = spark.size * height * (1 - age); + const size = spark.size * height * (1 - age) * fx.headScale; return (
{ }) : null} + {meteorVisible && haloOpacity > 0 ? ( +
+ ) : null} + {meteorVisible ? ( <>
{ width: headRadius * 4, }} /> - {props.rarity === 5 ? ( + {props.rarity >= 4 ? ( <>
@@ -259,6 +319,7 @@ export const GachaComposition = (props) => {
{ borderRadius: '50%', height: width * 1.6, left: emblemX - width * 0.8, - maskImage: 'radial-gradient(circle, black 8%, transparent 55%)', + maskImage: rayMask, opacity: raysOpacity, position: 'absolute', top: emblemY - width * 0.8, transform: `rotate(${seconds * 9}deg)`, - WebkitMaskImage: 'radial-gradient(circle, black 8%, transparent 55%)', + WebkitMaskImage: rayMask, width: width * 1.6, }} /> + {fx.counterRays ? ( +
+ ) : null} - {revealMotes.map((mote, index) => { + {fx.starburst + ? Array.from({length: starburstSpikes}, (_, index) => { + const long = index % 2 === 0; + const length = height * (long ? 1.1 : 0.7) * starburstScale; + return ( +
+ ); + }) + : null} + + {revealMotes.slice(0, fx.motes).map((mote, index) => { const y = (((mote.y - (seconds - tl.revealStart) * mote.speed) % 1) + 1) % 1; const size = mote.size * height; return ( @@ -298,22 +398,55 @@ export const GachaComposition = (props) => { ); })} - {ringProgress < 1 ? ( -
- ) : null} + {rainSparkles.slice(0, fx.sparkleRain).map((sparkle, index) => { + const y = ((sparkle.offset + (seconds - tl.revealStart) * sparkle.speed) % 1.2) - 0.1; + const size = sparkle.size * height; + return ( +
+ ); + })} + + {Array.from({length: fx.shockwaves}, (_, index) => { + const start = tl.revealStart + index * 0.16; + const progress = interpolate(seconds, [start, start + 0.9], [0, 1], { + ...clamp, + easing: Easing.out(Easing.quad), + }); + if (progress <= 0 || progress >= 1) { + return null; + } + return ( +
+ ); + })}
{
{ position: 'absolute', }} /> + {fx.sheen && seconds >= tl.emblemSettled ? ( +
+ ) : null}
- {getEmblemGlyph(props.label)} + {rankLetter}
@@ -397,6 +541,7 @@ export const GachaComposition = (props) => { {starTimes.map((time, index) => { const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.6, 1], clamp); const flare = interpolate(seconds, [time, time + 0.3], [1, 0], clamp); + const flareSize = starSize * 2.4 * fx.starFlare; return (
{
@@ -440,7 +585,7 @@ export const GachaComposition = (props) => { width: bloomRadius * 2, }} /> - + ) : null} diff --git a/renderer/src/remotion/gacha-timeline.js b/renderer/src/remotion/gacha-timeline.js index 8a28848..a479e00 100644 --- a/renderer/src/remotion/gacha-timeline.js +++ b/renderer/src/remotion/gacha-timeline.js @@ -143,11 +143,110 @@ export const getLabelFontSize = (label, height) => { return Math.round(height * 0.055); }; +/** Rank letter shown on the emblem for each rarity. */ +const rankLetters = {3: 'B', 4: 'A', 5: 'S'}; + /** - * The emblem shows the label's first character, keeping emoji and other - * multi-unit code points intact. - * - * @param {string} label - * @returns {string} + * @param {GachaRarity} rarity + * @returns {'B' | 'A' | 'S'} */ -export const getEmblemGlyph = (label) => (Array.from(label.trim())[0] ?? '?').toUpperCase(); +export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLetters[rarity]); + +/** + * @typedef {object} TierEffects + * @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 {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. + * @property {boolean} starburst Long light spikes behind the emblem. + * @property {number} raysOpacity Peak opacity of the rotating light rays. + * @property {boolean} counterRays Second ray layer rotating the other way. + * @property {number} motes Glowing motes floating up behind the reveal. + * @property {number} sparkleRain Sparkles falling across the reveal. + * @property {boolean} sheen Light sweep across the emblem and rank letter. + * @property {number} starFlare Size multiplier of each star's pop flare. + */ + +/** + * How loud each tier is. Every beat exists at every tier; higher tiers get + * more of it, so 4★ clearly beats 3★ and 5★ clearly beats both. + * + * @type {Record} + */ +const tierEffects = { + 3: { + headScale: 1, + trailSamples: 22, + sparks: 12, + halo: false, + skyFlood: 0, + shake: 0, + shockwaves: 1, + starburst: false, + raysOpacity: 0.16, + counterRays: false, + motes: 14, + sparkleRain: 0, + sheen: false, + starFlare: 1, + }, + 4: { + headScale: 1.3, + trailSamples: 34, + sparks: 36, + halo: false, + skyFlood: 0.25, + shake: 0.012, + shockwaves: 2, + starburst: false, + raysOpacity: 0.42, + counterRays: false, + motes: 32, + sparkleRain: 0, + sheen: false, + starFlare: 1.4, + }, + 5: { + headScale: 1.75, + trailSamples: 48, + sparks: 80, + halo: true, + skyFlood: 0.7, + shake: 0.03, + shockwaves: 3, + starburst: true, + raysOpacity: 0.7, + counterRays: true, + motes: 56, + sparkleRain: 40, + sheen: true, + starFlare: 2, + }, +}; + +/** + * @param {GachaRarity} rarity + * @returns {TierEffects} + */ +export const getTierEffects = (rarity) => tierEffects[rarity]; + +/** + * Decaying impact shake offset in pixels, zero before `start` and after it + * settles. + * + * @param {number} seconds + * @param {number} start + * @param {number} amplitude Pixels at the moment of impact. + * @returns {{x: number, y: number}} + */ +export const getShakeOffset = (seconds, start, amplitude) => { + const elapsed = seconds - start; + if (amplitude <= 0 || elapsed < 0 || elapsed > 0.8) { + return {x: 0, y: 0}; + } + const decay = amplitude * (1 - elapsed / 0.8) ** 2; + return {x: Math.sin(elapsed * 71) * decay, y: Math.cos(elapsed * 53) * decay}; +}; diff --git a/renderer/test/gacha-timeline.test.js b/renderer/test/gacha-timeline.test.js index 97edd84..260b62b 100644 --- a/renderer/test/gacha-timeline.test.js +++ b/renderer/test/gacha-timeline.test.js @@ -3,11 +3,13 @@ import { createStarfield, gachaTimeline, gachaTotalSeconds, - getEmblemGlyph, getLabelFontSize, getMeteorPoint, + getRankLetter, getRarityPalette, + getShakeOffset, getStarRevealTimes, + getTierEffects, } from '../src/remotion/gacha-timeline.js'; describe('gacha timeline', () => { @@ -56,9 +58,39 @@ describe('gacha timeline', () => { expect(getLabelFontSize('Pizza', 360)).toBeGreaterThan(getLabelFontSize('x'.repeat(40), 360)); }); - test('emblem glyph keeps multi-unit characters whole', () => { - expect(getEmblemGlyph(' pizza')).toBe('P'); - expect(getEmblemGlyph('🍕 time')).toBe('🍕'); - expect(getEmblemGlyph('ăn')).toBe('Ă'); + test('emblem shows B, A, S rank letters by rarity', () => { + expect([3, 4, 5].map((rarity) => getRankLetter(/** @type {3 | 4 | 5} */ (rarity)))).toEqual(['B', 'A', 'S']); + }); + + test('every numeric effect grows with rarity and 5★ unlocks the extras', () => { + const [three, four, five] = [3, 4, 5].map((rarity) => getTierEffects(/** @type {3 | 4 | 5} */ (rarity))); + for (const key of /** @type {const} */ ([ + 'headScale', + 'trailSamples', + 'sparks', + 'shockwaves', + 'raysOpacity', + 'motes', + 'starFlare', + ])) { + expect(four?.[key], key).toBeGreaterThan(three?.[key] ?? Infinity); + expect(five?.[key], key).toBeGreaterThan(four?.[key] ?? Infinity); + } + expect(four?.shake).toBeGreaterThan(three?.shake ?? Infinity); + expect(five?.shake).toBeGreaterThan(four?.shake ?? Infinity); + expect(five?.skyFlood).toBeGreaterThan(four?.skyFlood ?? Infinity); + expect([three?.halo, four?.halo, five?.halo]).toEqual([false, false, true]); + expect(five).toMatchObject({starburst: true, counterRays: true, sheen: true}); + expect(three?.sparkleRain).toBe(0); + expect(five?.sparkleRain).toBeGreaterThan(0); + }); + + test('impact shake decays to rest', () => { + expect(getShakeOffset(1, 2, 10)).toEqual({x: 0, y: 0}); + expect(getShakeOffset(3, 2, 10)).toEqual({x: 0, y: 0}); + expect(getShakeOffset(2.1, 2, 0)).toEqual({x: 0, y: 0}); + const early = getShakeOffset(2.05, 2, 10); + const late = getShakeOffset(2.7, 2, 10); + expect(Math.hypot(early.x, early.y)).toBeGreaterThan(Math.hypot(late.x, late.y)); }); });