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

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
* a mountain ridge, a hero meteor in the rarity colour slows and burns out at
* the centre, and the result appears where it died. Kept free of React so the
* motion can be unit tested.
* Shot timing and motion for the beta wish, cut like the six-star wish
* sequence of an anime gacha game: a dive through a cloud vortex, a beam
* 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
* 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({
fadeInEnd: 0.4,
heroStart: 1.1,
heroBurn: 4.4,
labelIn: 4.5,
rankIn: 4.9,
vortexEnd: 1.1,
beamEnd: 1.45,
meteorStop: 2.9,
haloEnd: 3.7,
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. */
export const revealAnchor = Object.freeze({x: 0.5, y: 0.44});
/** The beta always reveals six stars and rank SSS. */
export const betaStarCount = 6;
/** Every meteor travels down and to the right at this angle, as in the source shot. */
export const meteorAngleDegrees = 32;
const angle = (meteorAngleDegrees * Math.PI) / 180;
const direction = Object.freeze({x: Math.cos(angle), y: Math.sin(angle)});
/** @typedef {'vortex' | 'beam' | 'sky' | 'volley' | 'silhouette' | 'reveal'} BetaShot */
/**
* @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} width
* @param {number} height
* @returns {{x: number, y: number} | null}
* @param {number} start
* @param {number} end
* @returns {number}
*/
export const getSkyMeteorHead = (meteor, seconds, width, height) => {
if (seconds < meteor.start) {
return null;
export const progress = (seconds, start, end) => clamp01((seconds - start) / (end - start));
/**
* 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;
return {x: meteor.x * width + direction.x * travel, y: meteor.y * height + direction.y * travel};
if (seconds < tl.beamEnd) {
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
* it burns out on the reveal anchor.
* Full-frame flash colour and opacity: white out of the opening, red then
* 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} width
* @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.
* @returns {{color: string, opacity: number}}
*/
export const getHeroMeteor = (seconds, width, height) => {
export const getFlash = (seconds) => {
const tl = gachaBetaTimeline;
const t = clamp01((seconds - tl.heroStart) / (tl.heroBurn - tl.heroStart));
const remaining = heroReach * height * (1 - t) ** 2.4;
/** @type {Array<[number, number, number, string, number?]>} start, peak, end, colour, peak opacity */
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 {
x: revealAnchor.x * width - direction.x * remaining,
y: revealAnchor.y * height - direction.y * remaining,
visible: seconds < tl.heroStart ? 0 : 1 - smoothstep((seconds - (tl.heroBurn - 0.15)) / 0.3),
tail: 1 - 0.75 * t,
x: 0.02 + 0.54 * glide,
y: 0.12 + 0.44 * glide,
speed: (1 - t) ** 2,
};
};
/**
* Brightness of the burn-out glint where the hero meteor dies, peaking at the
* burn and fading as the result settles.
* Rainbow halo around the stopped meteor: it blooms large, then contracts
* while the head warms from white to pink.
*
* @param {number} seconds
* @returns {number}
* @returns {{radius: number, opacity: number, warmth: number}}
* `radius` is in frame heights.
*/
export const getBurnGlint = (seconds) => {
const elapsed = seconds - gachaBetaTimeline.heroBurn;
if (elapsed < -0.2 || elapsed > 1) {
return 0;
}
return elapsed < 0 ? 1 + elapsed / 0.2 : (1 - elapsed) ** 2;
export const getHaloState = (seconds) => {
const tl = gachaBetaTimeline;
const grow = smoothstep(progress(seconds, tl.meteorStop - 0.3, tl.meteorStop + 0.25));
const shrink = smoothstep(progress(seconds, tl.meteorStop + 0.4, tl.haloEnd));
return {
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';
+1 -1
View File
@@ -24,5 +24,5 @@ const createGachaRenderer = (compositionId) => (inputProps, options) =>
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');