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'),