fix(gacha): stop the card text moving after the card settles

Three things moved the text once the card looked still:

- The spin's last degrees: Chrome draws text on a slightly tilted card
  differently from a flat one, so glyphs hopped as the spin ran out. The
  spin now lands flat a moment before the card stops gliding.
- The landing: the card flew at its rounded size scaled to a fractional
  layout size, then was laid out again, shifting the text. The card's
  size is now whole pixels.
- The last flight frames: a sub-pixel creep at almost-identity scale drew
  the text differently from the card at rest. The flight now reaches its
  final pose slightly early.
This commit is contained in:
tiennm99 committed 2026-10-01 23:22:51 +07:00
1 parent 949e5e57ec
commit 48a30863c3
2 files changed
+28 -9

No files matched your search

+8 -1
View File
@@ -19,8 +19,15 @@ body {
justify-content: center; justify-content: center;
} }
/*
* Whole-pixel card size. pack-cards flies the card at its rounded layout size
* scaled to the real one, then lays it out again when it lands; a fractional
* size made the text re-layout and jump once the card had stopped.
*/
#stage .recap-deck { #stage .recap-deck {
width: min(52vh, 78vw); aspect-ratio: auto;
height: round(down, calc(min(52vh, 78vw) * 88 / 63), 1px);
width: round(down, min(52vh, 78vw), 1px);
} }
#stage .recap-pack-scene { #stage .recap-pack-scene {
+20 -8
View File
@@ -55,13 +55,22 @@ const createFace = () => {
* library: the rise out of the pack eases exponentially in and out, * 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 * 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 * for three extra turns, and the flip eases out like a flicked card slowing to
* a stop while sparkles burst around it. * a stop while sparkles burst around it. The spin lands flat a moment before
* the card stops gliding: Chrome redraws text on a slightly tilted card
* differently from a flat one, so ending both together made the text hop
* after the card looked still.
*/ */
const flight = Object.freeze({duration: 1750, clearAt: 900}); /**
* The flight reaches its final pose `settleMs` (of the original 1750ms)
* early, skipping a sub-pixel creep: the text drawn on the card's last scaled
* frames differs from the text once the card is laid out at rest, so it
* visibly hopped when the card landed.
*/
const flight = Object.freeze({duration: 1750, clearAt: 900, settleMs: 60});
/** Exponential ease-in-out, as a CSS timing function. */ /** Exponential ease-in-out, as a CSS timing function. */
const easeInOutExpo = 'cubic-bezier(.87, 0, .13, 1)'; const easeInOutExpo = 'cubic-bezier(.87, 0, .13, 1)';
const afterClearStretch = 1.65; const afterClearStretch = 1.65;
const flip = Object.freeze({startDegrees: 180 + 3 * 360, easing: 'cubic-bezier(.3, .4, .25, 1)'}); const flip = Object.freeze({startDegrees: 180 + 3 * 360, easing: 'cubic-bezier(.3, .4, .25, 1)', flatAt: 0.8});
/** /**
* Maps a time in the original flight to the stretched one. * Maps a time in the original flight to the stretched one.
@@ -148,8 +157,10 @@ Element.prototype.animate = function (keyframes, options) {
const [start] = keyframes; const [start] = keyframes;
const eased = const eased =
start && cleared > 1 ? [{...start, easing: easeInOutExpo}, ...keyframes.slice(cleared - 1)] : keyframes; start && cleared > 1 ? [{...start, easing: easeInOutExpo}, ...keyframes.slice(cleared - 1)] : keyframes;
const rest = eased.at(-1);
const stretched = eased.map((frame) => ({ const stretched = eased.map((frame) => ({
...frame, ...frame,
...(rest && at(frame) >= flight.duration - flight.settleMs ? {transform: rest.transform} : {}),
offset: stretch(at(frame)) / total, offset: stretch(at(frame)) / total,
})); }));
return animate.call(this, stretched, {...options, duration: total}); return animate.call(this, stretched, {...options, duration: total});
@@ -167,11 +178,12 @@ Element.prototype.animate = function (keyframes, options) {
if (this.parentElement) { if (this.parentElement) {
burstSparkles(this.parentElement, flight.clearAt, duration); burstSparkles(this.parentElement, flight.clearAt, duration);
} }
return animate.call(this, [{transform: `rotateY(${flip.startDegrees}deg)`}, keyframes[1] ?? {}], { const flat = keyframes[1] ?? {};
...options, return animate.call(
duration, this,
easing: flip.easing, [{transform: `rotateY(${flip.startDegrees}deg)`, easing: flip.easing}, {...flat, offset: flip.flatAt}, flat],
}); {...options, duration, easing: 'linear'},
);
} }
return animate.call(this, keyframes, options); return animate.call(this, keyframes, options);
}; };