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:
Redsandy
2026-08-13 12:57:59 +03:00
parent f0733d6918
commit 70beb96f1a
7 changed files with 805 additions and 155 deletions

View File

@@ -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 = { export type CoinFlightProps = {
id: number
left: number
delay: number
}
type Props = {
active: boolean active: boolean
amount: number spawns: CellPoint[]
durationMs: number boardRef: RefObject<HTMLElement | null>
fromSelector?: string 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) { export function CoinFlight({
const [coins, setCoins] = useState<Coin[]>([]) 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(() => { useEffect(() => {
if (!active || amount <= 0) { if (!active || !spawns.length) return
setCoins([])
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 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 ( const end = stagePoint(stageBox, target, 0.5, 0.55)
<div className="ff-coins" aria-hidden> const midPt = mid ? stagePoint(stageBox, mid, 0.5, 0.5) : null
{coins.map((c) => (
<span const nodes: HTMLSpanElement[] = []
key={c.id} const animations: Animation[] = []
className="ff-coins__coin" let hits = 0
style={{ let cancelled = false
left: `${c.left}%`,
animationDuration: `${durationMs}ms`, const finish = () => {
animationDelay: `${c.delay}ms`, if (cancelled) return
}} cancelled = true
> nodes.forEach((n) => n.remove())
doneRef.current?.()
</span> }
))}
</div> 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 />
} }

View File

@@ -7,8 +7,14 @@ import { ReelColumn } from './ReelColumn'
import { SPEED_ORDER, SPEED_TIMINGS, delay } from './speed' import { SPEED_ORDER, SPEED_TIMINGS, delay } from './speed'
import type { AnimSpeed } from './speed' import type { AnimSpeed } from './speed'
import { emptyGrid } from './symbols' import { emptyGrid } from './symbols'
import { WinCounter } from './WinCounter' import { WinCounter, type WinCounterHandle } from './WinCounter'
import { WinLinesOverlay } from './WinLinesOverlay' import { WinLinesOverlay } from './WinLinesOverlay'
import {
buildCoinSpawns,
collectWinCells,
prefersReducedMotion,
type CellPoint,
} from './winVfx'
type Props = { type Props = {
game: GameInfo game: GameInfo
@@ -19,6 +25,8 @@ type ReelState = {
stopping: boolean stopping: boolean
} }
type ToastState = { text: string; key: number } | null
const IDLE_REELS: ReelState[] = Array.from({ length: 5 }, () => ({ const IDLE_REELS: ReelState[] = Array.from({ length: 5 }, () => ({
spinning: false, spinning: false,
stopping: false, stopping: false,
@@ -44,19 +52,31 @@ export function ForestFortuneGame({ game }: Props) {
const [showLines, setShowLines] = useState(false) const [showLines, setShowLines] = useState(false)
const [sessionWin, setSessionWin] = useState(0) const [sessionWin, setSessionWin] = useState(0)
const [displayWin, setDisplayWin] = useState(0) const [displayWin, setDisplayWin] = useState(0)
const [coinBurst, setCoinBurst] = useState<{ active: boolean; amount: number }>({ const [coinActive, setCoinActive] = useState(false)
active: false, const [coinSpawns, setCoinSpawns] = useState<CellPoint[]>([])
amount: 0, const [coinViaMid, setCoinViaMid] = useState(false)
}) const [boardWin, setBoardWin] = useState(false)
const [winPulse, setWinPulse] = useState(false) const [toast, setToast] = useState<ToastState>(null)
const [sparkCells, setSparkCells] = useState<Set<string>>(new Set())
const cancelRef = useRef(false) const cancelRef = useRef(false)
const displayWinRef = useRef(0) 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 timing = SPEED_TIMINGS[speed]
const multiplier = current?.multiplier ?? 1
useEffect(() => { useEffect(() => {
return () => { return () => {
cancelRef.current = true cancelRef.current = true
coinDoneResolver.current?.()
} }
}, []) }, [])
@@ -94,31 +114,43 @@ export function ForestFortuneGame({ game }: Props) {
return cells return cells
}, [current, showLines]) }, [current, showLines])
async function animateCountUp(from: number, to: number, ms: number) { function syncCoinTargets() {
const start = performance.now() 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) => { return new Promise<void>((resolve) => {
const tick = (now: number) => { coinDoneResolver.current = () => {
if (cancelRef.current) { coinDoneResolver.current = null
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() resolve()
} }
}
requestAnimationFrame(tick)
}) })
} }
async function animateSpinOutcome(outcome: SpinOutcome) { async function animateSpinOutcome(outcome: SpinOutcome) {
setShowLines(false) setShowLines(false)
setCoinBurst({ active: false, amount: 0 }) setCoinActive(false)
setCoinSpawns([])
setCoinViaMid(false)
setBoardWin(false)
setToast(null)
setSparkCells(new Set())
setCurrent(outcome) setCurrent(outcome)
setGrid(outcome.grid) setGrid(outcome.grid)
@@ -146,27 +178,74 @@ export function ForestFortuneGame({ game }: Props) {
const winAmount = Number(outcome.total_win) const winAmount = Number(outcome.total_win)
const hasLines = outcome.line_wins.length > 0 || outcome.meta_triggered const hasLines = outcome.line_wins.length > 0 || outcome.meta_triggered
if (hasLines) { const viaMid = outcome.multiplier > 1
if (hasLines || winAmount > 0) {
setShowLines(true) setShowLines(true)
await delay(timing.lineHoldMs) setBoardWin(winAmount > 0)
if (outcome.meta_triggered) {
setToast({ text: 'ДУХ!', key: Date.now() })
} else if (winAmount > 0) {
setToast({ text: 'WIN', key: Date.now() })
} }
if (winAmount > 0 && !cancelRef.current) { const preCoin = Math.max(0, Math.floor(timing.lineDrawMs * 0.7))
setCoinBurst({ active: true, amount: winAmount }) await delay(preCoin)
setWinPulse(true) if (cancelRef.current) return
const from = displayWinRef.current
const to = from + winAmount // Let multiplier badge mount before measuring targets
await animateCountUp(from, to, timing.coinMs) await delay(16)
displayWinRef.current = to syncCoinTargets()
setSessionWin(to)
setDisplayWin(to) if (winAmount > 0) {
setWinPulse(false) const cells = collectWinCells(outcome)
setCoinBurst({ active: false, amount: 0 }) const spawns = buildCoinSpawns(cells, winAmount)
} else if (hasLines) { setSparkCells(new Set(spawns.map((c) => `${c.reel}-${c.row}`)))
await delay(Math.min(200, timing.betweenSpinsMs)) 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) setShowLines(false)
setBoardWin(false)
setToast(null)
setSparkCells(new Set())
await delay(timing.betweenSpinsMs) await delay(timing.betweenSpinsMs)
} }
@@ -178,6 +257,7 @@ export function ForestFortuneGame({ game }: Props) {
setDisplayWin(0) setDisplayWin(0)
displayWinRef.current = 0 displayWinRef.current = 0
setShowLines(false) setShowLines(false)
setCoinActive(false)
cancelRef.current = false cancelRef.current = false
try { try {
@@ -208,6 +288,9 @@ export function ForestFortuneGame({ game }: Props) {
} finally { } finally {
setBusy(false) setBusy(false)
setReels(IDLE_REELS) 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)) Math.min(maxBet, Math.max(minBet, bet)) * Math.min(maxSpins, Math.max(minSpins, spinCount))
).toFixed(2) ).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 ( return (
<section className="ff"> <section className="ff">
<header className="ff__header"> <header className="ff__header ff__header--bar">
<div className="ff__heading">
<h1 className="ff__title">{game.title}</h1> <h1 className="ff__title">{game.title}</h1>
<p className="ff__lead">{game.description}</p> <p className="ff__lead">{game.description}</p>
<div className="ff__meta"> </div>
<span> <div className="ff__balance" aria-label="Баланс">
Уровень <strong>{current?.level ?? 1}</strong> <span className="ff__balance-label">Баланс</span>
</span> <strong className="ff__balance-value">{user?.balance ?? '—'}</strong>
<span>
Множитель <strong>×{current?.multiplier ?? 1}</strong>
</span>
<span>
Баланс <strong>{user?.balance ?? '—'}</strong>
</span>
</div> </div>
</header> </header>
<div className="ff__stage"> <div className="ff__stage" ref={stageRef}>
<WinCounter value={displayWin} pulsing={winPulse} /> <div className="ff-win-wrap">
<WinCounter ref={counterApiRef} value={displayWin} multiplier={multiplier} />
</div>
<div className="ff__board-wrap"> <div className={`ff__board-wrap${boardWin ? ' ff-board--win' : ''}`}>
<div className="ff__board ff__board--reels" aria-label="Поле 5 на 5"> <div className="ff__board ff__board--reels" aria-label="Поле 5 на 5" ref={boardRef}>
{grid.map((column, reel) => ( {grid.map((column, reel) => (
<ReelColumn <ReelColumn
key={reel} key={reel}
@@ -253,6 +342,7 @@ export function ForestFortuneGame({ game }: Props) {
timing={timing} timing={timing}
winRows={winRowsByReel[reel] ?? new Set()} winRows={winRowsByReel[reel] ?? new Set()}
scatterRows={scatterRowsByReel[reel] ?? new Set()} scatterRows={scatterRowsByReel[reel] ?? new Set()}
sparkRows={sparkByReel[reel] ?? new Set()}
/> />
))} ))}
<WinLinesOverlay <WinLinesOverlay
@@ -261,11 +351,30 @@ export function ForestFortuneGame({ game }: Props) {
scatterCells={scatterCells} scatterCells={scatterCells}
drawMs={timing.lineDrawMs} 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> </div>
</div> </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"> <div className="ff__controls">
<div className="ff__row"> <div className="ff__row">
<label className="ff__field"> <label className="ff__field">

View File

@@ -10,6 +10,7 @@ type Props = {
timing: SpeedTiming timing: SpeedTiming
winRows: Set<number> winRows: Set<number>
scatterRows: Set<number> scatterRows: Set<number>
sparkRows?: Set<number>
} }
function blurColumn(): string[] { function blurColumn(): string[] {
@@ -24,6 +25,7 @@ export function ReelColumn({
timing, timing,
winRows, winRows,
scatterRows, scatterRows,
sparkRows,
}: Props) { }: Props) {
const [spinSymbols, setSpinSymbols] = useState(symbols) const [spinSymbols, setSpinSymbols] = useState(symbols)
const [mode, setMode] = useState<'idle' | 'spin' | 'stop'>('idle') const [mode, setMode] = useState<'idle' | 'spin' | 'stop'>('idle')
@@ -59,12 +61,13 @@ export function ReelColumn({
const row = mode === 'spin' ? -1 : idx const row = mode === 'spin' ? -1 : idx
const win = row >= 0 && winRows.has(row) const win = row >= 0 && winRows.has(row)
const scatter = row >= 0 && scatterRows.has(row) const scatter = row >= 0 && scatterRows.has(row)
const spark = row >= 0 && sparkRows?.has(row)
return ( return (
<div <div
key={`${reelIndex}-${mode}-${idx}`} key={`${reelIndex}-${mode}-${idx}`}
className={`ff-reel__cell${win ? ' ff-reel__cell--win' : ''}${ className={`ff-reel__cell${win ? ' ff-reel__cell--win' : ''}${
scatter ? ' ff-reel__cell--scatter' : '' scatter ? ' ff-reel__cell--scatter' : ''
}`} }${spark ? ' ff-reel__cell--spark' : ''}`}
> >
<span className="ff-reel__glyph">{symbolGlyph(sym)}</span> <span className="ff-reel__glyph">{symbolGlyph(sym)}</span>
</div> </div>

View File

@@ -1,13 +1,77 @@
type Props = { import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
value: number
pulsing: boolean 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 ( return (
<div className={`ff-win${pulsing ? ' ff-win--pulse' : ''}`} aria-live="polite"> <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> <span className="ff-win__label">Выигрыш</span>
<strong className="ff-win__value">{value.toFixed(2)}</strong> {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> </div>
) )
} })

View File

@@ -10,8 +10,12 @@ export type SpeedTiming = {
lineHoldMs: number lineHoldMs: number
/** Stroke draw duration for win lines */ /** Stroke draw duration for win lines */
lineDrawMs: number lineDrawMs: number
/** Coin flight + counter tick */ /** Single coin flight duration */
coinMs: number coinMs: number
/** Delay between coin launches */
coinStaggerMs: number
/** Counter shake duration hint */
counterShakeMs: number
/** Pause before next spin in pack */ /** Pause before next spin in pack */
betweenSpinsMs: number betweenSpinsMs: number
/** CSS animation duration for spinning strip */ /** CSS animation duration for spinning strip */
@@ -25,7 +29,9 @@ export const SPEED_TIMINGS: Record<AnimSpeed, SpeedTiming> = {
stopGapMs: 380, stopGapMs: 380,
lineHoldMs: 1600, lineHoldMs: 1600,
lineDrawMs: 750, lineDrawMs: 750,
coinMs: 900, coinMs: 720,
coinStaggerMs: 110,
counterShakeMs: 180,
betweenSpinsMs: 450, betweenSpinsMs: 450,
stripCycleMs: 420, stripCycleMs: 420,
}, },
@@ -35,7 +41,9 @@ export const SPEED_TIMINGS: Record<AnimSpeed, SpeedTiming> = {
stopGapMs: 220, stopGapMs: 220,
lineHoldMs: 1000, lineHoldMs: 1000,
lineDrawMs: 520, lineDrawMs: 520,
coinMs: 550, coinMs: 520,
coinStaggerMs: 70,
counterShakeMs: 140,
betweenSpinsMs: 280, betweenSpinsMs: 280,
stripCycleMs: 280, stripCycleMs: 280,
}, },
@@ -45,7 +53,9 @@ export const SPEED_TIMINGS: Record<AnimSpeed, SpeedTiming> = {
stopGapMs: 90, stopGapMs: 90,
lineHoldMs: 480, lineHoldMs: 480,
lineDrawMs: 260, lineDrawMs: 260,
coinMs: 280, coinMs: 300,
coinStaggerMs: 40,
counterShakeMs: 100,
betweenSpinsMs: 120, betweenSpinsMs: 120,
stripCycleMs: 160, stripCycleMs: 160,
}, },

View File

@@ -0,0 +1,75 @@
import type { PaylineWin, SpinOutcome } from '../../types'
import { winningSegment } from './WinLinesOverlay'
export type CellPoint = {
reel: number
row: number
/** 0100 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
}

View File

@@ -642,6 +642,18 @@ html.gate-transit-lock body {
text-align: center; 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 { .ff__title {
margin: 0; margin: 0;
font-family: var(--font-display); font-family: var(--font-display);
@@ -656,35 +668,68 @@ html.gate-transit-lock body {
font-size: 0.95rem; font-size: 0.95rem;
} }
.ff__meta { .ff__balance {
display: flex; flex: 0 0 auto;
flex-wrap: wrap; display: grid;
justify-content: center; justify-items: end;
gap: 0.75rem 1.1rem; gap: 0.15rem;
margin-top: 0.85rem; padding: 0.35rem 0.55rem;
font-size: 0.9rem; border-radius: 8px;
color: var(--cream); 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); color: var(--gold-soft);
font-variant-numeric: tabular-nums;
} }
.ff__stage { .ff__stage {
position: relative;
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
} }
.ff-win-wrap {
position: relative;
z-index: 5;
}
.ff-win { .ff-win {
display: flex; position: relative;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.95rem;
border-radius: 10px; border-radius: 10px;
background: linear-gradient(135deg, rgba(26, 27, 22, 0.9), rgba(58, 59, 49, 0.55)); 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); border: 1px solid rgba(166, 137, 70, 0.4);
box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.25); 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 { .ff-win__label {
@@ -694,29 +739,100 @@ html.gate-transit-lock body {
color: var(--muted); 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 { .ff-win__value {
justify-self: end;
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.65rem; font-size: 1.65rem;
color: var(--gold-soft); color: var(--gold-soft);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.ff-win--pulse { .ff-win__float {
animation: ff-win-pulse 0.45s ease-out; position: absolute;
border-color: var(--gold); 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 { .ff-win__shake-layer {
0% { display: none;
transform: scale(1); }
box-shadow: 0 0 0 rgba(166, 137, 70, 0);
@keyframes ff-win-shake {
0%,
100% {
transform: translateX(0) rotate(0deg);
}
20% {
transform: translateX(-3px) rotate(-0.6deg) scale(1.03);
} }
40% { 40% {
transform: scale(1.03); transform: translateX(3px) rotate(0.6deg) scale(1.04);
box-shadow: 0 0 22px rgba(166, 137, 70, 0.35); }
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% { 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; 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 { .ff__board {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); 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); 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 { .ff-reel__glyph {
font-size: clamp(1.2rem, 5.2vw, 1.75rem); font-size: clamp(1.2rem, 5.2vw, 1.75rem);
line-height: 1; line-height: 1;
@@ -888,33 +1052,70 @@ html.gate-transit-lock body {
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
z-index: 4; z-index: 6;
overflow: visible; overflow: visible;
} }
.ff-coins__coin { .ff-coins__coin {
position: absolute; position: absolute;
bottom: 42%; width: 1.15rem;
font-size: 0.95rem; height: 1.15rem;
color: var(--gold-soft); margin: 0;
text-shadow: 0 0 8px rgba(166, 137, 70, 0.7); border-radius: 50%;
animation-name: ff-coin-fly; display: grid;
animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1); place-items: center;
animation-fill-mode: forwards; font-size: 0.7rem;
opacity: 0; 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% { 0% {
opacity: 0; opacity: 0;
transform: translate(-50%, 0) scale(0.6); transform: translate(-50%, -40%) scale(0.6);
} }
15% { 25% {
opacity: 1; opacity: 1;
transform: translate(-50%, -50%) scale(1.12);
}
70% {
opacity: 1;
transform: translate(-50%, -55%) scale(1);
} }
100% { 100% {
opacity: 0; 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__dot-glow,
.ff-lines__scatter, .ff-lines__scatter,
.ff-coins__coin, .ff-coins__coin,
.ff-win--pulse { .ff-win__inner--shake,
animation: none; .ff-win__float,
.ff-win__mult--hit,
.ff-toast,
.ff-board--win::after,
.ff-reel__cell--spark {
animation: none !important;
}
.ff-toast {
opacity: 1; opacity: 1;
} }
} }