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:
tiennm99 committed 2026-10-01 14:43:46 +07:00
1 parent 9b93c53647
commit dccd524f9d
10 files changed
+251 -65

No files matched your search

+10 -4
View File
@@ -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();
});
});
+26 -1
View File
@@ -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();
});
+18 -4
View File
@@ -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));
});