Initial commit.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
||||
import { CardView } from './CardView'
|
||||
import { SUIT_EMOJI } from '../petArt'
|
||||
|
||||
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])
|
||||
|
||||
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) {
|
||||
send(msg)
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop">
|
||||
{/* Status line */}
|
||||
<div className="shop-status">
|
||||
{cleanup ? (
|
||||
excessPets > 0 ? (
|
||||
<span className="status-hot">
|
||||
Too many pets! Discard <strong>{excessPets}</strong> — they become
|
||||
apples 🍎
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">
|
||||
Waiting for {opponent?.name ?? 'opponent'} to discard 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: 'discard', cards: selected })}
|
||||
>
|
||||
Discard {excessPets} pet{excessPets > 1 ? 's' : ''} → 🍎
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={!myTurn || selected.length === 0}
|
||||
onClick={() => act({ type: 'discard', cards: selected })}
|
||||
title="Convert selected cards into apples (+1 power each)"
|
||||
>
|
||||
Discard {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 ? SUIT_EMOJI[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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user