mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
b8d5544fd6
commit
081ab00f2e
6 files changed
+1380
-405
No files matched your search
+15
-7
@@ -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
@@ -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');
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -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');
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user