Improve snappiness and add speed setting.

This commit is contained in:
Greyson Parrelli
2026-07-26 14:21:56 -04:00
parent e3234b6f83
commit 4ed04c967b
6 changed files with 250 additions and 66 deletions
+65 -29
View File
@@ -4,10 +4,11 @@ import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView'
import { DiceRoll, ROLL_MS } from './DiceRoll'
import { SPEED_OPTIONS, useBattleSpeed } from '../useBattleSpeed'
// How long a settled rock roll (and its damage) stays on screen before the
// battle advances to the next step.
const ROCK_PAUSE_MS = 1000
const ROCK_PAUSE_MS = 650
interface Props {
view: GameView
@@ -37,28 +38,31 @@ interface SideVis {
leaving: string[]
}
// Milliseconds each event type stays on screen during playback.
// Milliseconds each event type stays on screen during playback, at 1× speed.
// These are the base beats — kept snappy so the replay reads quickly; the
// battle-speed multiplier scales every one of them (and the CSS animations
// keyed off --battle-speed) in lockstep.
const EVENT_MS: Record<BattleEvent['type'], number> = {
prep: 700,
reveal: 800,
summon: 1000,
mill: 700,
prep: 450,
reveal: 520,
summon: 650,
mill: 450,
// Lead-in before the dice appear; the roll itself is timed by ROLL_MS +
// ROCK_PAUSE_MS once it's on screen (see the auto-advance effect).
rock: 500,
clash: 1400,
shield: 900,
strip: 1100,
steal: 1100,
eat: 1000,
heal: 900,
setaside: 700,
release: 500,
trumpet: 800,
prevent: 900,
mana: 800,
ailment: 900,
bounce: 1000,
rock: 320,
clash: 900,
shield: 600,
strip: 720,
steal: 720,
eat: 640,
heal: 600,
setaside: 480,
release: 380,
trumpet: 540,
prevent: 600,
mana: 540,
ailment: 600,
bounce: 680,
}
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
@@ -297,6 +301,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
const done = step >= events.length
const lastEvent = step > 0 ? events[step - 1] : null
// Playback speed multiplier, remembered across sessions. It divides every JS
// step timer and feeds --battle-speed to the CSS so the animations quicken
// right along with the pacing.
const [speed, setSpeed] = useBattleSpeed()
// paused freezes auto-advance so the player can walk the log manually.
const [paused, setPaused] = useState(false)
@@ -310,18 +319,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
return
}
setRockSettled(false)
const t = window.setTimeout(() => setRockSettled(true), ROLL_MS)
const t = window.setTimeout(() => setRockSettled(true), ROLL_MS / speed)
return () => window.clearTimeout(t)
}, [step, showingRock])
}, [step, showingRock, speed])
useEffect(() => {
if (done || paused) return
// The rock roll owns its timing: hold until the dice settle, then a full
// beat with the damage showing before moving on.
const delay = showingRock ? ROLL_MS + ROCK_PAUSE_MS : (EVENT_MS[events[step].type] ?? 1000)
const t = window.setTimeout(() => setStep((s) => s + 1), delay)
// beat with the damage showing before moving on. Every beat is scaled by
// the speed multiplier so faster playback shortens the whole replay.
const base = showingRock ? ROLL_MS + ROCK_PAUSE_MS : (EVENT_MS[events[step].type] ?? 1000)
const t = window.setTimeout(() => setStep((s) => s + 1), base / speed)
return () => window.clearTimeout(t)
}, [step, done, paused, events, showingRock])
}, [step, done, paused, events, showingRock, speed])
// Manual controls. Stepping by hand pauses playback so it doesn't fight you.
const stepTo = (n: number) => {
@@ -422,7 +432,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
{rockDice && lastEvent?.seat === seat && (
// The dice roll sits beside the throwing side's deck.
<div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={pos} />
<DiceRoll key={step} dice={rockDice} side={pos} speed={speed} />
</div>
)}
</div>
@@ -535,8 +545,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
)
}
// A clash on screen flashes the centre seam; key the burst by step so every
// clash re-triggers it.
const centerClash = !done && lastEvent?.type === 'clash'
return (
<div className="battle">
<div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}>
<div className="battle-header">
<h2>Battle! Round {battle.round}</h2>
<div className="battle-controls">
@@ -578,6 +592,24 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
>
Skip
</button>
<div className="battle-speed" role="group" aria-label="Playback speed">
<span className="battle-speed-label" aria-hidden>
</span>
{SPEED_OPTIONS.map((s) => (
<button
key={s}
className={`btn btn-ghost btn-sm battle-speed-btn ${
speed === s ? 'is-active' : ''
}`}
onClick={() => setSpeed(s)}
aria-pressed={speed === s}
title={`Play at ${s}× speed`}
>
{s}×
</button>
))}
</div>
</div>
</div>
@@ -589,7 +621,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<div className="battlefield">
{renderSide(oppSeat, 'top')}
<div className="battle-center" aria-hidden>
<div
key={centerClash ? `clash-${step}` : 'center'}
className={`battle-center ${centerClash ? 'battle-center-clash' : ''}`}
aria-hidden
>
<span className="battle-center-bolt"></span>
</div>
{renderSide(youSeat, 'bottom')}