feat: obtížnější chytání motýlků + zloděj, housenka a agresivní škůdci
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
- 60 úrovní se strmou křivkou, dražší vylepšení, vzácnější zlatý motýl - ochrana proti podvádění (rate cap + cooldown chytání) - perzistentní škůdci přibývají rychleji; vosy nalétávají do síťky, ptáci ji protrhnou vždy - protržená síťka se sama zašije za minutu (nebo okamžitě za mince) - zloděj krade mince a housenka žere úlovky – oba se plíží k počítadlu a jdou zaklikat - plašič ptáků s odpočtem, level progress bar, nová sezóna (reset statistik) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c5ca6c9d41
commit
e3f439bde0
@@ -365,6 +365,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.butterfly-torn-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
.butterfly-action-btn {
|
.butterfly-action-btn {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -401,6 +408,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bossové (zloděj / housenka) ---------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-stalker {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 6;
|
||||||
|
will-change: transform;
|
||||||
|
|
||||||
|
&.hit .stalker-emoji {
|
||||||
|
animation: butterfly-stalker-hit 0.18s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-emoji {
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-hp {
|
||||||
|
width: 46px;
|
||||||
|
height: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-hp-inner {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff6b6b, #c92a2a);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-stalker-hit {
|
||||||
|
0% { transform: scale(1) rotate(0); }
|
||||||
|
50% { transform: scale(0.82) rotate(-8deg); filter: brightness(1.6); }
|
||||||
|
100% { transform: scale(1) rotate(0); }
|
||||||
|
}
|
||||||
|
|
||||||
// Efekt při plácnutí vosy
|
// Efekt při plácnutí vosy
|
||||||
.butterfly-swat-fx {
|
.butterfly-swat-fx {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
+305
-157
@@ -2,30 +2,33 @@ 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';
|
||||||
|
|
||||||
// Různé barevné varianty motýlů (soubory v public/, referencované root-absolutně)
|
// Barevné varianty motýlů (soubory v public/, referencované root-absolutně)
|
||||||
const BUTTERFLY_VARIANTS = [
|
const BUTTERFLY_VARIANTS = [
|
||||||
'/butterfly-orange.svg', // Oranžová (monarcha)
|
'/butterfly-orange.svg',
|
||||||
'/butterfly-blue.svg', // Modrá
|
'/butterfly-blue.svg',
|
||||||
'/butterfly-yellow.svg', // Žlutá (otakárek)
|
'/butterfly-yellow.svg',
|
||||||
'/butterfly-pink.svg', // Růžová
|
'/butterfly-pink.svg',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Vzácný zlatý motýl
|
|
||||||
const GOLDEN_VARIANT = '/butterfly-golden.svg';
|
const GOLDEN_VARIANT = '/butterfly-golden.svg';
|
||||||
const GOLDEN_CHANCE = 0.025; // ~2,5 % motýlů je zlatých
|
const GOLDEN_CHANCE = 0.015; // ~1,5 % motýlů je zlatých (vzácnější)
|
||||||
|
|
||||||
// Grafika síťky (root-absolutní cesty kvůli produkčnímu servírování z kořene)
|
|
||||||
const NET_NORMAL = '/butterfly-net.svg';
|
const NET_NORMAL = '/butterfly-net.svg';
|
||||||
const NET_GOLDEN = '/butterfly-net-golden.svg';
|
const NET_GOLDEN = '/butterfly-net-golden.svg';
|
||||||
const NET_TORN = '/butterfly-net-torn.svg';
|
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_MILESTONE = 50;
|
|
||||||
const PREMIUM_DURATION_MS = 60_000;
|
const PREMIUM_DURATION_MS = 60_000;
|
||||||
export const REPAIR_COST = 10;
|
export const REPAIR_COST = 20;
|
||||||
export const REPELLENT_COST = 25;
|
export const REPELLENT_COST = 60;
|
||||||
const GOLD_VALUE = 25;
|
const GOLD_VALUE = 25;
|
||||||
|
|
||||||
|
// „Bossové" (zloděj / housenka)
|
||||||
|
const THIEF_HP = 22;
|
||||||
|
const CATERPILLAR_HP = 18;
|
||||||
|
const THIEF_MIN_COINS = 20; // zloděj přijde, jen když je co ukrást
|
||||||
|
const CATERPILLAR_MIN_CAUGHT = 15; // housenka přijde, jen když je co sežrat
|
||||||
|
|
||||||
/** Naformátuje zbývající čas jako „m:ss" nebo „s s". */
|
/** Naformátuje zbývající čas jako „m:ss" nebo „s s". */
|
||||||
function formatRemaining(ms: number): string {
|
function formatRemaining(ms: number): string {
|
||||||
const s = Math.ceil(ms / 1000);
|
const s = Math.ceil(ms / 1000);
|
||||||
@@ -37,44 +40,54 @@ function formatRemaining(ms: number): string {
|
|||||||
interface ButterflyData {
|
interface ButterflyData {
|
||||||
el: HTMLDivElement;
|
el: HTMLDivElement;
|
||||||
sprite: HTMLDivElement;
|
sprite: HTMLDivElement;
|
||||||
x: number;
|
x: number; y: number;
|
||||||
y: number;
|
dir: number; speed: number; size: number;
|
||||||
dir: number;
|
|
||||||
speed: number;
|
|
||||||
size: number;
|
|
||||||
golden: boolean;
|
golden: boolean;
|
||||||
bobFreq1: number;
|
bobFreq1: number; bobPhase1: number; bobAmp1: number;
|
||||||
bobPhase1: number;
|
bobFreq2: number; bobPhase2: number; bobAmp2: number;
|
||||||
bobAmp1: number;
|
|
||||||
bobFreq2: number;
|
|
||||||
bobPhase2: number;
|
|
||||||
bobAmp2: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Létající havěť (pták nebo vosa). */
|
/** Létající havěť (pták nebo vosa) – nalétává na síťku. */
|
||||||
interface CritterData {
|
interface CritterData {
|
||||||
el: HTMLDivElement;
|
el: HTMLDivElement;
|
||||||
x: number;
|
x: number; y: number;
|
||||||
y: number;
|
dir: number; speed: number; size: number;
|
||||||
dir: number;
|
bobPhase: number; bobAmp: number;
|
||||||
speed: number;
|
/** Pták: už protrhl síťku v tomto průletu */
|
||||||
size: number;
|
|
||||||
bobPhase: number;
|
|
||||||
bobAmp: number;
|
|
||||||
/** Pták: už protrhl síťku v tomto průletu (reset při obletu) */
|
|
||||||
triggered: boolean;
|
triggered: boolean;
|
||||||
/** Uklízeč posluchače kliknutí (jen u vos) */
|
/** Vosa: fáze útoku – nálet do síťky, nebo stažení před dalším náletem */
|
||||||
|
mode?: 'dive' | 'retreat';
|
||||||
|
/** Vosa: cíl stažení */
|
||||||
|
tx?: number; ty?: number;
|
||||||
cleanup?: () => void;
|
cleanup?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Callbacky, kterými scéna komunikuje s React obalem. */
|
/** Boss plížící se k počítadlu (zloděj krade mince, housenka žere úlovky). */
|
||||||
|
interface StalkerData {
|
||||||
|
el: HTMLDivElement;
|
||||||
|
hpInner: HTMLDivElement;
|
||||||
|
x: number; y: number;
|
||||||
|
vx: number; vy: number;
|
||||||
|
tx: number; ty: number;
|
||||||
|
hp: number; maxHp: number;
|
||||||
|
type: 'thief' | 'caterpillar';
|
||||||
|
done: boolean;
|
||||||
|
cleanup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface SceneCallbacks {
|
interface SceneCallbacks {
|
||||||
onCatch: (golden: boolean) => void;
|
onCatch: (golden: boolean) => void;
|
||||||
getCoins: () => number;
|
getCoins: () => number;
|
||||||
|
getCaught: () => 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;
|
||||||
|
onRobbery: () => void;
|
||||||
|
onDefeatThief: () => void;
|
||||||
|
onCaterpillarAte: () => void;
|
||||||
|
onDefeatCaterpillar: () => void;
|
||||||
|
onStalkerAppear: (type: 'thief' | 'caterpillar') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FlyingButterfliesProps {
|
interface FlyingButterfliesProps {
|
||||||
@@ -90,6 +103,7 @@ class ButterflyScene {
|
|||||||
private butterflies: ButterflyData[] = [];
|
private butterflies: ButterflyData[] = [];
|
||||||
private birds: CritterData[] = [];
|
private birds: CritterData[] = [];
|
||||||
private wasps: CritterData[] = [];
|
private wasps: CritterData[] = [];
|
||||||
|
private stalker: StalkerData | null = null;
|
||||||
private numButterflies: number;
|
private numButterflies: number;
|
||||||
private variants: readonly string[];
|
private variants: readonly string[];
|
||||||
private width: number;
|
private width: number;
|
||||||
@@ -109,6 +123,8 @@ class ButterflyScene {
|
|||||||
private premiumUntil: number = 0;
|
private premiumUntil: number = 0;
|
||||||
private stunUntil: number = 0;
|
private stunUntil: number = 0;
|
||||||
private lastMagnetAt: number = 0;
|
private lastMagnetAt: number = 0;
|
||||||
|
private nextCatchAt: number = 0;
|
||||||
|
private nextStalkerAt: number = 1800; // první možný boss ~30 s
|
||||||
|
|
||||||
private comboCount: number = 0;
|
private comboCount: number = 0;
|
||||||
private lastCatchTs: number = 0;
|
private lastCatchTs: number = 0;
|
||||||
@@ -128,16 +144,15 @@ class ButterflyScene {
|
|||||||
private static readonly STING_RADIUS = 46;
|
private static readonly STING_RADIUS = 46;
|
||||||
private static readonly STUN_FRAMES = 300; // ~5 s omráčení
|
private static readonly STUN_FRAMES = 300; // ~5 s omráčení
|
||||||
private static readonly MAGNET_RADIUS = 150;
|
private static readonly MAGNET_RADIUS = 150;
|
||||||
private static readonly MAGNET_INTERVAL = 42;
|
private static readonly MAGNET_INTERVAL = 60; // ~1 s
|
||||||
|
private static readonly CATCH_COOLDOWN = 8; // max ~7 chycení/s (proti zmenšení okna)
|
||||||
private static readonly COMBO_WINDOW_MS = 1200;
|
private static readonly COMBO_WINDOW_MS = 1200;
|
||||||
|
|
||||||
constructor(
|
private static readonly STALKER_TRAVEL_FRAMES = 1700; // ~28 s na doplížení
|
||||||
el: HTMLElement,
|
private static readonly STALKER_MIN_GAP = 3600; // 60 s
|
||||||
numButterflies: number,
|
private static readonly STALKER_MAX_GAP = 9000; // 150 s
|
||||||
variants: readonly string[],
|
|
||||||
netEnabled: boolean,
|
constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) {
|
||||||
cb: SceneCallbacks,
|
|
||||||
) {
|
|
||||||
this.viewport = el;
|
this.viewport = el;
|
||||||
this.world = document.createElement('div');
|
this.world = document.createElement('div');
|
||||||
this.numButterflies = numButterflies;
|
this.numButterflies = numButterflies;
|
||||||
@@ -164,15 +179,9 @@ class ButterflyScene {
|
|||||||
private resetButterfly = (b: ButterflyData): void => {
|
private resetButterfly = (b: ButterflyData): void => {
|
||||||
b.dir = Math.random() > 0.5 ? 1 : -1;
|
b.dir = Math.random() > 0.5 ? 1 : -1;
|
||||||
b.speed = Math.random() * 1.1 + 0.7;
|
b.speed = Math.random() * 1.1 + 0.7;
|
||||||
|
b.x = this.timer === 0 ? Math.random() * this.width : (b.dir === 1 ? -40 : this.width + 40);
|
||||||
if (this.timer === 0) {
|
|
||||||
b.x = Math.random() * this.width;
|
|
||||||
} else {
|
|
||||||
b.x = b.dir === 1 ? -40 : this.width + 40;
|
|
||||||
}
|
|
||||||
b.y = Math.random() * (this.height - 60) + 30;
|
b.y = Math.random() * (this.height - 60) + 30;
|
||||||
b.size = Math.random() * 0.6 + 0.6;
|
b.size = Math.random() * 0.6 + 0.6;
|
||||||
|
|
||||||
b.bobFreq1 = Math.random() * 0.02 + 0.03;
|
b.bobFreq1 = Math.random() * 0.02 + 0.03;
|
||||||
b.bobPhase1 = Math.random() * Math.PI * 2;
|
b.bobPhase1 = Math.random() * Math.PI * 2;
|
||||||
b.bobAmp1 = Math.random() * 0.8 + 0.6;
|
b.bobAmp1 = Math.random() * 0.8 + 0.6;
|
||||||
@@ -184,10 +193,9 @@ class ButterflyScene {
|
|||||||
if (b.golden) {
|
if (b.golden) {
|
||||||
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
|
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
|
||||||
b.sprite.classList.add('golden');
|
b.sprite.classList.add('golden');
|
||||||
b.size = Math.random() * 0.5 + 1.0; // zlatý je nápadně větší
|
b.size = Math.random() * 0.5 + 1.0;
|
||||||
} else {
|
} else {
|
||||||
const variant = 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.style.backgroundImage = `url(${variant})`;
|
|
||||||
b.sprite.classList.remove('golden');
|
b.sprite.classList.remove('golden');
|
||||||
}
|
}
|
||||||
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`;
|
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`;
|
||||||
@@ -198,16 +206,10 @@ class ButterflyScene {
|
|||||||
const vy =
|
const vy =
|
||||||
Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 +
|
Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 +
|
||||||
Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2;
|
Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2;
|
||||||
b.x += vx;
|
b.x += vx; b.y += vy;
|
||||||
b.y += vy;
|
|
||||||
const angle = Math.atan2(vy, vx) * (180 / Math.PI) + 90;
|
const angle = Math.atan2(vy, vx) * (180 / Math.PI) + 90;
|
||||||
b.el.style.transform = `translate(${b.x}px, ${b.y}px) rotate(${angle}deg) scale(${b.size})`;
|
b.el.style.transform = `translate(${b.x}px, ${b.y}px) rotate(${angle}deg) scale(${b.size})`;
|
||||||
|
if ((b.dir === 1 && b.x > this.width + 50) || (b.dir === -1 && b.x < -50) || b.y < -60 || b.y > this.height + 60) {
|
||||||
if (
|
|
||||||
(b.dir === 1 && b.x > this.width + 50) ||
|
|
||||||
(b.dir === -1 && b.x < -50) ||
|
|
||||||
b.y < -60 || b.y > this.height + 60
|
|
||||||
) {
|
|
||||||
this.resetButterfly(b);
|
this.resetButterfly(b);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -215,8 +217,7 @@ class ButterflyScene {
|
|||||||
// --- Síťka ---------------------------------------------------------------
|
// --- Síťka ---------------------------------------------------------------
|
||||||
|
|
||||||
private updateNetTransform = (): void => {
|
private updateNetTransform = (): void => {
|
||||||
this.net.style.transform =
|
this.net.style.transform = `translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`;
|
||||||
`translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private applyNetAppearance = (): void => {
|
private applyNetAppearance = (): void => {
|
||||||
@@ -236,18 +237,14 @@ class ButterflyScene {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.netGrabbed = true;
|
this.netGrabbed = true;
|
||||||
this.net.classList.add('grabbed');
|
this.net.classList.add('grabbed');
|
||||||
this.netX = e.clientX;
|
this.netX = e.clientX; this.netY = e.clientY;
|
||||||
this.netY = e.clientY;
|
|
||||||
this.updateNetTransform();
|
this.updateNetTransform();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onPointerMove = (e: PointerEvent): void => {
|
private onPointerMove = (e: PointerEvent): void => {
|
||||||
if (!this.netGrabbed) return;
|
if (!this.netGrabbed) return;
|
||||||
this.netX = e.clientX;
|
this.netX = e.clientX; this.netY = e.clientY;
|
||||||
this.netY = e.clientY;
|
|
||||||
this.updateNetTransform();
|
this.updateNetTransform();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onPointerUp = (): void => {
|
private onPointerUp = (): void => {
|
||||||
if (!this.netGrabbed) return;
|
if (!this.netGrabbed) return;
|
||||||
this.netGrabbed = false;
|
this.netGrabbed = false;
|
||||||
@@ -274,6 +271,7 @@ class ButterflyScene {
|
|||||||
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,24 +283,24 @@ class ButterflyScene {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna)
|
||||||
private checkCatches = (): void => {
|
private checkCatches = (): void => {
|
||||||
if (this.torn || this.timer < this.stunUntil) return;
|
if (this.torn || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
|
||||||
const r2 = this.catchRadius * this.catchRadius;
|
const r2 = this.catchRadius * this.catchRadius;
|
||||||
const half = ButterflyScene.BASE_SIZE / 2;
|
const half = ButterflyScene.BASE_SIZE / 2;
|
||||||
let caughtAny = false;
|
|
||||||
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) {
|
||||||
caughtAny = true;
|
|
||||||
this.registerCatch(b, this.netX, this.netY);
|
this.registerCatch(b, this.netX, this.netY);
|
||||||
|
this.popNet();
|
||||||
|
return; // jen jeden za cooldown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (caughtAny) this.popNet();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private runMagnet = (): void => {
|
private runMagnet = (): void => {
|
||||||
if (this.torn || this.timer < this.stunUntil) return;
|
if (this.torn || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
|
||||||
if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return;
|
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;
|
||||||
@@ -321,15 +319,11 @@ class ButterflyScene {
|
|||||||
this.premiumUntil = Date.now() + PREMIUM_DURATION_MS;
|
this.premiumUntil = Date.now() + PREMIUM_DURATION_MS;
|
||||||
this.applyNetAppearance();
|
this.applyNetAppearance();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Vnější (zaplacená) oprava protržené síťky. */
|
|
||||||
public repairNetExternally = (): void => {
|
public repairNetExternally = (): void => {
|
||||||
if (!this.torn) return;
|
if (!this.torn) return;
|
||||||
this.torn = false;
|
this.torn = false;
|
||||||
this.applyNetAppearance();
|
this.applyNetAppearance();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Nastaví protržení podle serveru (init/poll), bez hlášení zpět. */
|
|
||||||
public setTornFromServer = (torn: boolean): void => {
|
public setTornFromServer = (torn: boolean): void => {
|
||||||
if (this.torn === torn) return;
|
if (this.torn === torn) return;
|
||||||
this.torn = torn;
|
this.torn = torn;
|
||||||
@@ -364,13 +358,14 @@ class ButterflyScene {
|
|||||||
const c: CritterData = {
|
const c: CritterData = {
|
||||||
el,
|
el,
|
||||||
x: dir === 1 ? -size : this.width + size,
|
x: dir === 1 ? -size : this.width + size,
|
||||||
y: Math.random() * (this.height * 0.7) + this.height * 0.1,
|
y: Math.random() * (this.height * 0.6) + this.height * 0.1,
|
||||||
dir,
|
dir,
|
||||||
speed: type === 'bird' ? Math.random() * 2 + 3 : Math.random() * 1.4 + 2,
|
speed: type === 'bird' ? Math.random() * 2 + 3 : Math.random() * 1.4 + 2,
|
||||||
size,
|
size,
|
||||||
bobPhase: Math.random() * Math.PI * 2,
|
bobPhase: Math.random() * Math.PI * 2,
|
||||||
bobAmp: Math.random() * 0.8 + 0.5,
|
bobAmp: Math.random() * 0.8 + 0.5,
|
||||||
triggered: false,
|
triggered: false,
|
||||||
|
mode: type === 'wasp' ? 'dive' : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === 'wasp') {
|
if (type === 'wasp') {
|
||||||
@@ -395,54 +390,81 @@ class ButterflyScene {
|
|||||||
private wrapCritter = (c: CritterData): void => {
|
private wrapCritter = (c: CritterData): void => {
|
||||||
c.dir = Math.random() > 0.5 ? 1 : -1;
|
c.dir = Math.random() > 0.5 ? 1 : -1;
|
||||||
c.x = c.dir === 1 ? -c.size : this.width + c.size;
|
c.x = c.dir === 1 ? -c.size : this.width + c.size;
|
||||||
c.y = Math.random() * (this.height * 0.7) + this.height * 0.1;
|
c.y = Math.random() * (this.height * 0.6) + this.height * 0.1;
|
||||||
c.triggered = false;
|
c.triggered = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateWasp = (c: CritterData): void => {
|
/**
|
||||||
c.x += c.dir * c.speed;
|
* Pohyb havěti. `homing` 0 = letí rovně jedním směrem (ptáci), >0 = agresivně
|
||||||
// mírné míření k obruči, aby vosy otravovaly zrovna síťku
|
* nalétává k síťce (vosy). Sprite se natáčí podle směru letu.
|
||||||
const targetY = this.netY - c.size / 2;
|
*/
|
||||||
c.y += (targetY - c.y) * 0.015 + Math.sin(this.timer * 0.06 + c.bobPhase) * c.bobAmp;
|
private moveCritter = (c: CritterData, homingX: number, homingY: number): void => {
|
||||||
const flip = c.dir === -1 ? -1 : 1;
|
const cx = c.x + c.size / 2;
|
||||||
c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`;
|
const cy = c.y + c.size / 2;
|
||||||
if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) {
|
const bob = Math.sin(this.timer * 0.05 + c.bobPhase) * c.bobAmp * 0.5;
|
||||||
|
const vx = c.dir * c.speed + (homingX ? (this.netX - cx) * homingX : 0);
|
||||||
|
const vy = (homingY ? (this.netY - cy) * homingY : 0) + bob;
|
||||||
|
c.x += vx;
|
||||||
|
c.y += vy;
|
||||||
|
|
||||||
|
// Natočení podle směru letu (sprite je nakreslený doprava); při letu doleva
|
||||||
|
// se svisle překlopí, aby nebyl vzhůru nohama.
|
||||||
|
const angle = Math.atan2(vy, vx) * (180 / Math.PI);
|
||||||
|
const flipY = Math.abs(angle) > 90 ? -1 : 1;
|
||||||
|
c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${angle}deg) scaleY(${flipY})`;
|
||||||
|
|
||||||
|
if (c.x > this.width + c.size * 2 || c.x < -c.size * 2 || c.y < -c.size * 2 || c.y > this.height + c.size * 2) {
|
||||||
this.wrapCritter(c);
|
this.wrapCritter(c);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateBird = (c: CritterData): void => {
|
private removeLastCritter = (arr: CritterData[]): void => {
|
||||||
c.x += c.dir * c.speed;
|
|
||||||
c.y += Math.sin(this.timer * 0.03 + c.bobPhase) * c.bobAmp;
|
|
||||||
const flip = c.dir === -1 ? -1 : 1;
|
|
||||||
c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`;
|
|
||||||
if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) {
|
|
||||||
this.wrapCritter(c);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private removeCritter = (arr: CritterData[]): void => {
|
|
||||||
const c = arr.pop();
|
const c = arr.pop();
|
||||||
if (c) {
|
if (c) { c.cleanup?.(); if (c.el.parentNode) c.el.parentNode.removeChild(c.el); }
|
||||||
c.cleanup?.();
|
|
||||||
if (c.el.parentNode) c.el.parentNode.removeChild(c.el);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sladí počet vos a ptáků ve scéně s hodnotami ze serveru. */
|
|
||||||
public syncPests = (wasps: number, birds: number): void => {
|
public syncPests = (wasps: number, birds: number): void => {
|
||||||
if (!this.netEnabled) return;
|
if (!this.netEnabled) return;
|
||||||
while (this.wasps.length < wasps) this.wasps.push(this.spawnCritter('wasp'));
|
while (this.wasps.length < wasps) this.wasps.push(this.spawnCritter('wasp'));
|
||||||
while (this.wasps.length > wasps) this.removeCritter(this.wasps);
|
while (this.wasps.length > wasps) this.removeLastCritter(this.wasps);
|
||||||
while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird'));
|
while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird'));
|
||||||
while (this.birds.length > birds) this.removeCritter(this.birds);
|
while (this.birds.length > birds) this.removeLastCritter(this.birds);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Posune tvora plnou rychlostí k cíli a natočí ho po směru letu. */
|
||||||
|
private steerTo = (c: CritterData, targetX: number, targetY: number): void => {
|
||||||
|
const cx = c.x + c.size / 2;
|
||||||
|
const cy = c.y + c.size / 2;
|
||||||
|
const dx = targetX - cx;
|
||||||
|
const dy = targetY - cy;
|
||||||
|
const dist = Math.hypot(dx, dy) || 1;
|
||||||
|
const vx = (dx / dist) * c.speed;
|
||||||
|
const vy = (dy / dist) * c.speed;
|
||||||
|
c.x += vx;
|
||||||
|
c.y += vy;
|
||||||
|
const angle = Math.atan2(vy, vx) * (180 / Math.PI);
|
||||||
|
const flipY = Math.abs(angle) > 90 ? -1 : 1;
|
||||||
|
c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${angle}deg) scaleY(${flipY})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateWasps = (): void => {
|
private updateWasps = (): void => {
|
||||||
for (const wasp of this.wasps) {
|
for (const wasp of this.wasps) {
|
||||||
this.updateWasp(wasp);
|
if (wasp.mode === 'retreat') {
|
||||||
// Vosa žihne síťku, jakmile pomine předchozí omráčení
|
// Stáhnutí kousek od síťky před dalším náletem
|
||||||
if (!this.torn && this.timer >= this.stunUntil && this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) {
|
this.steerTo(wasp, wasp.tx ?? this.netX, wasp.ty ?? this.netY);
|
||||||
|
const dx = (wasp.x + wasp.size / 2) - (wasp.tx ?? this.netX);
|
||||||
|
const dy = (wasp.y + wasp.size / 2) - (wasp.ty ?? this.netY);
|
||||||
|
if (dx * dx + dy * dy < 26 * 26) wasp.mode = 'dive';
|
||||||
|
} else {
|
||||||
|
// Nálet přímo do síťky
|
||||||
|
this.steerTo(wasp, this.netX, this.netY);
|
||||||
|
if (this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) {
|
||||||
|
// Vytažení a příprava na další nálet
|
||||||
|
wasp.mode = 'retreat';
|
||||||
|
wasp.tx = Math.max(30, Math.min(this.width - 30, this.netX + (Math.random() * 2 - 1) * 220));
|
||||||
|
wasp.ty = Math.max(30, this.netY - (70 + Math.random() * 150));
|
||||||
|
// Žihnutí (jen když už pominulo předchozí omráčení)
|
||||||
|
if (!this.torn && this.timer >= this.stunUntil) {
|
||||||
this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES;
|
this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES;
|
||||||
this.net.classList.remove('stung');
|
this.net.classList.remove('stung');
|
||||||
void this.net.offsetWidth;
|
void this.net.offsetWidth;
|
||||||
@@ -451,14 +473,19 @@ class ButterflyScene {
|
|||||||
this.cb.onSting();
|
this.cb.onSting();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateBirds = (): void => {
|
private updateBirds = (): void => {
|
||||||
for (const bird of this.birds) {
|
for (const bird of this.birds) {
|
||||||
this.updateBird(bird);
|
// Svisle míří na výšku síťky jen ptáci, kteří k ní letí (mají ji před sebou);
|
||||||
// Prémiová síťka je z pevnějšího materiálu; protrhne se jen když má hráč na opravu
|
// kdo síťku minul, dolétne rovně.
|
||||||
|
const cx = bird.x + bird.size / 2;
|
||||||
|
const towardNet = (bird.dir === 1 && this.netX > cx) || (bird.dir === -1 && this.netX < cx);
|
||||||
|
this.moveCritter(bird, 0, towardNet ? 0.02 : 0);
|
||||||
|
// Pták protrhne síťku vždy (i bez mincí); síťka se pak sama zašije po čase
|
||||||
if (!bird.triggered && this.netGrabbed && !this.torn && !this.premiumActive
|
if (!bird.triggered && this.netGrabbed && !this.torn && !this.premiumActive
|
||||||
&& this.cb.getCoins() >= REPAIR_COST
|
|
||||||
&& this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) {
|
&& this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) {
|
||||||
bird.triggered = true;
|
bird.triggered = true;
|
||||||
this.tearNet(this.netX, this.netY);
|
this.tearNet(this.netX, this.netY);
|
||||||
@@ -466,6 +493,117 @@ class ButterflyScene {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Bossové (zloděj / housenka) ----------------------------------------
|
||||||
|
|
||||||
|
private trySpawnStalker = (): void => {
|
||||||
|
if (this.timer < this.nextStalkerAt || this.stalker) return;
|
||||||
|
const canThief = this.cb.getCoins() >= THIEF_MIN_COINS;
|
||||||
|
const canCat = this.cb.getCaught() >= CATERPILLAR_MIN_CAUGHT;
|
||||||
|
const options: ('thief' | 'caterpillar')[] = [];
|
||||||
|
if (canThief) options.push('thief');
|
||||||
|
if (canCat) options.push('caterpillar');
|
||||||
|
if (options.length === 0) {
|
||||||
|
this.nextStalkerAt = this.timer + 600; // zkus to znovu za ~10 s
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = options[Math.floor(Math.random() * options.length)];
|
||||||
|
this.spawnStalker(type);
|
||||||
|
this.nextStalkerAt = this.timer + ButterflyScene.STALKER_MIN_GAP
|
||||||
|
+ Math.floor(Math.random() * (ButterflyScene.STALKER_MAX_GAP - ButterflyScene.STALKER_MIN_GAP));
|
||||||
|
};
|
||||||
|
|
||||||
|
private spawnStalker = (type: 'thief' | 'caterpillar'): void => {
|
||||||
|
const maxHp = type === 'thief' ? THIEF_HP : CATERPILLAR_HP;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `butterfly-stalker ${type}`;
|
||||||
|
el.title = type === 'thief' ? 'Zaklikej zloděje!' : 'Zaklikej housenku!';
|
||||||
|
|
||||||
|
const hp = document.createElement('div');
|
||||||
|
hp.className = 'stalker-hp';
|
||||||
|
const hpInner = document.createElement('div');
|
||||||
|
hpInner.className = 'stalker-hp-inner';
|
||||||
|
hp.appendChild(hpInner);
|
||||||
|
|
||||||
|
const emoji = document.createElement('div');
|
||||||
|
emoji.className = 'stalker-emoji';
|
||||||
|
emoji.textContent = type === 'thief' ? '🦹' : '🐛';
|
||||||
|
|
||||||
|
el.appendChild(hp);
|
||||||
|
el.appendChild(emoji);
|
||||||
|
|
||||||
|
// Start v horním rohu, cíl u počítadla (vlevo dole)
|
||||||
|
const fromLeft = Math.random() < 0.5;
|
||||||
|
const startX = fromLeft ? 10 : this.width - 70;
|
||||||
|
const startY = 10;
|
||||||
|
const tx = type === 'thief' ? 95 : 45;
|
||||||
|
const ty = this.height - 55;
|
||||||
|
|
||||||
|
const s: StalkerData = {
|
||||||
|
el, hpInner,
|
||||||
|
x: startX, y: startY,
|
||||||
|
vx: (tx - startX) / ButterflyScene.STALKER_TRAVEL_FRAMES,
|
||||||
|
vy: (ty - startY) / ButterflyScene.STALKER_TRAVEL_FRAMES,
|
||||||
|
tx, ty, hp: maxHp, maxHp, type, done: false,
|
||||||
|
cleanup: () => { /* nahrazeno níže */ },
|
||||||
|
};
|
||||||
|
|
||||||
|
const hit = (ev: Event) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (s.done) return;
|
||||||
|
s.hp -= 1;
|
||||||
|
s.hpInner.style.width = `${Math.max(0, (s.hp / s.maxHp) * 100)}%`;
|
||||||
|
el.classList.remove('hit');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('hit');
|
||||||
|
if (s.hp <= 0) this.defeatStalker(s);
|
||||||
|
};
|
||||||
|
el.addEventListener('pointerdown', hit);
|
||||||
|
s.cleanup = () => el.removeEventListener('pointerdown', hit);
|
||||||
|
|
||||||
|
this.viewport.appendChild(el);
|
||||||
|
this.stalker = s;
|
||||||
|
this.cb.onStalkerAppear(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
private removeStalker = (): void => {
|
||||||
|
const s = this.stalker;
|
||||||
|
if (!s) return;
|
||||||
|
s.cleanup();
|
||||||
|
if (s.el.parentNode) s.el.parentNode.removeChild(s.el);
|
||||||
|
this.stalker = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private defeatStalker = (s: StalkerData): void => {
|
||||||
|
if (s.done) return;
|
||||||
|
s.done = true;
|
||||||
|
this.spawnFx(s.type === 'thief' ? '💰' : '🍃', 'butterfly-swat-fx', s.x + 24, s.y + 24);
|
||||||
|
if (s.type === 'thief') this.cb.onDefeatThief(); else this.cb.onDefeatCaterpillar();
|
||||||
|
this.removeStalker();
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateStalker = (): void => {
|
||||||
|
const s = this.stalker;
|
||||||
|
if (!s || s.done) return;
|
||||||
|
s.x += s.vx; s.y += s.vy;
|
||||||
|
s.el.style.transform = `translate(${s.x}px, ${s.y}px)`;
|
||||||
|
const dx = s.x - s.tx;
|
||||||
|
const dy = s.y - s.ty;
|
||||||
|
if (dx * dx + dy * dy < 400) {
|
||||||
|
s.done = true;
|
||||||
|
if (s.type === 'thief') {
|
||||||
|
this.spawnFx('💸 Okradeno!', 'butterfly-tear-fx', s.tx, s.ty - 20);
|
||||||
|
this.cb.onRobbery();
|
||||||
|
} else {
|
||||||
|
this.spawnFx('🐛 Sežráno!', 'butterfly-tear-fx', s.tx, s.ty - 20);
|
||||||
|
this.cb.onCaterpillarAte();
|
||||||
|
}
|
||||||
|
this.removeStalker();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Init / render ------------------------------------------------------
|
||||||
|
|
||||||
private initNet = (): void => {
|
private initNet = (): void => {
|
||||||
this.net.className = 'butterfly-net';
|
this.net.className = 'butterfly-net';
|
||||||
this.applyNetAppearance();
|
this.applyNetAppearance();
|
||||||
@@ -483,7 +621,6 @@ class ButterflyScene {
|
|||||||
this.butterflies = [];
|
this.butterflies = [];
|
||||||
this.world.innerHTML = '';
|
this.world.innerHTML = '';
|
||||||
this.world.className = 'butterfly-scene';
|
this.world.className = 'butterfly-scene';
|
||||||
|
|
||||||
for (let i = 0; i < this.numButterflies; i++) {
|
for (let i = 0; i < this.numButterflies; i++) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
const sprite = document.createElement('div');
|
const sprite = document.createElement('div');
|
||||||
@@ -497,7 +634,6 @@ class ButterflyScene {
|
|||||||
this.butterflies.push(b);
|
this.butterflies.push(b);
|
||||||
this.world.appendChild(el);
|
this.world.appendChild(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -516,6 +652,8 @@ class ButterflyScene {
|
|||||||
|
|
||||||
this.updateWasps();
|
this.updateWasps();
|
||||||
this.updateBirds();
|
this.updateBirds();
|
||||||
|
this.trySpawnStalker();
|
||||||
|
this.updateStalker();
|
||||||
|
|
||||||
const stunned = this.timer < this.stunUntil;
|
const stunned = this.timer < this.stunUntil;
|
||||||
if (stunned !== this.wasStunned) {
|
if (stunned !== this.wasStunned) {
|
||||||
@@ -535,7 +673,7 @@ class ButterflyScene {
|
|||||||
if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; }
|
if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; }
|
||||||
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();
|
||||||
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);
|
||||||
@@ -547,10 +685,10 @@ class ButterflyScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Komponenta minihry chytání motýlků: mince, úrovně, prémiová zlatá síťka za
|
* Minihra chytání motýlků: mince, úrovně (strmá křivka), prémiová zlatá síťka,
|
||||||
* milník, magnet, vzácní zlatí motýli, kombo, denní bonus a perzistentní škůdci
|
* vzácní zlatí motýli, kombo, perzistentní škůdci (vosy na zaklikání, nalétávající
|
||||||
* (vosy na zaklikání, ptáci trhající síťku, placený plašič). Stav se ukládá na
|
* ptáci), zloděj mincí a housenka žeroucí úlovky. Stav na serveru přes
|
||||||
* server přes {@link useButterflyStats}.
|
* {@link useButterflyStats}; rate-cap chrání proti podvádění.
|
||||||
*/
|
*/
|
||||||
const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
||||||
numButterflies = 9,
|
numButterflies = 9,
|
||||||
@@ -562,7 +700,6 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
|||||||
const sceneRef = useRef<ButterflyScene | null>(null);
|
const sceneRef = useRef<ButterflyScene | null>(null);
|
||||||
const badgeRef = useRef<HTMLDivElement>(null);
|
const badgeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [torn, setTorn] = useState(false);
|
|
||||||
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);
|
||||||
@@ -582,38 +719,55 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
|||||||
} else if (e.leveledUp) {
|
} else if (e.leveledUp) {
|
||||||
showFlash(`⭐ Nová úroveň: ${e.newTitle}!`);
|
showFlash(`⭐ Nová úroveň: ${e.newTitle}!`);
|
||||||
} else if (e.dailyBonusApplied) {
|
} else if (e.dailyBonusApplied) {
|
||||||
showFlash('☀️ Denní bonus +10 mincí!');
|
showFlash('☀️ Denní bonus mincí!');
|
||||||
}
|
}
|
||||||
}, [showFlash]);
|
}, [showFlash]);
|
||||||
|
|
||||||
const { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear } = useButterflyStats(handleReward);
|
const {
|
||||||
|
stats, displayCaught, coinsRef, reportCatch, repair,
|
||||||
|
killWasp, buyRepellent, reportTear, reportRobbery, defeatThief,
|
||||||
|
caterpillarAte, defeatCaterpillar,
|
||||||
|
} = useButterflyStats(handleReward);
|
||||||
|
|
||||||
const callbacksRef = useRef<SceneCallbacks>({
|
const callbacksRef = useRef<SceneCallbacks>({
|
||||||
onCatch: () => { }, getCoins: () => 0, onTornChange: () => { },
|
onCatch: () => { }, getCoins: () => 0, getCaught: () => 0, onTornChange: () => { },
|
||||||
onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { },
|
onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { },
|
||||||
|
onRobbery: () => { }, onDefeatThief: () => { }, onCaterpillarAte: () => { },
|
||||||
|
onDefeatCaterpillar: () => { }, onStalkerAppear: () => { },
|
||||||
});
|
});
|
||||||
callbacksRef.current = {
|
callbacksRef.current = {
|
||||||
onCatch: (golden) => reportCatch(golden),
|
onCatch: (golden) => reportCatch(golden),
|
||||||
getCoins: () => coinsRef.current,
|
getCoins: () => coinsRef.current,
|
||||||
onTornChange: (t) => { setTorn(t); if (t) void reportTear(); },
|
getCaught: () => stats?.caught ?? 0,
|
||||||
|
onTornChange: (t) => { if (t) void reportTear(); },
|
||||||
onCombo: (c) => setCombo(c),
|
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(),
|
||||||
|
onRobbery: () => { void reportRobbery(); showFlash('💸 Zloděj ti ukradl většinu mincí! Příště ho zaklikej.'); },
|
||||||
|
onDefeatThief: () => { void defeatThief(); showFlash('💰 Zloděj poražen! Malá odměna a mince v bezpečí.'); },
|
||||||
|
onCaterpillarAte: () => { void caterpillarAte(); showFlash('🐛 Housenka ti sežrala část úlovků!'); },
|
||||||
|
onDefeatCaterpillar: () => { void defeatCaterpillar(); showFlash('🍃 Housenka poražena! Úlovky v bezpečí.'); },
|
||||||
|
onStalkerAppear: (type) => showFlash(type === 'thief'
|
||||||
|
? '🦹 Zloděj! Míří k tvým mincím – zaklikej ho (chce to hodně ran)!'
|
||||||
|
: '🐛 Housenka! Plíží se k tvým úlovkům – zaklikej ji!'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialize = useCallback(() => {
|
const initialize = useCallback(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
sceneRef.current = new ButterflyScene(
|
sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants, enableNet, {
|
||||||
containerRef.current, numButterflies, butterflyVariants, enableNet,
|
|
||||||
{
|
|
||||||
onCatch: (g) => callbacksRef.current.onCatch(g),
|
onCatch: (g) => callbacksRef.current.onCatch(g),
|
||||||
getCoins: () => callbacksRef.current.getCoins(),
|
getCoins: () => callbacksRef.current.getCoins(),
|
||||||
|
getCaught: () => callbacksRef.current.getCaught(),
|
||||||
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(),
|
||||||
},
|
onRobbery: () => callbacksRef.current.onRobbery(),
|
||||||
);
|
onDefeatThief: () => callbacksRef.current.onDefeatThief(),
|
||||||
|
onCaterpillarAte: () => callbacksRef.current.onCaterpillarAte(),
|
||||||
|
onDefeatCaterpillar: () => callbacksRef.current.onDefeatCaterpillar(),
|
||||||
|
onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t),
|
||||||
|
});
|
||||||
sceneRef.current.init();
|
sceneRef.current.init();
|
||||||
sceneRef.current.render();
|
sceneRef.current.render();
|
||||||
}
|
}
|
||||||
@@ -627,39 +781,37 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
|||||||
};
|
};
|
||||||
}, [initialize]);
|
}, [initialize]);
|
||||||
|
|
||||||
// Synchronizace škůdců a protržení ze serveru do scény
|
|
||||||
const wasps = stats?.wasps ?? 0;
|
const wasps = stats?.wasps ?? 0;
|
||||||
const birds = stats?.birds ?? 0;
|
const birds = stats?.birds ?? 0;
|
||||||
const netTorn = stats?.netTorn ?? false;
|
const netTornUntil = stats?.netTornUntil ?? 0;
|
||||||
|
const torn = netTornUntil > nowTs;
|
||||||
|
const tornRemainingMs = Math.max(0, netTornUntil - nowTs);
|
||||||
useEffect(() => { sceneRef.current?.syncPests(wasps, birds); }, [wasps, birds]);
|
useEffect(() => { sceneRef.current?.syncPests(wasps, birds); }, [wasps, birds]);
|
||||||
useEffect(() => {
|
useEffect(() => { sceneRef.current?.setTornFromServer(torn); }, [torn]);
|
||||||
sceneRef.current?.setTornFromServer(netTorn);
|
|
||||||
setTorn(netTorn);
|
|
||||||
}, [netTorn]);
|
|
||||||
|
|
||||||
const caught = stats?.caught ?? 0;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = badgeRef.current;
|
const node = badgeRef.current;
|
||||||
if (!node || caught === 0) return;
|
if (!node || displayCaught === 0) return;
|
||||||
node.classList.remove('bump');
|
node.classList.remove('bump');
|
||||||
void node.offsetWidth;
|
void node.offsetWidth;
|
||||||
node.classList.add('bump');
|
node.classList.add('bump');
|
||||||
}, [caught]);
|
}, [displayCaught]);
|
||||||
|
|
||||||
const coins = stats?.coins ?? 0;
|
const coins = stats?.coins ?? 0;
|
||||||
const canAffordRepair = coins >= REPAIR_COST;
|
const canAffordRepair = coins >= REPAIR_COST;
|
||||||
const canAffordRepellent = coins >= REPELLENT_COST;
|
const canAffordRepellent = coins >= REPELLENT_COST;
|
||||||
const progress = Math.round(((caught % PREMIUM_MILESTONE) / PREMIUM_MILESTONE) * 100);
|
const levelProgress = stats?.levelProgress ?? 0;
|
||||||
const repellentUntil = stats?.repellentUntil ?? 0;
|
const repellentUntil = stats?.repellentUntil ?? 0;
|
||||||
const repellentRemainingMs = Math.max(0, repellentUntil - nowTs);
|
const repellentRemainingMs = Math.max(0, repellentUntil - nowTs);
|
||||||
const repellentActive = repellentRemainingMs > 0;
|
const repellentActive = repellentRemainingMs > 0;
|
||||||
|
|
||||||
// Tik po sekundách pro odpočet plašiče (jen dokud běží)
|
// Tik po sekundách pro odpočty plašiče i protržené síťky (jen dokud běží)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (repellentUntil <= Date.now()) return;
|
const until = Math.max(repellentUntil, netTornUntil);
|
||||||
|
if (until <= Date.now()) return;
|
||||||
const id = window.setInterval(() => setNowTs(Date.now()), 1000);
|
const id = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [repellentUntil]);
|
}, [repellentUntil, netTornUntil]);
|
||||||
|
|
||||||
const onRepairClick = useCallback(async () => {
|
const onRepairClick = useCallback(async () => {
|
||||||
const ok = await repair();
|
const ok = await repair();
|
||||||
@@ -694,7 +846,7 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="butterfly-counter-row">
|
<div className="butterfly-counter-row">
|
||||||
<span className="butterfly-counter-icon" aria-hidden="true" />
|
<span className="butterfly-counter-icon" aria-hidden="true" />
|
||||||
<span className="butterfly-counter-value">{caught}</span>
|
<span className="butterfly-counter-value">{displayCaught}</span>
|
||||||
<span className="butterfly-coin-icon" aria-hidden="true" />
|
<span className="butterfly-coin-icon" aria-hidden="true" />
|
||||||
<span className="butterfly-counter-value">{coins}</span>
|
<span className="butterfly-counter-value">{coins}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -702,8 +854,8 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
|||||||
<span className="butterfly-level-title">
|
<span className="butterfly-level-title">
|
||||||
{stats ? `${stats.level}. ${stats.title}` : '…'}
|
{stats ? `${stats.level}. ${stats.title}` : '…'}
|
||||||
</span>
|
</span>
|
||||||
<div className="butterfly-progress" title="Postup k další zlaté síťce">
|
<div className="butterfly-progress" title="Postup do další úrovně">
|
||||||
<div className="butterfly-progress-bar" style={{ width: `${progress}%` }} />
|
<div className="butterfly-progress-bar" style={{ width: `${levelProgress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -712,21 +864,26 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
|||||||
<span className={birds > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds}</span>
|
<span className={birds > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds}</span>
|
||||||
{repellentActive && (
|
{repellentActive && (
|
||||||
<span className="butterfly-pest ok" title="Zbývající doba plašiče">
|
<span className="butterfly-pest ok" title="Zbývající doba plašiče">
|
||||||
🚫 plašič {formatRemaining(repellentRemainingMs)}
|
🚫 {formatRemaining(repellentRemainingMs)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{torn && (
|
{torn && (
|
||||||
|
<>
|
||||||
|
<div className="butterfly-torn-note">
|
||||||
|
🕳️ Protržená síťka – sama se zašije za {formatRemaining(tornRemainingMs)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="butterfly-action-btn danger"
|
className="butterfly-action-btn danger"
|
||||||
onClick={(ev) => { ev.stopPropagation(); void onRepairClick(); }}
|
onClick={(ev) => { ev.stopPropagation(); void onRepairClick(); }}
|
||||||
disabled={!canAffordRepair}
|
disabled={!canAffordRepair}
|
||||||
title={canAffordRepair ? 'Zašít protrženou síťku' : 'Nemáš dost mincí – nachytej další motýly'}
|
title={canAffordRepair ? 'Zašít hned za mince' : 'Nemáš dost mincí – počkej, než se zašije sama'}
|
||||||
>
|
>
|
||||||
🪡 Zašít síťku za {REPAIR_COST} 🪙
|
🪡 Zašít hned za {REPAIR_COST} 🪙
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{birds > 0 && !repellentActive && (
|
{birds > 0 && !repellentActive && (
|
||||||
@@ -742,23 +899,14 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ButterflyLeaderboardModal
|
<ButterflyLeaderboardModal isOpen={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} myStats={stats} />
|
||||||
isOpen={leaderboardOpen}
|
|
||||||
onClose={() => setLeaderboardOpen(false)}
|
|
||||||
myStats={stats}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BUTTERFLY_PRESETS = {
|
export const BUTTERFLY_PRESETS = { LIGHT: 5, NORMAL: 9, HEAVY: 16 } as const;
|
||||||
LIGHT: 5,
|
|
||||||
NORMAL: 9,
|
|
||||||
HEAVY: 16,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const BUTTERFLY_COLOR_THEMES = {
|
export const BUTTERFLY_COLOR_THEMES = {
|
||||||
ALL: BUTTERFLY_VARIANTS,
|
ALL: BUTTERFLY_VARIANTS,
|
||||||
WARM: ['/butterfly-orange.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg'] as const,
|
WARM: ['/butterfly-orange.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg'] as const,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }:
|
|||||||
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
|
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
|
||||||
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
|
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
|
||||||
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
|
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
|
||||||
|
<div><span className="v">{myStats.thievesDefeated}</span><span className="l">🦹 zlodějů</span></div>
|
||||||
|
<div><span className="v">{myStats.caterpillarsDefeated}</span><span className="l">🐛 housenek</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,18 +7,14 @@ import {
|
|||||||
killWasp as killWaspApi,
|
killWasp as killWaspApi,
|
||||||
buyRepellent as buyRepellentApi,
|
buyRepellent as buyRepellentApi,
|
||||||
reportNetTorn as reportTearApi,
|
reportNetTorn as reportTearApi,
|
||||||
|
reportRobbery as reportRobberyApi,
|
||||||
|
defeatThief as defeatThiefApi,
|
||||||
|
reportCaterpillarAte as caterpillarAteApi,
|
||||||
|
defeatCaterpillar as defeatCaterpillarApi,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
|
|
||||||
/** Starý klíč, pod kterým se počet chycených ukládal jen do localStorage. */
|
|
||||||
const LEGACY_KEY = 'flyingButterfliesCaught';
|
|
||||||
/** Příznak, že už proběhla jednorázová migrace localStorage → server. */
|
|
||||||
const MIGRATED_KEY = 'flyingButterfliesMigrated';
|
|
||||||
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
|
/** 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;
|
||||||
/** Server přijme max 100 motýlů na dávku – migraci proto posíláme po částech. */
|
|
||||||
const MIGRATION_CHUNK = 100;
|
|
||||||
/** Bezpečnostní strop migrovaných úlovků (proti nesmyslným hodnotám v localStorage). */
|
|
||||||
const MIGRATION_MAX = 1000;
|
|
||||||
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
|
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
|
||||||
const POLL_INTERVAL_MS = 30_000;
|
const POLL_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
@@ -33,12 +29,15 @@ export interface RewardEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu,
|
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu, dávkové
|
||||||
* jednorázovou migraci z localStorage, dávkové odesílání úlovků, hubení vos,
|
* odesílání úlovků, hubení vos, plašič ptáků, protržení/opravu síťky, zloděje a
|
||||||
* plašič ptáků, protržení/opravu síťky a periodické přenačítání (růst škůdců).
|
* periodické přenačítání (růst škůdců). Server je zdroj pravdy; počítadlo se
|
||||||
|
* zobrazuje jako serverová hodnota + zatím neodeslané úlovky.
|
||||||
*/
|
*/
|
||||||
export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
||||||
const [stats, setStats] = useState<ButterflyStats | undefined>();
|
const [stats, setStats] = useState<ButterflyStats | undefined>();
|
||||||
|
/** Optimisticky zobrazený přírůstek chycených, který ještě neodešel na server. */
|
||||||
|
const [pendingShown, setPendingShown] = useState(0);
|
||||||
/** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
|
/** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
|
||||||
const coinsRef = useRef(0);
|
const coinsRef = useRef(0);
|
||||||
const pending = useRef({ normal: 0, golden: 0 });
|
const pending = useRef({ normal: 0, golden: 0 });
|
||||||
@@ -46,18 +45,9 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
const onRewardRef = useRef(onReward);
|
const onRewardRef = useRef(onReward);
|
||||||
onRewardRef.current = onReward;
|
onRewardRef.current = onReward;
|
||||||
|
|
||||||
/** Zapíše statistiky ze serveru, ale nedovolí „couvnout" počtu chycených
|
|
||||||
* (kvůli optimistickému počítadlu, které může být napřed před flushem). */
|
|
||||||
const applyStats = useCallback((incoming: ButterflyStats) => {
|
const applyStats = useCallback((incoming: ButterflyStats) => {
|
||||||
setStats(prev => {
|
coinsRef.current = incoming.coins;
|
||||||
const merged: ButterflyStats = { ...incoming };
|
setStats(incoming);
|
||||||
if (prev && prev.caught > incoming.caught) {
|
|
||||||
merged.caught = prev.caught;
|
|
||||||
merged.goldenCaught = Math.max(prev.goldenCaught, incoming.goldenCaught);
|
|
||||||
}
|
|
||||||
coinsRef.current = merged.coins;
|
|
||||||
return merged;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const flush = useCallback(async () => {
|
const flush = useCallback(async () => {
|
||||||
@@ -66,12 +56,15 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
flushTimer.current = null;
|
flushTimer.current = null;
|
||||||
}
|
}
|
||||||
const batch = pending.current;
|
const batch = pending.current;
|
||||||
if (batch.normal === 0 && batch.golden === 0) return;
|
const batchTotal = batch.normal + batch.golden;
|
||||||
|
if (batchTotal === 0) return;
|
||||||
pending.current = { normal: 0, golden: 0 };
|
pending.current = { normal: 0, golden: 0 };
|
||||||
try {
|
try {
|
||||||
const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
|
const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
applyStats(res.data.stats);
|
applyStats(res.data.stats);
|
||||||
|
// Odeslané úlovky už jsou v serverové hodnotě → sundáme je z optimistického přírůstku
|
||||||
|
setPendingShown(p => Math.max(0, p - batchTotal));
|
||||||
onRewardRef.current?.({
|
onRewardRef.current?.({
|
||||||
coinsAwarded: res.data.coinsAwarded,
|
coinsAwarded: res.data.coinsAwarded,
|
||||||
dailyBonusApplied: res.data.dailyBonusApplied,
|
dailyBonusApplied: res.data.dailyBonusApplied,
|
||||||
@@ -80,8 +73,11 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
newLevel: res.data.stats.level,
|
newLevel: res.data.stats.level,
|
||||||
newTitle: res.data.stats.title,
|
newTitle: res.data.stats.title,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setPendingShown(p => Math.max(0, p - batchTotal));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// Při chybě vrátíme dávku zpět (přírůstek necháme zobrazený)
|
||||||
pending.current.normal += batch.normal;
|
pending.current.normal += batch.normal;
|
||||||
pending.current.golden += batch.golden;
|
pending.current.golden += batch.golden;
|
||||||
}
|
}
|
||||||
@@ -91,15 +87,12 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
const reportCatch = useCallback((golden: boolean) => {
|
const reportCatch = useCallback((golden: boolean) => {
|
||||||
if (golden) pending.current.golden += 1;
|
if (golden) pending.current.golden += 1;
|
||||||
else pending.current.normal += 1;
|
else pending.current.normal += 1;
|
||||||
setStats(prev => prev
|
setPendingShown(p => p + 1);
|
||||||
? { ...prev, caught: prev.caught + 1, goldenCaught: prev.goldenCaught + (golden ? 1 : 0) }
|
|
||||||
: prev);
|
|
||||||
if (!flushTimer.current) {
|
if (!flushTimer.current) {
|
||||||
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
|
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
}, [flush]);
|
}, [flush]);
|
||||||
|
|
||||||
/** Zaplatí opravu síťky. Vrátí true při úspěchu, false při nedostatku mincí/chybě. */
|
|
||||||
const repair = useCallback(async (): Promise<boolean> => {
|
const repair = useCallback(async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await repairNetApi();
|
const res = await repairNetApi();
|
||||||
@@ -108,7 +101,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}, [applyStats]);
|
}, [applyStats]);
|
||||||
|
|
||||||
/** Zahubí jednu vosu (plácačka). */
|
|
||||||
const killWasp = useCallback(async () => {
|
const killWasp = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await killWaspApi();
|
const res = await killWaspApi();
|
||||||
@@ -116,7 +108,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [applyStats]);
|
}, [applyStats]);
|
||||||
|
|
||||||
/** Koupí plašič ptáků. Vrátí true při úspěchu, false při nedostatku mincí. */
|
|
||||||
const buyRepellent = useCallback(async (): Promise<boolean> => {
|
const buyRepellent = useCallback(async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await buyRepellentApi();
|
const res = await buyRepellentApi();
|
||||||
@@ -125,7 +116,6 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}, [applyStats]);
|
}, [applyStats]);
|
||||||
|
|
||||||
/** Nahlásí serveru, že pták protrhl síťku (perzistentně). */
|
|
||||||
const reportTear = useCallback(async () => {
|
const reportTear = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await reportTearApi();
|
const res = await reportTearApi();
|
||||||
@@ -133,7 +123,38 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [applyStats]);
|
}, [applyStats]);
|
||||||
|
|
||||||
/** Přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
|
/** Zloděj se dostal k penězům – server strhne podíl mincí. */
|
||||||
|
const reportRobbery = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await reportRobberyApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Hráč porazil zloděje – odměna a statistika. */
|
||||||
|
const defeatThief = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await defeatThiefApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Housenka se dostala k úlovkům – server sníží počet chycených. */
|
||||||
|
const caterpillarAte = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await caterpillarAteApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Hráč porazil housenku – odměna a statistika. */
|
||||||
|
const defeatCaterpillar = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await defeatCaterpillarApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getButterflyStats();
|
const res = await getButterflyStats();
|
||||||
@@ -141,29 +162,13 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [applyStats]);
|
}, [applyStats]);
|
||||||
|
|
||||||
// Načtení stavu + jednorázová migrace z localStorage
|
// Načtení stavu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getButterflyStats();
|
const res = await getButterflyStats();
|
||||||
if (cancelled || !res.data) return;
|
if (!cancelled && res.data) applyStats(res.data);
|
||||||
let current = res.data;
|
|
||||||
try {
|
|
||||||
if (!localStorage.getItem(MIGRATED_KEY)) {
|
|
||||||
const legacy = Number(localStorage.getItem(LEGACY_KEY));
|
|
||||||
let diff = Number.isFinite(legacy) ? Math.min(legacy - current.caught, MIGRATION_MAX) : 0;
|
|
||||||
while (diff > 0 && !cancelled) {
|
|
||||||
const chunk = Math.min(diff, MIGRATION_CHUNK);
|
|
||||||
const migRes = await catchButterflies({ body: { normal: chunk, golden: 0 } });
|
|
||||||
if (migRes.data) current = migRes.data.stats;
|
|
||||||
diff -= chunk;
|
|
||||||
}
|
|
||||||
localStorage.setItem(MIGRATED_KEY, '1');
|
|
||||||
localStorage.removeItem(LEGACY_KEY);
|
|
||||||
}
|
|
||||||
} catch { /* localStorage nedostupné – migraci přeskočíme */ }
|
|
||||||
if (!cancelled) applyStats(current);
|
|
||||||
} catch { /* server nedostupný – hra jede dál bez perzistence */ }
|
} catch { /* server nedostupný – hra jede dál bez perzistence */ }
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
@@ -193,5 +198,10 @@ export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
|||||||
};
|
};
|
||||||
}, [flush]);
|
}, [flush]);
|
||||||
|
|
||||||
return { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear };
|
const displayCaught = (stats?.caught ?? 0) + pendingShown;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
|
||||||
|
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,15 @@
|
|||||||
"Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům",
|
"Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům",
|
||||||
"Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu",
|
"Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu",
|
||||||
"Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat",
|
"Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat",
|
||||||
"Ptáci přibývají a trhají síťku (oprava za mince); můžeš si koupit plašič, který je na chvíli vyžene",
|
"Ptáci přibývají a trhají síťku (protrhnou ji vždy); protržená síťka se sama zašije za minutu, nebo si připlatíš za okamžitou opravu",
|
||||||
|
"Můžeš si koupit plašič, který ptáky na chvíli vyžene",
|
||||||
"Vosy i ptáci se počítají na serveru, takže je nevynuluješ obnovením stránky",
|
"Vosy i ptáci se počítají na serveru, takže je nevynuluješ obnovením stránky",
|
||||||
"Denní bonus, kombo za rychlé chytání, týmový žebříček a osobní statistiky (zlatí motýli, zabité vosy, vyplašení ptáci) po kliknutí na počítadlo",
|
"Denní bonus, kombo za rychlé chytání, týmový žebříček a osobní statistiky (zlatí motýli, zabité vosy, vyplašení ptáci) po kliknutí na počítadlo",
|
||||||
|
"Hra je celkově těžší: mnohem víc úrovní se strmější křivkou, dražší vylepšení a vzácnější zlatý motýl",
|
||||||
|
"Ptáci teď aktivně nalétávají na síťku a škůdců je víc",
|
||||||
|
"Občas přijde zloděj, který se plíží k tvým mincím – zaklikej ho, než ti je ukradne (chce to hodně ran)",
|
||||||
|
"Pozor i na housenku, která se plíží k úlovkům a umí ti sežrat nachytané motýly (i snížit úroveň)",
|
||||||
|
"Přidána ochrana proti podvádění (zmenšování okna) – rychlost chytání je omezená",
|
||||||
|
"Nová sezóna: statistiky začínají férově od nuly pro všechny",
|
||||||
"Oprava: v nasazené verzi se v počítadle vlevo dole opět zobrazuje ikonka motýlka"
|
"Oprava: v nasazené verzi se v počítadle vlevo dole opět zobrazuje ikonka motýlka"
|
||||||
]
|
]
|
||||||
|
|||||||
+180
-50
@@ -22,18 +22,29 @@ interface StoredButterflyStats {
|
|||||||
waspsKilled: number;
|
waspsKilled: number;
|
||||||
/** Celkový počet vyplašených ptáků */
|
/** Celkový počet vyplašených ptáků */
|
||||||
birdsScared: number;
|
birdsScared: number;
|
||||||
|
/** Celkový počet poražených zlodějů */
|
||||||
|
thievesDefeated: number;
|
||||||
|
/** Celkový počet poražených housenek */
|
||||||
|
caterpillarsDefeated: number;
|
||||||
/** Do kdy platí plašič ptáků (ms epoch), 0 = neaktivní */
|
/** Do kdy platí plašič ptáků (ms epoch), 0 = neaktivní */
|
||||||
repellentUntil: number;
|
repellentUntil: number;
|
||||||
/** Zda je síťka protržená */
|
/** Do kdy je síťka protržená (ms epoch); po uplynutí se sama zašije. 0 = celá */
|
||||||
netTorn: boolean;
|
netTornUntil: number;
|
||||||
/** Kdy naposledy „dorostla" vosa (ms epoch) */
|
/** Kdy naposledy „dorostla" vosa (ms epoch) */
|
||||||
lastWaspAt: number;
|
lastWaspAt: number;
|
||||||
/** Kdy naposledy „dorostl" pták (ms epoch) */
|
/** Kdy naposledy „dorostl" pták (ms epoch) */
|
||||||
lastBirdAt: number;
|
lastBirdAt: number;
|
||||||
|
|
||||||
|
// --- Ochrana proti podvádění (rychlost chytání) ---
|
||||||
|
/** Začátek aktuálního minutového okna pro počítadlo úlovků (ms epoch) */
|
||||||
|
catchWindowStart: number;
|
||||||
|
/** Počet započtených úlovků v aktuálním okně */
|
||||||
|
catchWindowCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const STORAGE_KEY = 'butterflyStats';
|
// Sezóna 2 – nový klíč znamená čistý start pro všechny (spravedlivé po ztížení hry)
|
||||||
|
const STORAGE_KEY = 'butterflyStats_s2';
|
||||||
|
|
||||||
// --- Herní konstanty ---------------------------------------------------------
|
// --- Herní konstanty ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -42,64 +53,117 @@ export const COIN_BASE = 1;
|
|||||||
/** Mince za jednoho vzácného zlatého motýla */
|
/** Mince za jednoho vzácného zlatého motýla */
|
||||||
export const GOLD_VALUE = 25;
|
export const GOLD_VALUE = 25;
|
||||||
/** Cena zašití protržené síťky v mincích */
|
/** Cena zašití protržené síťky v mincích */
|
||||||
export const REPAIR_COST = 10;
|
export const REPAIR_COST = 20;
|
||||||
/** Bonus mincí za první úlovek dne */
|
/** Bonus mincí za první úlovek dne */
|
||||||
export const DAILY_BONUS = 10;
|
export const DAILY_BONUS = 5;
|
||||||
/** Po každých kolika chycených se odemkne prémiová síťka */
|
/** Po každých kolika chycených se odemkne prémiová síťka */
|
||||||
export const PREMIUM_MILESTONE = 50;
|
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 = 5;
|
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 = 4;
|
export const BIRD_MAX = 5;
|
||||||
/** 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 */
|
||||||
export const BIRD_GROWTH_MS = 120_000;
|
export const BIRD_GROWTH_MS = 60_000;
|
||||||
/** Cena plašiče ptáků v mincích */
|
/** Cena plašiče ptáků v mincích */
|
||||||
export const REPELLENT_COST = 25;
|
export const REPELLENT_COST = 60;
|
||||||
/** 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 = 180_000;
|
export const REPELLENT_DURATION_MS = 120_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) */
|
||||||
|
export const ROBBERY_FRACTION = 0.85;
|
||||||
|
/** Odměna v mincích za poražení zloděje */
|
||||||
|
export const THIEF_REWARD = 20;
|
||||||
|
/** Kolik zásahů (kliknutí) zloděj vydrží */
|
||||||
|
export const THIEF_HP = 22;
|
||||||
|
/** Kolik nachytaných motýlů sežere housenka, když se dostane k úlovkům */
|
||||||
|
export const CATERPILLAR_EATS = 20;
|
||||||
|
/** Odměna v mincích za poražení housenky */
|
||||||
|
export const CATERPILLAR_REWARD = 15;
|
||||||
|
/** Kolik zásahů (kliknutí) housenka vydrží */
|
||||||
|
export const CATERPILLAR_HP = 18;
|
||||||
|
|
||||||
/** 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 = 3;
|
const COMBO_THRESHOLD = 4;
|
||||||
/** Horní strop komba, aby dávka nedala nesmyslně moc mincí */
|
/** Horní strop komba, aby dávka nedala nesmyslně moc mincí */
|
||||||
const COMBO_CAP = 20;
|
const COMBO_CAP = 8;
|
||||||
/** Maximální počet motýlů akceptovaný v jedné dávce (sanity limit) */
|
/** Maximální počet motýlů akceptovaný v jedné dávce (sanity limit) */
|
||||||
const MAX_BATCH = 100;
|
const MAX_BATCH = 100;
|
||||||
|
|
||||||
|
/** Ochrana proti podvádění: max započtených úlovků za minutu (nadbytek se zahodí) */
|
||||||
|
export const RATE_CAP_PER_MIN = 90;
|
||||||
|
/** Délka okna pro rate-cap (ms) */
|
||||||
|
const RATE_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Úrovně sběratele a jejich tituly. `min` je hranice celkového počtu chycených,
|
* Tituly úrovní. Pro úrovně nad rámec pole se použije poslední titul s hvězdičkami
|
||||||
* od které úroveň platí. Pole je vzestupně dle `min`.
|
* (prestiž), takže postup nikdy „nedojde".
|
||||||
*/
|
*/
|
||||||
const LEVELS: { min: number; title: string }[] = [
|
const LEVEL_TITLES = [
|
||||||
{ min: 0, title: 'Začátečník se síťkou' },
|
'Začátečník se síťkou', // 1
|
||||||
{ min: 10, title: 'Nedělní chytač' },
|
'Nedělní chytač', // 2
|
||||||
{ min: 30, title: 'Lovec luk' },
|
'Lovec luk', // 3
|
||||||
{ min: 60, title: 'Sběratel křídel' },
|
'Sběratel křídel', // 4
|
||||||
{ min: 120, title: 'Mistr síťky' },
|
'Průzkumník louky', // 5
|
||||||
{ min: 200, title: 'Motýlí šeptač' },
|
'Mistr síťky', // 6
|
||||||
{ min: 350, title: 'Legendární entomolog' },
|
'Motýlí stopař', // 7
|
||||||
{ min: 600, title: 'Vládce louky' },
|
'Zaklínač křídel', // 8
|
||||||
|
'Motýlí šeptač', // 9
|
||||||
|
'Kurátor motýlů', // 10
|
||||||
|
'Amatérský entomolog', // 11
|
||||||
|
'Legendární entomolog', // 12
|
||||||
|
'Strážce louky', // 13
|
||||||
|
'Vládce louky', // 14
|
||||||
|
'Motýlí velmistr', // 15
|
||||||
|
'Duch luk', // 16
|
||||||
|
'Motýlí legenda', // 17
|
||||||
|
'Nebeský lovec', // 18
|
||||||
|
'Motýlí božstvo', // 19
|
||||||
|
'Pán všech křídel', // 20
|
||||||
];
|
];
|
||||||
|
/** Nejvyšší dosažitelná úroveň */
|
||||||
|
export const MAX_LEVEL = 60;
|
||||||
|
|
||||||
// --- Čisté pomocné funkce ----------------------------------------------------
|
// --- Čisté pomocné funkce ----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kolik celkem chycených je potřeba k dosažení úrovně `n` (1-based).
|
||||||
|
* Úroveň 1 = 0. Křivka roste mocninně (vyšší úrovně jsou výrazně těžší).
|
||||||
|
*/
|
||||||
|
export function levelThreshold(n: number): number {
|
||||||
|
if (n <= 1) return 0;
|
||||||
|
return Math.round(6 * Math.pow(n - 1, 2.15));
|
||||||
|
}
|
||||||
|
|
||||||
/** Vrátí úroveň (1-based) odpovídající celkovému počtu chycených motýlů. */
|
/** Vrátí úroveň (1-based) odpovídající celkovému počtu chycených motýlů. */
|
||||||
export function levelForCaught(caught: number): number {
|
export function levelForCaught(caught: number): number {
|
||||||
let level = 1;
|
let level = 1;
|
||||||
for (let i = 0; i < LEVELS.length; i++) {
|
while (level < MAX_LEVEL && caught >= levelThreshold(level + 1)) {
|
||||||
if (caught >= LEVELS[i].min) {
|
level++;
|
||||||
level = i + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vrátí titul odpovídající dané úrovni (1-based). */
|
/** Vrátí titul odpovídající dané úrovni (1-based). */
|
||||||
export function titleForLevel(level: number): string {
|
export function titleForLevel(level: number): string {
|
||||||
const idx = Math.min(Math.max(level - 1, 0), LEVELS.length - 1);
|
if (level <= LEVEL_TITLES.length) {
|
||||||
return LEVELS[idx].title;
|
return LEVEL_TITLES[Math.max(level - 1, 0)];
|
||||||
|
}
|
||||||
|
const stars = '★'.repeat(Math.min(level - LEVEL_TITLES.length, 10));
|
||||||
|
return `${LEVEL_TITLES[LEVEL_TITLES.length - 1]} ${stars}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Postup v rámci aktuální úrovně (0–100 %). Na maximální úrovni 100. */
|
||||||
|
export function levelProgress(caught: number): number {
|
||||||
|
const level = levelForCaught(caught);
|
||||||
|
if (level >= MAX_LEVEL) return 100;
|
||||||
|
const start = levelThreshold(level);
|
||||||
|
const end = levelThreshold(level + 1);
|
||||||
|
if (end <= start) return 100;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(((caught - start) / (end - start)) * 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bonus mincí za kombo (dávku chycenou najednou). */
|
/** Bonus mincí za kombo (dávku chycenou najednou). */
|
||||||
@@ -115,8 +179,10 @@ function defaultStats(now: number): StoredButterflyStats {
|
|||||||
return {
|
return {
|
||||||
caught: 0, coins: 0, goldenCaught: 0,
|
caught: 0, coins: 0, goldenCaught: 0,
|
||||||
wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0,
|
wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0,
|
||||||
repellentUntil: 0, netTorn: false,
|
thievesDefeated: 0, caterpillarsDefeated: 0,
|
||||||
|
repellentUntil: 0, netTornUntil: 0,
|
||||||
lastWaspAt: now, lastBirdAt: now,
|
lastWaspAt: now, lastBirdAt: now,
|
||||||
|
catchWindowStart: now, catchWindowCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +197,14 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
|
|||||||
birds: s.birds ?? 0,
|
birds: s.birds ?? 0,
|
||||||
waspsKilled: s.waspsKilled ?? 0,
|
waspsKilled: s.waspsKilled ?? 0,
|
||||||
birdsScared: s.birdsScared ?? 0,
|
birdsScared: s.birdsScared ?? 0,
|
||||||
|
thievesDefeated: s.thievesDefeated ?? 0,
|
||||||
|
caterpillarsDefeated: s.caterpillarsDefeated ?? 0,
|
||||||
repellentUntil: s.repellentUntil ?? 0,
|
repellentUntil: s.repellentUntil ?? 0,
|
||||||
netTorn: s.netTorn ?? false,
|
netTornUntil: s.netTornUntil ?? 0,
|
||||||
lastWaspAt: s.lastWaspAt ?? now,
|
lastWaspAt: s.lastWaspAt ?? now,
|
||||||
lastBirdAt: s.lastBirdAt ?? now,
|
lastBirdAt: s.lastBirdAt ?? now,
|
||||||
|
catchWindowStart: s.catchWindowStart ?? now,
|
||||||
|
catchWindowCount: s.catchWindowCount ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +213,9 @@ function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
|
|||||||
* 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č.
|
||||||
*/
|
*/
|
||||||
export function growPests(s: StoredButterflyStats, now: number): void {
|
export function growPests(s: StoredButterflyStats, now: number): void {
|
||||||
|
// Síťka se sama zašije po uplynutí odpočtu
|
||||||
|
if (s.netTornUntil && s.netTornUntil <= now) s.netTornUntil = 0;
|
||||||
|
|
||||||
// Vosy
|
// Vosy
|
||||||
if (s.wasps < WASP_MAX) {
|
if (s.wasps < WASP_MAX) {
|
||||||
const add = Math.floor((now - s.lastWaspAt) / WASP_GROWTH_MS);
|
const add = Math.floor((now - s.lastWaspAt) / WASP_GROWTH_MS);
|
||||||
@@ -154,11 +227,11 @@ export function growPests(s: StoredButterflyStats, now: number): void {
|
|||||||
s.lastWaspAt = now;
|
s.lastWaspAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ptáci – jen když neběží plašič
|
// Ptáci – jen když neběží plašič. Během plašiče se lastBirdAt nemění (byl
|
||||||
|
// nastaven na konec plašiče při koupi), takže po vypršení růst začne od té chvíle.
|
||||||
const repellentActive = s.repellentUntil > now;
|
const repellentActive = s.repellentUntil > now;
|
||||||
if (repellentActive) {
|
if (!repellentActive) {
|
||||||
s.lastBirdAt = now;
|
if (s.birds < BIRD_MAX) {
|
||||||
} else if (s.birds < BIRD_MAX) {
|
|
||||||
const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS);
|
const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS);
|
||||||
if (add > 0) {
|
if (add > 0) {
|
||||||
s.birds = Math.min(s.birds + add, BIRD_MAX);
|
s.birds = Math.min(s.birds + add, BIRD_MAX);
|
||||||
@@ -168,6 +241,7 @@ export function growPests(s: StoredButterflyStats, now: number): void {
|
|||||||
s.lastBirdAt = now;
|
s.lastBirdAt = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Převede interní statistiky na DTO (doplní odvozenou úroveň a titul). */
|
/** Převede interní statistiky na DTO (doplní odvozenou úroveň a titul). */
|
||||||
function toDto(s: StoredButterflyStats): ButterflyStats {
|
function toDto(s: StoredButterflyStats): ButterflyStats {
|
||||||
@@ -178,13 +252,16 @@ function toDto(s: StoredButterflyStats): ButterflyStats {
|
|||||||
goldenCaught: s.goldenCaught,
|
goldenCaught: s.goldenCaught,
|
||||||
level,
|
level,
|
||||||
title: titleForLevel(level),
|
title: titleForLevel(level),
|
||||||
|
levelProgress: levelProgress(s.caught),
|
||||||
lastCatchDay: s.lastCatchDay,
|
lastCatchDay: s.lastCatchDay,
|
||||||
wasps: s.wasps,
|
wasps: s.wasps,
|
||||||
birds: s.birds,
|
birds: s.birds,
|
||||||
waspsKilled: s.waspsKilled,
|
waspsKilled: s.waspsKilled,
|
||||||
birdsScared: s.birdsScared,
|
birdsScared: s.birdsScared,
|
||||||
|
thievesDefeated: s.thievesDefeated,
|
||||||
|
caterpillarsDefeated: s.caterpillarsDefeated,
|
||||||
repellentUntil: s.repellentUntil,
|
repellentUntil: s.repellentUntil,
|
||||||
netTorn: s.netTorn,
|
netTornUntil: s.netTornUntil,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,16 +324,28 @@ export async function recordCatches(login: string, normal: number, golden: numbe
|
|||||||
const oldCaught = s.caught;
|
const oldCaught = s.caught;
|
||||||
const oldLevel = levelForCaught(oldCaught);
|
const oldLevel = levelForCaught(oldCaught);
|
||||||
|
|
||||||
dailyBonusApplied = batchTotal > 0 && s.lastCatchDay !== today;
|
// Ochrana proti podvádění: v minutovém okně se započítá max RATE_CAP_PER_MIN
|
||||||
|
// úlovků, zbytek se tiše zahodí (nezapočte se ani mince). Přednost mají zlatí.
|
||||||
|
if (now - s.catchWindowStart >= RATE_WINDOW_MS) {
|
||||||
|
s.catchWindowStart = now;
|
||||||
|
s.catchWindowCount = 0;
|
||||||
|
}
|
||||||
|
const allowance = Math.max(0, RATE_CAP_PER_MIN - s.catchWindowCount);
|
||||||
|
const creditGolden = Math.min(g, allowance);
|
||||||
|
const creditNormal = Math.min(n, Math.max(0, allowance - creditGolden));
|
||||||
|
const credited = creditGolden + creditNormal;
|
||||||
|
s.catchWindowCount += credited;
|
||||||
|
|
||||||
|
dailyBonusApplied = credited > 0 && s.lastCatchDay !== today;
|
||||||
const daily = dailyBonusApplied ? DAILY_BONUS : 0;
|
const daily = dailyBonusApplied ? DAILY_BONUS : 0;
|
||||||
|
|
||||||
coinsAwarded = n * COIN_BASE + g * GOLD_VALUE + comboBonus(batchTotal) + daily;
|
coinsAwarded = creditNormal * COIN_BASE + creditGolden * GOLD_VALUE + comboBonus(credited) + daily;
|
||||||
|
|
||||||
const newCaught = oldCaught + batchTotal;
|
const newCaught = oldCaught + credited;
|
||||||
s.caught = newCaught;
|
s.caught = newCaught;
|
||||||
s.coins += coinsAwarded;
|
s.coins += coinsAwarded;
|
||||||
s.goldenCaught += g;
|
s.goldenCaught += creditGolden;
|
||||||
if (batchTotal > 0) s.lastCatchDay = today;
|
if (credited > 0) s.lastCatchDay = today;
|
||||||
|
|
||||||
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);
|
||||||
@@ -266,18 +355,19 @@ export async function recordCatches(login: string, normal: number, golden: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zašije protrženou síťku za mince. Vyhodí {@link InsufficientCoinsError}, pokud
|
* Okamžitě zašije protrženou síťku za mince (volitelné – jinak se zašije sama).
|
||||||
* uživatel nemá dost mincí. Když síťka není protržená, jen vrátí aktuální stav.
|
* Vyhodí {@link InsufficientCoinsError} při nedostatku mincí. Když síťka není
|
||||||
|
* protržená, jen vrátí aktuální stav.
|
||||||
*/
|
*/
|
||||||
export async function repairNet(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
export async function repairNet(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
let failed = false;
|
let failed = false;
|
||||||
const mine = await mutateUser(login, now, (s) => {
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
if (!s.netTorn) return;
|
if (s.netTornUntil <= now) return; // není protržená (nebo se už zašila)
|
||||||
if (s.coins < REPAIR_COST) { failed = true; return; }
|
if (s.coins < REPAIR_COST) { failed = true; return; }
|
||||||
s.coins -= REPAIR_COST;
|
s.coins -= REPAIR_COST;
|
||||||
s.netTorn = false;
|
s.netTornUntil = 0;
|
||||||
});
|
});
|
||||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na opravu síťky');
|
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na okamžitou opravu síťky');
|
||||||
return toDto(mine);
|
return toDto(mine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,15 +394,55 @@ export async function buyRepellent(login: string, now: number = Date.now()): Pro
|
|||||||
s.birdsScared += s.birds;
|
s.birdsScared += s.birds;
|
||||||
s.birds = 0;
|
s.birds = 0;
|
||||||
s.repellentUntil = now + REPELLENT_DURATION_MS;
|
s.repellentUntil = now + REPELLENT_DURATION_MS;
|
||||||
s.lastBirdAt = now;
|
// Růst ptáků se rozběhne až od konce plašiče
|
||||||
|
s.lastBirdAt = s.repellentUntil;
|
||||||
});
|
});
|
||||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na plašič ptáků');
|
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na plašič ptáků');
|
||||||
return toDto(mine);
|
return toDto(mine);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zaznamená, že pták protrhl síťku (perzistentně). */
|
/**
|
||||||
|
* Zaznamená, že pták protrhl síťku (perzistentně). Síťka se sama zašije za
|
||||||
|
* AUTO_REPAIR_MS. Když už protržená je, běžící odpočet se neprodlužuje.
|
||||||
|
*/
|
||||||
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) => { s.netTorn = true; });
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
if (s.netTornUntil <= now) s.netTornUntil = now + AUTO_REPAIR_MS;
|
||||||
|
});
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zloděj se dostal k penězům a ukradl podíl mincí. */
|
||||||
|
export async function robbery(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
s.coins = Math.round(s.coins * (1 - ROBBERY_FRACTION));
|
||||||
|
});
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hráč porazil zloděje – malá odměna a statistika. */
|
||||||
|
export async function defeatThief(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
s.thievesDefeated += 1;
|
||||||
|
s.coins += THIEF_REWARD;
|
||||||
|
});
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Housenka se dostala k úlovkům a sežrala část nachytaných motýlů (může snížit úroveň). */
|
||||||
|
export async function caterpillarAte(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
s.caught = Math.max(0, s.caught - CATERPILLAR_EATS);
|
||||||
|
});
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hráč porazil housenku – malá odměna a statistika. */
|
||||||
|
export async function defeatCaterpillar(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
s.caterpillarsDefeated += 1;
|
||||||
|
s.coins += CATERPILLAR_REWARD;
|
||||||
|
});
|
||||||
return toDto(mine);
|
return toDto(mine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
killWasp,
|
killWasp,
|
||||||
buyRepellent,
|
buyRepellent,
|
||||||
reportNetTorn,
|
reportNetTorn,
|
||||||
|
robbery,
|
||||||
|
defeatThief,
|
||||||
|
caterpillarAte,
|
||||||
|
defeatCaterpillar,
|
||||||
getLeaderboard,
|
getLeaderboard,
|
||||||
InsufficientCoinsError,
|
InsufficientCoinsError,
|
||||||
} from "../butterflies";
|
} from "../butterflies";
|
||||||
@@ -76,6 +80,38 @@ router.post("/tear", async (req: Request, res, next) => {
|
|||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/robbery", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await robbery(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/defeatThief", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await defeatThief(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/caterpillarAte", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await caterpillarAte(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/defeatCaterpillar", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await defeatCaterpillar(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));
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ import {
|
|||||||
killWasp,
|
killWasp,
|
||||||
buyRepellent,
|
buyRepellent,
|
||||||
reportNetTorn,
|
reportNetTorn,
|
||||||
|
robbery,
|
||||||
|
defeatThief,
|
||||||
|
caterpillarAte,
|
||||||
|
defeatCaterpillar,
|
||||||
getLeaderboard,
|
getLeaderboard,
|
||||||
growPests,
|
growPests,
|
||||||
levelForCaught,
|
levelForCaught,
|
||||||
|
levelThreshold,
|
||||||
|
levelProgress,
|
||||||
titleForLevel,
|
titleForLevel,
|
||||||
comboBonus,
|
comboBonus,
|
||||||
InsufficientCoinsError,
|
InsufficientCoinsError,
|
||||||
@@ -24,11 +30,20 @@ import {
|
|||||||
BIRD_GROWTH_MS,
|
BIRD_GROWTH_MS,
|
||||||
REPELLENT_COST,
|
REPELLENT_COST,
|
||||||
REPELLENT_DURATION_MS,
|
REPELLENT_DURATION_MS,
|
||||||
|
RATE_CAP_PER_MIN,
|
||||||
|
MAX_LEVEL,
|
||||||
|
ROBBERY_FRACTION,
|
||||||
|
THIEF_REWARD,
|
||||||
|
CATERPILLAR_EATS,
|
||||||
|
CATERPILLAR_REWARD,
|
||||||
|
AUTO_REPAIR_MS,
|
||||||
} from '../butterflies';
|
} from '../butterflies';
|
||||||
import { formatDate } from '../utils';
|
import { formatDate } from '../utils';
|
||||||
|
|
||||||
const USER = 'tomas';
|
const USER = 'tomas';
|
||||||
const OTHER = 'petr';
|
const OTHER = 'petr';
|
||||||
|
/** Storage klíč sezóny 2 (musí odpovídat STORAGE_KEY v butterflies.ts). */
|
||||||
|
const KEY = 'butterflyStats_s2';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetMemoryStorage();
|
resetMemoryStorage();
|
||||||
@@ -37,35 +52,58 @@ beforeEach(() => {
|
|||||||
/** Nastaví uživateli přímo uložené statistiky (simulace zásahu do dat). */
|
/** Nastaví uživateli přímo uložené statistiky (simulace zásahu do dat). */
|
||||||
async function seed(login: string, data: any) {
|
async function seed(login: string, data: any) {
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const all = (await storage.getData<any>('butterflyStats')) ?? {};
|
const all = (await storage.getData<any>(KEY)) ?? {};
|
||||||
all[login] = data;
|
all[login] = data;
|
||||||
await storage.setData('butterflyStats', all);
|
await storage.setData(KEY, all);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('levelForCaught / titleForLevel', () => {
|
describe('úrovně', () => {
|
||||||
test('začíná na úrovni 1', () => {
|
test('začíná na úrovni 1', () => {
|
||||||
expect(levelForCaught(0)).toBe(1);
|
expect(levelForCaught(0)).toBe(1);
|
||||||
expect(titleForLevel(1)).toBe('Začátečník se síťkou');
|
expect(titleForLevel(1)).toBe('Začátečník se síťkou');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('roste s počtem chycených', () => {
|
test('křivka je rostoucí a stále strmější', () => {
|
||||||
expect(levelForCaught(9)).toBe(1);
|
expect(levelThreshold(1)).toBe(0);
|
||||||
expect(levelForCaught(10)).toBe(2);
|
expect(levelThreshold(2)).toBeGreaterThan(0);
|
||||||
expect(levelForCaught(30)).toBe(3);
|
// každý další skok je větší než ten předchozí (těžší postup)
|
||||||
expect(levelForCaught(10000)).toBe(8);
|
const d1 = levelThreshold(3) - levelThreshold(2);
|
||||||
|
const d2 = levelThreshold(4) - levelThreshold(3);
|
||||||
|
const d3 = levelThreshold(20) - levelThreshold(19);
|
||||||
|
expect(d2).toBeGreaterThan(d1);
|
||||||
|
expect(d3).toBeGreaterThan(d2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('levelForCaught respektuje prahy a strop', () => {
|
||||||
|
expect(levelForCaught(levelThreshold(2) - 1)).toBe(1);
|
||||||
|
expect(levelForCaught(levelThreshold(2))).toBe(2);
|
||||||
|
expect(levelForCaught(levelThreshold(5))).toBe(5);
|
||||||
|
expect(levelForCaught(100_000_000)).toBe(MAX_LEVEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('titul nad rámec seznamu dostane hvězdičky (prestiž)', () => {
|
||||||
|
expect(titleForLevel(MAX_LEVEL)).toContain('★');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('levelProgress je 0–100 a na maximu 100', () => {
|
||||||
|
expect(levelProgress(0)).toBe(0);
|
||||||
|
const p = levelProgress(Math.round((levelThreshold(2) + levelThreshold(3)) / 2));
|
||||||
|
expect(p).toBeGreaterThan(0);
|
||||||
|
expect(p).toBeLessThan(100);
|
||||||
|
expect(levelProgress(100_000_000)).toBe(100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('comboBonus', () => {
|
describe('comboBonus', () => {
|
||||||
test('malá dávka nedává kombo', () => {
|
test('malá dávka nedává kombo', () => {
|
||||||
expect(comboBonus(1)).toBe(0);
|
expect(comboBonus(1)).toBe(0);
|
||||||
expect(comboBonus(2)).toBe(0);
|
expect(comboBonus(3)).toBe(0); // pod prahem 4
|
||||||
});
|
});
|
||||||
|
|
||||||
test('větší dávka dává rostoucí bonus se stropem', () => {
|
test('větší dávka dává rostoucí bonus se stropem', () => {
|
||||||
expect(comboBonus(3)).toBe(1);
|
expect(comboBonus(4)).toBe(1);
|
||||||
expect(comboBonus(4)).toBe(2);
|
expect(comboBonus(5)).toBe(2);
|
||||||
expect(comboBonus(1000)).toBe(20); // COMBO_CAP
|
expect(comboBonus(1000)).toBe(8); // COMBO_CAP
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,13 +115,14 @@ describe('getStats', () => {
|
|||||||
expect(stats.goldenCaught).toBe(0);
|
expect(stats.goldenCaught).toBe(0);
|
||||||
expect(stats.level).toBe(1);
|
expect(stats.level).toBe(1);
|
||||||
expect(stats.title).toBe('Začátečník se síťkou');
|
expect(stats.title).toBe('Začátečník se síťkou');
|
||||||
|
expect(stats.wasps).toBe(0);
|
||||||
|
expect(stats.birds).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('recordCatches – mince', () => {
|
describe('recordCatches – mince', () => {
|
||||||
test('běžný motýl dá základní minci', async () => {
|
test('běžný motýl dá základní minci + denní bonus', async () => {
|
||||||
const result = await recordCatches(USER, 1, 0);
|
const result = await recordCatches(USER, 1, 0);
|
||||||
// 1 běžný + první úlovek dne => denní bonus
|
|
||||||
expect(result.coinsAwarded).toBe(COIN_BASE + DAILY_BONUS);
|
expect(result.coinsAwarded).toBe(COIN_BASE + DAILY_BONUS);
|
||||||
expect(result.dailyBonusApplied).toBe(true);
|
expect(result.dailyBonusApplied).toBe(true);
|
||||||
expect(result.stats.caught).toBe(1);
|
expect(result.stats.caught).toBe(1);
|
||||||
@@ -91,7 +130,6 @@ describe('recordCatches – mince', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('zlatý motýl má vyšší hodnotu a počítá se zvlášť', async () => {
|
test('zlatý motýl má vyšší hodnotu a počítá se zvlášť', async () => {
|
||||||
// předvyplníme dnešní den, aby nezasáhl denní bonus
|
|
||||||
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||||
const result = await recordCatches(USER, 0, 1);
|
const result = await recordCatches(USER, 0, 1);
|
||||||
expect(result.coinsAwarded).toBe(GOLD_VALUE);
|
expect(result.coinsAwarded).toBe(GOLD_VALUE);
|
||||||
@@ -102,14 +140,34 @@ describe('recordCatches – mince', () => {
|
|||||||
|
|
||||||
test('kombo se připočítá u větší dávky', async () => {
|
test('kombo se připočítá u větší dávky', async () => {
|
||||||
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||||
const result = await recordCatches(USER, 4, 0);
|
const result = await recordCatches(USER, 5, 0);
|
||||||
// 4 * COIN_BASE + comboBonus(4)=2
|
expect(result.coinsAwarded).toBe(5 * COIN_BASE + comboBonus(5));
|
||||||
expect(result.coinsAwarded).toBe(4 * COIN_BASE + 2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sanity limit ořízne absurdní dávku a odmítne záporné', async () => {
|
test('sanity limit + rate cap ořízne absurdní dávku', async () => {
|
||||||
const result = await recordCatches(USER, 100000, -5);
|
const result = await recordCatches(USER, 100000, 0);
|
||||||
expect(result.stats.caught).toBe(100); // MAX_BATCH
|
// MAX_BATCH=100, ale rate cap povolí jen RATE_CAP_PER_MIN za minutu
|
||||||
|
expect(result.stats.caught).toBe(RATE_CAP_PER_MIN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordCatches – rate cap (ochrana proti podvádění)', () => {
|
||||||
|
test('v jednom okně se započte max RATE_CAP_PER_MIN, zbytek se zahodí', async () => {
|
||||||
|
const t0 = 1_000_000;
|
||||||
|
const a = await recordCatches(USER, 60, 0, t0);
|
||||||
|
expect(a.stats.caught).toBe(60);
|
||||||
|
const b = await recordCatches(USER, 60, 0, t0 + 1000);
|
||||||
|
expect(b.stats.caught).toBe(RATE_CAP_PER_MIN); // 60 + 30
|
||||||
|
const c = await recordCatches(USER, 20, 0, t0 + 2000);
|
||||||
|
expect(c.stats.caught).toBe(RATE_CAP_PER_MIN); // už nic nepřibude
|
||||||
|
expect(c.coinsAwarded).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('po uplynutí okna se limit resetuje', async () => {
|
||||||
|
const t0 = 2_000_000;
|
||||||
|
await recordCatches(USER, RATE_CAP_PER_MIN, 0, t0);
|
||||||
|
const later = await recordCatches(USER, 10, 0, t0 + 61_000);
|
||||||
|
expect(later.stats.caught).toBe(RATE_CAP_PER_MIN + 10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,19 +189,14 @@ describe('recordCatches – denní bonus', () => {
|
|||||||
|
|
||||||
describe('recordCatches – progres a milníky', () => {
|
describe('recordCatches – progres a milníky', () => {
|
||||||
test('leveledUp se nastaví při překročení hranice úrovně', async () => {
|
test('leveledUp se nastaví při překročení hranice úrovně', async () => {
|
||||||
await seed(USER, { caught: 9, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
await seed(USER, { caught: levelThreshold(2) - 1, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||||
const result = await recordCatches(USER, 1, 0); // 9 -> 10 => úroveň 2
|
const result = await recordCatches(USER, 1, 0);
|
||||||
expect(result.leveledUp).toBe(true);
|
expect(result.leveledUp).toBe(true);
|
||||||
expect(result.stats.level).toBe(2);
|
expect(result.stats.level).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('premiumUnlocked se nastaví při překročení milníku', async () => {
|
test('premiumUnlocked se nastaví při překročení milníku', async () => {
|
||||||
await seed(USER, {
|
await seed(USER, { caught: PREMIUM_MILESTONE - 1, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||||
caught: PREMIUM_MILESTONE - 1,
|
|
||||||
coins: 0,
|
|
||||||
goldenCaught: 0,
|
|
||||||
lastCatchDay: formatDate(new Date()),
|
|
||||||
});
|
|
||||||
const result = await recordCatches(USER, 1, 0);
|
const result = await recordCatches(USER, 1, 0);
|
||||||
expect(result.premiumUnlocked).toBe(true);
|
expect(result.premiumUnlocked).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -155,30 +208,6 @@ describe('recordCatches – progres a milníky', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('repairNet', () => {
|
|
||||||
test('odečte cenu opravy při dostatku mincí a zruší protržení', async () => {
|
|
||||||
await seed(USER, { caught: 20, coins: REPAIR_COST + 5, goldenCaught: 0, netTorn: true });
|
|
||||||
const stats = await repairNet(USER);
|
|
||||||
expect(stats.coins).toBe(5);
|
|
||||||
expect(stats.netTorn).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('vyhodí chybu při nedostatku mincí', async () => {
|
|
||||||
await seed(USER, { caught: 1, coins: REPAIR_COST - 1, goldenCaught: 0, netTorn: true });
|
|
||||||
await expect(repairNet(USER)).rejects.toThrow(InsufficientCoinsError);
|
|
||||||
// mince zůstanou nezměněné a síťka stále protržená
|
|
||||||
const stats = await getStats(USER);
|
|
||||||
expect(stats.coins).toBe(REPAIR_COST - 1);
|
|
||||||
expect(stats.netTorn).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('neúčtuje, když síťka není protržená', async () => {
|
|
||||||
await seed(USER, { caught: 1, coins: 50, goldenCaught: 0, netTorn: false });
|
|
||||||
const stats = await repairNet(USER);
|
|
||||||
expect(stats.coins).toBe(50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('growPests', () => {
|
describe('growPests', () => {
|
||||||
test('vosy přibývají v čase do maxima', () => {
|
test('vosy přibývají v čase do maxima', () => {
|
||||||
const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
|
const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
|
||||||
@@ -202,6 +231,12 @@ describe('growPests', () => {
|
|||||||
growPests(s, now + 3 * BIRD_GROWTH_MS);
|
growPests(s, now + 3 * BIRD_GROWTH_MS);
|
||||||
expect(s.birds).toBe(0);
|
expect(s.birds).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ptáci přibývají do maxima', () => {
|
||||||
|
const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
|
||||||
|
growPests(s, 1_000_000 * BIRD_GROWTH_MS);
|
||||||
|
expect(s.birds).toBe(BIRD_MAX);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('killWasp', () => {
|
describe('killWasp', () => {
|
||||||
@@ -235,15 +270,99 @@ describe('buyRepellent', () => {
|
|||||||
await seed(USER, { caught: 0, coins: REPELLENT_COST - 1, goldenCaught: 0, birds: 2 });
|
await seed(USER, { caught: 0, coins: REPELLENT_COST - 1, goldenCaught: 0, birds: 2 });
|
||||||
await expect(buyRepellent(USER)).rejects.toThrow(InsufficientCoinsError);
|
await expect(buyRepellent(USER)).rejects.toThrow(InsufficientCoinsError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('po vypršení plašiče ptáci zase přibývají', async () => {
|
||||||
|
const t0 = 10_000_000;
|
||||||
|
await seed(USER, { caught: 0, coins: REPELLENT_COST, goldenCaught: 0, birds: 0, lastBirdAt: t0 });
|
||||||
|
await buyRepellent(USER, t0);
|
||||||
|
// dost dlouho po vypršení plašiče
|
||||||
|
const stats = await getStats(USER, t0 + REPELLENT_DURATION_MS + 2 * BIRD_GROWTH_MS + 1);
|
||||||
|
expect(stats.birds).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('reportNetTorn', () => {
|
describe('reportNetTorn', () => {
|
||||||
test('nastaví protrženou síťku (perzistentně)', async () => {
|
test('protrhne síťku na dobu auto-opravy', async () => {
|
||||||
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0 });
|
const now = 1_000_000;
|
||||||
const stats = await reportNetTorn(USER);
|
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: 0 });
|
||||||
expect(stats.netTorn).toBe(true);
|
const stats = await reportNetTorn(USER, now);
|
||||||
// stav přežije další čtení (F5)
|
expect(stats.netTornUntil).toBe(now + AUTO_REPAIR_MS);
|
||||||
expect((await getStats(USER)).netTorn).toBe(true);
|
});
|
||||||
|
|
||||||
|
test('opakované protržení neprodlouží běžící odpočet', async () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: now + 40_000 });
|
||||||
|
const stats = await reportNetTorn(USER, now);
|
||||||
|
expect(stats.netTornUntil).toBe(now + 40_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sama se zašije po uplynutí odpočtu', async () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, netTornUntil: now + 1000 });
|
||||||
|
const stats = await getStats(USER, now + 2000);
|
||||||
|
expect(stats.netTornUntil).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairNet', () => {
|
||||||
|
test('okamžitá oprava odečte cenu a zašije síťku', async () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
await seed(USER, { caught: 20, coins: REPAIR_COST + 5, goldenCaught: 0, netTornUntil: now + 30_000 });
|
||||||
|
const stats = await repairNet(USER, now);
|
||||||
|
expect(stats.coins).toBe(5);
|
||||||
|
expect(stats.netTornUntil).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bez mincí okamžitá oprava selže (ale síťka se sama zašije)', async () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
await seed(USER, { caught: 1, coins: REPAIR_COST - 1, goldenCaught: 0, netTornUntil: now + 30_000 });
|
||||||
|
await expect(repairNet(USER, now)).rejects.toThrow(InsufficientCoinsError);
|
||||||
|
const stats = await getStats(USER, now);
|
||||||
|
expect(stats.coins).toBe(REPAIR_COST - 1);
|
||||||
|
expect(stats.netTornUntil).toBeGreaterThan(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neúčtuje, když síťka není protržená', async () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
await seed(USER, { caught: 1, coins: 50, goldenCaught: 0, netTornUntil: 0 });
|
||||||
|
const stats = await repairNet(USER, now);
|
||||||
|
expect(stats.coins).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zloděj', () => {
|
||||||
|
test('okradení sebere podíl mincí', async () => {
|
||||||
|
await seed(USER, { caught: 0, coins: 100, goldenCaught: 0 });
|
||||||
|
const stats = await robbery(USER);
|
||||||
|
expect(stats.coins).toBe(Math.round(100 * (1 - ROBBERY_FRACTION)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poražení zloděje dá odměnu a zvýší statistiku', async () => {
|
||||||
|
await seed(USER, { caught: 0, coins: 10, goldenCaught: 0 });
|
||||||
|
const stats = await defeatThief(USER);
|
||||||
|
expect(stats.coins).toBe(10 + THIEF_REWARD);
|
||||||
|
expect(stats.thievesDefeated).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('housenka', () => {
|
||||||
|
test('sežere část úlovků (nikdy pod nulu) a může snížit úroveň', async () => {
|
||||||
|
await seed(USER, { caught: CATERPILLAR_EATS + 5, coins: 0, goldenCaught: 0 });
|
||||||
|
const stats = await caterpillarAte(USER);
|
||||||
|
expect(stats.caught).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nesnese caught pod nulu', async () => {
|
||||||
|
await seed(USER, { caught: 3, coins: 0, goldenCaught: 0 });
|
||||||
|
const stats = await caterpillarAte(USER);
|
||||||
|
expect(stats.caught).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poražení housenky dá odměnu a zvýší statistiku', async () => {
|
||||||
|
await seed(USER, { caught: 0, coins: 5, goldenCaught: 0 });
|
||||||
|
const stats = await defeatCaterpillar(USER);
|
||||||
|
expect(stats.coins).toBe(5 + CATERPILLAR_REWARD);
|
||||||
|
expect(stats.caterpillarsDefeated).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ paths:
|
|||||||
$ref: "./paths/butterflies/repellent.yml"
|
$ref: "./paths/butterflies/repellent.yml"
|
||||||
/butterflies/tear:
|
/butterflies/tear:
|
||||||
$ref: "./paths/butterflies/tear.yml"
|
$ref: "./paths/butterflies/tear.yml"
|
||||||
|
/butterflies/robbery:
|
||||||
|
$ref: "./paths/butterflies/robbery.yml"
|
||||||
|
/butterflies/defeatThief:
|
||||||
|
$ref: "./paths/butterflies/defeatThief.yml"
|
||||||
|
/butterflies/caterpillarAte:
|
||||||
|
$ref: "./paths/butterflies/caterpillarAte.yml"
|
||||||
|
/butterflies/defeatCaterpillar:
|
||||||
|
$ref: "./paths/butterflies/defeatCaterpillar.yml"
|
||||||
/butterflies/leaderboard:
|
/butterflies/leaderboard:
|
||||||
$ref: "./paths/butterflies/leaderboard.yml"
|
$ref: "./paths/butterflies/leaderboard.yml"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
post:
|
||||||
|
operationId: reportCaterpillarAte
|
||||||
|
summary: Housenka se dostala k úlovkům a sežrala část nachytaných motýlů.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky po sežrání úlovků.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
post:
|
||||||
|
operationId: defeatCaterpillar
|
||||||
|
summary: Hráč porazil housenku – malá odměna a statistika.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky po poražení housenky.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
post:
|
||||||
|
operationId: defeatThief
|
||||||
|
summary: Hráč porazil zloděje – malá odměna a statistika.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky po poražení zloděje.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
post:
|
||||||
|
operationId: reportRobbery
|
||||||
|
summary: Zloděj se dostal k penězům a ukradl podíl mincí.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky po okradení.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
@@ -938,12 +938,15 @@ ButterflyStats:
|
|||||||
- goldenCaught
|
- goldenCaught
|
||||||
- level
|
- level
|
||||||
- title
|
- title
|
||||||
|
- levelProgress
|
||||||
- wasps
|
- wasps
|
||||||
- birds
|
- birds
|
||||||
- waspsKilled
|
- waspsKilled
|
||||||
- birdsScared
|
- birdsScared
|
||||||
|
- thievesDefeated
|
||||||
|
- caterpillarsDefeated
|
||||||
- repellentUntil
|
- repellentUntil
|
||||||
- netTorn
|
- netTornUntil
|
||||||
properties:
|
properties:
|
||||||
caught:
|
caught:
|
||||||
description: Celkový počet chycených motýlů
|
description: Celkový počet chycených motýlů
|
||||||
@@ -960,6 +963,9 @@ ButterflyStats:
|
|||||||
title:
|
title:
|
||||||
description: Titul odpovídající aktuální úrovni
|
description: Titul odpovídající aktuální úrovni
|
||||||
type: string
|
type: string
|
||||||
|
levelProgress:
|
||||||
|
description: Postup v rámci aktuální úrovně v procentech (0–100)
|
||||||
|
type: integer
|
||||||
lastCatchDay:
|
lastCatchDay:
|
||||||
description: Datum posledního úlovku (YYYY-MM-DD) pro denní bonus
|
description: Datum posledního úlovku (YYYY-MM-DD) pro denní bonus
|
||||||
type: string
|
type: string
|
||||||
@@ -975,12 +981,18 @@ ButterflyStats:
|
|||||||
birdsScared:
|
birdsScared:
|
||||||
description: Celkový počet vyplašených ptáků (plašičem)
|
description: Celkový počet vyplašených ptáků (plašičem)
|
||||||
type: integer
|
type: integer
|
||||||
|
thievesDefeated:
|
||||||
|
description: Celkový počet poražených zlodějů
|
||||||
|
type: integer
|
||||||
|
caterpillarsDefeated:
|
||||||
|
description: Celkový počet poražených housenek
|
||||||
|
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
|
||||||
netTorn:
|
netTornUntil:
|
||||||
description: Zda je síťka aktuálně protržená (nutná placená oprava)
|
description: Časové razítko (ms epoch), do kdy je síťka protržená; po uplynutí se sama zašije. 0 = celá
|
||||||
type: boolean
|
type: integer
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user