Improve snappiness and add speed setting.
This commit is contained in:
@@ -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')}
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { CSSProperties } from 'react'
|
||||
|
||||
// How long the dice scramble before settling, and how fast the faces flicker
|
||||
// while they do. ROLL_MS is shared with BattlePhase so damage lands exactly
|
||||
// when the dice settle.
|
||||
// when the dice settle. Both are the base (1×) durations; the battle speed
|
||||
// multiplier divides them so the roll keeps pace with the rest of the replay.
|
||||
export const ROLL_MS = 650
|
||||
const TICK_MS = 70
|
||||
|
||||
@@ -12,19 +13,27 @@ const TICK_MS = 70
|
||||
// on the values the server actually rolled. Faces carry rock icons (0, 1, or
|
||||
// 2 rocks) — the same die the game uses, not a pip D6. Mount it with a `key`
|
||||
// that changes per rock event so the scramble replays every time.
|
||||
export function DiceRoll({ dice, side }: { dice: number[]; side: 'top' | 'bottom' }) {
|
||||
export function DiceRoll({
|
||||
dice,
|
||||
side,
|
||||
speed = 1,
|
||||
}: {
|
||||
dice: number[]
|
||||
side: 'top' | 'bottom'
|
||||
speed?: number
|
||||
}) {
|
||||
const [display, setDisplay] = useState<number[]>(dice)
|
||||
const [settled, setSettled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const flicker = window.setInterval(() => {
|
||||
setDisplay(dice.map(() => Math.floor(Math.random() * 3)))
|
||||
}, TICK_MS)
|
||||
}, TICK_MS / speed)
|
||||
const stop = window.setTimeout(() => {
|
||||
window.clearInterval(flicker)
|
||||
setDisplay(dice)
|
||||
setSettled(true)
|
||||
}, ROLL_MS)
|
||||
}, ROLL_MS / speed)
|
||||
return () => {
|
||||
window.clearInterval(flicker)
|
||||
window.clearTimeout(stop)
|
||||
|
||||
Reference in New Issue
Block a user