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 send: (msg: ClientMessage) => void } export function ShopPhase({ view, you, send }: Props) { const [selected, setSelected] = useState([]) const cleanup = view.phase === 'cleanup' const myTurn = !cleanup && view.turn === view.youSeat && you.coins > 0 const deck = you.deck ?? [] const opponent = view.players.find((p) => p.seat !== view.youSeat) const pending = view.pending const myPending = pending?.playerId === you.id // Drop selections that no longer exist (bought/traded/discarded cards). useEffect(() => { setSelected((sel) => sel.filter((id) => deck.some((c) => c.id === id))) // 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], ) } const selectedCards = selected .map((id) => deck.find((c) => c.id === id)) .filter((c): c is Card => !!c) const sameSuit = selectedCards.length === 3 && selectedCards.every((c) => c.suit && c.suit === selectedCards[0].suit) const excessPets = you.petCount - view.maxPets const cleanupReady = cleanup && excessPets > 0 && selectedCards.length === excessPets && 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([]) } return (
{/* Status line */}
{cleanup ? ( excessPets > 0 ? ( Too many pets! Sell {excessPets} β€” they become apples 🍎 ) : ( Waiting for {opponent?.name ?? 'opponent'} to sell down to{' '} {view.maxPets} pets… ) ) : pending && !myPending ? ( {opponent?.name ?? 'Opponent'} is trading up a tier… ) : myTurn ? ( Your turn β€” spend a coin πŸͺ™ ) : ( {view.players[view.turn]?.name ?? 'Opponent'}’s turn… )}
{/* Shop row */} {!cleanup && (
Shop Β· Tier {view.round} Β· {view.deckCounts[view.round - 1]} left in deck
{view.shopRow.map((c, i) => c.id ? ( act({ type: 'buy', row: i }) : undefined} /> ) : (
), )}
{myTurn &&
Tap a card to buy it for 1 πŸͺ™
}
)} {/* Your deck */}
Your deck {' '} Β· {you.petCount}/{view.maxPets} pets
{deck.length === 0 ? (
No cards yet β€” buy something!
) : (
{deck.map((c) => ( toggle(c.id)} /> ))}
)}
{/* Actions */}
{cleanup ? ( excessPets > 0 && ( ) ) : ( <> )}
{/* Trade picker */} {myPending && pending && (

Pick one β€” the other goes under the tier {pending.tier} deck

{pending.options.map((c, i) => ( send({ type: 'tradeChoose', pick: i })} /> ))}
)} {flyers.length > 0 && createPortal( <> {flyers.map((fl) => (
setFlyers((f) => f.filter((x) => x.key !== fl.key)) } > {fl.emoji}
))} , document.body, )}
) }