Improve snappiness and add speed setting.

This commit is contained in:
Greyson Parrelli
2026-07-26 14:21:56 -04:00
parent e3234b6f83
commit 4ed04c967b
6 changed files with 250 additions and 66 deletions
BIN
View File
Binary file not shown.
+65 -29
View File
@@ -4,10 +4,11 @@ import { createPortal } from 'react-dom'
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 { DiceRoll, ROLL_MS } from './DiceRoll' 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 // How long a settled rock roll (and its damage) stays on screen before the
// battle advances to the next step. // battle advances to the next step.
const ROCK_PAUSE_MS = 1000 const ROCK_PAUSE_MS = 650
interface Props { interface Props {
view: GameView view: GameView
@@ -37,28 +38,31 @@ interface SideVis {
leaving: string[] 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> = { const EVENT_MS: Record<BattleEvent['type'], number> = {
prep: 700, prep: 450,
reveal: 800, reveal: 520,
summon: 1000, summon: 650,
mill: 700, mill: 450,
// Lead-in before the dice appear; the roll itself is timed by ROLL_MS + // 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_PAUSE_MS once it's on screen (see the auto-advance effect).
rock: 500, rock: 320,
clash: 1400, clash: 900,
shield: 900, shield: 600,
strip: 1100, strip: 720,
steal: 1100, steal: 720,
eat: 1000, eat: 640,
heal: 900, heal: 600,
setaside: 700, setaside: 480,
release: 500, release: 380,
trumpet: 800, trumpet: 540,
prevent: 900, prevent: 600,
mana: 800, mana: 540,
ailment: 900, ailment: 600,
bounce: 1000, bounce: 680,
} }
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length 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 done = step >= events.length
const lastEvent = step > 0 ? events[step - 1] : null 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. // paused freezes auto-advance so the player can walk the log manually.
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
@@ -310,18 +319,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
return return
} }
setRockSettled(false) setRockSettled(false)
const t = window.setTimeout(() => setRockSettled(true), ROLL_MS) const t = window.setTimeout(() => setRockSettled(true), ROLL_MS / speed)
return () => window.clearTimeout(t) return () => window.clearTimeout(t)
}, [step, showingRock]) }, [step, showingRock, speed])
useEffect(() => { useEffect(() => {
if (done || paused) return if (done || paused) return
// The rock roll owns its timing: hold until the dice settle, then a full // The rock roll owns its timing: hold until the dice settle, then a full
// beat with the damage showing before moving on. // beat with the damage showing before moving on. Every beat is scaled by
const delay = showingRock ? ROLL_MS + ROCK_PAUSE_MS : (EVENT_MS[events[step].type] ?? 1000) // the speed multiplier so faster playback shortens the whole replay.
const t = window.setTimeout(() => setStep((s) => s + 1), delay) 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) 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. // Manual controls. Stepping by hand pauses playback so it doesn't fight you.
const stepTo = (n: number) => { const stepTo = (n: number) => {
@@ -422,7 +432,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
{rockDice && lastEvent?.seat === seat && ( {rockDice && lastEvent?.seat === seat && (
// The dice roll sits beside the throwing side's deck. // The dice roll sits beside the throwing side's deck.
<div className="stack-dice"> <div className="stack-dice">
<DiceRoll key={step} dice={rockDice} side={pos} /> <DiceRoll key={step} dice={rockDice} side={pos} speed={speed} />
</div> </div>
)} )}
</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 ( return (
<div className="battle"> <div className="battle" style={{ '--battle-speed': speed } as React.CSSProperties}>
<div className="battle-header"> <div className="battle-header">
<h2>Battle! Round {battle.round}</h2> <h2>Battle! Round {battle.round}</h2>
<div className="battle-controls"> <div className="battle-controls">
@@ -578,6 +592,24 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
> >
Skip Skip
</button> </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>
</div> </div>
@@ -589,7 +621,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
<div className="battlefield"> <div className="battlefield">
{renderSide(oppSeat, 'top')} {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> <span className="battle-center-bolt"></span>
</div> </div>
{renderSide(youSeat, 'bottom')} {renderSide(youSeat, 'bottom')}
+13 -4
View File
@@ -3,7 +3,8 @@ import type { CSSProperties } from 'react'
// How long the dice scramble before settling, and how fast the faces flicker // 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 // 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 export const ROLL_MS = 650
const TICK_MS = 70 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 // 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` // 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. // 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 [display, setDisplay] = useState<number[]>(dice)
const [settled, setSettled] = useState(false) const [settled, setSettled] = useState(false)
useEffect(() => { useEffect(() => {
const flicker = window.setInterval(() => { const flicker = window.setInterval(() => {
setDisplay(dice.map(() => Math.floor(Math.random() * 3))) setDisplay(dice.map(() => Math.floor(Math.random() * 3)))
}, TICK_MS) }, TICK_MS / speed)
const stop = window.setTimeout(() => { const stop = window.setTimeout(() => {
window.clearInterval(flicker) window.clearInterval(flicker)
setDisplay(dice) setDisplay(dice)
setSettled(true) setSettled(true)
}, ROLL_MS) }, ROLL_MS / speed)
return () => { return () => {
window.clearInterval(flicker) window.clearInterval(flicker)
window.clearTimeout(stop) window.clearTimeout(stop)
+134 -32
View File
@@ -1710,7 +1710,7 @@ h3 {
its former neighbors glide into the gap. */ its former neighbors glide into the gap. */
.card-ghost { .card-ghost {
z-index: 3; z-index: 3;
animation: card-ghost-out 280ms cubic-bezier(0.4, 0, 0.7, 0.4) forwards; animation: card-ghost-out 240ms cubic-bezier(0.4, 0, 0.7, 0.4) forwards;
} }
@keyframes card-ghost-out { @keyframes card-ghost-out {
@@ -1886,11 +1886,43 @@ h3 {
/* ---------- battle ---------- */ /* ---------- battle ---------- */
.battle { .battle {
/* Playback-speed multiplier, set inline from the speed picker. Every battle
animation divides its base duration by this, so the visuals quicken in
lockstep with the JS step timers. Fallback keeps calc() valid if unset. */
--battle-speed: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
} }
/* Speed picker in the playback controls. */
.battle-speed {
display: flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding-left: 6px;
border-left: 1px solid rgba(253, 243, 220, 0.18);
}
.battle-speed-label {
font-size: 0.9rem;
opacity: 0.7;
margin-right: 2px;
}
.battle-speed-btn {
min-width: 2.6em;
padding: 3px 5px;
font-size: 0.78rem;
}
.battle-speed-btn.is-active {
background: var(--gold);
color: #2a1d0c;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.battle-header { .battle-header {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -2073,7 +2105,7 @@ h3 {
left: 50%; left: 50%;
margin-left: -42px; margin-left: -42px;
z-index: 6; z-index: 6;
animation: summon-pop 1000ms ease forwards; animation: summon-pop calc(720ms / var(--battle-speed)) ease forwards;
pointer-events: none; pointer-events: none;
} }
@@ -2151,7 +2183,7 @@ h3 {
z-index: 60; z-index: 60;
font-size: 2rem; font-size: 2rem;
pointer-events: none; pointer-events: none;
animation: spawn-fly 1000ms ease-out forwards; animation: spawn-fly 760ms ease-out forwards;
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5)); filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
} }
@@ -2179,7 +2211,7 @@ h3 {
position: fixed; position: fixed;
z-index: 60; z-index: 60;
transform-origin: top left; transform-origin: top left;
animation: buy-fly 480ms cubic-bezier(0.5, 0.02, 0.3, 1) forwards; animation: buy-fly 380ms cubic-bezier(0.5, 0.02, 0.3, 1) forwards;
pointer-events: none; pointer-events: none;
filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.5)); filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.5));
} }
@@ -2244,7 +2276,7 @@ h3 {
.setaside-card.is-leaving, .setaside-card.is-leaving,
.food-fan-card.is-leaving { .food-fan-card.is-leaving {
animation: card-leave 400ms cubic-bezier(0.4, 0, 0.7, 0.4) forwards; animation: card-leave calc(300ms / var(--battle-speed)) cubic-bezier(0.4, 0, 0.7, 0.4) forwards;
pointer-events: none; pointer-events: none;
} }
@@ -2274,11 +2306,11 @@ h3 {
} }
.food-fan-card.food-in-bottom { .food-fan-card.food-in-bottom {
animation: food-in-bottom 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; animation: food-in-bottom calc(320ms / var(--battle-speed)) cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
} }
.food-fan-card.food-in-top { .food-fan-card.food-in-top {
animation: food-in-top 440ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; animation: food-in-top calc(320ms / var(--battle-speed)) cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
} }
/* A pet that faints with a pending effect slides out of the arena into the /* A pet that faints with a pending effect slides out of the arena into the
@@ -2298,7 +2330,7 @@ h3 {
} }
.setaside-card.setaside-in { .setaside-card.setaside-in {
animation: setaside-in 600ms cubic-bezier(0.2, 0.8, 0.3, 1) backwards; animation: setaside-in calc(440ms / var(--battle-speed)) cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
} }
.battle-unit { .battle-unit {
@@ -2354,12 +2386,12 @@ h3 {
.battle-unit.unit-reveal-bottom { .battle-unit.unit-reveal-bottom {
transform-origin: center bottom; transform-origin: center bottom;
animation: unit-reveal-bottom 520ms cubic-bezier(0.2, 0.8, 0.3, 1); animation: unit-reveal-bottom calc(380ms / var(--battle-speed)) cubic-bezier(0.2, 0.8, 0.3, 1);
} }
.battle-unit.unit-reveal-top { .battle-unit.unit-reveal-top {
transform-origin: center top; transform-origin: center top;
animation: unit-reveal-top 520ms cubic-bezier(0.2, 0.8, 0.3, 1); animation: unit-reveal-top calc(380ms / var(--battle-speed)) cubic-bezier(0.2, 0.8, 0.3, 1);
} }
/* --- rock dice --- */ /* --- rock dice --- */
@@ -2402,13 +2434,13 @@ h3 {
} }
.droll.is-rolling { .droll.is-rolling {
animation: droll-shake 170ms linear infinite; animation: droll-shake calc(150ms / var(--battle-speed)) linear infinite;
animation-delay: calc(var(--i) * 45ms); animation-delay: calc(var(--i) * 45ms / var(--battle-speed));
} }
.droll.is-settled { .droll.is-settled {
animation: droll-settle 280ms cubic-bezier(0.2, 0.85, 0.3, 1) both; animation: droll-settle calc(240ms / var(--battle-speed)) cubic-bezier(0.2, 0.85, 0.3, 1) both;
animation-delay: calc(var(--i) * 55ms); animation-delay: calc(var(--i) * 55ms / var(--battle-speed));
} }
@keyframes droll-shake { @keyframes droll-shake {
@@ -2494,7 +2526,7 @@ h3 {
font-size: 1rem; font-size: 1rem;
color: #7be495; color: #7be495;
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5); text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5);
animation: fx-pop 1000ms ease forwards; animation: fx-pop calc(760ms / var(--battle-speed)) ease forwards;
pointer-events: none; pointer-events: none;
z-index: 5; z-index: 5;
white-space: nowrap; white-space: nowrap;
@@ -2504,43 +2536,113 @@ h3 {
filter: grayscale(1); filter: grayscale(1);
} }
/* You (bottom) lunge up into the seam; the opponent (top) lunges down. */ /* You (bottom) lunge up into the seam; the opponent (top) lunges down. A quick
crouch of anticipation, an explosive lunge, then an impact recoil with a
scale punch and a little shake before settling — reads as a real collision
rather than a gentle bump. */
@keyframes clash-bottom { @keyframes clash-bottom {
0% { 0% {
transform: translateY(0); transform: translateY(0) scale(1) rotate(0);
} }
35% { 12% {
transform: translateY(-26px) rotate(2deg); transform: translateY(10px) scale(0.95);
} }
60% { 40% {
transform: translateY(6px); transform: translateY(-34px) scale(1.09) rotate(2.5deg);
}
52% {
transform: translateY(-21px) scale(0.98) rotate(-2.5deg);
}
67% {
transform: translateY(8px) rotate(1.5deg);
}
83% {
transform: translateY(-3px) rotate(-0.5deg);
} }
100% { 100% {
transform: translateY(0); transform: translateY(0) scale(1) rotate(0);
} }
} }
@keyframes clash-top { @keyframes clash-top {
0% { 0% {
transform: translateY(0); transform: translateY(0) scale(1) rotate(0);
} }
35% { 12% {
transform: translateY(26px) rotate(-2deg); transform: translateY(-10px) scale(0.95);
} }
60% { 40% {
transform: translateY(-6px); transform: translateY(34px) scale(1.09) rotate(-2.5deg);
}
52% {
transform: translateY(21px) scale(0.98) rotate(2.5deg);
}
67% {
transform: translateY(-8px) rotate(-1.5deg);
}
83% {
transform: translateY(3px) rotate(0.5deg);
} }
100% { 100% {
transform: translateY(0); transform: translateY(0) scale(1) rotate(0);
} }
} }
.battle-unit.clash-bottom { .battle-unit.clash-bottom {
animation: clash-bottom 500ms ease; animation: clash-bottom calc(460ms / var(--battle-speed)) cubic-bezier(0.36, 0.7, 0.3, 1);
} }
.battle-unit.clash-top { .battle-unit.clash-top {
animation: clash-top 500ms ease; animation: clash-top calc(460ms / var(--battle-speed)) cubic-bezier(0.36, 0.7, 0.3, 1);
}
/* When the two pets meet, the centre seam cracks: the bolt flares and a bright
shockwave ring bursts outward from the collision point. */
@keyframes clash-bolt {
0% {
transform: scale(0.5) rotate(-12deg);
opacity: 0.5;
filter: drop-shadow(0 0 6px rgba(246, 201, 78, 0.5));
}
30% {
transform: scale(1.7) rotate(6deg);
opacity: 1;
filter: drop-shadow(0 0 18px rgba(255, 236, 150, 0.95));
}
100% {
transform: scale(1) rotate(0);
opacity: 0.5;
filter: drop-shadow(0 0 8px rgba(246, 201, 78, 0.5));
}
}
@keyframes clash-shock {
0% {
transform: translate(-50%, -50%) scale(0.2);
opacity: 0.85;
}
100% {
transform: translate(-50%, -50%) scale(2.6);
opacity: 0;
}
}
.battle-center-clash .battle-center-bolt {
animation: clash-bolt calc(420ms / var(--battle-speed)) cubic-bezier(0.3, 0.8, 0.3, 1);
}
.battle-center-clash::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 46px;
height: 46px;
border-radius: 50%;
border: 3px solid rgba(255, 232, 150, 0.9);
box-shadow: 0 0 14px rgba(255, 224, 130, 0.7);
animation: clash-shock calc(420ms / var(--battle-speed)) ease-out forwards;
pointer-events: none;
} }
@keyframes dying { @keyframes dying {
@@ -2551,7 +2653,7 @@ h3 {
} }
.battle-unit.unit-dying { .battle-unit.unit-dying {
animation: dying 700ms ease 500ms forwards; animation: dying calc(480ms / var(--battle-speed)) ease calc(360ms / var(--battle-speed)) forwards;
} }
@keyframes damage-pop { @keyframes damage-pop {
@@ -2577,7 +2679,7 @@ h3 {
font-size: 1.4rem; font-size: 1.4rem;
color: #ff6b52; color: #ff6b52;
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5); text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5);
animation: damage-pop 1100ms ease 250ms forwards; animation: damage-pop calc(700ms / var(--battle-speed)) ease calc(160ms / var(--battle-speed)) forwards;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
z-index: 5; z-index: 5;
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from 'react'
// Playback speed for the battle replay. A multiplier: 2 means everything (JS
// step timers and CSS animation durations alike) runs twice as fast. The choice
// is remembered across sessions in localStorage so a player who likes it quick
// doesn't have to re-set it every battle.
const STORAGE_KEY = 'sap:battleSpeed'
// The values offered in the speed picker. 1 is the honest, savour-it pace;
// higher values are for players who've seen enough clashes for one evening.
export const SPEED_OPTIONS = [0.5, 1, 1.5, 2, 3] as const
const DEFAULT_SPEED = 1
function load(): number {
if (typeof localStorage === 'undefined') return DEFAULT_SPEED
const raw = localStorage.getItem(STORAGE_KEY)
const n = raw ? Number(raw) : NaN
// Guard against stale/garbage values, and snap to a known option.
return SPEED_OPTIONS.includes(n as (typeof SPEED_OPTIONS)[number]) ? n : DEFAULT_SPEED
}
export function useBattleSpeed(): [number, (n: number) => void] {
const [speed, setSpeed] = useState<number>(load)
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, String(speed))
} catch {
// Private-mode / storage-disabled: keep the in-memory value, just don't persist.
}
}, [speed])
const set = useCallback((n: number) => setSpeed(n), [])
return [speed, set]
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useCatalog.ts","./src/useGame.ts","./src/vite-env.d.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"} {"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useBattleSpeed.ts","./src/useCatalog.ts","./src/useGame.ts","./src/vite-env.d.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"}