Improve snappiness and add speed setting.
This commit is contained in:
Binary file not shown.
@@ -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')}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
Reference in New Issue
Block a user