From 76c96464a8e30ec25b24657d7a2abe2501d933ee Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 22:52:52 +0700 Subject: [PATCH] feat(gacha): ease the tear, pack drop, and card rise exponentially The scripted drag across the seal, the torn pack falling away, and the card rising out of it now ease exponentially in and out. The pack keeps pack-cards' 1.35s drop and hold, and the card still clears the pack when the spin starts. --- renderer/src/gacha/page/page.css | 20 ++++++++++++++++++++ renderer/src/gacha/page/page.js | 21 +++++++++++++++------ renderer/src/gacha/wish-plan.js | 17 +++++++++++++++-- renderer/test/gacha-wish-plan.test.js | 13 +++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/renderer/src/gacha/page/page.css b/renderer/src/gacha/page/page.css index b5fb49e..b79afd8 100644 --- a/renderer/src/gacha/page/page.css +++ b/renderer/src/gacha/page/page.css @@ -34,6 +34,26 @@ body { width: min(36vh, 60vw); } +/* + * The torn pack keeps pack-cards' 1.35s drop and its short hold, but falls + * in one exponential ease-in-out. Not scoped to #stage: the pack also drops + * inside pack-cards' flight overlay on . + */ +html .recap-pack-torn :is(.recap-webgl-pack, .recap-pack-lining) { + animation-name: wish-pack-away; + animation-timing-function: cubic-bezier(0.87, 0, 0.13, 1); +} + +@keyframes wish-pack-away { + 0%, + 20% { + transform: translateY(0); + } + 100% { + transform: translateY(calc(100vh + 100%)); + } +} + /* A video has no buttons or prompts to read: hide the interactive captions. */ #stage .recap-pack-scene > .eyebrow, #stage .recap-pack-title, diff --git a/renderer/src/gacha/page/page.js b/renderer/src/gacha/page/page.js index 9075c30..4d72436 100644 --- a/renderer/src/gacha/page/page.js +++ b/renderer/src/gacha/page/page.js @@ -52,12 +52,14 @@ const createFace = () => { * over 1750ms: the card is clear of the pack at 900ms, then it flips with a * half turn (rotateY 180deg to 0deg) and the pack drops away while it glides * to the centre. The page reshapes those animations without changing the - * library: the rise keeps its speed, everything after the card clears the - * pack is stretched so the flip has time for three extra turns, and the flip - * eases out like a flicked card slowing to a stop while sparkles burst - * around it. + * library: the rise out of the pack eases exponentially in and out, + * everything after the card clears the pack is stretched so the flip has time + * for three extra turns, and the flip eases out like a flicked card slowing to + * a stop while sparkles burst around it. */ const flight = Object.freeze({duration: 1750, clearAt: 900}); +/** Exponential ease-in-out, as a CSS timing function. */ +const easeInOutExpo = 'cubic-bezier(.87, 0, .13, 1)'; const afterClearStretch = 1.65; const flip = Object.freeze({startDegrees: 180 + 3 * 360, easing: 'cubic-bezier(.3, .4, .25, 1)'}); @@ -139,9 +141,16 @@ Element.prototype.animate = function (keyframes, options) { // The flight path: keyframes spaced evenly over the whole flight. if (options.duration === flight.duration && options.easing === 'linear') { const total = stretch(flight.duration); - const stretched = keyframes.map((frame) => ({ + const at = (/** @type {Keyframe} */ frame) => Number(frame.offset) * flight.duration; + // Until the card clears the pack it only translates, so the rise is one + // straight segment: keep its two ends and ease between them. + const cleared = keyframes.findIndex((frame) => at(frame) > flight.clearAt); + const [start] = keyframes; + const eased = + start && cleared > 1 ? [{...start, easing: easeInOutExpo}, ...keyframes.slice(cleared - 1)] : keyframes; + const stretched = eased.map((frame) => ({ ...frame, - offset: stretch(Number(frame.offset) * flight.duration) / total, + offset: stretch(at(frame)) / total, })); return animate.call(this, stretched, {...options, duration: total}); } diff --git a/renderer/src/gacha/wish-plan.js b/renderer/src/gacha/wish-plan.js index 6b11ea3..5e20d38 100644 --- a/renderer/src/gacha/wish-plan.js +++ b/renderer/src/gacha/wish-plan.js @@ -82,10 +82,23 @@ export const getWishPageProps = (request) => { return {label: request.label, rank, stars: request.rarity, rarity, glow, artwork: {accent, tint}}; }; +/** + * Exponential ease-in-out over 0..1. + * + * @param {number} t + */ +const easeInOutExpo = (t) => { + if (t <= 0 || t >= 1) { + return Math.min(1, Math.max(0, t)); + } + return t < 0.5 ? 2 ** (20 * t - 10) / 2 : (2 - 2 ** (10 - 20 * t)) / 2; +}; + /** * Pointer position for a frame during the drag, as fractions of the pack * control's box, or null outside it. The button is held while the pointer - * sweeps across the seal. + * sweeps across the seal, easing exponentially in and out like a hand + * tearing it. * * @param {number} seconds * @returns {{x: number, y: number} | null} @@ -95,7 +108,7 @@ export const getDragPointer = (seconds) => { if (seconds < dragStart || seconds > dragEnd) { return null; } - return {x: 0.08 + 0.9 * ((seconds - dragStart) / (dragEnd - dragStart)), y: 0.12}; + return {x: 0.08 + 0.9 * easeInOutExpo((seconds - dragStart) / (dragEnd - dragStart)), y: 0.12}; }; const contentTypes = Object.freeze({ diff --git a/renderer/test/gacha-wish-plan.test.js b/renderer/test/gacha-wish-plan.test.js index 5e9c9a6..07d23c6 100644 --- a/renderer/test/gacha-wish-plan.test.js +++ b/renderer/test/gacha-wish-plan.test.js @@ -46,6 +46,19 @@ describe('gacha wish plan', () => { expect(dragEnd + 2).toBeLessThan(gachaTotalSeconds); }); + test('the drag eases exponentially in and out across the seal', () => { + const {dragStart, dragEnd} = wishTimeline; + const x = (/** @type {number} */ fraction) => + getDragPointer(dragStart + (dragEnd - dragStart) * fraction)?.x ?? NaN; + expect(x(0)).toBeCloseTo(0.08); + expect(x(1)).toBeCloseTo(0.98); + expect(x(0.5)).toBeCloseTo(0.53); + // Slow at both ends, fast through the middle. + expect(x(0.2) - x(0)).toBeLessThan(0.01); + expect(x(1) - x(0.8)).toBeLessThan(0.01); + expect(x(0.6) - x(0.4)).toBeGreaterThan(0.6); + }); + test('page requests map to the page directory and the installed package', () => { expect(resolveWishAsset(`${wishOrigin}/index.html`, roots)).toEqual({ file: path.join(roots.page, 'index.html'),