Improve dice rolls.

This commit is contained in:
Greyson Parrelli
2026-07-23 10:26:15 -04:00
parent 44f8bcfcab
commit 7bb20e5d2d
6 changed files with 173 additions and 184 deletions
+42 -16
View File
@@ -3,9 +3,13 @@ import type { Dispatch, SetStateAction } from 'react'
import { createPortal } from 'react-dom'
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
import { CardView } from './CardView'
import { DiceTray } from './Dice'
import { DiceRoll, ROLL_MS } from './DiceRoll'
import { artFor } from '../petArt'
// 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
interface Props {
view: GameView
send: (msg: ClientMessage) => void
@@ -34,7 +38,9 @@ const EVENT_MS: Record<BattleEvent['type'], number> = {
reveal: 800,
summon: 1000,
mill: 700,
rock: 1500,
// 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,
@@ -193,12 +199,28 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
// paused freezes auto-advance so the player can walk the log manually.
const [paused, setPaused] = useState(false)
// A rock event on screen scrambles its dice first; only once they settle do
// we apply the damage and reveal the result.
const showingRock = !done && lastEvent?.type === 'rock'
const [rockSettled, setRockSettled] = useState(false)
useEffect(() => {
if (!showingRock) {
setRockSettled(true)
return
}
setRockSettled(false)
const t = window.setTimeout(() => setRockSettled(true), ROLL_MS)
return () => window.clearTimeout(t)
}, [step, showingRock])
useEffect(() => {
if (done || paused) return
const delay = EVENT_MS[events[step].type] ?? 1000
// 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)
return () => window.clearTimeout(t)
}, [step, done, paused, events])
}, [step, done, paused, events, showingRock])
// Manual controls. Stepping by hand pauses playback so it doesn't fight you.
const stepTo = (n: number) => {
@@ -210,9 +232,12 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
setStep(0)
}
// While the dice are still scrambling, replay only up to just before the
// rock so its damage/faint hasn't landed yet.
const upto = showingRock && !rockSettled ? step - 1 : step
const sides = useMemo(
() => replay(events, battle.stackSizes, step),
[events, battle.stackSizes, step],
() => replay(events, battle.stackSizes, upto),
[events, battle.stackSizes, upto],
)
const youSeat = view.youSeat
@@ -222,6 +247,14 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
const won = battle.winnerSeat === youSeat
const draw = battle.winnerSeat < 0
// Dice for the rock event currently on screen, shown under the thrower's
// side. `step` keys the tray so the scramble replays for every rock event.
const rockDice =
!done && lastEvent?.type === 'rock' && lastEvent.dice && lastEvent.dice.length > 0
? lastEvent.dice
: null
const rockSide: 'left' | 'right' = lastEvent?.seat === youSeat ? 'left' : 'right'
function renderSide(seat: number, dir: 'left' | 'right') {
const s = sides[seat]
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
@@ -230,7 +263,8 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
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 pop = done ? null : unitPop(lastEvent, seat, events, step)
// Hold the N / miss! pop until the dice settle.
const pop = done || (showingRock && !rockSettled) ? null : unitPop(lastEvent, seat, events, step)
const lineup = lineups?.[seat] ?? []
const stackEl = (
@@ -386,18 +420,10 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<div className="battlefield">
{renderSide(youSeat, 'left')}
<div className="battle-center" aria-hidden>
{!done && lastEvent?.type === 'rock' &&
(lastEvent.dice && lastEvent.dice.length > 0 ? (
// Remount per rock event (keyed by step) so the tumble replays.
<DiceTray key={step} dice={lastEvent.dice} />
) : (
<div className={`rock-fly ${lastEvent.target === youSeat ? 'rock-fly-left' : 'rock-fly-right'}`}>
🪨
</div>
))}
<span className="battle-center-bolt"></span>
</div>
{renderSide(oppSeat, 'right')}
{rockDice && <DiceRoll key={step} dice={rockDice} side={rockSide} />}
</div>
{peek &&
-60
View File
@@ -1,60 +0,0 @@
import type { CSSProperties } from 'react'
// The rock die has six faces: 0, 0, 1, 1, 2, 2. We lay them on a cube so a
// real 3D tumble can settle with the rolled value facing the camera.
// front/back = 0, right/left = 1, top/bottom = 2
// To bring a value to the front we rotate the cube to whichever face carries
// it: 0 -> identity, 1 -> show the right face, 2 -> show the top face.
const LAND: Record<number, { rx: number; ry: number }> = {
0: { rx: 0, ry: 0 },
1: { rx: 0, ry: -90 },
2: { rx: 90, ry: 0 },
}
function pips(n: number) {
return (
<span className={`die-pips die-pips-${n}`} aria-hidden>
{Array.from({ length: n }, (_, i) => (
<span key={i} className="die-pip" />
))}
</span>
)
}
function Die({ value, index }: { value: number; index: number }) {
const land = LAND[value] ?? LAND[0]
// Stagger each die so they don't tumble in lockstep, and give each its own
// landing orientation. The keyframe adds the spins on top of these targets.
const style = {
'--rx': `${land.rx}deg`,
'--ry': `${land.ry}deg`,
'--delay': `${index * 90}ms`,
} as CSSProperties
// The outer .die carries the drop-shadow; the shadow must live off the
// preserve-3d element, since a `filter` collapses 3D children into a plane.
return (
<div className="die" style={style}>
<div className="die-cube">
<div className="die-face die-front">{pips(0)}</div>
<div className="die-face die-back">{pips(0)}</div>
<div className="die-face die-right">{pips(1)}</div>
<div className="die-face die-left">{pips(1)}</div>
<div className="die-face die-top">{pips(2)}</div>
<div className="die-face die-bottom">{pips(2)}</div>
</div>
</div>
)
}
// DiceTray rolls one cube per die face rolled. `key` should change per rock
// event so React remounts and replays the tumble.
export function DiceTray({ dice }: { dice: number[] }) {
return (
<div className="dice-tray" aria-hidden>
{dice.map((v, i) => (
<Die key={i} value={v} index={i} />
))}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
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.
export const ROLL_MS = 650
const TICK_MS = 70
// DiceRoll shows one rock die per rolled face in fixed slots under the
// thrower's side: they shake and flicker through faces in place, then settle
// 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: 'left' | 'right' }) {
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)
const stop = window.setTimeout(() => {
window.clearInterval(flicker)
setDisplay(dice)
setSettled(true)
}, ROLL_MS)
return () => {
window.clearInterval(flicker)
window.clearTimeout(stop)
}
// Runs once per mount; the caller remounts (via key) for each new roll.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className={`dice-roll dice-roll-${side}`} aria-hidden>
{display.map((v, i) => (
<div
key={i}
className={`droll droll-n-${v} ${settled ? 'is-settled' : 'is-rolling'}`}
style={{ '--i': i } as CSSProperties}
>
{Array.from({ length: v }, (_, r) => (
<span key={r} className="droll-rock" />
))}
</div>
))}
</div>
)
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function Home({ onSession }: { onSession: (s: Session) => void }) {
<input
value={name}
maxLength={20}
placeholder="e.g. Greyson"
placeholder="Enter your name"
onChange={(e) => setName(e.target.value)}
/>
</label>