feat(gacha): rebuild the beta wish as a meteor shower reveal

The beta wish now renders an 8-second night sky over a snow-rimmed
mountain ridge, where coloured meteors glide down the diagonal. A hero
meteor in the rarity colour slows and burns out at the centre, where the
label appears under rank SSS. The toon cloud, comet, and perspective
camera are removed. The /api/gachabeta contract is unchanged.
This commit is contained in:
tiennm99 committed 2026-10-01 16:07:46 +07:00
1 parent adea684a2f
commit b8d5544fd6
5 files changed
+370 -761

No files matched your search

+47 -81
View File
@@ -3,101 +3,67 @@ import {
gachaBetaTimeline,
gachaBetaTotalSeconds,
getBetaRank,
getBetaShake,
getCamera,
getCometGlow,
getCometPosition,
heroCloud,
pierceTime,
project,
getBurnGlint,
getHeroMeteor,
getSkyMeteorHead,
meteorAngleDegrees,
revealAnchor,
skyMeteors,
} from '../src/remotion/gacha-beta-scene.js';
describe('gacha beta scene', () => {
test('phases run in order inside the clip', () => {
const width = 640;
const height = 360;
describe('gacha beta meteor scene', () => {
test('beats run in order and the result holds before the clip ends', () => {
const tl = gachaBetaTimeline;
expect(tl.cometAppear).toBeLessThan(tl.pierceStart);
expect(tl.pierceStart).toBeLessThan(pierceTime);
expect(pierceTime).toBeLessThan(tl.pierceEnd);
expect(tl.pierceEnd).toBeLessThan(tl.flightEnd);
expect(tl.flightEnd).toBeLessThan(tl.flashPeak);
expect(tl.cardIn).toBeLessThan(tl.flashEnd);
expect(tl.rankIn + 1).toBeLessThan(gachaBetaTotalSeconds);
expect(tl.fadeInEnd).toBeLessThan(tl.heroStart);
expect(tl.heroStart).toBeLessThan(tl.heroBurn);
expect(tl.heroBurn).toBeLessThan(tl.labelIn);
expect(tl.labelIn).toBeLessThan(tl.rankIn);
expect(tl.rankIn + 2).toBeLessThan(gachaBetaTotalSeconds);
});
test('comet waits behind the hero cloud, then crosses its plane at the pierce', () => {
expect(getCometPosition(gachaBetaTimeline.cometAppear).z).toBeGreaterThan(heroCloud.z);
expect(getCometPosition(pierceTime).z).toBeCloseTo(heroCloud.z);
expect(getCometPosition(gachaBetaTimeline.pierceEnd).z).toBeLessThan(heroCloud.z);
});
test('comet is hidden behind the cloud: projected inside its silhouette', () => {
const seconds = gachaBetaTimeline.pierceStart;
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(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeLessThan(heroCloud.radius * cloud.scale);
expect(comet.depth).toBeGreaterThan(cloud.depth);
}
});
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]) {
const camera = getCamera(seconds);
const comet = project(getCometPosition(seconds), camera, 640, 360);
expect(comet).not.toBeNull();
if (comet) {
expect(comet.x).toBeGreaterThan(640 * 0.2);
expect(comet.x).toBeLessThan(640 * 0.8);
expect(comet.y).toBeGreaterThan(360 * 0.2);
expect(comet.y).toBeLessThan(360 * 0.8);
test('background meteors appear on their cue and glide down to the right', () => {
for (const meteor of skyMeteors) {
expect(getSkyMeteorHead(meteor, meteor.start - 0.01, width, height)).toBeNull();
const a = getSkyMeteorHead(meteor, meteor.start + 0.5, width, height);
const b = getSkyMeteorHead(meteor, meteor.start + 1.5, width, height);
expect(a && b).toBeTruthy();
if (a && b) {
expect(b.x).toBeGreaterThan(a.x);
expect(b.y).toBeGreaterThan(a.y);
expect((Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI).toBeCloseTo(meteorAngleDegrees);
}
}
});
test('camera never moves backward', () => {
let previous = -Infinity;
for (let seconds = 0; seconds <= gachaBetaTimeline.flightEnd; seconds += 0.05) {
const {z} = getCamera(seconds);
expect(z).toBeGreaterThanOrEqual(previous - 1e-6);
previous = z;
test('the volley stays in the upper sky so the result area is clear at the reveal', () => {
for (const meteor of skyMeteors) {
const head = getSkyMeteorHead(meteor, gachaBetaTimeline.labelIn, width, height);
if (head) {
expect(head.y).toBeLessThan(height * 0.62);
}
}
});
test('projection drops points behind the camera', () => {
expect(project({x: 0, y: 0, z: 10}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360)).toBeNull();
const centre = project({x: 0, y: 0, z: 500}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360);
expect(centre).toMatchObject({x: 320, y: 180});
test('hero meteor is hidden before its cue, then slows to burn out on the anchor', () => {
const tl = gachaBetaTimeline;
expect(getHeroMeteor(tl.heroStart - 0.1, width, height).visible).toBe(0);
const early = getHeroMeteor(tl.heroStart + 0.5, width, height);
const mid = getHeroMeteor(tl.heroStart + 1, width, height);
const late = getHeroMeteor(tl.heroBurn - 0.5, width, height);
const late2 = getHeroMeteor(tl.heroBurn, width, height);
expect(Math.hypot(mid.x - early.x, mid.y - early.y)).toBeGreaterThan(Math.hypot(late2.x - late.x, late2.y - late.y));
expect(late2.x).toBeCloseTo(revealAnchor.x * width);
expect(late2.y).toBeCloseTo(revealAnchor.y * height);
expect(getHeroMeteor(tl.heroBurn + 0.2, width, height).visible).toBe(0);
});
test('comet glow builds to full by the burst', () => {
expect(getCometGlow(0)).toBe(0);
expect(getCometGlow(3)).toBeGreaterThan(getCometGlow(2));
expect(getCometGlow(gachaBetaTimeline.flightEnd)).toBeCloseTo(1);
});
test('shake fires at the pierce and the burst only', () => {
expect(getBetaShake(1, 360)).toEqual({x: 0, y: 0});
expect(Math.hypot(...Object.values(getBetaShake(pierceTime + 0.05, 360)))).toBeGreaterThan(0);
expect(Math.hypot(...Object.values(getBetaShake(gachaBetaTimeline.flightEnd + 0.05, 360)))).toBeGreaterThan(0);
test('burn glint fires only around the burn-out', () => {
expect(getBurnGlint(gachaBetaTimeline.heroBurn - 1)).toBe(0);
expect(getBurnGlint(gachaBetaTimeline.heroBurn)).toBe(1);
expect(getBurnGlint(gachaBetaTimeline.heroBurn + 2)).toBe(0);
});
test('beta food reveal displays SSS', () => {