Files
super-auto-pets-board-game/web/src/components/ShopPhase.tsx
T
Greyson Parrelli 9a3ee34357 Add touch-friendly shop interactions on phones.
A phone has no hover to preview a card, so tapping now opens a magnified,
fully-readable view with the actions that fit it instead of acting blind:

- Shop card: tap to see it big, then Buy (or read it even when unaffordable).
- Your deck card: tap to see it big, then Sell it, or Select to enter a
  bulk-selection mode where taps toggle pets (no magnify) and the action bar
  offers Sell / Triple / Cancel.

Desktop keeps click-to-buy and click-to-select unchanged; the new behavior is
gated on a max-width:600px media query via a small useMediaQuery hook.
2026-07-26 15:22:41 -04:00

732 lines
27 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 { useMediaQuery } from '../useMediaQuery'
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)
// Phones have no hover to preview a card, so a tap opens a magnified view with
// action buttons instead of acting immediately. `focus` is the tapped card
// shown big; `selectMode` is the deck's bulk-selection mode (tap to select
// without magnifying, then sell/triple). Both are ignored on desktop.
const isPhone = useMediaQuery('(max-width: 600px)')
const [focus, setFocus] = useState<{ kind: 'shop' | 'deck'; card: Card; row?: number } | null>(
null,
)
const [selectMode, setSelectMode] = 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)
}
// Bulk actions on the current selection; also leave mobile select mode.
const sellSelected = () => {
act({ type: 'sell', cards: selected })
setSelectMode(false)
}
const tradeSelected = () => {
act({ type: 'trade', cards: selected })
setSelectMode(false)
}
// Sell just the one card shown in the magnified view.
const sellOne = (id: string) => {
act({ type: 'sell', cards: [id] })
setFocus(null)
}
// Buy the card shown in the magnified shop view, then close it.
const buyFocused = (row: number) => {
buy(row)
setFocus(null)
}
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"
// Phone: a tap opens the magnified view (with a Buy button)
// rather than buying blind; and it stays tappable to read even
// when unaffordable. Desktop keeps click-to-buy.
disabled={isPhone ? false : !canBuy}
onClick={
isPhone
? selectMode
? undefined
: () => setFocus({ kind: 'shop', card: c, row: i })
: 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)}
// Phone: tap opens the magnified view (Sell / Select); once
// in select mode, tap toggles selection directly. Desktop
// toggles selection on tap.
onClick={
isPhone && !selectMode
? () => setFocus({ kind: 'deck', card: c })
: () => 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. On a phone, selling and tripling happen inside select mode
(entered from a pet's magnified view); the bar then shows those bulk
actions plus Cancel. Otherwise it's just Pass. Desktop is unchanged. */}
{isPhone && selectMode && (
<div className="hint">Tap pets to select, then sell or triple or cancel.</div>
)}
<div className="actions">
{(!isPhone || selectMode) && (
<button
className="btn btn-secondary"
disabled={!myTurn || selected.length === 0}
onClick={sellSelected}
title="Convert selected cards into apples (+1 power each, this battle only) — free"
>
Sell {selected.length > 0 ? selected.length : ''} 🍎
</button>
)}
{(!isPhone || selectMode) && (
<button
className="btn btn-secondary"
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
onClick={tradeSelected}
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>
)}
{isPhone && selectMode && (
<button
className="btn btn-ghost"
onClick={() => {
setSelected([])
setSelectMode(false)
}}
>
Cancel
</button>
)}
{(!isPhone || !selectMode) && (
<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>
{/* Phone: a tapped card shown big, with the actions that fit it — Buy for
a shop card, Sell / Select for one of yours. Reading the effect at a
readable size is the whole point, so this always shows the full card. */}
{focus &&
createPortal(
<div className="modal-backdrop" onClick={() => setFocus(null)}>
<div className="card-focus" onClick={(e) => e.stopPropagation()}>
<CardView card={focus.card} size="lg" preview />
{focus.kind === 'shop' && !canBuy && (
<div className="hint">
{!myTurn ? 'Wait for your turn to buy.' : 'Not enough gold for this.'}
</div>
)}
<div className="card-focus-actions">
{focus.kind === 'shop' ? (
<>
<button
className="btn btn-primary btn-big"
disabled={!canBuy}
onClick={() => buyFocused(focus.row!)}
>
{freeBuy
? 'Buy free 🎉'
: you.coins > 0
? 'Buy · 1 🪙'
: avocados > 0
? 'Buy · 🥑'
: 'Buy'}
</button>
<button className="btn btn-ghost" onClick={() => setFocus(null)}>
Close
</button>
</>
) : (
<>
<button
className="btn btn-secondary"
disabled={!myTurn}
onClick={() => sellOne(focus.card.id)}
title="Sell for an apple (+1 power this battle) — free"
>
Sell 🍎
</button>
<button
className="btn btn-primary"
onClick={() => {
setSelectMode(true)
setSelected([focus.card.id])
setFocus(null)
}}
title="Select multiple pets to sell or triple together"
>
Select
</button>
<button className="btn btn-ghost" onClick={() => setFocus(null)}>
Close
</button>
</>
)}
</div>
</div>
</div>,
document.body,
)}
{/* 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>
)
}