diff --git a/client/public/bee.svg b/client/public/bee.svg new file mode 100644 index 0000000..cd3b946 --- /dev/null +++ b/client/public/bee.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/bird.svg b/client/public/bird.svg new file mode 100644 index 0000000..9899614 --- /dev/null +++ b/client/public/bird.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/client/public/butterfly-golden.svg b/client/public/butterfly-golden.svg new file mode 100644 index 0000000..a08d68e --- /dev/null +++ b/client/public/butterfly-golden.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/butterfly-net-golden.svg b/client/public/butterfly-net-golden.svg new file mode 100644 index 0000000..ff726e4 --- /dev/null +++ b/client/public/butterfly-net-golden.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/butterfly-net-torn.svg b/client/public/butterfly-net-torn.svg new file mode 100644 index 0000000..de5b951 --- /dev/null +++ b/client/public/butterfly-net-torn.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/coin.svg b/client/public/coin.svg new file mode 100644 index 0000000..5b1d087 --- /dev/null +++ b/client/public/coin.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/FlyingButterflies.scss b/client/src/FlyingButterflies.scss index 0ed8782..a4926e1 100644 --- a/client/src/FlyingButterflies.scss +++ b/client/src/FlyingButterflies.scss @@ -36,6 +36,31 @@ background-size: contain; animation: butterfly-flap 0.45s ease-in-out infinite; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15)); + + // Vzácný zlatý motýl – WOW: silná pulzující zlatá záře + duhový nádech + &.golden { + animation: + butterfly-flap 0.35s ease-in-out infinite, + butterfly-golden-glow 1.4s ease-in-out infinite; + } +} + +@keyframes butterfly-golden-glow { + 0%, + 100% { + filter: + saturate(1.15) hue-rotate(0deg) + drop-shadow(0 0 5px rgba(255, 215, 80, 0.85)) + drop-shadow(0 0 10px rgba(255, 180, 40, 0.55)) + drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2)); + } + 50% { + filter: + saturate(1.4) hue-rotate(-16deg) + drop-shadow(0 0 13px rgba(255, 235, 130, 1)) + drop-shadow(0 0 24px rgba(255, 190, 50, 0.95)) + drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2)); + } } @keyframes butterfly-flap { @@ -48,6 +73,28 @@ } } +// --- Havěť (ptáci a vosy) ---------------------------------------------------- + +.butterfly-critter { + position: absolute; + top: 0; + left: 0; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + pointer-events: none; + will-change: transform; + filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.2)); + z-index: 3; + + // Vosa jde zaklikat (plácačka) + &.wasp { + pointer-events: auto; + cursor: crosshair; + touch-action: none; + } +} + // --- Síťka na chytání -------------------------------------------------------- .butterfly-net { @@ -63,7 +110,6 @@ will-change: transform; filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)); transition: filter 0.15s ease; - // zabrání označování/scrollování při tažení na dotykových zařízeních touch-action: none; user-select: none; @@ -72,9 +118,45 @@ filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35)); } + // Prémiová (zlatá) síťka – zlatá záře a jemný pulz + &.premium { + filter: drop-shadow(0 0 8px rgba(255, 200, 60, 0.85)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)); + animation: butterfly-net-glow 1.3s ease-in-out infinite; + } + + // Protržená síťka – nedá se chytat + &.torn { + cursor: not-allowed; + opacity: 0.9; + filter: drop-shadow(0 2px 3px rgba(150, 30, 30, 0.35)); + } + &.catch-pop { animation: butterfly-net-pop 0.3s ease-out; } + + &.stung { + animation: butterfly-net-stung 0.4s ease-in-out; + } + + // Omráčená síťka (po žihnutí vosou) – po celou dobu, kdy nejde chytat. + // Má přednost před grabbed kurzorem. + &.stunned { + cursor: not-allowed !important; + animation: butterfly-net-dizzy 0.9s ease-in-out infinite; + } +} + +// Zešednutí + červený nádech, ať je jasné, že síťka teď nechytá. +// NEanimujeme transform (ten řídí JS inline) – jen filter. +@keyframes butterfly-net-dizzy { + 0%, + 100% { + filter: grayscale(0.7) brightness(0.85) drop-shadow(0 0 5px rgba(217, 72, 15, 0.55)); + } + 50% { + filter: grayscale(0.85) brightness(0.8) drop-shadow(0 0 9px rgba(217, 72, 15, 0.8)); + } } @keyframes butterfly-net-pop { @@ -89,17 +171,58 @@ } } -// Prchavé „+1" v místě chycení -.butterfly-catch-fx { +@keyframes butterfly-net-glow { + 0%, + 100% { + filter: drop-shadow(0 0 6px rgba(255, 200, 60, 0.6)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)); + } + 50% { + filter: drop-shadow(0 0 12px rgba(255, 220, 100, 1)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)); + } +} + +// Pozn.: NEanimujeme transform – ten nastavuje JS inline (poloha síťky). +// Žihnutí vosou proto naznačíme jen červeným zábleskem filtru. +@keyframes butterfly-net-stung { + 0%, + 100% { + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1); + } + 30% { + filter: drop-shadow(0 0 8px rgba(217, 72, 15, 0.85)) brightness(1.1) hue-rotate(-15deg); + } +} + +// Prchavé texty (chycení, protržení, žihnutí) +.butterfly-catch-fx, +.butterfly-tear-fx, +.butterfly-sting-fx { position: fixed; margin: -12px 0 0 10px; font-weight: 700; font-size: 1.1rem; - color: #2f9e44; - text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8); pointer-events: none; z-index: 5; - animation: butterfly-catch-float 0.75s ease-out forwards; + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8); + animation: butterfly-catch-float 0.8s ease-out forwards; +} + +.butterfly-catch-fx { + color: #2f9e44; + + &.golden { + color: #e0a200; + font-size: 1.35rem; + text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3); + } +} + +.butterfly-tear-fx { + color: #c92a2a; +} + +.butterfly-sting-fx { + color: #d9480f; } @keyframes butterfly-catch-float { @@ -117,48 +240,214 @@ } } -// --- Počítadlo chycených motýlů ---------------------------------------------- +// --- HUD: počítadlo, mince, úroveň, kombo, hlášky ---------------------------- -.butterfly-counter { +.butterfly-hud { position: fixed; bottom: 16px; left: 16px; - display: inline-flex; - align-items: center; + z-index: 5; + display: flex; + flex-direction: column; + align-items: flex-start; gap: 6px; - padding: 6px 12px 6px 10px; - background: rgba(255, 255, 255, 0.85); + // HUD sám o sobě neblokuje klikání; interaktivní je jen tlačítko opravy + pointer-events: none; +} + +.butterfly-counter { + display: inline-flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.88); border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 999px; + border-radius: 14px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); backdrop-filter: blur(4px); font-weight: 600; font-size: 0.95rem; color: #333; - z-index: 5; user-select: none; + pointer-events: auto; + cursor: pointer; + transition: box-shadow 0.15s ease; + + &:hover { + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22); + } &.bump { animation: butterfly-counter-bump 0.3s ease-out; } } -.butterfly-counter-icon { +.butterfly-counter-row { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.butterfly-counter-icon, +.butterfly-coin-icon { display: inline-block; width: 20px; height: 20px; - background-image: url(butterfly-orange.svg); background-repeat: no-repeat; background-position: center; background-size: contain; } +.butterfly-counter-icon { + background-image: url(/butterfly-orange.svg); +} + +.butterfly-coin-icon { + background-image: url(/coin.svg); + margin-left: 4px; +} + .butterfly-counter-value { min-width: 1ch; text-align: center; font-variant-numeric: tabular-nums; } +.butterfly-level { + display: flex; + flex-direction: column; + gap: 3px; +} + +.butterfly-level-title { + font-size: 0.72rem; + font-weight: 600; + color: #6b5b2e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} + +.butterfly-progress { + width: 100%; + height: 5px; + background: rgba(0, 0, 0, 0.1); + border-radius: 999px; + overflow: hidden; +} + +.butterfly-progress-bar { + height: 100%; + background: linear-gradient(90deg, #ffcb3d, #e0a200); + border-radius: 999px; + transition: width 0.4s ease; +} + +.butterfly-pests { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + font-weight: 700; +} + +.butterfly-pest { + color: #868e96; + font-variant-numeric: tabular-nums; + + &.warn { + color: #d9480f; + } + + &.ok { + color: #2f9e44; + } +} + +.butterfly-action-btn { + margin-top: 4px; + padding: 5px 10px; + border: none; + border-radius: 999px; + background: #1c7ed6; + color: #fff; + font-weight: 700; + font-size: 0.82rem; + cursor: pointer; + pointer-events: auto; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + transition: background 0.15s ease, transform 0.1s ease; + + &:hover:not(:disabled) { + background: #1971c2; + } + + &:active:not(:disabled) { + transform: scale(0.96); + } + + &:disabled { + background: #adb5bd; + cursor: not-allowed; + } + + &.danger { + background: #c92a2a; + + &:hover:not(:disabled) { + background: #e03131; + } + } +} + +// Efekt při plácnutí vosy +.butterfly-swat-fx { + position: fixed; + margin: -14px 0 0 -6px; + font-size: 1.4rem; + pointer-events: none; + z-index: 5; + animation: butterfly-catch-float 0.8s ease-out forwards; +} + +.butterfly-combo { + padding: 4px 10px; + border-radius: 999px; + background: rgba(224, 162, 0, 0.92); + color: #fff; + font-weight: 800; + font-size: 0.9rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + animation: butterfly-counter-bump 0.3s ease-out; +} + +.butterfly-flash { + max-width: 260px; + padding: 8px 12px; + border-radius: 12px; + background: rgba(33, 37, 41, 0.92); + color: #fff; + font-weight: 600; + font-size: 0.85rem; + line-height: 1.25; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25); + animation: butterfly-flash-in 0.25s ease-out; + pointer-events: auto; + cursor: pointer; +} + +@keyframes butterfly-flash-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + @keyframes butterfly-counter-bump { 0% { transform: scale(1); @@ -173,12 +462,27 @@ // Ohleduplnost k uživatelům, kteří nechtějí pohyb @media (prefers-reduced-motion: reduce) { - .butterfly-sprite { + .butterfly-sprite, + .butterfly-sprite.golden { animation: none; } .butterfly-net.catch-pop, - .butterfly-counter.bump { + .butterfly-net.premium, + .butterfly-net.stung, + .butterfly-net.stunned, + .butterfly-counter.bump, + .butterfly-combo { animation: none; } + + // Bez animace aspoň staticky ukážeme, že síťka je omráčená + .butterfly-net.stunned { + filter: grayscale(0.8) brightness(0.82) drop-shadow(0 0 6px rgba(217, 72, 15, 0.7)); + } + + // Zlatý motýl bez animace aspoň staticky září + .butterfly-sprite.golden { + filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7)); + } } diff --git a/client/src/FlyingButterflies.tsx b/client/src/FlyingButterflies.tsx index c84a0d2..ad3af0f 100644 --- a/client/src/FlyingButterflies.tsx +++ b/client/src/FlyingButterflies.tsx @@ -1,30 +1,48 @@ import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats'; +import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal'; -// Různé barevné varianty motýlů +// Různé barevné varianty motýlů (soubory v public/, referencované root-absolutně) const BUTTERFLY_VARIANTS = [ - 'butterfly-orange.svg', // Oranžová (monarcha) - 'butterfly-blue.svg', // Modrá - 'butterfly-yellow.svg', // Žlutá (otakárek) - 'butterfly-pink.svg', // Růžová + '/butterfly-orange.svg', // Oranžová (monarcha) + '/butterfly-blue.svg', // Modrá + '/butterfly-yellow.svg', // Žlutá (otakárek) + '/butterfly-pink.svg', // Růžová ] as const; -// Klíč pro uložení počtu chycených motýlů do local storage -const CAUGHT_STORAGE_KEY = 'flyingButterfliesCaught'; +// Vzácný zlatý motýl +const GOLDEN_VARIANT = '/butterfly-golden.svg'; +const GOLDEN_CHANCE = 0.025; // ~2,5 % motýlů je zlatých + +// Grafika síťky (root-absolutní cesty kvůli produkčnímu servírování z kořene) +const NET_NORMAL = '/butterfly-net.svg'; +const NET_GOLDEN = '/butterfly-net-golden.svg'; +const NET_TORN = '/butterfly-net-torn.svg'; + +// Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts) +const PREMIUM_MILESTONE = 50; +const PREMIUM_DURATION_MS = 60_000; +export const REPAIR_COST = 10; +export const REPELLENT_COST = 25; +const GOLD_VALUE = 25; + +/** Naformátuje zbývající čas jako „m:ss" nebo „s s". */ +function formatRemaining(ms: number): string { + const s = Math.ceil(ms / 1000); + const m = Math.floor(s / 60); + const ss = s % 60; + return m > 0 ? `${m}:${String(ss).padStart(2, '0')}` : `${ss} s`; +} interface ButterflyData { - /** Vnější element – nese pozici, natočení a velikost */ el: HTMLDivElement; - /** Vnitřní element – nese CSS animaci mávání křídly */ sprite: HTMLDivElement; x: number; y: number; - /** Směr letu vodorovně: +1 doprava, -1 doleva */ dir: number; - /** Vodorovná rychlost (px/snímek) */ speed: number; - /** Velikost (měřítko sprite) */ size: number; - // Dvě sinusovky pro přirozené zvlněné stoupání a klesání + golden: boolean; bobFreq1: number; bobPhase1: number; bobAmp1: number; @@ -33,23 +51,45 @@ interface ButterflyData { bobAmp2: number; } +/** Létající havěť (pták nebo vosa). */ +interface CritterData { + el: HTMLDivElement; + x: number; + y: number; + dir: number; + speed: number; + size: number; + bobPhase: number; + bobAmp: number; + /** Pták: už protrhl síťku v tomto průletu (reset při obletu) */ + triggered: boolean; + /** Uklízeč posluchače kliknutí (jen u vos) */ + cleanup?: () => void; +} + +/** Callbacky, kterými scéna komunikuje s React obalem. */ +interface SceneCallbacks { + onCatch: (golden: boolean) => void; + getCoins: () => number; + onTornChange: (torn: boolean) => void; + onCombo: (count: number) => void; + onSting: () => void; + onSwatWasp: () => void; +} + interface FlyingButterfliesProps { - /** Počet poletujících motýlů (výchozí: 9) */ numButterflies?: number; - /** CSS třída pro kontejner (výchozí: 'flying-butterflies') */ className?: string; - /** Barevné varianty motýlů k použití (výchozí: všechny) */ butterflyVariants?: readonly string[]; - /** Zapne síťku na chytání motýlů (výchozí: true) */ enableNet?: boolean; - /** Callback při chycení motýla (obdrží nový celkový počet) */ - onCatch?: (total: number) => void; } class ButterflyScene { private viewport: HTMLElement; private world: HTMLDivElement; private butterflies: ButterflyData[] = []; + private birds: CritterData[] = []; + private wasps: CritterData[] = []; private numButterflies: number; private variants: readonly string[]; private width: number; @@ -58,65 +98,81 @@ class ButterflyScene { private animationId: number | null = null; private handleResize: () => void; - // Síťka na chytání private net: HTMLDivElement; private netEnabled: boolean; private netGrabbed: boolean = false; private netX: number = 0; private netY: number = 0; - private onCatch?: () => void; + private cb: SceneCallbacks; + + private torn: boolean = false; + private premiumUntil: number = 0; + private stunUntil: number = 0; + private lastMagnetAt: number = 0; + + private comboCount: number = 0; + private lastCatchTs: number = 0; + private comboTimer: number = 0; - // Základní velikost sprite v px (dále se násobí náhodným měřítkem) private static readonly BASE_SIZE = 34; + private static readonly NET_SIZE_NORMAL = 120; + private static readonly NET_SIZE_PREMIUM = 168; + private static readonly CATCH_RADIUS_NORMAL = 40; + private static readonly CATCH_RADIUS_PREMIUM = 62; + private static readonly HOOP_FRAC_X = 0.36; + private static readonly HOOP_FRAC_Y = 0.35; - // Rozměry síťky (px) a poloha obruče v rámci SVG (viewBox 100×100, střed 36×35) - private static readonly NET_SIZE = 120; - private static readonly HOOP_OFFSET_X = ButterflyScene.NET_SIZE * 0.36; - private static readonly HOOP_OFFSET_Y = ButterflyScene.NET_SIZE * 0.35; - // Dosah chytání kolem středu obruče - private static readonly CATCH_RADIUS = 40; + private static readonly BIRD_SIZE = 64; + private static readonly WASP_SIZE = 44; + private static readonly TEAR_RADIUS = 46; + private static readonly STING_RADIUS = 46; + private static readonly STUN_FRAMES = 300; // ~5 s omráčení + private static readonly MAGNET_RADIUS = 150; + private static readonly MAGNET_INTERVAL = 42; + private static readonly COMBO_WINDOW_MS = 1200; constructor( el: HTMLElement, - numButterflies: number = 9, - variants: readonly string[] = BUTTERFLY_VARIANTS, - netEnabled: boolean = true, - onCatch?: () => void, + numButterflies: number, + variants: readonly string[], + netEnabled: boolean, + cb: SceneCallbacks, ) { this.viewport = el; this.world = document.createElement('div'); this.numButterflies = numButterflies; this.variants = variants; this.netEnabled = netEnabled; - this.onCatch = onCatch; + this.cb = cb; this.width = this.viewport.offsetWidth; this.height = this.viewport.offsetHeight; - this.net = document.createElement('div'); - this.handleResize = () => { this.width = this.viewport.offsetWidth; this.height = this.viewport.offsetHeight; }; } - // Nastaví motýlovi nové náhodné parametry a umístí ho na okraj obrazovky + private get premiumActive(): boolean { return Date.now() < this.premiumUntil; } + private get netSize(): number { return this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL; } + private get catchRadius(): number { return this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; } + private get hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; } + private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; } + + // --- Motýli -------------------------------------------------------------- + private resetButterfly = (b: ButterflyData): void => { - // Vletí zleva nebo zprava b.dir = Math.random() > 0.5 ? 1 : -1; - b.speed = Math.random() * 1.1 + 0.7; // 0.7 – 1.8 px/snímek + b.speed = Math.random() * 1.1 + 0.7; - // Na začátku (timer 0) rozprostřeme motýly po celé šířce, ať není obrazovka prázdná if (this.timer === 0) { b.x = Math.random() * this.width; } else { b.x = b.dir === 1 ? -40 : this.width + 40; } b.y = Math.random() * (this.height - 60) + 30; + b.size = Math.random() * 0.6 + 0.6; - b.size = Math.random() * 0.6 + 0.6; // 0.6 – 1.2 - - // Dvě sinusovky s různou frekvencí → nepravidelné, měkké křivky letu b.bobFreq1 = Math.random() * 0.02 + 0.03; b.bobPhase1 = Math.random() * Math.PI * 2; b.bobAmp1 = Math.random() * 0.8 + 0.6; @@ -124,44 +180,56 @@ class ButterflyScene { b.bobPhase2 = Math.random() * Math.PI * 2; b.bobAmp2 = Math.random() * 0.6 + 0.4; - // Náhodná varianta a rychlost mávání křídly - const variant = this.variants[Math.floor(Math.random() * this.variants.length)]; - b.sprite.style.backgroundImage = `url(${variant})`; - b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; // 0.3 – 0.55 s + b.golden = Math.random() < GOLDEN_CHANCE; + if (b.golden) { + b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`; + b.sprite.classList.add('golden'); + b.size = Math.random() * 0.5 + 1.0; // zlatý je nápadně větší + } else { + const variant = this.variants[Math.floor(Math.random() * this.variants.length)]; + b.sprite.style.backgroundImage = `url(${variant})`; + b.sprite.classList.remove('golden'); + } + b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; }; private updateButterfly = (b: ButterflyData): void => { const vx = b.dir * b.speed; - // Svislá rychlost jako součet dvou sinusovek – přirozené plachtění const vy = Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 + Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2; - b.x += vx; b.y += vy; - - // Sprite natočíme po směru letu (motýl je nakreslený hlavou nahoru) const angle = Math.atan2(vy, vx) * (180 / Math.PI) + 90; - b.el.style.transform = `translate(${b.x}px, ${b.y}px) rotate(${angle}deg) scale(${b.size})`; - // Reset po opuštění obrazovky (s rezervou na natočení) if ( (b.dir === 1 && b.x > this.width + 50) || (b.dir === -1 && b.x < -50) || - b.y < -60 || - b.y > this.height + 60 + b.y < -60 || b.y > this.height + 60 ) { this.resetButterfly(b); } }; - // --- Síťka na chytání --------------------------------------------------- + // --- Síťka --------------------------------------------------------------- private updateNetTransform = (): void => { this.net.style.transform = - `translate(${this.netX - ButterflyScene.HOOP_OFFSET_X}px, ` + - `${this.netY - ButterflyScene.HOOP_OFFSET_Y}px)`; + `translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`; + }; + + private applyNetAppearance = (): void => { + const size = this.netSize; + this.net.style.width = `${size}px`; + this.net.style.height = `${size}px`; + let image = NET_NORMAL; + if (this.torn) image = NET_TORN; + else if (this.premiumActive) image = NET_GOLDEN; + this.net.style.backgroundImage = `url(${image})`; + this.net.classList.toggle('premium', this.premiumActive && !this.torn); + this.net.classList.toggle('torn', this.torn); + this.updateNetTransform(); }; private onPointerDown = (e: PointerEvent): void => { @@ -186,70 +254,231 @@ class ButterflyScene { this.net.classList.remove('grabbed'); }; - // Zobrazí prchavé „+1" v místě chycení - private spawnCatchFx = (): void => { + private spawnFx = (text: string, cssClass: string, x: number, y: number): void => { const fx = document.createElement('div'); - fx.className = 'butterfly-catch-fx'; - fx.textContent = '+1'; - fx.style.left = `${this.netX}px`; - fx.style.top = `${this.netY}px`; + fx.className = cssClass; + fx.textContent = text; + fx.style.left = `${x}px`; + fx.style.top = `${y}px`; this.viewport.appendChild(fx); - window.setTimeout(() => { - if (fx.parentNode) fx.parentNode.removeChild(fx); - }, 750); + window.setTimeout(() => { if (fx.parentNode) fx.parentNode.removeChild(fx); }, 800); + }; + + private popNet = (): void => { + this.net.classList.remove('catch-pop'); + void this.net.offsetWidth; + this.net.classList.add('catch-pop'); + }; + + private registerCatch = (b: ButterflyData, atX: number, atY: number): void => { + this.cb.onCatch(b.golden); + this.spawnFx(b.golden ? `+${GOLD_VALUE}` : '+1', b.golden ? 'butterfly-catch-fx golden' : 'butterfly-catch-fx', atX, atY); + this.resetButterfly(b); + + const now = Date.now(); + this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1; + this.lastCatchTs = now; + if (this.comboCount >= 2) { + this.cb.onCombo(this.comboCount); + if (this.comboTimer) window.clearTimeout(this.comboTimer); + this.comboTimer = window.setTimeout(() => this.cb.onCombo(0), ButterflyScene.COMBO_WINDOW_MS); + } }; - // Zkontroluje, zda obruč překrývá nějakého motýla – a chytí ho private checkCatches = (): void => { - const r2 = ButterflyScene.CATCH_RADIUS * ButterflyScene.CATCH_RADIUS; - const half = (ButterflyScene.BASE_SIZE / 2); + if (this.torn || this.timer < this.stunUntil) return; + const r2 = this.catchRadius * this.catchRadius; + const half = ButterflyScene.BASE_SIZE / 2; let caughtAny = false; - - for (let i = 0; i < this.butterflies.length; i++) { - const b = this.butterflies[i]; - const cx = b.x + half * b.size; - const cy = b.y + half * b.size; - const dx = cx - this.netX; - const dy = cy - this.netY; - + for (const b of this.butterflies) { + const dx = (b.x + half * b.size) - this.netX; + const dy = (b.y + half * b.size) - this.netY; if (dx * dx + dy * dy < r2) { caughtAny = true; - if (this.onCatch) this.onCatch(); - // Chycený motýl je nahrazen novým – celkový počet létajících se nemění - this.resetButterfly(b); + this.registerCatch(b, this.netX, this.netY); } } + if (caughtAny) this.popNet(); + }; - if (caughtAny) { - this.spawnCatchFx(); - this.net.classList.remove('catch-pop'); - // vynucení reflow pro restart animace - void this.net.offsetWidth; - this.net.classList.add('catch-pop'); + private runMagnet = (): void => { + if (this.torn || this.timer < this.stunUntil) return; + if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return; + this.lastMagnetAt = this.timer; + const half = ButterflyScene.BASE_SIZE / 2; + let nearest: ButterflyData | null = null; + let nearestD2 = ButterflyScene.MAGNET_RADIUS * ButterflyScene.MAGNET_RADIUS; + for (const b of this.butterflies) { + const dx = (b.x + half * b.size) - this.netX; + const dy = (b.y + half * b.size) - this.netY; + const d2 = dx * dx + dy * dy; + if (d2 < nearestD2) { nearestD2 = d2; nearest = b; } + } + if (nearest) { this.registerCatch(nearest, this.netX, this.netY); this.popNet(); } + }; + + public activatePremiumNet = (): void => { + this.premiumUntil = Date.now() + PREMIUM_DURATION_MS; + this.applyNetAppearance(); + }; + + /** Vnější (zaplacená) oprava protržené síťky. */ + public repairNetExternally = (): void => { + if (!this.torn) return; + this.torn = false; + this.applyNetAppearance(); + }; + + /** Nastaví protržení podle serveru (init/poll), bez hlášení zpět. */ + public setTornFromServer = (torn: boolean): void => { + if (this.torn === torn) return; + this.torn = torn; + this.applyNetAppearance(); + }; + + private tearNet = (atX: number, atY: number): void => { + if (this.torn) return; + this.torn = true; + this.applyNetAppearance(); + this.spawnFx('Ratata!', 'butterfly-tear-fx', atX, atY); + this.cb.onTornChange(true); + }; + + // --- Havěť (ptáci a vosy) ----------------------------------------------- + + private critterHitsHoop = (c: CritterData, radius: number): boolean => { + const dx = (c.x + c.size / 2) - this.netX; + const dy = (c.y + c.size / 2) - this.netY; + return dx * dx + dy * dy < radius * radius; + }; + + private spawnCritter = (type: 'wasp' | 'bird'): CritterData => { + const size = type === 'bird' ? ButterflyScene.BIRD_SIZE : ButterflyScene.WASP_SIZE; + const el = document.createElement('div'); + el.className = type === 'bird' ? 'butterfly-critter bird' : 'butterfly-critter wasp'; + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.backgroundImage = `url(${type === 'bird' ? '/bird.svg' : '/bee.svg'})`; + + const dir = Math.random() > 0.5 ? 1 : -1; + const c: CritterData = { + el, + x: dir === 1 ? -size : this.width + size, + y: Math.random() * (this.height * 0.7) + this.height * 0.1, + dir, + speed: type === 'bird' ? Math.random() * 2 + 3 : Math.random() * 1.4 + 2, + size, + bobPhase: Math.random() * Math.PI * 2, + bobAmp: Math.random() * 0.8 + 0.5, + triggered: false, + }; + + if (type === 'wasp') { + el.title = 'Plácni vosu!'; + const swat = (ev: Event) => { + ev.preventDefault(); + ev.stopPropagation(); + this.spawnFx('💥', 'butterfly-swat-fx', c.x + c.size / 2, c.y + c.size / 2); + const idx = this.wasps.indexOf(c); + if (idx >= 0) this.wasps.splice(idx, 1); + if (c.el.parentNode) c.el.parentNode.removeChild(c.el); + this.cb.onSwatWasp(); + }; + el.addEventListener('pointerdown', swat); + c.cleanup = () => el.removeEventListener('pointerdown', swat); + } + + this.world.appendChild(el); + return c; + }; + + private wrapCritter = (c: CritterData): void => { + c.dir = Math.random() > 0.5 ? 1 : -1; + c.x = c.dir === 1 ? -c.size : this.width + c.size; + c.y = Math.random() * (this.height * 0.7) + this.height * 0.1; + c.triggered = false; + }; + + private updateWasp = (c: CritterData): void => { + c.x += c.dir * c.speed; + // mírné míření k obruči, aby vosy otravovaly zrovna síťku + const targetY = this.netY - c.size / 2; + c.y += (targetY - c.y) * 0.015 + Math.sin(this.timer * 0.06 + c.bobPhase) * c.bobAmp; + const flip = c.dir === -1 ? -1 : 1; + c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`; + if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) { + this.wrapCritter(c); + } + }; + + private updateBird = (c: CritterData): void => { + c.x += c.dir * c.speed; + c.y += Math.sin(this.timer * 0.03 + c.bobPhase) * c.bobAmp; + const flip = c.dir === -1 ? -1 : 1; + c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`; + if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) { + this.wrapCritter(c); + } + }; + + private removeCritter = (arr: CritterData[]): void => { + const c = arr.pop(); + if (c) { + c.cleanup?.(); + if (c.el.parentNode) c.el.parentNode.removeChild(c.el); + } + }; + + /** Sladí počet vos a ptáků ve scéně s hodnotami ze serveru. */ + public syncPests = (wasps: number, birds: number): void => { + if (!this.netEnabled) return; + while (this.wasps.length < wasps) this.wasps.push(this.spawnCritter('wasp')); + while (this.wasps.length > wasps) this.removeCritter(this.wasps); + while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird')); + while (this.birds.length > birds) this.removeCritter(this.birds); + }; + + private updateWasps = (): void => { + for (const wasp of this.wasps) { + this.updateWasp(wasp); + // Vosa žihne síťku, jakmile pomine předchozí omráčení + if (!this.torn && this.timer >= this.stunUntil && this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) { + this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES; + this.net.classList.remove('stung'); + void this.net.offsetWidth; + this.net.classList.add('stung'); + this.spawnFx('Au!', 'butterfly-sting-fx', this.netX, this.netY); + this.cb.onSting(); + } + } + }; + + private updateBirds = (): void => { + for (const bird of this.birds) { + this.updateBird(bird); + // Prémiová síťka je z pevnějšího materiálu; protrhne se jen když má hráč na opravu + if (!bird.triggered && this.netGrabbed && !this.torn && !this.premiumActive + && this.cb.getCoins() >= REPAIR_COST + && this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) { + bird.triggered = true; + this.tearNet(this.netX, this.netY); + } } }; private initNet = (): void => { this.net.className = 'butterfly-net'; - this.net.style.width = `${ButterflyScene.NET_SIZE}px`; - this.net.style.height = `${ButterflyScene.NET_SIZE}px`; - this.net.style.backgroundImage = 'url(butterfly-net.svg)'; - - // Výchozí poloha – vpravo dole + this.applyNetAppearance(); this.netX = this.width * 0.82; this.netY = this.height * 0.72; this.updateNetTransform(); - this.net.addEventListener('pointerdown', this.onPointerDown); window.addEventListener('pointermove', this.onPointerMove); window.addEventListener('pointerup', this.onPointerUp); window.addEventListener('pointercancel', this.onPointerUp); - this.viewport.appendChild(this.net); }; - // ----------------------------------------------------------------------- - public init = (): void => { this.butterflies = []; this.world.innerHTML = ''; @@ -260,44 +489,42 @@ class ButterflyScene { const sprite = document.createElement('div'); sprite.className = 'butterfly-sprite'; el.appendChild(sprite); - const b: ButterflyData = { - el, - sprite, - x: 0, - y: 0, - dir: 1, - speed: 1, - size: 1, - bobFreq1: 0, - bobPhase1: 0, - bobAmp1: 0, - bobFreq2: 0, - bobPhase2: 0, - bobAmp2: 0, + el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false, + bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0, }; - this.resetButterfly(b); this.butterflies.push(b); this.world.appendChild(el); } this.viewport.appendChild(this.world); - - if (this.netEnabled) { - this.initNet(); - } - + if (this.netEnabled) this.initNet(); window.addEventListener('resize', this.handleResize); }; - public render = (): void => { - for (let i = 0; i < this.butterflies.length; i++) { - this.updateButterfly(this.butterflies[i]); - } + private wasPremium: boolean = false; + private wasStunned: boolean = false; - if (this.netEnabled && this.netGrabbed) { - this.checkCatches(); + public render = (): void => { + for (const b of this.butterflies) this.updateButterfly(b); + + if (this.netEnabled) { + const nowPremium = this.premiumActive; + if (this.wasPremium && !nowPremium) this.applyNetAppearance(); + this.wasPremium = nowPremium; + + this.updateWasps(); + this.updateBirds(); + + const stunned = this.timer < this.stunUntil; + if (stunned !== this.wasStunned) { + this.net.classList.toggle('stunned', stunned); + this.wasStunned = stunned; + } + + if (this.netGrabbed) this.checkCatches(); + if (nowPremium) this.runMagnet(); } this.timer++; @@ -305,99 +532,112 @@ class ButterflyScene { }; public destroy = (): void => { - if (this.animationId) { - cancelAnimationFrame(this.animationId); - this.animationId = null; - } - - if (this.world && this.world.parentNode) { - this.world.parentNode.removeChild(this.world); - } + if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } + if (this.comboTimer) window.clearTimeout(this.comboTimer); + for (const w of this.wasps) w.cleanup?.(); + if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world); this.net.removeEventListener('pointerdown', this.onPointerDown); window.removeEventListener('pointermove', this.onPointerMove); window.removeEventListener('pointerup', this.onPointerUp); window.removeEventListener('pointercancel', this.onPointerUp); - if (this.net.parentNode) { - this.net.parentNode.removeChild(this.net); - } - + if (this.net.parentNode) this.net.parentNode.removeChild(this.net); window.removeEventListener('resize', this.handleResize); }; } /** - * Komponenta pro zobrazení poletujících motýlů na pozadí stránky. - * Motýli plachtí vodorovně po měkkých zvlněných křivkách a mávají křídly. - * Volitelná síťka umožňuje motýly chytat – po chycení přiletí nový a počet - * chycených se ukládá do local storage. - * - * @param numButterflies - Počet motýlů (výchozí: 9) - * @param className - CSS třída pro kontejner (výchozí: 'flying-butterflies') - * @param butterflyVariants - Barevné varianty motýlů (výchozí: všechny) - * @param enableNet - Zapne síťku na chytání (výchozí: true) - * @param onCatch - Callback při chycení motýla (obdrží nový celkový počet) + * Komponenta minihry chytání motýlků: mince, úrovně, prémiová zlatá síťka za + * milník, magnet, vzácní zlatí motýli, kombo, denní bonus a perzistentní škůdci + * (vosy na zaklikání, ptáci trhající síťku, placený plašič). Stav se ukládá na + * server přes {@link useButterflyStats}. */ const FlyingButterflies: React.FC = ({ numButterflies = 9, className = 'flying-butterflies', butterflyVariants = BUTTERFLY_VARIANTS, enableNet = true, - onCatch, }) => { const containerRef = useRef(null); const sceneRef = useRef(null); const badgeRef = useRef(null); - const [caught, setCaught] = useState(() => { - try { - const stored = Number(localStorage.getItem(CAUGHT_STORAGE_KEY)); - return Number.isFinite(stored) && stored > 0 ? stored : 0; - } catch { - return 0; - } - }); + const [torn, setTorn] = useState(false); + const [combo, setCombo] = useState(0); + const [flash, setFlash] = useState(null); + const [leaderboardOpen, setLeaderboardOpen] = useState(false); + const [nowTs, setNowTs] = useState(() => Date.now()); + const flashTimer = useRef(null); - // Stabilní callback – scéna se kvůli změně počtu nemusí přegenerovat - const handleCatch = useCallback(() => { - setCaught((prev) => { - const next = prev + 1; - try { - localStorage.setItem(CAUGHT_STORAGE_KEY, String(next)); - } catch { - /* local storage nedostupné – počet se prostě neuloží */ - } - if (onCatch) onCatch(next); - return next; - }); - }, [onCatch]); + const showFlash = useCallback((msg: string) => { + setFlash(msg); + if (flashTimer.current) window.clearTimeout(flashTimer.current); + flashTimer.current = window.setTimeout(() => setFlash(null), 6000); + }, []); + + const handleReward = useCallback((e: RewardEvent) => { + if (e.premiumUnlocked) { + sceneRef.current?.activatePremiumNet(); + showFlash('🪄 Zlatá síťka aktivní! Větší, magnetická a odolná proti ptákům.'); + } else if (e.leveledUp) { + showFlash(`⭐ Nová úroveň: ${e.newTitle}!`); + } else if (e.dailyBonusApplied) { + showFlash('☀️ Denní bonus +10 mincí!'); + } + }, [showFlash]); + + const { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear } = useButterflyStats(handleReward); + + const callbacksRef = useRef({ + onCatch: () => { }, getCoins: () => 0, onTornChange: () => { }, + onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { }, + }); + callbacksRef.current = { + onCatch: (golden) => reportCatch(golden), + getCoins: () => coinsRef.current, + onTornChange: (t) => { setTorn(t); if (t) void reportTear(); }, + onCombo: (c) => setCombo(c), + onSting: () => showFlash('🐝 Au! Vosa tě žihla – síťka teď 5 s nechytá. Zaklikej vosy!'), + onSwatWasp: () => void killWasp(), + }; const initialize = useCallback(() => { if (containerRef.current) { sceneRef.current = new ButterflyScene( - containerRef.current, - numButterflies, - butterflyVariants, - enableNet, - handleCatch, + containerRef.current, numButterflies, butterflyVariants, enableNet, + { + onCatch: (g) => callbacksRef.current.onCatch(g), + getCoins: () => callbacksRef.current.getCoins(), + onTornChange: (t) => callbacksRef.current.onTornChange(t), + onCombo: (c) => callbacksRef.current.onCombo(c), + onSting: () => callbacksRef.current.onSting(), + onSwatWasp: () => callbacksRef.current.onSwatWasp(), + }, ); sceneRef.current.init(); sceneRef.current.render(); } - }, [numButterflies, butterflyVariants, enableNet, handleCatch]); + }, [numButterflies, butterflyVariants, enableNet]); useEffect(() => { initialize(); - return () => { - if (sceneRef.current) { - sceneRef.current.destroy(); - sceneRef.current = null; - } + if (sceneRef.current) { sceneRef.current.destroy(); sceneRef.current = null; } + if (flashTimer.current) window.clearTimeout(flashTimer.current); }; }, [initialize]); - // Krátké „poskočení" počítadla při každém chycení + // Synchronizace škůdců a protržení ze serveru do scény + const wasps = stats?.wasps ?? 0; + const birds = stats?.birds ?? 0; + const netTorn = stats?.netTorn ?? false; + useEffect(() => { sceneRef.current?.syncPests(wasps, birds); }, [wasps, birds]); + useEffect(() => { + sceneRef.current?.setTornFromServer(netTorn); + setTorn(netTorn); + }, [netTorn]); + + const caught = stats?.caught ?? 0; useEffect(() => { const node = badgeRef.current; if (!node || caught === 0) return; @@ -406,31 +646,123 @@ const FlyingButterflies: React.FC = ({ node.classList.add('bump'); }, [caught]); + const coins = stats?.coins ?? 0; + const canAffordRepair = coins >= REPAIR_COST; + const canAffordRepellent = coins >= REPELLENT_COST; + const progress = Math.round(((caught % PREMIUM_MILESTONE) / PREMIUM_MILESTONE) * 100); + const repellentUntil = stats?.repellentUntil ?? 0; + const repellentRemainingMs = Math.max(0, repellentUntil - nowTs); + const repellentActive = repellentRemainingMs > 0; + + // Tik po sekundách pro odpočet plašiče (jen dokud běží) + useEffect(() => { + if (repellentUntil <= Date.now()) return; + const id = window.setInterval(() => setNowTs(Date.now()), 1000); + return () => window.clearInterval(id); + }, [repellentUntil]); + + const onRepairClick = useCallback(async () => { + const ok = await repair(); + if (ok) sceneRef.current?.repairNetExternally(); + }, [repair]); + + const onRepellentClick = useCallback(async () => { + const ok = await buyRepellent(); + if (ok) showFlash('🦅🚫 Plašič ptáků koupen – ptáci na chvíli zmizeli.'); + }, [buyRepellent, showFlash]); + return ( <>
{enableNet && ( -
-