Files
super-auto-pets-board-game/web/src/components/ShopPhase.tsx
T

272 lines
8.7 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, 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<string[]>([])
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<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}`,
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 (
<div className="shop">
{/* Status line */}
<div className="shop-status">
{cleanup ? (
excessPets > 0 ? (
<span className="status-hot">
Too many pets! Sell <strong>{excessPets}</strong> they become
apples 🍎
</span>
) : (
<span className="muted">
Waiting for {opponent?.name ?? 'opponent'} to sell down to{' '}
{view.maxPets} pets
</span>
)
) : pending && !myPending ? (
<span className="muted">
{opponent?.name ?? 'Opponent'} is trading up a tier
</span>
) : myTurn ? (
<span className="status-hot">Your turn spend a coin 🪙</span>
) : (
<span className="muted">
{view.players[view.turn]?.name ?? 'Opponent'}s turn
</span>
)}
</div>
{/* Shop row */}
{!cleanup && (
<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">
{view.shopRow.map((c, i) =>
c.id ? (
<CardView
key={c.id}
card={c}
size="lg"
disabled={!myTurn}
onClick={myTurn ? () => act({ type: 'buy', row: i }) : undefined}
/>
) : (
<div key={`empty-${i}`} className="card-slot-empty" />
),
)}
</div>
{myTurn && <div className="hint">Tap a card to buy it for 1 🪙</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">
{deck.map((c) => (
<CardView
key={c.id}
card={c}
selected={selected.includes(c.id)}
onClick={() => toggle(c.id)}
/>
))}
</div>
)}
</section>
{/* Actions */}
<div className="actions">
{cleanup ? (
excessPets > 0 && (
<button
className="btn btn-primary"
disabled={!cleanupReady}
onClick={() => act({ type: 'sell', cards: selected })}
>
Sell {excessPets} pet{excessPets > 1 ? 's' : ''} 🍎
</button>
)
) : (
<>
<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)"
>
Sell {selected.length > 0 ? selected.length : ''} 🍎 (1 🪙)
</button>
<button
className="btn btn-secondary"
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
onClick={() => act({ type: 'trade', cards: selected })}
title="Trade 3 same-suit pets for a pick from the next tier"
>
Trade 3{' '}
{sameSuit && selectedCards[0].suit ? (
<span className={`suit-dot suit-${selectedCards[0].suit}`} />
) : (
'matching'
)}{' '}
Tier {Math.min(view.round + 1, view.maxRounds)} (1 🪙)
</button>
<button
className="btn btn-ghost"
disabled={!myTurn}
onClick={() => act({ type: 'pass' })}
title="Give up your remaining coins"
>
Pass
</button>
</>
)}
</div>
{/* Trade 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>
)}
{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))
}
>
{fl.emoji}
</div>
))}
</>,
document.body,
)}
</div>
)
}