feat: obtížnější chytání motýlků + zloděj, housenka a agresivní škůdci
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s

- 60 úrovní se strmou křivkou, dražší vylepšení, vzácnější zlatý motýl
- ochrana proti podvádění (rate cap + cooldown chytání)
- perzistentní škůdci přibývají rychleji; vosy nalétávají do síťky, ptáci ji protrhnou vždy
- protržená síťka se sama zašije za minutu (nebo okamžitě za mince)
- zloděj krade mince a housenka žere úlovky – oba se plíží k počítadlu a jdou zaklikat
- plašič ptáků s odpočtem, level progress bar, nová sezóna (reset statistik)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stánek Pavel
2026-07-22 14:10:29 +02:00
co-authored by Claude Opus 4.8
parent c5ca6c9d41
commit e3f439bde0
14 changed files with 918 additions and 347 deletions
+59
View File
@@ -365,6 +365,13 @@
}
}
.butterfly-torn-note {
margin-top: 4px;
font-size: 0.76rem;
font-weight: 600;
color: #c92a2a;
}
.butterfly-action-btn {
margin-top: 4px;
padding: 5px 10px;
@@ -401,6 +408,58 @@
}
}
// --- Bossové (zloděj / housenka) ---------------------------------------------
.butterfly-stalker {
position: fixed;
top: 0;
left: 0;
width: 56px;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: auto;
cursor: crosshair;
user-select: none;
touch-action: none;
z-index: 6;
will-change: transform;
&.hit .stalker-emoji {
animation: butterfly-stalker-hit 0.18s ease-out;
}
}
.stalker-emoji {
font-size: 44px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
}
.stalker-hp {
width: 46px;
height: 6px;
margin-bottom: 3px;
background: rgba(0, 0, 0, 0.25);
border-radius: 999px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.stalker-hp-inner {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #ff6b6b, #c92a2a);
border-radius: 999px;
transition: width 0.1s linear;
}
@keyframes butterfly-stalker-hit {
0% { transform: scale(1) rotate(0); }
50% { transform: scale(0.82) rotate(-8deg); filter: brightness(1.6); }
100% { transform: scale(1) rotate(0); }
}
// Efekt při plácnutí vosy
.butterfly-swat-fx {
position: fixed;
+325 -177
View File
@@ -2,30 +2,33 @@ import React, { useEffect, useRef, useCallback, useState } from 'react';
import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats';
import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal';
// Různé barevné varianty motýlů (soubory v public/, referencované root-absolutně)
// Barevné varianty motýlů (soubory v public/, referencované root-absolutně)
const BUTTERFLY_VARIANTS = [
'/butterfly-orange.svg', // Oranžová (monarcha)
'/butterfly-blue.svg', // Modrá
'/butterfly-yellow.svg', // Žlutá (otakárek)
'/butterfly-pink.svg', // Růžová
'/butterfly-orange.svg',
'/butterfly-blue.svg',
'/butterfly-yellow.svg',
'/butterfly-pink.svg',
] as const;
// Vzácný zlatý motýl
const GOLDEN_VARIANT = '/butterfly-golden.svg';
const GOLDEN_CHANCE = 0.025; // ~2,5 % motýlů je zlatých
const GOLDEN_CHANCE = 0.015; // ~1,5 % motýlů je zlatých (vzácnější)
// Grafika síťky (root-absolutní cesty kvůli produkčnímu servírování z kořene)
const NET_NORMAL = '/butterfly-net.svg';
const NET_GOLDEN = '/butterfly-net-golden.svg';
const NET_TORN = '/butterfly-net-torn.svg';
// Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts)
const PREMIUM_MILESTONE = 50;
const PREMIUM_DURATION_MS = 60_000;
export const REPAIR_COST = 10;
export const REPELLENT_COST = 25;
export const REPAIR_COST = 20;
export const REPELLENT_COST = 60;
const GOLD_VALUE = 25;
// „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);
@@ -37,44 +40,54 @@ function formatRemaining(ms: number): string {
interface ButterflyData {
el: HTMLDivElement;
sprite: HTMLDivElement;
x: number;
y: number;
dir: number;
speed: number;
size: number;
x: number; y: number;
dir: number; speed: number; size: number;
golden: boolean;
bobFreq1: number;
bobPhase1: number;
bobAmp1: number;
bobFreq2: number;
bobPhase2: number;
bobAmp2: number;
bobFreq1: number; bobPhase1: number; bobAmp1: number;
bobFreq2: number; bobPhase2: number; bobAmp2: number;
}
/** Létající havěť (pták nebo vosa). */
/** 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 (reset při obletu) */
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;
/** Uklízeč posluchače kliknutí (jen u vos) */
/** 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;
}
/** Callbacky, kterými scéna komunikuje s React obalem. */
/** 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;
onTornChange: (torn: boolean) => void;
onCombo: (count: number) => void;
onSting: () => void;
onSwatWasp: () => void;
onRobbery: () => void;
onDefeatThief: () => void;
onCaterpillarAte: () => void;
onDefeatCaterpillar: () => void;
onStalkerAppear: (type: 'thief' | 'caterpillar') => void;
}
interface FlyingButterfliesProps {
@@ -90,6 +103,7 @@ class ButterflyScene {
private butterflies: ButterflyData[] = [];
private birds: CritterData[] = [];
private wasps: CritterData[] = [];
private stalker: StalkerData | null = null;
private numButterflies: number;
private variants: readonly string[];
private width: number;
@@ -109,6 +123,8 @@ class ButterflyScene {
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
private comboCount: number = 0;
private lastCatchTs: number = 0;
@@ -126,18 +142,17 @@ class ButterflyScene {
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 STUN_FRAMES = 300; // ~5 s omráčení
private static readonly MAGNET_RADIUS = 150;
private static readonly MAGNET_INTERVAL = 42;
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;
constructor(
el: HTMLElement,
numButterflies: number,
variants: readonly string[],
netEnabled: boolean,
cb: SceneCallbacks,
) {
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
constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) {
this.viewport = el;
this.world = document.createElement('div');
this.numButterflies = numButterflies;
@@ -164,15 +179,9 @@ class ButterflyScene {
private resetButterfly = (b: ButterflyData): void => {
b.dir = Math.random() > 0.5 ? 1 : -1;
b.speed = Math.random() * 1.1 + 0.7;
if (this.timer === 0) {
b.x = Math.random() * this.width;
} else {
b.x = b.dir === 1 ? -40 : this.width + 40;
}
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;
@@ -184,10 +193,9 @@ class ButterflyScene {
if (b.golden) {
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
b.sprite.classList.add('golden');
b.size = Math.random() * 0.5 + 1.0; // zlatý je nápadně větší
b.size = Math.random() * 0.5 + 1.0;
} else {
const variant = this.variants[Math.floor(Math.random() * this.variants.length)];
b.sprite.style.backgroundImage = `url(${variant})`;
b.sprite.style.backgroundImage = `url(${this.variants[Math.floor(Math.random() * this.variants.length)]})`;
b.sprite.classList.remove('golden');
}
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`;
@@ -198,16 +206,10 @@ class ButterflyScene {
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;
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
) {
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);
}
};
@@ -215,8 +217,7 @@ class ButterflyScene {
// --- Síťka ---------------------------------------------------------------
private updateNetTransform = (): void => {
this.net.style.transform =
`translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`;
this.net.style.transform = `translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`;
};
private applyNetAppearance = (): void => {
@@ -236,18 +237,14 @@ class ButterflyScene {
e.preventDefault();
this.netGrabbed = true;
this.net.classList.add('grabbed');
this.netX = e.clientX;
this.netY = e.clientY;
this.netX = e.clientX; this.netY = e.clientY;
this.updateNetTransform();
};
private onPointerMove = (e: PointerEvent): void => {
if (!this.netGrabbed) return;
this.netX = e.clientX;
this.netY = e.clientY;
this.netX = e.clientX; this.netY = e.clientY;
this.updateNetTransform();
};
private onPointerUp = (): void => {
if (!this.netGrabbed) return;
this.netGrabbed = false;
@@ -274,6 +271,7 @@ class ButterflyScene {
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);
this.nextCatchAt = this.timer + ButterflyScene.CATCH_COOLDOWN;
const now = Date.now();
this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1;
@@ -285,24 +283,24 @@ class ButterflyScene {
}
};
// Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna)
private checkCatches = (): void => {
if (this.torn || this.timer < this.stunUntil) return;
if (this.torn || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
const r2 = this.catchRadius * this.catchRadius;
const half = ButterflyScene.BASE_SIZE / 2;
let caughtAny = false;
for (const b of this.butterflies) {
const dx = (b.x + half * b.size) - this.netX;
const dy = (b.y + half * b.size) - this.netY;
if (dx * dx + dy * dy < r2) {
caughtAny = true;
this.registerCatch(b, this.netX, this.netY);
this.popNet();
return; // jen jeden za cooldown
}
}
if (caughtAny) this.popNet();
};
private runMagnet = (): void => {
if (this.torn || this.timer < this.stunUntil) return;
if (this.torn || 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;
@@ -321,15 +319,11 @@ class ButterflyScene {
this.premiumUntil = Date.now() + PREMIUM_DURATION_MS;
this.applyNetAppearance();
};
/** Vnější (zaplacená) oprava protržené síťky. */
public repairNetExternally = (): void => {
if (!this.torn) return;
this.torn = false;
this.applyNetAppearance();
};
/** Nastaví protržení podle serveru (init/poll), bez hlášení zpět. */
public setTornFromServer = (torn: boolean): void => {
if (this.torn === torn) return;
this.torn = torn;
@@ -364,13 +358,14 @@ class ButterflyScene {
const c: CritterData = {
el,
x: dir === 1 ? -size : this.width + size,
y: Math.random() * (this.height * 0.7) + this.height * 0.1,
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') {
@@ -395,70 +390,102 @@ class ButterflyScene {
private wrapCritter = (c: CritterData): void => {
c.dir = Math.random() > 0.5 ? 1 : -1;
c.x = c.dir === 1 ? -c.size : this.width + c.size;
c.y = Math.random() * (this.height * 0.7) + this.height * 0.1;
c.y = Math.random() * (this.height * 0.6) + this.height * 0.1;
c.triggered = false;
};
private updateWasp = (c: CritterData): void => {
c.x += c.dir * c.speed;
// mírné míření k obruči, aby vosy otravovaly zrovna síťku
const targetY = this.netY - c.size / 2;
c.y += (targetY - c.y) * 0.015 + Math.sin(this.timer * 0.06 + c.bobPhase) * c.bobAmp;
const flip = c.dir === -1 ? -1 : 1;
c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`;
if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) {
/**
* 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 updateBird = (c: CritterData): void => {
c.x += c.dir * c.speed;
c.y += Math.sin(this.timer * 0.03 + c.bobPhase) * c.bobAmp;
const flip = c.dir === -1 ? -1 : 1;
c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`;
if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) {
this.wrapCritter(c);
}
};
private removeCritter = (arr: CritterData[]): void => {
private removeLastCritter = (arr: CritterData[]): void => {
const c = arr.pop();
if (c) {
c.cleanup?.();
if (c.el.parentNode) c.el.parentNode.removeChild(c.el);
}
if (c) { c.cleanup?.(); if (c.el.parentNode) c.el.parentNode.removeChild(c.el); }
};
/** Sladí počet vos a ptáků ve scéně s hodnotami ze serveru. */
public syncPests = (wasps: number, birds: number): void => {
if (!this.netEnabled) return;
while (this.wasps.length < wasps) this.wasps.push(this.spawnCritter('wasp'));
while (this.wasps.length > wasps) this.removeCritter(this.wasps);
while (this.wasps.length > wasps) this.removeLastCritter(this.wasps);
while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird'));
while (this.birds.length > birds) this.removeCritter(this.birds);
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) {
this.updateWasp(wasp);
// Vosa žihne síťku, jakmile pomine předchozí omráčení
if (!this.torn && this.timer >= this.stunUntil && this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) {
this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES;
this.net.classList.remove('stung');
void this.net.offsetWidth;
this.net.classList.add('stung');
this.spawnFx('Au!', 'butterfly-sting-fx', this.netX, this.netY);
this.cb.onSting();
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) {
this.updateBird(bird);
// Prémiová síťka je z pevnějšího materiálu; protrhne se jen když má hráč na opravu
// 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.cb.getCoins() >= REPAIR_COST
&& this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) {
bird.triggered = true;
this.tearNet(this.netX, this.netY);
@@ -466,6 +493,117 @@ class ButterflyScene {
}
};
// --- 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;
}
const type = options[Math.floor(Math.random() * options.length)];
this.spawnStalker(type);
this.nextStalkerAt = this.timer + ButterflyScene.STALKER_MIN_GAP
+ Math.floor(Math.random() * (ButterflyScene.STALKER_MAX_GAP - ButterflyScene.STALKER_MIN_GAP));
};
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.viewport.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();
}
};
// --- Init / render ------------------------------------------------------
private initNet = (): void => {
this.net.className = 'butterfly-net';
this.applyNetAppearance();
@@ -483,7 +621,6 @@ class ButterflyScene {
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');
@@ -497,7 +634,6 @@ class ButterflyScene {
this.butterflies.push(b);
this.world.appendChild(el);
}
this.viewport.appendChild(this.world);
if (this.netEnabled) this.initNet();
window.addEventListener('resize', this.handleResize);
@@ -516,6 +652,8 @@ class ButterflyScene {
this.updateWasps();
this.updateBirds();
this.trySpawnStalker();
this.updateStalker();
const stunned = this.timer < this.stunUntil;
if (stunned !== this.wasStunned) {
@@ -535,7 +673,7 @@ class ButterflyScene {
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();
if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world);
this.net.removeEventListener('pointerdown', this.onPointerDown);
window.removeEventListener('pointermove', this.onPointerMove);
@@ -547,10 +685,10 @@ class ButterflyScene {
}
/**
* Komponenta minihry chytání motýlků: mince, úrovně, prémiová zlatá síťka za
* milník, magnet, vzácní zlatí motýli, kombo, denní bonus a perzistentní škůdci
* (vosy na zaklikání, ptáci trhající síťku, placený plašič). Stav se ukládá na
* server přes {@link useButterflyStats}.
* 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,
@@ -562,7 +700,6 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
const sceneRef = useRef<ButterflyScene | null>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const [torn, setTorn] = useState(false);
const [combo, setCombo] = useState(0);
const [flash, setFlash] = useState<string | null>(null);
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
@@ -582,38 +719,55 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
} else if (e.leveledUp) {
showFlash(`⭐ Nová úroveň: ${e.newTitle}!`);
} else if (e.dailyBonusApplied) {
showFlash('☀️ Denní bonus +10 mincí!');
showFlash('☀️ Denní bonus mincí!');
}
}, [showFlash]);
const { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear } = useButterflyStats(handleReward);
const {
stats, displayCaught, coinsRef, reportCatch, repair,
killWasp, buyRepellent, reportTear, reportRobbery, defeatThief,
caterpillarAte, defeatCaterpillar,
} = useButterflyStats(handleReward);
const callbacksRef = useRef<SceneCallbacks>({
onCatch: () => { }, getCoins: () => 0, onTornChange: () => { },
onCatch: () => { }, getCoins: () => 0, getCaught: () => 0, onTornChange: () => { },
onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { },
onRobbery: () => { }, onDefeatThief: () => { }, onCaterpillarAte: () => { },
onDefeatCaterpillar: () => { }, onStalkerAppear: () => { },
});
callbacksRef.current = {
onCatch: (golden) => reportCatch(golden),
getCoins: () => coinsRef.current,
onTornChange: (t) => { setTorn(t); if (t) void reportTear(); },
getCaught: () => stats?.caught ?? 0,
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(),
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!'),
};
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(),
onTornChange: (t) => callbacksRef.current.onTornChange(t),
onCombo: (c) => callbacksRef.current.onCombo(c),
onSting: () => callbacksRef.current.onSting(),
onSwatWasp: () => callbacksRef.current.onSwatWasp(),
},
);
sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants, enableNet, {
onCatch: (g) => callbacksRef.current.onCatch(g),
getCoins: () => callbacksRef.current.getCoins(),
getCaught: () => callbacksRef.current.getCaught(),
onTornChange: (t) => callbacksRef.current.onTornChange(t),
onCombo: (c) => callbacksRef.current.onCombo(c),
onSting: () => callbacksRef.current.onSting(),
onSwatWasp: () => callbacksRef.current.onSwatWasp(),
onRobbery: () => callbacksRef.current.onRobbery(),
onDefeatThief: () => callbacksRef.current.onDefeatThief(),
onCaterpillarAte: () => callbacksRef.current.onCaterpillarAte(),
onDefeatCaterpillar: () => callbacksRef.current.onDefeatCaterpillar(),
onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t),
});
sceneRef.current.init();
sceneRef.current.render();
}
@@ -627,39 +781,37 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
};
}, [initialize]);
// Synchronizace škůdců a protržení ze serveru do scény
const wasps = stats?.wasps ?? 0;
const birds = stats?.birds ?? 0;
const netTorn = stats?.netTorn ?? false;
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(netTorn);
setTorn(netTorn);
}, [netTorn]);
useEffect(() => { sceneRef.current?.setTornFromServer(torn); }, [torn]);
const caught = stats?.caught ?? 0;
useEffect(() => {
const node = badgeRef.current;
if (!node || caught === 0) return;
if (!node || displayCaught === 0) return;
node.classList.remove('bump');
void node.offsetWidth;
node.classList.add('bump');
}, [caught]);
}, [displayCaught]);
const coins = stats?.coins ?? 0;
const canAffordRepair = coins >= REPAIR_COST;
const canAffordRepellent = coins >= REPELLENT_COST;
const progress = Math.round(((caught % PREMIUM_MILESTONE) / PREMIUM_MILESTONE) * 100);
const levelProgress = stats?.levelProgress ?? 0;
const repellentUntil = stats?.repellentUntil ?? 0;
const repellentRemainingMs = Math.max(0, repellentUntil - nowTs);
const repellentActive = repellentRemainingMs > 0;
// Tik po sekundách pro odpočet plašiče (jen dokud běží)
// Tik po sekundách pro odpočty plašiče i protržené síťky (jen dokud běží)
useEffect(() => {
if (repellentUntil <= Date.now()) return;
const until = Math.max(repellentUntil, netTornUntil);
if (until <= Date.now()) return;
const id = window.setInterval(() => setNowTs(Date.now()), 1000);
return () => window.clearInterval(id);
}, [repellentUntil]);
}, [repellentUntil, netTornUntil]);
const onRepairClick = useCallback(async () => {
const ok = await repair();
@@ -694,7 +846,7 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
>
<div className="butterfly-counter-row">
<span className="butterfly-counter-icon" aria-hidden="true" />
<span className="butterfly-counter-value">{caught}</span>
<span className="butterfly-counter-value">{displayCaught}</span>
<span className="butterfly-coin-icon" aria-hidden="true" />
<span className="butterfly-counter-value">{coins}</span>
</div>
@@ -702,8 +854,8 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
<span className="butterfly-level-title">
{stats ? `${stats.level}. ${stats.title}` : '…'}
</span>
<div className="butterfly-progress" title="Postup k další zlaté síťce">
<div className="butterfly-progress-bar" style={{ width: `${progress}%` }} />
<div className="butterfly-progress" title="Postup do další úrovně">
<div className="butterfly-progress-bar" style={{ width: `${levelProgress}%` }} />
</div>
</div>
@@ -712,21 +864,26 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
<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">
🚫 plašič {formatRemaining(repellentRemainingMs)}
🚫 {formatRemaining(repellentRemainingMs)}
</span>
)}
</div>
{torn && (
<button
type="button"
className="butterfly-action-btn danger"
onClick={(ev) => { ev.stopPropagation(); void onRepairClick(); }}
disabled={!canAffordRepair}
title={canAffordRepair ? 'Zašít protrženou síťku' : 'Nemáš dost mincí nachytej další motýly'}
>
🪡 Zašít síťku za {REPAIR_COST} 🪙
</button>
<>
<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 && (
@@ -742,23 +899,14 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
)}
</div>
<ButterflyLeaderboardModal
isOpen={leaderboardOpen}
onClose={() => setLeaderboardOpen(false)}
myStats={stats}
/>
<ButterflyLeaderboardModal isOpen={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} myStats={stats} />
</div>
)}
</>
);
};
export const BUTTERFLY_PRESETS = {
LIGHT: 5,
NORMAL: 9,
HEAVY: 16,
} as const;
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,
@@ -46,6 +46,8 @@ export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }:
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
<div><span className="v">{myStats.thievesDefeated}</span><span className="l">🦹 zlodějů</span></div>
<div><span className="v">{myStats.caterpillarsDefeated}</span><span className="l">🐛 housenek</span></div>
</div>
</div>
)}
+60 -50
View File
@@ -7,18 +7,14 @@ import {
killWasp as killWaspApi,
buyRepellent as buyRepellentApi,
reportNetTorn as reportTearApi,
reportRobbery as reportRobberyApi,
defeatThief as defeatThiefApi,
reportCaterpillarAte as caterpillarAteApi,
defeatCaterpillar as defeatCaterpillarApi,
} from '../../../types';
/** Starý klíč, pod kterým se počet chycených ukládal jen do localStorage. */
const LEGACY_KEY = 'flyingButterfliesCaught';
/** Příznak, že už proběhla jednorázová migrace localStorage → server. */
const MIGRATED_KEY = 'flyingButterfliesMigrated';
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
const FLUSH_DEBOUNCE_MS = 1500;
/** Server přijme max 100 motýlů na dávku migraci proto posíláme po částech. */
const MIGRATION_CHUNK = 100;
/** Bezpečnostní strop migrovaných úlovků (proti nesmyslným hodnotám v localStorage). */
const MIGRATION_MAX = 1000;
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
const POLL_INTERVAL_MS = 30_000;
@@ -33,12 +29,15 @@ export interface RewardEvent {
}
/**
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu,
* jednorázovou migraci z localStorage, dávkové odesílání úlovků, hubení vos,
* plašič ptáků, protržení/opravu síťky a periodické přenačítání (růst škůdců).
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu, dávkové
* odesílání úlovků, hubení vos, plašič ptáků, protržení/opravu síťky, zloděje a
* periodické přenačítání (růst škůdců). Server je zdroj pravdy; počítadlo se
* zobrazuje jako serverová hodnota + zatím neodeslané úlovky.
*/
export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
const [stats, setStats] = useState<ButterflyStats | undefined>();
/** Optimisticky zobrazený přírůstek chycených, který ještě neodešel na server. */
const [pendingShown, setPendingShown] = useState(0);
/** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
const coinsRef = useRef(0);
const pending = useRef({ normal: 0, golden: 0 });
@@ -46,18 +45,9 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
const onRewardRef = useRef(onReward);
onRewardRef.current = onReward;
/** Zapíše statistiky ze serveru, ale nedovolí „couvnout" počtu chycených
* (kvůli optimistickému počítadlu, které může být napřed před flushem). */
const applyStats = useCallback((incoming: ButterflyStats) => {
setStats(prev => {
const merged: ButterflyStats = { ...incoming };
if (prev && prev.caught > incoming.caught) {
merged.caught = prev.caught;
merged.goldenCaught = Math.max(prev.goldenCaught, incoming.goldenCaught);
}
coinsRef.current = merged.coins;
return merged;
});
coinsRef.current = incoming.coins;
setStats(incoming);
}, []);
const flush = useCallback(async () => {
@@ -66,12 +56,15 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
flushTimer.current = null;
}
const batch = pending.current;
if (batch.normal === 0 && batch.golden === 0) return;
const batchTotal = batch.normal + batch.golden;
if (batchTotal === 0) return;
pending.current = { normal: 0, golden: 0 };
try {
const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
if (res.data) {
applyStats(res.data.stats);
// Odeslané úlovky už jsou v serverové hodnotě → sundáme je z optimistického přírůstku
setPendingShown(p => Math.max(0, p - batchTotal));
onRewardRef.current?.({
coinsAwarded: res.data.coinsAwarded,
dailyBonusApplied: res.data.dailyBonusApplied,
@@ -80,8 +73,11 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
newLevel: res.data.stats.level,
newTitle: res.data.stats.title,
});
} else {
setPendingShown(p => Math.max(0, p - batchTotal));
}
} catch {
// Při chybě vrátíme dávku zpět (přírůstek necháme zobrazený)
pending.current.normal += batch.normal;
pending.current.golden += batch.golden;
}
@@ -91,15 +87,12 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
const reportCatch = useCallback((golden: boolean) => {
if (golden) pending.current.golden += 1;
else pending.current.normal += 1;
setStats(prev => prev
? { ...prev, caught: prev.caught + 1, goldenCaught: prev.goldenCaught + (golden ? 1 : 0) }
: prev);
setPendingShown(p => p + 1);
if (!flushTimer.current) {
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
}
}, [flush]);
/** Zaplatí opravu síťky. Vrátí true při úspěchu, false při nedostatku mincí/chybě. */
const repair = useCallback(async (): Promise<boolean> => {
try {
const res = await repairNetApi();
@@ -108,7 +101,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { return false; }
}, [applyStats]);
/** Zahubí jednu vosu (plácačka). */
const killWasp = useCallback(async () => {
try {
const res = await killWaspApi();
@@ -116,7 +108,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { /* ignore */ }
}, [applyStats]);
/** Koupí plašič ptáků. Vrátí true při úspěchu, false při nedostatku mincí. */
const buyRepellent = useCallback(async (): Promise<boolean> => {
try {
const res = await buyRepellentApi();
@@ -125,7 +116,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { return false; }
}, [applyStats]);
/** Nahlásí serveru, že pták protrhl síťku (perzistentně). */
const reportTear = useCallback(async () => {
try {
const res = await reportTearApi();
@@ -133,7 +123,38 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { /* ignore */ }
}, [applyStats]);
/** Přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
/** Zloděj se dostal k penězům server strhne podíl mincí. */
const reportRobbery = useCallback(async () => {
try {
const res = await reportRobberyApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Hráč porazil zloděje odměna a statistika. */
const defeatThief = useCallback(async () => {
try {
const res = await defeatThiefApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Housenka se dostala k úlovkům server sníží počet chycených. */
const caterpillarAte = useCallback(async () => {
try {
const res = await caterpillarAteApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Hráč porazil housenku odměna a statistika. */
const defeatCaterpillar = useCallback(async () => {
try {
const res = await defeatCaterpillarApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
const refresh = useCallback(async () => {
try {
const res = await getButterflyStats();
@@ -141,29 +162,13 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { /* ignore */ }
}, [applyStats]);
// Načtení stavu + jednorázová migrace z localStorage
// Načtení stavu
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await getButterflyStats();
if (cancelled || !res.data) return;
let current = res.data;
try {
if (!localStorage.getItem(MIGRATED_KEY)) {
const legacy = Number(localStorage.getItem(LEGACY_KEY));
let diff = Number.isFinite(legacy) ? Math.min(legacy - current.caught, MIGRATION_MAX) : 0;
while (diff > 0 && !cancelled) {
const chunk = Math.min(diff, MIGRATION_CHUNK);
const migRes = await catchButterflies({ body: { normal: chunk, golden: 0 } });
if (migRes.data) current = migRes.data.stats;
diff -= chunk;
}
localStorage.setItem(MIGRATED_KEY, '1');
localStorage.removeItem(LEGACY_KEY);
}
} catch { /* localStorage nedostupné migraci přeskočíme */ }
if (!cancelled) applyStats(current);
if (!cancelled && res.data) applyStats(res.data);
} catch { /* server nedostupný hra jede dál bez perzistence */ }
})();
return () => { cancelled = true; };
@@ -193,5 +198,10 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
};
}, [flush]);
return { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear };
const displayCaught = (stats?.caught ?? 0) + pendingShown;
return {
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
};
}