feat: rozšíření chytání motýlků o progres, mince a škůdce
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
- serverová perzistence statistik (mince, úrovně, zlatí motýli) + týmový žebříček - prémiová zlatá síťka za milník (větší, magnetická, odolná proti ptákům) - perzistentní škůdci: vosy na zaklikání a ptáci trhající síťku (F5-proof) - placený plašič ptáků s odpočtem, denní bonus, kombo - WOW zlatý motýl + osobní statistiky v modalu žebříčku - oprava zobrazení ikonek (root-absolutní cesty k public assetům) 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
c8c5ecc60c
commit
c5ca6c9d41
+519
-187
@@ -1,30 +1,48 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats';
|
||||
import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal';
|
||||
|
||||
// Různé barevné varianty motýlů
|
||||
// Různé barevné varianty motýlů (soubory v public/, referencované root-absolutně)
|
||||
const BUTTERFLY_VARIANTS = [
|
||||
'butterfly-orange.svg', // Oranžová (monarcha)
|
||||
'butterfly-blue.svg', // Modrá
|
||||
'butterfly-yellow.svg', // Žlutá (otakárek)
|
||||
'butterfly-pink.svg', // Růžová
|
||||
'/butterfly-orange.svg', // Oranžová (monarcha)
|
||||
'/butterfly-blue.svg', // Modrá
|
||||
'/butterfly-yellow.svg', // Žlutá (otakárek)
|
||||
'/butterfly-pink.svg', // Růžová
|
||||
] as const;
|
||||
|
||||
// Klíč pro uložení počtu chycených motýlů do local storage
|
||||
const CAUGHT_STORAGE_KEY = 'flyingButterfliesCaught';
|
||||
// Vzácný zlatý motýl
|
||||
const GOLDEN_VARIANT = '/butterfly-golden.svg';
|
||||
const GOLDEN_CHANCE = 0.025; // ~2,5 % motýlů je zlatých
|
||||
|
||||
// Grafika síťky (root-absolutní cesty kvůli produkčnímu servírování z kořene)
|
||||
const NET_NORMAL = '/butterfly-net.svg';
|
||||
const NET_GOLDEN = '/butterfly-net-golden.svg';
|
||||
const NET_TORN = '/butterfly-net-torn.svg';
|
||||
|
||||
// Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts)
|
||||
const PREMIUM_MILESTONE = 50;
|
||||
const PREMIUM_DURATION_MS = 60_000;
|
||||
export const REPAIR_COST = 10;
|
||||
export const REPELLENT_COST = 25;
|
||||
const GOLD_VALUE = 25;
|
||||
|
||||
/** Naformátuje zbývající čas jako „m:ss" nebo „s s". */
|
||||
function formatRemaining(ms: number): string {
|
||||
const s = Math.ceil(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const ss = s % 60;
|
||||
return m > 0 ? `${m}:${String(ss).padStart(2, '0')}` : `${ss} s`;
|
||||
}
|
||||
|
||||
interface ButterflyData {
|
||||
/** Vnější element – nese pozici, natočení a velikost */
|
||||
el: HTMLDivElement;
|
||||
/** Vnitřní element – nese CSS animaci mávání křídly */
|
||||
sprite: HTMLDivElement;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Směr letu vodorovně: +1 doprava, -1 doleva */
|
||||
dir: number;
|
||||
/** Vodorovná rychlost (px/snímek) */
|
||||
speed: number;
|
||||
/** Velikost (měřítko sprite) */
|
||||
size: number;
|
||||
// Dvě sinusovky pro přirozené zvlněné stoupání a klesání
|
||||
golden: boolean;
|
||||
bobFreq1: number;
|
||||
bobPhase1: number;
|
||||
bobAmp1: number;
|
||||
@@ -33,23 +51,45 @@ interface ButterflyData {
|
||||
bobAmp2: number;
|
||||
}
|
||||
|
||||
/** Létající havěť (pták nebo vosa). */
|
||||
interface CritterData {
|
||||
el: HTMLDivElement;
|
||||
x: number;
|
||||
y: number;
|
||||
dir: number;
|
||||
speed: number;
|
||||
size: number;
|
||||
bobPhase: number;
|
||||
bobAmp: number;
|
||||
/** Pták: už protrhl síťku v tomto průletu (reset při obletu) */
|
||||
triggered: boolean;
|
||||
/** Uklízeč posluchače kliknutí (jen u vos) */
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
||||
/** Callbacky, kterými scéna komunikuje s React obalem. */
|
||||
interface SceneCallbacks {
|
||||
onCatch: (golden: boolean) => void;
|
||||
getCoins: () => number;
|
||||
onTornChange: (torn: boolean) => void;
|
||||
onCombo: (count: number) => void;
|
||||
onSting: () => void;
|
||||
onSwatWasp: () => void;
|
||||
}
|
||||
|
||||
interface FlyingButterfliesProps {
|
||||
/** Počet poletujících motýlů (výchozí: 9) */
|
||||
numButterflies?: number;
|
||||
/** CSS třída pro kontejner (výchozí: 'flying-butterflies') */
|
||||
className?: string;
|
||||
/** Barevné varianty motýlů k použití (výchozí: všechny) */
|
||||
butterflyVariants?: readonly string[];
|
||||
/** Zapne síťku na chytání motýlů (výchozí: true) */
|
||||
enableNet?: boolean;
|
||||
/** Callback při chycení motýla (obdrží nový celkový počet) */
|
||||
onCatch?: (total: number) => void;
|
||||
}
|
||||
|
||||
class ButterflyScene {
|
||||
private viewport: HTMLElement;
|
||||
private world: HTMLDivElement;
|
||||
private butterflies: ButterflyData[] = [];
|
||||
private birds: CritterData[] = [];
|
||||
private wasps: CritterData[] = [];
|
||||
private numButterflies: number;
|
||||
private variants: readonly string[];
|
||||
private width: number;
|
||||
@@ -58,65 +98,81 @@ class ButterflyScene {
|
||||
private animationId: number | null = null;
|
||||
private handleResize: () => void;
|
||||
|
||||
// Síťka na chytání
|
||||
private net: HTMLDivElement;
|
||||
private netEnabled: boolean;
|
||||
private netGrabbed: boolean = false;
|
||||
private netX: number = 0;
|
||||
private netY: number = 0;
|
||||
private onCatch?: () => void;
|
||||
private cb: SceneCallbacks;
|
||||
|
||||
private torn: boolean = false;
|
||||
private premiumUntil: number = 0;
|
||||
private stunUntil: number = 0;
|
||||
private lastMagnetAt: number = 0;
|
||||
|
||||
private comboCount: number = 0;
|
||||
private lastCatchTs: number = 0;
|
||||
private comboTimer: number = 0;
|
||||
|
||||
// Základní velikost sprite v px (dále se násobí náhodným měřítkem)
|
||||
private static readonly BASE_SIZE = 34;
|
||||
private static readonly NET_SIZE_NORMAL = 120;
|
||||
private static readonly NET_SIZE_PREMIUM = 168;
|
||||
private static readonly CATCH_RADIUS_NORMAL = 40;
|
||||
private static readonly CATCH_RADIUS_PREMIUM = 62;
|
||||
private static readonly HOOP_FRAC_X = 0.36;
|
||||
private static readonly HOOP_FRAC_Y = 0.35;
|
||||
|
||||
// Rozměry síťky (px) a poloha obruče v rámci SVG (viewBox 100×100, střed 36×35)
|
||||
private static readonly NET_SIZE = 120;
|
||||
private static readonly HOOP_OFFSET_X = ButterflyScene.NET_SIZE * 0.36;
|
||||
private static readonly HOOP_OFFSET_Y = ButterflyScene.NET_SIZE * 0.35;
|
||||
// Dosah chytání kolem středu obruče
|
||||
private static readonly CATCH_RADIUS = 40;
|
||||
private static readonly BIRD_SIZE = 64;
|
||||
private static readonly WASP_SIZE = 44;
|
||||
private static readonly TEAR_RADIUS = 46;
|
||||
private static readonly STING_RADIUS = 46;
|
||||
private static readonly STUN_FRAMES = 300; // ~5 s omráčení
|
||||
private static readonly MAGNET_RADIUS = 150;
|
||||
private static readonly MAGNET_INTERVAL = 42;
|
||||
private static readonly COMBO_WINDOW_MS = 1200;
|
||||
|
||||
constructor(
|
||||
el: HTMLElement,
|
||||
numButterflies: number = 9,
|
||||
variants: readonly string[] = BUTTERFLY_VARIANTS,
|
||||
netEnabled: boolean = true,
|
||||
onCatch?: () => void,
|
||||
numButterflies: number,
|
||||
variants: readonly string[],
|
||||
netEnabled: boolean,
|
||||
cb: SceneCallbacks,
|
||||
) {
|
||||
this.viewport = el;
|
||||
this.world = document.createElement('div');
|
||||
this.numButterflies = numButterflies;
|
||||
this.variants = variants;
|
||||
this.netEnabled = netEnabled;
|
||||
this.onCatch = onCatch;
|
||||
this.cb = cb;
|
||||
this.width = this.viewport.offsetWidth;
|
||||
this.height = this.viewport.offsetHeight;
|
||||
|
||||
this.net = document.createElement('div');
|
||||
|
||||
this.handleResize = () => {
|
||||
this.width = this.viewport.offsetWidth;
|
||||
this.height = this.viewport.offsetHeight;
|
||||
};
|
||||
}
|
||||
|
||||
// Nastaví motýlovi nové náhodné parametry a umístí ho na okraj obrazovky
|
||||
private get premiumActive(): boolean { return Date.now() < this.premiumUntil; }
|
||||
private get netSize(): number { return this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL; }
|
||||
private get catchRadius(): number { return this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; }
|
||||
private get hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; }
|
||||
private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; }
|
||||
|
||||
// --- Motýli --------------------------------------------------------------
|
||||
|
||||
private resetButterfly = (b: ButterflyData): void => {
|
||||
// Vletí zleva nebo zprava
|
||||
b.dir = Math.random() > 0.5 ? 1 : -1;
|
||||
b.speed = Math.random() * 1.1 + 0.7; // 0.7 – 1.8 px/snímek
|
||||
b.speed = Math.random() * 1.1 + 0.7;
|
||||
|
||||
// Na začátku (timer 0) rozprostřeme motýly po celé šířce, ať není obrazovka prázdná
|
||||
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.size = Math.random() * 0.6 + 0.6;
|
||||
|
||||
b.size = Math.random() * 0.6 + 0.6; // 0.6 – 1.2
|
||||
|
||||
// Dvě sinusovky s různou frekvencí → nepravidelné, měkké křivky letu
|
||||
b.bobFreq1 = Math.random() * 0.02 + 0.03;
|
||||
b.bobPhase1 = Math.random() * Math.PI * 2;
|
||||
b.bobAmp1 = Math.random() * 0.8 + 0.6;
|
||||
@@ -124,44 +180,56 @@ class ButterflyScene {
|
||||
b.bobPhase2 = Math.random() * Math.PI * 2;
|
||||
b.bobAmp2 = Math.random() * 0.6 + 0.4;
|
||||
|
||||
// Náhodná varianta a rychlost mávání křídly
|
||||
const variant = this.variants[Math.floor(Math.random() * this.variants.length)];
|
||||
b.sprite.style.backgroundImage = `url(${variant})`;
|
||||
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; // 0.3 – 0.55 s
|
||||
b.golden = Math.random() < GOLDEN_CHANCE;
|
||||
if (b.golden) {
|
||||
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
|
||||
b.sprite.classList.add('golden');
|
||||
b.size = Math.random() * 0.5 + 1.0; // zlatý je nápadně větší
|
||||
} else {
|
||||
const variant = this.variants[Math.floor(Math.random() * this.variants.length)];
|
||||
b.sprite.style.backgroundImage = `url(${variant})`;
|
||||
b.sprite.classList.remove('golden');
|
||||
}
|
||||
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`;
|
||||
};
|
||||
|
||||
private updateButterfly = (b: ButterflyData): void => {
|
||||
const vx = b.dir * b.speed;
|
||||
// Svislá rychlost jako součet dvou sinusovek – přirozené plachtění
|
||||
const vy =
|
||||
Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 +
|
||||
Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2;
|
||||
|
||||
b.x += vx;
|
||||
b.y += vy;
|
||||
|
||||
// Sprite natočíme po směru letu (motýl je nakreslený hlavou nahoru)
|
||||
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})`;
|
||||
|
||||
// Reset po opuštění obrazovky (s rezervou na natočení)
|
||||
if (
|
||||
(b.dir === 1 && b.x > this.width + 50) ||
|
||||
(b.dir === -1 && b.x < -50) ||
|
||||
b.y < -60 ||
|
||||
b.y > this.height + 60
|
||||
b.y < -60 || b.y > this.height + 60
|
||||
) {
|
||||
this.resetButterfly(b);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Síťka na chytání ---------------------------------------------------
|
||||
// --- Síťka ---------------------------------------------------------------
|
||||
|
||||
private updateNetTransform = (): void => {
|
||||
this.net.style.transform =
|
||||
`translate(${this.netX - ButterflyScene.HOOP_OFFSET_X}px, ` +
|
||||
`${this.netY - ButterflyScene.HOOP_OFFSET_Y}px)`;
|
||||
`translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`;
|
||||
};
|
||||
|
||||
private applyNetAppearance = (): void => {
|
||||
const size = this.netSize;
|
||||
this.net.style.width = `${size}px`;
|
||||
this.net.style.height = `${size}px`;
|
||||
let image = NET_NORMAL;
|
||||
if (this.torn) image = NET_TORN;
|
||||
else if (this.premiumActive) image = NET_GOLDEN;
|
||||
this.net.style.backgroundImage = `url(${image})`;
|
||||
this.net.classList.toggle('premium', this.premiumActive && !this.torn);
|
||||
this.net.classList.toggle('torn', this.torn);
|
||||
this.updateNetTransform();
|
||||
};
|
||||
|
||||
private onPointerDown = (e: PointerEvent): void => {
|
||||
@@ -186,70 +254,231 @@ class ButterflyScene {
|
||||
this.net.classList.remove('grabbed');
|
||||
};
|
||||
|
||||
// Zobrazí prchavé „+1" v místě chycení
|
||||
private spawnCatchFx = (): void => {
|
||||
private spawnFx = (text: string, cssClass: string, x: number, y: number): void => {
|
||||
const fx = document.createElement('div');
|
||||
fx.className = 'butterfly-catch-fx';
|
||||
fx.textContent = '+1';
|
||||
fx.style.left = `${this.netX}px`;
|
||||
fx.style.top = `${this.netY}px`;
|
||||
fx.className = cssClass;
|
||||
fx.textContent = text;
|
||||
fx.style.left = `${x}px`;
|
||||
fx.style.top = `${y}px`;
|
||||
this.viewport.appendChild(fx);
|
||||
window.setTimeout(() => {
|
||||
if (fx.parentNode) fx.parentNode.removeChild(fx);
|
||||
}, 750);
|
||||
window.setTimeout(() => { if (fx.parentNode) fx.parentNode.removeChild(fx); }, 800);
|
||||
};
|
||||
|
||||
private popNet = (): void => {
|
||||
this.net.classList.remove('catch-pop');
|
||||
void this.net.offsetWidth;
|
||||
this.net.classList.add('catch-pop');
|
||||
};
|
||||
|
||||
private registerCatch = (b: ButterflyData, atX: number, atY: number): void => {
|
||||
this.cb.onCatch(b.golden);
|
||||
this.spawnFx(b.golden ? `+${GOLD_VALUE}` : '+1', b.golden ? 'butterfly-catch-fx golden' : 'butterfly-catch-fx', atX, atY);
|
||||
this.resetButterfly(b);
|
||||
|
||||
const now = Date.now();
|
||||
this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1;
|
||||
this.lastCatchTs = now;
|
||||
if (this.comboCount >= 2) {
|
||||
this.cb.onCombo(this.comboCount);
|
||||
if (this.comboTimer) window.clearTimeout(this.comboTimer);
|
||||
this.comboTimer = window.setTimeout(() => this.cb.onCombo(0), ButterflyScene.COMBO_WINDOW_MS);
|
||||
}
|
||||
};
|
||||
|
||||
// Zkontroluje, zda obruč překrývá nějakého motýla – a chytí ho
|
||||
private checkCatches = (): void => {
|
||||
const r2 = ButterflyScene.CATCH_RADIUS * ButterflyScene.CATCH_RADIUS;
|
||||
const half = (ButterflyScene.BASE_SIZE / 2);
|
||||
if (this.torn || this.timer < this.stunUntil) return;
|
||||
const r2 = this.catchRadius * this.catchRadius;
|
||||
const half = ButterflyScene.BASE_SIZE / 2;
|
||||
let caughtAny = false;
|
||||
|
||||
for (let i = 0; i < this.butterflies.length; i++) {
|
||||
const b = this.butterflies[i];
|
||||
const cx = b.x + half * b.size;
|
||||
const cy = b.y + half * b.size;
|
||||
const dx = cx - this.netX;
|
||||
const dy = cy - this.netY;
|
||||
|
||||
for (const b of this.butterflies) {
|
||||
const dx = (b.x + half * b.size) - this.netX;
|
||||
const dy = (b.y + half * b.size) - this.netY;
|
||||
if (dx * dx + dy * dy < r2) {
|
||||
caughtAny = true;
|
||||
if (this.onCatch) this.onCatch();
|
||||
// Chycený motýl je nahrazen novým – celkový počet létajících se nemění
|
||||
this.resetButterfly(b);
|
||||
this.registerCatch(b, this.netX, this.netY);
|
||||
}
|
||||
}
|
||||
if (caughtAny) this.popNet();
|
||||
};
|
||||
|
||||
if (caughtAny) {
|
||||
this.spawnCatchFx();
|
||||
this.net.classList.remove('catch-pop');
|
||||
// vynucení reflow pro restart animace
|
||||
void this.net.offsetWidth;
|
||||
this.net.classList.add('catch-pop');
|
||||
private runMagnet = (): void => {
|
||||
if (this.torn || this.timer < this.stunUntil) return;
|
||||
if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return;
|
||||
this.lastMagnetAt = this.timer;
|
||||
const half = ButterflyScene.BASE_SIZE / 2;
|
||||
let nearest: ButterflyData | null = null;
|
||||
let nearestD2 = ButterflyScene.MAGNET_RADIUS * ButterflyScene.MAGNET_RADIUS;
|
||||
for (const b of this.butterflies) {
|
||||
const dx = (b.x + half * b.size) - this.netX;
|
||||
const dy = (b.y + half * b.size) - this.netY;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < nearestD2) { nearestD2 = d2; nearest = b; }
|
||||
}
|
||||
if (nearest) { this.registerCatch(nearest, this.netX, this.netY); this.popNet(); }
|
||||
};
|
||||
|
||||
public activatePremiumNet = (): void => {
|
||||
this.premiumUntil = Date.now() + PREMIUM_DURATION_MS;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
|
||||
/** Vnější (zaplacená) oprava protržené síťky. */
|
||||
public repairNetExternally = (): void => {
|
||||
if (!this.torn) return;
|
||||
this.torn = false;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
|
||||
/** Nastaví protržení podle serveru (init/poll), bez hlášení zpět. */
|
||||
public setTornFromServer = (torn: boolean): void => {
|
||||
if (this.torn === torn) return;
|
||||
this.torn = torn;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
|
||||
private tearNet = (atX: number, atY: number): void => {
|
||||
if (this.torn) return;
|
||||
this.torn = true;
|
||||
this.applyNetAppearance();
|
||||
this.spawnFx('Ratata!', 'butterfly-tear-fx', atX, atY);
|
||||
this.cb.onTornChange(true);
|
||||
};
|
||||
|
||||
// --- Havěť (ptáci a vosy) -----------------------------------------------
|
||||
|
||||
private critterHitsHoop = (c: CritterData, radius: number): boolean => {
|
||||
const dx = (c.x + c.size / 2) - this.netX;
|
||||
const dy = (c.y + c.size / 2) - this.netY;
|
||||
return dx * dx + dy * dy < radius * radius;
|
||||
};
|
||||
|
||||
private spawnCritter = (type: 'wasp' | 'bird'): CritterData => {
|
||||
const size = type === 'bird' ? ButterflyScene.BIRD_SIZE : ButterflyScene.WASP_SIZE;
|
||||
const el = document.createElement('div');
|
||||
el.className = type === 'bird' ? 'butterfly-critter bird' : 'butterfly-critter wasp';
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
el.style.backgroundImage = `url(${type === 'bird' ? '/bird.svg' : '/bee.svg'})`;
|
||||
|
||||
const dir = Math.random() > 0.5 ? 1 : -1;
|
||||
const c: CritterData = {
|
||||
el,
|
||||
x: dir === 1 ? -size : this.width + size,
|
||||
y: Math.random() * (this.height * 0.7) + this.height * 0.1,
|
||||
dir,
|
||||
speed: type === 'bird' ? Math.random() * 2 + 3 : Math.random() * 1.4 + 2,
|
||||
size,
|
||||
bobPhase: Math.random() * Math.PI * 2,
|
||||
bobAmp: Math.random() * 0.8 + 0.5,
|
||||
triggered: false,
|
||||
};
|
||||
|
||||
if (type === 'wasp') {
|
||||
el.title = 'Plácni vosu!';
|
||||
const swat = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.spawnFx('💥', 'butterfly-swat-fx', c.x + c.size / 2, c.y + c.size / 2);
|
||||
const idx = this.wasps.indexOf(c);
|
||||
if (idx >= 0) this.wasps.splice(idx, 1);
|
||||
if (c.el.parentNode) c.el.parentNode.removeChild(c.el);
|
||||
this.cb.onSwatWasp();
|
||||
};
|
||||
el.addEventListener('pointerdown', swat);
|
||||
c.cleanup = () => el.removeEventListener('pointerdown', swat);
|
||||
}
|
||||
|
||||
this.world.appendChild(el);
|
||||
return c;
|
||||
};
|
||||
|
||||
private wrapCritter = (c: CritterData): void => {
|
||||
c.dir = Math.random() > 0.5 ? 1 : -1;
|
||||
c.x = c.dir === 1 ? -c.size : this.width + c.size;
|
||||
c.y = Math.random() * (this.height * 0.7) + this.height * 0.1;
|
||||
c.triggered = false;
|
||||
};
|
||||
|
||||
private updateWasp = (c: CritterData): void => {
|
||||
c.x += c.dir * c.speed;
|
||||
// mírné míření k obruči, aby vosy otravovaly zrovna síťku
|
||||
const targetY = this.netY - c.size / 2;
|
||||
c.y += (targetY - c.y) * 0.015 + Math.sin(this.timer * 0.06 + c.bobPhase) * c.bobAmp;
|
||||
const flip = c.dir === -1 ? -1 : 1;
|
||||
c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`;
|
||||
if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) {
|
||||
this.wrapCritter(c);
|
||||
}
|
||||
};
|
||||
|
||||
private updateBird = (c: CritterData): void => {
|
||||
c.x += c.dir * c.speed;
|
||||
c.y += Math.sin(this.timer * 0.03 + c.bobPhase) * c.bobAmp;
|
||||
const flip = c.dir === -1 ? -1 : 1;
|
||||
c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`;
|
||||
if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) {
|
||||
this.wrapCritter(c);
|
||||
}
|
||||
};
|
||||
|
||||
private removeCritter = (arr: CritterData[]): void => {
|
||||
const c = arr.pop();
|
||||
if (c) {
|
||||
c.cleanup?.();
|
||||
if (c.el.parentNode) c.el.parentNode.removeChild(c.el);
|
||||
}
|
||||
};
|
||||
|
||||
/** Sladí počet vos a ptáků ve scéně s hodnotami ze serveru. */
|
||||
public syncPests = (wasps: number, birds: number): void => {
|
||||
if (!this.netEnabled) return;
|
||||
while (this.wasps.length < wasps) this.wasps.push(this.spawnCritter('wasp'));
|
||||
while (this.wasps.length > wasps) this.removeCritter(this.wasps);
|
||||
while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird'));
|
||||
while (this.birds.length > birds) this.removeCritter(this.birds);
|
||||
};
|
||||
|
||||
private updateWasps = (): void => {
|
||||
for (const wasp of this.wasps) {
|
||||
this.updateWasp(wasp);
|
||||
// Vosa žihne síťku, jakmile pomine předchozí omráčení
|
||||
if (!this.torn && this.timer >= this.stunUntil && this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) {
|
||||
this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES;
|
||||
this.net.classList.remove('stung');
|
||||
void this.net.offsetWidth;
|
||||
this.net.classList.add('stung');
|
||||
this.spawnFx('Au!', 'butterfly-sting-fx', this.netX, this.netY);
|
||||
this.cb.onSting();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private updateBirds = (): void => {
|
||||
for (const bird of this.birds) {
|
||||
this.updateBird(bird);
|
||||
// Prémiová síťka je z pevnějšího materiálu; protrhne se jen když má hráč na opravu
|
||||
if (!bird.triggered && this.netGrabbed && !this.torn && !this.premiumActive
|
||||
&& this.cb.getCoins() >= REPAIR_COST
|
||||
&& this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) {
|
||||
bird.triggered = true;
|
||||
this.tearNet(this.netX, this.netY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private initNet = (): void => {
|
||||
this.net.className = 'butterfly-net';
|
||||
this.net.style.width = `${ButterflyScene.NET_SIZE}px`;
|
||||
this.net.style.height = `${ButterflyScene.NET_SIZE}px`;
|
||||
this.net.style.backgroundImage = 'url(butterfly-net.svg)';
|
||||
|
||||
// Výchozí poloha – vpravo dole
|
||||
this.applyNetAppearance();
|
||||
this.netX = this.width * 0.82;
|
||||
this.netY = this.height * 0.72;
|
||||
this.updateNetTransform();
|
||||
|
||||
this.net.addEventListener('pointerdown', this.onPointerDown);
|
||||
window.addEventListener('pointermove', this.onPointerMove);
|
||||
window.addEventListener('pointerup', this.onPointerUp);
|
||||
window.addEventListener('pointercancel', this.onPointerUp);
|
||||
|
||||
this.viewport.appendChild(this.net);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public init = (): void => {
|
||||
this.butterflies = [];
|
||||
this.world.innerHTML = '';
|
||||
@@ -260,44 +489,42 @@ class ButterflyScene {
|
||||
const sprite = document.createElement('div');
|
||||
sprite.className = 'butterfly-sprite';
|
||||
el.appendChild(sprite);
|
||||
|
||||
const b: ButterflyData = {
|
||||
el,
|
||||
sprite,
|
||||
x: 0,
|
||||
y: 0,
|
||||
dir: 1,
|
||||
speed: 1,
|
||||
size: 1,
|
||||
bobFreq1: 0,
|
||||
bobPhase1: 0,
|
||||
bobAmp1: 0,
|
||||
bobFreq2: 0,
|
||||
bobPhase2: 0,
|
||||
bobAmp2: 0,
|
||||
el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false,
|
||||
bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0,
|
||||
};
|
||||
|
||||
this.resetButterfly(b);
|
||||
this.butterflies.push(b);
|
||||
this.world.appendChild(el);
|
||||
}
|
||||
|
||||
this.viewport.appendChild(this.world);
|
||||
|
||||
if (this.netEnabled) {
|
||||
this.initNet();
|
||||
}
|
||||
|
||||
if (this.netEnabled) this.initNet();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
};
|
||||
|
||||
public render = (): void => {
|
||||
for (let i = 0; i < this.butterflies.length; i++) {
|
||||
this.updateButterfly(this.butterflies[i]);
|
||||
}
|
||||
private wasPremium: boolean = false;
|
||||
private wasStunned: boolean = false;
|
||||
|
||||
if (this.netEnabled && this.netGrabbed) {
|
||||
this.checkCatches();
|
||||
public render = (): void => {
|
||||
for (const b of this.butterflies) this.updateButterfly(b);
|
||||
|
||||
if (this.netEnabled) {
|
||||
const nowPremium = this.premiumActive;
|
||||
if (this.wasPremium && !nowPremium) this.applyNetAppearance();
|
||||
this.wasPremium = nowPremium;
|
||||
|
||||
this.updateWasps();
|
||||
this.updateBirds();
|
||||
|
||||
const stunned = this.timer < this.stunUntil;
|
||||
if (stunned !== this.wasStunned) {
|
||||
this.net.classList.toggle('stunned', stunned);
|
||||
this.wasStunned = stunned;
|
||||
}
|
||||
|
||||
if (this.netGrabbed) this.checkCatches();
|
||||
if (nowPremium) this.runMagnet();
|
||||
}
|
||||
|
||||
this.timer++;
|
||||
@@ -305,99 +532,112 @@ class ButterflyScene {
|
||||
};
|
||||
|
||||
public destroy = (): void => {
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
|
||||
if (this.world && this.world.parentNode) {
|
||||
this.world.parentNode.removeChild(this.world);
|
||||
}
|
||||
if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; }
|
||||
if (this.comboTimer) window.clearTimeout(this.comboTimer);
|
||||
for (const w of this.wasps) w.cleanup?.();
|
||||
|
||||
if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world);
|
||||
this.net.removeEventListener('pointerdown', this.onPointerDown);
|
||||
window.removeEventListener('pointermove', this.onPointerMove);
|
||||
window.removeEventListener('pointerup', this.onPointerUp);
|
||||
window.removeEventListener('pointercancel', this.onPointerUp);
|
||||
if (this.net.parentNode) {
|
||||
this.net.parentNode.removeChild(this.net);
|
||||
}
|
||||
|
||||
if (this.net.parentNode) this.net.parentNode.removeChild(this.net);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponenta pro zobrazení poletujících motýlů na pozadí stránky.
|
||||
* Motýli plachtí vodorovně po měkkých zvlněných křivkách a mávají křídly.
|
||||
* Volitelná síťka umožňuje motýly chytat – po chycení přiletí nový a počet
|
||||
* chycených se ukládá do local storage.
|
||||
*
|
||||
* @param numButterflies - Počet motýlů (výchozí: 9)
|
||||
* @param className - CSS třída pro kontejner (výchozí: 'flying-butterflies')
|
||||
* @param butterflyVariants - Barevné varianty motýlů (výchozí: všechny)
|
||||
* @param enableNet - Zapne síťku na chytání (výchozí: true)
|
||||
* @param onCatch - Callback při chycení motýla (obdrží nový celkový počet)
|
||||
* Komponenta minihry chytání motýlků: mince, úrovně, prémiová zlatá síťka za
|
||||
* milník, magnet, vzácní zlatí motýli, kombo, denní bonus a perzistentní škůdci
|
||||
* (vosy na zaklikání, ptáci trhající síťku, placený plašič). Stav se ukládá na
|
||||
* server přes {@link useButterflyStats}.
|
||||
*/
|
||||
const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
||||
numButterflies = 9,
|
||||
className = 'flying-butterflies',
|
||||
butterflyVariants = BUTTERFLY_VARIANTS,
|
||||
enableNet = true,
|
||||
onCatch,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<ButterflyScene | null>(null);
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [caught, setCaught] = useState<number>(() => {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(CAUGHT_STORAGE_KEY));
|
||||
return Number.isFinite(stored) && stored > 0 ? stored : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
const [torn, setTorn] = useState(false);
|
||||
const [combo, setCombo] = useState(0);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
|
||||
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||
const flashTimer = useRef<number | null>(null);
|
||||
|
||||
// Stabilní callback – scéna se kvůli změně počtu nemusí přegenerovat
|
||||
const handleCatch = useCallback(() => {
|
||||
setCaught((prev) => {
|
||||
const next = prev + 1;
|
||||
try {
|
||||
localStorage.setItem(CAUGHT_STORAGE_KEY, String(next));
|
||||
} catch {
|
||||
/* local storage nedostupné – počet se prostě neuloží */
|
||||
}
|
||||
if (onCatch) onCatch(next);
|
||||
return next;
|
||||
});
|
||||
}, [onCatch]);
|
||||
const showFlash = useCallback((msg: string) => {
|
||||
setFlash(msg);
|
||||
if (flashTimer.current) window.clearTimeout(flashTimer.current);
|
||||
flashTimer.current = window.setTimeout(() => setFlash(null), 6000);
|
||||
}, []);
|
||||
|
||||
const handleReward = useCallback((e: RewardEvent) => {
|
||||
if (e.premiumUnlocked) {
|
||||
sceneRef.current?.activatePremiumNet();
|
||||
showFlash('🪄 Zlatá síťka aktivní! Větší, magnetická a odolná proti ptákům.');
|
||||
} else if (e.leveledUp) {
|
||||
showFlash(`⭐ Nová úroveň: ${e.newTitle}!`);
|
||||
} else if (e.dailyBonusApplied) {
|
||||
showFlash('☀️ Denní bonus +10 mincí!');
|
||||
}
|
||||
}, [showFlash]);
|
||||
|
||||
const { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear } = useButterflyStats(handleReward);
|
||||
|
||||
const callbacksRef = useRef<SceneCallbacks>({
|
||||
onCatch: () => { }, getCoins: () => 0, onTornChange: () => { },
|
||||
onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { },
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onCatch: (golden) => reportCatch(golden),
|
||||
getCoins: () => coinsRef.current,
|
||||
onTornChange: (t) => { setTorn(t); if (t) void reportTear(); },
|
||||
onCombo: (c) => setCombo(c),
|
||||
onSting: () => showFlash('🐝 Au! Vosa tě žihla – síťka teď 5 s nechytá. Zaklikej vosy!'),
|
||||
onSwatWasp: () => void killWasp(),
|
||||
};
|
||||
|
||||
const initialize = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
sceneRef.current = new ButterflyScene(
|
||||
containerRef.current,
|
||||
numButterflies,
|
||||
butterflyVariants,
|
||||
enableNet,
|
||||
handleCatch,
|
||||
containerRef.current, numButterflies, butterflyVariants, enableNet,
|
||||
{
|
||||
onCatch: (g) => callbacksRef.current.onCatch(g),
|
||||
getCoins: () => callbacksRef.current.getCoins(),
|
||||
onTornChange: (t) => callbacksRef.current.onTornChange(t),
|
||||
onCombo: (c) => callbacksRef.current.onCombo(c),
|
||||
onSting: () => callbacksRef.current.onSting(),
|
||||
onSwatWasp: () => callbacksRef.current.onSwatWasp(),
|
||||
},
|
||||
);
|
||||
sceneRef.current.init();
|
||||
sceneRef.current.render();
|
||||
}
|
||||
}, [numButterflies, butterflyVariants, enableNet, handleCatch]);
|
||||
}, [numButterflies, butterflyVariants, enableNet]);
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
if (sceneRef.current) {
|
||||
sceneRef.current.destroy();
|
||||
sceneRef.current = null;
|
||||
}
|
||||
if (sceneRef.current) { sceneRef.current.destroy(); sceneRef.current = null; }
|
||||
if (flashTimer.current) window.clearTimeout(flashTimer.current);
|
||||
};
|
||||
}, [initialize]);
|
||||
|
||||
// Krátké „poskočení" počítadla při každém chycení
|
||||
// Synchronizace škůdců a protržení ze serveru do scény
|
||||
const wasps = stats?.wasps ?? 0;
|
||||
const birds = stats?.birds ?? 0;
|
||||
const netTorn = stats?.netTorn ?? false;
|
||||
useEffect(() => { sceneRef.current?.syncPests(wasps, birds); }, [wasps, birds]);
|
||||
useEffect(() => {
|
||||
sceneRef.current?.setTornFromServer(netTorn);
|
||||
setTorn(netTorn);
|
||||
}, [netTorn]);
|
||||
|
||||
const caught = stats?.caught ?? 0;
|
||||
useEffect(() => {
|
||||
const node = badgeRef.current;
|
||||
if (!node || caught === 0) return;
|
||||
@@ -406,31 +646,123 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
||||
node.classList.add('bump');
|
||||
}, [caught]);
|
||||
|
||||
const coins = stats?.coins ?? 0;
|
||||
const canAffordRepair = coins >= REPAIR_COST;
|
||||
const canAffordRepellent = coins >= REPELLENT_COST;
|
||||
const progress = Math.round(((caught % PREMIUM_MILESTONE) / PREMIUM_MILESTONE) * 100);
|
||||
const repellentUntil = stats?.repellentUntil ?? 0;
|
||||
const repellentRemainingMs = Math.max(0, repellentUntil - nowTs);
|
||||
const repellentActive = repellentRemainingMs > 0;
|
||||
|
||||
// Tik po sekundách pro odpočet plašiče (jen dokud běží)
|
||||
useEffect(() => {
|
||||
if (repellentUntil <= Date.now()) return;
|
||||
const id = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [repellentUntil]);
|
||||
|
||||
const onRepairClick = useCallback(async () => {
|
||||
const ok = await repair();
|
||||
if (ok) sceneRef.current?.repairNetExternally();
|
||||
}, [repair]);
|
||||
|
||||
const onRepellentClick = useCallback(async () => {
|
||||
const ok = await buyRepellent();
|
||||
if (ok) showFlash('🦅🚫 Plašič ptáků koupen – ptáci na chvíli zmizeli.');
|
||||
}, [buyRepellent, showFlash]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className={className} />
|
||||
{enableNet && (
|
||||
<div ref={badgeRef} className="butterfly-counter" title="Chycení motýli">
|
||||
<span className="butterfly-counter-icon" aria-hidden="true" />
|
||||
<span className="butterfly-counter-value">{caught}</span>
|
||||
<div className="butterfly-hud">
|
||||
{flash && (
|
||||
<div className="butterfly-flash" onClick={() => setFlash(null)} title="Klikni pro zavření">
|
||||
{flash}
|
||||
</div>
|
||||
)}
|
||||
{combo > 1 && <div className="butterfly-combo">Kombo ×{combo}!</div>}
|
||||
|
||||
<div
|
||||
ref={badgeRef}
|
||||
className="butterfly-counter"
|
||||
title="Zobrazit žebříček a statistiky"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setLeaderboardOpen(true)}
|
||||
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') setLeaderboardOpen(true); }}
|
||||
>
|
||||
<div className="butterfly-counter-row">
|
||||
<span className="butterfly-counter-icon" aria-hidden="true" />
|
||||
<span className="butterfly-counter-value">{caught}</span>
|
||||
<span className="butterfly-coin-icon" aria-hidden="true" />
|
||||
<span className="butterfly-counter-value">{coins}</span>
|
||||
</div>
|
||||
<div className="butterfly-level">
|
||||
<span className="butterfly-level-title">
|
||||
{stats ? `${stats.level}. ${stats.title}` : '…'}
|
||||
</span>
|
||||
<div className="butterfly-progress" title="Postup k další zlaté síťce">
|
||||
<div className="butterfly-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="butterfly-pests" title="Škůdci u síťky">
|
||||
<span className={wasps > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🐝 {wasps}</span>
|
||||
<span className={birds > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds}</span>
|
||||
{repellentActive && (
|
||||
<span className="butterfly-pest ok" title="Zbývající doba plašiče">
|
||||
🚫 plašič {formatRemaining(repellentRemainingMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{torn && (
|
||||
<button
|
||||
type="button"
|
||||
className="butterfly-action-btn danger"
|
||||
onClick={(ev) => { ev.stopPropagation(); void onRepairClick(); }}
|
||||
disabled={!canAffordRepair}
|
||||
title={canAffordRepair ? 'Zašít protrženou síťku' : 'Nemáš dost mincí – nachytej další motýly'}
|
||||
>
|
||||
🪡 Zašít síťku za {REPAIR_COST} 🪙
|
||||
</button>
|
||||
)}
|
||||
|
||||
{birds > 0 && !repellentActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="butterfly-action-btn"
|
||||
onClick={(ev) => { ev.stopPropagation(); void onRepellentClick(); }}
|
||||
disabled={!canAffordRepellent}
|
||||
title={canAffordRepellent ? 'Vyžene všechny ptáky a chvíli brání novým' : 'Nemáš dost mincí na plašič'}
|
||||
>
|
||||
🦅🚫 Plašič za {REPELLENT_COST} 🪙
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ButterflyLeaderboardModal
|
||||
isOpen={leaderboardOpen}
|
||||
onClose={() => setLeaderboardOpen(false)}
|
||||
myStats={stats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Přednastavení množství motýlů pro různé účely
|
||||
export const BUTTERFLY_PRESETS = {
|
||||
LIGHT: 5, // Pár motýlů
|
||||
NORMAL: 9, // Standardní množství
|
||||
HEAVY: 16, // Rušná letní louka
|
||||
LIGHT: 5,
|
||||
NORMAL: 9,
|
||||
HEAVY: 16,
|
||||
} as const;
|
||||
|
||||
// Přednastavené barevné kombinace
|
||||
export const BUTTERFLY_COLOR_THEMES = {
|
||||
ALL: BUTTERFLY_VARIANTS,
|
||||
WARM: ['butterfly-orange.svg', 'butterfly-yellow.svg', 'butterfly-pink.svg'] as const,
|
||||
COOL: ['butterfly-blue.svg'] as const,
|
||||
WARM: ['/butterfly-orange.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg'] as const,
|
||||
COOL: ['/butterfly-blue.svg'] as const,
|
||||
} as const;
|
||||
|
||||
export default FlyingButterflies;
|
||||
|
||||
Reference in New Issue
Block a user