Files
super-auto-pets-board-game/web/src/components/Table.tsx
T
2026-07-24 09:51:25 -04:00

242 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom'
import { useGame } from '../useGame'
import { useCatalog } from '../useCatalog'
import { CardView } from './CardView'
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 }
})
// A shop-phase peek at an opponent's deck from the previous round's battle
// (its arranged lineup is already public). Anchored under the clicked button.
const [deckPeek, setDeckPeek] = useState<{ seat: number; rect: DOMRect } | null>(null)
// 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>
)}
{/* Peek at the opponent's deck from last round's battle. */}
{view.phase === 'shop' &&
p.seat !== view.youSeat &&
(view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && (
<button
className={`chip chip-btn ${deckPeek?.seat === p.seat ? 'is-active' : ''}`}
title="See their deck from last round's battle"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
setDeckPeek((cur) => (cur?.seat === p.seat ? null : { seat: p.seat, rect }))
}}
>
👁 deck
</button>
)}
{(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} />
)}
{deckPeek &&
view.phase === 'shop' &&
(() => {
const lineup = view.battle?.lineups?.[deckPeek.seat] ?? []
if (!lineup.length) return null
const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent'
const left = Math.max(8, Math.min(deckPeek.rect.left, window.innerWidth - 380))
return createPortal(
<>
<div className="deck-peek-backdrop" onClick={() => setDeckPeek(null)} />
<div
className="deck-peek deck-peek-toolbar"
style={{ top: deckPeek.rect.bottom + 8, left }}
>
<div className="deck-peek-label">
{oppName}s deck last round · {lineup.length} card
{lineup.length !== 1 ? 's' : ''} (top first)
</div>
<div className="deck-peek-cards">
{lineup.map((c, i) => (
<CardView key={c.id || i} card={c} size="sm" />
))}
</div>
</div>
</>,
document.body,
)
})()}
</div>
)
}