From adea684a2f868118b4cd705ac7c2e18c3e9dfd31 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 15:42:54 +0700 Subject: [PATCH] feat(gacha): refine beta wish animation and SSS reveal --- renderer/README.md | 17 ++- ...10-01-rewrite-gacha-beta-wish-animation.md | 23 ++++ .../src/remotion/GachaBetaComposition.jsx | 115 ++++++++++++++---- renderer/src/remotion/gacha-beta-scene.js | 18 +-- renderer/test/gacha-beta-scene.test.js | 20 ++- 5 files changed, 152 insertions(+), 41 deletions(-) create mode 100644 renderer/plans/journals/2026-10-01-rewrite-gacha-beta-wish-animation.md diff --git a/renderer/README.md b/renderer/README.md index 8d6ba5f..ad3ae36 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -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 diff --git a/renderer/plans/journals/2026-10-01-rewrite-gacha-beta-wish-animation.md b/renderer/plans/journals/2026-10-01-rewrite-gacha-beta-wish-animation.md new file mode 100644 index 0000000..71c32cc --- /dev/null +++ b/renderer/plans/journals/2026-10-01-rewrite-gacha-beta-wish-animation.md @@ -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. diff --git a/renderer/src/remotion/GachaBetaComposition.jsx b/renderer/src/remotion/GachaBetaComposition.jsx index e6fefed..4ae1ab2 100644 --- a/renderer/src/remotion/GachaBetaComposition.jsx +++ b/renderer/src/remotion/GachaBetaComposition.jsx @@ -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}) => ( - - - - - +
+ +
+ +
+
); /** @@ -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: ( -
+
{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 (
{ 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)`, }} > @@ -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: ( +
+ ), + }); + } + 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) => { - + { {cardVisible ? (
{
); })} +
+ {Array.from({length: 12}, (_, index) => ( +
+ ))} +
{ 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} diff --git a/renderer/src/remotion/gacha-beta-scene.js b/renderer/src/remotion/gacha-beta-scene.js index d0e0ea2..c1e67f9 100644 --- a/renderer/src/remotion/gacha-beta-scene.js +++ b/renderer/src/remotion/gacha-beta-scene.js @@ -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} */ -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'; diff --git a/renderer/test/gacha-beta-scene.test.js b/renderer/test/gacha-beta-scene.test.js index 4742d8a..47b4cf8 100644 --- a/renderer/test/gacha-beta-scene.test.js +++ b/renderer/test/gacha-beta-scene.test.js @@ -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'); }); });