Only buying costs gold; selling and trading (Triple) are free. Pass is now a final action, legal only at or under the pet limit: it forfeits remaining gold and ends that player's shopping for the round, with the shop closing once everyone has passed. That makes the separate cleanup phase unreachable (you sell down in-shop before passing), so it is removed. The client asks for confirmation before passing, and the bot knows buys are the only coin sink, when passing is legal, and that it must sell down before it can pass.
292 lines
9.3 KiB
TypeScript
292 lines
9.3 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 [confirmPass, setConfirmPass] = useState(false)
|
||
const myTurn = view.turn === view.youSeat && !you.ready
|
||
const canBuy = myTurn && you.coins > 0
|
||
const overPets = you.petCount > view.maxPets
|
||
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)
|
||
|
||
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">
|
||
{pending && !myPending ? (
|
||
<span className="muted">
|
||
{opponent?.name ?? 'Opponent'} is trading 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="status-hot">Your turn — buy, sell, trade, or pass</span>
|
||
) : (
|
||
<span className="muted">
|
||
{view.players[view.turn]?.name ?? 'Opponent'}’s turn…
|
||
</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">
|
||
{view.shopRow.map((c, i) =>
|
||
c.id ? (
|
||
<CardView
|
||
key={c.id}
|
||
card={c}
|
||
size="lg"
|
||
disabled={!canBuy}
|
||
onClick={canBuy ? () => act({ type: 'buy', row: i }) : undefined}
|
||
/>
|
||
) : (
|
||
<div key={`empty-${i}`} className="card-slot-empty" />
|
||
),
|
||
)}
|
||
</div>
|
||
{myTurn && (
|
||
<div className="hint">
|
||
{canBuy
|
||
? 'Tap a card to buy it for 1 🪙 — selling and trading are free'
|
||
: 'No coins left — you can still sell, trade, or pass'}
|
||
</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">
|
||
<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="Trade 3 same-suit pets for a pick from the next tier — free"
|
||
>
|
||
Trade 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"
|
||
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>
|
||
)}
|
||
|
||
{/* 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>
|
||
)
|
||
}
|