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.
This commit is contained in:
@@ -91,6 +91,9 @@ type BattleEvent struct {
|
|||||||
// clash: per-seat damage totals / deaths after the exchange.
|
// clash: per-seat damage totals / deaths after the exchange.
|
||||||
Damage []int `json:"damage,omitempty"`
|
Damage []int `json:"damage,omitempty"`
|
||||||
Died []bool `json:"died,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.
|
// rock: dice total rolled; rock/strip/steal: target pet's fate.
|
||||||
Roll int `json:"roll"`
|
Roll int `json:"roll"`
|
||||||
DamageAfter int `json:"damageAfter"`
|
DamageAfter int `json:"damageAfter"`
|
||||||
@@ -368,13 +371,15 @@ func (g *Game) resolveBattle() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
roll := 0
|
roll := 0
|
||||||
for range dice {
|
faces := make([]int, dice)
|
||||||
roll += g.rollRockDie()
|
for i := range dice {
|
||||||
|
faces[i] = g.rollRockDie()
|
||||||
|
roll += faces[i]
|
||||||
}
|
}
|
||||||
dealt, blocked := hitUnit(target, roll)
|
dealt, blocked := hitUnit(target, roll)
|
||||||
died := !tu.Alive()
|
died := !tu.Alive()
|
||||||
emit(BattleEvent{
|
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,
|
DamageAfter: tu.Damage, TargetDied: died,
|
||||||
})
|
})
|
||||||
if blocked {
|
if blocked {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
|
import type { BattleEvent, Card, ClientMessage, GameView } from '../types'
|
||||||
import { CardView } from './CardView'
|
import { CardView } from './CardView'
|
||||||
|
import { DiceTray } from './Dice'
|
||||||
import { artFor } from '../petArt'
|
import { artFor } from '../petArt'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -28,7 +29,7 @@ const EVENT_MS: Record<BattleEvent['type'], number> = {
|
|||||||
reveal: 800,
|
reveal: 800,
|
||||||
summon: 1000,
|
summon: 1000,
|
||||||
mill: 700,
|
mill: 700,
|
||||||
rock: 1200,
|
rock: 1500,
|
||||||
clash: 1400,
|
clash: 1400,
|
||||||
shield: 900,
|
shield: 900,
|
||||||
strip: 1100,
|
strip: 1100,
|
||||||
@@ -312,11 +313,15 @@ export function BattlePhase({ view, send }: Props) {
|
|||||||
<div className="battlefield">
|
<div className="battlefield">
|
||||||
{renderSide(youSeat, 'left')}
|
{renderSide(youSeat, 'left')}
|
||||||
<div className="battle-center" aria-hidden>
|
<div className="battle-center" aria-hidden>
|
||||||
{!done && lastEvent?.type === 'rock' && (
|
{!done && lastEvent?.type === 'rock' &&
|
||||||
<div className={`rock-fly ${lastEvent.target === youSeat ? 'rock-fly-left' : 'rock-fly-right'}`}>
|
(lastEvent.dice && lastEvent.dice.length > 0 ? (
|
||||||
🪨
|
// Remount per rock event (keyed by step) so the tumble replays.
|
||||||
</div>
|
<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>
|
<span className="battle-center-bolt">⚡</span>
|
||||||
</div>
|
</div>
|
||||||
{renderSide(oppSeat, 'right')}
|
{renderSide(oppSeat, 'right')}
|
||||||
|
|||||||
@@ -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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -943,6 +943,95 @@ h3 {
|
|||||||
animation: rock-fly-left 700ms ease-in forwards;
|
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 {
|
@keyframes fx-pop {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface BattleEvent {
|
|||||||
died?: boolean[]
|
died?: boolean[]
|
||||||
// rock / strip / steal
|
// rock / strip / steal
|
||||||
roll?: number
|
roll?: number
|
||||||
|
dice?: number[] // rock: individual die faces (each 0, 1, or 2)
|
||||||
damageAfter?: number
|
damageAfter?: number
|
||||||
targetDied?: boolean
|
targetDied?: boolean
|
||||||
// eat (new bonus total) / reveal (starting bonus)
|
// eat (new bonus total) / reveal (starting bonus)
|
||||||
|
|||||||
Reference in New Issue
Block a user