242 lines
9.4 KiB
TypeScript
242 lines
9.4 KiB
TypeScript
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>
|
||
)
|
||
}
|