From c8c5ecc60ca13f4467e1460b3a4902f7287d05e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A1nek=20Pavel?= Date: Tue, 21 Jul 2026 10:08:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20chyt=C3=A1n=C3=AD=20mot=C3=BDlk=C5=AF?= =?UTF-8?q?=20s=C3=AD=C5=A5kou=20s=20po=C4=8D=C3=ADtadlem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Přidána síťka, kterou lze uchopit myší a lovit poletující motýly. Po chycení přiletí nový (počet létajících se nemění), počet ulovených se zobrazuje jako odznak a ukládá do local storage. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/public/butterfly-net.svg | 106 +++++++++++++++ client/src/FlyingButterflies.scss | 128 ++++++++++++++++++ client/src/FlyingButterflies.tsx | 208 +++++++++++++++++++++++++++++- server/changelogs/2026-07-21.json | 3 + 4 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 client/public/butterfly-net.svg create mode 100644 server/changelogs/2026-07-21.json 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 && ( +
+
+ )} + + ); }; // 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á" +]