feat: obchod, černé můry, netopýr, pavouk a denní úkoly u chytání motýlků
CI / Generate TypeScript types (push) Successful in 15s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 1m2s
CI / Playwright E2E tests (push) Successful in 1m33s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 1s

- obchod s pomůckami a trvalými vylepšeními (větší síťka, strašák, zpevněná síťka) – mince mají trvalý smysl
- černé můry se míchají mezi motýly; chycení (i magnetem) ubere hodně úlovků
- ptáci silnější, plašič dražší a kratší, zloději chodí častěji podle bohatství
- denní úkol s odměnou + achievementy/odznaky
- netopýr v noci loví motýly, pavouk zamotá síťku pavučinou – oba na zaklikání
- nákupní hlášky rozlišují nedostatek mincí od jiné chyby; herní smyčka se pozastaví při skryté záložce

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stánek Pavel
2026-07-27 10:23:09 +02:00
co-authored by Claude Opus 4.8
parent e3f439bde0
commit 252d105408
22 changed files with 1384 additions and 43 deletions
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- Netopýr (čelem doprava; JS natáčí dle směru letu) -->
<g fill="#2B2733" stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
<!-- křídla -->
<path d="M50,52 C34,30 18,30 6,40 C16,42 18,52 14,62 C24,54 34,52 44,58 Z"/>
<path d="M50,52 C66,30 82,30 94,40 C84,42 82,52 86,62 C76,54 66,52 56,58 Z"/>
<!-- tělo -->
<ellipse cx="50" cy="56" rx="9" ry="13"/>
<!-- uši -->
<path d="M44,44 L40,32 L48,42 Z"/>
<path d="M56,44 L60,32 L52,42 Z"/>
</g>
<!-- oči -->
<g fill="#F5C518">
<circle cx="46" cy="50" r="1.8"/>
<circle cx="54" cy="50" r="1.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 716 B

+34
View File
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<linearGradient id="mothWing" x1="0" y1="0" x2="0.6" y2="1">
<stop offset="0%" stop-color="#5A5560"/>
<stop offset="60%" stop-color="#3A3540"/>
<stop offset="100%" stop-color="#211E26"/>
</linearGradient>
</defs>
<g stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
<path fill="url(#mothWing)" d="M48,34 C32,16 10,20 10,36 C10,50 30,50 48,48 Z"/>
<path fill="url(#mothWing)" d="M52,34 C68,16 90,20 90,36 C90,50 70,50 52,48 Z"/>
<path fill="url(#mothWing)" d="M47,49 C35,52 20,56 22,70 C24,80 42,78 47,62 Z"/>
<path fill="url(#mothWing)" d="M53,49 C65,52 80,56 78,70 C76,80 58,78 53,62 Z"/>
</g>
<!-- zlověstné oční skvrny -->
<g fill="#C9302C" stroke="#3a0d0b" stroke-width="1">
<circle cx="24" cy="34" r="4"/>
<circle cx="76" cy="34" r="4"/>
</g>
<g fill="#1a0605">
<circle cx="24" cy="34" r="1.6"/>
<circle cx="76" cy="34" r="1.6"/>
</g>
<!-- huňatá tykadla -->
<g stroke="#15121A" stroke-width="1.8" fill="none" stroke-linecap="round">
<path d="M49,30 C44,20 39,16 34,14"/>
<path d="M51,30 C56,20 61,16 66,14"/>
</g>
<!-- tělo -->
<g fill="#15121A" stroke="none">
<rect x="47" y="26" width="6" height="46" rx="3"/>
<circle cx="50" cy="26" r="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- vlákno -->
<line x1="50" y1="0" x2="50" y2="30" stroke="#8A8A8A" stroke-width="1.5"/>
<g stroke="#15121A" stroke-width="3" fill="none" stroke-linecap="round">
<!-- nohy -->
<path d="M40,52 C24,44 18,52 12,46"/>
<path d="M40,58 C24,58 16,66 10,64"/>
<path d="M40,64 C26,70 20,80 12,82"/>
<path d="M40,70 C30,80 28,90 22,94"/>
<path d="M60,52 C76,44 82,52 88,46"/>
<path d="M60,58 C76,58 84,66 90,64"/>
<path d="M60,64 C74,70 80,80 88,82"/>
<path d="M60,70 C70,80 72,90 78,94"/>
</g>
<!-- tělo -->
<g stroke="#15121A" stroke-width="1.5">
<ellipse cx="50" cy="46" rx="11" ry="9" fill="#2B2733"/>
<ellipse cx="50" cy="64" rx="15" ry="16" fill="#211E26"/>
<!-- přesýpací hodiny (varovná značka) -->
<path d="M50,58 l5,6 l-5,6 l-5,-6 z" fill="#C9302C" stroke="none"/>
</g>
<!-- oči -->
<g fill="#F5C518">
<circle cx="46" cy="44" r="1.6"/>
<circle cx="54" cy="44" r="1.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+81
View File
@@ -73,6 +73,17 @@
} }
} }
// Č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));
animation: butterfly-flap 0.5s ease-in-out infinite, butterfly-moth-pulse 1.6s ease-in-out infinite;
}
@keyframes butterfly-moth-pulse {
0%, 100% { filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.5)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
50% { filter: drop-shadow(0 0 9px rgba(150, 40, 40, 0.8)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
}
// --- Havěť (ptáci a vosy) ---------------------------------------------------- // --- Havěť (ptáci a vosy) ----------------------------------------------------
.butterfly-critter { .butterfly-critter {
@@ -93,6 +104,42 @@
cursor: crosshair; cursor: crosshair;
touch-action: none; touch-action: none;
} }
// Netopýr jde zahnat klikáním
&.bat {
pointer-events: auto;
cursor: crosshair;
touch-action: none;
z-index: 5;
filter: drop-shadow(0 0 6px rgba(60, 40, 90, 0.8));
}
&.hit {
animation: butterfly-stalker-hit 0.18s ease-out;
}
}
// Pavučina přes síťku blokuje chytání, dokud ji hráč nestrhne
.butterfly-web {
position: fixed;
top: 0;
left: 0;
width: 120px;
height: 120px;
background-image: url(/spider.svg);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: auto;
cursor: crosshair;
touch-action: none;
z-index: 6;
opacity: 0.9;
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.4));
&.hit {
animation: butterfly-stalker-hit 0.15s ease-out;
}
} }
// --- Síťka na chytání -------------------------------------------------------- // --- Síťka na chytání --------------------------------------------------------
@@ -139,6 +186,11 @@
animation: butterfly-net-stung 0.4s ease-in-out; animation: butterfly-net-stung 0.4s ease-in-out;
} }
// Záblesk při chycení černé můry (chyba)
&.moth-hit {
animation: butterfly-net-moth 0.4s ease-out;
}
// Omráčená síťka (po žihnutí vosou) po celou dobu, kdy nejde chytat. // Omráčená síťka (po žihnutí vosou) po celou dobu, kdy nejde chytat.
// Má přednost před grabbed kurzorem. // Má přednost před grabbed kurzorem.
&.stunned { &.stunned {
@@ -215,6 +267,17 @@
font-size: 1.35rem; font-size: 1.35rem;
text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3); text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3);
} }
&.moth {
color: #c92a2a;
font-size: 1.35rem;
text-shadow: 0 0 6px rgba(120, 40, 160, 0.7), 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
@keyframes butterfly-net-moth {
0%, 100% { filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1); }
30% { filter: drop-shadow(0 0 9px rgba(150, 40, 160, 0.9)) brightness(0.8); }
} }
.butterfly-tear-fx { .butterfly-tear-fx {
@@ -255,6 +318,24 @@
pointer-events: none; pointer-events: none;
} }
.butterfly-shop-open {
pointer-events: auto;
align-self: flex-start;
padding: 6px 12px;
border: none;
border-radius: 999px;
background: #f59e1e;
color: #fff;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: background 0.15s ease, transform 0.08s ease;
&:hover { background: #e8890a; }
&:active { transform: scale(0.96); }
}
.butterfly-counter { .butterfly-counter {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
+301 -20
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useCallback, useState } from 'react'; import React, { useEffect, useRef, useCallback, useState } from 'react';
import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats'; import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats';
import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal'; import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal';
import ButterflyShopModal from './components/modals/ButterflyShopModal';
// Barevné varianty motýlů (soubory v public/, referencované root-absolutně) // Barevné varianty motýlů (soubory v public/, referencované root-absolutně)
const BUTTERFLY_VARIANTS = [ const BUTTERFLY_VARIANTS = [
@@ -12,6 +13,8 @@ const BUTTERFLY_VARIANTS = [
const GOLDEN_VARIANT = '/butterfly-golden.svg'; const GOLDEN_VARIANT = '/butterfly-golden.svg';
const GOLDEN_CHANCE = 0.015; // ~1,5 % motýlů je zlatých (vzácnější) 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_NORMAL = '/butterfly-net.svg';
const NET_GOLDEN = '/butterfly-net-golden.svg'; const NET_GOLDEN = '/butterfly-net-golden.svg';
@@ -20,8 +23,11 @@ const NET_TORN = '/butterfly-net-torn.svg';
// Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts) // Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts)
const PREMIUM_DURATION_MS = 60_000; const PREMIUM_DURATION_MS = 60_000;
export const REPAIR_COST = 20; export const REPAIR_COST = 20;
export const REPELLENT_COST = 60; export const REPELLENT_COST = 70;
const GOLD_VALUE = 25; 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;
// „Bossové" (zloděj / housenka) // „Bossové" (zloděj / housenka)
const THIEF_HP = 22; const THIEF_HP = 22;
@@ -43,10 +49,23 @@ interface ButterflyData {
x: number; y: number; x: number; y: number;
dir: number; speed: number; size: number; dir: number; speed: number; size: number;
golden: boolean; golden: boolean;
/** Černá můra chycení stojí úlovky (nepočítá se jako úlovek) */
moth: boolean;
bobFreq1: number; bobPhase1: number; bobAmp1: number; bobFreq1: number; bobPhase1: number; bobAmp1: number;
bobFreq2: number; bobPhase2: number; bobAmp2: 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. */ /** Létající havěť (pták nebo vosa) nalétává na síťku. */
interface CritterData { interface CritterData {
el: HTMLDivElement; el: HTMLDivElement;
@@ -79,15 +98,19 @@ interface SceneCallbacks {
onCatch: (golden: boolean) => void; onCatch: (golden: boolean) => void;
getCoins: () => number; getCoins: () => number;
getCaught: () => number; getCaught: () => number;
getNetUpgrade: () => number;
onTornChange: (torn: boolean) => void; onTornChange: (torn: boolean) => void;
onCombo: (count: number) => void; onCombo: (count: number) => void;
onSting: () => void; onSting: () => void;
onSwatWasp: () => void; onSwatWasp: () => void;
onMothCaught: () => void;
onRobbery: () => void; onRobbery: () => void;
onDefeatThief: () => void; onDefeatThief: () => void;
onCaterpillarAte: () => void; onCaterpillarAte: () => void;
onDefeatCaterpillar: () => void; onDefeatCaterpillar: () => void;
onStalkerAppear: (type: 'thief' | 'caterpillar') => void; onStalkerAppear: (type: 'thief' | 'caterpillar') => void;
onBatShooed: () => void;
onWebCleared: () => void;
} }
interface FlyingButterfliesProps { interface FlyingButterfliesProps {
@@ -118,6 +141,7 @@ class ButterflyScene {
private netX: number = 0; private netX: number = 0;
private netY: number = 0; private netY: number = 0;
private cb: SceneCallbacks; private cb: SceneCallbacks;
private handleVisibility: () => void;
private torn: boolean = false; private torn: boolean = false;
private premiumUntil: number = 0; private premiumUntil: number = 0;
@@ -126,6 +150,13 @@ class ButterflyScene {
private nextCatchAt: number = 0; private nextCatchAt: number = 0;
private nextStalkerAt: number = 1800; // první možný boss ~30 s 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;
private comboCount: number = 0; private comboCount: number = 0;
private lastCatchTs: number = 0; private lastCatchTs: number = 0;
private comboTimer: number = 0; private comboTimer: number = 0;
@@ -152,6 +183,15 @@ class ButterflyScene {
private static readonly STALKER_MIN_GAP = 3600; // 60 s private static readonly STALKER_MIN_GAP = 3600; // 60 s
private static readonly STALKER_MAX_GAP = 9000; // 150 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) { constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) {
this.viewport = el; this.viewport = el;
this.world = document.createElement('div'); this.world = document.createElement('div');
@@ -166,11 +206,22 @@ class ButterflyScene {
this.width = this.viewport.offsetWidth; this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight; 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 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 { return this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL; }
private get catchRadius(): number { return this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; } 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 hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; }
private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; } private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; }
@@ -189,14 +240,23 @@ class ButterflyScene {
b.bobPhase2 = Math.random() * Math.PI * 2; b.bobPhase2 = Math.random() * Math.PI * 2;
b.bobAmp2 = Math.random() * 0.6 + 0.4; b.bobAmp2 = Math.random() * 0.6 + 0.4;
b.golden = Math.random() < GOLDEN_CHANCE; // Nejdřív můra, jinak vzácně zlatý, jinak běžná varianta
if (b.golden) { 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.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
b.sprite.classList.remove('moth');
b.sprite.classList.add('golden'); b.sprite.classList.add('golden');
b.size = Math.random() * 0.5 + 1.0; b.size = Math.random() * 0.5 + 1.0;
} else { } else {
b.sprite.style.backgroundImage = `url(${this.variants[Math.floor(Math.random() * this.variants.length)]})`; b.sprite.style.backgroundImage = `url(${this.variants[Math.floor(Math.random() * this.variants.length)]})`;
b.sprite.classList.remove('golden'); b.sprite.classList.remove('golden');
b.sprite.classList.remove('moth');
} }
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`;
}; };
@@ -267,11 +327,28 @@ class ButterflyScene {
this.net.classList.add('catch-pop'); 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;
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 => { private registerCatch = (b: ButterflyData, atX: number, atY: number): void => {
this.cb.onCatch(b.golden); this.cb.onCatch(b.golden);
this.spawnFx(b.golden ? `+${GOLD_VALUE}` : '+1', b.golden ? 'butterfly-catch-fx golden' : 'butterfly-catch-fx', atX, atY); this.spawnFx(b.golden ? `+${GOLD_VALUE}` : '+1', b.golden ? 'butterfly-catch-fx golden' : 'butterfly-catch-fx', atX, atY);
this.resetButterfly(b); this.resetButterfly(b);
this.nextCatchAt = this.timer + ButterflyScene.CATCH_COOLDOWN;
const now = Date.now(); const now = Date.now();
this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1; this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1;
@@ -285,14 +362,14 @@ class ButterflyScene {
// Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna) // Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna)
private checkCatches = (): void => { private checkCatches = (): void => {
if (this.torn || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return; if (this.torn || this.webbed || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
const r2 = this.catchRadius * this.catchRadius; const r2 = this.catchRadius * this.catchRadius;
const half = ButterflyScene.BASE_SIZE / 2; const half = ButterflyScene.BASE_SIZE / 2;
for (const b of this.butterflies) { for (const b of this.butterflies) {
const dx = (b.x + half * b.size) - this.netX; const dx = (b.x + half * b.size) - this.netX;
const dy = (b.y + half * b.size) - this.netY; const dy = (b.y + half * b.size) - this.netY;
if (dx * dx + dy * dy < r2) { if (dx * dx + dy * dy < r2) {
this.registerCatch(b, this.netX, this.netY); this.catchOne(b, this.netX, this.netY);
this.popNet(); this.popNet();
return; // jen jeden za cooldown return; // jen jeden za cooldown
} }
@@ -300,7 +377,7 @@ class ButterflyScene {
}; };
private runMagnet = (): void => { private runMagnet = (): void => {
if (this.torn || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return; if (this.torn || this.webbed || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return; if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return;
this.lastMagnetAt = this.timer; this.lastMagnetAt = this.timer;
const half = ButterflyScene.BASE_SIZE / 2; const half = ButterflyScene.BASE_SIZE / 2;
@@ -312,7 +389,7 @@ class ButterflyScene {
const d2 = dx * dx + dy * dy; const d2 = dx * dx + dy * dy;
if (d2 < nearestD2) { nearestD2 = d2; nearest = b; } if (d2 < nearestD2) { nearestD2 = d2; nearest = b; }
} }
if (nearest) { this.registerCatch(nearest, this.netX, this.netY); this.popNet(); } if (nearest) { this.catchOne(nearest, this.netX, this.netY); this.popNet(); }
}; };
public activatePremiumNet = (): void => { public activatePremiumNet = (): void => {
@@ -506,10 +583,19 @@ class ButterflyScene {
this.nextStalkerAt = this.timer + 600; // zkus to znovu za ~10 s this.nextStalkerAt = this.timer + 600; // zkus to znovu za ~10 s
return; return;
} }
const type = options[Math.floor(Math.random() * options.length)]; // 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); this.spawnStalker(type);
this.nextStalkerAt = this.timer + ButterflyScene.STALKER_MIN_GAP 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)); + 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 => { private spawnStalker = (type: 'thief' | 'caterpillar'): void => {
@@ -602,6 +688,137 @@ class ButterflyScene {
} }
}; };
// --- 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.world.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.viewport.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 ------------------------------------------------------ // --- Init / render ------------------------------------------------------
private initNet = (): void => { private initNet = (): void => {
@@ -627,7 +844,7 @@ class ButterflyScene {
sprite.className = 'butterfly-sprite'; sprite.className = 'butterfly-sprite';
el.appendChild(sprite); el.appendChild(sprite);
const b: ButterflyData = { const b: ButterflyData = {
el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false, el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false, moth: false,
bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0, bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0,
}; };
this.resetButterfly(b); this.resetButterfly(b);
@@ -637,6 +854,7 @@ class ButterflyScene {
this.viewport.appendChild(this.world); this.viewport.appendChild(this.world);
if (this.netEnabled) this.initNet(); if (this.netEnabled) this.initNet();
window.addEventListener('resize', this.handleResize); window.addEventListener('resize', this.handleResize);
document.addEventListener('visibilitychange', this.handleVisibility);
}; };
private wasPremium: boolean = false; private wasPremium: boolean = false;
@@ -654,6 +872,10 @@ class ButterflyScene {
this.updateBirds(); this.updateBirds();
this.trySpawnStalker(); this.trySpawnStalker();
this.updateStalker(); this.updateStalker();
this.trySpawnBat();
this.updateBat();
this.trySpawnWeb();
this.updateWeb();
const stunned = this.timer < this.stunUntil; const stunned = this.timer < this.stunUntil;
if (stunned !== this.wasStunned) { if (stunned !== this.wasStunned) {
@@ -674,6 +896,8 @@ class ButterflyScene {
if (this.comboTimer) window.clearTimeout(this.comboTimer); if (this.comboTimer) window.clearTimeout(this.comboTimer);
for (const w of this.wasps) w.cleanup?.(); for (const w of this.wasps) w.cleanup?.();
this.removeStalker(); this.removeStalker();
this.removeBat();
this.removeWeb();
if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world); if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world);
this.net.removeEventListener('pointerdown', this.onPointerDown); this.net.removeEventListener('pointerdown', this.onPointerDown);
window.removeEventListener('pointermove', this.onPointerMove); window.removeEventListener('pointermove', this.onPointerMove);
@@ -681,6 +905,7 @@ class ButterflyScene {
window.removeEventListener('pointercancel', this.onPointerUp); window.removeEventListener('pointercancel', this.onPointerUp);
if (this.net.parentNode) this.net.parentNode.removeChild(this.net); if (this.net.parentNode) this.net.parentNode.removeChild(this.net);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
document.removeEventListener('visibilitychange', this.handleVisibility);
}; };
} }
@@ -703,6 +928,7 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
const [combo, setCombo] = useState(0); const [combo, setCombo] = useState(0);
const [flash, setFlash] = useState<string | null>(null); const [flash, setFlash] = useState<string | null>(null);
const [leaderboardOpen, setLeaderboardOpen] = useState(false); const [leaderboardOpen, setLeaderboardOpen] = useState(false);
const [shopOpen, setShopOpen] = useState(false);
const [nowTs, setNowTs] = useState(() => Date.now()); const [nowTs, setNowTs] = useState(() => Date.now());
const flashTimer = useRef<number | null>(null); const flashTimer = useRef<number | null>(null);
@@ -726,23 +952,29 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
const { const {
stats, displayCaught, coinsRef, reportCatch, repair, stats, displayCaught, coinsRef, reportCatch, repair,
killWasp, buyRepellent, reportTear, reportRobbery, defeatThief, killWasp, buyRepellent, reportTear, reportRobbery, defeatThief,
caterpillarAte, defeatCaterpillar, caterpillarAte, defeatCaterpillar, mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade,
} = useButterflyStats(handleReward); } = useButterflyStats(handleReward);
const netUpgradeRef = useRef(0);
netUpgradeRef.current = stats?.upgrades?.net ?? 0;
const noop = () => { };
const callbacksRef = useRef<SceneCallbacks>({ const callbacksRef = useRef<SceneCallbacks>({
onCatch: () => { }, getCoins: () => 0, getCaught: () => 0, onTornChange: () => { }, onCatch: noop, getCoins: () => 0, getCaught: () => 0, getNetUpgrade: () => 0, onTornChange: noop,
onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { }, onCombo: noop, onSting: noop, onSwatWasp: noop, onMothCaught: noop,
onRobbery: () => { }, onDefeatThief: () => { }, onCaterpillarAte: () => { }, onRobbery: noop, onDefeatThief: noop, onCaterpillarAte: noop,
onDefeatCaterpillar: () => { }, onStalkerAppear: () => { }, onDefeatCaterpillar: noop, onStalkerAppear: noop, onBatShooed: noop, onWebCleared: noop,
}); });
callbacksRef.current = { callbacksRef.current = {
onCatch: (golden) => reportCatch(golden), onCatch: (golden) => reportCatch(golden),
getCoins: () => coinsRef.current, getCoins: () => coinsRef.current,
getCaught: () => stats?.caught ?? 0, getCaught: () => stats?.caught ?? 0,
getNetUpgrade: () => netUpgradeRef.current,
onTornChange: (t) => { if (t) void reportTear(); }, onTornChange: (t) => { if (t) void reportTear(); },
onCombo: (c) => setCombo(c), onCombo: (c) => setCombo(c),
onSting: () => showFlash('🐝 Au! Vosa tě žihla síťka teď 5 s nechytá. Zaklikej vosy!'), onSting: () => showFlash('🐝 Au! Vosa tě žihla síťka teď 5 s nechytá. Zaklikej vosy!'),
onSwatWasp: () => void killWasp(), 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.'); }, 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čí.'); }, 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ů!'); }, onCaterpillarAte: () => { void caterpillarAte(); showFlash('🐛 Housenka ti sežrala část úlovků!'); },
@@ -750,6 +982,8 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
onStalkerAppear: (type) => showFlash(type === 'thief' onStalkerAppear: (type) => showFlash(type === 'thief'
? '🦹 Zloděj! Míří k tvým mincím zaklikej ho (chce to hodně ran)!' ? '🦹 Zloděj! Míří k tvým mincím zaklikej ho (chce to hodně ran)!'
: '🐛 Housenka! Plíží se k tvým úlovkům zaklikej ji!'), : '🐛 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.'),
}; };
const initialize = useCallback(() => { const initialize = useCallback(() => {
@@ -758,15 +992,19 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
onCatch: (g) => callbacksRef.current.onCatch(g), onCatch: (g) => callbacksRef.current.onCatch(g),
getCoins: () => callbacksRef.current.getCoins(), getCoins: () => callbacksRef.current.getCoins(),
getCaught: () => callbacksRef.current.getCaught(), getCaught: () => callbacksRef.current.getCaught(),
getNetUpgrade: () => callbacksRef.current.getNetUpgrade(),
onTornChange: (t) => callbacksRef.current.onTornChange(t), onTornChange: (t) => callbacksRef.current.onTornChange(t),
onCombo: (c) => callbacksRef.current.onCombo(c), onCombo: (c) => callbacksRef.current.onCombo(c),
onSting: () => callbacksRef.current.onSting(), onSting: () => callbacksRef.current.onSting(),
onSwatWasp: () => callbacksRef.current.onSwatWasp(), onSwatWasp: () => callbacksRef.current.onSwatWasp(),
onMothCaught: () => callbacksRef.current.onMothCaught(),
onRobbery: () => callbacksRef.current.onRobbery(), onRobbery: () => callbacksRef.current.onRobbery(),
onDefeatThief: () => callbacksRef.current.onDefeatThief(), onDefeatThief: () => callbacksRef.current.onDefeatThief(),
onCaterpillarAte: () => callbacksRef.current.onCaterpillarAte(), onCaterpillarAte: () => callbacksRef.current.onCaterpillarAte(),
onDefeatCaterpillar: () => callbacksRef.current.onDefeatCaterpillar(), onDefeatCaterpillar: () => callbacksRef.current.onDefeatCaterpillar(),
onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t), onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t),
onBatShooed: () => callbacksRef.current.onBatShooed(),
onWebCleared: () => callbacksRef.current.onWebCleared(),
}); });
sceneRef.current.init(); sceneRef.current.init();
sceneRef.current.render(); sceneRef.current.render();
@@ -818,10 +1056,34 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
if (ok) sceneRef.current?.repairNetExternally(); if (ok) sceneRef.current?.repairNetExternally();
}, [repair]); }, [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 () => { const onRepellentClick = useCallback(async () => {
const ok = await buyRepellent(); flashBuy(await buyRepellent(), '🦅🚫 Plašič ptáků koupen ptáci na chvíli zmizeli.');
if (ok) showFlash('🦅🚫 Plašič ptáků koupen ptáci na chvíli zmizeli.'); }, [buyRepellent, flashBuy]);
}, [buyRepellent, showFlash]);
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 ( return (
<> <>
@@ -899,7 +1161,26 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
)} )}
</div> </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} /> <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> </div>
)} )}
</> </>
@@ -43,7 +43,38 @@
.butterfly-leaderboard-heading { .butterfly-leaderboard-heading {
font-weight: 700; font-weight: 700;
margin: 4px 0 8px; margin: 12px 0 8px;
}
.butterfly-achievements {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
.butterfly-achievement {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
font-size: 0.78rem;
opacity: 0.55;
filter: grayscale(1);
&.unlocked {
opacity: 1;
filter: none;
background: rgba(255, 203, 61, 0.18);
}
.ach-icon { flex: 0 0 auto; }
.ach-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.butterfly-leaderboard { .butterfly-leaderboard {
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Modal } from "react-bootstrap"; import { Modal } from "react-bootstrap";
import { ButterflyLeaderboardEntry, ButterflyStats, getButterflyLeaderboard } from "../../../../types"; import {
ButterflyLeaderboardEntry, ButterflyStats, ButterflyAchievement,
getButterflyLeaderboard, getButterflyAchievements,
} from "../../../../types";
import { useAuth } from "../../context/auth"; import { useAuth } from "../../context/auth";
import "./ButterflyLeaderboardModal.scss"; import "./ButterflyLeaderboardModal.scss";
@@ -12,20 +15,25 @@ type Props = {
const MEDALS = ['🥇', '🥈', '🥉']; const MEDALS = ['🥇', '🥈', '🥉'];
/** Modální dialog s osobními statistikami a týmovým žebříčkem chytačů motýlů. */ /** Modální dialog s osobními statistikami, achievementy a týmovým žebříčkem. */
export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) { export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) {
const auth = useAuth(); const auth = useAuth();
const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>(); const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>();
const [achievements, setAchievements] = useState<ButterflyAchievement[]>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
getButterflyLeaderboard({ query: { limit: 20 } }) Promise.all([
.then(res => { if (!cancelled) setEntries(res.data ?? []); }) getButterflyLeaderboard({ query: { limit: 20 } }).then(r => r.data ?? []).catch(() => []),
.catch(() => { if (!cancelled) setEntries([]); }) getButterflyAchievements().then(r => r.data ?? []).catch(() => []),
.finally(() => { if (!cancelled) setLoading(false); }); ]).then(([lb, ach]) => {
if (cancelled) return;
setEntries(lb);
setAchievements(ach);
}).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isOpen]); }, [isOpen]);
@@ -52,6 +60,24 @@ export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }:
</div> </div>
)} )}
{achievements && achievements.length > 0 && (
<>
<h6 className="butterfly-leaderboard-heading">Odznaky</h6>
<div className="butterfly-achievements">
{achievements.map(a => (
<div
key={a.id}
className={a.unlocked ? 'butterfly-achievement unlocked' : 'butterfly-achievement'}
title={a.description}
>
<span className="ach-icon">{a.unlocked ? '🏅' : '🔒'}</span>
<span className="ach-title">{a.title}</span>
</div>
))}
</div>
</>
)}
<h6 className="butterfly-leaderboard-heading">Žebříček týmu</h6> <h6 className="butterfly-leaderboard-heading">Žebříček týmu</h6>
{loading && <p className="text-center text-muted mb-0">Načítám žebříček</p>} {loading && <p className="text-center text-muted mb-0">Načítám žebříček</p>}
{!loading && entries && entries.length === 0 && ( {!loading && entries && entries.length === 0 && (
@@ -0,0 +1,106 @@
.shop-balance {
font-size: 0.9rem;
font-weight: 700;
color: #e0a200;
margin-left: 8px;
}
.shop-heading {
font-weight: 700;
margin: 14px 0 8px;
}
.butterfly-shop-daily {
padding: 10px 12px;
border-radius: 12px;
background: rgba(28, 126, 214, 0.12);
border: 1px solid rgba(28, 126, 214, 0.35);
margin-bottom: 12px;
.daily-title {
font-weight: 700;
font-size: 0.9rem;
margin-bottom: 6px;
}
.daily-bar {
height: 8px;
background: rgba(0, 0, 0, 0.1);
border-radius: 999px;
overflow: hidden;
}
.daily-bar-inner {
height: 100%;
background: linear-gradient(90deg, #4dabf7, #1c7ed6);
border-radius: 999px;
transition: width 0.3s ease;
}
.daily-status {
margin-top: 5px;
font-size: 0.8rem;
color: #495057;
font-variant-numeric: tabular-nums;
}
}
.butterfly-shop-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.butterfly-shop-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 10px;
background: #fff;
cursor: pointer;
text-align: left;
transition: background 0.12s ease, transform 0.08s ease;
&:hover:not(:disabled) {
background: #f1f3f5;
}
&:active:not(:disabled) {
transform: scale(0.99);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.shop-item-label {
display: flex;
flex-direction: column;
font-weight: 600;
font-size: 0.9rem;
}
.shop-item-lvl {
display: inline;
color: #1c7ed6;
font-weight: 700;
}
.shop-item-desc {
font-weight: 400;
font-size: 0.74rem;
color: #868e96;
}
.shop-item-cost {
flex: 0 0 auto;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #e0a200;
white-space: nowrap;
}
}
@@ -0,0 +1,113 @@
import { Modal } from "react-bootstrap";
import { ButterflyStats } from "../../../../types";
import "./ButterflyShopModal.scss";
type UpgradeId = 'net' | 'scarecrow' | 'reinforced';
type Props = {
isOpen: boolean;
onClose: () => void;
stats?: ButterflyStats;
onBuyPremium: () => void;
onWaspSpray: () => void;
onRepellent: () => void;
onBuyInsurance: () => void;
onBuyUpgrade: (item: UpgradeId) => void;
};
// Zrcadlí serverové hodnoty (server/src/butterflies.ts)
const PREMIUM_BUY_COST = 40;
const WASP_SPRAY_COST = 25;
const REPELLENT_COST = 70;
const INSURANCE_COST = 50;
const UPGRADE_BASE: Record<UpgradeId, number> = { net: 100, scarecrow: 120, reinforced: 150 };
const UPGRADE_MAX: Record<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
const UPGRADE_META: Record<UpgradeId, { icon: string; title: string; desc: string }> = {
net: { icon: '🥅', title: 'Větší síťka', desc: 'Zvětší dosah chytání' },
scarecrow: { icon: '🐦‍⬛', title: 'Strašák', desc: 'Ptáci přibývají pomaleji' },
reinforced: { icon: '🧵', title: 'Zpevněná síťka', desc: 'Rychlejší samo-zašití' },
};
function upgradeCost(item: UpgradeId, level: number): number {
return UPGRADE_BASE[item] * Math.pow(2, level);
}
/** Obchod: spotřební pomůcky a trvalá vylepšení za mince. */
export default function ButterflyShopModal({
isOpen, onClose, stats, onBuyPremium, onWaspSpray, onRepellent, onBuyInsurance, onBuyUpgrade,
}: Readonly<Props>) {
const coins = stats?.coins ?? 0;
const insuranceActive = !!stats && stats.insuranceUntil > Date.now();
const consumable = (label: string, cost: number, onBuy: () => void, extra?: string) => (
<button
type="button"
className="butterfly-shop-item"
onClick={onBuy}
disabled={coins < cost}
>
<span className="shop-item-label">{label}{extra ? ` ${extra}` : ''}</span>
<span className="shop-item-cost">{cost} 🪙</span>
</button>
);
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>🛒 Obchod <span className="shop-balance">{coins} 🪙</span></Modal.Title>
</Modal.Header>
<Modal.Body>
{stats?.daily && (
<div className="butterfly-shop-daily">
<div className="daily-title">🎯 Denní úkol: {stats.daily.title}</div>
<div className="daily-bar">
<div
className="daily-bar-inner"
style={{ width: `${Math.min(100, Math.round((stats.daily.progress / stats.daily.target) * 100))}%` }}
/>
</div>
<div className="daily-status">
{stats.daily.done
? `✅ Splněno (+${stats.daily.reward} 🪙)`
: `${stats.daily.progress}/${stats.daily.target} · odměna ${stats.daily.reward} 🪙`}
</div>
</div>
)}
<h6 className="shop-heading">Pomůcky</h6>
<div className="butterfly-shop-list">
{consumable('🪄 Prémiová síťka (60 s)', PREMIUM_BUY_COST, onBuyPremium)}
{consumable('💨 Vosí sprej (vyhubí vosy)', WASP_SPRAY_COST, onWaspSpray)}
{consumable('🦅🚫 Plašič ptáků', REPELLENT_COST, onRepellent)}
{consumable('🛡️ Pojistka proti zloději', INSURANCE_COST, onBuyInsurance, insuranceActive ? '(aktivní)' : '')}
</div>
<h6 className="shop-heading">Trvalá vylepšení</h6>
<div className="butterfly-shop-list">
{(Object.keys(UPGRADE_META) as UpgradeId[]).map(item => {
const level = stats?.upgrades?.[item] ?? 0;
const max = UPGRADE_MAX[item];
const maxed = level >= max;
const cost = upgradeCost(item, level);
const meta = UPGRADE_META[item];
return (
<button
key={item}
type="button"
className="butterfly-shop-item upgrade"
onClick={() => onBuyUpgrade(item)}
disabled={maxed || coins < cost}
>
<span className="shop-item-label">
{meta.icon} {meta.title} <span className="shop-item-lvl">{level}/{max}</span>
<span className="shop-item-desc">{meta.desc}</span>
</span>
<span className="shop-item-cost">{maxed ? 'MAX' : `${cost} 🪙`}</span>
</button>
);
})}
</div>
</Modal.Body>
</Modal>
);
}
+44 -4
View File
@@ -11,8 +11,19 @@ import {
defeatThief as defeatThiefApi, defeatThief as defeatThiefApi,
reportCaterpillarAte as caterpillarAteApi, reportCaterpillarAte as caterpillarAteApi,
defeatCaterpillar as defeatCaterpillarApi, defeatCaterpillar as defeatCaterpillarApi,
reportMothHit as mothHitApi,
buyPremiumNet as buyPremiumApi,
buyWaspSpray as waspSprayApi,
buyInsurance as buyInsuranceApi,
buyUpgrade as buyUpgradeApi,
} from '../../../types'; } from '../../../types';
/** Druhy trvalých vylepšení v obchodě. */
export type UpgradeItem = 'net' | 'scarecrow' | 'reinforced';
/** Výsledek nákupu: úspěch / nedostatek mincí / jiná chyba (např. starý server). */
export type BuyResult = 'ok' | 'poor' | 'error';
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */ /** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
const FLUSH_DEBOUNCE_MS = 1500; const FLUSH_DEBOUNCE_MS = 1500;
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */ /** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
@@ -108,12 +119,12 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { /* ignore */ } } catch { /* ignore */ }
}, [applyStats]); }, [applyStats]);
const buyRepellent = useCallback(async (): Promise<boolean> => { const buyRepellent = useCallback(async (): Promise<BuyResult> => {
try { try {
const res = await buyRepellentApi(); const res = await buyRepellentApi();
if (res.data) { applyStats(res.data); return true; } if (res.data) { applyStats(res.data); return 'ok'; }
return false; return res.response?.status === 402 ? 'poor' : 'error';
} catch { return false; } } catch { return 'error'; }
}, [applyStats]); }, [applyStats]);
const reportTear = useCallback(async () => { const reportTear = useCallback(async () => {
@@ -147,6 +158,34 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
} catch { /* ignore */ } } catch { /* ignore */ }
}, [applyStats]); }, [applyStats]);
/** Hráč omylem chytil černou můru server sníží úlovky. */
const mothHit = useCallback(async () => {
try {
const res = await mothHitApi();
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'; }
return res.response?.status === 402 ? 'poor' : 'error';
}, [applyStats]);
/** Obchod: koupě spotřebního předmětu / vylepšení. */
const buyPremium = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await buyPremiumApi()); } catch { return 'error'; }
}, [buyResult]);
const waspSpray = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await waspSprayApi()); } catch { return 'error'; }
}, [buyResult]);
const buyInsurance = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await buyInsuranceApi()); } catch { return 'error'; }
}, [buyResult]);
const buyUpgrade = useCallback(async (item: UpgradeItem): Promise<BuyResult> => {
try { return buyResult(await buyUpgradeApi({ body: { item } })); } catch { return 'error'; }
}, [buyResult]);
/** Hráč porazil housenku odměna a statistika. */ /** Hráč porazil housenku odměna a statistika. */
const defeatCaterpillar = useCallback(async () => { const defeatCaterpillar = useCallback(async () => {
try { try {
@@ -203,5 +242,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
return { return {
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent, stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar, reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade,
}; };
} }
+8
View File
@@ -0,0 +1,8 @@
[
"Nový obchod (tlačítko 🛒): utrácej mince za pomůcky i trvalá vylepšení větší síťka, strašák na ptáky, zpevněná síťka, prémiová síťka na přání, vosí sprej, plašič a pojistka proti zloději",
"Pozor na černé můry míchají se mezi motýly a když nějakou omylem chytíš (i magnetem), přijdeš o hodně úlovků",
"Ptáci jsou znovu výzva: je jich víc, přibývají rychleji a plašič je dražší a kratší",
"Zloději chodí častěji a lákají je bohatí hráči vyplatí se koupit pojistku",
"Denní úkol za odměnu mincí a odznaky (achievementy) za milníky najdeš je po kliknutí na počítadlo",
"V noci občas přilétne netopýr, který loví motýly, a pavouk umí zamotat síťku pavučinou odeženeš je klikáním"
]
+229 -11
View File
@@ -40,6 +40,15 @@ interface StoredButterflyStats {
catchWindowStart: number; catchWindowStart: number;
/** Počet započtených úlovků v aktuálním okně */ /** Počet započtených úlovků v aktuálním okně */
catchWindowCount: number; catchWindowCount: number;
/** Kolikrát hráč omylem chytil černou můru */
mothsHit: number;
/** Trvalá vylepšení z obchodu */
upgrades: { net: number; scarecrow: number; reinforced: number };
/** Do kdy platí pojistka proti zloději (ms epoch), 0 = neaktivní */
insuranceUntil: number;
/** Denní úkol */
daily: { day: string; taskId: string; progress: number; target: number; done: boolean };
} }
const storage = getStorage(); const storage = getStorage();
@@ -62,15 +71,15 @@ export const PREMIUM_MILESTONE = 75;
/** Maximální počet současně poletujících vos */ /** Maximální počet současně poletujících vos */
export const WASP_MAX = 6; export const WASP_MAX = 6;
/** Maximální počet současně poletujících ptáků */ /** Maximální počet současně poletujících ptáků */
export const BIRD_MAX = 5; export const BIRD_MAX = 7;
/** Jak často (ms) přibude jedna vosa */ /** Jak často (ms) přibude jedna vosa */
export const WASP_GROWTH_MS = 30_000; export const WASP_GROWTH_MS = 30_000;
/** Jak často (ms) přibude jeden pták */ /** Jak často (ms) přibude jeden pták (základ; zpomaluje strašák) */
export const BIRD_GROWTH_MS = 60_000; export const BIRD_GROWTH_MS = 40_000;
/** Cena plašiče ptáků v mincích */ /** Cena plašiče ptáků v mincích */
export const REPELLENT_COST = 60; export const REPELLENT_COST = 70;
/** Jak dlouho (ms) plašič ptáků drží ptáky pryč */ /** Jak dlouho (ms) plašič ptáků drží ptáky pryč */
export const REPELLENT_DURATION_MS = 120_000; export const REPELLENT_DURATION_MS = 75_000;
/** Za jak dlouho (ms) se protržená síťka sama zašije */ /** Za jak dlouho (ms) se protržená síťka sama zašije */
export const AUTO_REPAIR_MS = 60_000; export const AUTO_REPAIR_MS = 60_000;
/** Jaký podíl mincí ukradne zloděj, když se dostane k penězům (01) */ /** Jaký podíl mincí ukradne zloděj, když se dostane k penězům (01) */
@@ -85,6 +94,55 @@ export const CATERPILLAR_EATS = 20;
export const CATERPILLAR_REWARD = 15; export const CATERPILLAR_REWARD = 15;
/** Kolik zásahů (kliknutí) housenka vydrží */ /** Kolik zásahů (kliknutí) housenka vydrží */
export const CATERPILLAR_HP = 18; export const CATERPILLAR_HP = 18;
/** O kolik úlovků přijdeš, když omylem chytíš černou můru */
export const MOTH_PENALTY = 15;
// --- Obchod: spotřební ---
/** Cena aktivace prémiové síťky na přání */
export const PREMIUM_BUY_COST = 40;
/** Cena vosího spreje (vyhubí všechny vosy) */
export const WASP_SPRAY_COST = 25;
/** Cena pojistky proti zloději */
export const INSURANCE_COST = 50;
/** Jak dlouho (ms) pojistka platí */
export const INSURANCE_DURATION_MS = 300_000;
/** Podíl mincí ukradený zlodějem, když je aktivní pojistka */
export const ROBBERY_FRACTION_INSURED = 0.2;
// --- Obchod: trvalá vylepšení ---
export type UpgradeId = 'net' | 'scarecrow' | 'reinforced';
/** Maximální úrovně jednotlivých vylepšení */
export const UPGRADE_MAX: Record<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
/** Základní cena vylepšení (další úroveň je 2× dražší) */
const UPGRADE_BASE: Record<UpgradeId, number> = { net: 100, scarecrow: 120, reinforced: 150 };
/** Vrátí cenu příští úrovně daného vylepšení (level = aktuální úroveň). */
export function upgradeCost(item: UpgradeId, level: number): number {
return UPGRADE_BASE[item] * Math.pow(2, level);
}
// --- Denní úkoly ---
/** Odměna v mincích za splnění denního úkolu */
export const DAILY_TASK_REWARD = 30;
const DAILY_TASKS: { id: string; target: number }[] = [
{ id: 'catch', target: 60 },
{ id: 'golden', target: 3 },
{ id: 'thief', target: 1 },
{ id: 'wasp', target: 15 },
];
const DAILY_TITLES: Record<string, string> = {
catch: 'Nachytej {n} motýlů',
golden: 'Chyť {n} zlatých motýlů',
thief: 'Poraz {n} zloděje',
wasp: 'Zabij {n} vos',
};
/** Deterministický výběr úkolu podle data (aby byl pro všechny stejný a stabilní). */
function dailyTaskForDay(day: string): { id: string; target: number } {
let h = 0;
for (let i = 0; i < day.length; i++) h = (h * 31 + day.charCodeAt(i)) | 0;
return DAILY_TASKS[Math.abs(h) % DAILY_TASKS.length];
}
/** Od jaké velikosti dávky se začíná počítat kombo bonus */ /** Od jaké velikosti dávky se začíná počítat kombo bonus */
const COMBO_THRESHOLD = 4; const COMBO_THRESHOLD = 4;
@@ -183,6 +241,10 @@ function defaultStats(now: number): StoredButterflyStats {
repellentUntil: 0, netTornUntil: 0, repellentUntil: 0, netTornUntil: 0,
lastWaspAt: now, lastBirdAt: now, lastWaspAt: now, lastBirdAt: now,
catchWindowStart: now, catchWindowCount: 0, catchWindowStart: now, catchWindowCount: 0,
mothsHit: 0,
upgrades: { net: 0, scarecrow: 0, reinforced: 0 },
insuranceUntil: 0,
daily: { day: '', taskId: 'catch', progress: 0, target: 0, done: false },
}; };
} }
@@ -205,9 +267,47 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
lastBirdAt: s.lastBirdAt ?? now, lastBirdAt: s.lastBirdAt ?? now,
catchWindowStart: s.catchWindowStart ?? now, catchWindowStart: s.catchWindowStart ?? now,
catchWindowCount: s.catchWindowCount ?? 0, catchWindowCount: s.catchWindowCount ?? 0,
mothsHit: s.mothsHit ?? 0,
upgrades: {
net: s.upgrades?.net ?? 0,
scarecrow: s.upgrades?.scarecrow ?? 0,
reinforced: s.upgrades?.reinforced ?? 0,
},
insuranceUntil: s.insuranceUntil ?? 0,
daily: s.daily ?? { day: '', taskId: 'catch', progress: 0, target: 0, done: false },
}; };
} }
/** Efektivní interval růstu ptáků (zpomaluje strašák). */
function effectiveBirdGrowth(s: StoredButterflyStats): number {
return Math.round(BIRD_GROWTH_MS * (1 + 0.35 * (s.upgrades?.scarecrow ?? 0)));
}
/** Efektivní doba samo-zašití síťky (zkracuje zpevněná síťka). */
function effectiveAutoRepair(s: StoredButterflyStats): number {
return Math.round(AUTO_REPAIR_MS * (1 - 0.25 * (s.upgrades?.reinforced ?? 0)));
}
/** Zajistí, že denní úkol odpovídá dnešnímu dni (jinak vygeneruje nový). */
function ensureDaily(s: StoredButterflyStats, now: number): void {
const day = formatDate(new Date(now));
if (!s.daily || s.daily.day !== day) {
const def = dailyTaskForDay(day);
s.daily = { day, taskId: def.id, progress: 0, target: def.target, done: false };
}
}
/** Přičte progres dennímu úkolu daného druhu a případně vyplatí odměnu. */
function bumpDaily(s: StoredButterflyStats, kind: string, amount: number): void {
if (s.daily && !s.daily.done && s.daily.taskId === kind && amount > 0) {
s.daily.progress += amount;
if (s.daily.progress >= s.daily.target) {
s.daily.done = true;
s.coins += DAILY_TASK_REWARD;
}
}
}
/** /**
* Nechá v čase „dorůst" škůdce do maxima. Zachovává rozpracovaný čas (posouvá * Nechá v čase „dorůst" škůdce do maxima. Zachovává rozpracovaný čas (posouvá
* razítko jen o spotřebované celé intervaly). Ptáci nerostou, když je aktivní plašič. * razítko jen o spotřebované celé intervaly). Ptáci nerostou, když je aktivní plašič.
@@ -232,10 +332,11 @@ export function growPests(s: StoredButterflyStats, now: number): void {
const repellentActive = s.repellentUntil > now; const repellentActive = s.repellentUntil > now;
if (!repellentActive) { if (!repellentActive) {
if (s.birds < BIRD_MAX) { if (s.birds < BIRD_MAX) {
const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS); const birdGrowth = effectiveBirdGrowth(s);
const add = Math.floor((now - s.lastBirdAt) / birdGrowth);
if (add > 0) { if (add > 0) {
s.birds = Math.min(s.birds + add, BIRD_MAX); s.birds = Math.min(s.birds + add, BIRD_MAX);
s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * BIRD_GROWTH_MS; s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * birdGrowth;
} }
} else { } else {
s.lastBirdAt = now; s.lastBirdAt = now;
@@ -260,8 +361,19 @@ function toDto(s: StoredButterflyStats): ButterflyStats {
birdsScared: s.birdsScared, birdsScared: s.birdsScared,
thievesDefeated: s.thievesDefeated, thievesDefeated: s.thievesDefeated,
caterpillarsDefeated: s.caterpillarsDefeated, caterpillarsDefeated: s.caterpillarsDefeated,
mothsHit: s.mothsHit,
repellentUntil: s.repellentUntil, repellentUntil: s.repellentUntil,
netTornUntil: s.netTornUntil, netTornUntil: s.netTornUntil,
insuranceUntil: s.insuranceUntil,
upgrades: { ...s.upgrades },
daily: {
taskId: s.daily.taskId,
title: (DAILY_TITLES[s.daily.taskId] ?? '{n}').replace('{n}', String(s.daily.target)),
progress: s.daily.progress,
target: s.daily.target,
done: s.daily.done,
reward: DAILY_TASK_REWARD,
},
}; };
} }
@@ -283,6 +395,7 @@ async function mutateUser(
const all = current ?? {}; const all = current ?? {};
const mine = normalize(all[login] ?? defaultStats(now), now); const mine = normalize(all[login] ?? defaultStats(now), now);
growPests(mine, now); growPests(mine, now);
ensureDaily(mine, now);
mutator(mine); mutator(mine);
all[login] = mine; all[login] = mine;
return all; return all;
@@ -347,6 +460,9 @@ export async function recordCatches(login: string, normal: number, golden: numbe
s.goldenCaught += creditGolden; s.goldenCaught += creditGolden;
if (credited > 0) s.lastCatchDay = today; if (credited > 0) s.lastCatchDay = today;
bumpDaily(s, 'catch', credited);
bumpDaily(s, 'golden', creditGolden);
leveledUp = levelForCaught(newCaught) > oldLevel; leveledUp = levelForCaught(newCaught) > oldLevel;
premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE); premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE);
}); });
@@ -377,6 +493,7 @@ export async function killWasp(login: string, now: number = Date.now()): Promise
if (s.wasps > 0) { if (s.wasps > 0) {
s.wasps -= 1; s.wasps -= 1;
s.waspsKilled += 1; s.waspsKilled += 1;
bumpDaily(s, 'wasp', 1);
} }
}); });
return toDto(mine); return toDto(mine);
@@ -407,24 +524,28 @@ export async function buyRepellent(login: string, now: number = Date.now()): Pro
*/ */
export async function reportNetTorn(login: string, now: number = Date.now()): Promise<ButterflyStats> { export async function reportNetTorn(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => { const mine = await mutateUser(login, now, (s) => {
if (s.netTornUntil <= now) s.netTornUntil = now + AUTO_REPAIR_MS; if (s.netTornUntil <= now) s.netTornUntil = now + effectiveAutoRepair(s);
}); });
return toDto(mine); return toDto(mine);
} }
/** Zloděj se dostal k penězům a ukradl podíl mincí. */ /** Zloděj se dostal k penězům a ukradl podíl mincí (méně, když je aktivní pojistka). */
export async function robbery(login: string, now: number = Date.now()): Promise<ButterflyStats> { export async function robbery(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => { const mine = await mutateUser(login, now, (s) => {
s.coins = Math.round(s.coins * (1 - ROBBERY_FRACTION)); const insured = s.insuranceUntil > now;
const fraction = insured ? ROBBERY_FRACTION_INSURED : ROBBERY_FRACTION;
s.coins = Math.round(s.coins * (1 - fraction));
if (insured) s.insuranceUntil = 0; // pojistka se spotřebuje
}); });
return toDto(mine); return toDto(mine);
} }
/** Hráč porazil zloděje malá odměna a statistika. */ /** Hráč porazil zloděje malá odměna, statistika a progres denního úkolu. */
export async function defeatThief(login: string, now: number = Date.now()): Promise<ButterflyStats> { export async function defeatThief(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => { const mine = await mutateUser(login, now, (s) => {
s.thievesDefeated += 1; s.thievesDefeated += 1;
s.coins += THIEF_REWARD; s.coins += THIEF_REWARD;
bumpDaily(s, 'thief', 1);
}); });
return toDto(mine); return toDto(mine);
} }
@@ -446,6 +567,103 @@ export async function defeatCaterpillar(login: string, now: number = Date.now())
return toDto(mine); return toDto(mine);
} }
/** Hráč omylem chytil černou můru přijde o část úlovků. */
export async function mothHit(login: string, now: number = Date.now()): Promise<ButterflyStats> {
const mine = await mutateUser(login, now, (s) => {
s.caught = Math.max(0, s.caught - MOTH_PENALTY);
s.mothsHit += 1;
});
return toDto(mine);
}
// --- Obchod ------------------------------------------------------------------
/** Koupí okamžitou aktivaci prémiové síťky (efekt řídí klient). */
export async function buyPremium(login: string, now: number = Date.now()): Promise<ButterflyStats> {
let failed = false;
const mine = await mutateUser(login, now, (s) => {
if (s.coins < PREMIUM_BUY_COST) { failed = true; return; }
s.coins -= PREMIUM_BUY_COST;
});
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na prémiovou síťku');
return toDto(mine);
}
/** Koupí vosí sprej vyhubí všechny vosy. */
export async function waspSpray(login: string, now: number = Date.now()): Promise<ButterflyStats> {
let failed = false;
const mine = await mutateUser(login, now, (s) => {
if (s.coins < WASP_SPRAY_COST) { failed = true; return; }
s.coins -= WASP_SPRAY_COST;
s.waspsKilled += s.wasps;
s.wasps = 0;
});
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na vosí sprej');
return toDto(mine);
}
/** Koupí pojistku proti zloději (dočasně sníží ztrátu při okradení). */
export async function buyInsurance(login: string, now: number = Date.now()): Promise<ButterflyStats> {
let failed = false;
const mine = await mutateUser(login, now, (s) => {
if (s.coins < INSURANCE_COST) { failed = true; return; }
s.coins -= INSURANCE_COST;
s.insuranceUntil = now + INSURANCE_DURATION_MS;
});
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na pojistku');
return toDto(mine);
}
/** Chyba pro neplatnou/vyprodanou koupi vylepšení. */
export class UpgradeError extends Error { }
/** Koupí další úroveň trvalého vylepšení (cena se s úrovní zdvojnásobuje). */
export async function buyUpgrade(login: string, item: UpgradeId, now: number = Date.now()): Promise<ButterflyStats> {
if (!(item in UPGRADE_MAX)) throw new UpgradeError('Neznámé vylepšení');
let failed = false;
let maxed = false;
const mine = await mutateUser(login, now, (s) => {
const level = s.upgrades[item] ?? 0;
if (level >= UPGRADE_MAX[item]) { maxed = true; return; }
const cost = upgradeCost(item, level);
if (s.coins < cost) { failed = true; return; }
s.coins -= cost;
s.upgrades[item] = level + 1;
});
if (maxed) throw new UpgradeError('Vylepšení je na maximální úrovni');
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na vylepšení');
return toDto(mine);
}
// --- Achievementy (odvozené z aktuálních statistik) --------------------------
export interface Achievement {
id: string;
title: string;
description: string;
unlocked: boolean;
}
/** Vrátí seznam achievementů odvozený z aktuálních statistik uživatele. */
export async function getAchievements(login: string, now: number = Date.now()): Promise<Achievement[]> {
const s = await mutateUser(login, now, () => { /* jen načíst/dorůst */ });
const level = levelForCaught(s.caught);
const def: Achievement[] = [
{ id: 'first-golden', title: 'Zlatý úlovek', description: 'Chyť prvního zlatého motýla', unlocked: s.goldenCaught >= 1 },
{ id: 'golden-10', title: 'Zlatokop', description: 'Chyť 10 zlatých motýlů', unlocked: s.goldenCaught >= 10 },
{ id: 'catch-500', title: 'Sběratel', description: 'Nachytej 500 motýlů', unlocked: s.caught >= 500 },
{ id: 'catch-2000', title: 'Motýlí magnát', description: 'Nachytej 2000 motýlů', unlocked: s.caught >= 2000 },
{ id: 'level-10', title: 'Zkušený chytač', description: 'Dosáhni úrovně 10', unlocked: level >= 10 },
{ id: 'level-20', title: 'Legenda louky', description: 'Dosáhni úrovně 20', unlocked: level >= 20 },
{ id: 'thief-10', title: 'Postrach zlodějů', description: 'Poraz 10 zlodějů', unlocked: s.thievesDefeated >= 10 },
{ id: 'caterpillar-10', title: 'Zahradník', description: 'Poraz 10 housenek', unlocked: s.caterpillarsDefeated >= 10 },
{ id: 'wasp-50', title: 'Plácačka', description: 'Zabij 50 vos', unlocked: s.waspsKilled >= 50 },
{ id: 'bird-25', title: 'Strašák', description: 'Vyplaš 25 ptáků', unlocked: s.birdsScared >= 25 },
{ id: 'net-max', title: 'Obří síť', description: 'Vylepši síťku na maximum', unlocked: (s.upgrades?.net ?? 0) >= UPGRADE_MAX.net },
];
return def;
}
/** /**
* Vrátí žebříček nejlepších chytačů seřazený sestupně dle počtu chycených * Vrátí žebříček nejlepších chytačů seřazený sestupně dle počtu chycených
* (při shodě dle počtu zlatých motýlů). * (při shodě dle počtu zlatých motýlů).
+71 -1
View File
@@ -12,10 +12,18 @@ import {
defeatThief, defeatThief,
caterpillarAte, caterpillarAte,
defeatCaterpillar, defeatCaterpillar,
mothHit,
buyPremium,
waspSpray,
buyInsurance,
buyUpgrade,
getAchievements,
getLeaderboard, getLeaderboard,
InsufficientCoinsError, InsufficientCoinsError,
UpgradeError,
UpgradeId,
} from "../butterflies"; } from "../butterflies";
import { CatchButterfliesData } from "../../../types"; import { CatchButterfliesData, BuyUpgradeData } from "../../../types";
const router = express.Router(); const router = express.Router();
@@ -112,6 +120,68 @@ router.post("/defeatCaterpillar", async (req: Request, res, next) => {
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/moth", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await mothHit(login);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/buyPremium", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await buyPremium(login);
res.status(200).json(data);
} catch (e: any) {
if (e instanceof InsufficientCoinsError) return res.status(402).json({ error: e.message });
next(e);
}
});
router.post("/waspSpray", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await waspSpray(login);
res.status(200).json(data);
} catch (e: any) {
if (e instanceof InsufficientCoinsError) return res.status(402).json({ error: e.message });
next(e);
}
});
router.post("/insurance", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await buyInsurance(login);
res.status(200).json(data);
} catch (e: any) {
if (e instanceof InsufficientCoinsError) return res.status(402).json({ error: e.message });
next(e);
}
});
router.post("/buyUpgrade", async (req: Request<{}, any, BuyUpgradeData["body"]>, res, next) => {
try {
const login = getLogin(parseToken(req));
if (!req.body?.item) return res.status(400).json({ error: "Chybné parametry volání" });
const data = await buyUpgrade(login, req.body.item as UpgradeId);
res.status(200).json(data);
} catch (e: any) {
if (e instanceof InsufficientCoinsError) return res.status(402).json({ error: e.message });
if (e instanceof UpgradeError) return res.status(400).json({ error: e.message });
next(e);
}
});
router.get("/achievements", async (req: Request, res, next) => {
try {
const login = getLogin(parseToken(req));
const data = await getAchievements(login);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.get("/leaderboard", async (req: Request, res, next) => { router.get("/leaderboard", async (req: Request, res, next) => {
try { try {
getLogin(parseToken(req)); getLogin(parseToken(req));
+104
View File
@@ -11,6 +11,13 @@ import {
defeatThief, defeatThief,
caterpillarAte, caterpillarAte,
defeatCaterpillar, defeatCaterpillar,
mothHit,
buyPremium,
waspSpray,
buyInsurance,
buyUpgrade,
getAchievements,
upgradeCost,
getLeaderboard, getLeaderboard,
growPests, growPests,
levelForCaught, levelForCaught,
@@ -37,6 +44,14 @@ import {
CATERPILLAR_EATS, CATERPILLAR_EATS,
CATERPILLAR_REWARD, CATERPILLAR_REWARD,
AUTO_REPAIR_MS, AUTO_REPAIR_MS,
MOTH_PENALTY,
PREMIUM_BUY_COST,
WASP_SPRAY_COST,
INSURANCE_COST,
INSURANCE_DURATION_MS,
ROBBERY_FRACTION_INSURED,
UPGRADE_MAX,
DAILY_TASK_REWARD,
} from '../butterflies'; } from '../butterflies';
import { formatDate } from '../utils'; import { formatDate } from '../utils';
@@ -366,6 +381,95 @@ describe('housenka', () => {
}); });
}); });
describe('černé můry', () => {
test('chycení můry ubere úlovky (nikdy pod nulu) a zvýší statistiku', async () => {
await seed(USER, { caught: MOTH_PENALTY + 4, coins: 0, goldenCaught: 0 });
const stats = await mothHit(USER);
expect(stats.caught).toBe(4);
expect(stats.mothsHit).toBe(1);
});
test('nesnese caught pod nulu', async () => {
await seed(USER, { caught: 2, coins: 0, goldenCaught: 0 });
const stats = await mothHit(USER);
expect(stats.caught).toBe(0);
});
});
describe('obchod spotřební', () => {
test('buyPremium odečte cenu', async () => {
await seed(USER, { caught: 0, coins: PREMIUM_BUY_COST + 5, goldenCaught: 0 });
const stats = await buyPremium(USER);
expect(stats.coins).toBe(5);
});
test('buyPremium bez mincí selže', async () => {
await seed(USER, { caught: 0, coins: PREMIUM_BUY_COST - 1, goldenCaught: 0 });
await expect(buyPremium(USER)).rejects.toThrow(InsufficientCoinsError);
});
test('waspSpray vyhubí všechny vosy a odečte cenu', async () => {
await seed(USER, { caught: 0, coins: WASP_SPRAY_COST, goldenCaught: 0, wasps: 4, lastWaspAt: Date.now() });
const stats = await waspSpray(USER);
expect(stats.coins).toBe(0);
expect(stats.wasps).toBe(0);
});
test('pojistka sníží ztrátu při okradení a spotřebuje se', async () => {
const now = 3_000_000;
await seed(USER, { caught: 0, coins: INSURANCE_COST + 100, goldenCaught: 0 });
await buyInsurance(USER, now);
const robbed = await robbery(USER, now + 1000);
// po koupi zbývá 100 mincí; pojistka → ztráta jen ROBBERY_FRACTION_INSURED
expect(robbed.coins).toBe(Math.round(100 * (1 - ROBBERY_FRACTION_INSURED)));
expect(robbed.insuranceUntil).toBe(0); // spotřebováno
});
});
describe('obchod trvalá vylepšení', () => {
test('cena roste s úrovní a vylepšení se aplikuje', async () => {
await seed(USER, { caught: 0, coins: upgradeCost('net', 0) + upgradeCost('net', 1) + 5, goldenCaught: 0 });
const a = await buyUpgrade(USER, 'net');
expect(a.upgrades.net).toBe(1);
const b = await buyUpgrade(USER, 'net');
expect(b.upgrades.net).toBe(2);
expect(b.coins).toBe(5);
});
test('nelze koupit nad maximum', async () => {
await seed(USER, { caught: 0, coins: 1_000_000, goldenCaught: 0, upgrades: { net: UPGRADE_MAX.net, scarecrow: 0, reinforced: 0 } });
await expect(buyUpgrade(USER, 'net')).rejects.toThrow();
});
test('bez mincí koupě selže', async () => {
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0 });
await expect(buyUpgrade(USER, 'net')).rejects.toThrow(InsufficientCoinsError);
});
});
describe('denní úkol', () => {
test('progres se plní a po splnění se vyplatí odměna jednou', async () => {
const now = 4_000_000;
const day = formatDate(new Date(now));
await seed(USER, {
caught: 0, coins: 0, goldenCaught: 0, wasps: 5, lastWaspAt: now,
daily: { day, taskId: 'wasp', progress: 0, target: 2, done: false },
});
await killWasp(USER, now);
let stats = await killWasp(USER, now);
expect(stats.daily.done).toBe(true);
expect(stats.coins).toBe(DAILY_TASK_REWARD);
// další zabití už odměnu nepřidá
stats = await killWasp(USER, now);
expect(stats.coins).toBe(DAILY_TASK_REWARD);
});
});
describe('achievementy', () => {
test('odemykají se podle statistik', async () => {
await seed(USER, { caught: 600, coins: 0, goldenCaught: 1 });
const list = await getAchievements(USER);
const byId = Object.fromEntries(list.map(a => [a.id, a.unlocked]));
expect(byId['first-golden']).toBe(true);
expect(byId['catch-500']).toBe(true);
expect(byId['catch-2000']).toBe(false);
});
});
describe('getLeaderboard', () => { describe('getLeaderboard', () => {
test('řadí sestupně dle počtu chycených a respektuje limit', async () => { test('řadí sestupně dle počtu chycených a respektuje limit', async () => {
await seed(USER, { caught: 100, coins: 0, goldenCaught: 2 }); await seed(USER, { caught: 100, coins: 0, goldenCaught: 2 });
+12
View File
@@ -102,6 +102,18 @@ paths:
$ref: "./paths/butterflies/caterpillarAte.yml" $ref: "./paths/butterflies/caterpillarAte.yml"
/butterflies/defeatCaterpillar: /butterflies/defeatCaterpillar:
$ref: "./paths/butterflies/defeatCaterpillar.yml" $ref: "./paths/butterflies/defeatCaterpillar.yml"
/butterflies/moth:
$ref: "./paths/butterflies/moth.yml"
/butterflies/buyPremium:
$ref: "./paths/butterflies/buyPremium.yml"
/butterflies/waspSpray:
$ref: "./paths/butterflies/waspSpray.yml"
/butterflies/insurance:
$ref: "./paths/butterflies/insurance.yml"
/butterflies/buyUpgrade:
$ref: "./paths/butterflies/buyUpgrade.yml"
/butterflies/achievements:
$ref: "./paths/butterflies/achievements.yml"
/butterflies/leaderboard: /butterflies/leaderboard:
$ref: "./paths/butterflies/leaderboard.yml" $ref: "./paths/butterflies/leaderboard.yml"
+10
View File
@@ -0,0 +1,10 @@
get:
operationId: getButterflyAchievements
summary: Vrátí seznam achievementů (odznaků) přihlášeného uživatele.
responses:
"200":
description: Seznam achievementů se stavem odemčení.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ButterflyAchievementList"
+12
View File
@@ -0,0 +1,12 @@
post:
operationId: buyPremiumNet
summary: Koupí okamžitou aktivaci prémiové síťky za mince.
responses:
"200":
description: Aktualizované statistiky po koupi.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ButterflyStats"
"402":
description: Nedostatek mincí.
+20
View File
@@ -0,0 +1,20 @@
post:
operationId: buyUpgrade
summary: Koupí další úroveň trvalého vylepšení (cena se s úrovní zdvojnásobuje).
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ButterflyUpgradeRequest"
responses:
"200":
description: Aktualizované statistiky po koupi vylepšení.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ButterflyStats"
"400":
description: Neplatné vylepšení nebo maximální úroveň.
"402":
description: Nedostatek mincí.
+12
View File
@@ -0,0 +1,12 @@
post:
operationId: buyInsurance
summary: Koupí pojistku proti zloději (dočasně sníží ztrátu při okradení).
responses:
"200":
description: Aktualizované statistiky po koupi pojistky.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ButterflyStats"
"402":
description: Nedostatek mincí.
+10
View File
@@ -0,0 +1,10 @@
post:
operationId: reportMothHit
summary: Hráč omylem chytil černou můru přijde o část úlovků.
responses:
"200":
description: Aktualizované statistiky po chycení můry.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ButterflyStats"
+12
View File
@@ -0,0 +1,12 @@
post:
operationId: buyWaspSpray
summary: Koupí vosí sprej vyhubí všechny vosy.
responses:
"200":
description: Aktualizované statistiky po použití spreje.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ButterflyStats"
"402":
description: Nedostatek mincí.
+96
View File
@@ -945,8 +945,12 @@ ButterflyStats:
- birdsScared - birdsScared
- thievesDefeated - thievesDefeated
- caterpillarsDefeated - caterpillarsDefeated
- mothsHit
- repellentUntil - repellentUntil
- netTornUntil - netTornUntil
- insuranceUntil
- upgrades
- daily
properties: properties:
caught: caught:
description: Celkový počet chycených motýlů description: Celkový počet chycených motýlů
@@ -987,12 +991,104 @@ ButterflyStats:
caterpillarsDefeated: caterpillarsDefeated:
description: Celkový počet poražených housenek description: Celkový počet poražených housenek
type: integer type: integer
mothsHit:
description: Kolikrát hráč omylem chytil černou můru
type: integer
repellentUntil: repellentUntil:
description: Časové razítko (ms epoch), do kdy platí plašič ptáků; 0 = neaktivní description: Časové razítko (ms epoch), do kdy platí plašič ptáků; 0 = neaktivní
type: integer type: integer
netTornUntil: netTornUntil:
description: Časové razítko (ms epoch), do kdy je síťka protržená; po uplynutí se sama zašije. 0 = celá description: Časové razítko (ms epoch), do kdy je síťka protržená; po uplynutí se sama zašije. 0 = celá
type: integer type: integer
insuranceUntil:
description: Časové razítko (ms epoch), do kdy platí pojistka proti zloději; 0 = neaktivní
type: integer
upgrades:
$ref: "#/ButterflyUpgrades"
daily:
$ref: "#/ButterflyDaily"
ButterflyUpgrades:
description: Úrovně trvalých vylepšení z obchodu
type: object
additionalProperties: false
required:
- net
- scarecrow
- reinforced
properties:
net:
description: Úroveň vylepšení velikosti síťky
type: integer
scarecrow:
description: Úroveň strašáka (zpomaluje přibývání ptáků)
type: integer
reinforced:
description: Úroveň zpevněné síťky (rychlejší samo-zašití)
type: integer
ButterflyDaily:
description: Denní úkol hráče
type: object
additionalProperties: false
required:
- taskId
- title
- progress
- target
- done
- reward
properties:
taskId:
description: Identifikátor druhu úkolu
type: string
title:
description: Popis úkolu pro uživatele
type: string
progress:
description: Aktuální postup
type: integer
target:
description: Cíl úkolu
type: integer
done:
description: Zda je úkol splněný (a odměna vyplacená)
type: boolean
reward:
description: Odměna v mincích za splnění
type: integer
ButterflyUpgradeRequest:
description: Požadavek na koupi trvalého vylepšení
type: object
additionalProperties: false
required:
- item
properties:
item:
description: Které vylepšení koupit
type: string
enum: [net, scarecrow, reinforced]
ButterflyAchievement:
description: Jeden achievement (odznak)
type: object
additionalProperties: false
required:
- id
- title
- description
- unlocked
properties:
id:
type: string
title:
type: string
description:
type: string
unlocked:
type: boolean
ButterflyAchievementList:
description: Seznam achievementů
type: array
items:
$ref: "#/ButterflyAchievement"
ButterflyCatchRequest: ButterflyCatchRequest:
description: Dávka nachytaných motýlů k zaznamenání description: Dávka nachytaných motýlů k zaznamenání
type: object type: object