Compare commits
3 Commits
59d4a691d6
...
70beb96f1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70beb96f1a | ||
|
|
f0733d6918 | ||
|
|
dfe445349b |
20
public/raven-silhouette.svg
Normal file
20
public/raven-silhouette.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="160" viewBox="0 0 256 160">
|
||||||
|
<g fill="#0a0b08">
|
||||||
|
<!-- body -->
|
||||||
|
<ellipse cx="128" cy="88" rx="28" ry="16" />
|
||||||
|
<!-- neck + head -->
|
||||||
|
<path d="M100 82c-10-4-22-4-32 2-3 2-7 1-8-2 6-10 20-16 34-14 6 1 10 6 11 12 0 2-2 3-5 2z" />
|
||||||
|
<ellipse cx="78" cy="78" rx="14" ry="10" transform="rotate(-18 78 78)" />
|
||||||
|
<!-- beak -->
|
||||||
|
<path d="M64 76l-18 2 18 5z" />
|
||||||
|
<!-- left wing -->
|
||||||
|
<path d="M120 80c-28-22-62-36-96-34-6 0-8-6-3-9 38-16 78-8 108 14 8 6 10 16 4 22-4 3-9 4-13 7z" />
|
||||||
|
<!-- right wing -->
|
||||||
|
<path d="M136 78c26-24 60-40 96-42 6 0 9 6 4 9-34 18-66 34-90 52-6 4-14 2-16-4-2-6 2-12 6-15z" />
|
||||||
|
<!-- tail -->
|
||||||
|
<path d="M148 92c16 8 28 24 30 40 0 4-5 5-7 2-8-14-18-26-32-34-3-2-2-7 2-8 2 0 5 0 7 0z" />
|
||||||
|
<!-- legs tucked -->
|
||||||
|
<path d="M122 100c2 10 1 18-2 24-1 2-4 1-4-1 2-8 2-15 0-22 0-2 3-3 6-1z" />
|
||||||
|
</g>
|
||||||
|
<circle cx="72" cy="74" r="2.6" fill="#c8d97d" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 968 B |
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 }),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
236
src/games/forest-fortune/CoinFlight.tsx
Normal file
236
src/games/forest-fortune/CoinFlight.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useRef, type RefObject } from 'react'
|
||||||
|
import type { CellPoint } from './winVfx'
|
||||||
|
import { prefersReducedMotion } from './winVfx'
|
||||||
|
|
||||||
|
export type CoinFlightProps = {
|
||||||
|
active: boolean
|
||||||
|
spawns: CellPoint[]
|
||||||
|
boardRef: RefObject<HTMLElement | null>
|
||||||
|
stageRef: RefObject<HTMLElement | null>
|
||||||
|
/** Final destination (win value) */
|
||||||
|
targetRef: RefObject<HTMLElement | null>
|
||||||
|
/** Optional waypoint (multiplier badge) */
|
||||||
|
midRef?: RefObject<HTMLElement | null>
|
||||||
|
/** When true, coins go cell → mid → target */
|
||||||
|
viaMid?: boolean
|
||||||
|
flightMs: number
|
||||||
|
staggerMs: number
|
||||||
|
onMidHit?: (index: number) => void
|
||||||
|
onCoinHit?: (index: number) => void
|
||||||
|
onComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function stagePoint(
|
||||||
|
stageBox: DOMRect,
|
||||||
|
el: HTMLElement,
|
||||||
|
biasX = 0.5,
|
||||||
|
biasY = 0.5,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const box = el.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: box.left + box.width * biasX - stageBox.left,
|
||||||
|
y: box.top + box.height * biasY - stageBox.top,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoinFlight({
|
||||||
|
active,
|
||||||
|
spawns,
|
||||||
|
boardRef,
|
||||||
|
stageRef,
|
||||||
|
targetRef,
|
||||||
|
midRef,
|
||||||
|
viaMid = false,
|
||||||
|
flightMs,
|
||||||
|
staggerMs,
|
||||||
|
onMidHit,
|
||||||
|
onCoinHit,
|
||||||
|
onComplete,
|
||||||
|
}: CoinFlightProps) {
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const midHitRef = useRef(onMidHit)
|
||||||
|
const hitRef = useRef(onCoinHit)
|
||||||
|
const doneRef = useRef(onComplete)
|
||||||
|
midHitRef.current = onMidHit
|
||||||
|
hitRef.current = onCoinHit
|
||||||
|
doneRef.current = onComplete
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !spawns.length) return
|
||||||
|
|
||||||
|
const stage = stageRef.current
|
||||||
|
const board = boardRef.current
|
||||||
|
const target = targetRef.current
|
||||||
|
const layer = layerRef.current
|
||||||
|
const mid = midRef?.current ?? null
|
||||||
|
const useMid = Boolean(viaMid && mid)
|
||||||
|
|
||||||
|
if (!stage || !board || !target || !layer) {
|
||||||
|
doneRef.current?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reduce = prefersReducedMotion()
|
||||||
|
const stageBox = stage.getBoundingClientRect()
|
||||||
|
const boardBox = board.getBoundingClientRect()
|
||||||
|
const boardStyle = window.getComputedStyle(board)
|
||||||
|
const padL = parseFloat(boardStyle.paddingLeft) || 0
|
||||||
|
const padT = parseFloat(boardStyle.paddingTop) || 0
|
||||||
|
const padR = parseFloat(boardStyle.paddingRight) || 0
|
||||||
|
const padB = parseFloat(boardStyle.paddingBottom) || 0
|
||||||
|
const contentW = Math.max(1, boardBox.width - padL - padR)
|
||||||
|
const contentH = Math.max(1, boardBox.height - padT - padB)
|
||||||
|
|
||||||
|
const end = stagePoint(stageBox, target, 0.5, 0.55)
|
||||||
|
const midPt = mid ? stagePoint(stageBox, mid, 0.5, 0.5) : null
|
||||||
|
|
||||||
|
const nodes: HTMLSpanElement[] = []
|
||||||
|
const animations: Animation[] = []
|
||||||
|
let hits = 0
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
cancelled = true
|
||||||
|
nodes.forEach((n) => n.remove())
|
||||||
|
doneRef.current?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduce) {
|
||||||
|
spawns.forEach((_, i) => {
|
||||||
|
if (useMid) midHitRef.current?.(i)
|
||||||
|
hitRef.current?.(i)
|
||||||
|
})
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const leg1 = useMid ? Math.round(flightMs * 0.55) : flightMs
|
||||||
|
const leg2 = useMid ? Math.round(flightMs * 0.55) : 0
|
||||||
|
|
||||||
|
spawns.forEach((spawn, index) => {
|
||||||
|
const startX = boardBox.left - stageBox.left + padL + (spawn.x / 100) * contentW
|
||||||
|
const startY = boardBox.top - stageBox.top + padT + (spawn.y / 100) * contentH
|
||||||
|
|
||||||
|
const el = document.createElement('span')
|
||||||
|
el.className = 'ff-coins__coin'
|
||||||
|
el.textContent = '●'
|
||||||
|
el.style.left = `${startX}px`
|
||||||
|
el.style.top = `${startY}px`
|
||||||
|
layer.appendChild(el)
|
||||||
|
nodes.push(el)
|
||||||
|
|
||||||
|
const delayMs = index * staggerMs
|
||||||
|
const firstTarget = useMid && midPt ? midPt : end
|
||||||
|
const ctrl1X = (startX + firstTarget.x) / 2 + (Math.random() - 0.5) * 36
|
||||||
|
const ctrl1Y = Math.min(startY, firstTarget.y) - 36 - Math.random() * 24
|
||||||
|
|
||||||
|
const anim1 = el.animate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
transform: 'translate(-50%, -50%) scale(0.55)',
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 0.14,
|
||||||
|
transform: 'translate(-50%, -50%) scale(1.15)',
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 0.55,
|
||||||
|
transform: `translate(calc(-50% + ${(ctrl1X - startX) * 0.55}px), calc(-50% + ${(ctrl1Y - startY) * 0.7}px)) scale(1)`,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
transform: `translate(calc(-50% + ${firstTarget.x - startX}px), calc(-50% + ${firstTarget.y - startY}px)) scale(${useMid ? 0.85 : 0.45})`,
|
||||||
|
opacity: useMid ? 1 : 0.15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: leg1,
|
||||||
|
delay: delayMs,
|
||||||
|
easing: 'cubic-bezier(0.22, 0.7, 0.2, 1)',
|
||||||
|
fill: 'forwards',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
animations.push(anim1)
|
||||||
|
|
||||||
|
anim1.onfinish = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (!useMid || !midPt) {
|
||||||
|
hitRef.current?.(index)
|
||||||
|
el.classList.add('ff-coins__coin--hit')
|
||||||
|
hits += 1
|
||||||
|
window.setTimeout(() => el.remove(), 80)
|
||||||
|
if (hits >= spawns.length) finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
midHitRef.current?.(index)
|
||||||
|
el.classList.add('ff-coins__coin--mid')
|
||||||
|
|
||||||
|
// Rebase element to mid position for second leg
|
||||||
|
el.style.left = `${midPt.x}px`
|
||||||
|
el.style.top = `${midPt.y}px`
|
||||||
|
el.style.transform = 'translate(-50%, -50%) scale(0.85)'
|
||||||
|
|
||||||
|
const ctrl2X = (midPt.x + end.x) / 2 + (Math.random() - 0.5) * 20
|
||||||
|
const ctrl2Y = Math.min(midPt.y, end.y) - 18 - Math.random() * 16
|
||||||
|
|
||||||
|
const anim2 = el.animate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
transform: 'translate(-50%, -50%) scale(0.9)',
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 0.45,
|
||||||
|
transform: `translate(calc(-50% + ${(ctrl2X - midPt.x) * 0.5}px), calc(-50% + ${(ctrl2Y - midPt.y) * 0.6}px)) scale(1.05)`,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
transform: `translate(calc(-50% + ${end.x - midPt.x}px), calc(-50% + ${end.y - midPt.y}px)) scale(0.4)`,
|
||||||
|
opacity: 0.12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: leg2,
|
||||||
|
easing: 'cubic-bezier(0.25, 0.8, 0.2, 1)',
|
||||||
|
fill: 'forwards',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
animations.push(anim2)
|
||||||
|
|
||||||
|
anim2.onfinish = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
hitRef.current?.(index)
|
||||||
|
el.classList.add('ff-coins__coin--hit')
|
||||||
|
hits += 1
|
||||||
|
window.setTimeout(() => el.remove(), 80)
|
||||||
|
if (hits >= spawns.length) finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const safety = window.setTimeout(
|
||||||
|
finish,
|
||||||
|
staggerMs * spawns.length + leg1 + leg2 + 260,
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(safety)
|
||||||
|
animations.forEach((a) => a.cancel())
|
||||||
|
nodes.forEach((n) => n.remove())
|
||||||
|
}
|
||||||
|
}, [active, spawns, boardRef, stageRef, targetRef, midRef, viaMid, flightMs, staggerMs])
|
||||||
|
|
||||||
|
if (!active) return null
|
||||||
|
|
||||||
|
return <div ref={layerRef} className="ff-coins" aria-hidden />
|
||||||
|
}
|
||||||
439
src/games/forest-fortune/ForestFortuneGame.tsx
Normal file
439
src/games/forest-fortune/ForestFortuneGame.tsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { api } from '../../api'
|
||||||
|
import { useAuth } from '../../auth'
|
||||||
|
import type { GameInfo, SessionSpinResult, SpinOutcome } from '../../types'
|
||||||
|
import { CoinFlight } from './CoinFlight'
|
||||||
|
import { ReelColumn } from './ReelColumn'
|
||||||
|
import { SPEED_ORDER, SPEED_TIMINGS, delay } from './speed'
|
||||||
|
import type { AnimSpeed } from './speed'
|
||||||
|
import { emptyGrid } from './symbols'
|
||||||
|
import { WinCounter, type WinCounterHandle } from './WinCounter'
|
||||||
|
import { WinLinesOverlay } from './WinLinesOverlay'
|
||||||
|
import {
|
||||||
|
buildCoinSpawns,
|
||||||
|
collectWinCells,
|
||||||
|
prefersReducedMotion,
|
||||||
|
type CellPoint,
|
||||||
|
} from './winVfx'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
game: GameInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReelState = {
|
||||||
|
spinning: boolean
|
||||||
|
stopping: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToastState = { text: string; key: number } | null
|
||||||
|
|
||||||
|
const IDLE_REELS: ReelState[] = Array.from({ length: 5 }, () => ({
|
||||||
|
spinning: false,
|
||||||
|
stopping: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function ForestFortuneGame({ game }: Props) {
|
||||||
|
const { user, setUser } = useAuth()
|
||||||
|
const minBet = Number(game.min_bet) || 1
|
||||||
|
const maxBet = Number(game.max_bet) || 1000
|
||||||
|
const minSpins = game.min_spin_count || 1
|
||||||
|
const maxSpins = game.max_spin_count || 1000
|
||||||
|
|
||||||
|
const [bet, setBet] = useState(Math.max(minBet, 10))
|
||||||
|
const [spinCount, setSpinCount] = useState(10)
|
||||||
|
const [speed, setSpeed] = useState<AnimSpeed>('normal')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [grid, setGrid] = useState(emptyGrid)
|
||||||
|
const [current, setCurrent] = useState<SpinOutcome | null>(null)
|
||||||
|
const [summary, setSummary] = useState<SessionSpinResult | null>(null)
|
||||||
|
const [replayIndex, setReplayIndex] = useState(0)
|
||||||
|
const [reels, setReels] = useState<ReelState[]>(IDLE_REELS)
|
||||||
|
const [showLines, setShowLines] = useState(false)
|
||||||
|
const [sessionWin, setSessionWin] = useState(0)
|
||||||
|
const [displayWin, setDisplayWin] = useState(0)
|
||||||
|
const [coinActive, setCoinActive] = useState(false)
|
||||||
|
const [coinSpawns, setCoinSpawns] = useState<CellPoint[]>([])
|
||||||
|
const [coinViaMid, setCoinViaMid] = useState(false)
|
||||||
|
const [boardWin, setBoardWin] = useState(false)
|
||||||
|
const [toast, setToast] = useState<ToastState>(null)
|
||||||
|
const [sparkCells, setSparkCells] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const cancelRef = useRef(false)
|
||||||
|
const displayWinRef = useRef(0)
|
||||||
|
const pendingHitsRef = useRef<{ remaining: number; step: number; end: number } | null>(null)
|
||||||
|
const coinDoneResolver = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
|
const boardRef = useRef<HTMLDivElement>(null)
|
||||||
|
const counterApiRef = useRef<WinCounterHandle>(null)
|
||||||
|
const valueTargetRef = useRef<HTMLElement | null>(null)
|
||||||
|
const multTargetRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const timing = SPEED_TIMINGS[speed]
|
||||||
|
const multiplier = current?.multiplier ?? 1
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancelRef.current = true
|
||||||
|
coinDoneResolver.current?.()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const winRowsByReel = useMemo(() => {
|
||||||
|
const map: Array<Set<number>> = Array.from({ length: 5 }, () => new Set())
|
||||||
|
if (!showLines || !current) return map
|
||||||
|
for (const win of current.line_wins) {
|
||||||
|
const n = Math.min(win.count, win.path.length)
|
||||||
|
for (let reel = 0; reel < n; reel += 1) {
|
||||||
|
map[reel]?.add(win.path[reel]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [current, showLines])
|
||||||
|
|
||||||
|
const scatterRowsByReel = useMemo(() => {
|
||||||
|
const map: Array<Set<number>> = Array.from({ length: 5 }, () => new Set())
|
||||||
|
if (!showLines || !current?.meta_triggered) return map
|
||||||
|
current.grid.forEach((col, reel) => {
|
||||||
|
col.forEach((sym, row) => {
|
||||||
|
if (sym === 'scatter') map[reel]?.add(row)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [current, showLines])
|
||||||
|
|
||||||
|
const scatterCells = useMemo(() => {
|
||||||
|
if (!showLines || !current?.meta_triggered) return []
|
||||||
|
const cells: Array<{ reel: number; row: number }> = []
|
||||||
|
current.grid.forEach((col, reel) => {
|
||||||
|
col.forEach((sym, row) => {
|
||||||
|
if (sym === 'scatter') cells.push({ reel, row })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return cells
|
||||||
|
}, [current, showLines])
|
||||||
|
|
||||||
|
function syncCoinTargets() {
|
||||||
|
valueTargetRef.current = counterApiRef.current?.getValueEl() ?? null
|
||||||
|
multTargetRef.current = counterApiRef.current?.getMultEl() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMidHit(_index: number) {
|
||||||
|
counterApiRef.current?.shakeMult()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCoinHit(_index: number) {
|
||||||
|
const pending = pendingHitsRef.current
|
||||||
|
if (!pending) return
|
||||||
|
counterApiRef.current?.shake()
|
||||||
|
const next = Math.min(pending.end, displayWinRef.current + pending.step)
|
||||||
|
displayWinRef.current = next
|
||||||
|
setDisplayWin(next)
|
||||||
|
setSessionWin(next)
|
||||||
|
pending.remaining -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitCoinsDone() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
coinDoneResolver.current = () => {
|
||||||
|
coinDoneResolver.current = null
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function animateSpinOutcome(outcome: SpinOutcome) {
|
||||||
|
setShowLines(false)
|
||||||
|
setCoinActive(false)
|
||||||
|
setCoinSpawns([])
|
||||||
|
setCoinViaMid(false)
|
||||||
|
setBoardWin(false)
|
||||||
|
setToast(null)
|
||||||
|
setSparkCells(new Set())
|
||||||
|
setCurrent(outcome)
|
||||||
|
setGrid(outcome.grid)
|
||||||
|
|
||||||
|
setReels(Array.from({ length: 5 }, () => ({ spinning: true, stopping: false })))
|
||||||
|
await delay(timing.spinLeadMs)
|
||||||
|
if (cancelRef.current) return
|
||||||
|
|
||||||
|
for (let reel = 0; reel < 5; reel += 1) {
|
||||||
|
if (cancelRef.current) return
|
||||||
|
setReels((prev) =>
|
||||||
|
prev.map((r, i) => {
|
||||||
|
if (i < reel) return { spinning: false, stopping: false }
|
||||||
|
if (i === reel) return { spinning: true, stopping: true }
|
||||||
|
return r
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await delay(Math.max(timing.stopGapMs, 180))
|
||||||
|
setReels((prev) =>
|
||||||
|
prev.map((r, i) => (i === reel ? { spinning: false, stopping: false } : r)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setReels(IDLE_REELS)
|
||||||
|
if (cancelRef.current) return
|
||||||
|
|
||||||
|
const winAmount = Number(outcome.total_win)
|
||||||
|
const hasLines = outcome.line_wins.length > 0 || outcome.meta_triggered
|
||||||
|
const viaMid = outcome.multiplier > 1
|
||||||
|
|
||||||
|
if (hasLines || winAmount > 0) {
|
||||||
|
setShowLines(true)
|
||||||
|
setBoardWin(winAmount > 0)
|
||||||
|
if (outcome.meta_triggered) {
|
||||||
|
setToast({ text: 'ДУХ!', key: Date.now() })
|
||||||
|
} else if (winAmount > 0) {
|
||||||
|
setToast({ text: 'WIN', key: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const preCoin = Math.max(0, Math.floor(timing.lineDrawMs * 0.7))
|
||||||
|
await delay(preCoin)
|
||||||
|
if (cancelRef.current) return
|
||||||
|
|
||||||
|
// Let multiplier badge mount before measuring targets
|
||||||
|
await delay(16)
|
||||||
|
syncCoinTargets()
|
||||||
|
|
||||||
|
if (winAmount > 0) {
|
||||||
|
const cells = collectWinCells(outcome)
|
||||||
|
const spawns = buildCoinSpawns(cells, winAmount)
|
||||||
|
setSparkCells(new Set(spawns.map((c) => `${c.reel}-${c.row}`)))
|
||||||
|
const startVal = displayWinRef.current
|
||||||
|
const endVal = startVal + winAmount
|
||||||
|
pendingHitsRef.current = {
|
||||||
|
remaining: spawns.length,
|
||||||
|
step: spawns.length ? winAmount / spawns.length : winAmount,
|
||||||
|
end: endVal,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefersReducedMotion() || !spawns.length) {
|
||||||
|
if (viaMid) counterApiRef.current?.shakeMult()
|
||||||
|
counterApiRef.current?.shake()
|
||||||
|
displayWinRef.current = endVal
|
||||||
|
setDisplayWin(endVal)
|
||||||
|
setSessionWin(endVal)
|
||||||
|
counterApiRef.current?.showFloat(winAmount)
|
||||||
|
await delay(timing.lineHoldMs - preCoin)
|
||||||
|
} else {
|
||||||
|
setCoinSpawns(spawns)
|
||||||
|
setCoinViaMid(viaMid)
|
||||||
|
setCoinActive(true)
|
||||||
|
const coinsPromise = waitCoinsDone()
|
||||||
|
const holdRest = Math.max(
|
||||||
|
0,
|
||||||
|
timing.lineHoldMs - preCoin,
|
||||||
|
spawns.length * timing.coinStaggerMs + timing.coinMs * (viaMid ? 1.15 : 1),
|
||||||
|
)
|
||||||
|
await Promise.all([coinsPromise, delay(holdRest)])
|
||||||
|
displayWinRef.current = endVal
|
||||||
|
setDisplayWin(endVal)
|
||||||
|
setSessionWin(endVal)
|
||||||
|
counterApiRef.current?.showFloat(winAmount)
|
||||||
|
setCoinActive(false)
|
||||||
|
setCoinSpawns([])
|
||||||
|
setCoinViaMid(false)
|
||||||
|
}
|
||||||
|
pendingHitsRef.current = null
|
||||||
|
} else {
|
||||||
|
await delay(Math.max(0, timing.lineHoldMs - preCoin))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowLines(false)
|
||||||
|
setBoardWin(false)
|
||||||
|
setToast(null)
|
||||||
|
setSparkCells(new Set())
|
||||||
|
await delay(timing.betweenSpinsMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function play() {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setSummary(null)
|
||||||
|
setSessionWin(0)
|
||||||
|
setDisplayWin(0)
|
||||||
|
displayWinRef.current = 0
|
||||||
|
setShowLines(false)
|
||||||
|
setCoinActive(false)
|
||||||
|
cancelRef.current = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const safeBet = Math.min(maxBet, Math.max(minBet, bet))
|
||||||
|
const safeCount = Math.min(maxSpins, Math.max(minSpins, Math.floor(spinCount)))
|
||||||
|
const result = await api.spin(game.slug, safeBet, safeCount)
|
||||||
|
if (user) setUser({ ...user, balance: result.balance })
|
||||||
|
|
||||||
|
for (let i = 0; i < result.spins.length; i += 1) {
|
||||||
|
if (cancelRef.current) break
|
||||||
|
setReplayIndex(i + 1)
|
||||||
|
await animateSpinOutcome(result.spins[i]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSummary(result)
|
||||||
|
if (result.spins.length) {
|
||||||
|
const last = result.spins[result.spins.length - 1]!
|
||||||
|
setCurrent(last)
|
||||||
|
setGrid(last.grid)
|
||||||
|
}
|
||||||
|
const total = Number(result.total_win)
|
||||||
|
displayWinRef.current = total
|
||||||
|
setSessionWin(total)
|
||||||
|
setDisplayWin(total)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка спина')
|
||||||
|
setReels(IDLE_REELS)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
setReels(IDLE_REELS)
|
||||||
|
setCoinActive(false)
|
||||||
|
setBoardWin(false)
|
||||||
|
setToast(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleSpeed() {
|
||||||
|
const idx = SPEED_ORDER.indexOf(speed)
|
||||||
|
setSpeed(SPEED_ORDER[(idx + 1) % SPEED_ORDER.length]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = (
|
||||||
|
Math.min(maxBet, Math.max(minBet, bet)) * Math.min(maxSpins, Math.max(minSpins, spinCount))
|
||||||
|
).toFixed(2)
|
||||||
|
|
||||||
|
const sparkByReel = useMemo(() => {
|
||||||
|
const map: Array<Set<number>> = Array.from({ length: 5 }, () => new Set())
|
||||||
|
sparkCells.forEach((key) => {
|
||||||
|
const [r, row] = key.split('-').map(Number)
|
||||||
|
if (r != null && row != null) map[r]?.add(row)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [sparkCells])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ff">
|
||||||
|
<header className="ff__header ff__header--bar">
|
||||||
|
<div className="ff__heading">
|
||||||
|
<h1 className="ff__title">{game.title}</h1>
|
||||||
|
<p className="ff__lead">{game.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ff__balance" aria-label="Баланс">
|
||||||
|
<span className="ff__balance-label">Баланс</span>
|
||||||
|
<strong className="ff__balance-value">{user?.balance ?? '—'}</strong>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ff__stage" ref={stageRef}>
|
||||||
|
<div className="ff-win-wrap">
|
||||||
|
<WinCounter ref={counterApiRef} value={displayWin} multiplier={multiplier} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`ff__board-wrap${boardWin ? ' ff-board--win' : ''}`}>
|
||||||
|
<div className="ff__board ff__board--reels" aria-label="Поле 5 на 5" ref={boardRef}>
|
||||||
|
{grid.map((column, reel) => (
|
||||||
|
<ReelColumn
|
||||||
|
key={reel}
|
||||||
|
reelIndex={reel}
|
||||||
|
symbols={column}
|
||||||
|
spinning={reels[reel]?.spinning ?? false}
|
||||||
|
stopping={reels[reel]?.stopping ?? false}
|
||||||
|
timing={timing}
|
||||||
|
winRows={winRowsByReel[reel] ?? new Set()}
|
||||||
|
scatterRows={scatterRowsByReel[reel] ?? new Set()}
|
||||||
|
sparkRows={sparkByReel[reel] ?? new Set()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<WinLinesOverlay
|
||||||
|
lineWins={current?.line_wins ?? []}
|
||||||
|
visible={showLines}
|
||||||
|
scatterCells={scatterCells}
|
||||||
|
drawMs={timing.lineDrawMs}
|
||||||
|
/>
|
||||||
|
{toast && (
|
||||||
|
<div key={toast.key} className="ff-toast" aria-hidden>
|
||||||
|
{toast.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CoinFlight
|
||||||
|
active={coinActive}
|
||||||
|
spawns={coinSpawns}
|
||||||
|
boardRef={boardRef}
|
||||||
|
stageRef={stageRef}
|
||||||
|
targetRef={valueTargetRef}
|
||||||
|
midRef={multTargetRef}
|
||||||
|
viaMid={coinViaMid}
|
||||||
|
flightMs={timing.coinMs}
|
||||||
|
staggerMs={timing.coinStaggerMs}
|
||||||
|
onMidHit={onMidHit}
|
||||||
|
onCoinHit={onCoinHit}
|
||||||
|
onComplete={() => coinDoneResolver.current?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ff__controls">
|
||||||
|
<div className="ff__row">
|
||||||
|
<label className="ff__field">
|
||||||
|
<span>Ставка</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={minBet}
|
||||||
|
max={maxBet}
|
||||||
|
step={1}
|
||||||
|
value={bet}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setBet(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="ff__field">
|
||||||
|
<span>Спинов</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={minSpins}
|
||||||
|
max={maxSpins}
|
||||||
|
step={1}
|
||||||
|
value={spinCount}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setSpinCount(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ff__row ff__row--actions">
|
||||||
|
<button type="button" className="btn btn--outline ff__speed" disabled={busy} onClick={cycleSpeed}>
|
||||||
|
Скорость: {SPEED_TIMINGS[speed].label}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn--gold ff__spin" disabled={busy} onClick={() => void play()}>
|
||||||
|
{busy ? `Спин ${replayIndex}/${spinCount}` : `Крутить · ${cost}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{current && busy && (
|
||||||
|
<p className="ff__spin-info">
|
||||||
|
{Number(current.total_win) > 0 ? `+${current.total_win}` : 'Пусто'}
|
||||||
|
{current.meta_triggered ? ' · дух поднял уровень!' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{summary && !busy && (
|
||||||
|
<div className="ff__summary">
|
||||||
|
<h2>Итог пачки</h2>
|
||||||
|
<p>
|
||||||
|
Ставка {summary.total_bet} → выигрыш <strong>{summary.total_win}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Триггеров: {summary.trigger_count} · макс. уровень: {summary.max_level}
|
||||||
|
{sessionWin > 0 ? ` · насчитано ${sessionWin.toFixed(2)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/games/forest-fortune/ReelColumn.tsx
Normal file
80
src/games/forest-fortune/ReelColumn.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState, type CSSProperties } from 'react'
|
||||||
|
import { randomSymbol, symbolGlyph } from './reelUtils'
|
||||||
|
import type { SpeedTiming } from './speed'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
reelIndex: number
|
||||||
|
symbols: string[]
|
||||||
|
spinning: boolean
|
||||||
|
stopping: boolean
|
||||||
|
timing: SpeedTiming
|
||||||
|
winRows: Set<number>
|
||||||
|
scatterRows: Set<number>
|
||||||
|
sparkRows?: Set<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurColumn(): string[] {
|
||||||
|
return Array.from({ length: 10 }, () => randomSymbol())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReelColumn({
|
||||||
|
reelIndex,
|
||||||
|
symbols,
|
||||||
|
spinning,
|
||||||
|
stopping,
|
||||||
|
timing,
|
||||||
|
winRows,
|
||||||
|
scatterRows,
|
||||||
|
sparkRows,
|
||||||
|
}: Props) {
|
||||||
|
const [spinSymbols, setSpinSymbols] = useState(symbols)
|
||||||
|
const [mode, setMode] = useState<'idle' | 'spin' | 'stop'>('idle')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (spinning && !stopping) {
|
||||||
|
setSpinSymbols(blurColumn())
|
||||||
|
setMode('spin')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (stopping) {
|
||||||
|
setMode('stop')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMode('idle')
|
||||||
|
}, [spinning, stopping])
|
||||||
|
|
||||||
|
const cells = mode === 'spin' ? spinSymbols : symbols
|
||||||
|
const rendered = mode === 'spin' ? [...cells, ...cells] : cells
|
||||||
|
|
||||||
|
const style: CSSProperties | undefined =
|
||||||
|
mode === 'spin' ? { ['--ff-spin-ms' as string]: `${timing.stripCycleMs}ms` } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`ff-reel${mode === 'spin' ? ' ff-reel--spin' : ''}${mode === 'stop' ? ' ff-reel--stop' : ''}`}
|
||||||
|
data-reel={reelIndex}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className="ff-reel__viewport">
|
||||||
|
<div className="ff-reel__strip">
|
||||||
|
{rendered.map((sym, idx) => {
|
||||||
|
const row = mode === 'spin' ? -1 : idx
|
||||||
|
const win = row >= 0 && winRows.has(row)
|
||||||
|
const scatter = row >= 0 && scatterRows.has(row)
|
||||||
|
const spark = row >= 0 && sparkRows?.has(row)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${reelIndex}-${mode}-${idx}`}
|
||||||
|
className={`ff-reel__cell${win ? ' ff-reel__cell--win' : ''}${
|
||||||
|
scatter ? ' ff-reel__cell--scatter' : ''
|
||||||
|
}${spark ? ' ff-reel__cell--spark' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="ff-reel__glyph">{symbolGlyph(sym)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
src/games/forest-fortune/WinCounter.tsx
Normal file
77
src/games/forest-fortune/WinCounter.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export type WinCounterHandle = {
|
||||||
|
shake: () => void
|
||||||
|
shakeMult: () => void
|
||||||
|
showFloat: (amount: number) => void
|
||||||
|
getMultEl: () => HTMLElement | null
|
||||||
|
getValueEl: () => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: number
|
||||||
|
multiplier: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WinCounter = forwardRef<WinCounterHandle, Props>(function WinCounter(
|
||||||
|
{ value, multiplier },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const multRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const valueRef = useRef<HTMLStrongElement>(null)
|
||||||
|
const [shakeKey, setShakeKey] = useState(0)
|
||||||
|
const [multShakeKey, setMultShakeKey] = useState(0)
|
||||||
|
const [impact, setImpact] = useState(false)
|
||||||
|
const [floatLabel, setFloatLabel] = useState<string | null>(null)
|
||||||
|
const floatTimer = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
shake() {
|
||||||
|
setShakeKey((k) => k + 1)
|
||||||
|
setImpact(true)
|
||||||
|
window.setTimeout(() => setImpact(false), 160)
|
||||||
|
},
|
||||||
|
shakeMult() {
|
||||||
|
setMultShakeKey((k) => k + 1)
|
||||||
|
},
|
||||||
|
showFloat(amount: number) {
|
||||||
|
if (floatTimer.current) window.clearTimeout(floatTimer.current)
|
||||||
|
setFloatLabel(`+${amount.toFixed(2)}`)
|
||||||
|
floatTimer.current = window.setTimeout(() => setFloatLabel(null), 900)
|
||||||
|
},
|
||||||
|
getMultEl: () => multRef.current,
|
||||||
|
getValueEl: () => valueRef.current,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const showMult = multiplier > 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={`ff-win${impact ? ' ff-win--impact' : ''}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`ff-win__inner${shakeKey > 0 ? ' ff-win__inner--shake' : ''}`}
|
||||||
|
key={`shake-${shakeKey}`}
|
||||||
|
>
|
||||||
|
<span className="ff-win__label">Выигрыш</span>
|
||||||
|
{showMult && (
|
||||||
|
<span
|
||||||
|
ref={multRef}
|
||||||
|
key={`mult-${multShakeKey}`}
|
||||||
|
className={`ff-win__mult${multShakeKey > 0 ? ' ff-win__mult--hit' : ''}`}
|
||||||
|
>
|
||||||
|
×{multiplier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!showMult && <span ref={multRef} className="ff-win__mult-slot" aria-hidden />}
|
||||||
|
<strong ref={valueRef} className="ff-win__value">
|
||||||
|
{value.toFixed(2)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{floatLabel && <span className="ff-win__float">{floatLabel}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
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] ?? '?'
|
||||||
|
}
|
||||||
70
src/games/forest-fortune/speed.ts
Normal file
70
src/games/forest-fortune/speed.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export type AnimSpeed = 'slow' | 'normal' | 'fast'
|
||||||
|
|
||||||
|
export type SpeedTiming = {
|
||||||
|
label: string
|
||||||
|
/** Initial full-spin before first reel stops */
|
||||||
|
spinLeadMs: number
|
||||||
|
/** Delay between consecutive reel stops */
|
||||||
|
stopGapMs: number
|
||||||
|
/** How long win lines stay visible (includes draw) */
|
||||||
|
lineHoldMs: number
|
||||||
|
/** Stroke draw duration for win lines */
|
||||||
|
lineDrawMs: number
|
||||||
|
/** Single coin flight duration */
|
||||||
|
coinMs: number
|
||||||
|
/** Delay between coin launches */
|
||||||
|
coinStaggerMs: number
|
||||||
|
/** Counter shake duration hint */
|
||||||
|
counterShakeMs: number
|
||||||
|
/** Pause before next spin in pack */
|
||||||
|
betweenSpinsMs: number
|
||||||
|
/** CSS animation duration for spinning strip */
|
||||||
|
stripCycleMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPEED_TIMINGS: Record<AnimSpeed, SpeedTiming> = {
|
||||||
|
slow: {
|
||||||
|
label: 'Медленно',
|
||||||
|
spinLeadMs: 700,
|
||||||
|
stopGapMs: 380,
|
||||||
|
lineHoldMs: 1600,
|
||||||
|
lineDrawMs: 750,
|
||||||
|
coinMs: 720,
|
||||||
|
coinStaggerMs: 110,
|
||||||
|
counterShakeMs: 180,
|
||||||
|
betweenSpinsMs: 450,
|
||||||
|
stripCycleMs: 420,
|
||||||
|
},
|
||||||
|
normal: {
|
||||||
|
label: 'Обычная',
|
||||||
|
spinLeadMs: 420,
|
||||||
|
stopGapMs: 220,
|
||||||
|
lineHoldMs: 1000,
|
||||||
|
lineDrawMs: 520,
|
||||||
|
coinMs: 520,
|
||||||
|
coinStaggerMs: 70,
|
||||||
|
counterShakeMs: 140,
|
||||||
|
betweenSpinsMs: 280,
|
||||||
|
stripCycleMs: 280,
|
||||||
|
},
|
||||||
|
fast: {
|
||||||
|
label: 'Быстро',
|
||||||
|
spinLeadMs: 180,
|
||||||
|
stopGapMs: 90,
|
||||||
|
lineHoldMs: 480,
|
||||||
|
lineDrawMs: 260,
|
||||||
|
coinMs: 300,
|
||||||
|
coinStaggerMs: 40,
|
||||||
|
counterShakeMs: 100,
|
||||||
|
betweenSpinsMs: 120,
|
||||||
|
stripCycleMs: 160,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPEED_ORDER: AnimSpeed[] = ['slow', 'normal', 'fast']
|
||||||
|
|
||||||
|
export function delay(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
window.setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
75
src/games/forest-fortune/winVfx.ts
Normal file
75
src/games/forest-fortune/winVfx.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { PaylineWin, SpinOutcome } from '../../types'
|
||||||
|
import { winningSegment } from './WinLinesOverlay'
|
||||||
|
|
||||||
|
export type CellPoint = {
|
||||||
|
reel: number
|
||||||
|
row: number
|
||||||
|
/** 0–100 within board content box */
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellPct(reel: number, row: number): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: ((reel + 0.5) / 5) * 100,
|
||||||
|
y: ((row + 0.5) / 5) * 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unique winning cells from line segments (+ scatter on meta), left-to-right order. */
|
||||||
|
export function collectWinCells(outcome: SpinOutcome): CellPoint[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const points: CellPoint[] = []
|
||||||
|
|
||||||
|
const push = (reel: number, row: number) => {
|
||||||
|
const key = `${reel}-${row}`
|
||||||
|
if (seen.has(key)) return
|
||||||
|
seen.add(key)
|
||||||
|
const { x, y } = cellPct(reel, row)
|
||||||
|
points.push({ reel, row, x, y })
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineWins: PaylineWin[] = outcome.line_wins
|
||||||
|
for (const win of lineWins) {
|
||||||
|
const segment = winningSegment(win)
|
||||||
|
segment.forEach((row, reel) => push(reel, row))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.meta_triggered) {
|
||||||
|
outcome.grid.forEach((col, reel) => {
|
||||||
|
col.forEach((sym, row) => {
|
||||||
|
if (sym === 'scatter') push(reel, row)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if win with no lines (edge), use board center
|
||||||
|
if (!points.length && Number(outcome.total_win) > 0) {
|
||||||
|
const { x, y } = cellPct(2, 2)
|
||||||
|
points.push({ reel: 2, row: 2, x, y })
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build coin spawn list: 1 per cell, +echo for big wins, cap 12. */
|
||||||
|
export function buildCoinSpawns(cells: CellPoint[], winAmount: number): CellPoint[] {
|
||||||
|
if (!cells.length) return []
|
||||||
|
const echo = winAmount >= 50 ? 2 : winAmount >= 15 ? 1 : 0
|
||||||
|
const out: CellPoint[] = []
|
||||||
|
for (let e = 0; e <= echo; e += 1) {
|
||||||
|
for (const c of cells) {
|
||||||
|
out.push({
|
||||||
|
...c,
|
||||||
|
x: c.x + (Math.random() - 0.5) * 4,
|
||||||
|
y: c.y + (Math.random() - 0.5) * 4,
|
||||||
|
})
|
||||||
|
if (out.length >= 12) return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefersReducedMotion(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
}
|
||||||
611
src/index.css
611
src/index.css
@@ -564,7 +564,7 @@ html.gate-transit-lock body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
perspective: 1400px;
|
perspective: 1400px;
|
||||||
perspective-origin: 50% 46%;
|
perspective-origin: 50% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-transit__hero {
|
.gate-transit__hero {
|
||||||
@@ -582,8 +582,21 @@ html.gate-transit-lock body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter: drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6));
|
filter: drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6));
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gate-transit__ravens {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-transit__ravens canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.gate-transit__veil {
|
.gate-transit__veil {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -616,3 +629,599 @@ html.gate-transit-lock body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Forest Fortune */
|
||||||
|
.ff {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__header--bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__heading {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.8rem, 6vw, 2.4rem);
|
||||||
|
color: var(--gold-soft);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__lead {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__balance {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(15, 16, 13, 0.45);
|
||||||
|
border: 1px solid rgba(166, 137, 70, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__balance-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__balance-value {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--gold-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__stage {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, rgba(26, 27, 22, 0.9), rgba(58, 59, 49, 0.55));
|
||||||
|
border: 1px solid rgba(166, 137, 70, 0.4);
|
||||||
|
box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.25);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__inner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.7rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__inner--shake {
|
||||||
|
animation: ff-win-shake 0.28s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win--impact {
|
||||||
|
border-color: var(--gold);
|
||||||
|
box-shadow:
|
||||||
|
0 0 18px rgba(166, 137, 70, 0.45),
|
||||||
|
inset 0 0 18px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__mult {
|
||||||
|
justify-self: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.55rem, 5vw, 2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #c0392b;
|
||||||
|
text-shadow:
|
||||||
|
0 0 10px rgba(192, 57, 43, 0.45),
|
||||||
|
0 1px 0 rgba(15, 16, 13, 0.55);
|
||||||
|
transform: rotate(-12deg);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__mult--hit {
|
||||||
|
animation: ff-mult-hit 0.28s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__mult-slot {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__value {
|
||||||
|
justify-self: end;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.65rem;
|
||||||
|
color: var(--gold-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__float {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.85rem;
|
||||||
|
top: -0.35rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gold-soft);
|
||||||
|
text-shadow: 0 0 12px rgba(166, 137, 70, 0.65);
|
||||||
|
animation: ff-win-float 0.9s ease-out forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-win__shake-layer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-win-shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: translateX(-3px) rotate(-0.6deg) scale(1.03);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateX(3px) rotate(0.6deg) scale(1.04);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateX(-2px) rotate(-0.3deg);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: translateX(2px) rotate(0.3deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-win-float {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px) scale(0.85);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1.08);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-22px) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-mult-hit {
|
||||||
|
0% {
|
||||||
|
transform: rotate(-12deg) scale(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: rotate(-8deg) scale(1.35);
|
||||||
|
color: #e74c3c;
|
||||||
|
text-shadow: 0 0 16px rgba(231, 76, 60, 0.7);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(-12deg) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__board-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-board--win::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 28px rgba(166, 137, 70, 0.35),
|
||||||
|
0 0 24px rgba(166, 137, 70, 0.25);
|
||||||
|
animation: ff-board-flash 0.7s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-board-flash {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background:
|
||||||
|
linear-gradient(160deg, rgba(58, 70, 42, 0.55), rgba(26, 27, 22, 0.9)),
|
||||||
|
radial-gradient(circle at 30% 20%, rgba(166, 137, 70, 0.18), transparent 55%);
|
||||||
|
border: 1px solid rgba(166, 137, 70, 0.35);
|
||||||
|
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__board--reels {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0;
|
||||||
|
background: rgba(15, 16, 13, 0.55);
|
||||||
|
border: none;
|
||||||
|
box-shadow: inset -1px 0 0 rgba(154, 148, 134, 0.16);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel:last-child {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel__viewport {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel__strip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel--spin .ff-reel__strip {
|
||||||
|
height: 200%;
|
||||||
|
animation: ff-reel-spin var(--ff-spin-ms, 280ms) linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel--spin .ff-reel__cell {
|
||||||
|
filter: blur(1.2px);
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel--stop {
|
||||||
|
animation: ff-reel-land 0.28s cubic-bezier(0.12, 0.78, 0.18, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-reel-spin {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-reel-land {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateY(3px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel__cell {
|
||||||
|
flex: 1 0 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-bottom: 1px solid rgba(154, 148, 134, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel--spin .ff-reel__cell {
|
||||||
|
flex: 1 0 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel__cell--win {
|
||||||
|
background: rgba(166, 137, 70, 0.22);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(166, 137, 70, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel__cell--scatter {
|
||||||
|
box-shadow: inset 0 0 16px rgba(200, 217, 125, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel__cell--spark {
|
||||||
|
animation: ff-cell-spark 0.55s ease-out;
|
||||||
|
background: rgba(226, 200, 120, 0.28);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(226, 200, 120, 0.7),
|
||||||
|
0 0 14px rgba(226, 200, 120, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-cell-spark {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1.06);
|
||||||
|
filter: brightness(1.35);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel__glyph {
|
||||||
|
font-size: clamp(1.2rem, 5.2vw, 1.75rem);
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-lines {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.55rem;
|
||||||
|
width: calc(100% - 1.1rem);
|
||||||
|
height: calc(100% - 1.1rem);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-lines__glow {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(1.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-lines__path {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-lines__dot,
|
||||||
|
.ff-lines__dot-glow {
|
||||||
|
opacity: 0;
|
||||||
|
animation: ff-scatter-pop 0.28s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-lines__dot-glow {
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-line-trace {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-lines__scatter {
|
||||||
|
opacity: 0;
|
||||||
|
animation: ff-scatter-pop 0.4s ease-out 0.15s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-scatter-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.6);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-coins {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 6;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-coins__coin {
|
||||||
|
position: absolute;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #1a1b16;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #f0d9a0, #a68946 55%, #7a6230);
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px rgba(226, 200, 120, 0.75),
|
||||||
|
inset 0 0 4px rgba(255, 255, 255, 0.35);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-coins__coin--mid {
|
||||||
|
box-shadow: 0 0 14px rgba(231, 76, 60, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-coins__coin--hit {
|
||||||
|
box-shadow: 0 0 16px rgba(226, 200, 120, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-toast {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 42%;
|
||||||
|
z-index: 5;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.8rem, 8vw, 2.6rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--gold-soft);
|
||||||
|
text-shadow:
|
||||||
|
0 0 18px rgba(166, 137, 70, 0.75),
|
||||||
|
0 2px 0 rgba(15, 16, 13, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: ff-toast-pop 0.85s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ff-toast-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -40%) scale(0.6);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1.12);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -55%) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -70%) scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__row--actions {
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(166, 137, 70, 0.35);
|
||||||
|
background: rgba(15, 16, 13, 0.55);
|
||||||
|
color: var(--cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__speed {
|
||||||
|
min-height: 2.8rem;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__spin {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__spin-info {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--cream);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__summary {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(15, 16, 13, 0.45);
|
||||||
|
border: 1px solid rgba(166, 137, 70, 0.28);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__summary h2 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--gold-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__summary p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ff-reel--spin .ff-reel__strip,
|
||||||
|
.ff-reel--stop {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-reel--spin .ff-reel__cell {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-lines__path,
|
||||||
|
.ff-lines__glow {
|
||||||
|
animation: none !important;
|
||||||
|
stroke-dashoffset: 0 !important;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-lines__dot,
|
||||||
|
.ff-lines__dot-glow,
|
||||||
|
.ff-lines__scatter,
|
||||||
|
.ff-coins__coin,
|
||||||
|
.ff-win__inner--shake,
|
||||||
|
.ff-win__float,
|
||||||
|
.ff-win__mult--hit,
|
||||||
|
.ff-toast,
|
||||||
|
.ff-board--win::after,
|
||||||
|
.ff-reel__cell--spark {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff-toast {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -17,6 +19,11 @@ import {
|
|||||||
createReducedLogoFlyTimeline,
|
createReducedLogoFlyTimeline,
|
||||||
type LogoFlyTargets,
|
type LogoFlyTargets,
|
||||||
} from './gateTimeline'
|
} from './gateTimeline'
|
||||||
|
import { capturePortalScreen, defaultRavenFx, type RavenFx } from './ravenFx'
|
||||||
|
|
||||||
|
const RavenBurstCanvas = lazy(() =>
|
||||||
|
import('./RavenBurstCanvas').then((m) => ({ default: m.RavenBurstCanvas })),
|
||||||
|
)
|
||||||
|
|
||||||
type StartResult = {
|
type StartResult = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -54,7 +61,6 @@ function collectUiFade(gate: HTMLElement, logo: HTMLElement | null): HTMLElement
|
|||||||
|
|
||||||
function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
|
function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
|
||||||
const rect = logo.getBoundingClientRect()
|
const rect = logo.getBoundingClientRect()
|
||||||
// Geometry only — keep invisible until the original logo is hidden in the same frame
|
|
||||||
gsap.set(hero, {
|
gsap.set(hero, {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: rect.top,
|
top: rect.top,
|
||||||
@@ -62,7 +68,7 @@ function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
|
|||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
zIndex: 2,
|
zIndex: 1,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
visibility: 'hidden',
|
visibility: 'hidden',
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -70,7 +76,7 @@ function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
|
|||||||
z: 0,
|
z: 0,
|
||||||
filter: 'drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))',
|
filter: 'drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
transformOrigin: '50% 46%',
|
transformOrigin: '50% 50%',
|
||||||
transformPerspective: 1400,
|
transformPerspective: 1400,
|
||||||
force3D: true,
|
force3D: true,
|
||||||
willChange: 'transform, filter, opacity',
|
willChange: 'transform, filter, opacity',
|
||||||
@@ -86,10 +92,13 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [phase, setPhase] = useState<GateTransitPhase>('idle')
|
const [phase, setPhase] = useState<GateTransitPhase>('idle')
|
||||||
const [showOverlay, setShowOverlay] = useState(false)
|
const [showOverlay, setShowOverlay] = useState(false)
|
||||||
|
const [mountRavens, setMountRavens] = useState(false)
|
||||||
|
|
||||||
const veilRef = useRef<HTMLDivElement | null>(null)
|
const veilRef = useRef<HTMLDivElement | null>(null)
|
||||||
const stageRef = useRef<HTMLDivElement | null>(null)
|
const stageRef = useRef<HTMLDivElement | null>(null)
|
||||||
const heroRef = useRef<HTMLImageElement | null>(null)
|
const heroRef = useRef<HTMLImageElement | null>(null)
|
||||||
|
const ravenWrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const ravenFxRef = useRef<RavenFx>({ ...defaultRavenFx })
|
||||||
const timelineRef = useRef<gsap.core.Timeline | null>(null)
|
const timelineRef = useRef<gsap.core.Timeline | null>(null)
|
||||||
const acceptPromiseRef = useRef<Promise<void> | null>(null)
|
const acceptPromiseRef = useRef<Promise<void> | null>(null)
|
||||||
const acceptErrorRef = useRef<string | null>(null)
|
const acceptErrorRef = useRef<string | null>(null)
|
||||||
@@ -116,7 +125,9 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
(result: StartResult) => {
|
(result: StartResult) => {
|
||||||
timelineRef.current?.kill()
|
timelineRef.current?.kill()
|
||||||
timelineRef.current = null
|
timelineRef.current = null
|
||||||
|
setMountRavens(false)
|
||||||
setShowOverlay(false)
|
setShowOverlay(false)
|
||||||
|
Object.assign(ravenFxRef.current, defaultRavenFx)
|
||||||
setPhase(result.ok ? 'done' : 'error')
|
setPhase(result.ok ? 'done' : 'error')
|
||||||
cleanupDomLocks()
|
cleanupDomLocks()
|
||||||
resolveStartRef.current?.(result)
|
resolveStartRef.current?.(result)
|
||||||
@@ -191,18 +202,24 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
placeHeroOverLogo(hero, logo)
|
placeHeroOverLogo(hero, logo)
|
||||||
|
|
||||||
// One frame with hero invisible + correct box, then atomic swap with the page logo
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (timelineRef.current) return
|
if (timelineRef.current) return
|
||||||
placeHeroOverLogo(hero, logo)
|
placeHeroOverLogo(hero, logo)
|
||||||
revealHeroOverLogo(hero, logo)
|
revealHeroOverLogo(hero, logo)
|
||||||
|
|
||||||
|
// Lock cone apex to the portal (zoom origin) before scale starts
|
||||||
|
const portal = capturePortalScreen(hero)
|
||||||
|
ravenFxRef.current.apexSx = portal.x
|
||||||
|
ravenFxRef.current.apexSy = portal.y
|
||||||
|
|
||||||
const targets: LogoFlyTargets = {
|
const targets: LogoFlyTargets = {
|
||||||
hero,
|
hero,
|
||||||
uiFade: collectUiFade(gate, logo),
|
uiFade: collectUiFade(gate, logo),
|
||||||
originalLogo: logo,
|
originalLogo: logo,
|
||||||
veil: veilRef.current,
|
veil: veilRef.current,
|
||||||
stage: stageRef.current,
|
stage: stageRef.current,
|
||||||
|
ravenLayer: ravenWrapRef.current,
|
||||||
|
ravenFx: ravenFxRef.current,
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
@@ -236,6 +253,12 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
finish({ ok: false, error: 'Не удалось запустить переход' })
|
finish({ ok: false, error: 'Не удалось запустить переход' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Wait a beat for lazy raven canvas when enabled
|
||||||
|
if (mountRavens && !ravenWrapRef.current && tries < 40) {
|
||||||
|
tries += 1
|
||||||
|
window.setTimeout(tick, 40)
|
||||||
|
return
|
||||||
|
}
|
||||||
startTimeline()
|
startTimeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +267,7 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
window.clearTimeout(id)
|
window.clearTimeout(id)
|
||||||
}
|
}
|
||||||
}, [phase, showOverlay, startTimeline, finish])
|
}, [phase, showOverlay, mountRavens, startTimeline, finish])
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback(
|
||||||
(runAccept: () => Promise<void>) => {
|
(runAccept: () => Promise<void>) => {
|
||||||
@@ -252,7 +275,24 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
return Promise.resolve({ ok: false, error: 'Переход уже идёт' })
|
return Promise.resolve({ ok: false, error: 'Переход уже идёт' })
|
||||||
}
|
}
|
||||||
|
|
||||||
reducedRef.current = prefersReducedMotion()
|
return new Promise<StartResult>((resolve) => {
|
||||||
|
resolveStartRef.current = resolve
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const reduced = prefersReducedMotion()
|
||||||
|
reducedRef.current = reduced
|
||||||
|
Object.assign(ravenFxRef.current, defaultRavenFx)
|
||||||
|
|
||||||
|
let ravens = !reduced
|
||||||
|
if (ravens) {
|
||||||
|
try {
|
||||||
|
await import('./RavenBurstCanvas')
|
||||||
|
} catch {
|
||||||
|
ravens = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMountRavens(ravens)
|
||||||
setShowOverlay(true)
|
setShowOverlay(true)
|
||||||
setPhase('playing')
|
setPhase('playing')
|
||||||
document.documentElement.classList.add('gate-transit-lock')
|
document.documentElement.classList.add('gate-transit-lock')
|
||||||
@@ -272,9 +312,7 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
acceptPromiseRef.current = acceptPromise
|
acceptPromiseRef.current = acceptPromise
|
||||||
void acceptPromise.catch(() => undefined)
|
void acceptPromise.catch(() => undefined)
|
||||||
|
})()
|
||||||
return new Promise<StartResult>((resolve) => {
|
|
||||||
resolveStartRef.current = resolve
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[phase],
|
[phase],
|
||||||
@@ -314,6 +352,11 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{mountRavens && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RavenBurstCanvas fxRef={ravenFxRef} wrapRef={ravenWrapRef} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
<div className="gate-transit__veil" ref={veilRef} />
|
<div className="gate-transit__veil" ref={veilRef} />
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
32
src/vfx/RavenBurstCanvas.tsx
Normal file
32
src/vfx/RavenBurstCanvas.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Suspense, type MutableRefObject, type RefObject } from 'react'
|
||||||
|
import { Canvas } from '@react-three/fiber'
|
||||||
|
import type { RavenFx } from './ravenFx'
|
||||||
|
import { RavenFlock } from './RavenFlock'
|
||||||
|
|
||||||
|
type RavenBurstCanvasProps = {
|
||||||
|
fxRef: MutableRefObject<RavenFx>
|
||||||
|
wrapRef: RefObject<HTMLDivElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RavenBurstCanvas({ fxRef, wrapRef }: RavenBurstCanvasProps) {
|
||||||
|
return (
|
||||||
|
<div className="gate-transit__ravens" ref={wrapRef}>
|
||||||
|
<Canvas
|
||||||
|
dpr={[1, 1.25]}
|
||||||
|
gl={{
|
||||||
|
alpha: true,
|
||||||
|
antialias: false,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
stencil: false,
|
||||||
|
depth: true,
|
||||||
|
}}
|
||||||
|
camera={{ position: [0, 0, 5.2], fov: 42, near: 0.05, far: 40 }}
|
||||||
|
style={{ background: 'transparent' }}
|
||||||
|
>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RavenFlock fx={fxRef} />
|
||||||
|
</Suspense>
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
170
src/vfx/RavenFlock.tsx
Normal file
170
src/vfx/RavenFlock.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
|
import { useFrame, useLoader, useThree } from '@react-three/fiber'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import type { MutableRefObject } from 'react'
|
||||||
|
import type { RavenFx } from './ravenFx'
|
||||||
|
|
||||||
|
const COUNT = 18
|
||||||
|
const APEX_Z = -2.8
|
||||||
|
const CONE_SLOPE = 0.42
|
||||||
|
const AXIS_SPEED = 5.2
|
||||||
|
const CAM_Z = 5.2
|
||||||
|
|
||||||
|
type Bird = {
|
||||||
|
active: boolean
|
||||||
|
born: boolean
|
||||||
|
depth: number
|
||||||
|
theta: number
|
||||||
|
phase: number
|
||||||
|
flapSpeed: number
|
||||||
|
size: number
|
||||||
|
flip: number
|
||||||
|
delay: number
|
||||||
|
speed: number
|
||||||
|
slope: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBirds(): Bird[] {
|
||||||
|
const birds: Bird[] = []
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const wave = Math.floor(i / 3)
|
||||||
|
const theta = (i / COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.25
|
||||||
|
birds.push({
|
||||||
|
active: false,
|
||||||
|
born: false,
|
||||||
|
depth: 0,
|
||||||
|
theta,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
flapSpeed: 10 + Math.random() * 6,
|
||||||
|
size: 0.2 + Math.random() * 0.28,
|
||||||
|
flip: i % 2 === 0 ? -1 : 1,
|
||||||
|
delay: 0.03 * wave + Math.random() * 0.04,
|
||||||
|
speed: AXIS_SPEED * (0.85 + Math.random() * 0.35),
|
||||||
|
slope: CONE_SLOPE * (0.9 + Math.random() * 0.25),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return birds
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Viewport CSS px → world on the apex plane. Uses the raven canvas rect for correct NDC. */
|
||||||
|
function screenToApexWorld(
|
||||||
|
sx: number,
|
||||||
|
sy: number,
|
||||||
|
camera: THREE.PerspectiveCamera,
|
||||||
|
glDom: HTMLCanvasElement | null,
|
||||||
|
out: THREE.Vector3,
|
||||||
|
) {
|
||||||
|
const canvasRect = glDom?.getBoundingClientRect()
|
||||||
|
const left = canvasRect?.left ?? 0
|
||||||
|
const top = canvasRect?.top ?? 0
|
||||||
|
const viewW = canvasRect?.width || window.innerWidth
|
||||||
|
const viewH = canvasRect?.height || window.innerHeight
|
||||||
|
|
||||||
|
const ndcX = ((sx - left) / viewW) * 2 - 1
|
||||||
|
const ndcY = -((sy - top) / viewH) * 2 + 1
|
||||||
|
|
||||||
|
const dist = Math.abs(CAM_Z - APEX_Z)
|
||||||
|
const vFov = THREE.MathUtils.degToRad(camera.fov)
|
||||||
|
const halfH = Math.tan(vFov / 2) * dist
|
||||||
|
const halfW = halfH * (viewW / viewH)
|
||||||
|
out.set(ndcX * halfW, ndcY * halfH, APEX_Z)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RavenFlockProps = {
|
||||||
|
fx: MutableRefObject<RavenFx>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RavenFlock({ fx }: RavenFlockProps) {
|
||||||
|
const meshRef = useRef<THREE.InstancedMesh>(null)
|
||||||
|
const birds = useMemo(() => createBirds(), [])
|
||||||
|
const dummy = useMemo(() => new THREE.Object3D(), [])
|
||||||
|
const apex = useMemo(() => new THREE.Vector3(0, 0, APEX_Z), [])
|
||||||
|
const texture = useLoader(THREE.TextureLoader, '/raven-silhouette.svg')
|
||||||
|
const gl = useThree((s) => s.gl)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace
|
||||||
|
texture.anisotropy = 4
|
||||||
|
texture.needsUpdate = true
|
||||||
|
}, [texture])
|
||||||
|
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
const mesh = meshRef.current
|
||||||
|
if (!mesh) return
|
||||||
|
|
||||||
|
const cam = state.camera as THREE.PerspectiveCamera
|
||||||
|
// Fixed camera — do NOT recenter on apex (that pinned birds to screen center)
|
||||||
|
cam.position.set(0, 0, CAM_Z)
|
||||||
|
cam.lookAt(0, 0, 0)
|
||||||
|
cam.updateMatrixWorld()
|
||||||
|
|
||||||
|
const { burst, opacity, apexSx, apexSy } = fx.current
|
||||||
|
if (Number.isFinite(apexSx) && Number.isFinite(apexSy)) {
|
||||||
|
screenToApexWorld(apexSx, apexSy, cam, gl.domElement, apex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial
|
||||||
|
mat.opacity = opacity
|
||||||
|
|
||||||
|
const dt = Math.min(delta, 0.05)
|
||||||
|
const t = state.clock.elapsedTime
|
||||||
|
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const bird = birds[i]
|
||||||
|
|
||||||
|
if (!bird.born && burst > bird.delay) {
|
||||||
|
bird.born = true
|
||||||
|
bird.active = true
|
||||||
|
bird.depth = 0.02 + Math.random() * 0.06
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bird.active) {
|
||||||
|
dummy.position.set(0, -40, 0)
|
||||||
|
dummy.scale.set(0, 0, 0)
|
||||||
|
dummy.updateMatrix()
|
||||||
|
mesh.setMatrixAt(i, dummy.matrix)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bird.depth += bird.speed * dt
|
||||||
|
|
||||||
|
// Cone tip at portal; base // screen (XY)
|
||||||
|
const radius = bird.depth * bird.slope
|
||||||
|
const x = apex.x + Math.cos(bird.theta) * radius
|
||||||
|
const y = apex.y + Math.sin(bird.theta) * radius
|
||||||
|
const z = apex.z + bird.depth
|
||||||
|
|
||||||
|
const flap = 1 + Math.sin(t * bird.flapSpeed + bird.phase) * 0.18
|
||||||
|
const grow = THREE.MathUtils.smoothstep(bird.depth, 0.15, 3.2)
|
||||||
|
const s = bird.size * flap * (0.15 + grow * 1.55)
|
||||||
|
|
||||||
|
dummy.position.set(x, y, z)
|
||||||
|
dummy.quaternion.copy(cam.quaternion)
|
||||||
|
dummy.rotateY(bird.flip < 0 ? Math.PI : 0)
|
||||||
|
dummy.rotateZ(Math.sin(t * bird.flapSpeed + bird.phase) * 0.16)
|
||||||
|
dummy.scale.set(s * 1.45, s, 1)
|
||||||
|
dummy.updateMatrix()
|
||||||
|
mesh.setMatrixAt(i, dummy.matrix)
|
||||||
|
|
||||||
|
if (bird.depth > 7.5) {
|
||||||
|
bird.active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh.instanceMatrix.needsUpdate = true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]} frustumCulled={false}>
|
||||||
|
<planeGeometry args={[1, 0.55]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={texture}
|
||||||
|
color="#0a0b08"
|
||||||
|
transparent
|
||||||
|
depthWrite={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</instancedMesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
|
import { defaultRavenFx, type RavenFx } from './ravenFx'
|
||||||
|
|
||||||
export type GateTimelineHandlers = {
|
export type GateTimelineHandlers = {
|
||||||
onBlackout: () => void
|
onBlackout: () => void
|
||||||
@@ -12,14 +13,20 @@ export type LogoFlyTargets = {
|
|||||||
originalLogo: HTMLElement | null
|
originalLogo: HTMLElement | null
|
||||||
veil: HTMLElement | null
|
veil: HTMLElement | null
|
||||||
stage: HTMLElement | null
|
stage: HTMLElement | null
|
||||||
|
ravenLayer: HTMLElement | null
|
||||||
|
ravenFx: RavenFx
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Continuously fly into logo.png (central black arch), then void → home. */
|
/** Continuously fly into logo.png (central black arch), then void → home. */
|
||||||
export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTimelineHandlers) {
|
export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTimelineHandlers) {
|
||||||
const { hero, uiFade, veil, stage } = targets
|
const { hero, uiFade, veil, stage, ravenLayer, ravenFx } = targets
|
||||||
|
|
||||||
|
// Reset animatable raven fields only — keep captured portal screen position
|
||||||
|
const { apexSx, apexSy } = ravenFx
|
||||||
|
Object.assign(ravenFx, defaultRavenFx, { apexSx, apexSy })
|
||||||
|
|
||||||
gsap.set(hero, {
|
gsap.set(hero, {
|
||||||
transformOrigin: '50% 46%',
|
transformOrigin: '50% 50%',
|
||||||
transformPerspective: 1400,
|
transformPerspective: 1400,
|
||||||
force3D: true,
|
force3D: true,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -36,6 +43,10 @@ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTim
|
|||||||
gsap.set(veil, { opacity: 0 })
|
gsap.set(veil, { opacity: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ravenLayer) {
|
||||||
|
gsap.set(ravenLayer, { opacity: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => handlers.onComplete(),
|
onComplete: () => handlers.onComplete(),
|
||||||
})
|
})
|
||||||
@@ -69,6 +80,35 @@ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTim
|
|||||||
0.05,
|
0.05,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Ravens burst immediately as the zoom starts (apex locked to portal)
|
||||||
|
tl.to(
|
||||||
|
ravenFx,
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'power1.out',
|
||||||
|
},
|
||||||
|
0.05,
|
||||||
|
)
|
||||||
|
tl.to(
|
||||||
|
ravenFx,
|
||||||
|
{
|
||||||
|
burst: 1,
|
||||||
|
duration: 0.85,
|
||||||
|
ease: 'power2.out',
|
||||||
|
},
|
||||||
|
0.08,
|
||||||
|
)
|
||||||
|
tl.to(
|
||||||
|
ravenFx,
|
||||||
|
{
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.4,
|
||||||
|
ease: 'power2.in',
|
||||||
|
},
|
||||||
|
1.55,
|
||||||
|
)
|
||||||
|
|
||||||
tl.to(
|
tl.to(
|
||||||
hero,
|
hero,
|
||||||
{
|
{
|
||||||
@@ -103,7 +143,7 @@ export function createReducedLogoFlyTimeline(
|
|||||||
) {
|
) {
|
||||||
const { hero, uiFade, veil } = targets
|
const { hero, uiFade, veil } = targets
|
||||||
|
|
||||||
gsap.set(hero, { transformOrigin: '50% 46%', scale: 1 })
|
gsap.set(hero, { transformOrigin: '50% 50%', scale: 1 })
|
||||||
if (veil) gsap.set(veil, { opacity: 0 })
|
if (veil) gsap.set(veil, { opacity: 0 })
|
||||||
|
|
||||||
const tl = gsap.timeline({ onComplete: () => handlers.onComplete() })
|
const tl = gsap.timeline({ onComplete: () => handlers.onComplete() })
|
||||||
|
|||||||
32
src/vfx/ravenFx.ts
Normal file
32
src/vfx/ravenFx.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export type RavenFx = {
|
||||||
|
/** 0–1 spawn/burst progress */
|
||||||
|
burst: number
|
||||||
|
/** Layer opacity */
|
||||||
|
opacity: number
|
||||||
|
/**
|
||||||
|
* Fixed screen-pixel position of the gate image center.
|
||||||
|
* Captured once at scale=1; stays locked during zoom.
|
||||||
|
* NaN = not set yet.
|
||||||
|
*/
|
||||||
|
apexSx: number
|
||||||
|
apexSy: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultRavenFx: RavenFx = {
|
||||||
|
burst: 0,
|
||||||
|
opacity: 0,
|
||||||
|
apexSx: Number.NaN,
|
||||||
|
apexSy: Number.NaN,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point on the gate image: horizontal center, 50% from top. */
|
||||||
|
export const PORTAL_ORIGIN = { x: 0.5, y: 0.5 } as const
|
||||||
|
|
||||||
|
/** Center of the gate/logo image in viewport CSS pixels. */
|
||||||
|
export function capturePortalScreen(el: HTMLElement): { x: number; y: number } {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: rect.left + rect.width * PORTAL_ORIGIN.x,
|
||||||
|
y: rect.top + rect.height * PORTAL_ORIGIN.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user