Battle result dialog, speed toggle, and topbar refactor for mobile.

- End-of-battle result is now a centered dialog (Victory/Defeat/Draw) with
  'Next round' and 'Not yet'; 'Not yet' dismisses it to rewatch the battle,
  and a toolbar button remains to advance. A modal is always fully on screen,
  fixing the next-round button being unreachable below the arena on phones.
- Also make the play area genuinely scrollable on phones (stretch the body
  so table-main's overflow can engage) as a safety net.
- Collapse the 5-button playback speed picker into one button that cycles
  through the options, keeping the battle controls on a single row.
- Topbar: show the round as a die face (1-6 pips) on all screens, move the
  room code and Leave into a '...' dropdown menu, and drop the wordmark on
  phones so the rail stays one row (die + players + menu).
- On phones, strip battle cards to art + power/damage + name (no suit, tier,
  or ability text); the magnified view still shows the full card.
This commit is contained in:
Greyson Parrelli
2026-07-26 15:15:18 -04:00
parent de9bb480c2
commit 33bef8f58a
4 changed files with 217 additions and 64 deletions
+58 -26
View File
@@ -289,7 +289,11 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
export function BattlePhase({ view, send, step, setStep }: Props) {
const battle = view.battle!
const events = battle.events ?? []
// acked = the player committed to the next round (waiting on the opponent).
const [acked, setAcked] = useState(false)
// The result dialog pops when the replay ends. "Not yet" dismisses it so the
// battle can be rewatched; a toolbar button then remains to advance the round.
const [resultDismissed, setResultDismissed] = useState(false)
// The deck-peek popover lives inside .battlefield, which sets overflow-x
// (and thus overflow-y) to auto — so an absolutely-positioned popover gets
// clipped to the battlefield. We anchor it to the hovered stack's viewport
@@ -358,6 +362,17 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
const won = battle.winnerSeat === youSeat
const draw = battle.winnerSeat < 0
// Commit to the next round: ack and hand off to the server.
const proceed = () => {
setAcked(true)
send({ type: 'ready' })
}
const nextLabel = battle.round >= view.maxRounds ? 'See final results' : 'Next round →'
// Show the result dialog when the replay finishes — unless the player chose
// "Not yet" to rewatch (then only the toolbar button remains). Once they've
// committed, the dialog reappears to show the "waiting" state.
const showResult = done && (acked || !resultDismissed)
// Dice for the rock event currently on screen. renderSide draws them under
// the throwing side's deck; `step` keys the tray so the scramble replays for
// every rock event.
@@ -603,6 +618,17 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
>
{speed}×
</button>
{/* Once the replay ends, a persistent way to advance the round — the
path forward after the result dialog is dismissed to rewatch. */}
{done && !acked && (
<button
className="btn btn-primary btn-sm battle-next"
onClick={proceed}
title={nextLabel}
>
{battle.round >= view.maxRounds ? 'Results →' : 'Next round →'}
</button>
)}
</div>
</div>
@@ -658,33 +684,39 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
)
})()}
{done && (
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
<div className="battle-result-title">
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
</div>
{!draw && (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins{' '}
{'🏆'.repeat(battle.trophies)}
{/* The result lands as a centered dialog — always fully on screen, even on
a phone where the arena fills the viewport. "Not yet" dismisses it so
the battle can be rewatched; the toolbar keeps a way to advance. */}
{showResult &&
createPortal(
<div className="modal-backdrop">
<div className={`modal battle-result-modal ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
<div className="battle-result-title">
{draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
</div>
{!draw ? (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins {'🏆'.repeat(battle.trophies)}
</div>
) : (
<div className="battle-result-sub">No trophies awarded</div>
)}
{acked ? (
<p className="muted">Waiting for opponent</p>
) : (
<div className="actions">
<button className="btn btn-ghost" onClick={() => setResultDismissed(true)}>
Not yet
</button>
<button className="btn btn-primary btn-big" onClick={proceed}>
{nextLabel}
</button>
</div>
)}
</div>
)}
{draw && <div className="battle-result-sub">No trophies awarded</div>}
{acked ? (
<p className="muted">Waiting for opponent</p>
) : (
<button
className="btn btn-primary btn-big"
onClick={() => {
setAcked(true)
send({ type: 'ready' })
}}
>
{battle.round >= view.maxRounds ? 'See final results' : 'Next round →'}
</button>
)}
</div>
)}
</div>,
document.body,
)}
</div>
)
}
+9 -3
View File
@@ -13,10 +13,12 @@ const DIE_PIPS: Record<number, [number, number][]> = {
6: [[9, 9], [21, 9], [9, 15], [21, 15], [9, 21], [21, 21]],
}
function TierDie({ tier }: { tier: number }) {
const pips = DIE_PIPS[tier] ?? DIE_PIPS[1]
// DieFace renders a value 16 as a pipped die, like the real game's markers.
// Shared by the card's tier corner and the topbar's round indicator.
export function DieFace({ value, className }: { value: number; className?: string }) {
const pips = DIE_PIPS[value] ?? DIE_PIPS[1]
return (
<svg className="card-die" viewBox="0 0 30 30" aria-hidden>
<svg className={className} viewBox="0 0 30 30" aria-hidden>
<rect x="1.5" y="1.5" width="27" height="27" rx="6.5" fill="#fff" stroke="#2c1c0e" strokeWidth="2" />
{pips.map(([cx, cy], i) => (
<circle key={i} cx={cx} cy={cy} r="2.9" fill="#2c1c0e" />
@@ -25,6 +27,10 @@ function TierDie({ tier }: { tier: number }) {
)
}
function TierDie({ tier }: { tier: number }) {
return <DieFace value={tier} className="card-die" />
}
// useFitText shrinks text until it fits its box, so long abilities (and the
// smaller battle cards) render in full instead of being clipped by the fixed
// card height, and long single-line names shrink instead of wrapping. It
+45 -8
View File
@@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom'
import { useGame } from '../useGame'
import { useCatalog } from '../useCatalog'
import { CardView } from './CardView'
import { CardView, DieFace } from './CardView'
import type { Card, Session } from '../types'
import { Lobby } from './Lobby'
import { ShopPhase } from './ShopPhase'
@@ -44,6 +44,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
// this open flag is simply ignored.
const [logOpen, setLogOpen] = useState(false)
// The room code and Leave button live behind a "⋯" menu in the topbar rail,
// keeping the rail short enough for one row on a phone.
const [menuOpen, setMenuOpen] = useState(false)
// 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
@@ -132,8 +136,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
🐾 <span>SAP</span>
</div>
{view.phase !== 'gameover' && view.phase !== 'lobby' && (
<div className="topbar-round">
Round <strong>{view.round}</strong> / {view.maxRounds}
<div
className="topbar-round"
title={`Round ${view.round} of ${view.maxRounds}`}
aria-label={`Round ${view.round} of ${view.maxRounds}`}
>
<DieFace value={view.round} className="topbar-die" />
</div>
)}
<div className="topbar-players">
@@ -179,12 +187,41 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
</div>
))}
</div>
<div className="topbar-code" title="Share this code with your opponent">
{view.code}
<div className="topbar-menu">
<button
className="btn btn-ghost btn-sm topbar-menu-btn"
onClick={() => setMenuOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label="Game menu"
title="Room code & leave"
>
</button>
{menuOpen && (
<>
<div className="topbar-menu-backdrop" onClick={() => setMenuOpen(false)} />
<div className="topbar-menu-pop" role="menu">
<div className="topbar-menu-code">
<span className="muted">Room code</span>
<span className="topbar-code" title="Share this code with your opponent">
{view.code}
</span>
</div>
<button
className="btn btn-ghost btn-sm"
role="menuitem"
onClick={() => {
setMenuOpen(false)
onLeave()
}}
>
Leave game
</button>
</div>
</>
)}
</div>
<button className="btn btn-ghost btn-sm" onClick={onLeave}>
Leave
</button>
</header>
<div className="table-body">