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

192 lines
7.2 KiB
TypeScript

import { useMemo, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { useGame } from '../useGame'
import { useCatalog } from '../useCatalog'
import type { Card, Session } from '../types'
import { Lobby } from './Lobby'
import { ShopPhase } from './ShopPhase'
import { ArrangePhase } from './ArrangePhase'
import { BattlePhase } from './BattlePhase'
import { GameOver } from './GameOver'
import { DebugPanel } from './DebugPanel'
import { EventLog, battleLogLines } from './EventLog'
// Table connects to the game and routes to the right phase screen.
export function Table({ session, onLeave }: { session: Session; onLeave: () => void }) {
const { view, error, connected, send } = useGame(session)
// Battle replay step lives here (not inside BattlePhase) so the event log,
// a sibling, can render the battle narration up to the same step. Deriving
// the effective step from the current battle round resets it to 0 whenever a
// new battle arrives, without a separate effect. These hooks must run on
// every render (before any early return) to satisfy the rules of hooks.
const battleRound = view?.battle?.round ?? -1
const [stepState, setStepState] = useState<{ round: number; step: number }>({
round: -1,
step: 0,
})
const step = stepState.round === battleRound ? stepState.step : 0
const setStep: Dispatch<SetStateAction<number>> = (upd) =>
setStepState((prev) => {
const cur = prev.round === battleRound ? prev.step : 0
const next = typeof upd === 'function' ? (upd as (n: number) => number)(cur) : upd
return { round: battleRound, step: next }
})
// The battle outcome is known before the replay plays out, so we hold its
// "result" log entry back: it's dropped from the persistent list during the
// battle and appended as the final battle line only once the replay reaches
// the end (and reappears in the persistent log in later phases).
const inBattle = view?.phase === 'battle'
const events = view?.battle?.events ?? null
const battleDone = !!(inBattle && events && step >= events.length)
const entries = useMemo(() => {
const log = view?.log ?? []
if (!inBattle) return log
return log.filter((e) => !(e.kind === 'result' && e.round === battleRound))
}, [view, inBattle, battleRound])
const battleLines = useMemo(() => {
if (!inBattle || !events) return undefined
const lines = battleLogLines(events, step)
if (battleDone) {
const result = (view?.log ?? []).find(
(e) => e.kind === 'result' && e.round === battleRound,
)
if (result) {
lines.push({
key: `result-${result.seq}`,
icon: result.icon,
text: result.text,
seat: result.seat,
})
}
}
return lines
}, [view, inBattle, events, step, battleDone, battleRound])
// Map every card name we know about to a representative card, so the event
// log can preview pets/foods it mentions on hover. The catalog covers all
// buyable pets and foods for the pack (even ones not currently in view);
// concrete cards from the view fill in tokens and summons (Bee, Apple, …)
// that never appear in the shop.
const catalog = useCatalog(view?.pack)
const cardLookup = useMemo(() => {
const map = new Map<string, Card>()
for (const c of catalog) if (c.name) map.set(c.name, c)
const add = (c?: Card) => {
if (c?.name && !map.has(c.name)) map.set(c.name, c)
}
if (view) {
view.shopRow?.forEach(add)
view.players?.forEach((p) => p.deck?.forEach(add))
view.battle?.lineups?.forEach((line) => line.forEach(add))
view.battle?.events?.forEach((ev) => add(ev.card))
}
return map
}, [catalog, view])
if (!view) {
return (
<div className="centered muted">
{connected ? 'Loading game…' : 'Connecting…'}
</div>
)
}
// A player the host removed from the lobby no longer has a seat.
if (view.youSeat < 0) {
return (
<div className="centered lobby-removed">
<div className="lobby-bounce" aria-hidden>
👋
</div>
<h2>You were removed from the game</h2>
<p className="muted">The host removed you from this lobby.</p>
<button className="btn btn-primary" onClick={onLeave}>
Back to home
</button>
</div>
)
}
const you = view.players[view.youSeat]
const opponents = view.players.filter((p) => p.seat !== view.youSeat)
return (
<div className="table">
<header className="topbar">
<div className="topbar-brand" title="Super Auto Pets: The Board Game">
🐾 <span>SAP</span>
</div>
{view.phase !== 'gameover' && view.phase !== 'lobby' && (
<div className="topbar-round">
Round <strong>{view.round}</strong> / {view.maxRounds}
</div>
)}
<div className="topbar-players">
{view.players.map((p) => (
<div
key={p.id}
className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`}
>
{p.isBot ? (
<span className="bot-dot" title="Computer player">
🤖
</span>
) : (
<span className={`conn-dot ${p.connected ? 'on' : 'off'}`} />
)}
<span className="topbar-name">{p.name}</span>
<span className="chip">🏆 {p.trophies}</span>
{/* Your own gold shows as big discs above the buy row (ShopPhase);
the opponent's stays as a compact chip here. */}
{view.phase === 'shop' && p.seat !== view.youSeat && (
<span className="chip">🪙 {p.coins}</span>
)}
{(p.avocados ?? 0) > 0 && (
<span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span>
)}
</div>
))}
</div>
<div className="topbar-code" title="Share this code with your opponent">
{view.code}
</div>
<button className="btn btn-ghost btn-sm" onClick={onLeave}>
Leave
</button>
</header>
<div className="table-body">
<main className="table-main">
{view.phase === 'lobby' && <Lobby view={view} send={send} />}
{view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />}
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
{view.phase === 'battle' && (
<BattlePhase view={view} send={send} step={step} setStep={setStep} />
)}
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
</main>
{view.phase !== 'lobby' && (
<EventLog
entries={entries}
youSeat={view.youSeat}
battleLines={battleLines}
cardLookup={cardLookup}
/>
)}
</div>
{opponents.some((p) => !p.connected && !p.isBot) && view.phase !== 'lobby' && (
<div className="banner banner-warn">An opponent is disconnected</div>
)}
{error && <div className="toast">{error}</div>}
{view.debug && (
<DebugPanel canGrant={view.phase === 'shop'} pack={view.pack} send={send} />
)}
</div>
)
}