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;
}
/*
* 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 {
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 {
+20 -8
View File
@@ -55,13 +55,22 @@ const createFace = () => {
* 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.
* 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. */
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)'});
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.
@@ -148,8 +157,10 @@ Element.prototype.animate = function (keyframes, options) {
const [start] = keyframes;
const eased =
start && cleared > 1 ? [{...start, easing: easeInOutExpo}, ...keyframes.slice(cleared - 1)] : keyframes;
const rest = eased.at(-1);
const stretched = eased.map((frame) => ({
...frame,
...(rest && at(frame) >= flight.duration - flight.settleMs ? {transform: rest.transform} : {}),
offset: stretch(at(frame)) / total,
}));
return animate.call(this, stretched, {...options, duration: total});
@@ -167,11 +178,12 @@ Element.prototype.animate = function (keyframes, options) {
if (this.parentElement) {
burstSparkles(this.parentElement, flight.clearAt, duration);
}
return animate.call(this, [{transform: `rotateY(${flip.startDegrees}deg)`}, keyframes[1] ?? {}], {
...options,
duration,
easing: flip.easing,
});
const flat = keyframes[1] ?? {};
return animate.call(
this,
[{transform: `rotateY(${flip.startDegrees}deg)`, easing: flip.easing}, {...flat, offset: flip.flatAt}, flat],
{...options, duration, easing: 'linear'},
);
}
return animate.call(this, keyframes, options);
};