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
+106
View File
@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100">
<defs>
<linearGradient id="netFill" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.08"/>
</linearGradient>
<!-- Celý tvar síťového vaku -->
<clipPath id="netBagClip">
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"/>
</clipPath>
</defs>
<!-- Síťový vak za obručí -->
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"
fill="url(#netFill)"
stroke="#AEBBC8"
stroke-width="1"
opacity="0.7"/>
<!-- Výplet síťky -->
<g clip-path="url(#netBagClip)"
fill="none"
stroke="#AEBBC8"
stroke-width="0.8"
opacity="0.6"
stroke-linecap="round">
<!-- Vlákna začínající na horní a boční obruči -->
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
<!-- Vlákna od levého okraje obruče -->
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
<!-- Příčná oka -->
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
</g>
<!-- Rukojeť -->
<line x1="57" y1="57"
x2="93" y2="93"
stroke="#8B5A2B"
stroke-width="6"
stroke-linecap="round"/>
<line x1="57" y1="57"
x2="93" y2="93"
stroke="#A9713A"
stroke-width="3"
stroke-linecap="round"/>
<!-- Kovové napojení rukojeti -->
<line x1="56" y1="55"
x2="61" y2="61"
stroke="#71808E"
stroke-width="6"
stroke-linecap="round"/>
<line x1="56" y1="55"
x2="61" y2="61"
stroke="#B8C4CF"
stroke-width="2.5"
stroke-linecap="round"/>
<!-- Hlavní obruč nezměněná -->
<circle cx="36" cy="35" r="27"
fill="none"
stroke="#7A8896"
stroke-width="4.5"/>
<circle cx="36" cy="35" r="27"
fill="none"
stroke="#AEBBC8"
stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+128
View File
@@ -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;
}
}
+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
+3
View File
@@ -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á"
]