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
+46 -14
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,32 +684,38 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
)
})()}
{done && (
<div className={`battle-result ${draw ? 'is-draw' : won ? 'is-win' : 'is-loss'}`}>
{/* 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 && (
{!draw ? (
<div className="battle-result-sub">
{view.players[battle.winnerSeat]?.name} wins{' '}
{'🏆'.repeat(battle.trophies)}
{view.players[battle.winnerSeat]?.name} wins {'🏆'.repeat(battle.trophies)}
</div>
) : (
<div className="battle-result-sub">No trophies awarded</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 →'}
<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>
</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>
<button className="btn btn-ghost btn-sm" onClick={onLeave}>
Leave
<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>
</header>
<div className="table-body">
+105 -27
View File
@@ -422,14 +422,17 @@ h3 {
letter-spacing: 0.04em;
}
/* The round shows as a die face — 16 pips, like the real game's round marker. */
.topbar-round {
font-size: 0.95rem;
font-weight: 700;
color: rgba(255, 245, 225, 0.9);
display: flex;
align-items: center;
}
.topbar-round strong {
color: var(--gold);
.topbar-die {
width: 26px;
height: 26px;
display: block;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.4));
}
.topbar-players {
@@ -514,7 +517,7 @@ h3 {
line-height: 1;
}
/* The room code, stamped like a label on a card tray. */
/* The room code, stamped like a label on a card tray. Lives inside the ⋯ menu. */
.topbar-code {
font-family: var(--font-display);
letter-spacing: 0.28em;
@@ -525,6 +528,54 @@ h3 {
border-radius: 8px;
}
/* The ⋯ menu holds the room code and Leave, pushed to the right of the rail so
the middle is just the players and their stats. */
.topbar-menu {
position: relative;
margin-left: auto;
}
.topbar-menu-btn {
font-size: 1.15rem;
line-height: 1;
padding: 5px 12px;
}
/* A full-viewport catcher so a click anywhere off the menu closes it. */
.topbar-menu-backdrop {
position: fixed;
inset: 0;
z-index: 20;
}
.topbar-menu-pop {
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 21;
min-width: 190px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
background:
repeating-linear-gradient(92deg, rgba(0, 0, 0, 0.05) 0 3px, transparent 3px 7px),
linear-gradient(180deg, var(--wood-light), var(--wood) 70%, var(--wood-dark));
border: 2px solid var(--wood-dark);
border-radius: 12px;
box-shadow:
inset 0 1px 0 rgba(255, 220, 170, 0.25),
0 14px 30px rgba(0, 0, 0, 0.55);
}
.topbar-menu-code {
display: flex;
flex-direction: column;
gap: 5px;
align-items: flex-start;
font-size: 0.78rem;
}
.table-body {
flex: 1;
display: flex;
@@ -2666,14 +2717,11 @@ h3 {
z-index: 5;
}
.battle-result {
text-align: center;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
padding: 8px 0;
animation: result-in 400ms ease;
/* The end-of-battle result dialog: a centered modal so it's always fully on
screen (even on a phone where the arena fills the viewport). */
.battle-result-modal {
gap: 14px;
min-width: min(320px, 86vw);
}
@keyframes result-in {
@@ -2693,15 +2741,15 @@ h3 {
text-shadow: 0 3px 0 rgba(0, 0, 0, 0.3), 0 6px 14px rgba(0, 0, 0, 0.4);
}
.battle-result.is-win .battle-result-title {
.battle-result-modal.is-win .battle-result-title {
color: var(--gold);
}
.battle-result.is-loss .battle-result-title {
.battle-result-modal.is-loss .battle-result-title {
color: #ff8f7a;
}
.battle-result.is-draw .battle-result-title {
.battle-result-modal.is-draw .battle-result-title {
color: #6fd6c2;
}
@@ -2710,6 +2758,11 @@ h3 {
font-weight: 700;
}
/* The toolbar's "advance the round" button stands out from the ghost controls. */
.battle-next {
margin-left: 2px;
}
/* ---------- game over ---------- */
.gameover {
@@ -2856,19 +2909,23 @@ h3 {
gap: 5px 8px;
padding: 5px 9px;
}
/* Drop the wordmark entirely on a phone — the die, the player tags, and the
⋯ menu are all that's needed, and losing it helps the rail stay one row. */
.topbar-brand {
font-size: 1.05rem;
}
/* The wordmark is redundant next to the paw on a phone — drop it to buy width
so the rail packs onto fewer rows. */
.topbar-brand span {
display: none;
}
/* Let the tags pack tight instead of stretching across a whole row. */
.topbar-die {
width: 23px;
height: 23px;
}
/* Let the tags pack tight; the ⋯ menu is pushed to the right by margin-auto. */
.topbar-players {
flex: 0 1 auto;
gap: 6px;
}
.topbar-menu-btn {
padding: 4px 10px;
}
.topbar-round {
font-size: 0.82rem;
}
@@ -3065,6 +3122,30 @@ h3 {
min-width: 72px;
min-height: 112px;
}
/* A battle card is small and glanced at, not read — so on a phone strip it to
the essentials: big art, the power badge and any damage marker, and the
name. No suit dot, tier die, or ability text (the magnified view still shows
the full card). The scene grows to fill the space the panel gives up. */
.battle-unit .card-scene {
flex: 1 1 auto;
}
.battle-unit .card-panel {
flex: 0 0 auto;
}
.battle-unit .card-effect,
.battle-unit .card-suit,
.battle-unit .card-die,
.battle-unit .card-panel-slot {
display: none;
}
.battle-unit .card-panel-head {
grid-template-columns: 1fr;
border-bottom: none;
padding-bottom: 0;
}
.battle-unit .card-art {
font-size: 2.1rem;
}
.card-back {
width: 56px;
height: 78px;
@@ -3078,13 +3159,10 @@ h3 {
.food-fan-card:not(:first-child) {
margin-left: -50px;
}
.battle-result {
gap: 8px;
}
.battle-result-title {
font-size: 1.7rem;
}
.battle-result .btn-big {
.battle-result-modal .btn-big {
font-size: 1.05rem;
padding: 11px 22px;
}