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
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:
co-authored by
Claude Opus 4.8
parent
146c31b775
commit
c8c5ecc60c
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user