Add support for up to 6 players.
This commit is contained in:
+5
-3
@@ -23,9 +23,11 @@ export function joinGame(code: string, name: string): Promise<Session> {
|
||||
return post('/api/join', { code, name })
|
||||
}
|
||||
|
||||
export async function fetchCatalog(pack?: string): Promise<Card[]> {
|
||||
const url = pack ? `/api/catalog?pack=${encodeURIComponent(pack)}` : '/api/catalog'
|
||||
const res = await fetch(url)
|
||||
export async function fetchCatalog(packs?: string[]): Promise<Card[]> {
|
||||
const query = (packs ?? [])
|
||||
.map((p) => `pack=${encodeURIComponent(p)}`)
|
||||
.join('&')
|
||||
const res = await fetch(query ? `/api/catalog?${query}` : '/api/catalog')
|
||||
if (!res.ok) throw new Error('failed to load catalog')
|
||||
return (await res.json()) as Card[]
|
||||
}
|
||||
|
||||
@@ -313,14 +313,18 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
return n
|
||||
})()
|
||||
|
||||
const opponent = view.players.find((p) => p.seat !== view.youSeat)
|
||||
// Everyone arranges at the same time, so the wait is on whoever is left.
|
||||
const waitingOn = view.players.filter((p) => p.seat !== view.youSeat && !p.ready)
|
||||
const rival = view.players.find((p) => p.seat === view.yourOpponent)
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<div className="centered">
|
||||
<h2>Order locked in ⚔️</h2>
|
||||
<p className="muted">
|
||||
Waiting for {opponent?.name ?? 'your opponent'} to arrange their deck…
|
||||
{waitingOn.length === 1
|
||||
? `Waiting for ${waitingOn[0].name} to arrange their deck…`
|
||||
: `Waiting for ${waitingOn.length} more players to arrange their decks…`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -329,7 +333,12 @@ export function ArrangePhase({ view, you, send }: Props) {
|
||||
return (
|
||||
<div className="arrange">
|
||||
<div className="shop-status">
|
||||
<span className="status-hot">Arrange your battle line</span>
|
||||
<span className="status-hot">
|
||||
Arrange your battle line
|
||||
{/* Who you face is public (the pairings are printed in the rulebook),
|
||||
and at a bigger table it's a different rival every round. */}
|
||||
{rival && <> — you fight {rival.name} this round</>}
|
||||
</span>
|
||||
</div>
|
||||
<p className="hint">
|
||||
The <strong>topmost</strong> card fights first. Food cards power up the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
|
||||
import type { BattleEvent, BattleResult, Card, ClientMessage, GameView } from '../types'
|
||||
import { sideOf } from '../types'
|
||||
import { CardView, CardZoom } from './CardView'
|
||||
import { DiceRoll, ROLL_MS } from './DiceRoll'
|
||||
import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed'
|
||||
@@ -17,6 +18,13 @@ interface Props {
|
||||
// Step is owned by Table so the event log can stay in sync with the replay.
|
||||
step: number
|
||||
setStep: Dispatch<SetStateAction<number>>
|
||||
// The battle being replayed, and the switcher for picking another table's.
|
||||
// With more than two players several battles resolve at once and any of them
|
||||
// can be watched; at two players there is only ever one.
|
||||
battle: BattleResult
|
||||
battles: BattleResult[]
|
||||
selected: number
|
||||
onSelect: (idx: number) => void
|
||||
}
|
||||
|
||||
interface UnitVis {
|
||||
@@ -245,40 +253,40 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
||||
|
||||
// unitPop decides the floating effect text over a seat's pet for the event
|
||||
// currently playing. Null = nothing.
|
||||
function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], step: number): string | null {
|
||||
function unitPop(ev: BattleEvent | null, side: number, events: BattleEvent[], step: number): string | null {
|
||||
if (!ev) return null
|
||||
switch (ev.type) {
|
||||
case 'rock':
|
||||
if (ev.target !== seat) return null
|
||||
if (ev.target !== side) return null
|
||||
return ev.roll === 0 ? 'miss!' : `−${ev.roll}`
|
||||
case 'clash': {
|
||||
const taken = clashDamageTaken(events, step - 1, seat)
|
||||
const taken = clashDamageTaken(events, step - 1, side)
|
||||
return taken > 0 ? `−${taken}` : null
|
||||
}
|
||||
case 'shield':
|
||||
return ev.seat === seat ? '🛡️' : null
|
||||
return ev.seat === side ? '🛡️' : null
|
||||
case 'prevent':
|
||||
return ev.seat === seat ? `🛡️ −${ev.count}` : null
|
||||
return ev.seat === side ? `🛡️ −${ev.count}` : null
|
||||
case 'trumpet':
|
||||
if (ev.seat !== seat) return null
|
||||
if (ev.seat !== side) return null
|
||||
return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}`
|
||||
case 'mana':
|
||||
if (ev.seat !== seat) return null
|
||||
if (ev.seat !== side) return null
|
||||
return (ev.count ?? 0) >= 0 ? `🔮 +${ev.count}` : `🔮 ${ev.count}`
|
||||
case 'ailment':
|
||||
if (ev.seat !== seat) return null
|
||||
if (ev.seat !== side) return null
|
||||
return ev.card?.ailment === 'spooked' ? '👻' : '🎯'
|
||||
case 'bounce':
|
||||
return ev.target === seat ? '🌀' : null
|
||||
return ev.target === side ? '🌀' : null
|
||||
case 'eat':
|
||||
return ev.seat === seat ? '🍎' : null
|
||||
return ev.seat === side ? '🍎' : null
|
||||
case 'heal':
|
||||
return ev.seat === seat ? '💚 +1' : null
|
||||
return ev.seat === side ? '💚 +1' : null
|
||||
case 'strip':
|
||||
return ev.target === seat ? '💨' : null
|
||||
return ev.target === side ? '💨' : null
|
||||
case 'steal':
|
||||
if (ev.seat === seat) return `+🍎×${ev.count}`
|
||||
if (ev.target === seat) return `−🍎×${ev.count}`
|
||||
if (ev.seat === side) return `+🍎×${ev.count}`
|
||||
if (ev.target === side) return `−🍎×${ev.count}`
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
@@ -287,8 +295,16 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
|
||||
|
||||
// BattlePhase plays back the battle log: cards flip off each deck, rocks
|
||||
// fly, pets clash, the fallen fade out, then the round result lands.
|
||||
export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
const battle = view.battle!
|
||||
export function BattlePhase({
|
||||
view,
|
||||
send,
|
||||
step,
|
||||
setStep,
|
||||
battle,
|
||||
battles,
|
||||
selected,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const events = battle.events ?? []
|
||||
// acked = the player committed to the next round (waiting on the opponent).
|
||||
const [acked, setAcked] = useState(false)
|
||||
@@ -299,7 +315,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
// and hides the other — so an absolutely-positioned popover gets clipped to
|
||||
// the battlefield. We anchor it to the hovered stack's viewport rect and
|
||||
// portal it to <body> so it floats over the whole window instead.
|
||||
const [peek, setPeek] = useState<{ seat: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
|
||||
const [peek, setPeek] = useState<{ side: number; pos: 'top' | 'bottom'; rect: DOMRect } | null>(
|
||||
null,
|
||||
)
|
||||
const lineups = battle.lineups
|
||||
@@ -375,12 +391,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
[events, battle.stackSizes, upto],
|
||||
)
|
||||
|
||||
const youSeat = view.youSeat
|
||||
const oppSeat = view.players.find((p) => p.seat !== youSeat)?.seat ?? 1
|
||||
const you = view.players[youSeat]
|
||||
const opp = view.players[oppSeat]
|
||||
const won = battle.winnerSeat === youSeat
|
||||
const draw = battle.winnerSeat < 0
|
||||
// Everything inside a battle is indexed by side (0 or 1), not by seat, so the
|
||||
// arena works in sides and maps out to players only for names and results.
|
||||
// When you're watching someone else's table you have no side in it: side 1
|
||||
// takes the bottom half so the board still reads as two halves facing off.
|
||||
const seatAt = (side: number) => view.players.find((p) => p.seat === battle.seats?.[side])
|
||||
const mySide = sideOf(battle, view.youSeat)
|
||||
const spectating = mySide < 0
|
||||
const bottomSide = spectating ? 1 : mySide
|
||||
const topSide = 1 - bottomSide
|
||||
const bottom = seatAt(bottomSide)
|
||||
const top = seatAt(topSide)
|
||||
|
||||
// The result banner and the round hand-off always speak about *your* battle,
|
||||
// even while you're watching another table play out.
|
||||
const ownBattle = battles.find((b) => sideOf(b, view.youSeat) >= 0) ?? battle
|
||||
const won = ownBattle.winnerSeat === view.youSeat
|
||||
const draw = ownBattle.winnerSeat < 0
|
||||
|
||||
// Commit to the next round: ack and hand off to the server.
|
||||
const proceed = () => {
|
||||
@@ -405,19 +432,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
// two pets meet at a horizontal clash line. Each half is a row —
|
||||
// [set-aside | pet | apples] — with the deck under the pet (bottom) or over
|
||||
// it (top). Set-aside stays on the left and apples on the right for both.
|
||||
function renderSide(seat: number, pos: 'top' | 'bottom') {
|
||||
const s = sides[seat]
|
||||
function renderSide(side: number, pos: 'top' | 'bottom') {
|
||||
const s = sides[side]
|
||||
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
|
||||
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
|
||||
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat
|
||||
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
|
||||
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat
|
||||
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
|
||||
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === side
|
||||
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === side
|
||||
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === side
|
||||
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === side
|
||||
// A food (apple) that just landed on this side's fan — reveal off the deck or
|
||||
// a Battle Prep hand-out — animates in from the deck rather than popping.
|
||||
const newFoodId =
|
||||
!done &&
|
||||
lastEvent?.seat === seat &&
|
||||
lastEvent?.seat === side &&
|
||||
(lastEvent.type === 'prep' ||
|
||||
(lastEvent.type === 'reveal' &&
|
||||
(lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment')))
|
||||
@@ -425,23 +452,23 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
: null
|
||||
// A pet just set aside slides into the set-aside row beside the arena.
|
||||
const newSetAsideId =
|
||||
!done && lastEvent?.type === 'setaside' && lastEvent.seat === seat
|
||||
!done && lastEvent?.type === 'setaside' && lastEvent.seat === side
|
||||
? lastEvent.card?.id
|
||||
: null
|
||||
// Hold the −N / miss! pop until the dice settle.
|
||||
const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, seat, events, step)
|
||||
const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, side, events, step)
|
||||
|
||||
const lineup = lineups?.[seat] ?? []
|
||||
const lineup = lineups?.[side] ?? []
|
||||
const stackEl = (
|
||||
<div className={`battle-deck battle-deck-${pos}`}>
|
||||
<div
|
||||
className={`stackpile ${lineup.length ? 'peekable' : ''}`}
|
||||
onMouseEnter={
|
||||
lineup.length
|
||||
? (e) => setPeek({ seat, pos, rect: e.currentTarget.getBoundingClientRect() })
|
||||
? (e) => setPeek({ side, pos, rect: e.currentTarget.getBoundingClientRect() })
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={() => setPeek((p) => (p?.seat === seat ? null : p))}
|
||||
onMouseLeave={() => setPeek((p) => (p?.side === side ? null : p))}
|
||||
title={lineup.length ? 'Hover to see the whole deck' : undefined}
|
||||
>
|
||||
{s.stack > 0 ? (
|
||||
@@ -464,7 +491,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
<CardView card={lastEvent.card} size="sm" dead />
|
||||
</div>
|
||||
)}
|
||||
{rockDice && lastEvent?.seat === seat && (
|
||||
{rockDice && lastEvent?.seat === side && (
|
||||
// The dice roll sits beside the throwing side's deck.
|
||||
<div className="stack-dice">
|
||||
<DiceRoll key={step} dice={rockDice} side={pos} speed={speed} />
|
||||
@@ -602,7 +629,9 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
return (
|
||||
<div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}>
|
||||
<div className="battle-header">
|
||||
<h2>Battle! Round {battle.round}</h2>
|
||||
<h2>
|
||||
{spectating ? 'Watching' : 'Battle!'} Round {battle.round}
|
||||
</h2>
|
||||
<div className="battle-controls">
|
||||
<button className="btn btn-ghost btn-sm" onClick={restart} title="Replay from the start">
|
||||
⏮
|
||||
@@ -667,14 +696,43 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* With four or six players several battles resolve at once. Tabs switch
|
||||
the arena between them so you can watch the whole field, not just your
|
||||
own fight; at two players there's only one battle and no tab bar. */}
|
||||
{battles.length > 1 && (
|
||||
<div className="battle-tabs" role="tablist" aria-label="Battles this round">
|
||||
{battles.map((b, i) => {
|
||||
const mine = sideOf(b, view.youSeat) >= 0
|
||||
const names = (b.seats ?? []).map(
|
||||
(s) => view.players.find((p) => p.seat === s)?.name ?? '?',
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
role="tab"
|
||||
aria-selected={i === selected}
|
||||
className={`battle-tab ${i === selected ? 'is-active' : ''} ${mine ? 'is-mine' : ''}`}
|
||||
onClick={() => onSelect(i)}
|
||||
title={mine ? 'Your battle' : `Watch ${names.join(' vs ')}`}
|
||||
>
|
||||
{mine ? 'Your fight' : names.join(' vs ')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="battle-names">
|
||||
<span>{you?.name} (you)</span>
|
||||
<span>
|
||||
{bottom?.name}
|
||||
{!spectating && ' (you)'}
|
||||
</span>
|
||||
<span className="battle-vs">VS</span>
|
||||
<span>{opp?.name}</span>
|
||||
<span>{top?.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="battlefield">
|
||||
{renderSide(oppSeat, 'top')}
|
||||
{renderSide(topSide, 'top')}
|
||||
<div
|
||||
key={centerClash ? `clash-${step}` : 'center'}
|
||||
className={`battle-center ${centerClash ? 'battle-center-clash' : ''}`}
|
||||
@@ -682,12 +740,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
>
|
||||
<span className="battle-center-bolt">⚡</span>
|
||||
</div>
|
||||
{renderSide(youSeat, 'bottom')}
|
||||
{renderSide(bottomSide, 'bottom')}
|
||||
</div>
|
||||
|
||||
{peek &&
|
||||
(() => {
|
||||
const lineup = lineups?.[peek.seat] ?? []
|
||||
const lineup = lineups?.[peek.side] ?? []
|
||||
if (!lineup.length) return null
|
||||
// Your deck sits at the bottom of the board, so float the peek above
|
||||
// it; the rival's sits at the top, so float it below.
|
||||
@@ -699,13 +757,14 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
? { bottom: window.innerHeight - peek.rect.top + 8 }
|
||||
: { top: peek.rect.bottom + 8 }),
|
||||
}
|
||||
const mine = peek.seat === youSeat
|
||||
const owner = seatAt(peek.side)
|
||||
const whose = owner?.seat === view.youSeat ? 'Your' : `${owner?.name ?? 'Their'}’s`
|
||||
// Each player's first pet is the one nearest the clash line; show the
|
||||
// lineup first-to-last, left to right, for both.
|
||||
return createPortal(
|
||||
<div className={`deck-peek deck-peek-${peek.pos}`} style={style}>
|
||||
<div className="deck-peek-label">
|
||||
{mine ? 'Your' : 'Opponent’s'} deck · {lineup.length} card
|
||||
{whose} deck · {lineup.length} card
|
||||
{lineup.length !== 1 ? 's' : ''} (first on the left)
|
||||
</div>
|
||||
<div className="deck-peek-cards first-left">
|
||||
@@ -731,13 +790,37 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
</div>
|
||||
{!draw ? (
|
||||
<div className="battle-result-sub">
|
||||
{view.players[battle.winnerSeat]?.name} wins {'🏆'.repeat(battle.trophies)}
|
||||
{view.players.find((p) => p.seat === ownBattle.winnerSeat)?.name} wins{' '}
|
||||
{'🏆'.repeat(ownBattle.trophies)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="battle-result-sub">No trophies awarded</div>
|
||||
)}
|
||||
{/* At a bigger table the rest of the field matters as much as your
|
||||
own result, so the round's other tables are summarised here. */}
|
||||
{battles.length > 1 && (
|
||||
<div className="battle-result-others">
|
||||
<div className="battle-result-others-title">Elsewhere this round</div>
|
||||
{battles
|
||||
.filter((b) => b !== ownBattle)
|
||||
.map((b, i) => {
|
||||
const winner = view.players.find((p) => p.seat === b.winnerSeat)
|
||||
const names = (b.seats ?? []).map(
|
||||
(s) => view.players.find((p) => p.seat === s)?.name ?? '?',
|
||||
)
|
||||
return (
|
||||
<div key={i} className="battle-result-other">
|
||||
<span>{names.join(' vs ')}</span>
|
||||
<span className="muted">
|
||||
{winner ? `${winner.name} ${'🏆'.repeat(b.trophies)}` : 'draw'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{acked ? (
|
||||
<p className="muted">Waiting for opponent…</p>
|
||||
<p className="muted">Waiting for the other players…</p>
|
||||
) : (
|
||||
<div className="actions">
|
||||
<button className="btn btn-ghost" onClick={() => setResultDismissed(true)}>
|
||||
@@ -768,21 +851,21 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
|
||||
|
||||
// clashDamageTaken computes how much damage a seat's pet took in the clash
|
||||
// at event index `idx` (its damage total there minus its total beforehand).
|
||||
function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number {
|
||||
function clashDamageTaken(events: BattleEvent[], idx: number, side: number): number {
|
||||
const ev = events[idx]
|
||||
if (ev?.type !== 'clash') return 0
|
||||
const after = ev.damage?.[seat] ?? 0
|
||||
const after = ev.damage?.[side] ?? 0
|
||||
// Walk back to the pet's damage before this clash.
|
||||
let before = 0
|
||||
for (let k = idx - 1; k >= 0; k--) {
|
||||
const e = events[k]
|
||||
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break
|
||||
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === seat) {
|
||||
if (e.type === 'reveal' && e.seat === side && e.card?.kind === 'pet') break
|
||||
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === side) {
|
||||
before = e.damageAfter ?? 0
|
||||
break
|
||||
}
|
||||
if (e.type === 'clash') {
|
||||
before = e.damage?.[seat] ?? 0
|
||||
before = e.damage?.[side] ?? 0
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,24 @@ import { CardView } from './CardView'
|
||||
|
||||
interface Props {
|
||||
canGrant: boolean // shop phase — grants only land then
|
||||
pack: string // active pack, so the catalog matches the game
|
||||
packs: string[] // packs in play, so the catalog matches the game
|
||||
send: (msg: ClientMessage) => void
|
||||
}
|
||||
|
||||
// DebugPanel is a testing aid (server DEBUG mode only): a collapsible drawer
|
||||
// listing every card in the active pack, tier by tier. Clicking one drops it
|
||||
// listing every card in the packs in play, tier by tier. Clicking one drops it
|
||||
// into your deck for free, off-turn.
|
||||
export function DebugPanel({ canGrant, pack, send }: Props) {
|
||||
export function DebugPanel({ canGrant, packs, send }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [catalog, setCatalog] = useState<Card[]>([])
|
||||
|
||||
// Joined into a stable key so a fresh array identity each render doesn't refetch.
|
||||
const packKey = packs.join(',')
|
||||
useEffect(() => {
|
||||
fetchCatalog(pack)
|
||||
fetchCatalog(packKey.split(','))
|
||||
.then(setCatalog)
|
||||
.catch(() => setCatalog([]))
|
||||
}, [pack])
|
||||
}, [packKey])
|
||||
|
||||
const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b)
|
||||
|
||||
|
||||
@@ -1,31 +1,64 @@
|
||||
import type { GameView } from '../types'
|
||||
|
||||
export function GameOver({ view, onLeave }: { view: GameView; onLeave: () => void }) {
|
||||
const winner = view.winnerSeat >= 0 ? view.players[view.winnerSeat] : null
|
||||
const youWon = view.winnerSeat === view.youSeat
|
||||
// The title can be shared: players level on trophies whose round-by-round
|
||||
// records are also identical split it (the rulebook's "share that victory!").
|
||||
const winners = view.winnerSeats ?? (view.winnerSeat >= 0 ? [view.winnerSeat] : [])
|
||||
const youWon = winners.includes(view.youSeat)
|
||||
const shared = winners.length > 1
|
||||
const winnerNames = winners
|
||||
.map((s) => view.players.find((p) => p.seat === s)?.name ?? '?')
|
||||
.join(' & ')
|
||||
|
||||
const title = !winners.length
|
||||
? "It's a tie!"
|
||||
: youWon
|
||||
? shared
|
||||
? 'You share the win!'
|
||||
: 'You win!'
|
||||
: shared
|
||||
? `${winnerNames} share the win!`
|
||||
: `${winnerNames} wins!`
|
||||
|
||||
// Standings run by trophies, and the countback that decided any tie is worth
|
||||
// showing: which rounds each player actually took.
|
||||
const standings = [...view.players].sort(
|
||||
(a, b) => b.trophies - a.trophies || a.seat - b.seat,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="gameover">
|
||||
<div className="gameover-emoji" aria-hidden>
|
||||
{winner ? (youWon ? '🎉' : '💀') : '🤝'}
|
||||
{!winners.length ? '🤝' : youWon ? '🎉' : shared ? '🤝' : '💀'}
|
||||
</div>
|
||||
<h1 className="gameover-title">
|
||||
{winner ? (youWon ? 'You win!' : `${winner.name} wins!`) : "It's a tie!"}
|
||||
</h1>
|
||||
<h1 className="gameover-title">{title}</h1>
|
||||
<div className="gameover-scores">
|
||||
{[...view.players]
|
||||
.sort((a, b) => b.trophies - a.trophies)
|
||||
.map((p) => (
|
||||
<div key={p.id} className={`score-line ${p.seat === view.youSeat ? 'is-you' : ''}`}>
|
||||
<span className="score-name">
|
||||
{p.name}
|
||||
{p.seat === view.youSeat ? ' (you)' : ''}
|
||||
{standings.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={[
|
||||
'score-line',
|
||||
p.seat === view.youSeat ? 'is-you' : '',
|
||||
winners.includes(p.seat) ? 'is-winner' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className="score-name">
|
||||
{winners.includes(p.seat) && <span aria-label="winner">👑 </span>}
|
||||
{p.name}
|
||||
{p.seat === view.youSeat ? ' (you)' : ''}
|
||||
</span>
|
||||
{(p.roundWins?.length ?? 0) > 0 && (
|
||||
<span className="score-rounds muted" title="Rounds won">
|
||||
won {p.roundWins!.map((r) => `R${r}`).join(' ')}
|
||||
</span>
|
||||
<span className="score-trophies">
|
||||
{'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
<span className="score-trophies">
|
||||
{'🏆'.repeat(p.trophies) || '—'} <strong>{p.trophies}</strong>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-primary btn-big" onClick={onLeave}>
|
||||
Back to the den
|
||||
|
||||
@@ -6,9 +6,13 @@ const BOT_LEVELS: { value: string; label: string; blurb: string }[] = [
|
||||
{ value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' },
|
||||
]
|
||||
|
||||
// Lobby is the pre-game setup screen. The host picks a pack, fills the second
|
||||
// seat with a bot or a friend, can remove players, and starts the game.
|
||||
// Lobby is the pre-game setup screen. The host picks the packs, fills seats
|
||||
// with friends or computer players, can remove anyone, and starts the game.
|
||||
// Everyone else sees the same lineup read-only and waits for the host.
|
||||
//
|
||||
// Two rules gate the start button, and the host needs to see both at a glance:
|
||||
// the table has to be an even size (players battle in pairs every round), and
|
||||
// the rulebook asks for one card pack per pair.
|
||||
export function Lobby({
|
||||
view,
|
||||
send,
|
||||
@@ -17,10 +21,33 @@ export function Lobby({
|
||||
send: (m: ClientMessage) => void
|
||||
}) {
|
||||
const isHost = view.youSeat === view.hostSeat
|
||||
const openSeats = view.maxPlayers - view.players.length
|
||||
const selectedPlayable =
|
||||
view.packs.find((p) => p.id === view.pack)?.playable ?? false
|
||||
const canStart = view.players.length >= view.minPlayers && selectedPlayable
|
||||
const seated = view.players.length
|
||||
const openSeats = view.maxPlayers - seated
|
||||
const packs = view.packs ?? []
|
||||
|
||||
const evenTable = view.playerCounts.includes(seated)
|
||||
const enoughPacks = packs.length >= view.packsNeeded
|
||||
const allPlayable = packs.every(
|
||||
(id) => view.packCatalog.find((p) => p.id === id)?.playable ?? false,
|
||||
)
|
||||
const canStart = evenTable && enoughPacks && allPlayable
|
||||
|
||||
// Toggling a pack sends the whole new selection — the server validates it as
|
||||
// a set, so there's no partial state to get stuck in.
|
||||
const togglePack = (id: string) => {
|
||||
const next = packs.includes(id) ? packs.filter((p) => p !== id) : [...packs, id]
|
||||
if (next.length === 0) return // there's always at least one pack in play
|
||||
send({ type: 'setPacks', packs: next })
|
||||
}
|
||||
|
||||
// The one thing standing between the host and a game, in their words.
|
||||
const blocker = !evenTable
|
||||
? seated < view.minPlayers
|
||||
? 'Waiting for a second player…'
|
||||
: `${seated} players can’t pair off — add or remove a seat`
|
||||
: !enoughPacks
|
||||
? `${seated} players needs ${view.packsNeeded} packs shuffled together`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="lobby">
|
||||
@@ -38,41 +65,66 @@ export function Lobby({
|
||||
</div>
|
||||
|
||||
<section className="lobby-section">
|
||||
<h3 className="lobby-section-title">Card pack</h3>
|
||||
<h3 className="lobby-section-title">
|
||||
Card packs ({packs.length}/{view.packCatalog.length})
|
||||
</h3>
|
||||
<p className="muted lobby-note">
|
||||
{view.packsNeeded > 1
|
||||
? `Every pack’s tier 1 cards shuffle together, tier 2 together, and so on. ${seated} players needs at least ${view.packsNeeded}.`
|
||||
: 'Pick one, or combine several for a deeper shop.'}
|
||||
</p>
|
||||
<div className="pack-grid">
|
||||
{view.packs.map((pack) => {
|
||||
const selected = pack.id === view.pack
|
||||
const disabled = !pack.playable || !isHost
|
||||
{view.packCatalog.map((pack) => {
|
||||
const selected = packs.includes(pack.id)
|
||||
const onlyOne = selected && packs.length === 1
|
||||
return (
|
||||
<button
|
||||
key={pack.id}
|
||||
className={`pack-card ${selected ? 'is-selected' : ''} ${
|
||||
pack.playable ? '' : 'is-locked'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
title={pack.playable ? pack.name : `${pack.name} — coming soon`}
|
||||
onClick={() => send({ type: 'setPack', pack: pack.id })}
|
||||
disabled={!pack.playable || !isHost || onlyOne}
|
||||
title={
|
||||
!pack.playable
|
||||
? `${pack.name} — coming soon`
|
||||
: onlyOne
|
||||
? 'At least one pack has to stay in play'
|
||||
: selected
|
||||
? `Remove ${pack.name}`
|
||||
: `Add ${pack.name}`
|
||||
}
|
||||
onClick={() => togglePack(pack.id)}
|
||||
>
|
||||
<span className="pack-emoji" aria-hidden>
|
||||
{pack.emoji}
|
||||
</span>
|
||||
<span className="pack-name">{pack.name}</span>
|
||||
<span className="pack-tag">
|
||||
{pack.playable ? (selected ? 'Selected' : 'Available') : 'Coming soon'}
|
||||
{!pack.playable ? 'Coming soon' : selected ? '✓ In play' : 'Add'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!isHost && (
|
||||
<p className="muted lobby-note">Only the host can change the pack.</p>
|
||||
<p className="muted lobby-note">Only the host can change the packs.</p>
|
||||
)}
|
||||
{isHost && !enoughPacks && (
|
||||
<p className="lobby-warn">
|
||||
Add {view.packsNeeded - packs.length} more pack
|
||||
{view.packsNeeded - packs.length !== 1 ? 's' : ''} to seat {seated} players.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="lobby-section">
|
||||
<h3 className="lobby-section-title">
|
||||
Players ({view.players.length}/{view.maxPlayers})
|
||||
Players ({seated}/{view.maxPlayers})
|
||||
</h3>
|
||||
<p className="muted lobby-note">
|
||||
Play happens in pairs, so the table needs {view.playerCounts.join(', ')} players.
|
||||
Fill any odd seat with a computer player.
|
||||
</p>
|
||||
<ul className="seat-list">
|
||||
{view.players.map((p) => (
|
||||
<SeatRow
|
||||
@@ -85,12 +137,13 @@ export function Lobby({
|
||||
/>
|
||||
))}
|
||||
|
||||
{Array.from({ length: openSeats }).map((_, i) => (
|
||||
<li key={`open-${i}`} className="seat-row seat-open">
|
||||
{openSeats > 0 && (
|
||||
<li className="seat-row seat-open">
|
||||
{isHost ? (
|
||||
<div className="seat-open-host">
|
||||
<span className="muted">
|
||||
Add a computer opponent, or share the code to invite a friend:
|
||||
{openSeats} open seat{openSeats !== 1 ? 's' : ''} — add a computer
|
||||
player, or share the code to invite a friend:
|
||||
</span>
|
||||
<div className="seat-bot-picker">
|
||||
{BOT_LEVELS.map((b) => (
|
||||
@@ -106,10 +159,12 @@ export function Lobby({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted">Waiting for the host to fill this seat…</span>
|
||||
<span className="muted">
|
||||
{openSeats} open seat{openSeats !== 1 ? 's' : ''} — waiting for the host…
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -119,9 +174,7 @@ export function Lobby({
|
||||
disabled={!canStart}
|
||||
onClick={() => send({ type: 'start' })}
|
||||
>
|
||||
{view.players.length < view.minPlayers
|
||||
? 'Waiting for a second player…'
|
||||
: 'Start game'}
|
||||
{blocker ?? 'Start game'}
|
||||
</button>
|
||||
) : (
|
||||
<p className="lobby-waiting muted">Waiting for the host to start the game…</p>
|
||||
|
||||
@@ -122,7 +122,10 @@ export function ShopPhase({ view, you, send }: Props) {
|
||||
const totalCoins = purse.current.max
|
||||
|
||||
const deck = you.deck ?? []
|
||||
const opponent = view.players.find((p) => p.seat !== view.youSeat)
|
||||
// Whoever the shop is currently waiting on, by name — at a bigger table
|
||||
// that's a specific player taking their turn, not "the opponent".
|
||||
const acting = view.players.find((p) => p.seat === view.turn)
|
||||
const stillShopping = view.players.filter((p) => p.seat !== view.youSeat && !p.ready)
|
||||
const pending = view.pending
|
||||
const myPending = pending?.playerId === you.id
|
||||
|
||||
@@ -263,12 +266,15 @@ export function ShopPhase({ view, you, send }: Props) {
|
||||
<div className="shop-status">
|
||||
{pending && !myPending ? (
|
||||
<span className="muted">
|
||||
{opponent?.name ?? 'Opponent'} is tripling up a tier…
|
||||
{acting?.name ?? 'Someone'} is tripling up a tier…
|
||||
</span>
|
||||
) : you.ready ? (
|
||||
<span className="muted">
|
||||
You passed — waiting for {opponent?.name ?? 'opponent'} to finish
|
||||
shopping…
|
||||
You passed — waiting for{' '}
|
||||
{stillShopping.length === 1
|
||||
? stillShopping[0].name
|
||||
: `${stillShopping.length} more players`}{' '}
|
||||
to finish shopping…
|
||||
</span>
|
||||
) : myTurn && overPets ? (
|
||||
<span className="status-hot">
|
||||
|
||||
@@ -17,23 +17,40 @@ import { EventLog, battleLogLines } from './EventLog'
|
||||
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,
|
||||
// A round runs one battle per pairing, so with four or six players there are
|
||||
// several to watch. Which one is on screen lives here, alongside the replay
|
||||
// step, because the event log is a sibling and narrates whichever battle is
|
||||
// playing. Both reset when a new round's battles arrive.
|
||||
//
|
||||
// These hooks must run on every render (before any early return) to satisfy
|
||||
// the rules of hooks.
|
||||
const battles = view?.battles ?? []
|
||||
const battleRound = battles[0]?.round ?? -1
|
||||
// Your own fight is what a round opens on; spectators of a game they aren't
|
||||
// seated in (or a malformed round) fall back to the first table.
|
||||
const ownIdx = Math.max(
|
||||
0,
|
||||
battles.findIndex((b) => (b.seats ?? []).includes(view?.youSeat ?? -1)),
|
||||
)
|
||||
const [sel, setSel] = useState<{ round: number; idx: number }>({ round: -1, idx: 0 })
|
||||
const selected = sel.round === battleRound ? Math.min(sel.idx, battles.length - 1) : ownIdx
|
||||
const battle = battles[selected]
|
||||
|
||||
// Deriving the effective step from the round and the selected battle resets
|
||||
// it to 0 whenever either changes, without a separate effect.
|
||||
const [stepState, setStepState] = useState<{ key: string; step: number }>({
|
||||
key: '',
|
||||
step: 0,
|
||||
})
|
||||
const step = stepState.round === battleRound ? stepState.step : 0
|
||||
const stepKey = `${battleRound}:${selected}`
|
||||
const step = stepState.key === stepKey ? stepState.step : 0
|
||||
const setStep: Dispatch<SetStateAction<number>> = (upd) =>
|
||||
setStepState((prev) => {
|
||||
const cur = prev.round === battleRound ? prev.step : 0
|
||||
const cur = prev.key === stepKey ? prev.step : 0
|
||||
const next = typeof upd === 'function' ? (upd as (n: number) => number)(cur) : upd
|
||||
return { round: battleRound, step: next }
|
||||
return { key: stepKey, step: next }
|
||||
})
|
||||
const selectBattle = (idx: number) => setSel({ round: battleRound, idx })
|
||||
|
||||
// A shop-phase peek at an opponent's deck from the previous round's battle
|
||||
// (its arranged lineup is already public). Shown as a centered modal.
|
||||
@@ -53,7 +70,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
// 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 events = battle?.events ?? null
|
||||
const battleDone = !!(inBattle && events && step >= events.length)
|
||||
|
||||
const entries = useMemo(() => {
|
||||
@@ -66,9 +83,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
if (!inBattle || !events) return undefined
|
||||
const lines = battleLogLines(events, step)
|
||||
if (battleDone) {
|
||||
const result = (view?.log ?? []).find(
|
||||
// The engine logs one result per battle, in the same order it resolves
|
||||
// them into view.battles — so the selected battle's line is at the same
|
||||
// index among the round's results.
|
||||
const result = (view?.log ?? []).filter(
|
||||
(e) => e.kind === 'result' && e.round === battleRound,
|
||||
)
|
||||
)[selected]
|
||||
if (result) {
|
||||
lines.push({
|
||||
key: `result-${result.seq}`,
|
||||
@@ -79,14 +99,14 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}, [view, inBattle, events, step, battleDone, battleRound])
|
||||
}, [view, inBattle, events, step, battleDone, battleRound, selected])
|
||||
|
||||
// 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)
|
||||
// buyable pets and foods across the packs in play (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?.packs)
|
||||
const cardLookup = useMemo(() => {
|
||||
const map = new Map<string, Card>()
|
||||
for (const c of catalog) if (c.name) map.set(c.name, c)
|
||||
@@ -96,8 +116,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
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))
|
||||
view.battles?.forEach((b) => {
|
||||
b.lineups?.forEach((line) => line?.forEach(add))
|
||||
b.events?.forEach((ev) => add(ev.card))
|
||||
})
|
||||
}
|
||||
return map
|
||||
}, [catalog, view])
|
||||
@@ -129,6 +151,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
const you = view.players[view.youSeat]
|
||||
const opponents = view.players.filter((p) => p.seat !== view.youSeat)
|
||||
|
||||
// Last round's lineups are public, so any player's deck can be peeked at
|
||||
// during the shop — whichever table they fought at. Indexed by seat here;
|
||||
// inside a battle result the lineups are indexed by side.
|
||||
const lastLineups = new Map<number, Card[]>()
|
||||
for (const b of battles) {
|
||||
;(b.seats ?? []).forEach((seat, side) => {
|
||||
const line = b.lineups?.[side]
|
||||
if (line?.length) lastLineups.set(seat, line)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table">
|
||||
<header className="topbar">
|
||||
@@ -144,11 +177,21 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
<DieFace value={view.round} className="topbar-die" />
|
||||
</div>
|
||||
)}
|
||||
<div className="topbar-players">
|
||||
{/* Up to six seats ride here, so each one stays terse: the extra chips
|
||||
only appear when they have something to say, and this round's
|
||||
opponent is flagged so you know who you're preparing for. */}
|
||||
<div className={`topbar-players count-${view.players.length}`}>
|
||||
{view.players.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`}
|
||||
className={[
|
||||
'topbar-player',
|
||||
p.seat === view.youSeat ? 'is-you' : '',
|
||||
p.seat === view.yourOpponent ? 'is-rival' : '',
|
||||
view.phase === 'shop' && p.seat === view.turn ? 'is-turn' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{p.isBot ? (
|
||||
<span className="bot-dot" title="Computer player">
|
||||
@@ -158,16 +201,22 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
<span className={`conn-dot ${p.connected ? 'on' : 'off'}`} />
|
||||
)}
|
||||
<span className="topbar-name">{p.name}</span>
|
||||
{p.seat === view.yourOpponent && view.phase !== 'lobby' && (
|
||||
<span className="chip chip-rival" title="You fight them this round">
|
||||
⚔️
|
||||
</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. */}
|
||||
everyone else'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. */}
|
||||
{/* Peek at anyone's deck from last round's battle — every table's
|
||||
lineups are public once fought. */}
|
||||
{view.phase === 'shop' &&
|
||||
p.seat !== view.youSeat &&
|
||||
(view.battle?.lineups?.[p.seat]?.length ?? 0) > 0 && (
|
||||
(lastLineups.get(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"
|
||||
@@ -231,8 +280,17 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
{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 === 'battle' && battle && (
|
||||
<BattlePhase
|
||||
view={view}
|
||||
send={send}
|
||||
step={step}
|
||||
setStep={setStep}
|
||||
battle={battle}
|
||||
battles={battles}
|
||||
selected={selected}
|
||||
onSelect={selectBattle}
|
||||
/>
|
||||
)}
|
||||
{view.phase === 'gameover' && <GameOver view={view} onLeave={onLeave} />}
|
||||
</main>
|
||||
@@ -265,13 +323,13 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
|
||||
)}
|
||||
{error && <div className="toast">{error}</div>}
|
||||
{view.debug && (
|
||||
<DebugPanel canGrant={view.phase === 'shop'} pack={view.pack} send={send} />
|
||||
<DebugPanel canGrant={view.phase === 'shop'} packs={view.packs} send={send} />
|
||||
)}
|
||||
|
||||
{deckPeek &&
|
||||
view.phase === 'shop' &&
|
||||
(() => {
|
||||
const lineup = view.battle?.lineups?.[deckPeek.seat] ?? []
|
||||
const lineup = lastLineups.get(deckPeek.seat) ?? []
|
||||
if (!lineup.length) return null
|
||||
const oppName = view.players.find((p) => p.seat === deckPeek.seat)?.name ?? 'Opponent'
|
||||
return createPortal(
|
||||
|
||||
@@ -488,6 +488,43 @@ h3 {
|
||||
0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* This round's opponent gets a cooler outline than your own gold one — enough
|
||||
to pick them out of five other seats without competing with it. */
|
||||
.topbar-player.is-rival {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
0 0 0 2px rgba(255, 120, 120, 0.75),
|
||||
0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Whoever the shop is waiting on, so a six-seat rail still shows the turn. */
|
||||
.topbar-player.is-turn {
|
||||
background: linear-gradient(180deg, rgba(255, 214, 102, 0.22), rgba(0, 0, 0, 0.28));
|
||||
}
|
||||
|
||||
/* Four or six pins won't fit at full size, so the rail tightens as it fills.
|
||||
Names ellipsis rather than wrap, keeping the topbar one row deep. */
|
||||
.topbar-players.count-4 .topbar-player,
|
||||
.topbar-players.count-6 .topbar-player {
|
||||
padding: 4px 9px;
|
||||
font-size: 0.82rem;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Names still have to fit, but not so tight that "Robo Rookie" and "Robo
|
||||
Rival" both collapse to "Robo R…" and the seats stop being tellable apart. */
|
||||
.topbar-players.count-6 .topbar-name {
|
||||
max-width: 12ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-rival {
|
||||
filter: drop-shadow(0 0 3px rgba(255, 120, 120, 0.8));
|
||||
}
|
||||
|
||||
.topbar-name {
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -921,6 +958,14 @@ h3 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* What's still standing between the host and a startable game. */
|
||||
.lobby-warn {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #ffcf8f;
|
||||
}
|
||||
|
||||
/* ---------- pack picker ---------- */
|
||||
|
||||
.pack-grid {
|
||||
@@ -2054,6 +2099,56 @@ h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* With four or six players a round holds several battles at once; these tabs
|
||||
switch the arena between them. They read as folder tabs sitting on top of
|
||||
the battlefield, with your own fight marked. */
|
||||
.battle-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.battle-tab {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
opacity: 0.72;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.battle-tab:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.battle-tab.is-active {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||
0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.battle-tab.is-mine {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.battle-tab.is-mine.is-active {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||
0 0 0 2px var(--gold);
|
||||
}
|
||||
|
||||
.battle-names {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -2844,6 +2939,33 @@ h3 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* How the rest of the field did this round, under your own result. */
|
||||
.battle-result-others {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 12px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.battle-result-others-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.battle-result-other {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The toolbar's "advance the round" button stands out from the ghost controls. */
|
||||
.battle-next {
|
||||
margin-left: 2px;
|
||||
@@ -2889,6 +3011,7 @@ h3 {
|
||||
|
||||
.score-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
font-size: 1.1rem;
|
||||
@@ -2901,6 +3024,26 @@ h3 {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.score-line.is-winner {
|
||||
background: rgba(255, 214, 102, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
/* The rounds a player took — the countback that settles a tie, spelled out. */
|
||||
.score-rounds {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.score-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- toasts & banners ---------- */
|
||||
|
||||
.toast {
|
||||
|
||||
+24
-6
@@ -25,6 +25,7 @@ export interface PlayerView {
|
||||
seat: number
|
||||
coins: number
|
||||
trophies: number
|
||||
roundWins?: number[] // rounds this player won a battle in (public)
|
||||
ready: boolean
|
||||
connected: boolean
|
||||
isBot?: boolean
|
||||
@@ -100,15 +101,25 @@ export interface BattleEvent {
|
||||
text?: string
|
||||
}
|
||||
|
||||
// A round runs one battle per pairing, so a six-player round has three of
|
||||
// these. Everything inside is indexed by *side* — 0 or 1 within this battle —
|
||||
// rather than by seat at the table, including BattleEvent.seat/target. `seats`
|
||||
// maps the two apart. winnerSeat is the exception: it's a real seat.
|
||||
export interface BattleResult {
|
||||
round: number
|
||||
seats: number[] // the two seats fighting, first player first
|
||||
stackSizes: number[]
|
||||
lineups?: Card[][] // each seat's arranged deck (top first); public for peeking
|
||||
lineups?: Card[][] // each side's arranged deck (top first); public for peeking
|
||||
events: BattleEvent[] | null
|
||||
winnerSeat: number
|
||||
trophies: number
|
||||
}
|
||||
|
||||
// sideOf maps a seat to its side index in a battle, or -1 if it wasn't in it.
|
||||
export function sideOf(battle: BattleResult, seat: number): number {
|
||||
return battle.seats?.indexOf(seat) ?? -1
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
seq: number
|
||||
round: number
|
||||
@@ -135,27 +146,34 @@ export interface GameView {
|
||||
round: number
|
||||
maxRounds: number
|
||||
maxPets: number
|
||||
pack: string
|
||||
packs: PackInfo[]
|
||||
packs: string[] // selected packs, shuffled together
|
||||
packCatalog: PackInfo[] // the choices offered in the lobby
|
||||
packsNeeded: number // packs the current table size requires
|
||||
hostSeat: number
|
||||
minPlayers: number
|
||||
maxPlayers: number
|
||||
playerCounts: number[] // table sizes a game can start at (2, 4, 6)
|
||||
youSeat: number
|
||||
turn: number
|
||||
prioritySeat: number
|
||||
shopRow: Card[]
|
||||
deckCounts: number[]
|
||||
players: PlayerView[]
|
||||
matchups?: [number, number][] // this round's battle pairings (public)
|
||||
yourOpponent: number // the seat you face this round, or -1
|
||||
pending?: PendingTrade
|
||||
pendingReveal?: PendingReveal
|
||||
pendingSacrifice?: PendingSacrifice
|
||||
battle?: BattleResult
|
||||
winnerSeat: number
|
||||
battle?: BattleResult // your own battle this round
|
||||
battles?: BattleResult[] // every table's, all replayable
|
||||
winnerSeat: number // outright winner, or -1 when shared
|
||||
winnerSeats?: number[] // everyone holding the title
|
||||
log?: LogEntry[]
|
||||
debug?: boolean // server DEBUG mode: unlocks the buy-any-card panel
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'setPack'; pack: string }
|
||||
| { type: 'setPacks'; packs: string[] }
|
||||
| { type: 'addBot'; difficulty: string }
|
||||
| { type: 'removePlayer'; target: string }
|
||||
| { type: 'start' }
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useEffect, useState } from 'react'
|
||||
import { fetchCatalog } from './api'
|
||||
import type { Card } from './types'
|
||||
|
||||
// useCatalog loads the representative card for every pet and food in a pack and
|
||||
// caches the result per pack. It's used to look up cards by name (e.g. to
|
||||
// preview pets mentioned in the event log), even ones not currently in view.
|
||||
export function useCatalog(pack?: string): Card[] {
|
||||
// useCatalog loads the representative card for every pet and food across the
|
||||
// packs in play. It's used to look up cards by name (e.g. to preview pets
|
||||
// mentioned in the event log), even ones not currently in view. The packs are
|
||||
// joined into a stable key so a new array identity each render doesn't refetch.
|
||||
export function useCatalog(packs?: string[]): Card[] {
|
||||
const [cards, setCards] = useState<Card[]>([])
|
||||
const key = (packs ?? []).join(',')
|
||||
useEffect(() => {
|
||||
if (!pack) return
|
||||
if (!key) return
|
||||
let cancelled = false
|
||||
fetchCatalog(pack)
|
||||
fetchCatalog(key.split(','))
|
||||
.then((c) => {
|
||||
if (!cancelled) setCards(c)
|
||||
})
|
||||
@@ -20,6 +22,6 @@ export function useCatalog(pack?: string): Card[] {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pack])
|
||||
}, [key])
|
||||
return cards
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user