fix(gacha): show the requested rank and stars on the beta reveal

The beta splash card used a fixed SSS rank and six stars. It now uses the
same rarity info as /api/gacha: the rank letter (B, A, or S) on the emblem
and name plate, and one star per rarity level beside the caller's label.
This commit is contained in:
tiennm99 committed 2026-10-01 16:36:22 +07:00
1 parent 081ab00f2e
commit f75c0e560b
5 files changed
+49 -42

No files matched your search

+5 -5
View File
@@ -77,13 +77,13 @@ gacha game's six-star wish:
3. Crystal comets fall through the daytime sky while a red star joins them
and strikes in a white flash.
4. A black silhouette of the rank emblem sheds shards on a red disc, then the
splash card shows the emblem, a name plate with the label and `Rank SSS`,
and six stars popping in.
splash card shows the emblem, a name plate with the request's `label` and
rank, and one star per rarity level popping in.
The caller randomly chooses the food and sends it as `label` (for example,
`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` tints the meteor
head and the splash card's emblem (blue 3★, purple 4★, gold 5★). `/api/gacha`
keeps its existing ranks.
`"Bún bò"`). As on `/api/gacha`, `rarity` picks the rank (`B` for 3★, `A` for
4★, `S` for 5★), the star count, and the tint of the meteor head and emblem
(blue, purple, gold).
Response is a silent H.264 `video/mp4` (Telegram plays it as an animation)
with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. Both routes share the
+29 -15
View File
@@ -5,7 +5,6 @@ import {bakeCloudLayer} from './gacha-beta-paint.js';
import {
createShards,
gachaBetaTimeline,
getBetaRank,
getBurstState,
getFlash,
getHaloState,
@@ -19,7 +18,14 @@ import {
progress,
volleyComets,
} from './gacha-beta-scene.js';
import {createRandom, createStarfield, getRarityPalette, getTwinkle, starColor} from './gacha-timeline.js';
import {
createRandom,
createStarfield,
getRankLetter,
getRarityPalette,
getTwinkle,
starColor,
} from './gacha-timeline.js';
const sansFont =
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
@@ -149,7 +155,15 @@ const createBetaLayout = (seed, width, height) => {
};
/** @typedef {ReturnType<typeof createBetaLayout>} BetaLayout */
/** @typedef {{seconds: number, width: number, height: number, layout: BetaLayout, palette: import('./gacha-timeline.js').RarityPalette}} ShotProps */
/**
* @typedef {object} ShotProps
* @property {number} seconds
* @property {number} width
* @property {number} height
* @property {BetaLayout} layout
* @property {import('./gacha-timeline.js').RarityPalette} palette
* @property {'B' | 'A' | 'S'} rank Rank for the request's rarity, as on /api/gacha.
*/
/**
* Four-point star glint with a soft core, used for meteor heads.
@@ -611,9 +625,9 @@ const VolleyShot = ({seconds, width, height, layout}) => {
* The rank emblem: a faceted rhombus crystal around the rank letters. Drawn
* solid black for the silhouette and in the rarity colours on the card.
*
* @param {{size: number, silhouette: boolean, palette: import('./gacha-timeline.js').RarityPalette}} props
* @param {{size: number, silhouette: boolean, palette: import('./gacha-timeline.js').RarityPalette, rank: string}} props
*/
const Emblem = ({size, silhouette, palette}) => {
const Emblem = ({size, silhouette, palette, rank}) => {
const fill = silhouette ? '#050305' : `linear-gradient(135deg, #ffffff 0%, ${palette.emblem} 100%)`;
return (
<div style={{height: size, position: 'relative', width: size}}>
@@ -651,14 +665,14 @@ const Emblem = ({size, silhouette, palette}) => {
textShadow: silhouette ? undefined : `0 0 ${size * 0.06}px ${palette.glow}`,
}}
>
{getBetaRank()}
{rank}
</div>
</div>
);
};
/** @param {ShotProps} props */
const SilhouetteShot = ({seconds, width, height, layout, palette}) => {
const SilhouetteShot = ({seconds, width, height, layout, palette, rank}) => {
const tl = gachaBetaTimeline;
const t = progress(seconds, tl.volleyEnd, tl.silhouetteEnd);
const cx = width / 2;
@@ -741,16 +755,16 @@ const SilhouetteShot = ({seconds, width, height, layout, palette}) => {
top: cy - emblem / 2,
}}
>
<Emblem palette={palette} silhouette size={emblem} />
<Emblem palette={palette} rank={rank} silhouette size={emblem} />
</div>
</AbsoluteFill>
);
};
/**
* @param {{seconds: number, width: number, height: number, layout: BetaLayout, palette: import('./gacha-timeline.js').RarityPalette, label: string}} props
* @param {ShotProps & {label: string, rarity: import('./gacha-timeline.js').GachaRarity}} props
*/
const RevealShot = ({seconds, width, height, layout, palette, label}) => {
const RevealShot = ({seconds, width, height, layout, palette, rank, label, rarity}) => {
const tl = gachaBetaTimeline;
const settle = progress(seconds, tl.silhouetteEnd, gachaBetaTimeline.silhouetteEnd + 2.5);
const plateIn = interpolate(seconds, [tl.plateIn, tl.plateIn + 0.4], [0, 1], {
@@ -828,7 +842,7 @@ const RevealShot = ({seconds, width, height, layout, palette, label}) => {
top: height * 0.46 - emblem / 2 + Math.sin(seconds * 1.5) * height * 0.008,
}}
>
<Emblem palette={palette} silhouette={false} size={emblem} />
<Emblem palette={palette} rank={rank} silhouette={false} size={emblem} />
</div>
</AbsoluteFill>
<div
@@ -873,7 +887,7 @@ const RevealShot = ({seconds, width, height, layout, palette, label}) => {
textShadow: `0 ${height * 0.004}px ${height * 0.012}px rgba(0,0,0,0.7)`,
}}
>
{`Rank ${getBetaRank()}`}
{`Rank ${rank}`}
</div>
</div>
</div>
@@ -885,7 +899,7 @@ const RevealShot = ({seconds, width, height, layout, palette, label}) => {
marginTop: height * 0.012,
}}
>
{getStarRevealTimes().map((time, index) => {
{getStarRevealTimes(rarity).map((time, index) => {
const pop = interpolate(seconds, [time, time + 0.12, time + 0.24], [0, 1.4, 1], clamp);
return (
<div
@@ -925,7 +939,7 @@ export const GachaBetaComposition = (props) => {
const palette = getRarityPalette(props.rarity);
const shot = getShot(seconds);
const flash = getFlash(seconds);
const shared = {seconds, width, height, layout, palette};
const shared = {seconds, width, height, layout, palette, rank: getRankLetter(props.rarity)};
return (
<AbsoluteFill
@@ -941,7 +955,7 @@ export const GachaBetaComposition = (props) => {
{shot === 'sky' ? <SkyShot {...shared} /> : null}
{shot === 'volley' ? <VolleyShot {...shared} /> : null}
{shot === 'silhouette' ? <SilhouetteShot {...shared} /> : null}
{shot === 'reveal' ? <RevealShot {...shared} label={props.label} /> : null}
{shot === 'reveal' ? <RevealShot {...shared} label={props.label} rarity={props.rarity} /> : null}
{flash.opacity > 0 ? <AbsoluteFill style={{background: flash.color, opacity: flash.opacity}} /> : null}
{flash.opacity > 0 && flash.color !== '#ffffff' && shot === 'sky' ? (
<AbsoluteFill
+7 -9
View File
@@ -4,7 +4,7 @@
* pass, a meteor that slows over a sea of clouds inside a rainbow halo and
* bursts, a red flash, a volley of falling crystal comets joined by a red
* one, a black silhouette on a red disc that sheds shards, and the splash
* card with its name plate and six stars. Kept free of React so the
* card with its name plate, rank, and stars. Kept free of React so the
* choreography can be unit tested.
*/
@@ -26,8 +26,7 @@ export const gachaBetaTimeline = Object.freeze({
starStep: 0.14,
});
/** The beta always reveals six stars and rank SSS. */
export const betaStarCount = 6;
/** @typedef {import('./gacha-timeline.js').GachaRarity} GachaRarity */
/** @typedef {'vortex' | 'beam' | 'sky' | 'volley' | 'silhouette' | 'reveal'} BetaShot */
@@ -273,12 +272,11 @@ export const getShardOffset = (shard, seconds) => {
};
/**
* Seconds at which each reveal star pops in, left to right.
* Seconds at which each reveal star pops in, left to right: one star per
* rarity level.
*
* @param {GachaRarity} rarity
* @returns {number[]}
*/
export const getStarRevealTimes = () =>
Array.from({length: betaStarCount}, (_, index) => gachaBetaTimeline.starsStart + index * gachaBetaTimeline.starStep);
/** The beta food wish always reveals SSS; rarity tints the meteor and splash. */
export const getBetaRank = () => 'SSS';
export const getStarRevealTimes = (rarity) =>
Array.from({length: rarity}, (_, index) => gachaBetaTimeline.starsStart + index * gachaBetaTimeline.starStep);
+1 -1
View File
@@ -24,5 +24,5 @@ const createGachaRenderer = (compositionId) => (inputProps, options) =>
export const renderGachaVideo = createGachaRenderer('GachaWish');
/** The beta wish: a six-star style wish sequence ending in an SSS splash card. */
/** The beta wish: a six-star style wish sequence ending in a ranked splash card. */
export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish');
+7 -12
View File
@@ -1,10 +1,8 @@
import {describe, expect, test} from 'vitest';
import {
betaStarCount,
createShards,
gachaBetaTimeline,
gachaBetaTotalSeconds,
getBetaRank,
getFlash,
getHaloState,
getMeteorHead,
@@ -35,12 +33,13 @@ describe('gacha beta wish scene', () => {
expect(tl.redStarIn).toBeLessThan(tl.volleyEnd);
});
test('the six stars finish popping with time to hold the card', () => {
const times = getStarRevealTimes();
expect(times).toHaveLength(betaStarCount);
expect(betaStarCount).toBe(6);
expect(times[0]).toBeGreaterThan(gachaBetaTimeline.plateIn);
expect((times.at(-1) ?? Infinity) + 1).toBeLessThan(gachaBetaTotalSeconds);
test('one star per rarity level, finishing with time to hold the card', () => {
for (const rarity of /** @type {const} */ ([3, 4, 5])) {
const times = getStarRevealTimes(rarity);
expect(times).toHaveLength(rarity);
expect(times[0]).toBeGreaterThan(gachaBetaTimeline.plateIn);
expect((times.at(-1) ?? Infinity) + 1).toBeLessThan(gachaBetaTotalSeconds);
}
});
test('flashes open the clip, cover the burst in red, and hand over at each loud cut', () => {
@@ -102,8 +101,4 @@ describe('gacha beta wish scene', () => {
expect(Math.hypot(end.dx, end.dy)).toBeCloseTo(shard.reach);
}
});
test('beta food reveal displays SSS', () => {
expect(getBetaRank()).toBe('SSS');
});
});