feat(gacha): restyle the beta wish as a six-star wish sequence

The beta wish now cuts like an anime gacha game's six-star wish over 10.5
seconds: a dive through a painterly cloud vortex, a beam pass, a meteor
that stops inside a rainbow halo and bursts, a red flash, falling crystal
comets joined by a red meteor, a black emblem silhouette shedding shards
on a red disc, and a splash card with a name plate and six stars.

Cloud layers are painted once per roll onto a canvas with a seeded noise
displacement, so frames only move the baked image instead of running a
live SVG filter. The /api/gachabeta contract is unchanged.
This commit is contained in:
tiennm99 committed 2026-10-01 16:32:30 +07:00
1 parent b8d5544fd6
commit 081ab00f2e
6 files changed
+1380 -405

No files matched your search

+15 -7
View File
@@ -67,15 +67,23 @@ only draws it.
### Gacha wish beta ### Gacha wish beta
`POST /api/gachabeta` takes the same body and returns the same response as `POST /api/gachabeta` takes the same body and returns the same response as
`/api/gacha`, rendering an 8-second beta style instead, modelled on the meteor `/api/gacha`, rendering a 10.5-second beta style instead, cut like an anime
shot of a music video: white, teal, green, red, and blue meteors glide slowly gacha game's six-star wish:
down a starry night sky over a snow-rimmed mountain ridge. A hero meteor in the
rarity colour (blue 3★, purple 4★, gold 5★) joins the volley, slows, and burns 1. The camera dives through a ring of painterly clouds into a swirling eye,
out in a glint at the centre, where the food label appears under rank `SSS`. then a beam rushes past.
2. A meteor slows to a stop over a sea of clouds inside a rainbow halo,
sheds sparkles, and bursts into a starburst under a red flash.
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.
The caller randomly chooses the food and sends it as `label` (for example, The caller randomly chooses the food and sends it as `label` (for example,
`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` only chooses the hero `"Bún bò"`). The beta reveal always shows `SSS`; `rarity` tints the meteor
meteor's colour. `/api/gacha` keeps its existing ranks. head and the splash card's emblem (blue 3★, purple 4★, gold 5★). `/api/gacha`
keeps its existing ranks.
Response is a silent H.264 `video/mp4` (Telegram plays it as an animation) 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 with `X-Gacha-Rarity` and `X-Render-Duration-Ms` headers. Both routes share the
File diff suppressed because it is too large. Load diff
+144
View File
@@ -0,0 +1,144 @@
/**
* Bakes the beta wish's cloud layers once per roll: puffs are painted with
* radial gradients on a canvas, then torn into painterly edges by a seeded
* noise displacement. Each frame then only moves the baked image, which keeps
* the clouds' look without paying for a live SVG filter on every frame.
*/
import {createRandom} from './gacha-timeline.js';
/**
* @typedef {object} Puff
* @property {number} dx Offset in cluster units.
* @property {number} dy
* @property {number} r Radius in cluster units.
*/
/**
* @typedef {object} CloudCluster
* @property {number} x Centre in canvas pixels.
* @property {number} y
* @property {number} size Cluster size in pixels.
* @property {Puff[]} puffs
*/
/**
* Smooth value noise in [0, 1] over a seeded lattice, summed over octaves.
*
* @param {number} seed
* @param {number} frequency Lattice cells per pixel at the first octave.
* @param {number} octaves
* @returns {(x: number, y: number) => number}
*/
export const createValueNoise = (seed, frequency, octaves) => {
const random = createRandom(seed);
const size = 256;
const lattice = Float32Array.from({length: size * size}, () => random());
/** @param {number} t */
const fade = (t) => t * t * (3 - 2 * t);
/**
* @param {number} x
* @param {number} y
*/
const sample = (x, y) => {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const tx = fade(x - x0);
const ty = fade(y - y0);
const at = (/** @type {number} */ ix, /** @type {number} */ iy) =>
lattice[(((iy % size) + size) % size) * size + (((ix % size) + size) % size)] ?? 0;
const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * tx;
const bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * tx;
return top + (bottom - top) * ty;
};
return (x, y) => {
let total = 0;
let weight = 0;
let amplitude = 1;
let scale = frequency;
for (let octave = 0; octave < octaves; octave += 1) {
total += sample(x * scale, y * scale) * amplitude;
weight += amplitude;
amplitude /= 2;
scale *= 2;
}
return total / weight;
};
};
/**
* Draws one cluster: a blue-shadowed base, then sunlit puffs on top.
*
* @param {CanvasRenderingContext2D} context
* @param {CloudCluster} cluster
*/
const paintCluster = (context, {x, y, size, puffs}) => {
const base = context.createRadialGradient(x, y + size * 0.1, 0, x, y + size * 0.3, size * 1.05);
base.addColorStop(0, 'rgba(170,198,240,0.95)');
base.addColorStop(0.55, 'rgba(120,156,220,0.6)');
base.addColorStop(1, 'rgba(120,156,220,0)');
context.fillStyle = base;
context.beginPath();
context.ellipse(x, y + size * 0.3, size * 1.05, size * 0.35, 0, 0, Math.PI * 2);
context.fill();
for (const puff of puffs) {
const cx = x + puff.dx * size;
const cy = y + puff.dy * size;
const r = puff.r * size;
const shade = context.createRadialGradient(cx - r * 0.08, cy - r * 0.32, 0, cx - r * 0.08, cy - r * 0.32, r * 1.3);
shade.addColorStop(0, '#ffffff');
shade.addColorStop(0.3, '#f5f9ff');
shade.addColorStop(0.52, '#d6e5fa');
shade.addColorStop(0.68, 'rgba(150,182,232,0.9)');
shade.addColorStop(0.8, 'rgba(150,182,232,0)');
context.fillStyle = shade;
context.beginPath();
context.arc(cx, cy, r, 0, Math.PI * 2);
context.fill();
}
};
/**
* Paints clusters onto a canvas of the given size, displaces the result with
* two noise fields for torn, painterly edges, and returns it as a PNG data
* URL. Returns an empty string outside a browser.
*
* @param {{width: number, height: number, clusters: CloudCluster[], seed: number, frequency: number, strength: number}} options
* `frequency` is noise cells per pixel; `strength` is the displacement in pixels.
* @returns {string}
*/
export const bakeCloudLayer = ({width, height, clusters, seed, frequency, strength}) => {
if (typeof document === 'undefined') {
return '';
}
const w = Math.ceil(width);
const h = Math.ceil(height);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const context = canvas.getContext('2d');
if (!context) {
return '';
}
for (const cluster of clusters) {
paintCluster(context, cluster);
}
const source = context.getImageData(0, 0, w, h);
const output = context.createImageData(w, h);
const noiseX = createValueNoise(seed, frequency, 2);
const noiseY = createValueNoise(seed + 1, frequency, 2);
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const sx = Math.min(w - 1, Math.max(0, Math.round(x + (noiseX(x, y) - 0.5) * 2 * strength)));
const sy = Math.min(h - 1, Math.max(0, Math.round(y + (noiseY(x, y) - 0.5) * 2 * strength)));
const from = (sy * w + sx) * 4;
const to = (y * w + x) * 4;
output.data[to] = source.data[from] ?? 0;
output.data[to + 1] = source.data[from + 1] ?? 0;
output.data[to + 2] = source.data[from + 2] ?? 0;
output.data[to + 3] = source.data[from + 3] ?? 0;
}
}
context.putImageData(output, 0, 0);
return canvas.toDataURL('image/png');
};
+238 -84
View File
@@ -1,29 +1,35 @@
/** /**
* Choreography for the beta wish: coloured meteors glide down a night sky over * Shot timing and motion for the beta wish, cut like the six-star wish
* a mountain ridge, a hero meteor in the rarity colour slows and burns out at * sequence of an anime gacha game: a dive through a cloud vortex, a beam
* the centre, and the result appears where it died. Kept free of React so the * pass, a meteor that slows over a sea of clouds inside a rainbow halo and
* motion can be unit tested. * 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
* choreography can be unit tested.
*/ */
export const gachaBetaTotalSeconds = 8; export const gachaBetaTotalSeconds = 10.5;
/** Seconds from the start of the clip for each beat. */ /** Seconds from the start of the clip for each cut and beat. */
export const gachaBetaTimeline = Object.freeze({ export const gachaBetaTimeline = Object.freeze({
fadeInEnd: 0.4, vortexEnd: 1.1,
heroStart: 1.1, beamEnd: 1.45,
heroBurn: 4.4, meteorStop: 2.9,
labelIn: 4.5, haloEnd: 3.7,
rankIn: 4.9, burstEnd: 4.5,
redFlashEnd: 4.8,
volleyEnd: 6.45,
redStarIn: 5.4,
silhouetteEnd: 7.7,
plateIn: 7.8,
starsStart: 8.2,
starStep: 0.14,
}); });
/** Where the hero meteor burns out and the result is centred, as frame fractions. */ /** The beta always reveals six stars and rank SSS. */
export const revealAnchor = Object.freeze({x: 0.5, y: 0.44}); export const betaStarCount = 6;
/** Every meteor travels down and to the right at this angle, as in the source shot. */ /** @typedef {'vortex' | 'beam' | 'sky' | 'volley' | 'silhouette' | 'reveal'} BetaShot */
export const meteorAngleDegrees = 32;
const angle = (meteorAngleDegrees * Math.PI) / 180;
const direction = Object.freeze({x: Math.cos(angle), y: Math.sin(angle)});
/** /**
* @param {number} value * @param {number} value
@@ -41,90 +47,238 @@ const smoothstep = (t) => {
}; };
/** /**
* @typedef {object} SkyMeteor
* @property {number} start Seconds at which the head is at its entry point.
* @property {number} x Entry point, fraction of width.
* @property {number} y Entry point, fraction of height.
* @property {number} speed Frame heights per second along the travel direction.
* @property {number} length Tail length in frame heights.
* @property {number} width Tail thickness in frame heights.
* @property {string} color Halo and tail colour; every head burns white.
*/
/**
* Background meteors in the source shot's colours. They enter from the upper
* left in a loose staggered volley and keep gliding slowly for the whole clip.
*/
export const skyMeteors = /** @type {readonly SkyMeteor[]} */ (
Object.freeze([
{start: 0, x: 0.36, y: -0.08, speed: 0.1, length: 0.5, width: 0.011, color: '#eef4ff'},
{start: 0, x: 0.1, y: -0.02, speed: 0.11, length: 0.34, width: 0.014, color: '#52e07a'},
{start: 0.2, x: -0.04, y: 0.12, speed: 0.12, length: 0.42, width: 0.018, color: '#9ff5e4'},
{start: 0.9, x: -0.06, y: -0.05, speed: 0.12, length: 0.46, width: 0.016, color: '#f4f8ff'},
{start: 1.3, x: 0.17, y: -0.09, speed: 0.1, length: 0.32, width: 0.012, color: '#ff8a7a'},
{start: 1.6, x: -0.1, y: 0.06, speed: 0.13, length: 0.5, width: 0.015, color: '#6f9cff'},
{start: 2.6, x: 0.55, y: -0.1, speed: 0.11, length: 0.4, width: 0.01, color: '#ffe6a8'},
{start: 3.2, x: -0.08, y: -0.08, speed: 0.12, length: 0.38, width: 0.012, color: '#5fe8d0'},
])
);
/**
* Head position of a background meteor in pixels, or null before it enters.
*
* @param {SkyMeteor} meteor
* @param {number} seconds * @param {number} seconds
* @param {number} width * @param {number} start
* @param {number} height * @param {number} end
* @returns {{x: number, y: number} | null} * @returns {number}
*/ */
export const getSkyMeteorHead = (meteor, seconds, width, height) => { export const progress = (seconds, start, end) => clamp01((seconds - start) / (end - start));
if (seconds < meteor.start) {
return null; /**
* The shot on screen. Every change is a hard cut; flashes cover the
* louder ones.
*
* @param {number} seconds
* @returns {BetaShot}
*/
export const getShot = (seconds) => {
const tl = gachaBetaTimeline;
if (seconds < tl.vortexEnd) {
return 'vortex';
} }
const travel = (seconds - meteor.start) * meteor.speed * height; if (seconds < tl.beamEnd) {
return {x: meteor.x * width + direction.x * travel, y: meteor.y * height + direction.y * travel}; return 'beam';
}
if (seconds < tl.redFlashEnd) {
return 'sky';
}
if (seconds < tl.volleyEnd) {
return 'volley';
}
if (seconds < tl.silhouetteEnd) {
return 'silhouette';
}
return 'reveal';
}; };
/** How far up the diagonal the hero meteor enters, in frame heights from the anchor. */
const heroReach = 0.72;
/** /**
* The hero meteor streaks down the same diagonal as the volley, slowing until * Full-frame flash colour and opacity: white out of the opening, red then
* it burns out on the reveal anchor. * white after the burst, white on the red meteor's impact, and a pale pink
* wash that hands the silhouette over to the splash card.
* *
* @param {number} seconds * @param {number} seconds
* @param {number} width * @returns {{color: string, opacity: number}}
* @param {number} height
* @returns {{x: number, y: number, visible: number, tail: number}}
* `visible` fades the meteor out as it burns; `tail` shrinks its trail as
* it slows.
*/ */
export const getHeroMeteor = (seconds, width, height) => { export const getFlash = (seconds) => {
const tl = gachaBetaTimeline; const tl = gachaBetaTimeline;
const t = clamp01((seconds - tl.heroStart) / (tl.heroBurn - tl.heroStart)); /** @type {Array<[number, number, number, string, number?]>} start, peak, end, colour, peak opacity */
const remaining = heroReach * height * (1 - t) ** 2.4; const flashes = [
[-1, 0, 0.45, '#ffffff'],
[tl.burstEnd - 0.2, tl.burstEnd, tl.burstEnd + 0.12, '#ff5a6e', 0.8],
[tl.burstEnd + 0.1, tl.redFlashEnd, tl.redFlashEnd + 0.3, '#ffffff'],
[tl.volleyEnd - 0.12, tl.volleyEnd, tl.volleyEnd + 0.3, '#ffffff'],
[tl.silhouetteEnd - 0.3, tl.silhouetteEnd, tl.silhouetteEnd + 0.45, '#ffe6f0'],
];
for (const [start, peak, end, color, strength = 1] of flashes) {
if (seconds >= start && seconds <= end) {
const shape =
seconds <= peak ? smoothstep(progress(seconds, start, peak)) : 1 - smoothstep(progress(seconds, peak, end));
return {color, opacity: shape * strength};
}
}
return {color: '#ffffff', opacity: 0};
};
/**
* Camera dive into the cloud vortex: the ring of clouds swells past the
* frame while the dark eye opens up.
*
* @param {number} seconds
* @returns {{zoom: number, spin: number}}
*/
export const getVortexState = (seconds) => {
const t = progress(seconds, 0, gachaBetaTimeline.vortexEnd);
return {zoom: 1 + 1.4 * t ** 2.2, spin: 40 * t};
};
/**
* Meteor head over the sea of clouds, as frame fractions. It enters from the
* upper left and decelerates to a stop just right of centre, where the halo
* and burst happen.
*
* @param {number} seconds
* @returns {{x: number, y: number, speed: number}}
* `speed` is 1 on entry and 0 once the meteor has stopped.
*/
export const getMeteorHead = (seconds) => {
const tl = gachaBetaTimeline;
const t = progress(seconds, tl.beamEnd, tl.meteorStop);
const glide = 1 - (1 - t) ** 3;
return { return {
x: revealAnchor.x * width - direction.x * remaining, x: 0.02 + 0.54 * glide,
y: revealAnchor.y * height - direction.y * remaining, y: 0.12 + 0.44 * glide,
visible: seconds < tl.heroStart ? 0 : 1 - smoothstep((seconds - (tl.heroBurn - 0.15)) / 0.3), speed: (1 - t) ** 2,
tail: 1 - 0.75 * t,
}; };
}; };
/** /**
* Brightness of the burn-out glint where the hero meteor dies, peaking at the * Rainbow halo around the stopped meteor: it blooms large, then contracts
* burn and fading as the result settles. * while the head warms from white to pink.
* *
* @param {number} seconds * @param {number} seconds
* @returns {number} * @returns {{radius: number, opacity: number, warmth: number}}
* `radius` is in frame heights.
*/ */
export const getBurnGlint = (seconds) => { export const getHaloState = (seconds) => {
const elapsed = seconds - gachaBetaTimeline.heroBurn; const tl = gachaBetaTimeline;
if (elapsed < -0.2 || elapsed > 1) { const grow = smoothstep(progress(seconds, tl.meteorStop - 0.3, tl.meteorStop + 0.25));
return 0; const shrink = smoothstep(progress(seconds, tl.meteorStop + 0.4, tl.haloEnd));
} return {
return elapsed < 0 ? 1 + elapsed / 0.2 : (1 - elapsed) ** 2; radius: 0.06 + 0.3 * grow - 0.24 * shrink,
opacity: grow * (1 - shrink),
warmth: smoothstep(progress(seconds, tl.meteorStop, tl.haloEnd)),
};
}; };
/** The beta food wish always reveals SSS; rarity still controls the hero meteor's colour. */ /**
* The burst that follows the halo: a swelling starburst and anamorphic flare
* while the sky tints violet.
*
* @param {number} seconds
* @returns {{glow: number, flare: number, tint: number}}
*/
export const getBurstState = (seconds) => {
const tl = gachaBetaTimeline;
const t = progress(seconds, tl.haloEnd - 0.2, tl.burstEnd);
return {glow: t ** 1.6, flare: smoothstep(t * 1.4), tint: smoothstep(t)};
};
/**
* @typedef {object} VolleyComet
* @property {number} x Head x as a frame fraction.
* @property {number} start Seconds at which the head enters from the top.
* @property {number} fall Frame heights travelled until the volley ends.
* @property {number} size Head size in frame heights.
*/
/** Crystal comets falling nearly straight down after the red flash. */
export const volleyComets = /** @type {readonly VolleyComet[]} */ (
Object.freeze([
{x: 0.04, start: 4.7, fall: 0.95, size: 0.1},
{x: 0.24, start: 4.78, fall: 1.05, size: 0.12},
{x: 0.43, start: 4.74, fall: 0.85, size: 0.09},
{x: 0.62, start: 4.82, fall: 1.0, size: 0.11},
])
);
/** The volley drifts right as it falls, as in the source shot. */
const volleyDrift = 0.12;
/**
* Head of a volley comet in frame fractions. It falls fast, then eases.
*
* @param {VolleyComet} comet
* @param {number} seconds
* @returns {{x: number, y: number}}
*/
export const getVolleyCometHead = (comet, seconds) => {
const t = progress(seconds, comet.start, gachaBetaTimeline.volleyEnd);
const fall = comet.fall * (1 - (1 - t) ** 2);
return {x: comet.x + volleyDrift * fall, y: -0.1 + fall};
};
/**
* The red six-star meteor: a red star flares in the upper right, then drops
* and strikes just below centre as the volley ends.
*
* @param {number} seconds
* @returns {{x: number, y: number, flare: number, falling: number}}
* `flare` scales the star glint, `falling` runs from 0 to 1 over the drop.
*/
export const getRedMeteor = (seconds) => {
const tl = gachaBetaTimeline;
const appear = smoothstep(progress(seconds, tl.redStarIn, tl.redStarIn + 0.3));
const falling = progress(seconds, tl.redStarIn + 0.35, tl.volleyEnd) ** 1.8;
return {
x: 0.68 - 0.2 * falling,
y: 0.08 + 0.55 * falling,
flare: appear * (1 + 1.5 * falling),
falling,
};
};
/**
* @typedef {object} Shard
* @property {number} angle Flight direction in degrees.
* @property {number} reach Distance travelled in frame heights.
* @property {number} size Edge length in frame heights.
* @property {number} spin Rotation in degrees over the shot.
* @property {number} start Delay in seconds after the silhouette appears.
*/
/**
* Seeded shards that burst off the silhouette and drift outward.
*
* @param {() => number} random
* @param {number} count
* @returns {Shard[]}
*/
export const createShards = (random, count) =>
Array.from({length: count}, () => ({
angle: random() * 360,
reach: 0.25 + random() * 0.55,
size: 0.015 + random() * 0.05,
spin: (random() - 0.5) * 540,
start: random() * 0.35,
}));
/**
* Offset of a shard from the silhouette centre, in frame heights.
*
* @param {Shard} shard
* @param {number} seconds
* @returns {{dx: number, dy: number, rotate: number, opacity: number}}
*/
export const getShardOffset = (shard, seconds) => {
const tl = gachaBetaTimeline;
const t = progress(seconds, tl.volleyEnd + 0.15 + shard.start, tl.silhouetteEnd);
const travel = shard.reach * (1 - (1 - t) ** 3);
const radians = (shard.angle * Math.PI) / 180;
return {
dx: Math.cos(radians) * travel,
dy: Math.sin(radians) * travel,
rotate: shard.spin * t,
opacity: t > 0 ? 1 : 0,
};
};
/**
* Seconds at which each reveal star pops in, left to right.
*
* @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 getBetaRank = () => 'SSS';
+1 -1
View File
@@ -24,5 +24,5 @@ const createGachaRenderer = (compositionId) => (inputProps, options) =>
export const renderGachaVideo = createGachaRenderer('GachaWish'); export const renderGachaVideo = createGachaRenderer('GachaWish');
/** The beta wish: a meteor shower over a mountain ridge, ending in an SSS reveal. */ /** The beta wish: a six-star style wish sequence ending in an SSS splash card. */
export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish'); export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish');
+87 -50
View File
@@ -1,69 +1,106 @@
import {describe, expect, test} from 'vitest'; import {describe, expect, test} from 'vitest';
import { import {
betaStarCount,
createShards,
gachaBetaTimeline, gachaBetaTimeline,
gachaBetaTotalSeconds, gachaBetaTotalSeconds,
getBetaRank, getBetaRank,
getBurnGlint, getFlash,
getHeroMeteor, getHaloState,
getSkyMeteorHead, getMeteorHead,
meteorAngleDegrees, getRedMeteor,
revealAnchor, getShardOffset,
skyMeteors, getShot,
getStarRevealTimes,
getVolleyCometHead,
getVortexState,
volleyComets,
} from '../src/remotion/gacha-beta-scene.js'; } from '../src/remotion/gacha-beta-scene.js';
import {createRandom} from '../src/remotion/gacha-timeline.js';
const width = 640; describe('gacha beta wish scene', () => {
const height = 360; test('shots cut in order: vortex, beam, sky, volley, silhouette, reveal', () => {
describe('gacha beta meteor scene', () => {
test('beats run in order and the result holds before the clip ends', () => {
const tl = gachaBetaTimeline; const tl = gachaBetaTimeline;
expect(tl.fadeInEnd).toBeLessThan(tl.heroStart); /** @type {string[]} */
expect(tl.heroStart).toBeLessThan(tl.heroBurn); const order = [];
expect(tl.heroBurn).toBeLessThan(tl.labelIn); for (let seconds = 0; seconds < gachaBetaTotalSeconds; seconds += 0.05) {
expect(tl.labelIn).toBeLessThan(tl.rankIn); const shot = getShot(seconds);
expect(tl.rankIn + 2).toBeLessThan(gachaBetaTotalSeconds); if (order.at(-1) !== shot) {
order.push(shot);
}
}
expect(order).toEqual(['vortex', 'beam', 'sky', 'volley', 'silhouette', 'reveal']);
expect(tl.meteorStop).toBeLessThan(tl.haloEnd);
expect(tl.haloEnd).toBeLessThan(tl.burstEnd);
expect(tl.redStarIn).toBeLessThan(tl.volleyEnd);
}); });
test('background meteors appear on their cue and glide down to the right', () => { test('the six stars finish popping with time to hold the card', () => {
for (const meteor of skyMeteors) { const times = getStarRevealTimes();
expect(getSkyMeteorHead(meteor, meteor.start - 0.01, width, height)).toBeNull(); expect(times).toHaveLength(betaStarCount);
const a = getSkyMeteorHead(meteor, meteor.start + 0.5, width, height); expect(betaStarCount).toBe(6);
const b = getSkyMeteorHead(meteor, meteor.start + 1.5, width, height); expect(times[0]).toBeGreaterThan(gachaBetaTimeline.plateIn);
expect(a && b).toBeTruthy(); expect((times.at(-1) ?? Infinity) + 1).toBeLessThan(gachaBetaTotalSeconds);
if (a && b) { });
expect(b.x).toBeGreaterThan(a.x);
expect(b.y).toBeGreaterThan(a.y); test('flashes open the clip, cover the burst in red, and hand over at each loud cut', () => {
expect((Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI).toBeCloseTo(meteorAngleDegrees); const tl = gachaBetaTimeline;
} expect(getFlash(0).opacity).toBe(1);
expect(getFlash(tl.burstEnd)).toMatchObject({color: '#ff5a6e'});
expect(getFlash(tl.redFlashEnd).opacity).toBe(1);
expect(getFlash(tl.volleyEnd).opacity).toBe(1);
expect(getFlash(tl.silhouetteEnd).opacity).toBe(1);
expect(getFlash(2).opacity).toBe(0);
expect(getFlash(9.5).opacity).toBe(0);
});
test('the camera dives into the vortex', () => {
expect(getVortexState(gachaBetaTimeline.vortexEnd).zoom).toBeGreaterThan(getVortexState(0).zoom);
});
test('the meteor decelerates to a stop and holds through the halo', () => {
const tl = gachaBetaTimeline;
const a = getMeteorHead(tl.beamEnd);
const b = getMeteorHead(tl.beamEnd + 0.3);
const c = getMeteorHead(tl.meteorStop - 0.3);
const stop = getMeteorHead(tl.meteorStop);
expect(b.x - a.x).toBeGreaterThan(stop.x - c.x);
expect(stop.speed).toBe(0);
expect(getMeteorHead(tl.haloEnd)).toEqual(stop);
});
test('the rainbow halo blooms after the stop and closes before the burst', () => {
const tl = gachaBetaTimeline;
expect(getHaloState(tl.beamEnd).opacity).toBe(0);
expect(getHaloState(tl.meteorStop + 0.3).opacity).toBeGreaterThan(0.8);
expect(getHaloState(tl.haloEnd).opacity).toBe(0);
});
test('volley comets fall and drift right', () => {
for (const comet of volleyComets) {
const early = getVolleyCometHead(comet, comet.start + 0.2);
const late = getVolleyCometHead(comet, gachaBetaTimeline.volleyEnd);
expect(late.y).toBeGreaterThan(early.y);
expect(late.x).toBeGreaterThan(early.x);
} }
}); });
test('the volley stays in the upper sky so the result area is clear at the reveal', () => { test('the red meteor flares, then drops toward the centre by the impact', () => {
for (const meteor of skyMeteors) {
const head = getSkyMeteorHead(meteor, gachaBetaTimeline.labelIn, width, height);
if (head) {
expect(head.y).toBeLessThan(height * 0.62);
}
}
});
test('hero meteor is hidden before its cue, then slows to burn out on the anchor', () => {
const tl = gachaBetaTimeline; const tl = gachaBetaTimeline;
expect(getHeroMeteor(tl.heroStart - 0.1, width, height).visible).toBe(0); expect(getRedMeteor(tl.redStarIn).flare).toBe(0);
const early = getHeroMeteor(tl.heroStart + 0.5, width, height); const impact = getRedMeteor(tl.volleyEnd);
const mid = getHeroMeteor(tl.heroStart + 1, width, height); expect(impact.falling).toBe(1);
const late = getHeroMeteor(tl.heroBurn - 0.5, width, height); expect(impact.y).toBeGreaterThan(getRedMeteor(tl.redStarIn + 0.3).y);
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('burn glint fires only around the burn-out', () => { test('silhouette shards burst outward from the centre', () => {
expect(getBurnGlint(gachaBetaTimeline.heroBurn - 1)).toBe(0); const shards = createShards(createRandom(3), 12);
expect(getBurnGlint(gachaBetaTimeline.heroBurn)).toBe(1); for (const shard of shards) {
expect(getBurnGlint(gachaBetaTimeline.heroBurn + 2)).toBe(0); const mid = getShardOffset(shard, gachaBetaTimeline.volleyEnd + 0.7);
const end = getShardOffset(shard, gachaBetaTimeline.silhouetteEnd);
expect(Math.hypot(end.dx, end.dy)).toBeGreaterThanOrEqual(Math.hypot(mid.dx, mid.dy));
expect(Math.hypot(end.dx, end.dy)).toBeCloseTo(shard.reach);
}
}); });
test('beta food reveal displays SSS', () => { test('beta food reveal displays SSS', () => {