diff --git a/web/src/anim.ts b/web/src/anim.ts index cb362f0..82ac011 100644 Binary files a/web/src/anim.ts and b/web/src/anim.ts differ diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 45a2119..ace7d34 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -4,10 +4,11 @@ import { createPortal } from 'react-dom' import type { BattleEvent, Card, ClientMessage, GameView } from '../types' import { CardView } from './CardView' 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 // battle advances to the next step. -const ROCK_PAUSE_MS = 1000 +const ROCK_PAUSE_MS = 650 interface Props { view: GameView @@ -37,28 +38,31 @@ interface SideVis { 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 = { - prep: 700, - reveal: 800, - summon: 1000, - mill: 700, + prep: 450, + reveal: 520, + summon: 650, + mill: 450, // 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, - steal: 1100, - eat: 1000, - heal: 900, - setaside: 700, - release: 500, - trumpet: 800, - prevent: 900, - mana: 800, - ailment: 900, - bounce: 1000, + rock: 320, + clash: 900, + shield: 600, + strip: 720, + steal: 720, + eat: 640, + heal: 600, + setaside: 480, + release: 380, + trumpet: 540, + prevent: 600, + mana: 540, + ailment: 600, + bounce: 680, } 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 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. const [paused, setPaused] = useState(false) @@ -310,18 +319,19 @@ export function BattlePhase({ view, send, step, setStep }: Props) { return } setRockSettled(false) - const t = window.setTimeout(() => setRockSettled(true), ROLL_MS) + const t = window.setTimeout(() => setRockSettled(true), ROLL_MS / speed) return () => window.clearTimeout(t) - }, [step, showingRock]) + }, [step, showingRock, speed]) useEffect(() => { if (done || paused) return // 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) + // beat with the damage showing before moving on. Every beat is scaled by + // the speed multiplier so faster playback shortens the whole replay. + 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) - }, [step, done, paused, events, showingRock]) + }, [step, done, paused, events, showingRock, speed]) // Manual controls. Stepping by hand pauses playback so it doesn't fight you. const stepTo = (n: number) => { @@ -422,7 +432,7 @@ export function BattlePhase({ view, send, step, setStep }: Props) { {rockDice && lastEvent?.seat === seat && ( // The dice roll sits beside the throwing side's deck.
- +
)} @@ -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 ( -
+

Battle! Round {battle.round}

@@ -578,6 +592,24 @@ export function BattlePhase({ view, send, step, setStep }: Props) { > Skip ⏭ +
+ + ⏱ + + {SPEED_OPTIONS.map((s) => ( + + ))} +
@@ -589,7 +621,11 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
{renderSide(oppSeat, 'top')} -
+
{renderSide(youSeat, 'bottom')} diff --git a/web/src/components/DiceRoll.tsx b/web/src/components/DiceRoll.tsx index 3721f64..410c5a2 100644 --- a/web/src/components/DiceRoll.tsx +++ b/web/src/components/DiceRoll.tsx @@ -3,7 +3,8 @@ 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. +// 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 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 // 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: 'top' | 'bottom' }) { +export function DiceRoll({ + dice, + side, + speed = 1, +}: { + dice: number[] + side: 'top' | 'bottom' + speed?: number +}) { 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) + }, TICK_MS / speed) const stop = window.setTimeout(() => { window.clearInterval(flicker) setDisplay(dice) setSettled(true) - }, ROLL_MS) + }, ROLL_MS / speed) return () => { window.clearInterval(flicker) window.clearTimeout(stop) diff --git a/web/src/styles.css b/web/src/styles.css index 8b37b81..7e57cf3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1710,7 +1710,7 @@ h3 { its former neighbors glide into the gap. */ .card-ghost { 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 { @@ -1886,11 +1886,43 @@ h3 { /* ---------- 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; flex-direction: column; 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 { display: flex; justify-content: center; @@ -2073,7 +2105,7 @@ h3 { left: 50%; margin-left: -42px; z-index: 6; - animation: summon-pop 1000ms ease forwards; + animation: summon-pop calc(720ms / var(--battle-speed)) ease forwards; pointer-events: none; } @@ -2151,7 +2183,7 @@ h3 { z-index: 60; font-size: 2rem; 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)); } @@ -2179,7 +2211,7 @@ h3 { position: fixed; z-index: 60; 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; filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.5)); } @@ -2244,7 +2276,7 @@ h3 { .setaside-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; } @@ -2274,11 +2306,11 @@ h3 { } .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 { - 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 @@ -2298,7 +2330,7 @@ h3 { } .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 { @@ -2354,12 +2386,12 @@ h3 { .battle-unit.unit-reveal-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 { 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 --- */ @@ -2402,13 +2434,13 @@ h3 { } .droll.is-rolling { - animation: droll-shake 170ms linear infinite; - animation-delay: calc(var(--i) * 45ms); + animation: droll-shake calc(150ms / var(--battle-speed)) linear infinite; + animation-delay: calc(var(--i) * 45ms / var(--battle-speed)); } .droll.is-settled { - animation: droll-settle 280ms cubic-bezier(0.2, 0.85, 0.3, 1) both; - animation-delay: calc(var(--i) * 55ms); + animation: droll-settle calc(240ms / var(--battle-speed)) cubic-bezier(0.2, 0.85, 0.3, 1) both; + animation-delay: calc(var(--i) * 55ms / var(--battle-speed)); } @keyframes droll-shake { @@ -2494,7 +2526,7 @@ h3 { font-size: 1rem; color: #7be495; 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; z-index: 5; white-space: nowrap; @@ -2504,43 +2536,113 @@ h3 { 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 { 0% { - transform: translateY(0); + transform: translateY(0) scale(1) rotate(0); } - 35% { - transform: translateY(-26px) rotate(2deg); + 12% { + transform: translateY(10px) scale(0.95); } - 60% { - transform: translateY(6px); + 40% { + 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% { - transform: translateY(0); + transform: translateY(0) scale(1) rotate(0); } } @keyframes clash-top { 0% { - transform: translateY(0); + transform: translateY(0) scale(1) rotate(0); } - 35% { - transform: translateY(26px) rotate(-2deg); + 12% { + transform: translateY(-10px) scale(0.95); } - 60% { - transform: translateY(-6px); + 40% { + 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% { - transform: translateY(0); + transform: translateY(0) scale(1) rotate(0); } } .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 { - 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 { @@ -2551,7 +2653,7 @@ h3 { } .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 { @@ -2577,7 +2679,7 @@ h3 { font-size: 1.4rem; color: #ff6b52; 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; pointer-events: none; z-index: 5; diff --git a/web/src/useBattleSpeed.ts b/web/src/useBattleSpeed.ts new file mode 100644 index 0000000..cf1707a --- /dev/null +++ b/web/src/useBattleSpeed.ts @@ -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(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] +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 637a1e8..dfc463b 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file