@@ -179,15 +232,25 @@ export function ShopPhase({ view, you, send }: Props) {
- {deck.map((c) => (
-
- toggle(c.id)}
- />
-
- ))}
+ {deck.map((c) => {
+ // Hold the slot empty while the bought card is flying into it, and
+ // skip the pop enter — the flight is its entrance.
+ const flying = c.id === buyFly?.id || c.id === cardFlyer?.id
+ return (
+
+ toggle(c.id)}
+ />
+
+ )
+ })}
{deckAnim.ghosts.map((g) => (
@@ -287,6 +350,29 @@ export function ShopPhase({ view, you, send }: Props) {
)}
+ {cardFlyer &&
+ createPortal(
+
setCardFlyer(null)}
+ >
+
+
,
+ document.body,
+ )}
+
{flyers.length > 0 &&
createPortal(
<>
diff --git a/web/src/styles.css b/web/src/styles.css
index 60d7c6b..3513250 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -1481,6 +1481,32 @@ h3 {
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
}
+/* A bought card in flight from the shop slot to its landing spot in your hand.
+ The element is positioned over its destination slot (fixed) and inverted back
+ to where it was clicked (--dx/--dy/--s), then plays that offset out — a FLIP
+ from shop to hand. Its deck slot stays empty (.is-arriving) until it lands. */
+@keyframes buy-fly {
+ from {
+ transform: translate(var(--dx), var(--dy)) scale(var(--s));
+ }
+ to {
+ transform: translate(0, 0) scale(1);
+ }
+}
+
+.buy-flyer {
+ position: fixed;
+ z-index: 60;
+ transform-origin: top left;
+ animation: buy-fly 480ms cubic-bezier(0.5, 0.02, 0.3, 1) forwards;
+ pointer-events: none;
+ filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.5));
+}
+
+.card-cell.is-arriving {
+ opacity: 0;
+}
+
/* --- the pet in play --- */
.battle-unit-zone {
@@ -1540,6 +1566,59 @@ h3 {
pointer-events: none;
}
+/* A freshly dealt food (apple) sails in from the deck — up and toward the outer
+ edge — and settles onto the fan, rather than blinking into place. Direction
+ follows the side the deck sits on (see food-in-left / food-in-right). */
+@keyframes food-in-left {
+ from {
+ opacity: 0;
+ transform: translate(-64px, -52px) rotate(-14deg) scale(0.6);
+ }
+ to {
+ opacity: 1;
+ transform: translate(0, 0) rotate(0) scale(1);
+ }
+}
+
+@keyframes food-in-right {
+ from {
+ opacity: 0;
+ transform: translate(64px, -52px) rotate(14deg) scale(0.6);
+ }
+ to {
+ opacity: 1;
+ transform: translate(0, 0) rotate(0) scale(1);
+ }
+}
+
+.food-fan-card.food-in-left {
+ animation: food-in-left 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
+}
+
+.food-fan-card.food-in-right {
+ animation: food-in-right 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
+}
+
+/* A pet that faints with a pending effect rises up out of the arena into the
+ set-aside row, shrinking from its arena size to the smaller set-aside size. */
+@keyframes setaside-in {
+ from {
+ opacity: 0;
+ transform: translateY(150px) scale(1.2);
+ }
+ 55% {
+ opacity: 1;
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.setaside-card.setaside-in {
+ animation: setaside-in 600ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
+}
+
.battle-unit {
position: relative;
flex-shrink: 0;
@@ -1557,19 +1636,46 @@ h3 {
font-size: 1.5rem;
}
-@keyframes unit-reveal {
+/* A pet flips face-up off its deck: it starts over the stack (outer edge) still
+ turned edge-on, then slides in toward its arena slot while turning to face us.
+ The deck sits to the outer side of each unit, so each side pulls the reveal in
+ from its own edge. */
+@keyframes unit-reveal-left {
from {
opacity: 0;
- transform: rotateY(90deg) scale(0.8);
+ transform: translateX(-108px) perspective(760px) rotateY(-92deg) scale(0.92);
+ }
+ 45% {
+ opacity: 1;
}
to {
opacity: 1;
- transform: rotateY(0) scale(1);
+ transform: translateX(0) perspective(760px) rotateY(0deg) scale(1);
}
}
-.battle-unit.unit-reveal {
- animation: unit-reveal 500ms ease;
+@keyframes unit-reveal-right {
+ from {
+ opacity: 0;
+ transform: translateX(108px) perspective(760px) rotateY(92deg) scale(0.92);
+ }
+ 45% {
+ opacity: 1;
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0) perspective(760px) rotateY(0deg) scale(1);
+ }
+}
+
+.battle-unit.unit-reveal-left {
+ transform-origin: left center;
+ animation: unit-reveal-left 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
+}
+
+.battle-unit.unit-reveal-right {
+ transform-origin: right center;
+ animation: unit-reveal-right 520ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
/* --- rock dice --- */