mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(gacha): fly into the rank emblem with a rainbow sunburst and twinkling sky
The meteor now flies in from the upper left and lands on the rank emblem, so the impact burst becomes the badge reveal. The 5-star rainbow ring is replaced by a rainbow sunburst of counter-rotating rays. Sky stars twinkle with short bright flares and cross glints, and each roll draws a fresh layout from a per-request seed that the route picks when the caller sends none.
This commit is contained in:
1 parent
9b93c53647
commit
dccd524f9d
10 files changed
+251
-65
No files matched your search
+6
-4
@@ -51,15 +51,17 @@ Authorization: Bearer change-me
|
||||
```
|
||||
|
||||
Renders a 7-second wish animation in the style of a gacha game: a meteor
|
||||
coloured by rarity (blue 3★, purple 4★, gold 5★) falls across a night sky,
|
||||
lands in a white flash, and the label is revealed beside a rank emblem (`B`,
|
||||
coloured by rarity (blue 3★, purple 4★, gold 5★) flies in from the left across
|
||||
a night sky and bursts in a white flash where the rank emblem appears, and the label is revealed beside a rank emblem (`B`,
|
||||
`A`, `S`) with its stars popping in. Each tier is louder than the one below:
|
||||
4★ adds a bigger meteor, a lens flare, impact shake, and a double shockwave;
|
||||
5★ adds a rainbow halo before landing, a gold sky flood, a starburst, counter
|
||||
5★ adds a rainbow sunburst before landing, a gold sky flood, a starburst, counter
|
||||
rotating rays, falling sparkles, and a sheen across the emblem. The per-tier
|
||||
table lives in `src/remotion/gacha-timeline.js`.
|
||||
`rarity` is required; `fps` is `24` or `30`; `width` is `640` (360 tall) or
|
||||
`854` (480 tall). The caller chooses the result and its rarity — the service
|
||||
`854` (480 tall). Optional `seed` (integer, `0` to `2147483647`) lays out the
|
||||
twinkling stars and particles; when omitted the service picks a random one, so
|
||||
every roll draws a different sky. The caller chooses the result and its rarity — the service
|
||||
only draws it.
|
||||
|
||||
Response is a silent H.264 `video/mp4` (Telegram plays it as an animation)
|
||||
|
||||
@@ -50,9 +50,9 @@ await mkdir(fixturesDir, {recursive: true});
|
||||
|
||||
/** @type {{name: string, request: import('../src/schemas/gacha-request.js').GachaRenderRequest}[]} */
|
||||
const gachaFixtures = [
|
||||
{name: 'gacha-5-star', request: {label: 'Pizza', rarity: 5, fps: 24, width: 640}},
|
||||
{name: 'gacha-4-star', request: {label: 'Phở bò tái nạm gầu', rarity: 4, fps: 24, width: 640}},
|
||||
{name: 'gacha-3-star', request: {label: 'Cơm tấm', rarity: 3, fps: 24, width: 854}},
|
||||
{name: 'gacha-5-star', request: {label: 'Pizza', rarity: 5, fps: 24, width: 640, seed: 7}},
|
||||
{name: 'gacha-4-star', request: {label: 'Phở bò tái nạm gầu', rarity: 4, fps: 24, width: 640, seed: 11}},
|
||||
{name: 'gacha-3-star', request: {label: 'Cơm tấm', rarity: 3, fps: 24, width: 854, seed: 23}},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {useMemo} from 'react';
|
||||
import {AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
import {
|
||||
createRandom,
|
||||
createStarfield,
|
||||
gachaTimeline,
|
||||
getLabelFontSize,
|
||||
emblemCenter,
|
||||
getMeteorPoint,
|
||||
getRankLetter,
|
||||
getRarityPalette,
|
||||
getShakeOffset,
|
||||
getStarRevealTimes,
|
||||
getTwinkle,
|
||||
getTierEffects,
|
||||
starColor,
|
||||
} from './gacha-timeline.js';
|
||||
@@ -18,27 +21,46 @@ const baseFont =
|
||||
|
||||
const clamp = /** @type {const} */ ({extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
|
||||
|
||||
const skyStars = createStarfield(70, 7);
|
||||
const trailSpacing = 0.011;
|
||||
const starburstSpikes = 10;
|
||||
|
||||
/**
|
||||
* Every decorative particle for one roll. The request seed picks the layout,
|
||||
* so each roll scatters its stars differently while every frame of one
|
||||
* render stays consistent.
|
||||
*
|
||||
* @param {number} seed
|
||||
*/
|
||||
const createParticles = (seed) => ({
|
||||
skyStars: createStarfield(70, seed),
|
||||
meteorSparks: createMeteorSparks(seed + 1),
|
||||
revealMotes: createRevealMotes(seed + 2),
|
||||
rainSparkles: createRainSparkles(seed + 3),
|
||||
});
|
||||
|
||||
/**
|
||||
* Sparks shed by the meteor. Each is born at a point along the fall and
|
||||
* drifts away from it while fading. Tiers draw a prefix of this pool.
|
||||
*
|
||||
* @param {number} seed
|
||||
*/
|
||||
const meteorSparks = (() => {
|
||||
const random = createRandom(11);
|
||||
function createMeteorSparks(seed) {
|
||||
const random = createRandom(seed);
|
||||
return Array.from({length: 80}, () => ({
|
||||
born: random(),
|
||||
driftX: (random() - 0.5) * 0.1,
|
||||
driftY: (random() - 0.2) * 0.1,
|
||||
size: 0.006 + random() * 0.012,
|
||||
}));
|
||||
})();
|
||||
}
|
||||
|
||||
/** Motes that float upward behind the revealed item. */
|
||||
const revealMotes = (() => {
|
||||
const random = createRandom(23);
|
||||
/**
|
||||
* Motes that float upward behind the revealed item.
|
||||
*
|
||||
* @param {number} seed
|
||||
*/
|
||||
function createRevealMotes(seed) {
|
||||
const random = createRandom(seed);
|
||||
return Array.from({length: 56}, () => ({
|
||||
x: random(),
|
||||
y: random(),
|
||||
@@ -46,11 +68,15 @@ const revealMotes = (() => {
|
||||
size: 0.004 + random() * 0.009,
|
||||
phase: random() * Math.PI * 2,
|
||||
}));
|
||||
})();
|
||||
}
|
||||
|
||||
/** Sparkles that rain down across the 5★ reveal. */
|
||||
const rainSparkles = (() => {
|
||||
const random = createRandom(31);
|
||||
/**
|
||||
* Sparkles that rain down across the 5★ reveal.
|
||||
*
|
||||
* @param {number} seed
|
||||
*/
|
||||
function createRainSparkles(seed) {
|
||||
const random = createRandom(seed);
|
||||
return Array.from({length: 40}, () => ({
|
||||
x: random(),
|
||||
offset: random(),
|
||||
@@ -58,7 +84,31 @@ const rainSparkles = (() => {
|
||||
size: 0.025 + random() * 0.03,
|
||||
spin: (random() - 0.5) * 360,
|
||||
}));
|
||||
})();
|
||||
}
|
||||
|
||||
const rainbowConic = 'conic-gradient(from 0deg, #ff6b6b, #ffd36b, #8cff9a, #6bd6ff, #b48cff, #ff8cd8, #ff6b6b)';
|
||||
|
||||
/** Fades the 5★ rainbow sunburst from a bright core out to its ray tips. */
|
||||
const sunburstFade = 'radial-gradient(circle, black 0%, black 18%, rgba(0, 0, 0, 0.6) 40%, transparent 70%)';
|
||||
|
||||
/**
|
||||
* Two ray layers turning in opposite directions; each ray takes the rainbow
|
||||
* hue of the angle it points at.
|
||||
*/
|
||||
const sunburstLayers = [
|
||||
{
|
||||
rays: 16,
|
||||
spin: 40,
|
||||
opacity: 1,
|
||||
mask: 'repeating-conic-gradient(from 0deg, black 0deg 6deg, transparent 6deg 22.5deg)',
|
||||
},
|
||||
{
|
||||
rays: 24,
|
||||
spin: -60,
|
||||
opacity: 0.7,
|
||||
mask: 'repeating-conic-gradient(from 4deg, black 0deg 2deg, transparent 2deg 15deg)',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} hex
|
||||
@@ -102,6 +152,7 @@ export const GachaComposition = (props) => {
|
||||
const palette = getRarityPalette(props.rarity);
|
||||
const fx = getTierEffects(props.rarity);
|
||||
const tl = gachaTimeline;
|
||||
const {skyStars, meteorSparks, revealMotes, rainSparkles} = useMemo(() => createParticles(props.seed), [props.seed]);
|
||||
|
||||
const meteorProgress = interpolate(seconds, [tl.meteorStart, tl.meteorEnd], [0, 1], {
|
||||
...clamp,
|
||||
@@ -123,8 +174,8 @@ export const GachaComposition = (props) => {
|
||||
const revealed = seconds >= tl.revealStart;
|
||||
const shake = getShakeOffset(seconds, tl.revealStart, fx.shake * height);
|
||||
const emblemSize = height * 0.52;
|
||||
const emblemX = width * 0.66;
|
||||
const emblemY = height * 0.47;
|
||||
const emblemX = width * emblemCenter.x;
|
||||
const emblemY = height * emblemCenter.y;
|
||||
const emblemScale = interpolate(
|
||||
seconds,
|
||||
[tl.revealStart, tl.revealStart + 0.45, tl.emblemSettled],
|
||||
@@ -175,21 +226,61 @@ export const GachaComposition = (props) => {
|
||||
background: `radial-gradient(ellipse 80% 40% at 50% 100%, ${withAlpha('#9bb4ff', 0.35)} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
{skyStars.map((star, index) => (
|
||||
<div
|
||||
key={`sky-${index}`}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '50%',
|
||||
height: star.radius * 2 * height,
|
||||
left: star.x * width,
|
||||
opacity: 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(seconds * 2.4 + star.phase)),
|
||||
position: 'absolute',
|
||||
top: star.y * height,
|
||||
width: star.radius * 2 * height,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{skyStars.map((star, index) => {
|
||||
const twinkle = getTwinkle(seconds, star);
|
||||
const size = star.radius * 2 * height * (0.8 + 0.7 * twinkle);
|
||||
const glint = star.glint ? size * 6 * twinkle : 0;
|
||||
return (
|
||||
<div
|
||||
key={`sky-${index}`}
|
||||
style={{
|
||||
height: 0,
|
||||
left: star.x * width,
|
||||
opacity: 0.2 + 0.8 * twinkle,
|
||||
position: 'absolute',
|
||||
top: star.y * height,
|
||||
width: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '50%',
|
||||
boxShadow: twinkle > 0.4 ? `0 0 ${size * 2}px rgba(200, 220, 255, ${twinkle})` : undefined,
|
||||
height: size,
|
||||
left: -size / 2,
|
||||
position: 'absolute',
|
||||
top: -size / 2,
|
||||
width: size,
|
||||
}}
|
||||
/>
|
||||
{glint > 1 ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, #ffffff, transparent)',
|
||||
height: 1,
|
||||
left: -glint / 2,
|
||||
position: 'absolute',
|
||||
top: -0.5,
|
||||
width: glint,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, transparent, #ffffff, transparent)',
|
||||
height: glint,
|
||||
left: -0.5,
|
||||
position: 'absolute',
|
||||
top: -glint / 2,
|
||||
width: 1,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{skyFlood > 0 ? (
|
||||
<AbsoluteFill
|
||||
@@ -259,19 +350,33 @@ export const GachaComposition = (props) => {
|
||||
{meteorVisible && haloOpacity > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: 'conic-gradient(from 0deg, #ff6b6b, #ffd36b, #8cff9a, #6bd6ff, #b48cff, #ff8cd8, #ff6b6b)',
|
||||
borderRadius: '50%',
|
||||
height: headRadius * 6,
|
||||
left: head.x - headRadius * 3,
|
||||
maskImage: 'radial-gradient(circle, transparent 52%, black 58%, black 64%, transparent 72%)',
|
||||
opacity: haloOpacity * 0.9,
|
||||
height: headRadius * 9,
|
||||
left: head.x - headRadius * 4.5,
|
||||
maskImage: sunburstFade,
|
||||
opacity: haloOpacity,
|
||||
position: 'absolute',
|
||||
top: head.y - headRadius * 3,
|
||||
transform: `rotate(${seconds * 240}deg) scale(${0.7 + 0.3 * haloOpacity})`,
|
||||
WebkitMaskImage: 'radial-gradient(circle, transparent 52%, black 58%, black 64%, transparent 72%)',
|
||||
width: headRadius * 6,
|
||||
top: head.y - headRadius * 4.5,
|
||||
transform: `scale(${0.5 + 0.5 * haloOpacity})`,
|
||||
WebkitMaskImage: sunburstFade,
|
||||
width: headRadius * 9,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{sunburstLayers.map((layer) => (
|
||||
<div
|
||||
key={layer.rays}
|
||||
style={{
|
||||
background: rainbowConic,
|
||||
borderRadius: '50%',
|
||||
inset: 0,
|
||||
maskImage: layer.mask,
|
||||
opacity: layer.opacity,
|
||||
position: 'absolute',
|
||||
transform: `rotate(${seconds * layer.spin}deg)`,
|
||||
WebkitMaskImage: layer.mask,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{meteorVisible ? (
|
||||
|
||||
@@ -38,6 +38,7 @@ const defaultGachaProps = {
|
||||
rarity: 5,
|
||||
fps: 24,
|
||||
width: 640,
|
||||
seed: 7,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,9 @@ export const gachaTimeline = Object.freeze({
|
||||
starStep: 0.18,
|
||||
});
|
||||
|
||||
/** Centre of the rank emblem on the reveal, as fractions of the frame. */
|
||||
export const emblemCenter = Object.freeze({x: 0.66, y: 0.47});
|
||||
|
||||
/** Every rarity reveals its stars in the same gold, as in the source game. */
|
||||
export const starColor = '#ffcc33';
|
||||
|
||||
@@ -51,8 +54,9 @@ const palettes = {
|
||||
export const getRarityPalette = (rarity) => palettes[rarity];
|
||||
|
||||
/**
|
||||
* Point on the meteor's curved fall from the upper right toward the lower
|
||||
* left of the frame, as a quadratic Bézier.
|
||||
* Point on the meteor's curved flight from the upper left toward the rank
|
||||
* emblem on the right, as a quadratic Bézier. It lands exactly on the emblem,
|
||||
* so the impact burst becomes the badge reveal.
|
||||
*
|
||||
* @param {number} progress 0 at launch, 1 at landing; values outside clamp.
|
||||
* @param {number} width
|
||||
@@ -61,9 +65,9 @@ export const getRarityPalette = (rarity) => palettes[rarity];
|
||||
*/
|
||||
export const getMeteorPoint = (progress, width, height) => {
|
||||
const t = Math.min(1, Math.max(0, progress));
|
||||
const start = {x: width * 1.08, y: -height * 0.18};
|
||||
const control = {x: width * 0.8, y: height * 0.3};
|
||||
const end = {x: width * 0.42, y: height * 0.6};
|
||||
const start = {x: -width * 0.08, y: -height * 0.18};
|
||||
const control = {x: width * 0.22, y: height * 0.38};
|
||||
const end = {x: width * emblemCenter.x, y: height * emblemCenter.y};
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x: u * u * start.x + 2 * u * t * control.x + t * t * end.x,
|
||||
@@ -104,6 +108,8 @@ export const createRandom = (seed) => {
|
||||
* @property {number} y Fraction of height.
|
||||
* @property {number} radius Fraction of height.
|
||||
* @property {number} phase Twinkle phase offset in radians.
|
||||
* @property {number} speed Twinkle speed in radians per second.
|
||||
* @property {boolean} glint Draws a cross-shaped glint when it flares.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -118,9 +124,22 @@ export const createStarfield = (count, seed) => {
|
||||
y: random() * 0.8,
|
||||
radius: 0.002 + random() * 0.004,
|
||||
phase: random() * Math.PI * 2,
|
||||
speed: 2 + random() * 5,
|
||||
glint: random() < 0.18,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Twinkle brightness in [0, 1]. Cubing the sine keeps a star dim most of the
|
||||
* time with short bright flares, which reads as twinkling rather than a slow
|
||||
* pulse.
|
||||
*
|
||||
* @param {number} seconds
|
||||
* @param {SkyStar} star
|
||||
* @returns {number}
|
||||
*/
|
||||
export const getTwinkle = (seconds, star) => (0.5 + 0.5 * Math.sin(seconds * star.speed + star.phase)) ** 3;
|
||||
|
||||
/**
|
||||
* Font size for the revealed name, shrinking long labels so they fit within
|
||||
* two lines on the left half of the frame.
|
||||
@@ -157,7 +176,7 @@ export const getRankLetter = (rarity) => /** @type {'B' | 'A' | 'S'} */ (rankLet
|
||||
* @property {number} headScale Meteor head size multiplier.
|
||||
* @property {number} trailSamples Glow blobs drawn along the meteor trail.
|
||||
* @property {number} sparks Sparks shed by the meteor.
|
||||
* @property {boolean} halo Rainbow ring forming around the meteor before landing.
|
||||
* @property {boolean} halo Rainbow sunburst bursting from the meteor before landing.
|
||||
* @property {number} skyFlood Peak opacity of the sky tint as the meteor lands.
|
||||
* @property {number} shake Impact camera shake amplitude, as a fraction of height.
|
||||
* @property {number} shockwaves Rings bursting from the emblem on reveal.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {randomInt} from 'node:crypto';
|
||||
import {ZodError} from 'zod';
|
||||
import {isAuthorized} from '../lib/bearer-auth.js';
|
||||
import {isRenderTimeoutError} from '../lib/render-errors.js';
|
||||
import {parseGachaRequest} from '../schemas/gacha-request.js';
|
||||
import {maxGachaSeed, parseGachaRequest} from '../schemas/gacha-request.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../config.js').AppConfig} AppConfig
|
||||
@@ -30,7 +31,9 @@ export const registerGachaRoute = async (app, deps) => {
|
||||
|
||||
let gachaRequest;
|
||||
try {
|
||||
gachaRequest = parseGachaRequest(request.body, {maxOptionChars: deps.config.maxOptionChars});
|
||||
gachaRequest = parseGachaRequest(request.body, {maxOptionChars: deps.config.maxOptionChars}, () =>
|
||||
randomInt(maxGachaSeed),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return reply.code(400).send({error: 'invalid_request', issues: error.issues});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export const maxGachaSeed = 2 ** 31 - 1;
|
||||
|
||||
/** Landscape frame sizes; heights keep 16:9 rounded to even pixels for H.264. */
|
||||
export const gachaFrameSizes = Object.freeze({640: 360, 854: 480});
|
||||
|
||||
@@ -14,6 +16,7 @@ export const gachaFrameSizes = Object.freeze({640: 360, 854: 480});
|
||||
* @property {3 | 4 | 5} rarity
|
||||
* @property {24 | 30} fps
|
||||
* @property {640 | 854} width
|
||||
* @property {number} seed Lays out the stars and particles for this roll.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -29,12 +32,20 @@ export const createGachaRequestSchema = (limits) =>
|
||||
rarity: z.union([z.literal(3), z.literal(4), z.literal(5)]),
|
||||
fps: z.union([z.literal(24), z.literal(30)]).default(24),
|
||||
width: z.union([z.literal(640), z.literal(854)]).default(640),
|
||||
seed: z.number().int().min(0).max(maxGachaSeed).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Parses a request, picking a fresh seed when the caller sends none so every
|
||||
* roll draws a different sky.
|
||||
*
|
||||
* @param {unknown} input
|
||||
* @param {GachaRequestLimits} limits
|
||||
* @param {() => number} pickSeed
|
||||
* @returns {GachaRenderRequest}
|
||||
*/
|
||||
export const parseGachaRequest = (input, limits) => createGachaRequestSchema(limits).parse(input);
|
||||
export const parseGachaRequest = (input, limits, pickSeed) => {
|
||||
const parsed = createGachaRequestSchema(limits).parse(input);
|
||||
return {...parsed, seed: parsed.seed ?? pickSeed()};
|
||||
};
|
||||
@@ -5,13 +5,17 @@ const limits = {maxOptionChars: 12};
|
||||
|
||||
describe('parseGachaRequest', () => {
|
||||
test('trims the label and applies defaults', () => {
|
||||
const request = parseGachaRequest({label: ' Pizza ', rarity: 5}, limits);
|
||||
const request = parseGachaRequest({label: ' Pizza ', rarity: 5}, limits, () => 42);
|
||||
|
||||
expect(request).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640});
|
||||
expect(request).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640, seed: 42});
|
||||
});
|
||||
|
||||
test('keeps a caller-provided seed', () => {
|
||||
expect(parseGachaRequest({label: 'Pizza', rarity: 5, seed: 9}, limits, () => 42).seed).toBe(9);
|
||||
});
|
||||
|
||||
test('accepts the larger frame and fps', () => {
|
||||
const request = parseGachaRequest({label: 'Pho', rarity: 4, fps: 30, width: 854}, limits);
|
||||
const request = parseGachaRequest({label: 'Pho', rarity: 4, fps: 30, width: 854}, limits, () => 1);
|
||||
|
||||
expect(request.fps).toBe(30);
|
||||
expect(request.width).toBe(854);
|
||||
@@ -26,7 +30,9 @@ describe('parseGachaRequest', () => {
|
||||
{label: 'Pizza', rarity: 3, width: 512},
|
||||
{label: 'Pizza', rarity: 3, fps: 15},
|
||||
{label: 'Pizza', rarity: 3, extra: true},
|
||||
{label: 'Pizza', rarity: 3, seed: -1},
|
||||
{label: 'Pizza', rarity: 3, seed: 1.5},
|
||||
])('rejects %o', (input) => {
|
||||
expect(() => parseGachaRequest(input, limits)).toThrow();
|
||||
expect(() => parseGachaRequest(input, limits, () => 0)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,32 @@ describe('POST /api/gacha', () => {
|
||||
expect(response.headers['content-type']).toBe('video/mp4');
|
||||
expect(response.headers['x-gacha-rarity']).toBe('5');
|
||||
expect(response.rawPayload.subarray(4, 8).toString()).toBe('ftyp');
|
||||
expect(rendered).toEqual({label: 'Pizza', rarity: 5, fps: 24, width: 640});
|
||||
expect(rendered).toMatchObject({label: 'Pizza', rarity: 5, fps: 24, width: 640});
|
||||
expect(Number.isInteger(/** @type {{seed: number}} */ (rendered).seed)).toBe(true);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('picks a different seed per roll unless one is given', async () => {
|
||||
/** @type {number[]} */
|
||||
const seeds = [];
|
||||
const app = await build(
|
||||
{},
|
||||
{
|
||||
renderGacha: async (request) => {
|
||||
seeds.push(request.seed);
|
||||
return {buffer: mp4, byteLength: mp4.byteLength, durationMs: 9};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5}});
|
||||
}
|
||||
await app.inject({method: 'POST', url: '/api/gacha', payload: {label: 'Pizza', rarity: 5, seed: 5}});
|
||||
|
||||
expect(new Set(seeds.slice(0, 3)).size).toBeGreaterThan(1);
|
||||
expect(seeds[3]).toBe(5);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
gachaTimeline,
|
||||
gachaTotalSeconds,
|
||||
getLabelFontSize,
|
||||
emblemCenter,
|
||||
getMeteorPoint,
|
||||
getRankLetter,
|
||||
getRarityPalette,
|
||||
getShakeOffset,
|
||||
getStarRevealTimes,
|
||||
getTierEffects,
|
||||
getTwinkle,
|
||||
} from '../src/remotion/gacha-timeline.js';
|
||||
|
||||
describe('gacha timeline', () => {
|
||||
@@ -38,13 +40,13 @@ describe('gacha timeline', () => {
|
||||
expect(new Set(glows).size).toBe(3);
|
||||
});
|
||||
|
||||
test('meteor falls from off-screen upper right toward the lower left', () => {
|
||||
test('meteor flies in from off-screen upper left and lands on the rank emblem', () => {
|
||||
const start = getMeteorPoint(0, 640, 360);
|
||||
const end = getMeteorPoint(1, 640, 360);
|
||||
expect(start.x).toBeGreaterThan(640);
|
||||
expect(start.x).toBeLessThan(0);
|
||||
expect(start.y).toBeLessThan(0);
|
||||
expect(end.x).toBeLessThan(start.x);
|
||||
expect(end.y).toBeGreaterThan(start.y);
|
||||
expect(end.x).toBeCloseTo(640 * emblemCenter.x);
|
||||
expect(end.y).toBeCloseTo(360 * emblemCenter.y);
|
||||
expect(getMeteorPoint(2, 640, 360)).toEqual(end);
|
||||
expect(getMeteorPoint(-1, 640, 360)).toEqual(start);
|
||||
});
|
||||
@@ -54,6 +56,18 @@ describe('gacha timeline', () => {
|
||||
expect(createStarfield(5, 7)).not.toEqual(createStarfield(5, 8));
|
||||
});
|
||||
|
||||
test('stars twinkle with short bright flares', () => {
|
||||
const [star] = createStarfield(1, 3);
|
||||
if (!star) {
|
||||
throw new Error('expected a star');
|
||||
}
|
||||
const samples = Array.from({length: 200}, (_, index) => getTwinkle(index / 50, star));
|
||||
expect(Math.max(...samples)).toBeGreaterThan(0.95);
|
||||
expect(Math.min(...samples)).toBeLessThan(0.05);
|
||||
const bright = samples.filter((value) => value > 0.5).length / samples.length;
|
||||
expect(bright).toBeLessThan(0.4);
|
||||
});
|
||||
|
||||
test('long labels get smaller text', () => {
|
||||
expect(getLabelFontSize('Pizza', 360)).toBeGreaterThan(getLabelFontSize('x'.repeat(40), 360));
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user