Files
Luncher/client/src/FlyingButterflies.tsx
T
Stánek PavelandClaude Opus 4.8 377a350211
CI / Generate TypeScript types (push) Successful in 15s
CI / Server unit tests (push) Successful in 24s
CI / Build server (push) Successful in 31s
CI / Build client (push) Successful in 1m0s
CI / Playwright E2E tests (push) Successful in 1m30s
CI / Build and push Docker image (push) Successful in 1m6s
CI / Notify (push) Successful in 5s
feat: anti-bot inspekce a vězení + přepínač hraní/objednávání
- přepínač Hraní/Objednávání; v objednávání hra nic neruší, v hraní herní vrstva odstíní kliknutí
- scéna žije trvale (přepnutí režimu neobejde pavučinu, omráčení, zabavenou síťku ani ban)
- anti-bot: ovládání jen reálnou myší (isTrusted/webdriver), občasná motýlí inspekce (chyť korunovaného)
- neúspěch inspekce = zabavená síťka 45 s (opticky skrytá); opakovaně = serverový ban s mříží a odpočtem
- během banu server nekredituje úlovky (F5/přepnutí-proof)
- větší síťka se teď viditelně zvětší hned po koupi
- changelog route přeskočí vadný JSON (nespadne celý endpoint) + oprava changelogu

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 12:56:20 +02:00

1424 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<FlyingButterfliesProps> = ({
numButterflies = 9,
className = 'flying-butterflies',
butterflyVariants = BUTTERFLY_VARIANTS,
enableNet = true,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<ButterflyScene | null>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const [combo, setCombo] = useState(0);
const [flash, setFlash] = useState<string | null>(null);
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
const [shopOpen, setShopOpen] = useState(false);
const [nowTs, setNowTs] = useState(() => Date.now());
const flashTimer = useRef<number | null>(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<SceneCallbacks>({
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 (
<>
<div ref={containerRef} className={interactive ? `${className} playing` : className} />
{enableNet && (
<button
type="button"
className={interactive ? 'butterfly-mode-toggle playing' : 'butterfly-mode-toggle'}
onClick={toggleMode}
title={interactive ? 'Přepnout na objednávání (vypne hru)' : 'Zapnout hraní s motýly'}
>
{interactive ? '🍽️ Objednávat' : '🎮 Hrát'}
</button>
)}
{interactive && inspecting && !banned && (
<div className="butterfly-inspection">
🕵️ Inspekce! Chyť <b>zářícího královského motýla 👑</b> a jiného se nedotkni!
</div>
)}
{interactive && banned && (
<div className="butterfly-jail">
<div className="butterfly-jail-bars" aria-hidden="true" />
<div className="butterfly-jail-note">
<div className="jail-emoji">🚔👮</div>
<div className="jail-title">Ve vězení za podvádění!</div>
<div className="jail-sub">Inspektor přistihl. Pustí za <b>{formatBan(banRemainingMs)}</b>.</div>
<div className="jail-hint">Chytání je zastavené. Objednávat můžeš dál přepni vpravo dole.</div>
</div>
</div>
)}
{interactive && (
<div className="butterfly-hud">
{flash && (
<div className="butterfly-flash" onClick={() => setFlash(null)} title="Klikni pro zavření">
{flash}
</div>
)}
{combo > 1 && <div className="butterfly-combo">Kombo ×{combo}!</div>}
<div
ref={badgeRef}
className="butterfly-counter"
title="Zobrazit žebříček a statistiky"
role="button"
tabIndex={0}
onClick={() => setLeaderboardOpen(true)}
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') setLeaderboardOpen(true); }}
>
<div className="butterfly-counter-row">
<span className="butterfly-counter-icon" aria-hidden="true" />
<span className="butterfly-counter-value">{displayCaught}</span>
<span className="butterfly-coin-icon" aria-hidden="true" />
<span className="butterfly-counter-value">{coins}</span>
</div>
<div className="butterfly-level">
<span className="butterfly-level-title">
{stats ? `${stats.level}. ${stats.title}` : '…'}
</span>
<div className="butterfly-progress" title="Postup do další úrovně">
<div className="butterfly-progress-bar" style={{ width: `${levelProgress}%` }} />
</div>
</div>
<div className="butterfly-pests" title="Škůdci u síťky">
<span className={wasps > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🐝 {wasps}</span>
<span className={birds > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds}</span>
{repellentActive && (
<span className="butterfly-pest ok" title="Zbývající doba plašiče">
🚫 {formatRemaining(repellentRemainingMs)}
</span>
)}
</div>
{torn && (
<>
<div className="butterfly-torn-note">
🕳️ Protržená síťka sama se zašije za {formatRemaining(tornRemainingMs)}
</div>
<button
type="button"
className="butterfly-action-btn danger"
onClick={(ev) => { ev.stopPropagation(); void onRepairClick(); }}
disabled={!canAffordRepair}
title={canAffordRepair ? 'Zašít hned za mince' : 'Nemáš dost mincí počkej, než se zašije sama'}
>
🪡 Zašít hned za {REPAIR_COST} 🪙
</button>
</>
)}
{birds > 0 && !repellentActive && (
<button
type="button"
className="butterfly-action-btn"
onClick={(ev) => { ev.stopPropagation(); void onRepellentClick(); }}
disabled={!canAffordRepellent}
title={canAffordRepellent ? 'Vyžene všechny ptáky a chvíli brání novým' : 'Nemáš dost mincí na plašič'}
>
🦅🚫 Plašič za {REPELLENT_COST} 🪙
</button>
)}
</div>
<button
type="button"
className="butterfly-shop-open"
onClick={() => setShopOpen(true)}
title="Otevřít obchod"
>
🛒 Obchod
</button>
<ButterflyLeaderboardModal isOpen={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} myStats={stats} />
<ButterflyShopModal
isOpen={shopOpen}
onClose={() => setShopOpen(false)}
stats={stats}
onBuyPremium={onBuyPremium}
onWaspSpray={onWaspSpray}
onRepellent={onRepellentClick}
onBuyInsurance={onBuyInsurance}
onBuyUpgrade={onBuyUpgrade}
/>
</div>
)}
</>
);
};
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;