From 70beb96f1a31f09ec8fa62834b7df932719952c9 Mon Sep 17 00:00:00 2001 From: Redsandy <34872843+Redsandyg@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:57:59 +0300 Subject: [PATCH] Enhance Forest Fortune game with coin flight animations and visual effects - Introduced `CoinFlight` component for animated coin transitions during wins. - Updated `ForestFortuneGame` to manage coin spawning and animation logic. - Added visual effects for win indicators, including toast notifications and spark animations. - Refactored `WinCounter` to support multiplier animations and float labels. - Enhanced CSS styles for improved game visuals and animations. - Implemented utility functions for handling win cell collection and coin spawn generation. --- src/games/forest-fortune/CoinFlight.tsx | 268 +++++++++++++--- .../forest-fortune/ForestFortuneGame.tsx | 227 ++++++++++---- src/games/forest-fortune/ReelColumn.tsx | 5 +- src/games/forest-fortune/WinCounter.tsx | 80 ++++- src/games/forest-fortune/speed.ts | 18 +- src/games/forest-fortune/winVfx.ts | 75 +++++ src/index.css | 287 +++++++++++++++--- 7 files changed, 805 insertions(+), 155 deletions(-) create mode 100644 src/games/forest-fortune/winVfx.ts diff --git a/src/games/forest-fortune/CoinFlight.tsx b/src/games/forest-fortune/CoinFlight.tsx index 774f12e..8e073e5 100644 --- a/src/games/forest-fortune/CoinFlight.tsx +++ b/src/games/forest-fortune/CoinFlight.tsx @@ -1,56 +1,236 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, type RefObject } from 'react' +import type { CellPoint } from './winVfx' +import { prefersReducedMotion } from './winVfx' -type Coin = { - id: number - left: number - delay: number -} - -type Props = { +export type CoinFlightProps = { active: boolean - amount: number - durationMs: number - fromSelector?: string + spawns: CellPoint[] + boardRef: RefObject + stageRef: RefObject + /** Final destination (win value) */ + targetRef: RefObject + /** Optional waypoint (multiplier badge) */ + midRef?: RefObject + /** When true, coins go cell → mid → target */ + viaMid?: boolean + flightMs: number + staggerMs: number + onMidHit?: (index: number) => void + onCoinHit?: (index: number) => void + onComplete?: () => void } -let coinSeq = 0 +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, amount, durationMs }: Props) { - const [coins, setCoins] = useState([]) +export function CoinFlight({ + active, + spawns, + boardRef, + stageRef, + targetRef, + midRef, + viaMid = false, + flightMs, + staggerMs, + onMidHit, + onCoinHit, + onComplete, +}: CoinFlightProps) { + const layerRef = useRef(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 || amount <= 0) { - setCoins([]) + 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 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 + 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) - return ( -
- {coins.map((c) => ( - - ● - - ))} -
- ) + 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
} diff --git a/src/games/forest-fortune/ForestFortuneGame.tsx b/src/games/forest-fortune/ForestFortuneGame.tsx index ef28fdf..dbeec5d 100644 --- a/src/games/forest-fortune/ForestFortuneGame.tsx +++ b/src/games/forest-fortune/ForestFortuneGame.tsx @@ -7,8 +7,14 @@ 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 { WinCounter, type WinCounterHandle } from './WinCounter' import { WinLinesOverlay } from './WinLinesOverlay' +import { + buildCoinSpawns, + collectWinCells, + prefersReducedMotion, + type CellPoint, +} from './winVfx' type Props = { game: GameInfo @@ -19,6 +25,8 @@ type ReelState = { stopping: boolean } +type ToastState = { text: string; key: number } | null + const IDLE_REELS: ReelState[] = Array.from({ length: 5 }, () => ({ spinning: false, stopping: false, @@ -44,19 +52,31 @@ export function ForestFortuneGame({ game }: Props) { 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 [coinActive, setCoinActive] = useState(false) + const [coinSpawns, setCoinSpawns] = useState([]) + const [coinViaMid, setCoinViaMid] = useState(false) + const [boardWin, setBoardWin] = useState(false) + const [toast, setToast] = useState(null) + const [sparkCells, setSparkCells] = useState>(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(null) + const boardRef = useRef(null) + const counterApiRef = useRef(null) + const valueTargetRef = useRef(null) + const multTargetRef = useRef(null) + const timing = SPEED_TIMINGS[speed] + const multiplier = current?.multiplier ?? 1 useEffect(() => { return () => { cancelRef.current = true + coinDoneResolver.current?.() } }, []) @@ -94,31 +114,43 @@ export function ForestFortuneGame({ game }: Props) { return cells }, [current, showLines]) - async function animateCountUp(from: number, to: number, ms: number) { - const start = performance.now() + 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((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() - } + coinDoneResolver.current = () => { + coinDoneResolver.current = null + resolve() } - requestAnimationFrame(tick) }) } async function animateSpinOutcome(outcome: SpinOutcome) { setShowLines(false) - setCoinBurst({ active: false, amount: 0 }) + setCoinActive(false) + setCoinSpawns([]) + setCoinViaMid(false) + setBoardWin(false) + setToast(null) + setSparkCells(new Set()) setCurrent(outcome) setGrid(outcome.grid) @@ -146,27 +178,74 @@ export function ForestFortuneGame({ game }: Props) { const winAmount = Number(outcome.total_win) const hasLines = outcome.line_wins.length > 0 || outcome.meta_triggered - if (hasLines) { - setShowLines(true) - await delay(timing.lineHoldMs) - } + const viaMid = outcome.multiplier > 1 - 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)) + 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) } @@ -178,6 +257,7 @@ export function ForestFortuneGame({ game }: Props) { setDisplayWin(0) displayWinRef.current = 0 setShowLines(false) + setCoinActive(false) cancelRef.current = false try { @@ -208,6 +288,9 @@ export function ForestFortuneGame({ game }: Props) { } finally { setBusy(false) setReels(IDLE_REELS) + setCoinActive(false) + setBoardWin(false) + setToast(null) } } @@ -220,29 +303,35 @@ export function ForestFortuneGame({ game }: Props) { Math.min(maxBet, Math.max(minBet, bet)) * Math.min(maxSpins, Math.max(minSpins, spinCount)) ).toFixed(2) + const sparkByReel = useMemo(() => { + const map: Array> = 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 (
-
-

{game.title}

-

{game.description}

-
- - Уровень {current?.level ?? 1} - - - Множитель ×{current?.multiplier ?? 1} - - - Баланс {user?.balance ?? '—'} - +
+
+

{game.title}

+

{game.description}

+
+
+ Баланс + {user?.balance ?? '—'}
-
- +
+
+ +
-
-
+
+
{grid.map((column, reel) => ( ))} + {toast && ( +
+ {toast.text} +
+ )}
-
+ + coinDoneResolver.current?.()} + />
diff --git a/src/games/forest-fortune/ReelColumn.tsx b/src/games/forest-fortune/ReelColumn.tsx index ad92a1c..2384d17 100644 --- a/src/games/forest-fortune/ReelColumn.tsx +++ b/src/games/forest-fortune/ReelColumn.tsx @@ -10,6 +10,7 @@ type Props = { timing: SpeedTiming winRows: Set scatterRows: Set + sparkRows?: Set } function blurColumn(): string[] { @@ -24,6 +25,7 @@ export function ReelColumn({ timing, winRows, scatterRows, + sparkRows, }: Props) { const [spinSymbols, setSpinSymbols] = useState(symbols) const [mode, setMode] = useState<'idle' | 'spin' | 'stop'>('idle') @@ -59,12 +61,13 @@ export function ReelColumn({ 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 (
{symbolGlyph(sym)}
diff --git a/src/games/forest-fortune/WinCounter.tsx b/src/games/forest-fortune/WinCounter.tsx index 974b0a4..34d54fc 100644 --- a/src/games/forest-fortune/WinCounter.tsx +++ b/src/games/forest-fortune/WinCounter.tsx @@ -1,13 +1,77 @@ -type Props = { - value: number - pulsing: boolean +import { forwardRef, useImperativeHandle, useRef, useState } from 'react' + +export type WinCounterHandle = { + shake: () => void + shakeMult: () => void + showFloat: (amount: number) => void + getMultEl: () => HTMLElement | null + getValueEl: () => HTMLElement | null } -export function WinCounter({ value, pulsing }: Props) { +type Props = { + value: number + multiplier: number +} + +export const WinCounter = forwardRef(function WinCounter( + { value, multiplier }, + ref, +) { + const rootRef = useRef(null) + const multRef = useRef(null) + const valueRef = useRef(null) + const [shakeKey, setShakeKey] = useState(0) + const [multShakeKey, setMultShakeKey] = useState(0) + const [impact, setImpact] = useState(false) + const [floatLabel, setFloatLabel] = useState(null) + const floatTimer = useRef(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 ( -
- Выигрыш - {value.toFixed(2)} +
+
0 ? ' ff-win__inner--shake' : ''}`} + key={`shake-${shakeKey}`} + > + Выигрыш + {showMult && ( + 0 ? ' ff-win__mult--hit' : ''}`} + > + ×{multiplier} + + )} + {!showMult && } + + {value.toFixed(2)} + +
+ {floatLabel && {floatLabel}}
) -} +}) diff --git a/src/games/forest-fortune/speed.ts b/src/games/forest-fortune/speed.ts index cd4b9dd..93bca09 100644 --- a/src/games/forest-fortune/speed.ts +++ b/src/games/forest-fortune/speed.ts @@ -10,8 +10,12 @@ export type SpeedTiming = { lineHoldMs: number /** Stroke draw duration for win lines */ lineDrawMs: number - /** Coin flight + counter tick */ + /** 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 */ @@ -25,7 +29,9 @@ export const SPEED_TIMINGS: Record = { stopGapMs: 380, lineHoldMs: 1600, lineDrawMs: 750, - coinMs: 900, + coinMs: 720, + coinStaggerMs: 110, + counterShakeMs: 180, betweenSpinsMs: 450, stripCycleMs: 420, }, @@ -35,7 +41,9 @@ export const SPEED_TIMINGS: Record = { stopGapMs: 220, lineHoldMs: 1000, lineDrawMs: 520, - coinMs: 550, + coinMs: 520, + coinStaggerMs: 70, + counterShakeMs: 140, betweenSpinsMs: 280, stripCycleMs: 280, }, @@ -45,7 +53,9 @@ export const SPEED_TIMINGS: Record = { stopGapMs: 90, lineHoldMs: 480, lineDrawMs: 260, - coinMs: 280, + coinMs: 300, + coinStaggerMs: 40, + counterShakeMs: 100, betweenSpinsMs: 120, stripCycleMs: 160, }, diff --git a/src/games/forest-fortune/winVfx.ts b/src/games/forest-fortune/winVfx.ts new file mode 100644 index 0000000..285ed57 --- /dev/null +++ b/src/games/forest-fortune/winVfx.ts @@ -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() + 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 +} diff --git a/src/index.css b/src/index.css index d89e434..e9147a3 100644 --- a/src/index.css +++ b/src/index.css @@ -642,6 +642,18 @@ html.gate-transit-lock body { 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); @@ -656,35 +668,68 @@ html.gate-transit-lock body { 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__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__meta strong { +.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 { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.75rem; - padding: 0.7rem 0.95rem; + 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 { @@ -694,29 +739,100 @@ html.gate-transit-lock body { 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--pulse { - animation: ff-win-pulse 0.45s ease-out; - border-color: var(--gold); +.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; } -@keyframes ff-win-pulse { - 0% { - transform: scale(1); - box-shadow: 0 0 0 rgba(166, 137, 70, 0); +.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: scale(1.03); - box-shadow: 0 0 22px rgba(166, 137, 70, 0.35); + 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% { - transform: scale(1); + 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); } } @@ -724,6 +840,31 @@ html.gate-transit-lock body { 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); @@ -827,6 +968,29 @@ html.gate-transit-lock body { 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; @@ -888,33 +1052,70 @@ html.gate-transit-lock body { position: absolute; inset: 0; pointer-events: none; - z-index: 4; + z-index: 6; 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; + 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; } -@keyframes ff-coin-fly { +.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%, 0) scale(0.6); + transform: translate(-50%, -40%) scale(0.6); } - 15% { + 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%, -120px) scale(1.15); + transform: translate(-50%, -70%) scale(0.95); } } @@ -1008,8 +1209,16 @@ html.gate-transit-lock body { .ff-lines__dot-glow, .ff-lines__scatter, .ff-coins__coin, - .ff-win--pulse { - animation: none; + .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; } }