From 252d10540823bea7e88ccf37f45dd0a2905020d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A1nek=20Pavel?= Date: Mon, 27 Jul 2026 10:23:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20obchod,=20=C4=8Dern=C3=A9=20m=C5=AFry,?= =?UTF-8?q?=20netop=C3=BDr,=20pavouk=20a=20denn=C3=AD=20=C3=BAkoly=20u=20c?= =?UTF-8?q?hyt=C3=A1n=C3=AD=20mot=C3=BDlk=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- client/public/bat.svg | 18 + client/public/butterfly-moth.svg | 34 ++ client/public/spider.svg | 27 ++ client/src/FlyingButterflies.scss | 81 +++++ client/src/FlyingButterflies.tsx | 321 ++++++++++++++++-- .../modals/ButterflyLeaderboardModal.scss | 33 +- .../modals/ButterflyLeaderboardModal.tsx | 38 ++- .../components/modals/ButterflyShopModal.scss | 106 ++++++ .../components/modals/ButterflyShopModal.tsx | 113 ++++++ client/src/hooks/useButterflyStats.ts | 48 ++- server/changelogs/2026-07-24.json | 8 + server/src/butterflies.ts | 240 ++++++++++++- server/src/routes/butterflyRoutes.ts | 72 +++- server/src/tests/butterflies.test.ts | 104 ++++++ types/api.yml | 12 + types/paths/butterflies/achievements.yml | 10 + types/paths/butterflies/buyPremium.yml | 12 + types/paths/butterflies/buyUpgrade.yml | 20 ++ types/paths/butterflies/insurance.yml | 12 + types/paths/butterflies/moth.yml | 10 + types/paths/butterflies/waspSpray.yml | 12 + types/schemas/_index.yml | 96 ++++++ 22 files changed, 1384 insertions(+), 43 deletions(-) create mode 100644 client/public/bat.svg create mode 100644 client/public/butterfly-moth.svg create mode 100644 client/public/spider.svg create mode 100644 client/src/components/modals/ButterflyShopModal.scss create mode 100644 client/src/components/modals/ButterflyShopModal.tsx create mode 100644 server/changelogs/2026-07-24.json create mode 100644 types/paths/butterflies/achievements.yml create mode 100644 types/paths/butterflies/buyPremium.yml create mode 100644 types/paths/butterflies/buyUpgrade.yml create mode 100644 types/paths/butterflies/insurance.yml create mode 100644 types/paths/butterflies/moth.yml create mode 100644 types/paths/butterflies/waspSpray.yml diff --git a/client/public/bat.svg b/client/public/bat.svg new file mode 100644 index 0000000..8bdf3f5 --- /dev/null +++ b/client/public/bat.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/client/public/butterfly-moth.svg b/client/public/butterfly-moth.svg new file mode 100644 index 0000000..9a8db9c --- /dev/null +++ b/client/public/butterfly-moth.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/spider.svg b/client/public/spider.svg new file mode 100644 index 0000000..fbf105c --- /dev/null +++ b/client/public/spider.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/FlyingButterflies.scss b/client/src/FlyingButterflies.scss index 6282e06..bc05404 100644 --- a/client/src/FlyingButterflies.scss +++ b/client/src/FlyingButterflies.scss @@ -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) ---------------------------------------------------- .butterfly-critter { @@ -93,6 +104,42 @@ cursor: crosshair; 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í -------------------------------------------------------- @@ -139,6 +186,11 @@ 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. // Má přednost před grabbed kurzorem. &.stunned { @@ -215,6 +267,17 @@ font-size: 1.35rem; 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 { @@ -255,6 +318,24 @@ 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 { display: inline-flex; flex-direction: column; diff --git a/client/src/FlyingButterflies.tsx b/client/src/FlyingButterflies.tsx index e1ab550..413c655 100644 --- a/client/src/FlyingButterflies.tsx +++ b/client/src/FlyingButterflies.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useCallback, useState } from 'react'; import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats'; import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal'; +import ButterflyShopModal from './components/modals/ButterflyShopModal'; // Barevné varianty motýlů (soubory v public/, referencované root-absolutně) const BUTTERFLY_VARIANTS = [ @@ -12,6 +13,8 @@ const BUTTERFLY_VARIANTS = [ const GOLDEN_VARIANT = '/butterfly-golden.svg'; const GOLDEN_CHANCE = 0.015; // ~1,5 % motýlů je zlatých (vzácnější) +const MOTH_VARIANT = '/butterfly-moth.svg'; +const MOTH_CHANCE = 0.14; // ~14 % je černá můra – chytit ji je chyba const NET_NORMAL = '/butterfly-net.svg'; const NET_GOLDEN = '/butterfly-net-golden.svg'; @@ -20,8 +23,11 @@ const NET_TORN = '/butterfly-net-torn.svg'; // Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts) const PREMIUM_DURATION_MS = 60_000; export const REPAIR_COST = 20; -export const REPELLENT_COST = 60; +export const REPELLENT_COST = 70; const GOLD_VALUE = 25; +const MOTH_PENALTY = 15; +/** Přírůstek dosahu chytání za úroveň vylepšení „větší síťka" */ +const NET_UPGRADE_RADIUS = 9; // „Bossové" (zloděj / housenka) const THIEF_HP = 22; @@ -43,10 +49,23 @@ interface ButterflyData { x: number; y: number; dir: number; speed: number; size: number; golden: boolean; + /** Černá můra – chycení stojí úlovky (nepočítá se jako úlovek) */ + moth: boolean; bobFreq1: number; bobPhase1: number; bobAmp1: number; bobFreq2: number; bobPhase2: number; bobAmp2: number; } +/** Netopýr v noci – loví poletující motýly, odháníš klikáním. */ +interface BatData { + el: HTMLDivElement; + x: number; y: number; + dir: number; speed: number; size: number; + bobPhase: number; bobAmp: number; + hp: number; + nextEatAt: number; + cleanup: () => void; +} + /** Létající havěť (pták nebo vosa) – nalétává na síťku. */ interface CritterData { el: HTMLDivElement; @@ -79,15 +98,19 @@ interface SceneCallbacks { onCatch: (golden: boolean) => void; getCoins: () => number; getCaught: () => number; + getNetUpgrade: () => number; onTornChange: (torn: boolean) => void; onCombo: (count: number) => void; onSting: () => void; onSwatWasp: () => void; + onMothCaught: () => void; onRobbery: () => void; onDefeatThief: () => void; onCaterpillarAte: () => void; onDefeatCaterpillar: () => void; onStalkerAppear: (type: 'thief' | 'caterpillar') => void; + onBatShooed: () => void; + onWebCleared: () => void; } interface FlyingButterfliesProps { @@ -118,6 +141,7 @@ class ButterflyScene { private netX: number = 0; private netY: number = 0; private cb: SceneCallbacks; + private handleVisibility: () => void; private torn: boolean = false; private premiumUntil: number = 0; @@ -126,6 +150,13 @@ class ButterflyScene { private nextCatchAt: number = 0; private nextStalkerAt: number = 1800; // první možný boss ~30 s + // Netopýr (v noci) a pavučina + private bat: BatData | null = null; + private nextBatAt: number = 1200; + private web: HTMLDivElement | null = null; + private webClicks: number = 0; + private nextSpiderAt: number = 2400; + private comboCount: number = 0; private lastCatchTs: 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_MAX_GAP = 9000; // 150 s + private static readonly BAT_SIZE = 60; + private static readonly BAT_HP = 3; + private static readonly BAT_EAT_INTERVAL = 150; // ~2,5 s mezi sežráním motýla + private static readonly BAT_MIN_GAP = 2400; + private static readonly BAT_MAX_GAP = 6000; + private static readonly WEB_CLICKS = 5; // kolik kliknutí strhne pavučinu + private static readonly SPIDER_MIN_GAP = 4200; + private static readonly SPIDER_MAX_GAP = 9000; + constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) { this.viewport = el; this.world = document.createElement('div'); @@ -166,11 +206,22 @@ class ButterflyScene { this.width = this.viewport.offsetWidth; this.height = this.viewport.offsetHeight; }; + // Pozastavení animační smyčky, když není záložka vidět (šetří CPU) + this.handleVisibility = () => { + if (document.hidden) { + if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } + } else if (this.animationId === null) { + this.animationId = requestAnimationFrame(this.render); + } + }; } private get premiumActive(): boolean { return Date.now() < this.premiumUntil; } private get netSize(): number { 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 hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; } @@ -189,14 +240,23 @@ class ButterflyScene { b.bobPhase2 = Math.random() * Math.PI * 2; b.bobAmp2 = Math.random() * 0.6 + 0.4; - b.golden = Math.random() < GOLDEN_CHANCE; - if (b.golden) { + // Nejdřív můra, jinak vzácně zlatý, jinak běžná varianta + b.moth = Math.random() < MOTH_CHANCE; + b.golden = !b.moth && Math.random() < GOLDEN_CHANCE; + if (b.moth) { + b.sprite.style.backgroundImage = `url(${MOTH_VARIANT})`; + b.sprite.classList.remove('golden'); + b.sprite.classList.add('moth'); + b.size = Math.random() * 0.3 + 0.9; + } else if (b.golden) { b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`; + b.sprite.classList.remove('moth'); b.sprite.classList.add('golden'); b.size = Math.random() * 0.5 + 1.0; } else { b.sprite.style.backgroundImage = `url(${this.variants[Math.floor(Math.random() * this.variants.length)]})`; b.sprite.classList.remove('golden'); + b.sprite.classList.remove('moth'); } b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; }; @@ -267,11 +327,28 @@ class ButterflyScene { 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 => { this.cb.onCatch(b.golden); this.spawnFx(b.golden ? `+${GOLD_VALUE}` : '+1', b.golden ? 'butterfly-catch-fx golden' : 'butterfly-catch-fx', atX, atY); this.resetButterfly(b); - this.nextCatchAt = this.timer + ButterflyScene.CATCH_COOLDOWN; const now = Date.now(); this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1; @@ -285,14 +362,14 @@ class ButterflyScene { // Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna) private checkCatches = (): void => { - if (this.torn || this.timer < this.stunUntil || 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 half = ButterflyScene.BASE_SIZE / 2; for (const b of this.butterflies) { const dx = (b.x + half * b.size) - this.netX; const dy = (b.y + half * b.size) - this.netY; if (dx * dx + dy * dy < r2) { - this.registerCatch(b, this.netX, this.netY); + this.catchOne(b, this.netX, this.netY); this.popNet(); return; // jen jeden za cooldown } @@ -300,7 +377,7 @@ class ButterflyScene { }; 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; this.lastMagnetAt = this.timer; const half = ButterflyScene.BASE_SIZE / 2; @@ -312,7 +389,7 @@ class ButterflyScene { const d2 = dx * dx + dy * dy; 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 => { @@ -506,10 +583,19 @@ class ButterflyScene { this.nextStalkerAt = this.timer + 600; // zkus to znovu za ~10 s 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.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)); + this.nextStalkerAt = this.timer + Math.round(gap * wealthFactor); }; 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 ------------------------------------------------------ private initNet = (): void => { @@ -627,7 +844,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, + 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, }; this.resetButterfly(b); @@ -637,6 +854,7 @@ class ButterflyScene { this.viewport.appendChild(this.world); if (this.netEnabled) this.initNet(); window.addEventListener('resize', this.handleResize); + document.addEventListener('visibilitychange', this.handleVisibility); }; private wasPremium: boolean = false; @@ -654,6 +872,10 @@ class ButterflyScene { this.updateBirds(); this.trySpawnStalker(); this.updateStalker(); + this.trySpawnBat(); + this.updateBat(); + this.trySpawnWeb(); + this.updateWeb(); const stunned = this.timer < this.stunUntil; if (stunned !== this.wasStunned) { @@ -674,6 +896,8 @@ class ButterflyScene { if (this.comboTimer) window.clearTimeout(this.comboTimer); for (const w of this.wasps) w.cleanup?.(); this.removeStalker(); + this.removeBat(); + this.removeWeb(); if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world); this.net.removeEventListener('pointerdown', this.onPointerDown); window.removeEventListener('pointermove', this.onPointerMove); @@ -681,6 +905,7 @@ class ButterflyScene { window.removeEventListener('pointercancel', this.onPointerUp); if (this.net.parentNode) this.net.parentNode.removeChild(this.net); window.removeEventListener('resize', this.handleResize); + document.removeEventListener('visibilitychange', this.handleVisibility); }; } @@ -703,6 +928,7 @@ const FlyingButterflies: React.FC = ({ const [combo, setCombo] = useState(0); const [flash, setFlash] = useState(null); const [leaderboardOpen, setLeaderboardOpen] = useState(false); + const [shopOpen, setShopOpen] = useState(false); const [nowTs, setNowTs] = useState(() => Date.now()); const flashTimer = useRef(null); @@ -726,23 +952,29 @@ const FlyingButterflies: React.FC = ({ const { stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear, reportRobbery, defeatThief, - caterpillarAte, defeatCaterpillar, + caterpillarAte, defeatCaterpillar, mothHit, buyPremium, waspSpray, buyInsurance, buyUpgrade, } = useButterflyStats(handleReward); + const netUpgradeRef = useRef(0); + netUpgradeRef.current = stats?.upgrades?.net ?? 0; + + const noop = () => { }; const callbacksRef = useRef({ - onCatch: () => { }, getCoins: () => 0, getCaught: () => 0, onTornChange: () => { }, - onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { }, - onRobbery: () => { }, onDefeatThief: () => { }, onCaterpillarAte: () => { }, - onDefeatCaterpillar: () => { }, onStalkerAppear: () => { }, + onCatch: noop, getCoins: () => 0, getCaught: () => 0, getNetUpgrade: () => 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, }); callbacksRef.current = { onCatch: (golden) => reportCatch(golden), getCoins: () => coinsRef.current, getCaught: () => stats?.caught ?? 0, + getNetUpgrade: () => netUpgradeRef.current, onTornChange: (t) => { if (t) void reportTear(); }, onCombo: (c) => setCombo(c), onSting: () => showFlash('🐝 Au! Vosa tě žihla – síťka teď 5 s nechytá. Zaklikej vosy!'), onSwatWasp: () => void killWasp(), + onMothCaught: () => { void mothHit(); showFlash('🖤 Chytil jsi černou můru! Přišel jsi o úlovky – vyhýbej se jim.'); }, onRobbery: () => { void reportRobbery(); showFlash('💸 Zloděj ti ukradl většinu mincí! Příště ho zaklikej.'); }, onDefeatThief: () => { void defeatThief(); showFlash('💰 Zloděj poražen! Malá odměna a mince v bezpečí.'); }, onCaterpillarAte: () => { void caterpillarAte(); showFlash('🐛 Housenka ti sežrala část úlovků!'); }, @@ -750,6 +982,8 @@ const FlyingButterflies: React.FC = ({ onStalkerAppear: (type) => showFlash(type === 'thief' ? '🦹 Zloděj! Míří k tvým mincím – zaklikej ho (chce to hodně ran)!' : '🐛 Housenka! Plíží se k tvým úlovkům – zaklikej ji!'), + onBatShooed: () => showFlash('🦇 Netopýr zahnán!'), + onWebCleared: () => showFlash('🕸️ Pavučina stržena – zase můžeš chytat.'), }; const initialize = useCallback(() => { @@ -758,15 +992,19 @@ const FlyingButterflies: React.FC = ({ onCatch: (g) => callbacksRef.current.onCatch(g), getCoins: () => callbacksRef.current.getCoins(), getCaught: () => callbacksRef.current.getCaught(), + getNetUpgrade: () => callbacksRef.current.getNetUpgrade(), onTornChange: (t) => callbacksRef.current.onTornChange(t), onCombo: (c) => callbacksRef.current.onCombo(c), onSting: () => callbacksRef.current.onSting(), onSwatWasp: () => callbacksRef.current.onSwatWasp(), + onMothCaught: () => callbacksRef.current.onMothCaught(), onRobbery: () => callbacksRef.current.onRobbery(), onDefeatThief: () => callbacksRef.current.onDefeatThief(), onCaterpillarAte: () => callbacksRef.current.onCaterpillarAte(), onDefeatCaterpillar: () => callbacksRef.current.onDefeatCaterpillar(), onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t), + onBatShooed: () => callbacksRef.current.onBatShooed(), + onWebCleared: () => callbacksRef.current.onWebCleared(), }); sceneRef.current.init(); sceneRef.current.render(); @@ -818,10 +1056,34 @@ const FlyingButterflies: React.FC = ({ if (ok) sceneRef.current?.repairNetExternally(); }, [repair]); + /** Zobrazí hlášku podle výsledku nákupu (rozliší nedostatek mincí od jiné chyby). */ + const flashBuy = useCallback((r: 'ok' | 'poor' | 'error', okMsg: string) => { + if (r === 'ok') showFlash(okMsg); + else if (r === 'poor') showFlash('🪙 Nemáš dost mincí.'); + else showFlash('⚠️ Nákup se nezdařil – zkus obnovit stránku (F5). Server možná běží starou verzi.'); + }, [showFlash]); + const onRepellentClick = useCallback(async () => { - const ok = await buyRepellent(); - if (ok) showFlash('🦅🚫 Plašič ptáků koupen – ptáci na chvíli zmizeli.'); - }, [buyRepellent, showFlash]); + flashBuy(await buyRepellent(), '🦅🚫 Plašič ptáků koupen – ptáci na chvíli zmizeli.'); + }, [buyRepellent, flashBuy]); + + const onBuyPremium = useCallback(async () => { + const r = await buyPremium(); + if (r === 'ok') sceneRef.current?.activatePremiumNet(); + flashBuy(r, '🪄 Zlatá síťka aktivována!'); + }, [buyPremium, flashBuy]); + + const onWaspSpray = useCallback(async () => { + flashBuy(await waspSpray(), '💨 Vosy zlikvidovány!'); + }, [waspSpray, flashBuy]); + + const onBuyInsurance = useCallback(async () => { + flashBuy(await buyInsurance(), '🛡️ Pojistka proti zloději aktivní.'); + }, [buyInsurance, flashBuy]); + + const onBuyUpgrade = useCallback(async (item: 'net' | 'scarecrow' | 'reinforced') => { + flashBuy(await buyUpgrade(item), '✅ Vylepšení koupeno!'); + }, [buyUpgrade, flashBuy]); return ( <> @@ -899,7 +1161,26 @@ const FlyingButterflies: React.FC = ({ )} + + setLeaderboardOpen(false)} myStats={stats} /> + setShopOpen(false)} + stats={stats} + onBuyPremium={onBuyPremium} + onWaspSpray={onWaspSpray} + onRepellent={onRepellentClick} + onBuyInsurance={onBuyInsurance} + onBuyUpgrade={onBuyUpgrade} + /> )} diff --git a/client/src/components/modals/ButterflyLeaderboardModal.scss b/client/src/components/modals/ButterflyLeaderboardModal.scss index 2697a8d..d392d15 100644 --- a/client/src/components/modals/ButterflyLeaderboardModal.scss +++ b/client/src/components/modals/ButterflyLeaderboardModal.scss @@ -43,7 +43,38 @@ .butterfly-leaderboard-heading { 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 { diff --git a/client/src/components/modals/ButterflyLeaderboardModal.tsx b/client/src/components/modals/ButterflyLeaderboardModal.tsx index de32bc6..e324647 100644 --- a/client/src/components/modals/ButterflyLeaderboardModal.tsx +++ b/client/src/components/modals/ButterflyLeaderboardModal.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from "react"; 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 "./ButterflyLeaderboardModal.scss"; @@ -12,20 +15,25 @@ type Props = { 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) { const auth = useAuth(); const [entries, setEntries] = useState(); + const [achievements, setAchievements] = useState(); const [loading, setLoading] = useState(false); useEffect(() => { if (!isOpen) return; let cancelled = false; setLoading(true); - getButterflyLeaderboard({ query: { limit: 20 } }) - .then(res => { if (!cancelled) setEntries(res.data ?? []); }) - .catch(() => { if (!cancelled) setEntries([]); }) - .finally(() => { if (!cancelled) setLoading(false); }); + Promise.all([ + getButterflyLeaderboard({ query: { limit: 20 } }).then(r => r.data ?? []).catch(() => []), + getButterflyAchievements().then(r => r.data ?? []).catch(() => []), + ]).then(([lb, ach]) => { + if (cancelled) return; + setEntries(lb); + setAchievements(ach); + }).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [isOpen]); @@ -52,6 +60,24 @@ export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: )} + {achievements && achievements.length > 0 && ( + <> +
Odznaky
+
+ {achievements.map(a => ( +
+ {a.unlocked ? '🏅' : '🔒'} + {a.title} +
+ ))} +
+ + )} +
Žebříček týmu
{loading &&

Načítám žebříček…

} {!loading && entries && entries.length === 0 && ( diff --git a/client/src/components/modals/ButterflyShopModal.scss b/client/src/components/modals/ButterflyShopModal.scss new file mode 100644 index 0000000..ca8d857 --- /dev/null +++ b/client/src/components/modals/ButterflyShopModal.scss @@ -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; + } +} diff --git a/client/src/components/modals/ButterflyShopModal.tsx b/client/src/components/modals/ButterflyShopModal.tsx new file mode 100644 index 0000000..c8cb63a --- /dev/null +++ b/client/src/components/modals/ButterflyShopModal.tsx @@ -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 = { net: 100, scarecrow: 120, reinforced: 150 }; +const UPGRADE_MAX: Record = { net: 5, scarecrow: 4, reinforced: 3 }; +const UPGRADE_META: Record = { + 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) { + const coins = stats?.coins ?? 0; + const insuranceActive = !!stats && stats.insuranceUntil > Date.now(); + + const consumable = (label: string, cost: number, onBuy: () => void, extra?: string) => ( + + ); + + return ( + + + 🛒 Obchod {coins} 🪙 + + + {stats?.daily && ( +
+
🎯 Denní úkol: {stats.daily.title}
+
+
+
+
+ {stats.daily.done + ? `✅ Splněno (+${stats.daily.reward} 🪙)` + : `${stats.daily.progress}/${stats.daily.target} · odměna ${stats.daily.reward} 🪙`} +
+
+ )} + +
Pomůcky
+
+ {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í)' : '')} +
+ +
Trvalá vylepšení
+
+ {(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 ( + + ); + })} +
+ + + ); +} diff --git a/client/src/hooks/useButterflyStats.ts b/client/src/hooks/useButterflyStats.ts index 742aeaa..2b9c88c 100644 --- a/client/src/hooks/useButterflyStats.ts +++ b/client/src/hooks/useButterflyStats.ts @@ -11,8 +11,19 @@ import { defeatThief as defeatThiefApi, reportCaterpillarAte as caterpillarAteApi, defeatCaterpillar as defeatCaterpillarApi, + reportMothHit as mothHitApi, + buyPremiumNet as buyPremiumApi, + buyWaspSpray as waspSprayApi, + buyInsurance as buyInsuranceApi, + buyUpgrade as buyUpgradeApi, } 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. */ const FLUSH_DEBOUNCE_MS = 1500; /** 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 */ } }, [applyStats]); - const buyRepellent = useCallback(async (): Promise => { + const buyRepellent = useCallback(async (): Promise => { try { const res = await buyRepellentApi(); - if (res.data) { applyStats(res.data); return true; } - return false; - } catch { return false; } + if (res.data) { applyStats(res.data); return 'ok'; } + return res.response?.status === 402 ? 'poor' : 'error'; + } catch { return 'error'; } }, [applyStats]); const reportTear = useCallback(async () => { @@ -147,6 +158,34 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) { } catch { /* ignore */ } }, [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 => { + try { return buyResult(await buyPremiumApi()); } catch { return 'error'; } + }, [buyResult]); + const waspSpray = useCallback(async (): Promise => { + try { return buyResult(await waspSprayApi()); } catch { return 'error'; } + }, [buyResult]); + const buyInsurance = useCallback(async (): Promise => { + try { return buyResult(await buyInsuranceApi()); } catch { return 'error'; } + }, [buyResult]); + const buyUpgrade = useCallback(async (item: UpgradeItem): Promise => { + try { return buyResult(await buyUpgradeApi({ body: { item } })); } catch { return 'error'; } + }, [buyResult]); + /** Hráč porazil housenku – odměna a statistika. */ const defeatCaterpillar = useCallback(async () => { try { @@ -203,5 +242,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, }; } diff --git a/server/changelogs/2026-07-24.json b/server/changelogs/2026-07-24.json new file mode 100644 index 0000000..b06ca24 --- /dev/null +++ b/server/changelogs/2026-07-24.json @@ -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" +] diff --git a/server/src/butterflies.ts b/server/src/butterflies.ts index d70ebfd..0d62b5c 100644 --- a/server/src/butterflies.ts +++ b/server/src/butterflies.ts @@ -40,6 +40,15 @@ interface StoredButterflyStats { catchWindowStart: number; /** Počet započtených úlovků v aktuálním okně */ 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(); @@ -62,15 +71,15 @@ export const PREMIUM_MILESTONE = 75; /** Maximální počet současně poletujících vos */ export const WASP_MAX = 6; /** 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 */ export const WASP_GROWTH_MS = 30_000; -/** Jak často (ms) přibude jeden pták */ -export const BIRD_GROWTH_MS = 60_000; +/** Jak často (ms) přibude jeden pták (základ; zpomaluje strašák) */ +export const BIRD_GROWTH_MS = 40_000; /** 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č */ -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 */ export const AUTO_REPAIR_MS = 60_000; /** Jaký podíl mincí ukradne zloděj, když se dostane k penězům (0–1) */ @@ -85,6 +94,55 @@ export const CATERPILLAR_EATS = 20; export const CATERPILLAR_REWARD = 15; /** Kolik zásahů (kliknutí) housenka vydrží */ 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 = { net: 5, scarecrow: 4, reinforced: 3 }; +/** Základní cena vylepšení (další úroveň je 2× dražší) */ +const UPGRADE_BASE: Record = { 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 = { + 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 */ const COMBO_THRESHOLD = 4; @@ -183,6 +241,10 @@ function defaultStats(now: number): StoredButterflyStats { repellentUntil: 0, netTornUntil: 0, lastWaspAt: now, lastBirdAt: now, 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, catchWindowStart: s.catchWindowStart ?? now, 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á * 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; if (!repellentActive) { 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) { 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 { s.lastBirdAt = now; @@ -260,8 +361,19 @@ function toDto(s: StoredButterflyStats): ButterflyStats { birdsScared: s.birdsScared, thievesDefeated: s.thievesDefeated, caterpillarsDefeated: s.caterpillarsDefeated, + mothsHit: s.mothsHit, repellentUntil: s.repellentUntil, 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 mine = normalize(all[login] ?? defaultStats(now), now); growPests(mine, now); + ensureDaily(mine, now); mutator(mine); all[login] = mine; return all; @@ -347,6 +460,9 @@ export async function recordCatches(login: string, normal: number, golden: numbe s.goldenCaught += creditGolden; if (credited > 0) s.lastCatchDay = today; + bumpDaily(s, 'catch', credited); + bumpDaily(s, 'golden', creditGolden); + leveledUp = levelForCaught(newCaught) > oldLevel; 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) { s.wasps -= 1; s.waspsKilled += 1; + bumpDaily(s, 'wasp', 1); } }); 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 { 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); } -/** 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 { 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); } -/** 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 { const mine = await mutateUser(login, now, (s) => { s.thievesDefeated += 1; s.coins += THIEF_REWARD; + bumpDaily(s, 'thief', 1); }); return toDto(mine); } @@ -446,6 +567,103 @@ export async function defeatCaterpillar(login: string, now: number = Date.now()) 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 * (při shodě dle počtu zlatých motýlů). diff --git a/server/src/routes/butterflyRoutes.ts b/server/src/routes/butterflyRoutes.ts index 3476693..9279aed 100644 --- a/server/src/routes/butterflyRoutes.ts +++ b/server/src/routes/butterflyRoutes.ts @@ -12,10 +12,18 @@ import { defeatThief, caterpillarAte, defeatCaterpillar, + mothHit, + buyPremium, + waspSpray, + buyInsurance, + buyUpgrade, + getAchievements, getLeaderboard, InsufficientCoinsError, + UpgradeError, + UpgradeId, } from "../butterflies"; -import { CatchButterfliesData } from "../../../types"; +import { CatchButterfliesData, BuyUpgradeData } from "../../../types"; const router = express.Router(); @@ -112,6 +120,68 @@ router.post("/defeatCaterpillar", async (req: Request, res, next) => { } 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) => { try { getLogin(parseToken(req)); diff --git a/server/src/tests/butterflies.test.ts b/server/src/tests/butterflies.test.ts index 647b86a..36ca634 100644 --- a/server/src/tests/butterflies.test.ts +++ b/server/src/tests/butterflies.test.ts @@ -11,6 +11,13 @@ import { defeatThief, caterpillarAte, defeatCaterpillar, + mothHit, + buyPremium, + waspSpray, + buyInsurance, + buyUpgrade, + getAchievements, + upgradeCost, getLeaderboard, growPests, levelForCaught, @@ -37,6 +44,14 @@ import { CATERPILLAR_EATS, CATERPILLAR_REWARD, 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'; 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', () => { test('řadí sestupně dle počtu chycených a respektuje limit', async () => { await seed(USER, { caught: 100, coins: 0, goldenCaught: 2 }); diff --git a/types/api.yml b/types/api.yml index b27566b..05dab7b 100644 --- a/types/api.yml +++ b/types/api.yml @@ -102,6 +102,18 @@ paths: $ref: "./paths/butterflies/caterpillarAte.yml" /butterflies/defeatCaterpillar: $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: $ref: "./paths/butterflies/leaderboard.yml" diff --git a/types/paths/butterflies/achievements.yml b/types/paths/butterflies/achievements.yml new file mode 100644 index 0000000..0a9f61c --- /dev/null +++ b/types/paths/butterflies/achievements.yml @@ -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" diff --git a/types/paths/butterflies/buyPremium.yml b/types/paths/butterflies/buyPremium.yml new file mode 100644 index 0000000..5efb584 --- /dev/null +++ b/types/paths/butterflies/buyPremium.yml @@ -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í. diff --git a/types/paths/butterflies/buyUpgrade.yml b/types/paths/butterflies/buyUpgrade.yml new file mode 100644 index 0000000..a4a6ec9 --- /dev/null +++ b/types/paths/butterflies/buyUpgrade.yml @@ -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í. diff --git a/types/paths/butterflies/insurance.yml b/types/paths/butterflies/insurance.yml new file mode 100644 index 0000000..d38ecf8 --- /dev/null +++ b/types/paths/butterflies/insurance.yml @@ -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í. diff --git a/types/paths/butterflies/moth.yml b/types/paths/butterflies/moth.yml new file mode 100644 index 0000000..9659892 --- /dev/null +++ b/types/paths/butterflies/moth.yml @@ -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" diff --git a/types/paths/butterflies/waspSpray.yml b/types/paths/butterflies/waspSpray.yml new file mode 100644 index 0000000..89fc56f --- /dev/null +++ b/types/paths/butterflies/waspSpray.yml @@ -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í. diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index bb03607..ebb412b 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -945,8 +945,12 @@ ButterflyStats: - birdsScared - thievesDefeated - caterpillarsDefeated + - mothsHit - repellentUntil - netTornUntil + - insuranceUntil + - upgrades + - daily properties: caught: description: Celkový počet chycených motýlů @@ -987,12 +991,104 @@ ButterflyStats: caterpillarsDefeated: description: Celkový počet poražených housenek type: integer + mothsHit: + description: Kolikrát hráč omylem chytil černou můru + type: integer repellentUntil: description: Časové razítko (ms epoch), do kdy platí plašič ptáků; 0 = neaktivní type: integer netTornUntil: description: Časové razítko (ms epoch), do kdy je síťka protržená; po uplynutí se sama zašije. 0 = celá 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: description: Dávka nachytaných motýlů k zaznamenání type: object