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.
This commit is contained in:
Redsandy
2026-08-12 15:27:27 +03:00
parent dfe445349b
commit f0733d6918
13 changed files with 1212 additions and 14 deletions

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 API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'
const TOKEN_KEY = 'vyryi_token' 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})` let detail = `Request failed (${response.status})`
try { try {
const body = (await response.json()) as { detail?: string } 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 { } catch {
/* ignore */ /* ignore */
} }
@@ -73,11 +73,18 @@ export const api = {
}) })
}, },
listGames(): Promise<GameStub[]> { listGames(): Promise<GameInfo[]> {
return request<GameStub[]>('/games') return request<GameInfo[]>('/games')
}, },
getGame(slug: string): Promise<GameStub> { getGame(slug: string): Promise<GameInfo> {
return request<GameStub>(`/games/${slug}`) 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,56 @@
import { useEffect, useState } from 'react'
type Coin = {
id: number
left: number
delay: number
}
type Props = {
active: boolean
amount: number
durationMs: number
fromSelector?: string
}
let coinSeq = 0
export function CoinFlight({ active, amount, durationMs }: Props) {
const [coins, setCoins] = useState<Coin[]>([])
useEffect(() => {
if (!active || amount <= 0) {
setCoins([])
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
return (
<div className="ff-coins" aria-hidden>
{coins.map((c) => (
<span
key={c.id}
className="ff-coins__coin"
style={{
left: `${c.left}%`,
animationDuration: `${durationMs}ms`,
animationDelay: `${c.delay}ms`,
}}
>
</span>
))}
</div>
)
}

View File

@@ -0,0 +1,330 @@
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 } from './WinCounter'
import { WinLinesOverlay } from './WinLinesOverlay'
type Props = {
game: GameInfo
}
type ReelState = {
spinning: boolean
stopping: boolean
}
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 [coinBurst, setCoinBurst] = useState<{ active: boolean; amount: number }>({
active: false,
amount: 0,
})
const [winPulse, setWinPulse] = useState(false)
const cancelRef = useRef(false)
const displayWinRef = useRef(0)
const timing = SPEED_TIMINGS[speed]
useEffect(() => {
return () => {
cancelRef.current = true
}
}, [])
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])
async function animateCountUp(from: number, to: number, ms: number) {
const start = performance.now()
return new Promise<void>((resolve) => {
const tick = (now: number) => {
if (cancelRef.current) {
resolve()
return
}
const t = Math.min(1, (now - start) / ms)
const eased = 1 - (1 - t) ** 3
setDisplayWin(from + (to - from) * eased)
if (t < 1) {
requestAnimationFrame(tick)
} else {
setDisplayWin(to)
resolve()
}
}
requestAnimationFrame(tick)
})
}
async function animateSpinOutcome(outcome: SpinOutcome) {
setShowLines(false)
setCoinBurst({ active: false, amount: 0 })
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
if (hasLines) {
setShowLines(true)
await delay(timing.lineHoldMs)
}
if (winAmount > 0 && !cancelRef.current) {
setCoinBurst({ active: true, amount: winAmount })
setWinPulse(true)
const from = displayWinRef.current
const to = from + winAmount
await animateCountUp(from, to, timing.coinMs)
displayWinRef.current = to
setSessionWin(to)
setDisplayWin(to)
setWinPulse(false)
setCoinBurst({ active: false, amount: 0 })
} else if (hasLines) {
await delay(Math.min(200, timing.betweenSpinsMs))
}
setShowLines(false)
await delay(timing.betweenSpinsMs)
}
async function play() {
setBusy(true)
setError(null)
setSummary(null)
setSessionWin(0)
setDisplayWin(0)
displayWinRef.current = 0
setShowLines(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)
}
}
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)
return (
<section className="ff">
<header className="ff__header">
<h1 className="ff__title">{game.title}</h1>
<p className="ff__lead">{game.description}</p>
<div className="ff__meta">
<span>
Уровень <strong>{current?.level ?? 1}</strong>
</span>
<span>
Множитель <strong>×{current?.multiplier ?? 1}</strong>
</span>
<span>
Баланс <strong>{user?.balance ?? '—'}</strong>
</span>
</div>
</header>
<div className="ff__stage">
<WinCounter value={displayWin} pulsing={winPulse} />
<div className="ff__board-wrap">
<div className="ff__board ff__board--reels" aria-label="Поле 5 на 5">
{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()}
/>
))}
<WinLinesOverlay
lineWins={current?.line_wins ?? []}
visible={showLines}
scatterCells={scatterCells}
drawMs={timing.lineDrawMs}
/>
</div>
<CoinFlight active={coinBurst.active} amount={coinBurst.amount} durationMs={timing.coinMs} />
</div>
</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,77 @@
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>
}
function blurColumn(): string[] {
return Array.from({ length: 10 }, () => randomSymbol())
}
export function ReelColumn({
reelIndex,
symbols,
spinning,
stopping,
timing,
winRows,
scatterRows,
}: 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)
return (
<div
key={`${reelIndex}-${mode}-${idx}`}
className={`ff-reel__cell${win ? ' ff-reel__cell--win' : ''}${
scatter ? ' ff-reel__cell--scatter' : ''
}`}
>
<span className="ff-reel__glyph">{symbolGlyph(sym)}</span>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
type Props = {
value: number
pulsing: boolean
}
export function WinCounter({ value, pulsing }: Props) {
return (
<div className={`ff-win${pulsing ? ' ff-win--pulse' : ''}`} aria-live="polite">
<span className="ff-win__label">Выигрыш</span>
<strong className="ff-win__value">{value.toFixed(2)}</strong>
</div>
)
}

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,60 @@
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
/** Coin flight + counter tick */
coinMs: 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: 900,
betweenSpinsMs: 450,
stripCycleMs: 420,
},
normal: {
label: 'Обычная',
spinLeadMs: 420,
stopGapMs: 220,
lineHoldMs: 1000,
lineDrawMs: 520,
coinMs: 550,
betweenSpinsMs: 280,
stripCycleMs: 280,
},
fast: {
label: 'Быстро',
spinLeadMs: 180,
stopGapMs: 90,
lineHoldMs: 480,
lineDrawMs: 260,
coinMs: 280,
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

@@ -629,3 +629,390 @@ 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__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__meta {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem 1.1rem;
margin-top: 0.85rem;
font-size: 0.9rem;
color: var(--cream);
}
.ff__meta strong {
color: var(--gold-soft);
}
.ff__stage {
display: grid;
gap: 0.75rem;
}
.ff-win {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.95rem;
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);
}
.ff-win__label {
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.ff-win__value {
font-family: var(--font-display);
font-size: 1.65rem;
color: var(--gold-soft);
font-variant-numeric: tabular-nums;
}
.ff-win--pulse {
animation: ff-win-pulse 0.45s ease-out;
border-color: var(--gold);
}
@keyframes ff-win-pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 rgba(166, 137, 70, 0);
}
40% {
transform: scale(1.03);
box-shadow: 0 0 22px rgba(166, 137, 70, 0.35);
}
100% {
transform: scale(1);
}
}
.ff__board-wrap {
position: relative;
}
.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__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: 4;
overflow: visible;
}
.ff-coins__coin {
position: absolute;
bottom: 42%;
font-size: 0.95rem;
color: var(--gold-soft);
text-shadow: 0 0 8px rgba(166, 137, 70, 0.7);
animation-name: ff-coin-fly;
animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1);
animation-fill-mode: forwards;
opacity: 0;
}
@keyframes ff-coin-fly {
0% {
opacity: 0;
transform: translate(-50%, 0) scale(0.6);
}
15% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, -120px) scale(1.15);
}
}
.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--pulse {
animation: none;
opacity: 1;
}
}

View File

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

View File

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

View File

@@ -20,14 +20,53 @@ export type Wallet = {
deposits: Deposit[] deposits: Deposit[]
} }
export type GameStub = { export type GameInfo = {
slug: string slug: string
title: string title: string
description: string description: string
status: 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 = { export type TokenResponse = {
access_token: string access_token: string
token_type: 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[]
}