Files
super-auto-pets-board-game/web/src/components/ShopPhase.tsx
T
2026-07-25 18:42:20 -04:00

597 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { artFor, artUrlFor } from '../petArt'
interface Flyer {
key: string
name: 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<string[]>([])
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 trumpets = you.trumpets ?? 0
const mana = you.mana ?? 0
const shopPeek = you.shopPeek
const hasBigfoot = (you.deck ?? []).some((c) =>
(c.effects as { action?: string }[] | undefined)?.some((e) => e.action === 'peekShop'),
)
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
// Show every coin you started the round with, fading the spent ones rather
// than dropping them. Coins only fall within a round, so the highest count
// seen this round is the starting purse. Reset when the round changes.
const purse = useRef({ round: view.round, max: you.coins })
if (purse.current.round !== view.round) {
purse.current = { round: view.round, max: you.coins }
}
purse.current.max = Math.max(purse.current.max, you.coins)
const totalCoins = purse.current.max
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<CardFlyer | null>(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<number | null>(null)
const capturedRef = useRef<Map<string, DOMRect>>(new Map())
const [flyers, setFlyers] = useState<Flyer[]>([])
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}`,
name: e.spawn === 'bee' ? 'Bee' : 'Apple',
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 (
<div className="shop">
{/* Status line */}
<div className="shop-status">
{pending && !myPending ? (
<span className="muted">
{opponent?.name ?? 'Opponent'} is tripling up a tier
</span>
) : you.ready ? (
<span className="muted">
You passed waiting for {opponent?.name ?? 'opponent'} to finish
shopping
</span>
) : myTurn && overPets ? (
<span className="status-hot">
Too many pets! Sell down to {view.maxPets} before you can pass 🍎
</span>
) : myTurn ? (
<span className="shop-status-pill">Your turn buy, sell, triple, or pass</span>
) : (
<span className="muted">
{view.players[view.turn]?.name ?? 'Opponent'}s turn
</span>
)}
</div>
{turnBanner && (
<div className="turn-banner">
<span className="turn-banner-pill">Your turn!</span>
</div>
)}
{/* Coins as big golden discs above the buy row. Spent coins stay put but
grey out, so you can see what you started the round with. */}
<div className="shop-coins" aria-label={`${you.coins} of ${totalCoins} gold`}>
{totalCoins > 0 ? (
Array.from({ length: totalCoins }, (_, i) => (
<span key={i} className={`coin-disc ${i >= you.coins ? 'is-spent' : ''}`}>
🪙
</span>
))
) : (
<span className="coin-empty muted">out of gold</span>
)}
</div>
{/* Shop row */}
<section className="shop-row-wrap">
<div className="section-label">
Shop · Tier {view.round}
<span className="muted"> · {view.deckCounts[view.round - 1]} left in deck</span>
</div>
<div className="shop-row" ref={shopAnim.containerRef}>
{view.shopRow.map((c, i) =>
c.id ? (
<div key={c.id} className="card-cell" data-flip-key={c.id} data-enter="flip">
<CardView
card={c}
size="lg"
disabled={!canBuy}
onClick={canBuy ? () => buy(i) : undefined}
/>
</div>
) : (
<div key={`empty-${i}`} className="card-slot-empty" />
),
)}
{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) => (
<div key={g.key} className="card-cell card-ghost" style={g.style}>
<CardView card={g.item} size="lg" />
</div>
))}
</div>
{myTurn && (
<div className="hint">
{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, triple, or pass'}
</div>
)}
</section>
{/* Set-aside Avocados (Golden pack) */}
{avocados > 0 && (
<section className="avocado-zone">
<div className="section-label">
Set aside
<span className="muted"> · discard instead of paying 1 🪙</span>
</div>
<button
type="button"
className={`avocado-token ${useAvocado ? 'is-active' : ''}`}
disabled={!myTurn || you.coins <= 0}
onClick={() => setUseAvocado((v) => !v)}
title={
you.coins <= 0
? 'Out of coins — buys will spend an Avocado'
: useAvocado
? 'Your next buy will spend an Avocado (click to cancel)'
: 'Spend an Avocado on your next buy instead of a coin'
}
>
🥑 ×{avocados}
</button>
{myTurn && (useAvocado || you.coins <= 0) && (
<div className="hint">Your next buy will discard an Avocado no coin spent.</div>
)}
</section>
)}
{/* Your deck */}
<section className="deck-wrap">
<div className="section-label">
Your deck
<span className="muted">
{' '}
· {you.petCount}/{view.maxPets} pets
</span>
</div>
{deck.length === 0 ? (
<div className="muted deck-empty">No cards yet buy something!</div>
) : (
<div className="deck-row" ref={deckAnim.containerRef}>
{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 (
<div
key={c.id}
className={`card-cell ${flying ? 'is-arriving' : ''}`}
data-flip-key={c.id}
data-enter={flying ? 'none' : 'pop'}
>
<CardView
card={c}
selected={selected.includes(c.id)}
onClick={() => toggle(c.id)}
/>
</div>
)
})}
{deckAnim.ghosts.map((g) => (
<div key={g.key} className="card-cell card-ghost" style={g.style}>
<CardView card={g.item} />
</div>
))}
</div>
)}
{trumpets > 0 && (
<div className="trumpet-indicator" title="Trumpets banked for your next battle">
🎺 ×{trumpets}
</div>
)}
{mana > 0 && (
<div className="trumpet-indicator" title="Mana — a persistent resource that powers Unicorn abilities">
🔮 ×{mana}
</div>
)}
{hasBigfoot && (
<div className="peek-indicator">
<button
className="btn btn-ghost btn-sm"
disabled={!myTurn || !!shopPeek}
onClick={() => send({ type: 'peek' })}
title="Bigfoot: look at the top of the shop deck (once per round)"
>
👁 Peek shop deck
</button>
{shopPeek && (
<div className="peek-card" title="Next card off the shop deck">
<CardView card={shopPeek} size="sm" />
</div>
)}
</div>
)}
</section>
{/* Actions */}
<div className="actions">
<button
className="btn btn-secondary"
disabled={!myTurn || selected.length === 0}
onClick={() => act({ type: 'sell', cards: selected })}
title="Convert selected cards into apples (+1 power each, this battle only) — free"
>
Sell {selected.length > 0 ? selected.length : ''} 🍎
</button>
<button
className="btn btn-secondary"
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
onClick={() => act({ type: 'trade', cards: selected })}
title="Triple 3 same-suit pets for a pick from the next tier — free"
>
Triple 3{' '}
{sameSuit && selectedCards[0].suit ? (
<span className={`suit-dot suit-${selectedCards[0].suit}`} />
) : (
'matching'
)}{' '}
Tier {Math.min(view.round + 1, view.maxRounds)}
</button>
<button
className={`btn btn-ghost ${passHint ? 'btn-pass-hint' : ''}`}
disabled={!myTurn || overPets}
onClick={() => setConfirmPass(true)}
title={
overPets
? `Sell down to ${view.maxPets} pets before passing`
: 'End your shopping for this round'
}
>
Pass
</button>
</div>
{/* Pass confirmation */}
{confirmPass && (
<div className="modal-backdrop" onClick={() => setConfirmPass(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Done shopping?</h3>
<p className="muted">
Passing ends your shopping for the rest of this round
{you.coins > 0 && (
<>
{' '}
and gives up your remaining {you.coins} 🪙
</>
)}
.
</p>
<div className="actions">
<button className="btn btn-ghost" onClick={() => setConfirmPass(false)}>
Keep shopping
</button>
<button
className="btn btn-primary"
onClick={() => {
setConfirmPass(false)
act({ type: 'pass' })
}}
>
Pass
</button>
</div>
</div>
</div>
)}
{/* Triple picker */}
{myPending && pending && (
<div className="modal-backdrop">
<div className="modal">
<h3>Pick one the other goes under the tier {pending.tier} deck</h3>
<div className="modal-cards">
{pending.options.map((c, i) => (
<CardView
key={c.id}
card={c}
size="lg"
onClick={() => send({ type: 'tradeChoose', pick: i })}
/>
))}
</div>
</div>
</div>
)}
{/* Cockatoo reveal picker (Golden pack) */}
{view.pendingReveal?.playerId === you.id && (
<div className="modal-backdrop">
<div className="modal">
<h3>Reveal a pet gain Apples equal to its Power</h3>
<div className="modal-cards">
{(view.pendingReveal.options ?? []).map((id) => {
const c = deck.find((d) => d.id === id)
return c ? (
<CardView
key={id}
card={c}
size="lg"
onClick={() => send({ type: 'revealChoose', card: id })}
/>
) : null
})}
</div>
</div>
</div>
)}
{/* Water of Youth sacrifice picker (Unicorn pack) */}
{view.pendingSacrifice?.playerId === you.id && (
<div className="modal-backdrop">
<div className="modal">
<h3>Sacrifice a pet to draw a free tier {view.pendingSacrifice.tier} card</h3>
<div className="modal-cards">
{(view.pendingSacrifice.options ?? []).map((id) => {
const c = deck.find((d) => d.id === id)
return c ? (
<CardView
key={id}
card={c}
size="lg"
onClick={() => send({ type: 'sacrificeChoose', card: id })}
/>
) : null
})}
</div>
</div>
</div>
)}
{cardFlyer &&
createPortal(
<div
key={cardFlyer.id}
className="buy-flyer"
style={
{
left: cardFlyer.dest.left,
top: cardFlyer.dest.top,
width: cardFlyer.dest.width,
height: cardFlyer.dest.height,
'--dx': `${cardFlyer.from.left - cardFlyer.dest.left}px`,
'--dy': `${cardFlyer.from.top - cardFlyer.dest.top}px`,
'--s': `${cardFlyer.from.width / cardFlyer.dest.width}`,
} as CSSProperties
}
onAnimationEnd={() => setCardFlyer(null)}
>
<CardView card={cardFlyer.card} />
</div>,
document.body,
)}
{flyers.length > 0 &&
createPortal(
<>
{flyers.map((fl) => (
<div
key={fl.key}
className="spawn-flyer"
style={{ left: fl.x, top: fl.y }}
onAnimationEnd={() =>
setFlyers((f) => f.filter((x) => x.key !== fl.key))
}
>
{artUrlFor(fl.name) ? (
<img className="spawn-flyer-img" src={artUrlFor(fl.name)!} alt="" draggable={false} />
) : (
artFor(fl.name)
)}
</div>
))}
</>,
document.body,
)}
</div>
)
}