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
- 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>
1424 lines
62 KiB
TypeScript
1424 lines
62 KiB
TypeScript
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 tě přistihl. Pustí tě 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;
|