From f0733d6918423a8345b8b1d05df5daddee19a207 Mon Sep 17 00:00:00 2001 From: Redsandy <34872843+Redsandyg@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:27:27 +0300 Subject: [PATCH] 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. --- src/api.ts | 19 +- src/games/forest-fortune/CoinFlight.tsx | 56 +++ .../forest-fortune/ForestFortuneGame.tsx | 330 +++++++++++++++ src/games/forest-fortune/ReelColumn.tsx | 77 ++++ src/games/forest-fortune/WinCounter.tsx | 13 + src/games/forest-fortune/WinLinesOverlay.tsx | 167 ++++++++ src/games/forest-fortune/reelUtils.ts | 22 + src/games/forest-fortune/speed.ts | 60 +++ src/games/forest-fortune/symbols.ts | 34 ++ src/index.css | 387 ++++++++++++++++++ src/pages/GamePage.tsx | 10 +- src/pages/HomePage.tsx | 10 +- src/types.ts | 41 +- 13 files changed, 1212 insertions(+), 14 deletions(-) create mode 100644 src/games/forest-fortune/CoinFlight.tsx create mode 100644 src/games/forest-fortune/ForestFortuneGame.tsx create mode 100644 src/games/forest-fortune/ReelColumn.tsx create mode 100644 src/games/forest-fortune/WinCounter.tsx create mode 100644 src/games/forest-fortune/WinLinesOverlay.tsx create mode 100644 src/games/forest-fortune/reelUtils.ts create mode 100644 src/games/forest-fortune/speed.ts create mode 100644 src/games/forest-fortune/symbols.ts diff --git a/src/api.ts b/src/api.ts index ef54845..58a6d76 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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(path: string, options: RequestInit = {}): Promise { 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 { - return request('/games') + listGames(): Promise { + return request('/games') }, - getGame(slug: string): Promise { - return request(`/games/${slug}`) + getGame(slug: string): Promise { + return request(`/games/${slug}`) + }, + + spin(slug: string, bet: number, spinCount: number): Promise { + return request(`/games/${slug}/spin`, { + method: 'POST', + body: JSON.stringify({ bet, spin_count: spinCount }), + }) }, } diff --git a/src/games/forest-fortune/CoinFlight.tsx b/src/games/forest-fortune/CoinFlight.tsx new file mode 100644 index 0000000..774f12e --- /dev/null +++ b/src/games/forest-fortune/CoinFlight.tsx @@ -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([]) + + 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 ( +
+ {coins.map((c) => ( + + ● + + ))} +
+ ) +} diff --git a/src/games/forest-fortune/ForestFortuneGame.tsx b/src/games/forest-fortune/ForestFortuneGame.tsx new file mode 100644 index 0000000..ef28fdf --- /dev/null +++ b/src/games/forest-fortune/ForestFortuneGame.tsx @@ -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('normal') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [grid, setGrid] = useState(emptyGrid) + const [current, setCurrent] = useState(null) + const [summary, setSummary] = useState(null) + const [replayIndex, setReplayIndex] = useState(0) + const [reels, setReels] = useState(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> = 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> = 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((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 ( +
+
+

{game.title}

+

{game.description}

+
+ + Уровень {current?.level ?? 1} + + + Множитель ×{current?.multiplier ?? 1} + + + Баланс {user?.balance ?? '—'} + +
+
+ +
+ + +
+
+ {grid.map((column, reel) => ( + + ))} + +
+ +
+
+ +
+
+ + +
+ +
+ + +
+
+ + {error &&

{error}

} + + {current && busy && ( +

+ {Number(current.total_win) > 0 ? `+${current.total_win}` : 'Пусто'} + {current.meta_triggered ? ' · дух поднял уровень!' : ''} +

+ )} + + {summary && !busy && ( +
+

Итог пачки

+

+ Ставка {summary.total_bet} → выигрыш {summary.total_win} +

+

+ Триггеров: {summary.trigger_count} · макс. уровень: {summary.max_level} + {sessionWin > 0 ? ` · насчитано ${sessionWin.toFixed(2)}` : ''} +

+
+ )} +
+ ) +} diff --git a/src/games/forest-fortune/ReelColumn.tsx b/src/games/forest-fortune/ReelColumn.tsx new file mode 100644 index 0000000..ad92a1c --- /dev/null +++ b/src/games/forest-fortune/ReelColumn.tsx @@ -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 + scatterRows: Set +} + +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 ( +
+
+
+ {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 ( +
+ {symbolGlyph(sym)} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/games/forest-fortune/WinCounter.tsx b/src/games/forest-fortune/WinCounter.tsx new file mode 100644 index 0000000..974b0a4 --- /dev/null +++ b/src/games/forest-fortune/WinCounter.tsx @@ -0,0 +1,13 @@ +type Props = { + value: number + pulsing: boolean +} + +export function WinCounter({ value, pulsing }: Props) { + return ( +
+ Выигрыш + {value.toFixed(2)} +
+ ) +} diff --git a/src/games/forest-fortune/WinLinesOverlay.tsx b/src/games/forest-fortune/WinLinesOverlay.tsx new file mode 100644 index 0000000..596b133 --- /dev/null +++ b/src/games/forest-fortune/WinLinesOverlay.tsx @@ -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(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 ( + + ) +} + +export function WinLinesOverlay({ lineWins, visible, scatterCells, drawMs = 520 }: Props) { + if (!visible) return null + + return ( + + {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 ( + + + + {pts.map((p, reel) => { + const t = reel / steps + const dotDelay = lineDelay + drawMs * t + return ( + + + + + ) + })} + + ) + })} + {scatterCells.map(({ reel, row }) => { + const { x, y } = cellCenter(reel, row) + return ( + + ) + })} + + ) +} diff --git a/src/games/forest-fortune/reelUtils.ts b/src/games/forest-fortune/reelUtils.ts new file mode 100644 index 0000000..efeee7a --- /dev/null +++ b/src/games/forest-fortune/reelUtils.ts @@ -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] ?? '?' +} diff --git a/src/games/forest-fortune/speed.ts b/src/games/forest-fortune/speed.ts new file mode 100644 index 0000000..cd4b9dd --- /dev/null +++ b/src/games/forest-fortune/speed.ts @@ -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 = { + 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((resolve) => { + window.setTimeout(resolve, ms) + }) +} diff --git a/src/games/forest-fortune/symbols.ts b/src/games/forest-fortune/symbols.ts new file mode 100644 index 0000000..505e276 --- /dev/null +++ b/src/games/forest-fortune/symbols.ts @@ -0,0 +1,34 @@ +export const SYMBOL_LABELS: Record = { + leaf: '🍃', + mushroom: '🍄', + pine: '🌲', + deer: '🦌', + fox: '🦊', + oak: '🌳', + wild: '🪵', + scatter: '✨', +} + +export const SYMBOL_NAMES: Record = { + 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 +} diff --git a/src/index.css b/src/index.css index 7a0e1ba..d89e434 100644 --- a/src/index.css +++ b/src/index.css @@ -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; + } +} + + + diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx index 9a07d46..1bef966 100644 --- a/src/pages/GamePage.tsx +++ b/src/pages/GamePage.tsx @@ -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(null) + const [game, setGame] = useState(null) const [error, setError] = useState(null) useEffect(() => { @@ -22,13 +23,16 @@ export function GamePage() { return } + const playable = game?.status === 'available' && game.slug === 'forest-fortune' + return (
← Назад {error &&

{error}

} - {game && ( + {game && playable && } + {game && !playable && (

{game.title}

diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 16f67c5..b11fc4a 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -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([]) + const [games, setGames] = useState([]) const [depositOpen, setDepositOpen] = useState(false) const [busy, setBusy] = useState(false) const [message, setMessage] = useState(null) @@ -75,13 +75,15 @@ export function HomePage() {

Слоты

-

Врата ещё закрыты — пока только предвестники.

+

Лесная Удача уже открыта — остальные ждут своего часа.

    {games.map((game) => (
  • {game.title} - Скоро + + {game.status === 'available' ? 'Открыто' : 'Скоро'} + {game.description}
  • diff --git a/src/types.ts b/src/types.ts index 695fd8a..d94c0c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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[] +}