import React, { useEffect, useRef, useCallback, useState } from 'react'; import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats'; import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal'; import ButterflyShopModal from './components/modals/ButterflyShopModal'; // Barevné varianty motýlů (soubory v public/, referencované root-absolutně) const BUTTERFLY_VARIANTS = [ '/butterfly-orange.svg', '/butterfly-blue.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg', ] as const; const GOLDEN_VARIANT = '/butterfly-golden.svg'; const GOLDEN_CHANCE = 0.015; // ~1,5 % motýlů je zlatých (vzácnější) const MOTH_VARIANT = '/butterfly-moth.svg'; const MOTH_CHANCE = 0.14; // ~14 % je černá můra – chytit ji je chyba 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_DURATION_MS = 60_000; export const REPAIR_COST = 20; export const REPELLENT_COST = 70; const GOLD_VALUE = 25; const MOTH_PENALTY = 15; /** Přírůstek dosahu chytání za úroveň vylepšení „větší síťka" */ const NET_UPGRADE_RADIUS = 9; /** Přírůstek vizuální velikosti síťky za úroveň vylepšení „větší síťka" (px) */ const NET_UPGRADE_SIZE = 20; // Anti-bot „inspekce" – kontrola, že hraje člověk const INSPECT_WINDOW_FRAMES = 8 * 60; // ~8 s na splnění const INSPECT_PAUSE_FRAMES = 45 * 60; // ~45 s zabavená síťka při selhání const CHECK_EVERY_CLEAN = 220; // po kolika úlovcích kontrola u „čistého" hraní const CHECK_EVERY_SUSPICIOUS = 25; // při botích signálech mnohem dřív // „Bossové" (zloděj / housenka) const THIEF_HP = 22; const CATERPILLAR_HP = 18; const THIEF_MIN_COINS = 20; // zloděj přijde, jen když je co ukrást const CATERPILLAR_MIN_CAUGHT = 15; // housenka přijde, jen když je co sežrat /** 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`; } /** Delší čas (ban) jako „H:MM:SS" nebo „M:SS". */ function formatBan(ms: number): string { const s = Math.ceil(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const ss = s % 60; const mm = String(m).padStart(2, '0'); const s2 = String(ss).padStart(2, '0'); return h > 0 ? `${h}:${mm}:${s2}` : `${m}:${s2}`; } interface ButterflyData { el: HTMLDivElement; sprite: HTMLDivElement; x: number; y: number; dir: number; speed: number; size: number; golden: boolean; /** Černá můra – chycení stojí úlovky (nepočítá se jako úlovek) */ moth: boolean; /** Korunovaný motýl pro anti-bot inspekci */ royal: boolean; bobFreq1: number; bobPhase1: number; bobAmp1: number; bobFreq2: number; bobPhase2: number; bobAmp2: number; } /** Netopýr v noci – loví poletující motýly, odháníš klikáním. */ interface BatData { el: HTMLDivElement; x: number; y: number; dir: number; speed: number; size: number; bobPhase: number; bobAmp: number; hp: number; nextEatAt: number; cleanup: () => void; } /** Létající havěť (pták nebo vosa) – nalétává na síťku. */ 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 */ triggered: boolean; /** Vosa: fáze útoku – nálet do síťky, nebo stažení před dalším náletem */ mode?: 'dive' | 'retreat'; /** Vosa: cíl stažení */ tx?: number; ty?: number; cleanup?: () => void; } /** Boss plížící se k počítadlu (zloděj krade mince, housenka žere úlovky). */ interface StalkerData { el: HTMLDivElement; hpInner: HTMLDivElement; x: number; y: number; vx: number; vy: number; tx: number; ty: number; hp: number; maxHp: number; type: 'thief' | 'caterpillar'; done: boolean; cleanup: () => void; } interface SceneCallbacks { onCatch: (golden: boolean) => void; getCoins: () => number; getCaught: () => number; getNetUpgrade: () => number; /** Do kdy platí serverový ban (ms epoch); 0 = bez banu */ getBanUntil: () => number; onTornChange: (torn: boolean) => void; onCombo: (count: number) => void; onSting: () => void; onSwatWasp: () => void; onMothCaught: () => void; onRobbery: () => void; onDefeatThief: () => void; onCaterpillarAte: () => void; onDefeatCaterpillar: () => void; onStalkerAppear: (type: 'thief' | 'caterpillar') => void; onBatShooed: () => void; onWebCleared: () => void; /** Stav anti-bot inspekce: začátek / úspěch / neúspěch (zabavená síťka) */ onInspection: (state: 'start' | 'pass' | 'fail') => void; } interface FlyingButterfliesProps { numButterflies?: number; className?: string; butterflyVariants?: readonly string[]; enableNet?: boolean; } class ButterflyScene { private viewport: HTMLElement; private world: HTMLDivElement; /** Vrstva se všemi herními prvky (síťka, škůdci, pavučina) – jde skrýt v režimu objednávání */ private gameLayer: HTMLDivElement; private butterflies: ButterflyData[] = []; private birds: CritterData[] = []; private wasps: CritterData[] = []; private stalker: StalkerData | null = null; private interactive: boolean = false; private numButterflies: number; private variants: readonly string[]; private width: number; private height: number; private timer: number = 0; private animationId: number | null = null; private handleResize: () => void; private net: HTMLDivElement; private netEnabled: boolean; private netGrabbed: boolean = false; private netX: number = 0; private netY: number = 0; private cb: SceneCallbacks; private handleVisibility: () => void; private torn: boolean = false; private premiumUntil: number = 0; private stunUntil: number = 0; private lastMagnetAt: number = 0; private nextCatchAt: number = 0; private nextStalkerAt: number = 1800; // první možný boss ~30 s // Netopýr (v noci) a pavučina private bat: BatData | null = null; private nextBatAt: number = 1200; private web: HTMLDivElement | null = null; private webClicks: number = 0; private nextSpiderAt: number = 2400; // Anti-bot inspekce private trustedControl: boolean = false; // síťku ovládá reálná (isTrusted) myš private syntheticSeen: boolean = false; // zaznamenán syntetický event / webdriver private catchesSinceCheck: number = 0; private nextCheckThreshold: number = CHECK_EVERY_CLEAN; private inspecting: boolean = false; private inspectUntil: number = 0; private inspectPauseUntil: number = 0; // do kdy je síťka „zabavená" private royal: ButterflyData | null = null; private comboCount: number = 0; private lastCatchTs: number = 0; private comboTimer: number = 0; 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; 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 = 60; // ~1 s private static readonly CATCH_COOLDOWN = 8; // max ~7 chycení/s (proti zmenšení okna) private static readonly COMBO_WINDOW_MS = 1200; private static readonly STALKER_TRAVEL_FRAMES = 1700; // ~28 s na doplížení private static readonly STALKER_MIN_GAP = 3600; // 60 s private static readonly STALKER_MAX_GAP = 9000; // 150 s private static readonly BAT_SIZE = 60; private static readonly BAT_HP = 3; private static readonly BAT_EAT_INTERVAL = 150; // ~2,5 s mezi sežráním motýla private static readonly BAT_MIN_GAP = 2400; private static readonly BAT_MAX_GAP = 6000; private static readonly WEB_CLICKS = 5; // kolik kliknutí strhne pavučinu private static readonly SPIDER_MIN_GAP = 4200; private static readonly SPIDER_MAX_GAP = 9000; constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) { this.viewport = el; this.world = document.createElement('div'); this.gameLayer = document.createElement('div'); this.gameLayer.className = 'butterfly-game-layer'; this.numButterflies = numButterflies; this.variants = variants; this.netEnabled = netEnabled; this.cb = cb; this.width = this.viewport.offsetWidth; this.height = this.viewport.offsetHeight; this.net = document.createElement('div'); // Automatizované prohlížeče (Playwright/Puppeteer/Selenium) se prozradí if (typeof navigator !== 'undefined' && (navigator as any).webdriver) this.syntheticSeen = true; this.handleResize = () => { this.width = this.viewport.offsetWidth; this.height = this.viewport.offsetHeight; }; // Pozastavení animační smyčky, když není záložka vidět (šetří CPU) this.handleVisibility = () => { if (document.hidden) { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } } else if (this.animationId === null) { this.animationId = requestAnimationFrame(this.render); } }; } private get premiumActive(): boolean { return Date.now() < this.premiumUntil; } private get netSize(): number { const base = this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL; return base + NET_UPGRADE_SIZE * this.cb.getNetUpgrade(); } private get catchRadius(): number { const base = this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; return base + NET_UPGRADE_RADIUS * this.cb.getNetUpgrade(); } private get hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; } private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; } /** Do kdy je síťka „zabavená" (po neúspěšné inspekci) – nejde chytat ani ji vidět. */ private get confiscated(): boolean { return this.timer < this.inspectPauseUntil; } /** Serverový ban (za opakované odhalení) – blokuje chytání. */ private get banned(): boolean { return Date.now() < this.cb.getBanUntil(); } /** * Přepne interaktivní režim (hraní vs. jen dekorace). Herní vrstvu jen skryje/ukáže, * takže aktivní penalizace (pavučina, omráčení, zabavená síťka) přepnutím neobejdeš. */ public setInteractive = (v: boolean): void => { this.interactive = v; this.gameLayer.style.display = v ? '' : 'none'; if (!v) { this.netGrabbed = false; this.net.classList.remove('grabbed'); } }; // --- Motýli -------------------------------------------------------------- private resetButterfly = (b: ButterflyData): void => { b.dir = Math.random() > 0.5 ? 1 : -1; b.speed = Math.random() * 1.1 + 0.7; b.x = this.timer === 0 ? Math.random() * this.width : (b.dir === 1 ? -40 : this.width + 40); b.y = Math.random() * (this.height - 60) + 30; b.size = Math.random() * 0.6 + 0.6; b.bobFreq1 = Math.random() * 0.02 + 0.03; b.bobPhase1 = Math.random() * Math.PI * 2; b.bobAmp1 = Math.random() * 0.8 + 0.6; b.bobFreq2 = Math.random() * 0.015 + 0.01; b.bobPhase2 = Math.random() * Math.PI * 2; b.bobAmp2 = Math.random() * 0.6 + 0.4; // Korunovaný motýl (inspekce) má přednost a zůstává jím, dokud inspekce trvá if (b.royal) { b.moth = false; b.golden = false; b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`; b.sprite.classList.remove('golden', 'moth'); b.sprite.classList.add('royal'); b.size = 1.25; b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; return; } b.sprite.classList.remove('royal'); // Nejdřív můra, jinak vzácně zlatý, jinak běžná varianta b.moth = Math.random() < MOTH_CHANCE; b.golden = !b.moth && Math.random() < GOLDEN_CHANCE; if (b.moth) { b.sprite.style.backgroundImage = `url(${MOTH_VARIANT})`; b.sprite.classList.remove('golden'); b.sprite.classList.add('moth'); b.size = Math.random() * 0.3 + 0.9; } else if (b.golden) { b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`; b.sprite.classList.remove('moth'); b.sprite.classList.add('golden'); b.size = Math.random() * 0.5 + 1.0; } else { b.sprite.style.backgroundImage = `url(${this.variants[Math.floor(Math.random() * this.variants.length)]})`; b.sprite.classList.remove('golden'); b.sprite.classList.remove('moth'); } b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; }; private updateButterfly = (b: ButterflyData): void => { const vx = b.dir * b.speed; 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; 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})`; if ((b.dir === 1 && b.x > this.width + 50) || (b.dir === -1 && b.x < -50) || b.y < -60 || b.y > this.height + 60) { this.resetButterfly(b); } }; // --- Síťka --------------------------------------------------------------- private updateNetTransform = (): void => { this.net.style.transform = `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 => { e.preventDefault(); // Síťku smí ovládat jen reálná myš (syntetické eventy mají isTrusted=false) this.trustedControl = e.isTrusted && !(typeof navigator !== 'undefined' && (navigator as any).webdriver); if (!e.isTrusted) this.syntheticSeen = true; this.netGrabbed = true; this.net.classList.add('grabbed'); this.netX = e.clientX; this.netY = e.clientY; this.updateNetTransform(); }; private onPointerMove = (e: PointerEvent): void => { if (!this.netGrabbed) return; if (!e.isTrusted) { this.syntheticSeen = true; this.trustedControl = false; } this.netX = e.clientX; this.netY = e.clientY; this.updateNetTransform(); }; private onPointerUp = (): void => { if (!this.netGrabbed) return; this.netGrabbed = false; this.net.classList.remove('grabbed'); }; private spawnFx = (text: string, cssClass: string, x: number, y: number): void => { const fx = document.createElement('div'); 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); }, 800); }; private popNet = (): void => { this.net.classList.remove('catch-pop'); void this.net.offsetWidth; this.net.classList.add('catch-pop'); }; /** Zpracuje kontakt síťky s letícím tvorem – buď úlovek, nebo penalta za můru. */ private catchOne = (b: ButterflyData, atX: number, atY: number): void => { this.nextCatchAt = this.timer + ButterflyScene.CATCH_COOLDOWN; // Během inspekce: chyť jen korunovaného, jinak neúspěch if (this.inspecting) { if (b.royal) this.passInspection(); else this.failInspection(); return; } this.catchesSinceCheck += 1; if (b.moth) { // Chytit černou můru je chyba – penalta, žádné kombo this.cb.onMothCaught(); this.spawnFx(`−${MOTH_PENALTY}`, 'butterfly-catch-fx moth', atX, atY); this.net.classList.remove('moth-hit'); void this.net.offsetWidth; this.net.classList.add('moth-hit'); this.comboCount = 0; this.cb.onCombo(0); this.resetButterfly(b); return; } this.registerCatch(b, atX, atY); }; 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); } }; // Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna) private checkCatches = (): void => { // Síťku musí ovládat reálná myš; při zabavení/banu/pavučině/omráčení se nechytá if (!this.trustedControl || this.confiscated || this.banned || this.torn || this.webbed || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return; const r2 = this.catchRadius * this.catchRadius; const half = ButterflyScene.BASE_SIZE / 2; 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) { this.catchOne(b, this.netX, this.netY); this.popNet(); return; // jen jeden za cooldown } } }; private runMagnet = (): void => { if (this.inspecting || !this.trustedControl || this.confiscated || this.banned || this.torn || this.webbed || this.timer < this.stunUntil || this.timer < this.nextCatchAt) 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.catchOne(nearest, this.netX, this.netY); this.popNet(); } }; // --- Anti-bot inspekce --------------------------------------------------- private scheduleNextCheck = (): void => { this.catchesSinceCheck = 0; const base = this.syntheticSeen ? CHECK_EVERY_SUSPICIOUS : CHECK_EVERY_CLEAN; this.nextCheckThreshold = base + Math.floor(Math.random() * base * 0.4); }; private clearRoyal = (): void => { if (this.royal) { this.royal.royal = false; this.royal.sprite.classList.remove('royal'); this.resetButterfly(this.royal); this.royal = null; } }; private startInspection = (): void => { if (!this.butterflies.length) { this.scheduleNextCheck(); return; } const b = this.butterflies[Math.floor(Math.random() * this.butterflies.length)]; b.royal = true; b.moth = false; b.golden = false; b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`; b.sprite.classList.remove('moth', 'golden'); b.sprite.classList.add('royal'); this.royal = b; this.inspecting = true; this.inspectUntil = this.timer + INSPECT_WINDOW_FRAMES; this.cb.onInspection('start'); }; private passInspection = (): void => { this.inspecting = false; this.clearRoyal(); this.scheduleNextCheck(); this.spawnFx('👍', 'butterfly-swat-fx', this.netX, this.netY); this.cb.onInspection('pass'); }; private failInspection = (): void => { this.inspecting = false; this.clearRoyal(); this.inspectPauseUntil = this.timer + INSPECT_PAUSE_FRAMES; this.scheduleNextCheck(); this.cb.onInspection('fail'); }; public activatePremiumNet = (): void => { this.premiumUntil = Date.now() + PREMIUM_DURATION_MS; this.applyNetAppearance(); }; public repairNetExternally = (): void => { if (!this.torn) return; this.torn = false; this.applyNetAppearance(); }; 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.6) + 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, mode: type === 'wasp' ? 'dive' : undefined, }; 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.gameLayer.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.6) + this.height * 0.1; c.triggered = false; }; /** * Pohyb havěti. `homing` 0 = letí rovně jedním směrem (ptáci), >0 = agresivně * nalétává k síťce (vosy). Sprite se natáčí podle směru letu. */ private moveCritter = (c: CritterData, homingX: number, homingY: number): void => { const cx = c.x + c.size / 2; const cy = c.y + c.size / 2; const bob = Math.sin(this.timer * 0.05 + c.bobPhase) * c.bobAmp * 0.5; const vx = c.dir * c.speed + (homingX ? (this.netX - cx) * homingX : 0); const vy = (homingY ? (this.netY - cy) * homingY : 0) + bob; c.x += vx; c.y += vy; // Natočení podle směru letu (sprite je nakreslený doprava); při letu doleva // se svisle překlopí, aby nebyl vzhůru nohama. const angle = Math.atan2(vy, vx) * (180 / Math.PI); const flipY = Math.abs(angle) > 90 ? -1 : 1; c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${angle}deg) scaleY(${flipY})`; if (c.x > this.width + c.size * 2 || c.x < -c.size * 2 || c.y < -c.size * 2 || c.y > this.height + c.size * 2) { this.wrapCritter(c); } }; private removeLastCritter = (arr: CritterData[]): void => { const c = arr.pop(); if (c) { c.cleanup?.(); if (c.el.parentNode) c.el.parentNode.removeChild(c.el); } }; 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.removeLastCritter(this.wasps); while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird')); while (this.birds.length > birds) this.removeLastCritter(this.birds); }; /** Posune tvora plnou rychlostí k cíli a natočí ho po směru letu. */ private steerTo = (c: CritterData, targetX: number, targetY: number): void => { const cx = c.x + c.size / 2; const cy = c.y + c.size / 2; const dx = targetX - cx; const dy = targetY - cy; const dist = Math.hypot(dx, dy) || 1; const vx = (dx / dist) * c.speed; const vy = (dy / dist) * c.speed; c.x += vx; c.y += vy; const angle = Math.atan2(vy, vx) * (180 / Math.PI); const flipY = Math.abs(angle) > 90 ? -1 : 1; c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${angle}deg) scaleY(${flipY})`; }; private updateWasps = (): void => { for (const wasp of this.wasps) { if (wasp.mode === 'retreat') { // Stáhnutí kousek od síťky před dalším náletem this.steerTo(wasp, wasp.tx ?? this.netX, wasp.ty ?? this.netY); const dx = (wasp.x + wasp.size / 2) - (wasp.tx ?? this.netX); const dy = (wasp.y + wasp.size / 2) - (wasp.ty ?? this.netY); if (dx * dx + dy * dy < 26 * 26) wasp.mode = 'dive'; } else { // Nálet přímo do síťky this.steerTo(wasp, this.netX, this.netY); if (this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) { // Vytažení a příprava na další nálet wasp.mode = 'retreat'; wasp.tx = Math.max(30, Math.min(this.width - 30, this.netX + (Math.random() * 2 - 1) * 220)); wasp.ty = Math.max(30, this.netY - (70 + Math.random() * 150)); // Žihnutí (jen když už pominulo předchozí omráčení) if (!this.torn && this.timer >= this.stunUntil) { 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) { // Svisle míří na výšku síťky jen ptáci, kteří k ní letí (mají ji před sebou); // kdo síťku minul, dolétne rovně. const cx = bird.x + bird.size / 2; const towardNet = (bird.dir === 1 && this.netX > cx) || (bird.dir === -1 && this.netX < cx); this.moveCritter(bird, 0, towardNet ? 0.02 : 0); // Pták protrhne síťku vždy (i bez mincí); síťka se pak sama zašije po čase if (!bird.triggered && this.netGrabbed && !this.torn && !this.premiumActive && this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) { bird.triggered = true; this.tearNet(this.netX, this.netY); } } }; // --- Bossové (zloděj / housenka) ---------------------------------------- private trySpawnStalker = (): void => { if (this.timer < this.nextStalkerAt || this.stalker) return; const canThief = this.cb.getCoins() >= THIEF_MIN_COINS; const canCat = this.cb.getCaught() >= CATERPILLAR_MIN_CAUGHT; const options: ('thief' | 'caterpillar')[] = []; if (canThief) options.push('thief'); if (canCat) options.push('caterpillar'); if (options.length === 0) { this.nextStalkerAt = this.timer + 600; // zkus to znovu za ~10 s return; } // Bohatý hráč láká zloděje: víc mincí → častěji a spíš zloděj const coins = this.cb.getCoins(); let type: 'thief' | 'caterpillar'; if (canThief && coins >= 200 && Math.random() < 0.7) { type = 'thief'; } else { type = options[Math.floor(Math.random() * options.length)]; } this.spawnStalker(type); const wealthFactor = coins >= 300 ? 0.4 : coins >= 150 ? 0.65 : 1; const gap = ButterflyScene.STALKER_MIN_GAP + Math.floor(Math.random() * (ButterflyScene.STALKER_MAX_GAP - ButterflyScene.STALKER_MIN_GAP)); this.nextStalkerAt = this.timer + Math.round(gap * wealthFactor); }; private spawnStalker = (type: 'thief' | 'caterpillar'): void => { const maxHp = type === 'thief' ? THIEF_HP : CATERPILLAR_HP; const el = document.createElement('div'); el.className = `butterfly-stalker ${type}`; el.title = type === 'thief' ? 'Zaklikej zloděje!' : 'Zaklikej housenku!'; const hp = document.createElement('div'); hp.className = 'stalker-hp'; const hpInner = document.createElement('div'); hpInner.className = 'stalker-hp-inner'; hp.appendChild(hpInner); const emoji = document.createElement('div'); emoji.className = 'stalker-emoji'; emoji.textContent = type === 'thief' ? '🦹' : '🐛'; el.appendChild(hp); el.appendChild(emoji); // Start v horním rohu, cíl u počítadla (vlevo dole) const fromLeft = Math.random() < 0.5; const startX = fromLeft ? 10 : this.width - 70; const startY = 10; const tx = type === 'thief' ? 95 : 45; const ty = this.height - 55; const s: StalkerData = { el, hpInner, x: startX, y: startY, vx: (tx - startX) / ButterflyScene.STALKER_TRAVEL_FRAMES, vy: (ty - startY) / ButterflyScene.STALKER_TRAVEL_FRAMES, tx, ty, hp: maxHp, maxHp, type, done: false, cleanup: () => { /* nahrazeno níže */ }, }; const hit = (ev: Event) => { ev.preventDefault(); ev.stopPropagation(); if (s.done) return; s.hp -= 1; s.hpInner.style.width = `${Math.max(0, (s.hp / s.maxHp) * 100)}%`; el.classList.remove('hit'); void el.offsetWidth; el.classList.add('hit'); if (s.hp <= 0) this.defeatStalker(s); }; el.addEventListener('pointerdown', hit); s.cleanup = () => el.removeEventListener('pointerdown', hit); this.gameLayer.appendChild(el); this.stalker = s; this.cb.onStalkerAppear(type); }; private removeStalker = (): void => { const s = this.stalker; if (!s) return; s.cleanup(); if (s.el.parentNode) s.el.parentNode.removeChild(s.el); this.stalker = null; }; private defeatStalker = (s: StalkerData): void => { if (s.done) return; s.done = true; this.spawnFx(s.type === 'thief' ? '💰' : '🍃', 'butterfly-swat-fx', s.x + 24, s.y + 24); if (s.type === 'thief') this.cb.onDefeatThief(); else this.cb.onDefeatCaterpillar(); this.removeStalker(); }; private updateStalker = (): void => { const s = this.stalker; if (!s || s.done) return; s.x += s.vx; s.y += s.vy; s.el.style.transform = `translate(${s.x}px, ${s.y}px)`; const dx = s.x - s.tx; const dy = s.y - s.ty; if (dx * dx + dy * dy < 400) { s.done = true; if (s.type === 'thief') { this.spawnFx('💸 Okradeno!', 'butterfly-tear-fx', s.tx, s.ty - 20); this.cb.onRobbery(); } else { this.spawnFx('🐛 Sežráno!', 'butterfly-tear-fx', s.tx, s.ty - 20); this.cb.onCaterpillarAte(); } this.removeStalker(); } }; // --- Netopýr (v noci) ---------------------------------------------------- private get isNight(): boolean { const h = new Date().getHours(); return h >= 18 || h < 7; } private trySpawnBat = (): void => { if (this.timer < this.nextBatAt) return; this.nextBatAt = this.timer + ButterflyScene.BAT_MIN_GAP + Math.floor(Math.random() * (ButterflyScene.BAT_MAX_GAP - ButterflyScene.BAT_MIN_GAP)); if (this.bat || !this.isNight) return; const size = ButterflyScene.BAT_SIZE; const el = document.createElement('div'); el.className = 'butterfly-critter bat'; el.style.width = `${size}px`; el.style.height = `${size}px`; el.style.backgroundImage = 'url(/bat.svg)'; el.title = 'Zažeň netopýra!'; const dir = Math.random() > 0.5 ? 1 : -1; const bat: BatData = { el, x: dir === 1 ? -size : this.width + size, y: Math.random() * (this.height * 0.4) + this.height * 0.08, dir, speed: Math.random() * 1.5 + 2, size, bobPhase: Math.random() * Math.PI * 2, bobAmp: Math.random() * 1.2 + 0.8, hp: ButterflyScene.BAT_HP, nextEatAt: this.timer + 60, cleanup: () => { /* nahrazeno */ }, }; const hit = (ev: Event) => { ev.preventDefault(); ev.stopPropagation(); bat.hp -= 1; el.classList.remove('hit'); void el.offsetWidth; el.classList.add('hit'); if (bat.hp <= 0) { this.spawnFx('💨', 'butterfly-swat-fx', bat.x + size / 2, bat.y + size / 2); this.removeBat(); this.cb.onBatShooed(); } }; el.addEventListener('pointerdown', hit); bat.cleanup = () => el.removeEventListener('pointerdown', hit); this.gameLayer.appendChild(el); this.bat = bat; }; private removeBat = (): void => { if (!this.bat) return; this.bat.cleanup(); if (this.bat.el.parentNode) this.bat.el.parentNode.removeChild(this.bat.el); this.bat = null; }; private updateBat = (): void => { const bat = this.bat; if (!bat) return; bat.x += bat.dir * bat.speed; bat.y += Math.sin(this.timer * 0.06 + bat.bobPhase) * bat.bobAmp; const flipY = bat.dir === -1 ? -1 : 1; bat.el.style.transform = `translate(${bat.x}px, ${bat.y}px) scaleX(${flipY})`; // Občas sežere nejbližšího motýla (resetne ho pryč) if (this.timer >= bat.nextEatAt && this.butterflies.length) { bat.nextEatAt = this.timer + ButterflyScene.BAT_EAT_INTERVAL; let nearest: ButterflyData | null = null; let bd2 = Infinity; for (const b of this.butterflies) { const dx = b.x - bat.x, dy = b.y - bat.y; const d2 = dx * dx + dy * dy; if (d2 < bd2) { bd2 = d2; nearest = b; } } if (nearest) { this.spawnFx('🦇', 'butterfly-swat-fx', nearest.x, nearest.y); this.resetButterfly(nearest); } } if (bat.x > this.width + bat.size * 2 || bat.x < -bat.size * 2) this.removeBat(); }; // --- Pavouk s pavučinou -------------------------------------------------- private get webbed(): boolean { return this.web !== null; } private trySpawnWeb = (): void => { if (this.timer < this.nextSpiderAt) return; this.nextSpiderAt = this.timer + ButterflyScene.SPIDER_MIN_GAP + Math.floor(Math.random() * (ButterflyScene.SPIDER_MAX_GAP - ButterflyScene.SPIDER_MIN_GAP)); if (this.web) return; const el = document.createElement('div'); el.className = 'butterfly-web'; el.title = 'Strhni pavučinu!'; el.style.left = `${this.netX - 60}px`; el.style.top = `${this.netY - 60}px`; this.webClicks = 0; const strip = (ev: Event) => { ev.preventDefault(); ev.stopPropagation(); this.webClicks += 1; el.classList.remove('hit'); void el.offsetWidth; el.classList.add('hit'); if (this.webClicks >= ButterflyScene.WEB_CLICKS) { this.spawnFx('✔', 'butterfly-swat-fx', this.netX, this.netY); this.removeWeb(); this.cb.onWebCleared(); } }; el.addEventListener('pointerdown', strip); (el as any)._cleanup = () => el.removeEventListener('pointerdown', strip); this.gameLayer.appendChild(el); this.web = el; this.spawnFx('🕸️ Pavučina!', 'butterfly-tear-fx', this.netX, this.netY - 20); }; private removeWeb = (): void => { if (!this.web) return; (this.web as any)._cleanup?.(); if (this.web.parentNode) this.web.parentNode.removeChild(this.web); this.web = null; }; private updateWeb = (): void => { if (this.web) { // pavučina drží u obruče síťky this.web.style.left = `${this.netX - 60}px`; this.web.style.top = `${this.netY - 60}px`; } }; // --- Init / render ------------------------------------------------------ private initNet = (): void => { this.net.className = 'butterfly-net'; 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.gameLayer.appendChild(this.net); }; public init = (): void => { this.butterflies = []; this.world.innerHTML = ''; this.world.className = 'butterfly-scene'; for (let i = 0; i < this.numButterflies; i++) { const el = document.createElement('div'); 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, golden: false, moth: false, royal: 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); this.viewport.appendChild(this.gameLayer); this.gameLayer.style.display = this.interactive ? '' : 'none'; if (this.netEnabled) this.initNet(); window.addEventListener('resize', this.handleResize); document.addEventListener('visibilitychange', this.handleVisibility); }; private wasPremium: boolean = false; private wasStunned: boolean = false; private lastNetUpgrade: number = 0; public render = (): void => { for (const b of this.butterflies) this.updateButterfly(b); if (this.netEnabled && this.interactive) { const nowPremium = this.premiumActive; const nu = this.cb.getNetUpgrade(); // Přepočítej vzhled síťky při změně prémiové síťky i po koupi vylepšení if ((this.wasPremium && !nowPremium) || nu !== this.lastNetUpgrade) { this.lastNetUpgrade = nu; this.applyNetAppearance(); } this.wasPremium = nowPremium; this.updateWasps(); this.updateBirds(); this.trySpawnStalker(); this.updateStalker(); this.trySpawnBat(); this.updateBat(); this.trySpawnWeb(); this.updateWeb(); const stunned = this.timer < this.stunUntil; if (stunned !== this.wasStunned) { this.net.classList.toggle('stunned', stunned); this.wasStunned = stunned; } // Zabavená síťka (neúspěch inspekce) nebo ban → síťku opticky skryj, běží jen nápis this.net.style.visibility = (this.confiscated || this.banned) ? 'hidden' : ''; // Anti-bot inspekce: konec časového okna = neúspěch if (this.inspecting && this.timer >= this.inspectUntil) { this.failInspection(); } // Spuštění inspekce po dosažení prahu (jen při reálném hraní) if (!this.inspecting && !this.confiscated && !this.banned && this.netGrabbed && this.trustedControl && this.catchesSinceCheck >= this.nextCheckThreshold) { this.startInspection(); } if (this.netGrabbed) this.checkCatches(); if (nowPremium) this.runMagnet(); } this.timer++; this.animationId = requestAnimationFrame(this.render); }; public destroy = (): void => { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } if (this.comboTimer) window.clearTimeout(this.comboTimer); for (const w of this.wasps) w.cleanup?.(); this.removeStalker(); this.removeBat(); this.removeWeb(); 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.gameLayer.parentNode) this.gameLayer.parentNode.removeChild(this.gameLayer); window.removeEventListener('resize', this.handleResize); document.removeEventListener('visibilitychange', this.handleVisibility); }; } /** * Minihra chytání motýlků: mince, úrovně (strmá křivka), prémiová zlatá síťka, * vzácní zlatí motýli, kombo, perzistentní škůdci (vosy na zaklikání, nalétávající * ptáci), zloděj mincí a housenka žeroucí úlovky. Stav na serveru přes * {@link useButterflyStats}; rate-cap chrání proti podvádění. */ const FlyingButterflies: React.FC = ({ numButterflies = 9, className = 'flying-butterflies', butterflyVariants = BUTTERFLY_VARIANTS, enableNet = true, }) => { const containerRef = useRef(null); const sceneRef = useRef(null); const badgeRef = useRef(null); const [combo, setCombo] = useState(0); const [flash, setFlash] = useState(null); const [leaderboardOpen, setLeaderboardOpen] = useState(false); const [shopOpen, setShopOpen] = useState(false); const [nowTs, setNowTs] = useState(() => Date.now()); const flashTimer = useRef(null); // Režim: 'order' = jen dekorace (appka bez rušení), 'play' = plná hra const [mode, setMode] = useState<'order' | 'play'>(() => { try { return localStorage.getItem('butterflyMode') === 'play' ? 'play' : 'order'; } catch { return 'order'; } }); const interactive = enableNet && mode === 'play'; const toggleMode = useCallback(() => { setMode(m => { const next = m === 'play' ? 'order' : 'play'; try { localStorage.setItem('butterflyMode', next); } catch { /* ignore */ } return next; }); }, []); 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 mincí!'); } }, [showFlash]); const [inspecting, setInspecting] = useState(false); const { stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar, mothHit, reportInspectionFail, buyPremium, waspSpray, buyInsurance, buyUpgrade, } = useButterflyStats(handleReward); const netUpgradeRef = useRef(0); netUpgradeRef.current = stats?.upgrades?.net ?? 0; const banUntilRef = useRef(0); banUntilRef.current = stats?.banUntil ?? 0; const noop = () => { }; const callbacksRef = useRef({ onCatch: noop, getCoins: () => 0, getCaught: () => 0, getNetUpgrade: () => 0, getBanUntil: () => 0, onTornChange: noop, onCombo: noop, onSting: noop, onSwatWasp: noop, onMothCaught: noop, onRobbery: noop, onDefeatThief: noop, onCaterpillarAte: noop, onDefeatCaterpillar: noop, onStalkerAppear: noop, onBatShooed: noop, onWebCleared: noop, onInspection: noop, }); callbacksRef.current = { onCatch: (golden) => reportCatch(golden), getCoins: () => coinsRef.current, getCaught: () => stats?.caught ?? 0, getNetUpgrade: () => netUpgradeRef.current, getBanUntil: () => banUntilRef.current, onTornChange: (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(), onMothCaught: () => { void mothHit(); showFlash('🖤 Chytil jsi černou můru! Přišel jsi o úlovky – vyhýbej se jim.'); }, onRobbery: () => { void reportRobbery(); showFlash('💸 Zloděj ti ukradl většinu mincí! Příště ho zaklikej.'); }, onDefeatThief: () => { void defeatThief(); showFlash('💰 Zloděj poražen! Malá odměna a mince v bezpečí.'); }, onCaterpillarAte: () => { void caterpillarAte(); showFlash('🐛 Housenka ti sežrala část úlovků!'); }, onDefeatCaterpillar: () => { void defeatCaterpillar(); showFlash('🍃 Housenka poražena! Úlovky v bezpečí.'); }, onStalkerAppear: (type) => showFlash(type === 'thief' ? '🦹 Zloděj! Míří k tvým mincím – zaklikej ho (chce to hodně ran)!' : '🐛 Housenka! Plíží se k tvým úlovkům – zaklikej ji!'), onBatShooed: () => showFlash('🦇 Netopýr zahnán!'), onWebCleared: () => showFlash('🕸️ Pavučina stržena – zase můžeš chytat.'), onInspection: (state) => { if (state === 'start') { setInspecting(true); } else if (state === 'pass') { setInspecting(false); showFlash('🕵️ Inspekce OK – hraj dál!'); } else { setInspecting(false); void reportInspectionFail(); showFlash('👮 Inspekce neúspěšná – síťka zabavena na 45 s!'); } }, }; // Aktuální interaktivita pro init (bez recreate scény při přepnutí režimu) const interactiveRef = useRef(interactive); interactiveRef.current = interactive; const initialize = useCallback(() => { if (containerRef.current) { sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants, enableNet, { onCatch: (g) => callbacksRef.current.onCatch(g), getCoins: () => callbacksRef.current.getCoins(), getCaught: () => callbacksRef.current.getCaught(), getNetUpgrade: () => callbacksRef.current.getNetUpgrade(), getBanUntil: () => callbacksRef.current.getBanUntil(), onTornChange: (t) => callbacksRef.current.onTornChange(t), onCombo: (c) => callbacksRef.current.onCombo(c), onSting: () => callbacksRef.current.onSting(), onSwatWasp: () => callbacksRef.current.onSwatWasp(), onMothCaught: () => callbacksRef.current.onMothCaught(), onRobbery: () => callbacksRef.current.onRobbery(), onDefeatThief: () => callbacksRef.current.onDefeatThief(), onCaterpillarAte: () => callbacksRef.current.onCaterpillarAte(), onDefeatCaterpillar: () => callbacksRef.current.onDefeatCaterpillar(), onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t), onBatShooed: () => callbacksRef.current.onBatShooed(), onWebCleared: () => callbacksRef.current.onWebCleared(), onInspection: (s) => callbacksRef.current.onInspection(s), }); sceneRef.current.init(); sceneRef.current.setInteractive(interactiveRef.current); sceneRef.current.render(); } // Scéna žije trvale; režim se přepíná přes setInteractive (viz efekt níže), // aby přepnutí neobešlo aktivní penalizace. }, [numButterflies, butterflyVariants, enableNet]); useEffect(() => { initialize(); return () => { if (sceneRef.current) { sceneRef.current.destroy(); sceneRef.current = null; } if (flashTimer.current) window.clearTimeout(flashTimer.current); }; }, [initialize]); // Přepnutí režimu hraní/objednávání jen přepne interaktivitu (bez recreate) useEffect(() => { sceneRef.current?.setInteractive(interactive); }, [interactive]); const wasps = stats?.wasps ?? 0; const birds = stats?.birds ?? 0; const netTornUntil = stats?.netTornUntil ?? 0; const torn = netTornUntil > nowTs; const tornRemainingMs = Math.max(0, netTornUntil - nowTs); useEffect(() => { sceneRef.current?.syncPests(wasps, birds); }, [wasps, birds]); useEffect(() => { sceneRef.current?.setTornFromServer(torn); }, [torn]); useEffect(() => { const node = badgeRef.current; if (!node || displayCaught === 0) return; node.classList.remove('bump'); void node.offsetWidth; node.classList.add('bump'); }, [displayCaught]); const coins = stats?.coins ?? 0; const canAffordRepair = coins >= REPAIR_COST; const canAffordRepellent = coins >= REPELLENT_COST; const levelProgress = stats?.levelProgress ?? 0; const repellentUntil = stats?.repellentUntil ?? 0; const repellentRemainingMs = Math.max(0, repellentUntil - nowTs); const repellentActive = repellentRemainingMs > 0; const banUntil = stats?.banUntil ?? 0; const banned = banUntil > nowTs; const banRemainingMs = Math.max(0, banUntil - nowTs); // Tik po sekundách pro odpočty plašiče, protržené síťky a banu (jen dokud běží) useEffect(() => { const until = Math.max(repellentUntil, netTornUntil, banUntil); if (until <= Date.now()) return; const id = window.setInterval(() => setNowTs(Date.now()), 1000); return () => window.clearInterval(id); }, [repellentUntil, netTornUntil, banUntil]); const onRepairClick = useCallback(async () => { const ok = await repair(); if (ok) sceneRef.current?.repairNetExternally(); }, [repair]); /** Zobrazí hlášku podle výsledku nákupu (rozliší nedostatek mincí od jiné chyby). */ const flashBuy = useCallback((r: 'ok' | 'poor' | 'error', okMsg: string) => { if (r === 'ok') showFlash(okMsg); else if (r === 'poor') showFlash('🪙 Nemáš dost mincí.'); else showFlash('⚠️ Nákup se nezdařil – zkus obnovit stránku (F5). Server možná běží starou verzi.'); }, [showFlash]); const onRepellentClick = useCallback(async () => { flashBuy(await buyRepellent(), '🦅🚫 Plašič ptáků koupen – ptáci na chvíli zmizeli.'); }, [buyRepellent, flashBuy]); const onBuyPremium = useCallback(async () => { const r = await buyPremium(); if (r === 'ok') sceneRef.current?.activatePremiumNet(); flashBuy(r, '🪄 Zlatá síťka aktivována!'); }, [buyPremium, flashBuy]); const onWaspSpray = useCallback(async () => { flashBuy(await waspSpray(), '💨 Vosy zlikvidovány!'); }, [waspSpray, flashBuy]); const onBuyInsurance = useCallback(async () => { flashBuy(await buyInsurance(), '🛡️ Pojistka proti zloději aktivní.'); }, [buyInsurance, flashBuy]); const onBuyUpgrade = useCallback(async (item: 'net' | 'scarecrow' | 'reinforced') => { flashBuy(await buyUpgrade(item), '✅ Vylepšení koupeno!'); }, [buyUpgrade, flashBuy]); return ( <>
{enableNet && ( )} {interactive && inspecting && !banned && (
🕵️ Inspekce! Chyť zářícího královského motýla 👑 — a jiného se nedotkni!
)} {interactive && banned && (
)} {interactive && (
{flash && (
setFlash(null)} title="Klikni pro zavření"> {flash}
)} {combo > 1 &&
Kombo ×{combo}!
}
setLeaderboardOpen(true)} onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') setLeaderboardOpen(true); }} >
{stats ? `${stats.level}. ${stats.title}` : '…'}
0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🐝 {wasps} 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds} {repellentActive && ( 🚫 {formatRemaining(repellentRemainingMs)} )}
{torn && ( <>
🕳️ Protržená síťka – sama se zašije za {formatRemaining(tornRemainingMs)}
)} {birds > 0 && !repellentActive && ( )}
setLeaderboardOpen(false)} myStats={stats} /> setShopOpen(false)} stats={stats} onBuyPremium={onBuyPremium} onWaspSpray={onWaspSpray} onRepellent={onRepellentClick} onBuyInsurance={onBuyInsurance} onBuyUpgrade={onBuyUpgrade} />
)} ); }; export const BUTTERFLY_PRESETS = { LIGHT: 5, NORMAL: 9, HEAVY: 16 } as const; export const BUTTERFLY_COLOR_THEMES = { ALL: BUTTERFLY_VARIANTS, WARM: ['/butterfly-orange.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg'] as const, COOL: ['/butterfly-blue.svg'] as const, } as const; export default FlyingButterflies;