import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties } from 'react' import { createPortal } from 'react-dom' import type { Card, ClientMessage, GameView, PlayerView } from '../types' import { CardView } from './CardView' import { useCardAnimations } from '../anim' interface Flyer { key: string emoji: string x: number y: number } // A bought card in flight from the shop slot it was clicked to the spot it lands // in your deck. `from` is where the click happened; `dest` is measured once the // card shows up in the deck. interface CardFlyer { id: string card: Card from: DOMRect dest: DOMRect } function prefersReducedMotion(): boolean { return ( typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ) } // playTurnChime sounds a short two-note flourish when it becomes your turn, // synthesized so there's no audio asset to ship. Silently no-ops if the Web // Audio API is unavailable or blocked. function playTurnChime() { try { const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext if (!AC) return const ctx = new AC() const now = ctx.currentTime ;[659.25, 987.77].forEach((freq, i) => { const t0 = now + i * 0.11 const osc = ctx.createOscillator() const gain = ctx.createGain() osc.type = 'triangle' osc.frequency.value = freq gain.gain.setValueAtTime(0.0001, t0) gain.gain.exponentialRampToValueAtTime(0.22, t0 + 0.02) gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.28) osc.connect(gain) gain.connect(ctx.destination) osc.start(t0) osc.stop(t0 + 0.3) }) window.setTimeout(() => ctx.close(), 900) } catch { /* audio unavailable β€” ignore */ } } interface Props { view: GameView you: PlayerView send: (msg: ClientMessage) => void } export function ShopPhase({ view, you, send }: Props) { const [selected, setSelected] = useState([]) const [confirmPass, setConfirmPass] = useState(false) // When set, the next buy is paid by discarding an Avocado instead of a coin // (Golden pack). It also kicks in automatically when out of coins. const [useAvocado, setUseAvocado] = useState(false) const myTurn = view.turn === view.youSeat && !you.ready const avocados = you.avocados ?? 0 const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold const canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy) // Nudge the player to pass once there's nothing left to buy. const passHint = myTurn && you.petCount <= view.maxPets && you.coins <= 0 && avocados === 0 && !freeBuy // Flash a "Your turn" banner (and a chime) when the turn swings to you β€” it's // easy to miss in a busy shop. const [turnBanner, setTurnBanner] = useState(false) const wasMyTurn = useRef(myTurn) useEffect(() => { if (myTurn && !wasMyTurn.current && !view.pending && !view.pendingReveal) { setTurnBanner(true) if (!prefersReducedMotion()) playTurnChime() const t = window.setTimeout(() => setTurnBanner(false), 1400) wasMyTurn.current = myTurn return () => window.clearTimeout(t) } wasMyTurn.current = myTurn }, [myTurn, view.pending, view.pendingReveal]) const overPets = you.petCount > view.maxPets const deck = you.deck ?? [] const opponent = view.players.find((p) => p.seat !== view.youSeat) const pending = view.pending const myPending = pending?.playerId === you.id // Cards flip in off the deck, spring in when bought, and shrink away when // sold/bought; surviving cards glide to close the gap. Empty shop slots // (id '') carry no flip key, so they sit still. const shopAnim = useCardAnimations(view.shopRow, (c) => c.id || 'empty') const deckAnim = useCardAnimations(deck, (c) => c.id) // --- buy animation: a bought card flies from the shop slot down into the // hand. `buyFly` is the pending intent (set on click, before the server round // trip); once the card lands in the deck we measure its slot and hand off to // `cardFlyer`, which renders the traveling copy. While either references a // card, that deck slot is held empty (hidden) so the card doesn't also pop in. const [buyFly, setBuyFly] = useState<{ id: string; from: DOMRect; card: Card } | null>(null) const [cardFlyer, setCardFlyer] = useState(null) useLayoutEffect(() => { if (!buyFly) return if (!deck.some((c) => c.id === buyFly.id)) return // hasn't landed in the deck yet const el = document.querySelector(`.deck-row [data-card-id="${buyFly.id}"]`) if (!el) return setCardFlyer({ id: buyFly.id, card: buyFly.card, from: buyFly.from, dest: el.getBoundingClientRect() }) setBuyFly(null) }, [deck, buyFly]) // 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) 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([]) } // Buy a card, flying it from the slot you clicked down into your hand. Capture // the clicked card's rect now (the slot refills with a new card right away); // the launch effect measures where it lands and animates the trip. function buy(row: number) { const card = view.shopRow[row] const el = card.id ? document.querySelector(`.shop-row [data-card-id="${card.id}"]`) : null if (el && !prefersReducedMotion()) { setBuyFly({ id: card.id, from: el.getBoundingClientRect(), card }) // Safety net: if the card never shows up in the deck, stop hiding its slot. window.setTimeout(() => setBuyFly((b) => (b?.id === card.id ? null : b)), 2000) } // A free first buy (Manta Ray) always goes through the normal buy; otherwise // pay with an Avocado when chosen, or when out of coins. const payAvocado = !freeBuy && avocados > 0 && (useAvocado || you.coins <= 0) act({ type: payAvocado ? 'buyAvocado' : 'buy', row }) setUseAvocado(false) } return (
{/* Status line */}
{pending && !myPending ? ( {opponent?.name ?? 'Opponent'} is trading up a tier… ) : you.ready ? ( You passed β€” waiting for {opponent?.name ?? 'opponent'} to finish shopping… ) : myTurn && overPets ? ( Too many pets! Sell down to {view.maxPets} before you can pass 🍎 ) : myTurn ? ( Your turn β€” buy, sell, trade, or pass ) : ( {view.players[view.turn]?.name ?? 'Opponent'}’s turn… )}
{turnBanner &&
Your turn!
} {/* Coins as big golden discs above the buy row. */}
{Array.from({ length: Math.max(you.coins, 0) }, (_, i) => ( πŸͺ™ ))} {you.coins === 0 && out of gold}
{/* Shop row */}
Shop Β· Tier {view.round} Β· {view.deckCounts[view.round - 1]} left in deck
{view.shopRow.map((c, i) => c.id ? (
buy(i) : undefined} />
) : (
), )} {shopAnim.ghosts // The bought card flies out as its own copy, so skip its shrink ghost. .filter((g) => g.key !== buyFly?.id && g.key !== cardFlyer?.id) .map((g) => (
))}
{myTurn && (
{freeBuy ? 'Your first buy this round is free πŸŽ‰ β€” tap a card' : you.coins > 0 ? 'Tap a card to buy it for 1 πŸͺ™ β€” selling and trading are free' : avocados > 0 ? 'No coins β€” tap a card to buy it by discarding an Avocado πŸ₯‘' : 'No coins left β€” you can still sell, trade, or pass'}
)}
{/* Set-aside Avocados (Golden pack) */} {avocados > 0 && (
Set aside Β· discard instead of paying 1 πŸͺ™
{myTurn && (useAvocado || you.coins <= 0) && (
Your next buy will discard an Avocado β€” no coin spent.
)}
)} {/* Your deck */}
Your deck {' '} Β· {you.petCount}/{view.maxPets} pets
{deck.length === 0 ? (
No cards yet β€” buy something!
) : (
{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) => (
))}
)}
{/* Actions */}
{/* Pass confirmation */} {confirmPass && (
setConfirmPass(false)}>
e.stopPropagation()}>

Done shopping?

Passing ends your shopping for the rest of this round {you.coins > 0 && ( <> {' '} and gives up your remaining {you.coins} πŸͺ™ )} .

)} {/* 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 })} /> ))}
)} {/* Cockatoo reveal picker (Golden pack) */} {view.pendingReveal?.playerId === you.id && (

Reveal a pet β€” gain Apples equal to its Power

{(view.pendingReveal.options ?? []).map((id) => { const c = deck.find((d) => d.id === id) return c ? ( send({ type: 'revealChoose', card: id })} /> ) : null })}
)} {cardFlyer && createPortal(
setCardFlyer(null)} >
, document.body, )} {flyers.length > 0 && createPortal( <> {flyers.map((fl) => (
setFlyers((f) => f.filter((x) => x.key !== fl.key)) } > {fl.emoji}
))} , document.body, )}
) }