Enhance Forest Fortune game with coin flight animations and visual effects
- Introduced `CoinFlight` component for animated coin transitions during wins. - Updated `ForestFortuneGame` to manage coin spawning and animation logic. - Added visual effects for win indicators, including toast notifications and spark animations. - Refactored `WinCounter` to support multiplier animations and float labels. - Enhanced CSS styles for improved game visuals and animations. - Implemented utility functions for handling win cell collection and coin spawn generation.
This commit is contained in:
@@ -1,56 +1,236 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
import type { CellPoint } from './winVfx'
|
||||
import { prefersReducedMotion } from './winVfx'
|
||||
|
||||
type Coin = {
|
||||
id: number
|
||||
left: number
|
||||
delay: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
export type CoinFlightProps = {
|
||||
active: boolean
|
||||
amount: number
|
||||
durationMs: number
|
||||
fromSelector?: string
|
||||
spawns: CellPoint[]
|
||||
boardRef: RefObject<HTMLElement | null>
|
||||
stageRef: RefObject<HTMLElement | null>
|
||||
/** Final destination (win value) */
|
||||
targetRef: RefObject<HTMLElement | null>
|
||||
/** Optional waypoint (multiplier badge) */
|
||||
midRef?: RefObject<HTMLElement | null>
|
||||
/** When true, coins go cell → mid → target */
|
||||
viaMid?: boolean
|
||||
flightMs: number
|
||||
staggerMs: number
|
||||
onMidHit?: (index: number) => void
|
||||
onCoinHit?: (index: number) => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
let coinSeq = 0
|
||||
function stagePoint(
|
||||
stageBox: DOMRect,
|
||||
el: HTMLElement,
|
||||
biasX = 0.5,
|
||||
biasY = 0.5,
|
||||
): { x: number; y: number } {
|
||||
const box = el.getBoundingClientRect()
|
||||
return {
|
||||
x: box.left + box.width * biasX - stageBox.left,
|
||||
y: box.top + box.height * biasY - stageBox.top,
|
||||
}
|
||||
}
|
||||
|
||||
export function CoinFlight({ active, amount, durationMs }: Props) {
|
||||
const [coins, setCoins] = useState<Coin[]>([])
|
||||
export function CoinFlight({
|
||||
active,
|
||||
spawns,
|
||||
boardRef,
|
||||
stageRef,
|
||||
targetRef,
|
||||
midRef,
|
||||
viaMid = false,
|
||||
flightMs,
|
||||
staggerMs,
|
||||
onMidHit,
|
||||
onCoinHit,
|
||||
onComplete,
|
||||
}: CoinFlightProps) {
|
||||
const layerRef = useRef<HTMLDivElement>(null)
|
||||
const midHitRef = useRef(onMidHit)
|
||||
const hitRef = useRef(onCoinHit)
|
||||
const doneRef = useRef(onComplete)
|
||||
midHitRef.current = onMidHit
|
||||
hitRef.current = onCoinHit
|
||||
doneRef.current = onComplete
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || amount <= 0) {
|
||||
setCoins([])
|
||||
if (!active || !spawns.length) return
|
||||
|
||||
const stage = stageRef.current
|
||||
const board = boardRef.current
|
||||
const target = targetRef.current
|
||||
const layer = layerRef.current
|
||||
const mid = midRef?.current ?? null
|
||||
const useMid = Boolean(viaMid && mid)
|
||||
|
||||
if (!stage || !board || !target || !layer) {
|
||||
doneRef.current?.()
|
||||
return
|
||||
}
|
||||
const count = Math.min(14, Math.max(5, Math.round(Math.log10(amount + 1) * 6) + 4))
|
||||
const next: Coin[] = Array.from({ length: count }, (_, i) => ({
|
||||
id: ++coinSeq,
|
||||
left: 18 + Math.random() * 64,
|
||||
delay: (i / count) * durationMs * 0.35,
|
||||
}))
|
||||
setCoins(next)
|
||||
const t = window.setTimeout(() => setCoins([]), durationMs + 80)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [active, amount, durationMs])
|
||||
|
||||
if (!coins.length) return null
|
||||
const reduce = prefersReducedMotion()
|
||||
const stageBox = stage.getBoundingClientRect()
|
||||
const boardBox = board.getBoundingClientRect()
|
||||
const boardStyle = window.getComputedStyle(board)
|
||||
const padL = parseFloat(boardStyle.paddingLeft) || 0
|
||||
const padT = parseFloat(boardStyle.paddingTop) || 0
|
||||
const padR = parseFloat(boardStyle.paddingRight) || 0
|
||||
const padB = parseFloat(boardStyle.paddingBottom) || 0
|
||||
const contentW = Math.max(1, boardBox.width - padL - padR)
|
||||
const contentH = Math.max(1, boardBox.height - padT - padB)
|
||||
|
||||
return (
|
||||
<div className="ff-coins" aria-hidden>
|
||||
{coins.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="ff-coins__coin"
|
||||
style={{
|
||||
left: `${c.left}%`,
|
||||
animationDuration: `${durationMs}ms`,
|
||||
animationDelay: `${c.delay}ms`,
|
||||
}}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
const end = stagePoint(stageBox, target, 0.5, 0.55)
|
||||
const midPt = mid ? stagePoint(stageBox, mid, 0.5, 0.5) : null
|
||||
|
||||
const nodes: HTMLSpanElement[] = []
|
||||
const animations: Animation[] = []
|
||||
let hits = 0
|
||||
let cancelled = false
|
||||
|
||||
const finish = () => {
|
||||
if (cancelled) return
|
||||
cancelled = true
|
||||
nodes.forEach((n) => n.remove())
|
||||
doneRef.current?.()
|
||||
}
|
||||
|
||||
if (reduce) {
|
||||
spawns.forEach((_, i) => {
|
||||
if (useMid) midHitRef.current?.(i)
|
||||
hitRef.current?.(i)
|
||||
})
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
const leg1 = useMid ? Math.round(flightMs * 0.55) : flightMs
|
||||
const leg2 = useMid ? Math.round(flightMs * 0.55) : 0
|
||||
|
||||
spawns.forEach((spawn, index) => {
|
||||
const startX = boardBox.left - stageBox.left + padL + (spawn.x / 100) * contentW
|
||||
const startY = boardBox.top - stageBox.top + padT + (spawn.y / 100) * contentH
|
||||
|
||||
const el = document.createElement('span')
|
||||
el.className = 'ff-coins__coin'
|
||||
el.textContent = '●'
|
||||
el.style.left = `${startX}px`
|
||||
el.style.top = `${startY}px`
|
||||
layer.appendChild(el)
|
||||
nodes.push(el)
|
||||
|
||||
const delayMs = index * staggerMs
|
||||
const firstTarget = useMid && midPt ? midPt : end
|
||||
const ctrl1X = (startX + firstTarget.x) / 2 + (Math.random() - 0.5) * 36
|
||||
const ctrl1Y = Math.min(startY, firstTarget.y) - 36 - Math.random() * 24
|
||||
|
||||
const anim1 = el.animate(
|
||||
[
|
||||
{
|
||||
offset: 0,
|
||||
transform: 'translate(-50%, -50%) scale(0.55)',
|
||||
opacity: 0,
|
||||
},
|
||||
{
|
||||
offset: 0.14,
|
||||
transform: 'translate(-50%, -50%) scale(1.15)',
|
||||
opacity: 1,
|
||||
},
|
||||
{
|
||||
offset: 0.55,
|
||||
transform: `translate(calc(-50% + ${(ctrl1X - startX) * 0.55}px), calc(-50% + ${(ctrl1Y - startY) * 0.7}px)) scale(1)`,
|
||||
opacity: 1,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
transform: `translate(calc(-50% + ${firstTarget.x - startX}px), calc(-50% + ${firstTarget.y - startY}px)) scale(${useMid ? 0.85 : 0.45})`,
|
||||
opacity: useMid ? 1 : 0.15,
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: leg1,
|
||||
delay: delayMs,
|
||||
easing: 'cubic-bezier(0.22, 0.7, 0.2, 1)',
|
||||
fill: 'forwards',
|
||||
},
|
||||
)
|
||||
animations.push(anim1)
|
||||
|
||||
anim1.onfinish = () => {
|
||||
if (cancelled) return
|
||||
|
||||
if (!useMid || !midPt) {
|
||||
hitRef.current?.(index)
|
||||
el.classList.add('ff-coins__coin--hit')
|
||||
hits += 1
|
||||
window.setTimeout(() => el.remove(), 80)
|
||||
if (hits >= spawns.length) finish()
|
||||
return
|
||||
}
|
||||
|
||||
midHitRef.current?.(index)
|
||||
el.classList.add('ff-coins__coin--mid')
|
||||
|
||||
// Rebase element to mid position for second leg
|
||||
el.style.left = `${midPt.x}px`
|
||||
el.style.top = `${midPt.y}px`
|
||||
el.style.transform = 'translate(-50%, -50%) scale(0.85)'
|
||||
|
||||
const ctrl2X = (midPt.x + end.x) / 2 + (Math.random() - 0.5) * 20
|
||||
const ctrl2Y = Math.min(midPt.y, end.y) - 18 - Math.random() * 16
|
||||
|
||||
const anim2 = el.animate(
|
||||
[
|
||||
{
|
||||
offset: 0,
|
||||
transform: 'translate(-50%, -50%) scale(0.9)',
|
||||
opacity: 1,
|
||||
},
|
||||
{
|
||||
offset: 0.45,
|
||||
transform: `translate(calc(-50% + ${(ctrl2X - midPt.x) * 0.5}px), calc(-50% + ${(ctrl2Y - midPt.y) * 0.6}px)) scale(1.05)`,
|
||||
opacity: 1,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
transform: `translate(calc(-50% + ${end.x - midPt.x}px), calc(-50% + ${end.y - midPt.y}px)) scale(0.4)`,
|
||||
opacity: 0.12,
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: leg2,
|
||||
easing: 'cubic-bezier(0.25, 0.8, 0.2, 1)',
|
||||
fill: 'forwards',
|
||||
},
|
||||
)
|
||||
animations.push(anim2)
|
||||
|
||||
anim2.onfinish = () => {
|
||||
if (cancelled) return
|
||||
hitRef.current?.(index)
|
||||
el.classList.add('ff-coins__coin--hit')
|
||||
hits += 1
|
||||
window.setTimeout(() => el.remove(), 80)
|
||||
if (hits >= spawns.length) finish()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const safety = window.setTimeout(
|
||||
finish,
|
||||
staggerMs * spawns.length + leg1 + leg2 + 260,
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(safety)
|
||||
animations.forEach((a) => a.cancel())
|
||||
nodes.forEach((n) => n.remove())
|
||||
}
|
||||
}, [active, spawns, boardRef, stageRef, targetRef, midRef, viaMid, flightMs, staggerMs])
|
||||
|
||||
if (!active) return null
|
||||
|
||||
return <div ref={layerRef} className="ff-coins" aria-hidden />
|
||||
}
|
||||
|
||||
@@ -7,8 +7,14 @@ import { ReelColumn } from './ReelColumn'
|
||||
import { SPEED_ORDER, SPEED_TIMINGS, delay } from './speed'
|
||||
import type { AnimSpeed } from './speed'
|
||||
import { emptyGrid } from './symbols'
|
||||
import { WinCounter } from './WinCounter'
|
||||
import { WinCounter, type WinCounterHandle } from './WinCounter'
|
||||
import { WinLinesOverlay } from './WinLinesOverlay'
|
||||
import {
|
||||
buildCoinSpawns,
|
||||
collectWinCells,
|
||||
prefersReducedMotion,
|
||||
type CellPoint,
|
||||
} from './winVfx'
|
||||
|
||||
type Props = {
|
||||
game: GameInfo
|
||||
@@ -19,6 +25,8 @@ type ReelState = {
|
||||
stopping: boolean
|
||||
}
|
||||
|
||||
type ToastState = { text: string; key: number } | null
|
||||
|
||||
const IDLE_REELS: ReelState[] = Array.from({ length: 5 }, () => ({
|
||||
spinning: false,
|
||||
stopping: false,
|
||||
@@ -44,19 +52,31 @@ export function ForestFortuneGame({ game }: Props) {
|
||||
const [showLines, setShowLines] = useState(false)
|
||||
const [sessionWin, setSessionWin] = useState(0)
|
||||
const [displayWin, setDisplayWin] = useState(0)
|
||||
const [coinBurst, setCoinBurst] = useState<{ active: boolean; amount: number }>({
|
||||
active: false,
|
||||
amount: 0,
|
||||
})
|
||||
const [winPulse, setWinPulse] = useState(false)
|
||||
const [coinActive, setCoinActive] = useState(false)
|
||||
const [coinSpawns, setCoinSpawns] = useState<CellPoint[]>([])
|
||||
const [coinViaMid, setCoinViaMid] = useState(false)
|
||||
const [boardWin, setBoardWin] = useState(false)
|
||||
const [toast, setToast] = useState<ToastState>(null)
|
||||
const [sparkCells, setSparkCells] = useState<Set<string>>(new Set())
|
||||
|
||||
const cancelRef = useRef(false)
|
||||
const displayWinRef = useRef(0)
|
||||
const pendingHitsRef = useRef<{ remaining: number; step: number; end: number } | null>(null)
|
||||
const coinDoneResolver = useRef<(() => void) | null>(null)
|
||||
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const counterApiRef = useRef<WinCounterHandle>(null)
|
||||
const valueTargetRef = useRef<HTMLElement | null>(null)
|
||||
const multTargetRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const timing = SPEED_TIMINGS[speed]
|
||||
const multiplier = current?.multiplier ?? 1
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelRef.current = true
|
||||
coinDoneResolver.current?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -94,31 +114,43 @@ export function ForestFortuneGame({ game }: Props) {
|
||||
return cells
|
||||
}, [current, showLines])
|
||||
|
||||
async function animateCountUp(from: number, to: number, ms: number) {
|
||||
const start = performance.now()
|
||||
function syncCoinTargets() {
|
||||
valueTargetRef.current = counterApiRef.current?.getValueEl() ?? null
|
||||
multTargetRef.current = counterApiRef.current?.getMultEl() ?? null
|
||||
}
|
||||
|
||||
function onMidHit(_index: number) {
|
||||
counterApiRef.current?.shakeMult()
|
||||
}
|
||||
|
||||
function onCoinHit(_index: number) {
|
||||
const pending = pendingHitsRef.current
|
||||
if (!pending) return
|
||||
counterApiRef.current?.shake()
|
||||
const next = Math.min(pending.end, displayWinRef.current + pending.step)
|
||||
displayWinRef.current = next
|
||||
setDisplayWin(next)
|
||||
setSessionWin(next)
|
||||
pending.remaining -= 1
|
||||
}
|
||||
|
||||
function waitCoinsDone() {
|
||||
return new Promise<void>((resolve) => {
|
||||
const tick = (now: number) => {
|
||||
if (cancelRef.current) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const t = Math.min(1, (now - start) / ms)
|
||||
const eased = 1 - (1 - t) ** 3
|
||||
setDisplayWin(from + (to - from) * eased)
|
||||
if (t < 1) {
|
||||
requestAnimationFrame(tick)
|
||||
} else {
|
||||
setDisplayWin(to)
|
||||
resolve()
|
||||
}
|
||||
coinDoneResolver.current = () => {
|
||||
coinDoneResolver.current = null
|
||||
resolve()
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
})
|
||||
}
|
||||
|
||||
async function animateSpinOutcome(outcome: SpinOutcome) {
|
||||
setShowLines(false)
|
||||
setCoinBurst({ active: false, amount: 0 })
|
||||
setCoinActive(false)
|
||||
setCoinSpawns([])
|
||||
setCoinViaMid(false)
|
||||
setBoardWin(false)
|
||||
setToast(null)
|
||||
setSparkCells(new Set())
|
||||
setCurrent(outcome)
|
||||
setGrid(outcome.grid)
|
||||
|
||||
@@ -146,27 +178,74 @@ export function ForestFortuneGame({ game }: Props) {
|
||||
|
||||
const winAmount = Number(outcome.total_win)
|
||||
const hasLines = outcome.line_wins.length > 0 || outcome.meta_triggered
|
||||
if (hasLines) {
|
||||
setShowLines(true)
|
||||
await delay(timing.lineHoldMs)
|
||||
}
|
||||
const viaMid = outcome.multiplier > 1
|
||||
|
||||
if (winAmount > 0 && !cancelRef.current) {
|
||||
setCoinBurst({ active: true, amount: winAmount })
|
||||
setWinPulse(true)
|
||||
const from = displayWinRef.current
|
||||
const to = from + winAmount
|
||||
await animateCountUp(from, to, timing.coinMs)
|
||||
displayWinRef.current = to
|
||||
setSessionWin(to)
|
||||
setDisplayWin(to)
|
||||
setWinPulse(false)
|
||||
setCoinBurst({ active: false, amount: 0 })
|
||||
} else if (hasLines) {
|
||||
await delay(Math.min(200, timing.betweenSpinsMs))
|
||||
if (hasLines || winAmount > 0) {
|
||||
setShowLines(true)
|
||||
setBoardWin(winAmount > 0)
|
||||
if (outcome.meta_triggered) {
|
||||
setToast({ text: 'ДУХ!', key: Date.now() })
|
||||
} else if (winAmount > 0) {
|
||||
setToast({ text: 'WIN', key: Date.now() })
|
||||
}
|
||||
|
||||
const preCoin = Math.max(0, Math.floor(timing.lineDrawMs * 0.7))
|
||||
await delay(preCoin)
|
||||
if (cancelRef.current) return
|
||||
|
||||
// Let multiplier badge mount before measuring targets
|
||||
await delay(16)
|
||||
syncCoinTargets()
|
||||
|
||||
if (winAmount > 0) {
|
||||
const cells = collectWinCells(outcome)
|
||||
const spawns = buildCoinSpawns(cells, winAmount)
|
||||
setSparkCells(new Set(spawns.map((c) => `${c.reel}-${c.row}`)))
|
||||
const startVal = displayWinRef.current
|
||||
const endVal = startVal + winAmount
|
||||
pendingHitsRef.current = {
|
||||
remaining: spawns.length,
|
||||
step: spawns.length ? winAmount / spawns.length : winAmount,
|
||||
end: endVal,
|
||||
}
|
||||
|
||||
if (prefersReducedMotion() || !spawns.length) {
|
||||
if (viaMid) counterApiRef.current?.shakeMult()
|
||||
counterApiRef.current?.shake()
|
||||
displayWinRef.current = endVal
|
||||
setDisplayWin(endVal)
|
||||
setSessionWin(endVal)
|
||||
counterApiRef.current?.showFloat(winAmount)
|
||||
await delay(timing.lineHoldMs - preCoin)
|
||||
} else {
|
||||
setCoinSpawns(spawns)
|
||||
setCoinViaMid(viaMid)
|
||||
setCoinActive(true)
|
||||
const coinsPromise = waitCoinsDone()
|
||||
const holdRest = Math.max(
|
||||
0,
|
||||
timing.lineHoldMs - preCoin,
|
||||
spawns.length * timing.coinStaggerMs + timing.coinMs * (viaMid ? 1.15 : 1),
|
||||
)
|
||||
await Promise.all([coinsPromise, delay(holdRest)])
|
||||
displayWinRef.current = endVal
|
||||
setDisplayWin(endVal)
|
||||
setSessionWin(endVal)
|
||||
counterApiRef.current?.showFloat(winAmount)
|
||||
setCoinActive(false)
|
||||
setCoinSpawns([])
|
||||
setCoinViaMid(false)
|
||||
}
|
||||
pendingHitsRef.current = null
|
||||
} else {
|
||||
await delay(Math.max(0, timing.lineHoldMs - preCoin))
|
||||
}
|
||||
}
|
||||
|
||||
setShowLines(false)
|
||||
setBoardWin(false)
|
||||
setToast(null)
|
||||
setSparkCells(new Set())
|
||||
await delay(timing.betweenSpinsMs)
|
||||
}
|
||||
|
||||
@@ -178,6 +257,7 @@ export function ForestFortuneGame({ game }: Props) {
|
||||
setDisplayWin(0)
|
||||
displayWinRef.current = 0
|
||||
setShowLines(false)
|
||||
setCoinActive(false)
|
||||
cancelRef.current = false
|
||||
|
||||
try {
|
||||
@@ -208,6 +288,9 @@ export function ForestFortuneGame({ game }: Props) {
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setReels(IDLE_REELS)
|
||||
setCoinActive(false)
|
||||
setBoardWin(false)
|
||||
setToast(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,29 +303,35 @@ export function ForestFortuneGame({ game }: Props) {
|
||||
Math.min(maxBet, Math.max(minBet, bet)) * Math.min(maxSpins, Math.max(minSpins, spinCount))
|
||||
).toFixed(2)
|
||||
|
||||
const sparkByReel = useMemo(() => {
|
||||
const map: Array<Set<number>> = Array.from({ length: 5 }, () => new Set())
|
||||
sparkCells.forEach((key) => {
|
||||
const [r, row] = key.split('-').map(Number)
|
||||
if (r != null && row != null) map[r]?.add(row)
|
||||
})
|
||||
return map
|
||||
}, [sparkCells])
|
||||
|
||||
return (
|
||||
<section className="ff">
|
||||
<header className="ff__header">
|
||||
<h1 className="ff__title">{game.title}</h1>
|
||||
<p className="ff__lead">{game.description}</p>
|
||||
<div className="ff__meta">
|
||||
<span>
|
||||
Уровень <strong>{current?.level ?? 1}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Множитель <strong>×{current?.multiplier ?? 1}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Баланс <strong>{user?.balance ?? '—'}</strong>
|
||||
</span>
|
||||
<header className="ff__header ff__header--bar">
|
||||
<div className="ff__heading">
|
||||
<h1 className="ff__title">{game.title}</h1>
|
||||
<p className="ff__lead">{game.description}</p>
|
||||
</div>
|
||||
<div className="ff__balance" aria-label="Баланс">
|
||||
<span className="ff__balance-label">Баланс</span>
|
||||
<strong className="ff__balance-value">{user?.balance ?? '—'}</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="ff__stage">
|
||||
<WinCounter value={displayWin} pulsing={winPulse} />
|
||||
<div className="ff__stage" ref={stageRef}>
|
||||
<div className="ff-win-wrap">
|
||||
<WinCounter ref={counterApiRef} value={displayWin} multiplier={multiplier} />
|
||||
</div>
|
||||
|
||||
<div className="ff__board-wrap">
|
||||
<div className="ff__board ff__board--reels" aria-label="Поле 5 на 5">
|
||||
<div className={`ff__board-wrap${boardWin ? ' ff-board--win' : ''}`}>
|
||||
<div className="ff__board ff__board--reels" aria-label="Поле 5 на 5" ref={boardRef}>
|
||||
{grid.map((column, reel) => (
|
||||
<ReelColumn
|
||||
key={reel}
|
||||
@@ -253,6 +342,7 @@ export function ForestFortuneGame({ game }: Props) {
|
||||
timing={timing}
|
||||
winRows={winRowsByReel[reel] ?? new Set()}
|
||||
scatterRows={scatterRowsByReel[reel] ?? new Set()}
|
||||
sparkRows={sparkByReel[reel] ?? new Set()}
|
||||
/>
|
||||
))}
|
||||
<WinLinesOverlay
|
||||
@@ -261,9 +351,28 @@ export function ForestFortuneGame({ game }: Props) {
|
||||
scatterCells={scatterCells}
|
||||
drawMs={timing.lineDrawMs}
|
||||
/>
|
||||
{toast && (
|
||||
<div key={toast.key} className="ff-toast" aria-hidden>
|
||||
{toast.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CoinFlight active={coinBurst.active} amount={coinBurst.amount} durationMs={timing.coinMs} />
|
||||
</div>
|
||||
|
||||
<CoinFlight
|
||||
active={coinActive}
|
||||
spawns={coinSpawns}
|
||||
boardRef={boardRef}
|
||||
stageRef={stageRef}
|
||||
targetRef={valueTargetRef}
|
||||
midRef={multTargetRef}
|
||||
viaMid={coinViaMid}
|
||||
flightMs={timing.coinMs}
|
||||
staggerMs={timing.coinStaggerMs}
|
||||
onMidHit={onMidHit}
|
||||
onCoinHit={onCoinHit}
|
||||
onComplete={() => coinDoneResolver.current?.()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ff__controls">
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
timing: SpeedTiming
|
||||
winRows: Set<number>
|
||||
scatterRows: Set<number>
|
||||
sparkRows?: Set<number>
|
||||
}
|
||||
|
||||
function blurColumn(): string[] {
|
||||
@@ -24,6 +25,7 @@ export function ReelColumn({
|
||||
timing,
|
||||
winRows,
|
||||
scatterRows,
|
||||
sparkRows,
|
||||
}: Props) {
|
||||
const [spinSymbols, setSpinSymbols] = useState(symbols)
|
||||
const [mode, setMode] = useState<'idle' | 'spin' | 'stop'>('idle')
|
||||
@@ -59,12 +61,13 @@ export function ReelColumn({
|
||||
const row = mode === 'spin' ? -1 : idx
|
||||
const win = row >= 0 && winRows.has(row)
|
||||
const scatter = row >= 0 && scatterRows.has(row)
|
||||
const spark = row >= 0 && sparkRows?.has(row)
|
||||
return (
|
||||
<div
|
||||
key={`${reelIndex}-${mode}-${idx}`}
|
||||
className={`ff-reel__cell${win ? ' ff-reel__cell--win' : ''}${
|
||||
scatter ? ' ff-reel__cell--scatter' : ''
|
||||
}`}
|
||||
}${spark ? ' ff-reel__cell--spark' : ''}`}
|
||||
>
|
||||
<span className="ff-reel__glyph">{symbolGlyph(sym)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,77 @@
|
||||
type Props = {
|
||||
value: number
|
||||
pulsing: boolean
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
|
||||
|
||||
export type WinCounterHandle = {
|
||||
shake: () => void
|
||||
shakeMult: () => void
|
||||
showFloat: (amount: number) => void
|
||||
getMultEl: () => HTMLElement | null
|
||||
getValueEl: () => HTMLElement | null
|
||||
}
|
||||
|
||||
export function WinCounter({ value, pulsing }: Props) {
|
||||
type Props = {
|
||||
value: number
|
||||
multiplier: number
|
||||
}
|
||||
|
||||
export const WinCounter = forwardRef<WinCounterHandle, Props>(function WinCounter(
|
||||
{ value, multiplier },
|
||||
ref,
|
||||
) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const multRef = useRef<HTMLSpanElement>(null)
|
||||
const valueRef = useRef<HTMLStrongElement>(null)
|
||||
const [shakeKey, setShakeKey] = useState(0)
|
||||
const [multShakeKey, setMultShakeKey] = useState(0)
|
||||
const [impact, setImpact] = useState(false)
|
||||
const [floatLabel, setFloatLabel] = useState<string | null>(null)
|
||||
const floatTimer = useRef<number | null>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
shake() {
|
||||
setShakeKey((k) => k + 1)
|
||||
setImpact(true)
|
||||
window.setTimeout(() => setImpact(false), 160)
|
||||
},
|
||||
shakeMult() {
|
||||
setMultShakeKey((k) => k + 1)
|
||||
},
|
||||
showFloat(amount: number) {
|
||||
if (floatTimer.current) window.clearTimeout(floatTimer.current)
|
||||
setFloatLabel(`+${amount.toFixed(2)}`)
|
||||
floatTimer.current = window.setTimeout(() => setFloatLabel(null), 900)
|
||||
},
|
||||
getMultEl: () => multRef.current,
|
||||
getValueEl: () => valueRef.current,
|
||||
}))
|
||||
|
||||
const showMult = multiplier > 1
|
||||
|
||||
return (
|
||||
<div className={`ff-win${pulsing ? ' ff-win--pulse' : ''}`} aria-live="polite">
|
||||
<span className="ff-win__label">Выигрыш</span>
|
||||
<strong className="ff-win__value">{value.toFixed(2)}</strong>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`ff-win${impact ? ' ff-win--impact' : ''}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
className={`ff-win__inner${shakeKey > 0 ? ' ff-win__inner--shake' : ''}`}
|
||||
key={`shake-${shakeKey}`}
|
||||
>
|
||||
<span className="ff-win__label">Выигрыш</span>
|
||||
{showMult && (
|
||||
<span
|
||||
ref={multRef}
|
||||
key={`mult-${multShakeKey}`}
|
||||
className={`ff-win__mult${multShakeKey > 0 ? ' ff-win__mult--hit' : ''}`}
|
||||
>
|
||||
×{multiplier}
|
||||
</span>
|
||||
)}
|
||||
{!showMult && <span ref={multRef} className="ff-win__mult-slot" aria-hidden />}
|
||||
<strong ref={valueRef} className="ff-win__value">
|
||||
{value.toFixed(2)}
|
||||
</strong>
|
||||
</div>
|
||||
{floatLabel && <span className="ff-win__float">{floatLabel}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,8 +10,12 @@ export type SpeedTiming = {
|
||||
lineHoldMs: number
|
||||
/** Stroke draw duration for win lines */
|
||||
lineDrawMs: number
|
||||
/** Coin flight + counter tick */
|
||||
/** Single coin flight duration */
|
||||
coinMs: number
|
||||
/** Delay between coin launches */
|
||||
coinStaggerMs: number
|
||||
/** Counter shake duration hint */
|
||||
counterShakeMs: number
|
||||
/** Pause before next spin in pack */
|
||||
betweenSpinsMs: number
|
||||
/** CSS animation duration for spinning strip */
|
||||
@@ -25,7 +29,9 @@ export const SPEED_TIMINGS: Record<AnimSpeed, SpeedTiming> = {
|
||||
stopGapMs: 380,
|
||||
lineHoldMs: 1600,
|
||||
lineDrawMs: 750,
|
||||
coinMs: 900,
|
||||
coinMs: 720,
|
||||
coinStaggerMs: 110,
|
||||
counterShakeMs: 180,
|
||||
betweenSpinsMs: 450,
|
||||
stripCycleMs: 420,
|
||||
},
|
||||
@@ -35,7 +41,9 @@ export const SPEED_TIMINGS: Record<AnimSpeed, SpeedTiming> = {
|
||||
stopGapMs: 220,
|
||||
lineHoldMs: 1000,
|
||||
lineDrawMs: 520,
|
||||
coinMs: 550,
|
||||
coinMs: 520,
|
||||
coinStaggerMs: 70,
|
||||
counterShakeMs: 140,
|
||||
betweenSpinsMs: 280,
|
||||
stripCycleMs: 280,
|
||||
},
|
||||
@@ -45,7 +53,9 @@ export const SPEED_TIMINGS: Record<AnimSpeed, SpeedTiming> = {
|
||||
stopGapMs: 90,
|
||||
lineHoldMs: 480,
|
||||
lineDrawMs: 260,
|
||||
coinMs: 280,
|
||||
coinMs: 300,
|
||||
coinStaggerMs: 40,
|
||||
counterShakeMs: 100,
|
||||
betweenSpinsMs: 120,
|
||||
stripCycleMs: 160,
|
||||
},
|
||||
|
||||
75
src/games/forest-fortune/winVfx.ts
Normal file
75
src/games/forest-fortune/winVfx.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { PaylineWin, SpinOutcome } from '../../types'
|
||||
import { winningSegment } from './WinLinesOverlay'
|
||||
|
||||
export type CellPoint = {
|
||||
reel: number
|
||||
row: number
|
||||
/** 0–100 within board content box */
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export function cellPct(reel: number, row: number): { x: number; y: number } {
|
||||
return {
|
||||
x: ((reel + 0.5) / 5) * 100,
|
||||
y: ((row + 0.5) / 5) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
/** Unique winning cells from line segments (+ scatter on meta), left-to-right order. */
|
||||
export function collectWinCells(outcome: SpinOutcome): CellPoint[] {
|
||||
const seen = new Set<string>()
|
||||
const points: CellPoint[] = []
|
||||
|
||||
const push = (reel: number, row: number) => {
|
||||
const key = `${reel}-${row}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
const { x, y } = cellPct(reel, row)
|
||||
points.push({ reel, row, x, y })
|
||||
}
|
||||
|
||||
const lineWins: PaylineWin[] = outcome.line_wins
|
||||
for (const win of lineWins) {
|
||||
const segment = winningSegment(win)
|
||||
segment.forEach((row, reel) => push(reel, row))
|
||||
}
|
||||
|
||||
if (outcome.meta_triggered) {
|
||||
outcome.grid.forEach((col, reel) => {
|
||||
col.forEach((sym, row) => {
|
||||
if (sym === 'scatter') push(reel, row)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: if win with no lines (edge), use board center
|
||||
if (!points.length && Number(outcome.total_win) > 0) {
|
||||
const { x, y } = cellPct(2, 2)
|
||||
points.push({ reel: 2, row: 2, x, y })
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
/** Build coin spawn list: 1 per cell, +echo for big wins, cap 12. */
|
||||
export function buildCoinSpawns(cells: CellPoint[], winAmount: number): CellPoint[] {
|
||||
if (!cells.length) return []
|
||||
const echo = winAmount >= 50 ? 2 : winAmount >= 15 ? 1 : 0
|
||||
const out: CellPoint[] = []
|
||||
for (let e = 0; e <= echo; e += 1) {
|
||||
for (const c of cells) {
|
||||
out.push({
|
||||
...c,
|
||||
x: c.x + (Math.random() - 0.5) * 4,
|
||||
y: c.y + (Math.random() - 0.5) * 4,
|
||||
})
|
||||
if (out.length >= 12) return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
287
src/index.css
287
src/index.css
@@ -642,6 +642,18 @@ html.gate-transit-lock body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ff__header--bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ff__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ff__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
@@ -656,35 +668,68 @@ html.gate-transit-lock body {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ff__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem 1.1rem;
|
||||
margin-top: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--cream);
|
||||
.ff__balance {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.15rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 16, 13, 0.45);
|
||||
border: 1px solid rgba(166, 137, 70, 0.28);
|
||||
}
|
||||
|
||||
.ff__meta strong {
|
||||
.ff__balance-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ff__balance-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
color: var(--gold-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ff__stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ff-win-wrap {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.ff-win {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.95rem;
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(26, 27, 22, 0.9), rgba(58, 59, 49, 0.55));
|
||||
border: 1px solid rgba(166, 137, 70, 0.4);
|
||||
box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.25);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ff-win__inner {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem 0.95rem;
|
||||
}
|
||||
|
||||
.ff-win__inner--shake {
|
||||
animation: ff-win-shake 0.28s ease-out;
|
||||
}
|
||||
|
||||
.ff-win--impact {
|
||||
border-color: var(--gold);
|
||||
box-shadow:
|
||||
0 0 18px rgba(166, 137, 70, 0.45),
|
||||
inset 0 0 18px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.ff-win__label {
|
||||
@@ -694,29 +739,100 @@ html.gate-transit-lock body {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ff-win__mult {
|
||||
justify-self: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.55rem, 5vw, 2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #c0392b;
|
||||
text-shadow:
|
||||
0 0 10px rgba(192, 57, 43, 0.45),
|
||||
0 1px 0 rgba(15, 16, 13, 0.55);
|
||||
transform: rotate(-12deg);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ff-win__mult--hit {
|
||||
animation: ff-mult-hit 0.28s ease-out;
|
||||
}
|
||||
|
||||
.ff-win__mult-slot {
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ff-win__value {
|
||||
justify-self: end;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.65rem;
|
||||
color: var(--gold-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ff-win--pulse {
|
||||
animation: ff-win-pulse 0.45s ease-out;
|
||||
border-color: var(--gold);
|
||||
.ff-win__float {
|
||||
position: absolute;
|
||||
right: 0.85rem;
|
||||
top: -0.35rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--gold-soft);
|
||||
text-shadow: 0 0 12px rgba(166, 137, 70, 0.65);
|
||||
animation: ff-win-float 0.9s ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes ff-win-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 rgba(166, 137, 70, 0);
|
||||
.ff-win__shake-layer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes ff-win-shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0) rotate(0deg);
|
||||
}
|
||||
20% {
|
||||
transform: translateX(-3px) rotate(-0.6deg) scale(1.03);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.03);
|
||||
box-shadow: 0 0 22px rgba(166, 137, 70, 0.35);
|
||||
transform: translateX(3px) rotate(0.6deg) scale(1.04);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-2px) rotate(-0.3deg);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(2px) rotate(0.3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ff-win-float {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.85);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1.08);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
transform: translateY(-22px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ff-mult-hit {
|
||||
0% {
|
||||
transform: rotate(-12deg) scale(1);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(-8deg) scale(1.35);
|
||||
color: #e74c3c;
|
||||
text-shadow: 0 0 16px rgba(231, 76, 60, 0.7);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(-12deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +840,31 @@ html.gate-transit-lock body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ff-board--win::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
box-shadow:
|
||||
inset 0 0 28px rgba(166, 137, 70, 0.35),
|
||||
0 0 24px rgba(166, 137, 70, 0.25);
|
||||
animation: ff-board-flash 0.7s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ff-board-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.ff__board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
@@ -827,6 +968,29 @@ html.gate-transit-lock body {
|
||||
box-shadow: inset 0 0 16px rgba(200, 217, 125, 0.28);
|
||||
}
|
||||
|
||||
.ff-reel__cell--spark {
|
||||
animation: ff-cell-spark 0.55s ease-out;
|
||||
background: rgba(226, 200, 120, 0.28);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(226, 200, 120, 0.7),
|
||||
0 0 14px rgba(226, 200, 120, 0.45);
|
||||
}
|
||||
|
||||
@keyframes ff-cell-spark {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
filter: brightness(1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.06);
|
||||
filter: brightness(1.35);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
filter: brightness(1);
|
||||
}
|
||||
}
|
||||
|
||||
.ff-reel__glyph {
|
||||
font-size: clamp(1.2rem, 5.2vw, 1.75rem);
|
||||
line-height: 1;
|
||||
@@ -888,33 +1052,70 @@ html.gate-transit-lock body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
z-index: 6;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ff-coins__coin {
|
||||
position: absolute;
|
||||
bottom: 42%;
|
||||
font-size: 0.95rem;
|
||||
color: var(--gold-soft);
|
||||
text-shadow: 0 0 8px rgba(166, 137, 70, 0.7);
|
||||
animation-name: ff-coin-fly;
|
||||
animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
animation-fill-mode: forwards;
|
||||
opacity: 0;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
color: #1a1b16;
|
||||
background: radial-gradient(circle at 35% 30%, #f0d9a0, #a68946 55%, #7a6230);
|
||||
box-shadow:
|
||||
0 0 10px rgba(226, 200, 120, 0.75),
|
||||
inset 0 0 4px rgba(255, 255, 255, 0.35);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes ff-coin-fly {
|
||||
.ff-coins__coin--mid {
|
||||
box-shadow: 0 0 14px rgba(231, 76, 60, 0.65);
|
||||
}
|
||||
|
||||
.ff-coins__coin--hit {
|
||||
box-shadow: 0 0 16px rgba(226, 200, 120, 1);
|
||||
}
|
||||
|
||||
.ff-toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 42%;
|
||||
z-index: 5;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.8rem, 8vw, 2.6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--gold-soft);
|
||||
text-shadow:
|
||||
0 0 18px rgba(166, 137, 70, 0.75),
|
||||
0 2px 0 rgba(15, 16, 13, 0.8);
|
||||
pointer-events: none;
|
||||
animation: ff-toast-pop 0.85s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes ff-toast-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0) scale(0.6);
|
||||
transform: translate(-50%, -40%) scale(0.6);
|
||||
}
|
||||
15% {
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.12);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -55%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -120px) scale(1.15);
|
||||
transform: translate(-50%, -70%) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,8 +1209,16 @@ html.gate-transit-lock body {
|
||||
.ff-lines__dot-glow,
|
||||
.ff-lines__scatter,
|
||||
.ff-coins__coin,
|
||||
.ff-win--pulse {
|
||||
animation: none;
|
||||
.ff-win__inner--shake,
|
||||
.ff-win__float,
|
||||
.ff-win__mult--hit,
|
||||
.ff-toast,
|
||||
.ff-board--win::after,
|
||||
.ff-reel__cell--spark {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.ff-toast {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user