diff --git a/client/public/butterfly-net.svg b/client/public/butterfly-net.svg
new file mode 100644
index 0000000..f5eab85
--- /dev/null
+++ b/client/public/butterfly-net.svg
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/src/FlyingButterflies.scss b/client/src/FlyingButterflies.scss
index 9507914..0ed8782 100644
--- a/client/src/FlyingButterflies.scss
+++ b/client/src/FlyingButterflies.scss
@@ -48,9 +48,137 @@
}
}
+// --- Síťka na chytání --------------------------------------------------------
+
+.butterfly-net {
+ position: fixed;
+ top: 0;
+ left: 0;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+ pointer-events: auto;
+ cursor: grab;
+ z-index: 4;
+ will-change: transform;
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
+ transition: filter 0.15s ease;
+ // zabrání označování/scrollování při tažení na dotykových zařízeních
+ touch-action: none;
+ user-select: none;
+
+ &.grabbed {
+ cursor: grabbing;
+ filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35));
+ }
+
+ &.catch-pop {
+ animation: butterfly-net-pop 0.3s ease-out;
+ }
+}
+
+@keyframes butterfly-net-pop {
+ 0% {
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
+ }
+ 40% {
+ filter: drop-shadow(0 2px 8px rgba(120, 200, 120, 0.7)) brightness(1.25);
+ }
+ 100% {
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
+ }
+}
+
+// Prchavé „+1" v místě chycení
+.butterfly-catch-fx {
+ position: fixed;
+ margin: -12px 0 0 10px;
+ font-weight: 700;
+ font-size: 1.1rem;
+ color: #2f9e44;
+ text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
+ pointer-events: none;
+ z-index: 5;
+ animation: butterfly-catch-float 0.75s ease-out forwards;
+}
+
+@keyframes butterfly-catch-float {
+ 0% {
+ opacity: 0;
+ transform: translateY(0) scale(0.9);
+ }
+ 20% {
+ opacity: 1;
+ transform: translateY(-6px) scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-40px) scale(1);
+ }
+}
+
+// --- Počítadlo chycených motýlů ----------------------------------------------
+
+.butterfly-counter {
+ position: fixed;
+ bottom: 16px;
+ left: 16px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px 6px 10px;
+ background: rgba(255, 255, 255, 0.85);
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ border-radius: 999px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ backdrop-filter: blur(4px);
+ font-weight: 600;
+ font-size: 0.95rem;
+ color: #333;
+ z-index: 5;
+ user-select: none;
+
+ &.bump {
+ animation: butterfly-counter-bump 0.3s ease-out;
+ }
+}
+
+.butterfly-counter-icon {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ background-image: url(butterfly-orange.svg);
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+}
+
+.butterfly-counter-value {
+ min-width: 1ch;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+
+@keyframes butterfly-counter-bump {
+ 0% {
+ transform: scale(1);
+ }
+ 40% {
+ transform: scale(1.18);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
@media (prefers-reduced-motion: reduce) {
.butterfly-sprite {
animation: none;
}
+
+ .butterfly-net.catch-pop,
+ .butterfly-counter.bump {
+ animation: none;
+ }
}
diff --git a/client/src/FlyingButterflies.tsx b/client/src/FlyingButterflies.tsx
index 3c02303..c84a0d2 100644
--- a/client/src/FlyingButterflies.tsx
+++ b/client/src/FlyingButterflies.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useCallback } from 'react';
+import React, { useEffect, useRef, useCallback, useState } from 'react';
// Různé barevné varianty motýlů
const BUTTERFLY_VARIANTS = [
@@ -8,6 +8,9 @@ const BUTTERFLY_VARIANTS = [
'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';
+
interface ButterflyData {
/** Vnější element – nese pozici, natočení a velikost */
el: HTMLDivElement;
@@ -37,6 +40,10 @@ interface FlyingButterfliesProps {
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 {
@@ -51,17 +58,42 @@ 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;
+
// Základní velikost sprite v px (dále se násobí náhodným měřítkem)
private static readonly BASE_SIZE = 34;
- constructor(el: HTMLElement, numButterflies: number = 9, variants: readonly string[] = BUTTERFLY_VARIANTS) {
+ // 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;
+
+ constructor(
+ el: HTMLElement,
+ numButterflies: number = 9,
+ variants: readonly string[] = BUTTERFLY_VARIANTS,
+ netEnabled: boolean = true,
+ onCatch?: () => void,
+ ) {
this.viewport = el;
this.world = document.createElement('div');
this.numButterflies = numButterflies;
this.variants = variants;
+ this.netEnabled = netEnabled;
+ this.onCatch = onCatch;
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;
@@ -124,6 +156,100 @@ class ButterflyScene {
}
};
+ // --- Síťka na chytání ---------------------------------------------------
+
+ private updateNetTransform = (): void => {
+ this.net.style.transform =
+ `translate(${this.netX - ButterflyScene.HOOP_OFFSET_X}px, ` +
+ `${this.netY - ButterflyScene.HOOP_OFFSET_Y}px)`;
+ };
+
+ private onPointerDown = (e: PointerEvent): void => {
+ e.preventDefault();
+ this.netGrabbed = true;
+ this.net.classList.add('grabbed');
+ this.netX = e.clientX;
+ this.netY = e.clientY;
+ this.updateNetTransform();
+ };
+
+ private onPointerMove = (e: PointerEvent): void => {
+ if (!this.netGrabbed) return;
+ this.netX = e.clientX;
+ this.netY = e.clientY;
+ this.updateNetTransform();
+ };
+
+ private onPointerUp = (): void => {
+ if (!this.netGrabbed) return;
+ this.netGrabbed = false;
+ this.net.classList.remove('grabbed');
+ };
+
+ // Zobrazí prchavé „+1" v místě chycení
+ private spawnCatchFx = (): 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`;
+ this.viewport.appendChild(fx);
+ window.setTimeout(() => {
+ if (fx.parentNode) fx.parentNode.removeChild(fx);
+ }, 750);
+ };
+
+ // 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);
+ 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;
+
+ 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);
+ }
+ }
+
+ 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 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.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 = '';
@@ -157,6 +283,11 @@ class ButterflyScene {
}
this.viewport.appendChild(this.world);
+
+ if (this.netEnabled) {
+ this.initNet();
+ }
+
window.addEventListener('resize', this.handleResize);
};
@@ -165,6 +296,10 @@ class ButterflyScene {
this.updateButterfly(this.butterflies[i]);
}
+ if (this.netEnabled && this.netGrabbed) {
+ this.checkCatches();
+ }
+
this.timer++;
this.animationId = requestAnimationFrame(this.render);
};
@@ -179,6 +314,14 @@ class ButterflyScene {
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);
+ }
+
window.removeEventListener('resize', this.handleResize);
};
}
@@ -186,26 +329,62 @@ class ButterflyScene {
/**
* 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)
*/
const FlyingButterflies: React.FC = ({
numButterflies = 9,
className = 'flying-butterflies',
butterflyVariants = BUTTERFLY_VARIANTS,
+ enableNet = true,
+ onCatch,
}) => {
const containerRef = useRef(null);
const sceneRef = useRef(null);
+ const badgeRef = useRef(null);
+
+ const [caught, setCaught] = useState(() => {
+ try {
+ const stored = Number(localStorage.getItem(CAUGHT_STORAGE_KEY));
+ return Number.isFinite(stored) && stored > 0 ? stored : 0;
+ } catch {
+ return 0;
+ }
+ });
+
+ // 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 initialize = useCallback(() => {
if (containerRef.current) {
- sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants);
+ sceneRef.current = new ButterflyScene(
+ containerRef.current,
+ numButterflies,
+ butterflyVariants,
+ enableNet,
+ handleCatch,
+ );
sceneRef.current.init();
sceneRef.current.render();
}
- }, [numButterflies, butterflyVariants]);
+ }, [numButterflies, butterflyVariants, enableNet, handleCatch]);
useEffect(() => {
initialize();
@@ -218,7 +397,26 @@ const FlyingButterflies: React.FC = ({
};
}, [initialize]);
- return
;
+ // Krátké „poskočení" počítadla při každém chycení
+ useEffect(() => {
+ const node = badgeRef.current;
+ if (!node || caught === 0) return;
+ node.classList.remove('bump');
+ void node.offsetWidth;
+ node.classList.add('bump');
+ }, [caught]);
+
+ return (
+ <>
+
+ {enableNet && (
+
+
+ {caught}
+
+ )}
+ >
+ );
};
// Přednastavení množství motýlů pro různé účely
diff --git a/server/changelogs/2026-07-21.json b/server/changelogs/2026-07-21.json
new file mode 100644
index 0000000..9af5a64
--- /dev/null
+++ b/server/changelogs/2026-07-21.json
@@ -0,0 +1,3 @@
+[
+ "Chytání motýlků: uchop myší síťku a lov poletující motýly – počet ulovených se ti počítá a ukládá"
+]