diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index 710deda..168090f 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -67,6 +67,7 @@ export function CardView({ return (
setHover({ x: e.clientX, y: e.clientY })} diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 16916fc..00ab4ab 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -1,7 +1,15 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import type { Card, ClientMessage, GameView, PlayerView } from '../types' import { CardView } from './CardView' +interface Flyer { + key: string + emoji: string + x: number + y: number +} + interface Props { view: GameView you: PlayerView @@ -23,6 +31,44 @@ export function ShopPhase({ view, you, send }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [you.deck]) + // --- spawn animation: apples/bees fly out from the creature that made them. + // The log tells us which card (source) spawned what; we fly an emoji from + // that card's on-screen position. For sells the source is already gone, so + // we grab its rect at click time (see act()). + const seenSeqRef = useRef(null) + const capturedRef = useRef>(new Map()) + const [flyers, setFlyers] = useState([]) + + useEffect(() => { + const log = view.log ?? [] + const last = log.length ? log[log.length - 1].seq : 0 + // First render just marks where we came in, so we never replay history. + if (seenSeqRef.current === null) { + seenSeqRef.current = last + return + } + if (last <= seenSeqRef.current) return + const fresh = log.filter((e) => e.seq > seenSeqRef.current! && e.spawn && e.source) + seenSeqRef.current = last + const added: Flyer[] = [] + for (const e of fresh) { + const src = e.source! + let rect: DOMRect | undefined + const el = document.querySelector(`[data-card-id="${src}"]`) + if (el) rect = el.getBoundingClientRect() + else if (capturedRef.current.has(src)) rect = capturedRef.current.get(src) + capturedRef.current.delete(src) + if (!rect) continue + added.push({ + key: `spawn-${e.seq}`, + emoji: e.spawn === 'bee' ? '🐝' : '🍎', + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }) + } + if (added.length) setFlyers((f) => [...f, ...added]) + }, [view.log]) + function toggle(id: string) { setSelected((sel) => sel.includes(id) ? sel.filter((s) => s !== id) : [...sel, id], @@ -43,6 +89,14 @@ export function ShopPhase({ view, you, send }: Props) { selectedCards.every((c) => c.kind === 'pet') function act(msg: ClientMessage) { + // Sold cards vanish before the apple entry arrives, so remember where they + // were now, keyed by id, for the spawn animation to fly from. + if (msg.type === 'sell') { + for (const id of msg.cards) { + const el = document.querySelector(`[data-card-id="${id}"]`) + if (el) capturedRef.current.set(id, el.getBoundingClientRect()) + } + } send(msg) setSelected([]) } @@ -193,6 +247,25 @@ export function ShopPhase({ view, you, send }: Props) {
)} + + {flyers.length > 0 && + createPortal( + <> + {flyers.map((fl) => ( +
+ setFlyers((f) => f.filter((x) => x.key !== fl.key)) + } + > + {fl.emoji} +
+ ))} + , + document.body, + )} ) } diff --git a/web/src/styles.css b/web/src/styles.css index be105be..5c2f66b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1048,6 +1048,31 @@ h3 { animation-name: summon-from-left; } +/* Apple/bee spawned in the shop, flying out from the creature that made it. */ +@keyframes spawn-fly { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.4) rotate(-10deg); + } + 20% { + opacity: 1; + transform: translate(-50%, -85%) scale(1.35) rotate(4deg); + } + 100% { + opacity: 0; + transform: translate(-50%, -165%) scale(1) rotate(0); + } +} + +.spawn-flyer { + position: fixed; + z-index: 60; + font-size: 2rem; + pointer-events: none; + animation: spawn-fly 1000ms ease-out forwards; + filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5)); +} + /* --- foods waiting for a pet --- */ .pending-foods {