feat: anti-bot inspekce a vězení + přepínač hraní/objednávání
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>
This commit is contained in:
Stánek Pavel
2026-07-27 12:56:20 +02:00
co-authored by Claude Opus 4.8
parent 252d105408
commit 377a350211
11 changed files with 502 additions and 22 deletions
+121
View File
@@ -7,6 +7,49 @@
pointer-events: none;
z-index: 2;
overflow: hidden;
// V režimu hraní herní vrstva zachytává kliknutí, aby se nedalo omylem
// překliknout do objednávek. V režimu objednávání propouští vše dál (none).
&.playing {
pointer-events: auto;
}
}
// Přepínač režimu hraní / objednávání
.butterfly-mode-toggle {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 7;
pointer-events: auto;
padding: 8px 14px;
border: none;
border-radius: 999px;
background: rgba(33, 37, 41, 0.88);
color: #fff;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(4px);
transition: background 0.15s ease, transform 0.08s ease;
&:hover { background: rgba(33, 37, 41, 1); }
&:active { transform: scale(0.96); }
&.playing {
background: #2f9e44;
&:hover { background: #2b8a3e; }
}
}
// Vrstva herních prvků (síťka, škůdci, pavučina) skrývá se v režimu objednávání
.butterfly-game-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.butterfly-scene {
@@ -73,6 +116,20 @@
}
}
// Korunovaný motýl (anti-bot inspekce) nepřehlédnutelná duhová záře
.butterfly-sprite.royal {
animation: butterfly-flap 0.35s ease-in-out infinite, butterfly-royal-glow 0.9s ease-in-out infinite;
}
@keyframes butterfly-royal-glow {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(255, 215, 80, 1)) drop-shadow(0 0 16px rgba(120, 200, 255, 0.8));
}
50% {
filter: drop-shadow(0 0 16px rgba(255, 120, 220, 1)) drop-shadow(0 0 28px rgba(120, 255, 160, 0.9));
}
}
// Černá můra zlověstný tmavý nádech
.butterfly-sprite.moth {
filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.6)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
@@ -626,3 +683,67 @@
filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7));
}
}
// --- Anti-bot inspekce a vězení ---------------------------------------------
.butterfly-inspection {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 8;
pointer-events: none;
max-width: 92vw;
padding: 10px 16px;
border-radius: 999px;
background: rgba(28, 126, 214, 0.95);
color: #fff;
font-weight: 700;
font-size: 0.95rem;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
animation: butterfly-flash-in 0.25s ease-out;
}
.butterfly-jail {
position: fixed;
inset: 0;
z-index: 6;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
// Svislé mříže přes celou obrazovku
.butterfly-jail-bars {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
90deg,
rgba(20, 22, 28, 0.92) 0px,
rgba(20, 22, 28, 0.92) 14px,
rgba(20, 22, 28, 0) 14px,
rgba(20, 22, 28, 0) 70px
);
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.6);
}
.butterfly-jail-note {
position: relative;
text-align: center;
padding: 22px 26px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
max-width: 90vw;
.jail-emoji { font-size: 2.4rem; }
.jail-title { font-weight: 800; font-size: 1.2rem; color: #c92a2a; margin-top: 4px; }
.jail-sub { margin-top: 6px; font-size: 0.95rem; color: #333; font-variant-numeric: tabular-nums; }
.jail-hint { margin-top: 8px; font-size: 0.8rem; color: #868e96; }
}
@media (prefers-reduced-motion: reduce) {
.butterfly-sprite.royal { animation: none; }
}
+245 -19
View File
@@ -28,6 +28,14 @@ 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;
@@ -43,6 +51,17 @@ function formatRemaining(ms: number): string {
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;
@@ -51,6 +70,8 @@ interface ButterflyData {
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;
}
@@ -99,6 +120,8 @@ interface SceneCallbacks {
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;
@@ -111,6 +134,8 @@ interface SceneCallbacks {
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 {
@@ -123,10 +148,13 @@ interface FlyingButterfliesProps {
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;
@@ -157,6 +185,16 @@ class ButterflyScene {
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;
@@ -195,6 +233,8 @@ class ButterflyScene {
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;
@@ -202,6 +242,8 @@ class ButterflyScene {
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;
@@ -217,7 +259,10 @@ class ButterflyScene {
}
private get premiumActive(): boolean { return Date.now() < this.premiumUntil; }
private get netSize(): number { return this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL; }
private get 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();
@@ -225,6 +270,21 @@ class ButterflyScene {
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 => {
@@ -240,6 +300,19 @@ class ButterflyScene {
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;
@@ -295,6 +368,9 @@ class ButterflyScene {
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;
@@ -302,6 +378,7 @@ class ButterflyScene {
};
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();
};
@@ -330,6 +407,13 @@ class ButterflyScene {
/** 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();
@@ -362,7 +446,9 @@ 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.webbed || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
// 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) {
@@ -377,7 +463,8 @@ class ButterflyScene {
};
private runMagnet = (): void => {
if (this.torn || this.webbed || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
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;
@@ -392,6 +479,54 @@ class ButterflyScene {
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();
@@ -460,7 +595,7 @@ class ButterflyScene {
c.cleanup = () => el.removeEventListener('pointerdown', swat);
}
this.world.appendChild(el);
this.gameLayer.appendChild(el);
return c;
};
@@ -647,7 +782,7 @@ class ButterflyScene {
el.addEventListener('pointerdown', hit);
s.cleanup = () => el.removeEventListener('pointerdown', hit);
this.viewport.appendChild(el);
this.gameLayer.appendChild(el);
this.stalker = s;
this.cb.onStalkerAppear(type);
};
@@ -732,7 +867,7 @@ class ButterflyScene {
};
el.addEventListener('pointerdown', hit);
bat.cleanup = () => el.removeEventListener('pointerdown', hit);
this.world.appendChild(el);
this.gameLayer.appendChild(el);
this.bat = bat;
};
@@ -799,7 +934,7 @@ class ButterflyScene {
};
el.addEventListener('pointerdown', strip);
(el as any)._cleanup = () => el.removeEventListener('pointerdown', strip);
this.viewport.appendChild(el);
this.gameLayer.appendChild(el);
this.web = el;
this.spawnFx('🕸️ Pavučina!', 'butterfly-tear-fx', this.netX, this.netY - 20);
};
@@ -831,7 +966,7 @@ class ButterflyScene {
window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('pointerup', this.onPointerUp);
window.addEventListener('pointercancel', this.onPointerUp);
this.viewport.appendChild(this.net);
this.gameLayer.appendChild(this.net);
};
public init = (): void => {
@@ -844,7 +979,7 @@ class ButterflyScene {
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,
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);
@@ -852,6 +987,8 @@ class ButterflyScene {
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);
@@ -859,13 +996,19 @@ class ButterflyScene {
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) {
if (this.netEnabled && this.interactive) {
const nowPremium = this.premiumActive;
if (this.wasPremium && !nowPremium) this.applyNetAppearance();
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();
@@ -883,6 +1026,19 @@ class ButterflyScene {
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();
}
@@ -903,7 +1059,7 @@ class ButterflyScene {
window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp);
window.removeEventListener('pointercancel', this.onPointerUp);
if (this.net.parentNode) this.net.parentNode.removeChild(this.net);
if (this.gameLayer.parentNode) this.gameLayer.parentNode.removeChild(this.gameLayer);
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('visibilitychange', this.handleVisibility);
};
@@ -932,6 +1088,20 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
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);
@@ -949,27 +1119,33 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
}
}, [showFlash]);
const [inspecting, setInspecting] = useState(false);
const {
stats, displayCaught, coinsRef, reportCatch, repair,
killWasp, buyRepellent, reportTear, reportRobbery, defeatThief,
caterpillarAte, defeatCaterpillar, mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade,
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, onTornChange: noop,
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,
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!'),
@@ -984,8 +1160,17 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
: '🐛 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, {
@@ -993,6 +1178,7 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
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(),
@@ -1005,10 +1191,14 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
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(() => {
@@ -1019,6 +1209,9 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
};
}, [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;
@@ -1042,14 +1235,17 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
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 i protržené síťky (jen dokud běží)
// 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);
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]);
}, [repellentUntil, netTornUntil, banUntil]);
const onRepairClick = useCallback(async () => {
const ok = await repair();
@@ -1087,8 +1283,38 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
return (
<>
<div ref={containerRef} className={className} />
<div ref={containerRef} className={interactive ? `${className} playing` : className} />
{enableNet && (
<button
type="button"
className={interactive ? 'butterfly-mode-toggle playing' : 'butterfly-mode-toggle'}
onClick={toggleMode}
title={interactive ? 'Přepnout na objednávání (vypne hru)' : 'Zapnout hraní s motýly'}
>
{interactive ? '🍽️ Objednávat' : '🎮 Hrát'}
</button>
)}
{interactive && inspecting && !banned && (
<div className="butterfly-inspection">
🕵 Inspekce! Chyť <b>zářícího královského motýla 👑</b> a jiného se nedotkni!
</div>
)}
{interactive && banned && (
<div className="butterfly-jail">
<div className="butterfly-jail-bars" aria-hidden="true" />
<div className="butterfly-jail-note">
<div className="jail-emoji">🚔👮</div>
<div className="jail-title">Ve vězení za podvádění!</div>
<div className="jail-sub">Inspektor přistihl. Pustí za <b>{formatBan(banRemainingMs)}</b>.</div>
<div className="jail-hint">Chytání je zastavené. Objednávat můžeš dál přepni vpravo dole.</div>
</div>
</div>
)}
{interactive && (
<div className="butterfly-hud">
{flash && (
<div className="butterfly-flash" onClick={() => setFlash(null)} title="Klikni pro zavření">
+10 -1
View File
@@ -12,6 +12,7 @@ import {
reportCaterpillarAte as caterpillarAteApi,
defeatCaterpillar as defeatCaterpillarApi,
reportMothHit as mothHitApi,
reportInspectionFail as inspectionFailApi,
buyPremiumNet as buyPremiumApi,
buyWaspSpray as waspSprayApi,
buyInsurance as buyInsuranceApi,
@@ -166,6 +167,14 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { /* ignore */ }
}, [applyStats]);
/** Selhání anti-bot inspekce server počítá selhání a případně udělí ban. */
const reportInspectionFail = useCallback(async () => {
try {
const res = await inspectionFailApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Vyhodnotí výsledek nákupu z odpovědi (402 = nedostatek mincí, jinak chyba). */
const buyResult = useCallback((res: { data?: unknown; response?: Response }): BuyResult => {
if (res.data) { applyStats(res.data as ButterflyStats); return 'ok'; }
@@ -242,6 +251,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
return {
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade,
mothHit, reportInspectionFail, buyPremium, waspSpray, buyInsurance, buyUpgrade,
};
}