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:
19
src/api.ts
19
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 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 }),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/games/forest-fortune/CoinFlight.tsx
Normal file
56
src/games/forest-fortune/CoinFlight.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
330
src/games/forest-fortune/ForestFortuneGame.tsx
Normal file
330
src/games/forest-fortune/ForestFortuneGame.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
src/games/forest-fortune/ReelColumn.tsx
Normal file
77
src/games/forest-fortune/ReelColumn.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/games/forest-fortune/WinCounter.tsx
Normal file
13
src/games/forest-fortune/WinCounter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
167
src/games/forest-fortune/WinLinesOverlay.tsx
Normal file
167
src/games/forest-fortune/WinLinesOverlay.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/games/forest-fortune/reelUtils.ts
Normal file
22
src/games/forest-fortune/reelUtils.ts
Normal 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] ?? '?'
|
||||||
|
}
|
||||||
60
src/games/forest-fortune/speed.ts
Normal file
60
src/games/forest-fortune/speed.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
34
src/games/forest-fortune/symbols.ts
Normal file
34
src/games/forest-fortune/symbols.ts
Normal 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
|
||||||
|
}
|
||||||
387
src/index.css
387
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
41
src/types.ts
41
src/types.ts
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user