diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 458b973..9866eeb 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -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 = { 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) {
{renderSide(youSeat, 'left')}
- {!done && lastEvent?.type === 'rock' && - (lastEvent.dice && lastEvent.dice.length > 0 ? ( - // Remount per rock event (keyed by step) so the tumble replays. - - ) : ( -
- 🪨 -
- ))}
{renderSide(oppSeat, 'right')} + {rockDice && }
{peek && diff --git a/web/src/components/Dice.tsx b/web/src/components/Dice.tsx deleted file mode 100644 index c0dcca7..0000000 --- a/web/src/components/Dice.tsx +++ /dev/null @@ -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 = { - 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/components/DiceRoll.tsx b/web/src/components/DiceRoll.tsx new file mode 100644 index 0000000..f82593d --- /dev/null +++ b/web/src/components/DiceRoll.tsx @@ -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(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 ( +
+ {display.map((v, i) => ( +
+ {Array.from({ length: v }, (_, r) => ( + + ))} +
+ ))} +
+ ) +} diff --git a/web/src/components/Home.tsx b/web/src/components/Home.tsx index bdafa39..e142650 100644 --- a/web/src/components/Home.tsx +++ b/web/src/components/Home.tsx @@ -37,7 +37,7 @@ export function Home({ onSession }: { onSession: (s: Session) => void }) { setName(e.target.value)} /> diff --git a/web/src/styles.css b/web/src/styles.css index f735b4d..2eac684 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1187,6 +1187,7 @@ h3 { /* The battlefield is the lit heart of the table — a spotlit arena inset into the felt, with a faint centre line where the two sides meet. */ .battlefield { + position: relative; display: flex; align-items: center; justify-content: center; @@ -1439,136 +1440,107 @@ h3 { animation: unit-reveal 500ms ease; } -@keyframes rock-fly-right { - 0% { - opacity: 0; - transform: translateX(-70px) translateY(-10px) rotate(0); - } - 20% { - opacity: 1; - } - 100% { - opacity: 1; - transform: translateX(70px) translateY(4px) rotate(360deg); - } -} +/* --- rock dice --- */ -@keyframes rock-fly-left { - 0% { - opacity: 0; - transform: translateX(70px) translateY(-10px) rotate(0); - } - 20% { - opacity: 1; - } - 100% { - opacity: 1; - transform: translateX(-70px) translateY(4px) rotate(-360deg); - } -} - -.rock-fly { +/* A little tray of dice under the thrower's side. Fixed slots: the dice + scramble in place, then settle showing the rolled rock faces. */ +.dice-roll { position: absolute; - font-size: 1.4rem; - z-index: 7; - pointer-events: none; -} - -.rock-fly-right { - animation: rock-fly-right 700ms ease-in forwards; -} - -.rock-fly-left { - animation: rock-fly-left 700ms ease-in forwards; -} - -/* --- 3D rock dice --- */ - -.dice-tray { - position: absolute; - z-index: 8; + bottom: 8px; display: flex; - gap: 10px; - perspective: 420px; + gap: 7px; + z-index: 8; pointer-events: none; + transform: translateX(-50%); +} +.dice-roll-left { + left: 27%; +} +.dice-roll-right { + left: 73%; } -.die { - --size: 34px; - --half: 17px; +.droll { 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)); + width: 34px; + height: 34px; + border-radius: 7px; + box-sizing: border-box; + border: 2px solid var(--cocoa); + background: linear-gradient(145deg, var(--cream), var(--cream-dark)); + box-shadow: + 0 3px 5px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.5); } -.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); +.droll.is-rolling { + animation: droll-shake 170ms linear infinite; + animation-delay: calc(var(--i) * 45ms); } -@keyframes die-roll { +.droll.is-settled { + animation: droll-settle 280ms cubic-bezier(0.2, 0.85, 0.3, 1) both; + animation-delay: calc(var(--i) * 55ms); +} + +@keyframes droll-shake { 0% { - transform: rotateX(calc(var(--rx) - 900deg)) rotateY(calc(var(--ry) - 1080deg)); + transform: rotate(-9deg) translateY(0) scale(1.06); + } + 25% { + transform: rotate(7deg) translateY(-2px) scale(1.09); + } + 50% { + transform: rotate(-6deg) translateY(1px) scale(1.05); + } + 75% { + transform: rotate(8deg) translateY(-1px) scale(1.08); } 100% { - transform: rotateX(var(--rx)) rotateY(var(--ry)); + transform: rotate(-9deg) translateY(0) scale(1.06); } } -.die-face { +@keyframes droll-settle { + 0% { + transform: scale(1.22) rotate(-5deg); + } + 60% { + transform: scale(0.95) rotate(1deg); + } + 100% { + transform: scale(1) rotate(0); + } +} + +/* A rock icon: a lumpy gray boulder. */ +.droll-rock { 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; + width: 14px; + height: 14px; + background: radial-gradient(circle at 35% 28%, #838a90, #34373c); + clip-path: polygon(22% 6%, 60% 0%, 90% 24%, 100% 58%, 82% 96%, 42% 100%, 8% 82%, 0% 40%); } -.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)); +.droll-n-1 .droll-rock { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } -.die-pips { - display: grid; - gap: 4px; - padding: 5px; +.droll-n-2 .droll-rock { + width: 12px; + height: 12px; } -.die-pips-1 { - grid-template-columns: 1fr; +.droll-n-2 .droll-rock:nth-child(1) { + top: 30%; + left: 30%; + transform: translate(-50%, -50%); } -.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); +.droll-n-2 .droll-rock:nth-child(2) { + top: 70%; + left: 70%; + transform: translate(-50%, -50%); } @keyframes fx-pop { diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 7a884b0..59ee396 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useGame.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/Dice.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useGame.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} \ No newline at end of file