From 71fa20493a422d4da3d76937f3eaf6578644789b Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 01:01:00 -0400 Subject: [PATCH] Add 3D dice roll animation for thrown rocks Emit the individual rock-die faces (0/0/1/1/2/2) from the battle resolver so the client can render a real CSS 3D cube per die that tumbles and settles on the rolled face, replacing the flat random damage number. Falls back to the flying-rock cue for older battle logs without per-die data. --- internal/game/battle.go | 11 +++- web/src/components/BattlePhase.tsx | 17 ++++-- web/src/components/Dice.tsx | 60 ++++++++++++++++++++ web/src/styles.css | 89 ++++++++++++++++++++++++++++++ web/src/types.ts | 1 + 5 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 web/src/components/Dice.tsx diff --git a/internal/game/battle.go b/internal/game/battle.go index a2357a6..32dee33 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -91,6 +91,9 @@ type BattleEvent struct { // clash: per-seat damage totals / deaths after the exchange. Damage []int `json:"damage,omitempty"` Died []bool `json:"died,omitempty"` + // rock: individual die faces rolled (each 0, 1, or 2), for the dice + // animation; Roll is their sum. + Dice []int `json:"dice,omitempty"` // rock: dice total rolled; rock/strip/steal: target pet's fate. Roll int `json:"roll"` DamageAfter int `json:"damageAfter"` @@ -368,13 +371,15 @@ func (g *Game) resolveBattle() { return false } roll := 0 - for range dice { - roll += g.rollRockDie() + faces := make([]int, dice) + for i := range dice { + faces[i] = g.rollRockDie() + roll += faces[i] } dealt, blocked := hitUnit(target, roll) died := !tu.Alive() emit(BattleEvent{ - Type: "rock", Seat: from, Target: target, Roll: roll, + Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces, DamageAfter: tu.Damage, TargetDied: died, }) if blocked { diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 8c94ffe..fdb5060 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import { CardView } from './CardView' +import { DiceTray } from './Dice' import { artFor } from '../petArt' interface Props { @@ -28,7 +29,7 @@ const EVENT_MS: Record = { reveal: 800, summon: 1000, mill: 700, - rock: 1200, + rock: 1500, clash: 1400, shield: 900, strip: 1100, @@ -312,11 +313,15 @@ export function BattlePhase({ view, send }: Props) {
{renderSide(youSeat, 'left')}
- {!done && lastEvent?.type === 'rock' && ( -
- 🪨 -
- )} + {!done && lastEvent?.type === 'rock' && + (lastEvent.dice && lastEvent.dice.length > 0 ? ( + // Remount per rock event (keyed by step) so the tumble replays. + + ) : ( +
+ 🪨 +
+ ))} âš¡
{renderSide(oppSeat, 'right')} diff --git a/web/src/components/Dice.tsx b/web/src/components/Dice.tsx new file mode 100644 index 0000000..c0dcca7 --- /dev/null +++ b/web/src/components/Dice.tsx @@ -0,0 +1,60 @@ +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 = { + 0: { rx: 0, ry: 0 }, + 1: { rx: 0, ry: -90 }, + 2: { rx: 90, ry: 0 }, +} + +function pips(n: number) { + return ( + + {Array.from({ length: n }, (_, i) => ( + + ))} + + ) +} + +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 ( +
+
+
{pips(0)}
+
{pips(0)}
+
{pips(1)}
+
{pips(1)}
+
{pips(2)}
+
{pips(2)}
+
+
+ ) +} + +// 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 ( +
+ {dice.map((v, i) => ( + + ))} +
+ ) +} diff --git a/web/src/styles.css b/web/src/styles.css index 75f0ce9..0387154 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -943,6 +943,95 @@ h3 { animation: rock-fly-left 700ms ease-in forwards; } +/* --- 3D rock dice --- */ + +.dice-tray { + position: absolute; + z-index: 8; + display: flex; + gap: 10px; + perspective: 420px; + pointer-events: none; +} + +.die { + --size: 34px; + --half: 17px; + position: relative; + width: var(--size); + height: var(--size); + /* A filter here would flatten the cube, so it only carries the shadow. */ + filter: drop-shadow(0 4px 5px rgba(0, 0, 0, 0.45)); +} + +.die-cube { + position: absolute; + inset: 0; + transform-style: preserve-3d; + /* From a heavily-spun start, settle onto this die's rolled face. */ + animation: die-roll 720ms cubic-bezier(0.2, 0.8, 0.3, 1) both; + animation-delay: var(--delay, 0ms); +} + +@keyframes die-roll { + 0% { + transform: rotateX(calc(var(--rx) - 900deg)) rotateY(calc(var(--ry) - 1080deg)); + } + 100% { + transform: rotateX(var(--rx)) rotateY(var(--ry)); + } +} + +.die-face { + position: absolute; + inset: 0; + display: grid; + place-items: center; + border-radius: 6px; + background: linear-gradient(145deg, var(--cream), var(--cream-dark)); + border: 2px solid var(--cocoa); + box-sizing: border-box; +} + +.die-front { + transform: translateZ(var(--half)); +} +.die-back { + transform: rotateY(180deg) translateZ(var(--half)); +} +.die-right { + transform: rotateY(90deg) translateZ(var(--half)); +} +.die-left { + transform: rotateY(-90deg) translateZ(var(--half)); +} +.die-top { + transform: rotateX(90deg) translateZ(var(--half)); +} +.die-bottom { + transform: rotateX(-90deg) translateZ(var(--half)); +} + +.die-pips { + display: grid; + gap: 4px; + padding: 5px; +} +.die-pips-1 { + grid-template-columns: 1fr; +} +.die-pips-2 { + grid-template-columns: repeat(2, 1fr); +} + +.die-pip { + width: 8px; + height: 8px; + border-radius: 50%; + background: radial-gradient(circle at 35% 30%, #6b7075, #2c2f33); + box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.35); +} + @keyframes fx-pop { 0% { opacity: 0; diff --git a/web/src/types.ts b/web/src/types.ts index 77781d7..9d87468 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -59,6 +59,7 @@ export interface BattleEvent { died?: boolean[] // rock / strip / steal roll?: number + dice?: number[] // rock: individual die faces (each 0, 1, or 2) damageAfter?: number targetDied?: boolean // eat (new bonus total) / reveal (starting bonus)