mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-13 00:15:16 +00:00
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:
1 parent
adea684a2f
commit
b8d5544fd6
5 files changed
+370
-761
No files matched your search
+7
-11
@@ -67,19 +67,15 @@ only draws it.
|
||||
### Gacha wish beta
|
||||
|
||||
`POST /api/gachabeta` takes the same body and returns the same response as
|
||||
`/api/gacha`, rendering an 8-second beta style instead: a cosmic, astrology
|
||||
themed night sky with toon-shaded clouds and stars, without outlines, seen
|
||||
through a perspective action camera:
|
||||
|
||||
1. The sky fades in around a main cloud. A comet approaches from behind,
|
||||
lights the cloud's rim, then punches through it with scattered puffs and an impact ring.
|
||||
2. The camera follows the flying star as its light and lens flare intensify,
|
||||
ending in a starburst and white flash.
|
||||
3. The food label appears inside an astrology ring with rank `SSS`.
|
||||
`/api/gacha`, rendering an 8-second beta style instead, modelled on the meteor
|
||||
shot of a music video: white, teal, green, red, and blue meteors glide slowly
|
||||
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
|
||||
out in a glint at the centre, where the food label appears under rank `SSS`.
|
||||
|
||||
The caller randomly chooses the food and sends it as `label` (for example,
|
||||
`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` still chooses the
|
||||
comet's blue, purple, or gold palette. `/api/gacha` keeps its existing ranks.
|
||||
`"Bún bò"`). The beta reveal always shows `SSS`; `rarity` only chooses the hero
|
||||
meteor's colour. `/api/gacha` keeps its existing ranks.
|
||||
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
@@ -3,101 +3,67 @@ import {
|
||||
gachaBetaTimeline,
|
||||
gachaBetaTotalSeconds,
|
||||
getBetaRank,
|
||||
getBetaShake,
|
||||
getCamera,
|
||||
getCometGlow,
|
||||
getCometPosition,
|
||||
heroCloud,
|
||||
pierceTime,
|
||||
project,
|
||||
getBurnGlint,
|
||||
getHeroMeteor,
|
||||
getSkyMeteorHead,
|
||||
meteorAngleDegrees,
|
||||
revealAnchor,
|
||||
skyMeteors,
|
||||
} from '../src/remotion/gacha-beta-scene.js';
|
||||
|
||||
describe('gacha beta scene', () => {
|
||||
test('phases run in order inside the clip', () => {
|
||||
const width = 640;
|
||||
const height = 360;
|
||||
|
||||
describe('gacha beta meteor scene', () => {
|
||||
test('beats run in order and the result holds before the clip ends', () => {
|
||||
const tl = gachaBetaTimeline;
|
||||
expect(tl.cometAppear).toBeLessThan(tl.pierceStart);
|
||||
expect(tl.pierceStart).toBeLessThan(pierceTime);
|
||||
expect(pierceTime).toBeLessThan(tl.pierceEnd);
|
||||
expect(tl.pierceEnd).toBeLessThan(tl.flightEnd);
|
||||
expect(tl.flightEnd).toBeLessThan(tl.flashPeak);
|
||||
expect(tl.cardIn).toBeLessThan(tl.flashEnd);
|
||||
expect(tl.rankIn + 1).toBeLessThan(gachaBetaTotalSeconds);
|
||||
expect(tl.fadeInEnd).toBeLessThan(tl.heroStart);
|
||||
expect(tl.heroStart).toBeLessThan(tl.heroBurn);
|
||||
expect(tl.heroBurn).toBeLessThan(tl.labelIn);
|
||||
expect(tl.labelIn).toBeLessThan(tl.rankIn);
|
||||
expect(tl.rankIn + 2).toBeLessThan(gachaBetaTotalSeconds);
|
||||
});
|
||||
|
||||
test('comet waits behind the hero cloud, then crosses its plane at the pierce', () => {
|
||||
expect(getCometPosition(gachaBetaTimeline.cometAppear).z).toBeGreaterThan(heroCloud.z);
|
||||
expect(getCometPosition(pierceTime).z).toBeCloseTo(heroCloud.z);
|
||||
expect(getCometPosition(gachaBetaTimeline.pierceEnd).z).toBeLessThan(heroCloud.z);
|
||||
});
|
||||
|
||||
test('comet is hidden behind the cloud: projected inside its silhouette', () => {
|
||||
const seconds = gachaBetaTimeline.pierceStart;
|
||||
const camera = getCamera(seconds);
|
||||
const comet = project(getCometPosition(seconds), camera, 640, 360);
|
||||
const cloud = project(heroCloud, camera, 640, 360);
|
||||
expect(comet && cloud).toBeTruthy();
|
||||
if (comet && cloud) {
|
||||
expect(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeLessThan(heroCloud.radius * cloud.scale);
|
||||
expect(comet.depth).toBeGreaterThan(cloud.depth);
|
||||
}
|
||||
});
|
||||
|
||||
test('approaching comet is visible beside the cloud before passing behind it', () => {
|
||||
const seconds = 1.4;
|
||||
const camera = getCamera(seconds);
|
||||
const comet = project(getCometPosition(seconds), camera, 640, 360);
|
||||
const cloud = project(heroCloud, camera, 640, 360);
|
||||
expect(comet && cloud).toBeTruthy();
|
||||
if (comet && cloud) {
|
||||
expect(comet.x).toBeGreaterThan(0);
|
||||
expect(comet.x).toBeLessThan(640);
|
||||
expect(comet.y).toBeGreaterThan(0);
|
||||
expect(comet.y).toBeLessThan(360);
|
||||
expect(Math.hypot(comet.x - cloud.x, comet.y - cloud.y)).toBeGreaterThan(heroCloud.radius * cloud.scale);
|
||||
expect(comet.depth).toBeGreaterThan(cloud.depth);
|
||||
}
|
||||
});
|
||||
|
||||
test('camera dollies toward the cloud, then chases the comet and keeps it in frame', () => {
|
||||
expect(getCamera(1).z).toBeGreaterThan(getCamera(0).z);
|
||||
for (const seconds of [3, 3.5, 4, 4.5]) {
|
||||
const camera = getCamera(seconds);
|
||||
const comet = project(getCometPosition(seconds), camera, 640, 360);
|
||||
expect(comet).not.toBeNull();
|
||||
if (comet) {
|
||||
expect(comet.x).toBeGreaterThan(640 * 0.2);
|
||||
expect(comet.x).toBeLessThan(640 * 0.8);
|
||||
expect(comet.y).toBeGreaterThan(360 * 0.2);
|
||||
expect(comet.y).toBeLessThan(360 * 0.8);
|
||||
test('background meteors appear on their cue and glide down to the right', () => {
|
||||
for (const meteor of skyMeteors) {
|
||||
expect(getSkyMeteorHead(meteor, meteor.start - 0.01, width, height)).toBeNull();
|
||||
const a = getSkyMeteorHead(meteor, meteor.start + 0.5, width, height);
|
||||
const b = getSkyMeteorHead(meteor, meteor.start + 1.5, width, height);
|
||||
expect(a && b).toBeTruthy();
|
||||
if (a && b) {
|
||||
expect(b.x).toBeGreaterThan(a.x);
|
||||
expect(b.y).toBeGreaterThan(a.y);
|
||||
expect((Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI).toBeCloseTo(meteorAngleDegrees);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('camera never moves backward', () => {
|
||||
let previous = -Infinity;
|
||||
for (let seconds = 0; seconds <= gachaBetaTimeline.flightEnd; seconds += 0.05) {
|
||||
const {z} = getCamera(seconds);
|
||||
expect(z).toBeGreaterThanOrEqual(previous - 1e-6);
|
||||
previous = z;
|
||||
test('the volley stays in the upper sky so the result area is clear at the reveal', () => {
|
||||
for (const meteor of skyMeteors) {
|
||||
const head = getSkyMeteorHead(meteor, gachaBetaTimeline.labelIn, width, height);
|
||||
if (head) {
|
||||
expect(head.y).toBeLessThan(height * 0.62);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('projection drops points behind the camera', () => {
|
||||
expect(project({x: 0, y: 0, z: 10}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360)).toBeNull();
|
||||
const centre = project({x: 0, y: 0, z: 500}, {x: 0, y: 0, z: 0, roll: 0}, 640, 360);
|
||||
expect(centre).toMatchObject({x: 320, y: 180});
|
||||
test('hero meteor is hidden before its cue, then slows to burn out on the anchor', () => {
|
||||
const tl = gachaBetaTimeline;
|
||||
expect(getHeroMeteor(tl.heroStart - 0.1, width, height).visible).toBe(0);
|
||||
const early = getHeroMeteor(tl.heroStart + 0.5, width, height);
|
||||
const mid = getHeroMeteor(tl.heroStart + 1, width, height);
|
||||
const late = getHeroMeteor(tl.heroBurn - 0.5, width, height);
|
||||
const late2 = getHeroMeteor(tl.heroBurn, width, height);
|
||||
expect(Math.hypot(mid.x - early.x, mid.y - early.y)).toBeGreaterThan(Math.hypot(late2.x - late.x, late2.y - late.y));
|
||||
expect(late2.x).toBeCloseTo(revealAnchor.x * width);
|
||||
expect(late2.y).toBeCloseTo(revealAnchor.y * height);
|
||||
expect(getHeroMeteor(tl.heroBurn + 0.2, width, height).visible).toBe(0);
|
||||
});
|
||||
|
||||
test('comet glow builds to full by the burst', () => {
|
||||
expect(getCometGlow(0)).toBe(0);
|
||||
expect(getCometGlow(3)).toBeGreaterThan(getCometGlow(2));
|
||||
expect(getCometGlow(gachaBetaTimeline.flightEnd)).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
test('shake fires at the pierce and the burst only', () => {
|
||||
expect(getBetaShake(1, 360)).toEqual({x: 0, y: 0});
|
||||
expect(Math.hypot(...Object.values(getBetaShake(pierceTime + 0.05, 360)))).toBeGreaterThan(0);
|
||||
expect(Math.hypot(...Object.values(getBetaShake(gachaBetaTimeline.flightEnd + 0.05, 360)))).toBeGreaterThan(0);
|
||||
test('burn glint fires only around the burn-out', () => {
|
||||
expect(getBurnGlint(gachaBetaTimeline.heroBurn - 1)).toBe(0);
|
||||
expect(getBurnGlint(gachaBetaTimeline.heroBurn)).toBe(1);
|
||||
expect(getBurnGlint(gachaBetaTimeline.heroBurn + 2)).toBe(0);
|
||||
});
|
||||
|
||||
test('beta food reveal displays SSS', () => {
|
||||
|
||||
Reference in new issue
Block a user