272 lines
8.7 KiB
TypeScript
272 lines
8.7 KiB
TypeScript
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>
|
||
)
|
||
}
|