Compare commits

..

3 Commits

Author SHA1 Message Date
Redsandy
70beb96f1a 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.
2026-08-13 12:57:59 +03:00
Redsandy
f0733d6918 Refactor game API and introduce Forest Fortune game
- Updated API types to replace `GameStub` with `GameInfo` and added `SessionSpinResult` and `SpinOutcome` types.
- Modified API requests in `api.ts` to use the new game types.
- Implemented the `ForestFortuneGame` component with game logic, including spinning mechanics and win animations.
- Created supporting components: `CoinFlight`, `ReelColumn`, `WinCounter`, and `WinLinesOverlay` for game UI.
- Added CSS styles for the Forest Fortune game layout and animations.
- Updated `GamePage` and `HomePage` to integrate the new game and reflect its status correctly.
2026-08-12 15:27:27 +03:00
Redsandy
dfe445349b Add raven animation feature with SVG assets and Three.js integration
- Introduced a new SVG asset for the raven silhouette.
- Updated CSS to position and style the raven animation layer.
- Implemented `RavenBurstCanvas` and `RavenFlock` components for rendering animated ravens using Three.js.
- Enhanced `GateTransitProvider` to manage raven animation state and integrate with existing transition logic.
- Added `ravenFx` type and default values for controlling raven animation properties.
- Adjusted transform origins and z-index for improved visual alignment during transitions.
2026-08-12 12:41:12 +03:00
20 changed files with 2242 additions and 44 deletions

View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="160" viewBox="0 0 256 160">
<g fill="#0a0b08">
<!-- body -->
<ellipse cx="128" cy="88" rx="28" ry="16" />
<!-- neck + head -->
<path d="M100 82c-10-4-22-4-32 2-3 2-7 1-8-2 6-10 20-16 34-14 6 1 10 6 11 12 0 2-2 3-5 2z" />
<ellipse cx="78" cy="78" rx="14" ry="10" transform="rotate(-18 78 78)" />
<!-- beak -->
<path d="M64 76l-18 2 18 5z" />
<!-- left wing -->
<path d="M120 80c-28-22-62-36-96-34-6 0-8-6-3-9 38-16 78-8 108 14 8 6 10 16 4 22-4 3-9 4-13 7z" />
<!-- right wing -->
<path d="M136 78c26-24 60-40 96-42 6 0 9 6 4 9-34 18-66 34-90 52-6 4-14 2-16-4-2-6 2-12 6-15z" />
<!-- tail -->
<path d="M148 92c16 8 28 24 30 40 0 4-5 5-7 2-8-14-18-26-32-34-3-2-2-7 2-8 2 0 5 0 7 0z" />
<!-- legs tucked -->
<path d="M122 100c2 10 1 18-2 24-1 2-4 1-4-1 2-8 2-15 0-22 0-2 3-3 6-1z" />
</g>
<circle cx="72" cy="74" r="2.6" fill="#c8d97d" />
</svg>

After

Width:  |  Height:  |  Size: 968 B

View File

@@ -1,4 +1,4 @@
import type { GameStub, TokenResponse, User, Wallet } from './types'
import type { GameInfo, SessionSpinResult, TokenResponse, User, Wallet } from './types'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'
const TOKEN_KEY = 'vyryi_token'
@@ -32,7 +32,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
let detail = `Request failed (${response.status})`
try {
const body = (await response.json()) as { detail?: string }
if (body.detail) detail = body.detail
if (body.detail) detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
} catch {
/* ignore */
}
@@ -73,11 +73,18 @@ export const api = {
})
},
listGames(): Promise<GameStub[]> {
return request<GameStub[]>('/games')
listGames(): Promise<GameInfo[]> {
return request<GameInfo[]>('/games')
},
getGame(slug: string): Promise<GameStub> {
return request<GameStub>(`/games/${slug}`)
getGame(slug: string): Promise<GameInfo> {
return request<GameInfo>(`/games/${slug}`)
},
spin(slug: string, bet: number, spinCount: number): Promise<SessionSpinResult> {
return request<SessionSpinResult>(`/games/${slug}/spin`, {
method: 'POST',
body: JSON.stringify({ bet, spin_count: spinCount }),
})
},
}

View File

@@ -0,0 +1,236 @@
import { useEffect, useRef, type RefObject } from 'react'
import type { CellPoint } from './winVfx'
import { prefersReducedMotion } from './winVfx'
export type CoinFlightProps = {
active: boolean
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
}
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,
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 || !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 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)
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 />
}

View File

@@ -0,0 +1,439 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../api'
import { useAuth } from '../../auth'
import type { GameInfo, SessionSpinResult, SpinOutcome } from '../../types'
import { CoinFlight } from './CoinFlight'
import { ReelColumn } from './ReelColumn'
import { SPEED_ORDER, SPEED_TIMINGS, delay } from './speed'
import type { AnimSpeed } from './speed'
import { emptyGrid } from './symbols'
import { WinCounter, type WinCounterHandle } from './WinCounter'
import { WinLinesOverlay } from './WinLinesOverlay'
import {
buildCoinSpawns,
collectWinCells,
prefersReducedMotion,
type CellPoint,
} from './winVfx'
type Props = {
game: GameInfo
}
type ReelState = {
spinning: boolean
stopping: boolean
}
type ToastState = { text: string; key: number } | null
const IDLE_REELS: ReelState[] = Array.from({ length: 5 }, () => ({
spinning: false,
stopping: false,
}))
export function ForestFortuneGame({ game }: Props) {
const { user, setUser } = useAuth()
const minBet = Number(game.min_bet) || 1
const maxBet = Number(game.max_bet) || 1000
const minSpins = game.min_spin_count || 1
const maxSpins = game.max_spin_count || 1000
const [bet, setBet] = useState(Math.max(minBet, 10))
const [spinCount, setSpinCount] = useState(10)
const [speed, setSpeed] = useState<AnimSpeed>('normal')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [grid, setGrid] = useState(emptyGrid)
const [current, setCurrent] = useState<SpinOutcome | null>(null)
const [summary, setSummary] = useState<SessionSpinResult | null>(null)
const [replayIndex, setReplayIndex] = useState(0)
const [reels, setReels] = useState<ReelState[]>(IDLE_REELS)
const [showLines, setShowLines] = useState(false)
const [sessionWin, setSessionWin] = useState(0)
const [displayWin, setDisplayWin] = useState(0)
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?.()
}
}, [])
const winRowsByReel = useMemo(() => {
const map: Array<Set<number>> = Array.from({ length: 5 }, () => new Set())
if (!showLines || !current) return map
for (const win of current.line_wins) {
const n = Math.min(win.count, win.path.length)
for (let reel = 0; reel < n; reel += 1) {
map[reel]?.add(win.path[reel]!)
}
}
return map
}, [current, showLines])
const scatterRowsByReel = useMemo(() => {
const map: Array<Set<number>> = Array.from({ length: 5 }, () => new Set())
if (!showLines || !current?.meta_triggered) return map
current.grid.forEach((col, reel) => {
col.forEach((sym, row) => {
if (sym === 'scatter') map[reel]?.add(row)
})
})
return map
}, [current, showLines])
const scatterCells = useMemo(() => {
if (!showLines || !current?.meta_triggered) return []
const cells: Array<{ reel: number; row: number }> = []
current.grid.forEach((col, reel) => {
col.forEach((sym, row) => {
if (sym === 'scatter') cells.push({ reel, row })
})
})
return cells
}, [current, showLines])
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) => {
coinDoneResolver.current = () => {
coinDoneResolver.current = null
resolve()
}
})
}
async function animateSpinOutcome(outcome: SpinOutcome) {
setShowLines(false)
setCoinActive(false)
setCoinSpawns([])
setCoinViaMid(false)
setBoardWin(false)
setToast(null)
setSparkCells(new Set())
setCurrent(outcome)
setGrid(outcome.grid)
setReels(Array.from({ length: 5 }, () => ({ spinning: true, stopping: false })))
await delay(timing.spinLeadMs)
if (cancelRef.current) return
for (let reel = 0; reel < 5; reel += 1) {
if (cancelRef.current) return
setReels((prev) =>
prev.map((r, i) => {
if (i < reel) return { spinning: false, stopping: false }
if (i === reel) return { spinning: true, stopping: true }
return r
}),
)
await delay(Math.max(timing.stopGapMs, 180))
setReels((prev) =>
prev.map((r, i) => (i === reel ? { spinning: false, stopping: false } : r)),
)
}
setReels(IDLE_REELS)
if (cancelRef.current) return
const winAmount = Number(outcome.total_win)
const hasLines = outcome.line_wins.length > 0 || outcome.meta_triggered
const viaMid = outcome.multiplier > 1
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)
}
async function play() {
setBusy(true)
setError(null)
setSummary(null)
setSessionWin(0)
setDisplayWin(0)
displayWinRef.current = 0
setShowLines(false)
setCoinActive(false)
cancelRef.current = false
try {
const safeBet = Math.min(maxBet, Math.max(minBet, bet))
const safeCount = Math.min(maxSpins, Math.max(minSpins, Math.floor(spinCount)))
const result = await api.spin(game.slug, safeBet, safeCount)
if (user) setUser({ ...user, balance: result.balance })
for (let i = 0; i < result.spins.length; i += 1) {
if (cancelRef.current) break
setReplayIndex(i + 1)
await animateSpinOutcome(result.spins[i]!)
}
setSummary(result)
if (result.spins.length) {
const last = result.spins[result.spins.length - 1]!
setCurrent(last)
setGrid(last.grid)
}
const total = Number(result.total_win)
displayWinRef.current = total
setSessionWin(total)
setDisplayWin(total)
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка спина')
setReels(IDLE_REELS)
} finally {
setBusy(false)
setReels(IDLE_REELS)
setCoinActive(false)
setBoardWin(false)
setToast(null)
}
}
function cycleSpeed() {
const idx = SPEED_ORDER.indexOf(speed)
setSpeed(SPEED_ORDER[(idx + 1) % SPEED_ORDER.length]!)
}
const cost = (
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 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" ref={stageRef}>
<div className="ff-win-wrap">
<WinCounter ref={counterApiRef} value={displayWin} multiplier={multiplier} />
</div>
<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}
reelIndex={reel}
symbols={column}
spinning={reels[reel]?.spinning ?? false}
stopping={reels[reel]?.stopping ?? false}
timing={timing}
winRows={winRowsByReel[reel] ?? new Set()}
scatterRows={scatterRowsByReel[reel] ?? new Set()}
sparkRows={sparkByReel[reel] ?? new Set()}
/>
))}
<WinLinesOverlay
lineWins={current?.line_wins ?? []}
visible={showLines}
scatterCells={scatterCells}
drawMs={timing.lineDrawMs}
/>
{toast && (
<div key={toast.key} className="ff-toast" aria-hidden>
{toast.text}
</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__row">
<label className="ff__field">
<span>Ставка</span>
<input
type="number"
min={minBet}
max={maxBet}
step={1}
value={bet}
disabled={busy}
onChange={(e) => setBet(Number(e.target.value))}
/>
</label>
<label className="ff__field">
<span>Спинов</span>
<input
type="number"
min={minSpins}
max={maxSpins}
step={1}
value={spinCount}
disabled={busy}
onChange={(e) => setSpinCount(Number(e.target.value))}
/>
</label>
</div>
<div className="ff__row ff__row--actions">
<button type="button" className="btn btn--outline ff__speed" disabled={busy} onClick={cycleSpeed}>
Скорость: {SPEED_TIMINGS[speed].label}
</button>
<button type="button" className="btn btn--gold ff__spin" disabled={busy} onClick={() => void play()}>
{busy ? `Спин ${replayIndex}/${spinCount}` : `Крутить · ${cost}`}
</button>
</div>
</div>
{error && <p className="error">{error}</p>}
{current && busy && (
<p className="ff__spin-info">
{Number(current.total_win) > 0 ? `+${current.total_win}` : 'Пусто'}
{current.meta_triggered ? ' · дух поднял уровень!' : ''}
</p>
)}
{summary && !busy && (
<div className="ff__summary">
<h2>Итог пачки</h2>
<p>
Ставка {summary.total_bet} выигрыш <strong>{summary.total_win}</strong>
</p>
<p>
Триггеров: {summary.trigger_count} · макс. уровень: {summary.max_level}
{sessionWin > 0 ? ` · насчитано ${sessionWin.toFixed(2)}` : ''}
</p>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,80 @@
import { useEffect, useState, type CSSProperties } from 'react'
import { randomSymbol, symbolGlyph } from './reelUtils'
import type { SpeedTiming } from './speed'
type Props = {
reelIndex: number
symbols: string[]
spinning: boolean
stopping: boolean
timing: SpeedTiming
winRows: Set<number>
scatterRows: Set<number>
sparkRows?: Set<number>
}
function blurColumn(): string[] {
return Array.from({ length: 10 }, () => randomSymbol())
}
export function ReelColumn({
reelIndex,
symbols,
spinning,
stopping,
timing,
winRows,
scatterRows,
sparkRows,
}: Props) {
const [spinSymbols, setSpinSymbols] = useState(symbols)
const [mode, setMode] = useState<'idle' | 'spin' | 'stop'>('idle')
useEffect(() => {
if (spinning && !stopping) {
setSpinSymbols(blurColumn())
setMode('spin')
return
}
if (stopping) {
setMode('stop')
return
}
setMode('idle')
}, [spinning, stopping])
const cells = mode === 'spin' ? spinSymbols : symbols
const rendered = mode === 'spin' ? [...cells, ...cells] : cells
const style: CSSProperties | undefined =
mode === 'spin' ? { ['--ff-spin-ms' as string]: `${timing.stripCycleMs}ms` } : undefined
return (
<div
className={`ff-reel${mode === 'spin' ? ' ff-reel--spin' : ''}${mode === 'stop' ? ' ff-reel--stop' : ''}`}
data-reel={reelIndex}
style={style}
>
<div className="ff-reel__viewport">
<div className="ff-reel__strip">
{rendered.map((sym, idx) => {
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>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
export type WinCounterHandle = {
shake: () => void
shakeMult: () => void
showFloat: (amount: number) => void
getMultEl: () => HTMLElement | null
getValueEl: () => HTMLElement | null
}
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
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>
)
})

View File

@@ -0,0 +1,167 @@
import { useLayoutEffect, useRef } from 'react'
import type { PaylineWin } from '../../types'
type Props = {
lineWins: PaylineWin[]
visible: boolean
scatterCells: Array<{ reel: number; row: number }>
/** How long the stroke takes to draw, ms */
drawMs?: number
}
function cellCenter(reel: number, row: number): { x: number; y: number } {
return {
x: (reel + 0.5) * 20,
y: (row + 0.5) * 20,
}
}
function toPath(pts: Array<{ x: number; y: number }>): string {
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' ')
}
const LINE_COLORS = ['#e2c878', '#c8d97d', '#f0d9a0', '#c4a96a', '#d9b56a']
export function winningSegment(win: PaylineWin): number[] {
const n = Math.min(Math.max(win.count, 0), win.path.length)
return win.path.slice(0, n)
}
function TracePath({
d,
color,
delayMs,
drawMs,
className,
strokeWidth,
}: {
d: string
color: string
delayMs: number
drawMs: number
className: string
strokeWidth: number
}) {
const ref = useRef<SVGPathElement>(null)
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const length = el.getTotalLength()
el.style.strokeDasharray = `${length}`
el.style.strokeDashoffset = `${length}`
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reduce) {
el.style.strokeDashoffset = '0'
return
}
const anim = el.animate(
[{ strokeDashoffset: length }, { strokeDashoffset: 0 }],
{
duration: drawMs,
delay: delayMs,
easing: 'ease-out',
fill: 'forwards',
},
)
return () => anim.cancel()
}, [d, delayMs, drawMs])
return (
<path
ref={ref}
className={className}
d={d}
stroke={color}
fill="none"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
)
}
export function WinLinesOverlay({ lineWins, visible, scatterCells, drawMs = 520 }: Props) {
if (!visible) return null
return (
<svg className="ff-lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden>
{lineWins.map((win, i) => {
const segment = winningSegment(win)
if (segment.length < 2) return null
const pts = segment.map((row, reel) => cellCenter(reel, row))
const d = toPath(pts)
const color = LINE_COLORS[i % LINE_COLORS.length]!
const lineDelay = i * 90
const steps = Math.max(segment.length - 1, 1)
return (
<g key={`line-${win.line_index}-${i}`}>
<TracePath
className="ff-lines__glow"
d={d}
color={color}
delayMs={lineDelay}
drawMs={drawMs}
strokeWidth={5}
/>
<TracePath
className="ff-lines__path"
d={d}
color={color}
delayMs={lineDelay}
drawMs={drawMs}
strokeWidth={2.4}
/>
{pts.map((p, reel) => {
const t = reel / steps
const dotDelay = lineDelay + drawMs * t
return (
<g key={`dot-${win.line_index}-${reel}`}>
<circle
className="ff-lines__dot-glow"
cx={p.x}
cy={p.y}
r="5.5"
fill={color}
style={{ animationDelay: `${dotDelay}ms` }}
/>
<circle
className="ff-lines__dot"
cx={p.x}
cy={p.y}
r="3.2"
fill="#1a1b16"
stroke={color}
strokeWidth="1.4"
style={{ animationDelay: `${dotDelay}ms` }}
/>
</g>
)
})}
</g>
)
})}
{scatterCells.map(({ reel, row }) => {
const { x, y } = cellCenter(reel, row)
return (
<circle
key={`sc-${reel}-${row}`}
className="ff-lines__scatter"
cx={x}
cy={y}
r="6"
fill="rgba(200, 217, 125, 0.22)"
stroke="#c8d97d"
strokeWidth="1"
/>
)
})}
</svg>
)
}

View File

@@ -0,0 +1,22 @@
import { SYMBOL_LABELS } from './symbols'
const SPIN_POOL = ['leaf', 'mushroom', 'pine', 'deer', 'fox', 'oak', 'wild', 'scatter'] as const
export function randomSymbol(): string {
return SPIN_POOL[Math.floor(Math.random() * SPIN_POOL.length)]!
}
/** Build a long strip ending with the final 5 symbols (bottom-aligned stop). */
export function buildSpinStrip(finalColumn: string[], cycles = 8): string[] {
const strip: string[] = []
for (let c = 0; c < cycles; c += 1) {
for (let i = 0; i < 5; i += 1) {
strip.push(randomSymbol())
}
}
return [...strip, ...finalColumn]
}
export function symbolGlyph(sym: string): string {
return SYMBOL_LABELS[sym] ?? '?'
}

View File

@@ -0,0 +1,70 @@
export type AnimSpeed = 'slow' | 'normal' | 'fast'
export type SpeedTiming = {
label: string
/** Initial full-spin before first reel stops */
spinLeadMs: number
/** Delay between consecutive reel stops */
stopGapMs: number
/** How long win lines stay visible (includes draw) */
lineHoldMs: number
/** Stroke draw duration for win lines */
lineDrawMs: number
/** 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 */
stripCycleMs: number
}
export const SPEED_TIMINGS: Record<AnimSpeed, SpeedTiming> = {
slow: {
label: 'Медленно',
spinLeadMs: 700,
stopGapMs: 380,
lineHoldMs: 1600,
lineDrawMs: 750,
coinMs: 720,
coinStaggerMs: 110,
counterShakeMs: 180,
betweenSpinsMs: 450,
stripCycleMs: 420,
},
normal: {
label: 'Обычная',
spinLeadMs: 420,
stopGapMs: 220,
lineHoldMs: 1000,
lineDrawMs: 520,
coinMs: 520,
coinStaggerMs: 70,
counterShakeMs: 140,
betweenSpinsMs: 280,
stripCycleMs: 280,
},
fast: {
label: 'Быстро',
spinLeadMs: 180,
stopGapMs: 90,
lineHoldMs: 480,
lineDrawMs: 260,
coinMs: 300,
coinStaggerMs: 40,
counterShakeMs: 100,
betweenSpinsMs: 120,
stripCycleMs: 160,
},
}
export const SPEED_ORDER: AnimSpeed[] = ['slow', 'normal', 'fast']
export function delay(ms: number) {
return new Promise<void>((resolve) => {
window.setTimeout(resolve, ms)
})
}

View File

@@ -0,0 +1,34 @@
export const SYMBOL_LABELS: Record<string, string> = {
leaf: '🍃',
mushroom: '🍄',
pine: '🌲',
deer: '🦌',
fox: '🦊',
oak: '🌳',
wild: '🪵',
scatter: '✨',
}
export const SYMBOL_NAMES: Record<string, string> = {
leaf: 'Лист',
mushroom: 'Гриб',
pine: 'Сосна',
deer: 'Олень',
fox: 'Лиса',
oak: 'Дуб',
wild: 'Корень',
scatter: 'Дух',
}
export function emptyGrid(): string[][] {
return Array.from({ length: 5 }, () => Array.from({ length: 5 }, () => 'leaf'))
}
/** Convert reel-major grid [reel][row] to row-major for CSS grid display. */
export function toRows(grid: string[][]): string[][] {
const rows: string[][] = []
for (let row = 0; row < 5; row += 1) {
rows.push(grid.map((reel) => reel[row] ?? 'leaf'))
}
return rows
}

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

@@ -564,7 +564,7 @@ html.gate-transit-lock body {
position: absolute;
inset: 0;
perspective: 1400px;
perspective-origin: 50% 46%;
perspective-origin: 50% 50%;
}
.gate-transit__hero {
@@ -582,8 +582,21 @@ html.gate-transit-lock body {
user-select: none;
object-fit: contain;
filter: drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6));
z-index: 1;
}
.gate-transit__ravens {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.gate-transit__ravens canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.gate-transit__veil {
position: absolute;
@@ -616,3 +629,599 @@ html.gate-transit-lock body {
}
}
/* Forest Fortune */
.ff {
display: grid;
gap: 1.1rem;
max-width: 420px;
margin: 0 auto;
padding-bottom: 2rem;
}
.ff__header {
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);
font-size: clamp(1.8rem, 6vw, 2.4rem);
color: var(--gold-soft);
letter-spacing: 0.04em;
}
.ff__lead {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.95rem;
}
.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__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 {
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 {
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
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__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;
}
.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: 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% {
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);
}
}
.ff__board-wrap {
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);
gap: 0.35rem;
padding: 0.65rem;
border-radius: 12px;
background:
linear-gradient(160deg, rgba(58, 70, 42, 0.55), rgba(26, 27, 22, 0.9)),
radial-gradient(circle at 30% 20%, rgba(166, 137, 70, 0.18), transparent 55%);
border: 1px solid rgba(166, 137, 70, 0.35);
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.35);
}
.ff__board--reels {
position: relative;
aspect-ratio: 1;
gap: 0;
overflow: hidden;
padding: 0.55rem;
}
.ff-reel {
position: relative;
overflow: hidden;
border-radius: 0;
background: rgba(15, 16, 13, 0.55);
border: none;
box-shadow: inset -1px 0 0 rgba(154, 148, 134, 0.16);
height: 100%;
min-height: 0;
}
.ff-reel:last-child {
box-shadow: none;
}
.ff-reel__viewport {
height: 100%;
overflow: hidden;
}
.ff-reel__strip {
display: flex;
flex-direction: column;
height: 100%;
}
.ff-reel--spin .ff-reel__strip {
height: 200%;
animation: ff-reel-spin var(--ff-spin-ms, 280ms) linear infinite;
}
.ff-reel--spin .ff-reel__cell {
filter: blur(1.2px);
opacity: 0.88;
}
.ff-reel--stop {
animation: ff-reel-land 0.28s cubic-bezier(0.12, 0.78, 0.18, 1);
}
@keyframes ff-reel-spin {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
@keyframes ff-reel-land {
0% {
transform: translateY(-10px);
}
60% {
transform: translateY(3px);
}
100% {
transform: translateY(0);
}
}
.ff-reel__cell {
flex: 1 0 0;
min-height: 0;
display: grid;
place-items: center;
border-bottom: 1px solid rgba(154, 148, 134, 0.08);
}
.ff-reel--spin .ff-reel__cell {
flex: 1 0 10%;
}
.ff-reel__cell--win {
background: rgba(166, 137, 70, 0.22);
box-shadow: inset 0 0 0 1px rgba(166, 137, 70, 0.55);
}
.ff-reel__cell--scatter {
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;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35));
}
.ff-lines {
position: absolute;
inset: 0.55rem;
width: calc(100% - 1.1rem);
height: calc(100% - 1.1rem);
pointer-events: none;
z-index: 3;
overflow: visible;
}
.ff-lines__glow {
opacity: 1;
filter: blur(1.5px);
}
.ff-lines__path {
opacity: 1;
}
.ff-lines__dot,
.ff-lines__dot-glow {
opacity: 0;
animation: ff-scatter-pop 0.28s ease-out forwards;
}
.ff-lines__dot-glow {
filter: blur(2px);
}
@keyframes ff-line-trace {
to {
stroke-dashoffset: 0;
}
}
.ff-lines__scatter {
opacity: 0;
animation: ff-scatter-pop 0.4s ease-out 0.15s forwards;
}
@keyframes ff-scatter-pop {
from {
opacity: 0;
transform: scale(0.6);
}
to {
opacity: 1;
transform: scale(1);
}
}
.ff-coins {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 6;
overflow: visible;
}
.ff-coins__coin {
position: absolute;
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;
}
.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%, -40%) scale(0.6);
}
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%, -70%) scale(0.95);
}
}
.ff__controls {
display: grid;
gap: 0.75rem;
}
.ff__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
}
.ff__row--actions {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
.ff__field {
display: grid;
gap: 0.3rem;
font-size: 0.85rem;
color: var(--muted);
}
.ff__field input {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 8px;
border: 1px solid rgba(166, 137, 70, 0.35);
background: rgba(15, 16, 13, 0.55);
color: var(--cream);
}
.ff__speed {
min-height: 2.8rem;
width: 100%;
font-size: 0.9rem;
}
.ff__spin {
width: 100%;
min-height: 2.8rem;
}
.ff__spin-info {
margin: 0;
text-align: center;
color: var(--cream);
font-size: 0.92rem;
}
.ff__summary {
padding: 0.9rem 1rem;
border-radius: 10px;
background: rgba(15, 16, 13, 0.45);
border: 1px solid rgba(166, 137, 70, 0.28);
text-align: center;
}
.ff__summary h2 {
margin: 0 0 0.4rem;
font-family: var(--font-display);
font-size: 1.25rem;
color: var(--gold-soft);
}
.ff__summary p {
margin: 0.25rem 0;
color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
.ff-reel--spin .ff-reel__strip,
.ff-reel--stop {
animation: none;
}
.ff-reel--spin .ff-reel__cell {
filter: none;
}
.ff-lines__path,
.ff-lines__glow {
animation: none !important;
stroke-dashoffset: 0 !important;
opacity: 1;
}
.ff-lines__dot,
.ff-lines__dot-glow,
.ff-lines__scatter,
.ff-coins__coin,
.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;
}
}

View File

@@ -2,12 +2,13 @@ import { useEffect, useState } from 'react'
import { Link, Navigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../auth'
import type { GameStub } from '../types'
import { ForestFortuneGame } from '../games/forest-fortune/ForestFortuneGame'
import type { GameInfo } from '../types'
export function GamePage() {
const { slug } = useParams<{ slug: string }>()
const { user, loading } = useAuth()
const [game, setGame] = useState<GameStub | null>(null)
const [game, setGame] = useState<GameInfo | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
@@ -22,13 +23,16 @@ export function GamePage() {
return <Navigate to="/" replace />
}
const playable = game?.status === 'available' && game.slug === 'forest-fortune'
return (
<main className="game-page">
<Link to="/home" className="back-link">
Назад
</Link>
{error && <p className="error">{error}</p>}
{game && (
{game && playable && <ForestFortuneGame game={game} />}
{game && !playable && (
<section className="game-stub">
<img src="/logo.png" alt="" className="game-stub__mark" />
<h1>{game.title}</h1>

View File

@@ -2,14 +2,14 @@ import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../auth'
import type { GameStub } from '../types'
import type { GameInfo } from '../types'
import { GATE_FROM_KEY } from '../vfx/gateCaps'
const DEPOSIT_AMOUNTS = [100, 500, 1000]
export function HomePage() {
const { user, loading, setUser } = useAuth()
const [games, setGames] = useState<GameStub[]>([])
const [games, setGames] = useState<GameInfo[]>([])
const [depositOpen, setDepositOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
@@ -75,13 +75,15 @@ export function HomePage() {
<section className="games" aria-labelledby="games-title">
<h2 id="games-title">Слоты</h2>
<p className="section-lead">Врата ещё закрыты пока только предвестники.</p>
<p className="section-lead">Лесная Удача уже открыта остальные ждут своего часа.</p>
<ul className="games__list">
{games.map((game) => (
<li key={game.slug}>
<Link to={`/games/${game.slug}`} className="game-link">
<span className="game-link__title">{game.title}</span>
<span className="game-link__status">Скоро</span>
<span className="game-link__status">
{game.status === 'available' ? 'Открыто' : 'Скоро'}
</span>
<span className="game-link__desc">{game.description}</span>
</Link>
</li>

View File

@@ -20,14 +20,53 @@ export type Wallet = {
deposits: Deposit[]
}
export type GameStub = {
export type GameInfo = {
slug: string
title: string
description: string
status: string
min_bet: string
max_bet: string
min_spin_count: number
max_spin_count: number
}
/** @deprecated use GameInfo */
export type GameStub = GameInfo
export type TokenResponse = {
access_token: string
token_type: string
}
export type PaylineWin = {
line_index: number
symbol: string
count: number
amount: string
path: number[]
}
export type SpinOutcome = {
index: number
grid: string[][]
line_wins: PaylineWin[]
scatter_count: number
scatter_win: string
total_win: string
level: number
multiplier: number
meta_triggered: boolean
}
export type SessionSpinResult = {
slug: string
bet: string
spin_count: number
total_bet: string
total_win: string
max_level: number
trigger_count: number
balance: string
spins: SpinOutcome[]
}

View File

@@ -1,5 +1,7 @@
import {
createContext,
lazy,
Suspense,
useCallback,
useContext,
useEffect,
@@ -17,6 +19,11 @@ import {
createReducedLogoFlyTimeline,
type LogoFlyTargets,
} from './gateTimeline'
import { capturePortalScreen, defaultRavenFx, type RavenFx } from './ravenFx'
const RavenBurstCanvas = lazy(() =>
import('./RavenBurstCanvas').then((m) => ({ default: m.RavenBurstCanvas })),
)
type StartResult = {
ok: boolean
@@ -54,7 +61,6 @@ function collectUiFade(gate: HTMLElement, logo: HTMLElement | null): HTMLElement
function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
const rect = logo.getBoundingClientRect()
// Geometry only — keep invisible until the original logo is hidden in the same frame
gsap.set(hero, {
position: 'fixed',
top: rect.top,
@@ -62,7 +68,7 @@ function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
width: rect.width,
height: rect.height,
margin: 0,
zIndex: 2,
zIndex: 1,
opacity: 0,
visibility: 'hidden',
scale: 1,
@@ -70,7 +76,7 @@ function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
z: 0,
filter: 'drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))',
objectFit: 'contain',
transformOrigin: '50% 46%',
transformOrigin: '50% 50%',
transformPerspective: 1400,
force3D: true,
willChange: 'transform, filter, opacity',
@@ -86,10 +92,13 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate()
const [phase, setPhase] = useState<GateTransitPhase>('idle')
const [showOverlay, setShowOverlay] = useState(false)
const [mountRavens, setMountRavens] = useState(false)
const veilRef = useRef<HTMLDivElement | null>(null)
const stageRef = useRef<HTMLDivElement | null>(null)
const heroRef = useRef<HTMLImageElement | null>(null)
const ravenWrapRef = useRef<HTMLDivElement | null>(null)
const ravenFxRef = useRef<RavenFx>({ ...defaultRavenFx })
const timelineRef = useRef<gsap.core.Timeline | null>(null)
const acceptPromiseRef = useRef<Promise<void> | null>(null)
const acceptErrorRef = useRef<string | null>(null)
@@ -116,7 +125,9 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
(result: StartResult) => {
timelineRef.current?.kill()
timelineRef.current = null
setMountRavens(false)
setShowOverlay(false)
Object.assign(ravenFxRef.current, defaultRavenFx)
setPhase(result.ok ? 'done' : 'error')
cleanupDomLocks()
resolveStartRef.current?.(result)
@@ -191,18 +202,24 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
placeHeroOverLogo(hero, logo)
// One frame with hero invisible + correct box, then atomic swap with the page logo
requestAnimationFrame(() => {
if (timelineRef.current) return
placeHeroOverLogo(hero, logo)
revealHeroOverLogo(hero, logo)
// Lock cone apex to the portal (zoom origin) before scale starts
const portal = capturePortalScreen(hero)
ravenFxRef.current.apexSx = portal.x
ravenFxRef.current.apexSy = portal.y
const targets: LogoFlyTargets = {
hero,
uiFade: collectUiFade(gate, logo),
originalLogo: logo,
veil: veilRef.current,
stage: stageRef.current,
ravenLayer: ravenWrapRef.current,
ravenFx: ravenFxRef.current,
}
const handlers = {
@@ -236,6 +253,12 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
finish({ ok: false, error: 'Не удалось запустить переход' })
return
}
// Wait a beat for lazy raven canvas when enabled
if (mountRavens && !ravenWrapRef.current && tries < 40) {
tries += 1
window.setTimeout(tick, 40)
return
}
startTimeline()
}
@@ -244,7 +267,7 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
cancelled = true
window.clearTimeout(id)
}
}, [phase, showOverlay, startTimeline, finish])
}, [phase, showOverlay, mountRavens, startTimeline, finish])
const start = useCallback(
(runAccept: () => Promise<void>) => {
@@ -252,7 +275,24 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
return Promise.resolve({ ok: false, error: 'Переход уже идёт' })
}
reducedRef.current = prefersReducedMotion()
return new Promise<StartResult>((resolve) => {
resolveStartRef.current = resolve
void (async () => {
const reduced = prefersReducedMotion()
reducedRef.current = reduced
Object.assign(ravenFxRef.current, defaultRavenFx)
let ravens = !reduced
if (ravens) {
try {
await import('./RavenBurstCanvas')
} catch {
ravens = false
}
}
setMountRavens(ravens)
setShowOverlay(true)
setPhase('playing')
document.documentElement.classList.add('gate-transit-lock')
@@ -272,9 +312,7 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
})
acceptPromiseRef.current = acceptPromise
void acceptPromise.catch(() => undefined)
return new Promise<StartResult>((resolve) => {
resolveStartRef.current = resolve
})()
})
},
[phase],
@@ -314,6 +352,11 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
draggable={false}
/>
</div>
{mountRavens && (
<Suspense fallback={null}>
<RavenBurstCanvas fxRef={ravenFxRef} wrapRef={ravenWrapRef} />
</Suspense>
)}
<div className="gate-transit__veil" ref={veilRef} />
</div>,
document.body,

View File

@@ -0,0 +1,32 @@
import { Suspense, type MutableRefObject, type RefObject } from 'react'
import { Canvas } from '@react-three/fiber'
import type { RavenFx } from './ravenFx'
import { RavenFlock } from './RavenFlock'
type RavenBurstCanvasProps = {
fxRef: MutableRefObject<RavenFx>
wrapRef: RefObject<HTMLDivElement | null>
}
export function RavenBurstCanvas({ fxRef, wrapRef }: RavenBurstCanvasProps) {
return (
<div className="gate-transit__ravens" ref={wrapRef}>
<Canvas
dpr={[1, 1.25]}
gl={{
alpha: true,
antialias: false,
powerPreference: 'high-performance',
stencil: false,
depth: true,
}}
camera={{ position: [0, 0, 5.2], fov: 42, near: 0.05, far: 40 }}
style={{ background: 'transparent' }}
>
<Suspense fallback={null}>
<RavenFlock fx={fxRef} />
</Suspense>
</Canvas>
</div>
)
}

170
src/vfx/RavenFlock.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { useLayoutEffect, useMemo, useRef } from 'react'
import { useFrame, useLoader, useThree } from '@react-three/fiber'
import * as THREE from 'three'
import type { MutableRefObject } from 'react'
import type { RavenFx } from './ravenFx'
const COUNT = 18
const APEX_Z = -2.8
const CONE_SLOPE = 0.42
const AXIS_SPEED = 5.2
const CAM_Z = 5.2
type Bird = {
active: boolean
born: boolean
depth: number
theta: number
phase: number
flapSpeed: number
size: number
flip: number
delay: number
speed: number
slope: number
}
function createBirds(): Bird[] {
const birds: Bird[] = []
for (let i = 0; i < COUNT; i++) {
const wave = Math.floor(i / 3)
const theta = (i / COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.25
birds.push({
active: false,
born: false,
depth: 0,
theta,
phase: Math.random() * Math.PI * 2,
flapSpeed: 10 + Math.random() * 6,
size: 0.2 + Math.random() * 0.28,
flip: i % 2 === 0 ? -1 : 1,
delay: 0.03 * wave + Math.random() * 0.04,
speed: AXIS_SPEED * (0.85 + Math.random() * 0.35),
slope: CONE_SLOPE * (0.9 + Math.random() * 0.25),
})
}
return birds
}
/** Viewport CSS px → world on the apex plane. Uses the raven canvas rect for correct NDC. */
function screenToApexWorld(
sx: number,
sy: number,
camera: THREE.PerspectiveCamera,
glDom: HTMLCanvasElement | null,
out: THREE.Vector3,
) {
const canvasRect = glDom?.getBoundingClientRect()
const left = canvasRect?.left ?? 0
const top = canvasRect?.top ?? 0
const viewW = canvasRect?.width || window.innerWidth
const viewH = canvasRect?.height || window.innerHeight
const ndcX = ((sx - left) / viewW) * 2 - 1
const ndcY = -((sy - top) / viewH) * 2 + 1
const dist = Math.abs(CAM_Z - APEX_Z)
const vFov = THREE.MathUtils.degToRad(camera.fov)
const halfH = Math.tan(vFov / 2) * dist
const halfW = halfH * (viewW / viewH)
out.set(ndcX * halfW, ndcY * halfH, APEX_Z)
}
type RavenFlockProps = {
fx: MutableRefObject<RavenFx>
}
export function RavenFlock({ fx }: RavenFlockProps) {
const meshRef = useRef<THREE.InstancedMesh>(null)
const birds = useMemo(() => createBirds(), [])
const dummy = useMemo(() => new THREE.Object3D(), [])
const apex = useMemo(() => new THREE.Vector3(0, 0, APEX_Z), [])
const texture = useLoader(THREE.TextureLoader, '/raven-silhouette.svg')
const gl = useThree((s) => s.gl)
useLayoutEffect(() => {
texture.colorSpace = THREE.SRGBColorSpace
texture.anisotropy = 4
texture.needsUpdate = true
}, [texture])
useFrame((state, delta) => {
const mesh = meshRef.current
if (!mesh) return
const cam = state.camera as THREE.PerspectiveCamera
// Fixed camera — do NOT recenter on apex (that pinned birds to screen center)
cam.position.set(0, 0, CAM_Z)
cam.lookAt(0, 0, 0)
cam.updateMatrixWorld()
const { burst, opacity, apexSx, apexSy } = fx.current
if (Number.isFinite(apexSx) && Number.isFinite(apexSy)) {
screenToApexWorld(apexSx, apexSy, cam, gl.domElement, apex)
}
const mat = mesh.material as THREE.MeshBasicMaterial
mat.opacity = opacity
const dt = Math.min(delta, 0.05)
const t = state.clock.elapsedTime
for (let i = 0; i < COUNT; i++) {
const bird = birds[i]
if (!bird.born && burst > bird.delay) {
bird.born = true
bird.active = true
bird.depth = 0.02 + Math.random() * 0.06
}
if (!bird.active) {
dummy.position.set(0, -40, 0)
dummy.scale.set(0, 0, 0)
dummy.updateMatrix()
mesh.setMatrixAt(i, dummy.matrix)
continue
}
bird.depth += bird.speed * dt
// Cone tip at portal; base // screen (XY)
const radius = bird.depth * bird.slope
const x = apex.x + Math.cos(bird.theta) * radius
const y = apex.y + Math.sin(bird.theta) * radius
const z = apex.z + bird.depth
const flap = 1 + Math.sin(t * bird.flapSpeed + bird.phase) * 0.18
const grow = THREE.MathUtils.smoothstep(bird.depth, 0.15, 3.2)
const s = bird.size * flap * (0.15 + grow * 1.55)
dummy.position.set(x, y, z)
dummy.quaternion.copy(cam.quaternion)
dummy.rotateY(bird.flip < 0 ? Math.PI : 0)
dummy.rotateZ(Math.sin(t * bird.flapSpeed + bird.phase) * 0.16)
dummy.scale.set(s * 1.45, s, 1)
dummy.updateMatrix()
mesh.setMatrixAt(i, dummy.matrix)
if (bird.depth > 7.5) {
bird.active = false
}
}
mesh.instanceMatrix.needsUpdate = true
})
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]} frustumCulled={false}>
<planeGeometry args={[1, 0.55]} />
<meshBasicMaterial
map={texture}
color="#0a0b08"
transparent
depthWrite={false}
side={THREE.DoubleSide}
toneMapped={false}
/>
</instancedMesh>
)
}

View File

@@ -1,4 +1,5 @@
import gsap from 'gsap'
import { defaultRavenFx, type RavenFx } from './ravenFx'
export type GateTimelineHandlers = {
onBlackout: () => void
@@ -12,14 +13,20 @@ export type LogoFlyTargets = {
originalLogo: HTMLElement | null
veil: HTMLElement | null
stage: HTMLElement | null
ravenLayer: HTMLElement | null
ravenFx: RavenFx
}
/** Continuously fly into logo.png (central black arch), then void → home. */
export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTimelineHandlers) {
const { hero, uiFade, veil, stage } = targets
const { hero, uiFade, veil, stage, ravenLayer, ravenFx } = targets
// Reset animatable raven fields only — keep captured portal screen position
const { apexSx, apexSy } = ravenFx
Object.assign(ravenFx, defaultRavenFx, { apexSx, apexSy })
gsap.set(hero, {
transformOrigin: '50% 46%',
transformOrigin: '50% 50%',
transformPerspective: 1400,
force3D: true,
scale: 1,
@@ -36,6 +43,10 @@ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTim
gsap.set(veil, { opacity: 0 })
}
if (ravenLayer) {
gsap.set(ravenLayer, { opacity: 1 })
}
const tl = gsap.timeline({
onComplete: () => handlers.onComplete(),
})
@@ -69,6 +80,35 @@ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTim
0.05,
)
// Ravens burst immediately as the zoom starts (apex locked to portal)
tl.to(
ravenFx,
{
opacity: 1,
duration: 0.2,
ease: 'power1.out',
},
0.05,
)
tl.to(
ravenFx,
{
burst: 1,
duration: 0.85,
ease: 'power2.out',
},
0.08,
)
tl.to(
ravenFx,
{
opacity: 0,
duration: 0.4,
ease: 'power2.in',
},
1.55,
)
tl.to(
hero,
{
@@ -103,7 +143,7 @@ export function createReducedLogoFlyTimeline(
) {
const { hero, uiFade, veil } = targets
gsap.set(hero, { transformOrigin: '50% 46%', scale: 1 })
gsap.set(hero, { transformOrigin: '50% 50%', scale: 1 })
if (veil) gsap.set(veil, { opacity: 0 })
const tl = gsap.timeline({ onComplete: () => handlers.onComplete() })

32
src/vfx/ravenFx.ts Normal file
View File

@@ -0,0 +1,32 @@
export type RavenFx = {
/** 01 spawn/burst progress */
burst: number
/** Layer opacity */
opacity: number
/**
* Fixed screen-pixel position of the gate image center.
* Captured once at scale=1; stays locked during zoom.
* NaN = not set yet.
*/
apexSx: number
apexSy: number
}
export const defaultRavenFx: RavenFx = {
burst: 0,
opacity: 0,
apexSx: Number.NaN,
apexSy: Number.NaN,
}
/** Point on the gate image: horizontal center, 50% from top. */
export const PORTAL_ORIGIN = { x: 0.5, y: 0.5 } as const
/** Center of the gate/logo image in viewport CSS pixels. */
export function capturePortalScreen(el: HTMLElement): { x: number; y: number } {
const rect = el.getBoundingClientRect()
return {
x: rect.left + rect.width * PORTAL_ORIGIN.x,
y: rect.top + rect.height * PORTAL_ORIGIN.y,
}
}