feat: chytání motýlků síťkou s počítadlem
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 35s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m27s
CI / Build and push Docker image (push) Successful in 42s
CI / Notify (push) Successful in 2s

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) <noreply@anthropic.com>
This commit is contained in:
Stánek Pavel
2026-07-21 10:08:47 +02:00
co-authored by Claude Opus 4.8
parent 146c31b775
commit c8c5ecc60c
4 changed files with 440 additions and 5 deletions
+203 -5
View File
@@ -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<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;
}
});
// 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<FlyingButterfliesProps> = ({
};
}, [initialize]);
return <div ref={containerRef} className={className} />;
// 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 (
<>
<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>
)}
</>
);
};
// Přednastavení množství motýlů pro různé účely