feat(gacha): refine beta wish animation and SSS reveal

This commit is contained in:
tiennm99 committed 2026-10-01 15:42:54 +07:00
1 parent 11f458fee5
commit adea684a2f
5 files changed
+152 -41

No files matched your search

+12 -5
View File
@@ -68,11 +68,18 @@ only draws it.
`POST /api/gachabeta` takes the same body and returns the same response as
`/api/gacha`, rendering an 8-second beta style instead: a cosmic, astrology
themed night sky in toon shading (flat colour bands, no outlines) seen through
a perspective action camera. The camera dollies toward a hero cloud, a comet
lights its rim from behind and bursts through it, the camera chases the comet
as its flare builds, and after the burst the label appears with a rank
(`S`, `SS`, `SSS` for rarity 3, 4, 5).
themed night sky with toon-shaded clouds and stars, without outlines, seen
through a perspective action camera:
1. The sky fades in around a main cloud. A comet approaches from behind,
lights the cloud's rim, then punches through it with scattered puffs and an impact ring.
2. The camera follows the flying star as its light and lens flare intensify,
ending in a starburst and white flash.
3. The food label appears inside an astrology ring with rank `SSS`.
The caller randomly chooses the food and sends it as `label` (for example,
`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` still chooses the
comet's blue, purple, or gold palette. `/api/gacha` keeps its existing ranks.
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
@@ -0,0 +1,23 @@
---
title: Rewrite gacha beta wish animation
date: 2026-10-01
summary: "Clarified the comet approach, cloud piercing, and cosmic SSS food reveal."
---
# Rewrite gacha beta wish animation
## Changes
Updated the existing eight-second beta composition with a fading night sky, a visible comet approach behind the main cloud, radial cloud dispersal and an impact ring, a perspective-spinning Remotion toon star, and an astrology-ring result reveal. The beta rank is always SSS, as requested; rarity still controls the light palette. Kept the existing caller-selected label contract, so the caller supplies its randomly chosen food. Updated the owning README section.
## Validation
Focused choreography tests passed. Lint, JavaScript type checking, and all 92 tests passed. Render smoke and API smoke passed, including the real beta MP4 response. Inspected rendered frames of the approach, cloud impact, flight, explosion, and final Vietnamese label with SSS. Reviewed the diff inline against the requested phases and API compatibility; no remaining findings.
The bare session environment initially could not launch Chromium because libnspr4 and other runtime libraries were absent. Downloaded and unpacked runtime dependencies under /tmp without system installation. Still-frame inspection then revealed missing text because this environment had no installed fonts. Added temporary Noto and DejaVu fonts through FONTCONFIG_FILE and regenerated the smoke fixtures successfully. Docker already installs Noto fonts and Chromium runtime dependencies.
## Result
The local sample is fixtures/gachabeta-5-star.mp4 (ignored by Git). No commits or publication performed. Render processes exited cleanly. AgentWiki publish skipped.
> Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.
+94 -21
View File
@@ -1,4 +1,5 @@
import {useMemo} from 'react';
import {Star} from '@remotion/shapes';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
import {
gachaBetaTimeline,
@@ -18,9 +19,9 @@ const baseFont =
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
/** Toon shading: flat colour bands with hard edges and no outline. */
const skyBands =
'linear-gradient(180deg, #0a0c2c 0% 28%, #15185a 28% 52%, #262a80 52% 72%, #3a44a6 72% 88%, #5664c4 88%)';
/** A luminous night sky behind flat, outline-free cloud shading. */
const skyBackground =
'radial-gradient(ellipse at 50% 105%, #7886ce 0%, #353f83 32%, #171b49 65%, #090e29 100%)';
const cloudShade = 'radial-gradient(circle at 38% 32%, #f6f4ff 0% 44%, #cdd1f6 44% 70%, #959cdb 70%)';
const heroShade = 'radial-gradient(circle at 38% 30%, #ffffff 0% 46%, #d8dcfb 46% 72%, #a0a8e4 72%)';
@@ -91,11 +92,26 @@ const withAlpha = (hex, alpha) => {
* @param {{size: number, fill: string, shade: string}} props
*/
const ToonStar = ({size, fill, shade}) => (
<svg height={size} style={{display: 'block'}} viewBox="0 0 24 24" width={size}>
<path d="M12 0.8l3.2 7.9 8.3.6-6.4 5.3 2.1 8.2L12 18.3l-7.2 4.5 2.1-8.2L0.5 9.3l8.3-.6z" fill={shade} />
<path d="M12 0.8l3.2 7.9 8.3.6-6.4 5.3L12 14.2z" fill={fill} />
<path d="M12 0.8L8.8 8.7l-8.3.6 6.4 5.3L12 14.2z" fill={fill} opacity={0.82} />
</svg>
<div style={{height: size, position: 'relative', width: size}}>
<Star
cornerRadius={size * 0.035}
fill={shade}
innerRadius={size * 0.23}
outerRadius={size / 2}
points={5}
style={{height: size, position: 'absolute', width: size}}
/>
<div style={{clipPath: 'polygon(0 0, 100% 0, 100% 42%, 50% 62%, 0 42%)', inset: 0, position: 'absolute'}}>
<Star
cornerRadius={size * 0.035}
fill={fill}
innerRadius={size * 0.23}
outerRadius={size / 2}
points={5}
style={{height: size, width: size}}
/>
</div>
</div>
);
/**
@@ -126,7 +142,9 @@ export const GachaBetaComposition = (props) => {
const cometVisible = seconds >= tl.cometAppear && seconds < tl.flashPeak;
const burst = interpolate(seconds, [tl.flightEnd - 0.1, tl.flashPeak], [0, 1], clamp);
const flash = interpolate(seconds, [tl.flightEnd, tl.flashPeak, tl.flashEnd], [0, 1, 0], clamp);
const cardVisible = seconds >= tl.cardIn - 0.2;
const cardVisible = seconds >= tl.cardIn;
const skyIn = interpolate(seconds, [0, 0.65], [0, 1], clamp);
const revealIn = interpolate(seconds, [tl.cardIn, tl.flashEnd + 0.2], [0, 1], clamp);
const pierceBurst = interpolate(seconds, [pierceTime, pierceTime + 0.9], [0, 1], {
...clamp,
easing: Easing.out(Easing.cubic),
@@ -179,13 +197,14 @@ export const GachaBetaComposition = (props) => {
drawables.push({
depth: hero.depth,
node: (
<div key="hero" style={{left: hero.x, position: 'absolute', top: hero.y}}>
<div key="hero" style={{left: hero.x, opacity: skyIn, position: 'absolute', top: hero.y}}>
{scene.heroPuffs.map((puff, index) => {
const distance = Math.hypot(puff.dx, puff.dy) || 1;
const push = pierceBurst * (0.6 + (1 - Math.min(1, distance)) * 1.4);
const ox = (puff.dx / distance) * push;
const oy = (puff.dy / distance) * push;
const opacity = 1 - pierceBurst * (distance < 0.5 ? 0.95 : 0.55);
const angle = (index / scene.heroPuffs.length) * Math.PI * 2;
const ox = Math.cos(angle) * push;
const oy = Math.sin(angle) * push;
const opacity = 1 - pierceBurst * (distance < 0.5 ? 1 : 0.65);
return (
<div
key={index}
@@ -197,7 +216,7 @@ export const GachaBetaComposition = (props) => {
opacity,
position: 'absolute',
top: (puff.dy + oy) * radius - puff.r * radius,
transform: `scale(${1 + pierceBurst * 0.3})`,
transform: `rotate(${puff.dx * 12}deg) scale(${1 + pierceBurst * 0.3}, 0.78)`,
width: puff.r * radius * 2,
}}
/>
@@ -276,7 +295,7 @@ export const GachaBetaComposition = (props) => {
left: cometScreen.x - headSize / 2,
position: 'absolute',
top: cometScreen.y - headSize / 2,
transform: `rotate(${seconds * 140}deg)`,
transform: `perspective(${height}px) rotateY(${Math.sin(seconds * 4) * 32}deg) rotateZ(${seconds * 100}deg)`,
}}
>
<ToonStar fill="#ffffff" shade={palette.core} size={headSize} />
@@ -328,9 +347,34 @@ export const GachaBetaComposition = (props) => {
}
}
if (hero && seconds >= pierceTime && seconds < pierceTime + 0.65) {
const impact = (seconds - pierceTime) / 0.65;
const diameter = heroCloud.radius * hero.scale * (0.4 + impact * 3.5);
drawables.push({
depth: hero.depth - 1,
node: (
<div
key="cloud-impact"
style={{
border: `${Math.max(1, 8 * (1 - impact))}px solid ${palette.core}`,
borderRadius: '50%',
boxShadow: `0 0 ${height * 0.04}px ${palette.glow}`,
height: diameter,
left: hero.x - diameter / 2,
opacity: 1 - impact,
position: 'absolute',
top: hero.y - diameter / 2,
transform: 'rotateX(48deg)',
width: diameter,
}}
/>
),
});
}
drawables.sort((a, b) => b.depth - a.depth);
const rank = getBetaRank(props.rarity);
const rank = getBetaRank();
const cardPop = interpolate(seconds, [tl.cardIn, tl.cardIn + 0.35, tl.cardIn + 0.55], [0.6, 1.08, 1], {
...clamp,
easing: Easing.out(Easing.cubic),
@@ -341,10 +385,11 @@ export const GachaBetaComposition = (props) => {
<AbsoluteFill style={{background: '#0a0c2c', fontFamily: baseFont, overflow: 'hidden'}}>
<AbsoluteFill
style={{
opacity: skyIn,
transform: `translate(${shake.x}px, ${shake.y}px) rotate(${camera.roll}deg) scale(1.18)`,
}}
>
<AbsoluteFill style={{background: skyBands}} />
<AbsoluteFill style={{background: skyBackground}} />
<svg
height={height}
@@ -446,11 +491,11 @@ export const GachaBetaComposition = (props) => {
{cardVisible ? (
<AbsoluteFill
style={{background: `radial-gradient(circle, ${palette.deep} 0% 30%, #120f3a 30% 60%, #0a0c2c 60%)`}}
style={{background: `radial-gradient(ellipse, ${withAlpha(palette.deep, 0.9)}, #0a0c2cee)`, opacity: revealIn}}
>
<div
style={{
background: `repeating-conic-gradient(from ${seconds * 12}deg, ${withAlpha(palette.glow, 0.32)} 0deg 10deg, transparent 10deg 20deg)`,
background: `repeating-conic-gradient(from ${seconds * 4}deg, ${withAlpha(palette.glow, 0.16)} 0deg 5deg, transparent 5deg 30deg)`,
borderRadius: '50%',
height: width * 1.5,
left: width / 2 - width * 0.75,
@@ -476,6 +521,34 @@ export const GachaBetaComposition = (props) => {
</div>
);
})}
<div
style={{
border: `1px solid ${withAlpha(palette.core, 0.35)}`,
borderRadius: '50%',
height: height * 0.8,
left: width / 2 - height * 0.4,
position: 'absolute',
top: height * 0.5 - height * 0.4,
transform: `rotate(${seconds * 5}deg)`,
width: height * 0.8,
}}
>
{Array.from({length: 12}, (_, index) => (
<div
key={index}
style={{
background: palette.core,
height: height * 0.018,
left: '50%',
opacity: 0.65,
position: 'absolute',
top: '50%',
transform: `rotate(${index * 30}deg) translateY(${-height * 0.4}px)`,
width: 2,
}}
/>
))}
</div>
<div
style={{
alignItems: 'center',
@@ -501,7 +574,7 @@ export const GachaBetaComposition = (props) => {
transform: `scale(${rankPop})`,
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
filter: `drop-shadow(0 ${height * 0.012}px 0 #8a4b00) drop-shadow(0 0 ${height * 0.04}px ${palette.glow})`,
filter: `drop-shadow(0 0 ${height * 0.04}px ${palette.glow})`,
}}
>
{rank}
@@ -516,7 +589,7 @@ export const GachaBetaComposition = (props) => {
maxWidth: width * 0.8,
overflowWrap: 'anywhere',
textAlign: 'center',
textShadow: `0 ${height * 0.01}px 0 ${palette.deep}, 0 0 ${height * 0.03}px ${withAlpha(palette.glow, 0.8)}`,
textShadow: `0 0 ${height * 0.03}px ${withAlpha(palette.glow, 0.8)}`,
}}
>
{props.label}
+5 -13
View File
@@ -5,8 +5,6 @@
* React so the choreography can be unit tested.
*/
/** @typedef {3 | 4 | 5} GachaRarity */
/**
* @typedef {object} Vec3
* @property {number} x
@@ -48,8 +46,8 @@ export const gachaBetaTimeline = Object.freeze({
/** The cloud the camera dollies toward and the comet pierces. */
export const heroCloud = Object.freeze({x: 0, y: 30, z: 800, radius: 170});
/** Where the comet waits behind the hero cloud, then where it exits in front. */
const cometHidden = Object.freeze({x: 60, y: 10, z: 1500});
/** The approach behind the cloud, the entry point, and the exit in front. */
const cometHidden = Object.freeze({x: -1050, y: -430, z: 1500});
const cometPierceFrom = Object.freeze({x: 40, y: 20, z: 1300});
const cometPierceTo = Object.freeze({x: 0, y: 30, z: 500});
@@ -92,7 +90,7 @@ const easeOutCubic = (t) => 1 - (1 - clamp01(t)) ** 3;
const lerp3 = (a, b, t) => ({x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: a.z + (b.z - a.z) * t});
/**
* Comet position in world units. It waits behind the hero cloud, punches
* Comet position in world units. It approaches behind the hero cloud, punches
* straight through it toward the camera, then flies off on a rising arc and
* slows as it gathers light for the burst.
*
@@ -202,11 +200,5 @@ export const getBetaShake = (seconds, height) => {
return {x, y};
};
/** @type {Record<GachaRarity, 'S' | 'SS' | 'SSS'>} */
const betaRanks = {3: 'S', 4: 'SS', 5: 'SSS'};
/**
* @param {GachaRarity} rarity
* @returns {'S' | 'SS' | 'SSS'}
*/
export const getBetaRank = (rarity) => betaRanks[rarity];
/** The beta food wish always reveals SSS; rarity still controls its light palette. */
export const getBetaRank = () => 'SSS';
+18 -2
View File
@@ -42,6 +42,22 @@ describe('gacha beta scene', () => {
}
});
test('approaching comet is visible beside the cloud before passing behind it', () => {
const seconds = 1.4;
const camera = getCamera(seconds);
const comet = project(getCometPosition(seconds), camera, 640, 360);
const cloud = project(heroCloud, camera, 640, 360);
expect(comet && cloud).toBeTruthy();
if (comet && cloud) {
expect(comet.x).toBeGreaterThan(0);
expect(comet.x).toBeLessThan(640);
expect(comet.y).toBeGreaterThan(0);
expect(comet.y).toBeLessThan(360);
expect(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeGreaterThan(heroCloud.radius * cloud.scale);
expect(comet.depth).toBeGreaterThan(cloud.depth);
}
});
test('camera dollies toward the cloud, then chases the comet and keeps it in frame', () => {
expect(getCamera(1).z).toBeGreaterThan(getCamera(0).z);
for (const seconds of [3, 3.5, 4, 4.5]) {
@@ -84,7 +100,7 @@ describe('gacha beta scene', () => {
expect(Math.hypot(...Object.values(getBetaShake(gachaBetaTimeline.flightEnd + 0.05, 360)))).toBeGreaterThan(0);
});
test('ranks map rarity to S, SS, SSS', () => {
expect([3, 4, 5].map((rarity) => getBetaRank(/** @type {3 | 4 | 5} */ (rarity)))).toEqual(['S', 'SS', 'SSS']);
test('beta food reveal displays SSS', () => {
expect(getBetaRank()).toBe('SSS');
});
});