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

+231 -510
View File
@@ -1,83 +1,23 @@
import {useMemo} from 'react';
import {Star} from '@remotion/shapes';
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
import {
gachaBetaTimeline,
getBetaRank,
getBetaShake,
getCamera,
getCometGlow,
getCometPosition,
heroCloud,
pierceTime,
project,
getBurnGlint,
getHeroMeteor,
getSkyMeteorHead,
meteorAngleDegrees,
revealAnchor,
skyMeteors,
} from './gacha-beta-scene.js';
import {createRandom, createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} from './gacha-timeline.js';
import {createStarfield, getLabelFontSize, getRarityPalette, getTwinkle} from './gacha-timeline.js';
const baseFont =
const sansFont =
'Quicksand, Inter, "Noto Sans", "Noto Sans Vietnamese", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const serifFont = '"Noto Serif", "Cormorant Garamond", Georgia, "Times New Roman", serif';
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
/** 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%)';
const trailSamples = 44;
const trailStep = 0.01;
/**
* Puffs that make one toon cloud, in units of the cloud radius.
*
* @param {() => number} random
* @param {number} count
*/
const createPuffs = (random, count) =>
Array.from({length: count}, (_, index) => {
const angle = (index / count) * Math.PI * 2 + random() * 0.6;
const reach = index === 0 ? 0 : 0.35 + random() * 0.45;
return {
dx: Math.cos(angle) * reach,
dy: Math.sin(angle) * reach * 0.45 + (random() - 0.3) * 0.15,
r: 0.38 + random() * 0.3,
};
});
/**
* Every seeded layout for one roll: stars, constellation links, and the
* clouds scattered through the sky.
*
* @param {number} seed
*/
const createBetaScene = (seed) => {
const random = createRandom(seed + 101);
const stars = createStarfield(80, seed);
const links = Array.from({length: 7}, () => {
const a = Math.floor(random() * 24);
return [a, a + 1 + Math.floor(random() * 3)];
});
const clouds = Array.from({length: 14}, () => ({
x: (random() - 0.5) * 3200,
y: 80 + random() * 420,
z: 1100 + random() * 5200,
radius: 160 + random() * 220,
puffs: createPuffs(random, 6),
})).sort((a, b) => b.z - a.z);
const heroPuffs = createPuffs(random, 13);
const confetti = Array.from({length: 34}, () => ({
x: random(),
offset: random(),
speed: 0.18 + random() * 0.3,
size: 0.02 + random() * 0.025,
spin: (random() - 0.5) * 400,
hue: Math.floor(random() * 4),
}));
return {stars, links, clouds, heroPuffs, confetti};
};
const confettiColors = ['#ffd36b', '#ff8cd8', '#8cff9a', '#8fd0ff'];
const easeOut = /** @type {const} */ ({...clamp, easing: Easing.out(Easing.cubic)});
/**
* @param {string} hex
@@ -88,43 +28,116 @@ const withAlpha = (hex, alpha) => {
return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`;
};
/** Narrows from full thickness at the head to a point at the end of the tail. */
const taper = 'polygon(0 0, 100% 46%, 100% 54%, 0 100%)';
/**
* @param {{size: number, fill: string, shade: string}} props
* One meteor: a white-hot head inside a coloured halo, a bright tapered core
* trail, and a wider, softer wisp around it. Drawn at the head and rotated so
* the tail points back up the travel diagonal.
*
* @param {{x: number, y: number, length: number, thickness: number, color: string, core: string, opacity: number}} props
*/
const ToonStar = ({size, fill, shade}) => (
<div style={{height: size, position: 'relative', width: size}}>
<Star
cornerRadius={size * 0.035}
fill={shade}
innerRadius={size * 0.23}
outerRadius={size / 2}
points={5}
style={{height: size, position: 'absolute', width: size}}
const Meteor = ({x, y, length, thickness, color, core, opacity}) => (
<div
style={{
left: x,
opacity,
position: 'absolute',
top: y,
transform: `rotate(${meteorAngleDegrees + 180}deg)`,
transformOrigin: '0 0',
}}
>
<div
style={{
background: `radial-gradient(circle, ${withAlpha(color, 0.35)} 0%, ${withAlpha(color, 0.12)} 40%, transparent 70%)`,
borderRadius: '50%',
height: thickness * 10,
left: -thickness * 5,
position: 'absolute',
top: -thickness * 5,
width: thickness * 10,
}}
/>
<div
style={{
background: `linear-gradient(90deg, ${withAlpha(color, 0.55)}, ${withAlpha(color, 0.18)} 45%, ${withAlpha(color, 0)})`,
clipPath: taper,
filter: `blur(${thickness * 0.5}px)`,
height: thickness * 3.2,
position: 'absolute',
top: -thickness * 1.6,
width: length * 1.1,
}}
/>
<div
style={{
background: `linear-gradient(90deg, ${core} 0%, ${color} 18%, ${withAlpha(color, 0.45)} 55%, ${withAlpha(color, 0)} 100%)`,
clipPath: taper,
height: thickness,
position: 'absolute',
top: -thickness / 2,
width: length,
}}
/>
<div
style={{
background: core,
borderRadius: '50%',
boxShadow: `0 0 ${thickness * 1.5}px ${thickness * 0.5}px ${color}, 0 0 ${thickness * 4}px ${withAlpha(color, 0.6)}`,
height: thickness * 1.15,
left: -thickness * 0.6,
position: 'absolute',
top: -thickness * 0.575,
width: thickness * 3,
}}
/>
<div style={{clipPath: 'polygon(0 0, 100% 0, 100% 42%, 50% 62%, 0 42%)', inset: 0, position: 'absolute'}}>
<Star
cornerRadius={size * 0.035}
fill={fill}
innerRadius={size * 0.23}
outerRadius={size / 2}
points={5}
style={{height: size, width: size}}
/>
</div>
</div>
);
/**
* @typedef {import('../schemas/gacha-request.js').GachaRenderRequest} GachaRenderRequest
* Hairline flourish beside the rank, ending in a dot, after the source
* video's title card.
*
* @param {{height: number, side: -1 | 1, spread: number}} props
*/
const Swash = ({height, side, spread}) => (
<div style={{alignItems: 'center', display: 'flex', flexDirection: side > 0 ? 'row' : 'row-reverse'}}>
<div
style={{
background: `linear-gradient(${side > 0 ? 90 : 270}deg, #ffffff, rgba(255,255,255,0.15))`,
height: 1.5,
width: height * 0.22 * spread,
}}
/>
<div style={{background: '#ffffff', borderRadius: '50%', height: height * 0.012, width: height * 0.012}} />
</div>
);
/**
* Beta wish: a cosmic, astrology-themed night sky in toon shading. The
* camera dollies toward a hero cloud, a comet bursts through it, the camera
* chases the comet as it flares, and after the burst the result appears with
* its rank.
* A snow-rimmed ridge drawn as a huge ellipse, so its crest curves like the
* source shot's wide lens.
*
* @param {GachaRenderRequest} props
* @param {{cx: number, cy: number, rx: number, ry: number, rotate: number, top: string, base: string}} props
*/
const Ridge = ({cx, cy, rx, ry, rotate, top, base}) => (
<div
style={{
background: `linear-gradient(180deg, ${top} 0%, ${base} 14%, ${base} 100%)`,
borderRadius: '50%',
height: ry * 2,
left: cx - rx,
position: 'absolute',
top: cy - ry,
transform: `rotate(${rotate}deg)`,
width: rx * 2,
}}
/>
);
/**
* @param {import('../schemas/gacha-request.js').GachaRenderRequest} props
*/
export const GachaBetaComposition = (props) => {
const frame = useCurrentFrame();
@@ -132,473 +145,181 @@ export const GachaBetaComposition = (props) => {
const seconds = frame / fps;
const tl = gachaBetaTimeline;
const palette = getRarityPalette(props.rarity);
const scene = useMemo(() => createBetaScene(props.seed), [props.seed]);
const stars = useMemo(() => createStarfield(110, props.seed), [props.seed]);
const camera = getCamera(seconds);
const shake = getBetaShake(seconds, height);
const comet = getCometPosition(seconds);
const cometScreen = project(comet, camera, width, height);
const glow = getCometGlow(seconds);
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;
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),
});
const speedLines = interpolate(
seconds,
[tl.pierceEnd, tl.pierceEnd + 0.4, tl.flightEnd - 0.6, tl.flightEnd],
[0, 1, 1, 0],
clamp,
);
/** @type {{depth: number, node: import('react').ReactNode}[]} */
const drawables = [];
for (const [index, cloud] of scene.clouds.entries()) {
const screen = project(cloud, camera, width, height);
if (!screen) {
continue;
}
const radius = cloud.radius * screen.scale;
drawables.push({
depth: screen.depth,
node: (
<div
key={`cloud-${index}`}
style={{left: screen.x, opacity: Math.min(1, 900 / screen.depth + 0.25), position: 'absolute', top: screen.y}}
>
{cloud.puffs.map((puff, puffIndex) => (
<div
key={puffIndex}
style={{
background: cloudShade,
borderRadius: '50%',
height: puff.r * radius * 2,
left: puff.dx * radius - puff.r * radius,
position: 'absolute',
top: puff.dy * radius - puff.r * radius,
width: puff.r * radius * 2,
}}
/>
))}
</div>
),
});
}
const hero = project(heroCloud, camera, width, height);
if (hero) {
const radius = heroCloud.radius * hero.scale;
drawables.push({
depth: hero.depth,
node: (
<div key="hero" style={{left: hero.x, opacity: skyIn, position: 'absolute', top: hero.y}}>
{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 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 (
<div
key={index}
style={{
background: heroShade,
borderRadius: '50%',
height: puff.r * radius * 2,
left: (puff.dx + ox) * radius - puff.r * radius,
opacity,
position: 'absolute',
top: (puff.dy + oy) * radius - puff.r * radius,
transform: `rotate(${puff.dx * 12}deg) scale(${1 + pierceBurst * 0.3}, 0.78)`,
width: puff.r * radius * 2,
}}
/>
);
})}
</div>
),
});
}
if (cometVisible && cometScreen) {
const trail = [];
for (let index = trailSamples - 1; index >= 1; index -= 1) {
const past = seconds - index * trailStep;
if (past < tl.cometAppear) {
continue;
}
const point = project(getCometPosition(past), camera, width, height);
if (!point) {
continue;
}
const fade = 1 - index / trailSamples;
const radius = (14 + 26 * glow) * point.scale * fade;
trail.push(
<div
key={`trail-${index}`}
style={{
background: `radial-gradient(circle, ${palette.core} 0% 30%, ${palette.glow} 30% 62%, ${withAlpha(palette.glow, 0.35)} 62% 100%)`,
borderRadius: '50%',
height: radius * 2,
left: point.x - radius,
opacity: fade,
position: 'absolute',
top: point.y - radius,
width: radius * 2,
}}
/>,
);
}
const headSize = (34 + 60 * glow + 90 * burst) * cometScreen.scale;
const flare = headSize * (1.4 + glow * 3);
drawables.push({
depth: cometScreen.depth,
node: (
<div key="comet" style={{inset: 0, position: 'absolute'}}>
{trail}
<div
style={{
background: `radial-gradient(circle, ${withAlpha(palette.glow, 0.7)} 0% 35%, ${withAlpha(palette.glow, 0.3)} 35% 60%, transparent 60%)`,
borderRadius: '50%',
height: headSize * 3.2,
left: cometScreen.x - headSize * 1.6,
position: 'absolute',
top: cometScreen.y - headSize * 1.6,
width: headSize * 3.2,
}}
/>
{[0, 90, 45, 135].map((angle, index) => (
<div
key={`flare-${angle}`}
style={{
background: `linear-gradient(90deg, transparent 0%, ${palette.core} 50%, transparent 100%)`,
clipPath: 'polygon(0% 50%, 50% 0%, 100% 50%, 50% 100%)',
height: Math.max(3, headSize * (index < 2 ? 0.16 : 0.09)),
left: cometScreen.x - (flare * (index < 2 ? 1 : 0.6)) / 2,
opacity: index < 2 ? 1 : glow,
position: 'absolute',
top: cometScreen.y - Math.max(1.5, headSize * (index < 2 ? 0.08 : 0.045)),
transform: `rotate(${angle + seconds * 20}deg)`,
width: flare * (index < 2 ? 1 : 0.6),
}}
/>
))}
<div
style={{
left: cometScreen.x - headSize / 2,
position: 'absolute',
top: cometScreen.y - headSize / 2,
transform: `perspective(${height}px) rotateY(${Math.sin(seconds * 4) * 32}deg) rotateZ(${seconds * 100}deg)`,
}}
>
<ToonStar fill="#ffffff" shade={palette.core} size={headSize} />
</div>
{burst > 0 ? (
<div
style={{
background: `repeating-conic-gradient(from ${seconds * 60}deg, #ffffff 0deg 9deg, ${palette.glow} 9deg 22.5deg)`,
clipPath:
'polygon(50% 0%, 58% 34%, 85% 15%, 66% 42%, 100% 50%, 66% 58%, 85% 85%, 58% 66%, 50% 100%, 42% 66%, 15% 85%, 34% 58%, 0% 50%, 34% 42%, 15% 15%, 42% 34%)',
height: height * 2.2 * burst,
left: cometScreen.x - height * 1.1 * burst,
position: 'absolute',
top: cometScreen.y - height * 1.1 * burst,
width: height * 2.2 * burst,
}}
/>
) : null}
</div>
),
});
}
// While the comet hides behind the hero cloud, its light spills around the
// cloud's rim so it visibly appears from behind before it breaks through.
const backlight = interpolate(seconds, [tl.cometAppear, tl.pierceStart, pierceTime], [0, 1, 0.6], clamp);
if (backlight > 0 && seconds < pierceTime) {
const light = project({...comet, y: heroCloud.y}, camera, width, height);
if (light) {
const radius = heroCloud.radius * 1.7 * light.scale;
drawables.push({
depth: light.depth + 1,
node: (
<div
key="backlight"
style={{
background: `radial-gradient(circle, ${withAlpha(palette.core, 0.95)} 0% 40%, ${withAlpha(palette.glow, 0.75)} 40% 62%, ${withAlpha(palette.glow, 0.25)} 62% 80%, transparent 80%)`,
borderRadius: '50%',
height: radius * 2,
left: light.x - radius,
opacity: backlight,
position: 'absolute',
top: light.y - radius,
width: radius * 2,
}}
/>
),
});
}
}
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: (
<div
key="cloud-impact"
style={{
border: `${Math.max(1, 8 * (1 - impact))}px solid ${palette.core}`,
borderRadius: '50%',
boxShadow: `0 0 ${height * 0.04}px ${palette.glow}`,
height: diameter,
left: hero.x - diameter / 2,
opacity: 1 - impact,
position: 'absolute',
top: hero.y - diameter / 2,
transform: 'rotateX(48deg)',
width: diameter,
}}
/>
),
});
}
drawables.sort((a, b) => b.depth - a.depth);
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),
});
const rankPop = interpolate(seconds, [tl.rankIn, tl.rankIn + 0.18, tl.rankIn + 0.36], [0, 1.35, 1], clamp);
const fadeIn = interpolate(seconds, [0, tl.fadeInEnd], [0, 1], clamp);
const hero = getHeroMeteor(seconds, width, height);
const glint = getBurnGlint(seconds);
const labelIn = interpolate(seconds, [tl.labelIn, tl.labelIn + 0.6], [0, 1], easeOut);
const rankIn = interpolate(seconds, [tl.rankIn, tl.rankIn + 0.5], [0, 1], easeOut);
const anchor = {x: revealAnchor.x * width, y: revealAnchor.y * height};
return (
<AbsoluteFill style={{background: '#0a0c2c', fontFamily: baseFont, overflow: 'hidden'}}>
<AbsoluteFill style={{background: '#000000', fontFamily: sansFont, overflow: 'hidden'}}>
<AbsoluteFill
style={{
opacity: skyIn,
transform: `translate(${shake.x}px, ${shake.y}px) rotate(${camera.roll}deg) scale(1.18)`,
background: 'linear-gradient(180deg, #040a26 0%, #0a1a46 42%, #1b3a6c 78%, #2f5684 100%)',
opacity: fadeIn,
}}
>
<AbsoluteFill style={{background: skyBackground}} />
<svg
height={height}
style={{left: 0, opacity: 0.32, position: 'absolute', top: 0}}
viewBox={`0 0 ${width} ${height}`}
width={width}
>
<g transform={`translate(${width / 2 - camera.x * 0.03} ${height * 0.4}) rotate(${seconds * 6})`}>
<circle fill="none" r={height * 0.46} stroke="#aab4ff" strokeWidth={1.5} />
<circle fill="none" r={height * 0.38} stroke="#aab4ff" strokeDasharray="2 6" strokeWidth={1} />
{Array.from({length: 12}, (_, index) => {
const angle = (index / 12) * Math.PI * 2;
return (
<g key={`tick-${index}`}>
<line
stroke="#aab4ff"
strokeWidth={1.5}
x1={Math.cos(angle) * height * 0.38}
x2={Math.cos(angle) * height * 0.46}
y1={Math.sin(angle) * height * 0.38}
y2={Math.sin(angle) * height * 0.46}
/>
<circle
cx={Math.cos(angle + 0.26) * height * 0.42}
cy={Math.sin(angle + 0.26) * height * 0.42}
fill="#d8dcff"
r={2.2}
/>
</g>
);
})}
</g>
{scene.links.map(([a, b], index) => {
const from = scene.stars[a ?? 0];
const to = scene.stars[b ?? 0];
if (!from || !to) {
return null;
}
return (
<line
key={`link-${index}`}
stroke="#c8d0ff"
strokeWidth={1}
x1={from.x * width}
x2={to.x * width}
y1={from.y * height * 0.7}
y2={to.y * height * 0.7}
/>
);
})}
</svg>
{scene.stars.map((star, index) => {
const twinkle = getTwinkle(seconds, star);
const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle);
{stars.map((star, index) => {
const size = star.radius * height * 0.8;
return (
<div
key={`star-${index}`}
style={{
background: '#ffffff',
background: '#dfe8ff',
borderRadius: '50%',
boxShadow: twinkle > 0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined,
height: size,
left: star.x * width - camera.x * 0.02,
opacity: 0.25 + 0.75 * twinkle,
left: star.x * width,
opacity: 0.15 + 0.55 * getTwinkle(seconds, star),
position: 'absolute',
top: star.y * height * 0.7,
top: star.y * height * 0.95,
width: size,
}}
/>
);
})}
{drawables.map((drawable) => drawable.node)}
{skyMeteors.map((meteor, index) => {
const head = getSkyMeteorHead(meteor, seconds, width, height);
return head ? (
<Meteor
color={meteor.color}
core="#ffffff"
key={`meteor-${index}`}
length={meteor.length * height}
opacity={1 - 0.7 * labelIn}
thickness={meteor.width * height}
x={head.x}
y={head.y}
/>
) : null;
})}
{speedLines > 0 && cometScreen
? Array.from({length: 18}, (_, index) => {
const angle = (index / 18) * 360 + ((index * 37) % 11);
const reach = ((seconds * 2.2 + index * 0.137) % 1) * width * 0.7;
return (
<div
key={`speed-${index}`}
style={{
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.75))',
height: 2,
left: cometScreen.x,
opacity: speedLines * 0.7,
position: 'absolute',
top: cometScreen.y,
transform: `rotate(${angle}deg) translateX(${height * 0.25 + reach}px)`,
transformOrigin: '0 50%',
width: width * 0.18,
}}
/>
);
})
: null}
</AbsoluteFill>
{cardVisible ? (
<AbsoluteFill
style={{background: `radial-gradient(ellipse, ${withAlpha(palette.deep, 0.9)}, #0a0c2cee)`, opacity: revealIn}}
>
<div
style={{
background: `repeating-conic-gradient(from ${seconds * 4}deg, ${withAlpha(palette.glow, 0.16)} 0deg 5deg, transparent 5deg 30deg)`,
borderRadius: '50%',
height: width * 1.5,
left: width / 2 - width * 0.75,
position: 'absolute',
top: height * 0.42 - width * 0.75,
width: width * 1.5,
}}
{hero.visible > 0 ? (
<Meteor
color={palette.glow}
core={palette.core}
length={height * 0.75 * hero.tail}
opacity={hero.visible}
thickness={height * 0.024}
x={hero.x}
y={hero.y}
/>
{scene.confetti.map((piece, index) => {
const y = ((piece.offset + (seconds - tl.cardIn) * piece.speed) % 1.2) - 0.1;
const size = piece.size * height;
return (
) : null}
<Ridge base="#0d1d3d" cx={width * 0.5} cy={height * 1.62} rotate={0} rx={width * 0.75} ry={height * 0.66} top="#1c3560" />
<Ridge
base="#1b3762"
cx={-width * 0.04}
cy={height * 1.1}
rotate={-8}
rx={width * 0.32}
ry={height * 0.32}
top="#8fb0d6"
/>
<Ridge
base="#1a3560"
cx={width * 1.06}
cy={height * 1.14}
rotate={10}
rx={width * 0.32}
ry={height * 0.32}
top="#88a9d0"
/>
<AbsoluteFill style={{background: 'radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(2,6,22,0.55) 100%)'}} />
{glint > 0 ? (
<>
<div
style={{
background: `radial-gradient(circle, ${palette.core} 0%, ${withAlpha(palette.glow, 0.65)} 22%, transparent 65%)`,
borderRadius: '50%',
height: height * 0.5,
left: anchor.x - height * 0.25,
opacity: glint,
position: 'absolute',
top: anchor.y - height * 0.25,
width: height * 0.5,
}}
/>
{[0, 90, 45, 135].map((rayAngle, index) => (
<div
key={`confetti-${index}`}
key={`glint-${rayAngle}`}
style={{
left: piece.x * width,
background: `linear-gradient(90deg, transparent, ${palette.core}, transparent)`,
height: 2,
left: anchor.x - height * 0.4,
opacity: glint * (index < 2 ? 1 : 0.5),
position: 'absolute',
top: y * height,
transform: `rotate(${seconds * piece.spin}deg)`,
}}
>
<ToonStar fill="#ffffff" shade={confettiColors[piece.hue] ?? '#ffd36b'} size={size} />
</div>
);
})}
<div
style={{
border: `1px solid ${withAlpha(palette.core, 0.35)}`,
borderRadius: '50%',
height: height * 0.8,
left: width / 2 - height * 0.4,
position: 'absolute',
top: height * 0.5 - height * 0.4,
transform: `rotate(${seconds * 5}deg)`,
width: height * 0.8,
}}
>
{Array.from({length: 12}, (_, index) => (
<div
key={index}
style={{
background: palette.core,
height: height * 0.018,
left: '50%',
opacity: 0.65,
position: 'absolute',
top: '50%',
transform: `rotate(${index * 30}deg) translateY(${-height * 0.4}px)`,
width: 2,
top: anchor.y - 1,
transform: `rotate(${rayAngle}deg) scaleX(${index < 2 ? 1 : 0.5})`,
width: height * 0.8,
}}
/>
))}
</div>
</>
) : null}
{labelIn > 0 ? (
<div
style={{
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
inset: 0,
justifyContent: 'center',
left: 0,
opacity: labelIn,
position: 'absolute',
transform: `scale(${cardPop})`,
right: 0,
top: anchor.y - height * 0.18,
transform: `translateY(${(1 - labelIn) * height * 0.03}px)`,
}}
>
<div
style={{
background: 'linear-gradient(180deg, #fffbe6 0% 40%, #ffd36b 40% 70%, #f0a92c 70%)',
color: 'transparent',
fontSize: height * 0.3,
fontStyle: 'italic',
fontWeight: 900,
letterSpacing: height * 0.005,
lineHeight: 1,
opacity: rankPop > 0 ? 1 : 0,
paddingRight: height * 0.03,
transform: `scale(${rankPop})`,
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
filter: `drop-shadow(0 0 ${height * 0.04}px ${palette.glow})`,
alignItems: 'center',
display: 'flex',
gap: height * 0.02,
opacity: rankIn,
transform: `scale(${0.9 + 0.1 * rankIn})`,
}}
>
{rank}
<Swash height={height} side={-1} spread={rankIn} />
<div
style={{
color: '#ffffff',
fontFamily: serifFont,
fontSize: height * 0.15,
fontStyle: 'italic',
fontWeight: 300,
letterSpacing: height * 0.012,
lineHeight: 1,
textShadow: `0 0 ${height * 0.03}px ${withAlpha(palette.glow, 0.9)}`,
}}
>
{getBetaRank()}
</div>
<Swash height={height} side={1} spread={rankIn} />
</div>
<div
style={{
color: '#ffffff',
fontSize: getLabelFontSize(props.label, height) * 1.1,
fontWeight: 900,
fontSize: getLabelFontSize(props.label, height),
fontWeight: 800,
lineHeight: 1.15,
marginTop: height * 0.03,
marginTop: height * 0.035,
maxWidth: width * 0.8,
overflowWrap: 'anywhere',
textAlign: 'center',
textShadow: `0 0 ${height * 0.03}px ${withAlpha(palette.glow, 0.8)}`,
textShadow: `0 0 ${height * 0.025}px ${withAlpha(palette.glow, 0.85)}, 0 ${height * 0.006}px ${height * 0.02}px #040a26`,
}}
>
{props.label}
</div>
</div>
</AbsoluteFill>
) : null}
{flash > 0 ? <AbsoluteFill style={{background: '#ffffff', opacity: flash}} /> : null}
) : null}
</AbsoluteFill>
</AbsoluteFill>
);
};
+84 -158
View File
@@ -1,67 +1,29 @@
/**
* Scene math for the beta wish: a toon-shaded night sky viewed through a
* perspective camera that dollies toward a hero cloud, follows a comet that
* pierces it, and holds on the comet as it flares and bursts. Kept free of
* React so the choreography can be unit tested.
*/
/**
* @typedef {object} Vec3
* @property {number} x
* @property {number} y
* @property {number} z
*/
/**
* @typedef {object} Camera
* @property {number} x
* @property {number} y
* @property {number} z
* @property {number} roll Screen roll in degrees.
*/
/**
* @typedef {object} Projection
* @property {number} x Screen pixels.
* @property {number} y Screen pixels.
* @property {number} scale Pixels per world unit at that depth.
* @property {number} depth World units in front of the camera.
* 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.
*/
export const gachaBetaTotalSeconds = 8;
/** Seconds from the start of the clip for each beat of the three phases. */
/** Seconds from the start of the clip for each beat. */
export const gachaBetaTimeline = Object.freeze({
dollyEnd: 2,
cometAppear: 1.2,
pierceStart: 1.9,
pierceEnd: 2.6,
flightEnd: 4.8,
flashPeak: 5.05,
flashEnd: 5.45,
cardIn: 5.2,
rankIn: 5.6,
fadeInEnd: 0.4,
heroStart: 1.1,
heroBurn: 4.4,
labelIn: 4.5,
rankIn: 4.9,
});
/** 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 hero meteor burns out and the result is centred, as frame fractions. */
export const revealAnchor = Object.freeze({x: 0.5, y: 0.44});
/** 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});
/** Every meteor travels down and to the right at this angle, as in the source shot. */
export const meteorAngleDegrees = 32;
/** The camera trails the comet from behind and slightly above it. */
const cameraFollowOffset = Object.freeze({x: 0, y: -30, z: -420});
/**
* Seconds at which the comet crosses the hero cloud's plane. The pierce moves
* linearly in depth, so the crossing is a fixed fraction of the segment.
*/
export const pierceTime =
gachaBetaTimeline.pierceStart +
((gachaBetaTimeline.pierceEnd - gachaBetaTimeline.pierceStart) * (cometPierceFrom.z - heroCloud.z)) /
(cometPierceFrom.z - cometPierceTo.z);
const angle = (meteorAngleDegrees * Math.PI) / 180;
const direction = Object.freeze({x: Math.cos(angle), y: Math.sin(angle)});
/**
* @param {number} value
@@ -73,132 +35,96 @@ const clamp01 = (value) => Math.min(1, Math.max(0, value));
* @param {number} t
* @returns {number}
*/
const easeInOutSine = (t) => -(Math.cos(Math.PI * clamp01(t)) - 1) / 2;
/**
* @param {number} t
* @returns {number}
*/
const easeOutCubic = (t) => 1 - (1 - clamp01(t)) ** 3;
/**
* @param {Vec3} a
* @param {Vec3} b
* @param {number} t
* @returns {Vec3}
*/
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 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.
*
* @param {number} seconds
* @returns {Vec3}
*/
export const getCometPosition = (seconds) => {
const tl = gachaBetaTimeline;
if (seconds < tl.pierceStart) {
const t = clamp01((seconds - tl.cometAppear) / (tl.pierceStart - tl.cometAppear));
return lerp3(cometHidden, cometPierceFrom, t);
}
if (seconds < tl.pierceEnd) {
return lerp3(cometPierceFrom, cometPierceTo, (seconds - tl.pierceStart) / (tl.pierceEnd - tl.pierceStart));
}
const u = easeOutCubic((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd));
return {
x: cometPierceTo.x + 600 * u,
y: cometPierceTo.y - 300 * u + 120 * Math.sin(Math.PI * u),
z: cometPierceTo.z + 2600 * u,
};
const smoothstep = (t) => {
const x = clamp01(t);
return x * x * (3 - 2 * x);
};
/**
* Action camera: a slow dolly toward the hero cloud, then a chase that locks
* onto the comet from behind with a little lag and banks into its turn.
*
* @param {number} seconds
* @returns {Camera}
* @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.
*/
export const getCamera = (seconds) => {
const tl = gachaBetaTimeline;
const dolly = {x: 0, y: 0, z: 250 * easeInOutSine(seconds / tl.dollyEnd)};
const lagged = getCometPosition(Math.max(tl.pierceEnd, seconds - 0.12));
const chase = {
x: lagged.x + cameraFollowOffset.x,
y: lagged.y + cameraFollowOffset.y,
z: lagged.z + cameraFollowOffset.z,
};
const follow = easeInOutSine((seconds - pierceTime) / (tl.pierceEnd + 0.4 - pierceTime));
const position = lerp3(dolly, {...chase, z: Math.max(dolly.z, chase.z)}, follow);
const bank = seconds > tl.pierceEnd ? 7 * Math.sin(Math.PI * clamp01((seconds - tl.pierceEnd) / 1.6)) : 0;
return {...position, roll: bank * follow};
};
/**
* Perspective projection onto the frame. Returns null for points behind or
* too close to the camera.
* 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 {Vec3} point
* @param {Camera} camera
* @param {SkyMeteor} meteor
* @param {number} seconds
* @param {number} width
* @param {number} height
* @returns {Projection | null}
* @returns {{x: number, y: number} | null}
*/
export const project = (point, camera, width, height) => {
const depth = point.z - camera.z;
if (depth < 30) {
export const getSkyMeteorHead = (meteor, seconds, width, height) => {
if (seconds < meteor.start) {
return null;
}
const focal = height * 1.2;
const scale = focal / depth;
const travel = (seconds - meteor.start) * meteor.speed * height;
return {x: meteor.x * width + direction.x * travel, y: meteor.y * height + direction.y * travel};
};
/** 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.
*
* @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.
*/
export const getHeroMeteor = (seconds, width, height) => {
const tl = gachaBetaTimeline;
const t = clamp01((seconds - tl.heroStart) / (tl.heroBurn - tl.heroStart));
const remaining = heroReach * height * (1 - t) ** 2.4;
return {
x: width / 2 + (point.x - camera.x) * scale,
y: height / 2 + (point.y - camera.y) * scale,
scale,
depth,
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,
};
};
/**
* Comet glow from 0 to 1: it ignites behind the cloud, then builds steadily
* through the flight until the burst.
* Brightness of the burn-out glint where the hero meteor dies, peaking at the
* burn and fading as the result settles.
*
* @param {number} seconds
* @returns {number}
*/
export const getCometGlow = (seconds) => {
const tl = gachaBetaTimeline;
if (seconds < tl.pierceEnd) {
return 0.35 * clamp01((seconds - tl.cometAppear) / 0.4);
export const getBurnGlint = (seconds) => {
const elapsed = seconds - gachaBetaTimeline.heroBurn;
if (elapsed < -0.2 || elapsed > 1) {
return 0;
}
return 0.35 + 0.65 * clamp01((seconds - tl.pierceEnd) / (tl.flightEnd - tl.pierceEnd)) ** 2;
return elapsed < 0 ? 1 + elapsed / 0.2 : (1 - elapsed) ** 2;
};
/**
* Decaying shake in pixels for the pierce and the burst.
*
* @param {number} seconds
* @param {number} height
* @returns {{x: number, y: number}}
*/
export const getBetaShake = (seconds, height) => {
let x = 0;
let y = 0;
for (const [start, amplitude] of [
[pierceTime, 0.02],
[gachaBetaTimeline.flightEnd, 0.035],
]) {
const elapsed = seconds - (start ?? 0);
if (elapsed >= 0 && elapsed < 0.6) {
const decay = (amplitude ?? 0) * height * (1 - elapsed / 0.6) ** 2;
x += Math.sin(elapsed * 77) * decay;
y += Math.cos(elapsed * 59) * decay;
}
}
return {x, y};
};
/** The beta food wish always reveals SSS; rarity still controls its light palette. */
/** The beta food wish always reveals SSS; rarity still controls the hero meteor's colour. */
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: toon-shaded 3D-perspective sky, comet through a cloud, SSS reveal. */
/** The beta wish: a meteor shower over a mountain ridge, ending in an SSS reveal. */
export const renderGachaBetaVideo = createGachaRenderer('GachaBetaWish');