597 lines
22 KiB
TypeScript
597 lines
22 KiB
TypeScript
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>
|
||
)
|
||
}
|