192 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|