feat: rozšíření chytání motýlků o progres, mince a škůdce
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 44s
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 27s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
- serverová perzistence statistik (mince, úrovně, zlatí motýli) + týmový žebříček - prémiová zlatá síťka za milník (větší, magnetická, odolná proti ptákům) - perzistentní škůdci: vosy na zaklikání a ptáci trhající síťku (F5-proof) - placený plašič ptáků s odpočtem, denní bonus, kombo - WOW zlatý motýl + osobní statistiky v modalu žebříčku - oprava zobrazení ikonek (root-absolutní cesty k public assetům) 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
c8c5ecc60c
commit
c5ca6c9d41
@@ -0,0 +1,26 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- Vosa letící doprava (JS překlápí podle směru) -->
|
||||||
|
<!-- křídla -->
|
||||||
|
<g fill="#DDEEFF" stroke="#9BB6CC" stroke-width="1" opacity="0.85">
|
||||||
|
<ellipse cx="46" cy="30" rx="14" ry="8" transform="rotate(-18 46 30)"/>
|
||||||
|
<ellipse cx="58" cy="32" rx="11" ry="6" transform="rotate(-8 58 32)"/>
|
||||||
|
</g>
|
||||||
|
<!-- žihadlo -->
|
||||||
|
<path fill="#3A2E00" d="M18,50 L6,50 L18,46 Z"/>
|
||||||
|
<!-- tělo -->
|
||||||
|
<g stroke="#3A2E00" stroke-width="1.4" stroke-linejoin="round">
|
||||||
|
<ellipse cx="46" cy="50" rx="30" ry="16" fill="#F5C518"/>
|
||||||
|
<!-- pruhy -->
|
||||||
|
<path fill="#2B2200" stroke="none" d="M34,36 q6,14 0,28 q-8,-2 -10,-14 q2,-12 10,-14 z"/>
|
||||||
|
<path fill="#2B2200" stroke="none" d="M50,35 q6,15 0,30 q-8,-3 -8,-15 q0,-12 8,-15 z"/>
|
||||||
|
<!-- hlava -->
|
||||||
|
<circle cx="72" cy="50" r="10" fill="#2B2200"/>
|
||||||
|
</g>
|
||||||
|
<!-- oko a tykadla -->
|
||||||
|
<circle fill="#FFFFFF" cx="75" cy="47" r="2.4"/>
|
||||||
|
<circle fill="#20313F" cx="75.6" cy="47" r="1.2"/>
|
||||||
|
<g stroke="#2B2200" stroke-width="1.2" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M78,42 C84,36 88,36 90,38"/>
|
||||||
|
<path d="M80,44 C86,40 90,41 92,43"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- Pták letící doprava (JS překlápí podle směru) -->
|
||||||
|
<g stroke="#3A4A5A" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<!-- ocas -->
|
||||||
|
<path fill="#5C6B7A" d="M18,52 L4,44 L6,56 L2,64 L18,60 Z"/>
|
||||||
|
<!-- tělo -->
|
||||||
|
<path fill="#6E7F90" d="M16,58 C24,44 44,40 64,46 C74,49 82,54 84,56 C78,62 66,66 52,66 C36,66 22,64 16,58 Z"/>
|
||||||
|
<!-- křídlo (nahoru) -->
|
||||||
|
<path fill="#55636F" d="M40,52 C46,30 60,24 70,26 C64,38 58,48 54,56 C50,58 44,57 40,52 Z"/>
|
||||||
|
<!-- hlava -->
|
||||||
|
<circle fill="#6E7F90" cx="78" cy="50" r="12"/>
|
||||||
|
</g>
|
||||||
|
<!-- zobák -->
|
||||||
|
<path fill="#F6A21E" stroke="#B9740A" stroke-width="1" stroke-linejoin="round" d="M88,48 L98,50 L88,54 Z"/>
|
||||||
|
<!-- oko -->
|
||||||
|
<circle fill="#20313F" cx="82" cy="47" r="2.2"/>
|
||||||
|
<circle fill="#FFFFFF" cx="82.8" cy="46.3" r="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 898 B |
@@ -0,0 +1,66 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="goldGlow" cx="0.5" cy="0.5" r="0.55">
|
||||||
|
<stop offset="0%" stop-color="#FFF6C8" stop-opacity="0.9"/>
|
||||||
|
<stop offset="55%" stop-color="#FFD24A" stop-opacity="0.35"/>
|
||||||
|
<stop offset="100%" stop-color="#FFD24A" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="goldWing" x1="0" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFFBE6"/>
|
||||||
|
<stop offset="35%" stop-color="#FFDE6A"/>
|
||||||
|
<stop offset="70%" stop-color="#F5B617"/>
|
||||||
|
<stop offset="100%" stop-color="#C97B00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldSheen" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/>
|
||||||
|
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Zářivá aura -->
|
||||||
|
<circle cx="50" cy="48" r="48" fill="url(#goldGlow)"/>
|
||||||
|
|
||||||
|
<!-- Křídla -->
|
||||||
|
<g stroke="#8A5A00" stroke-width="1.6" stroke-linejoin="round">
|
||||||
|
<path fill="url(#goldWing)" d="M48,32 C30,8 6,12 6,34 C6,52 30,54 48,50 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M52,32 C70,8 94,12 94,34 C94,52 70,54 52,50 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M47,50 C33,54 14,58 16,76 C18,89 41,86 47,66 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M53,50 C67,54 86,58 84,76 C82,89 59,86 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Drahokamové body na křídlech -->
|
||||||
|
<g fill="#FFF8D8" stroke="#E8B23A" stroke-width="0.6">
|
||||||
|
<circle cx="20" cy="30" r="4.5"/>
|
||||||
|
<circle cx="80" cy="30" r="4.5"/>
|
||||||
|
<circle cx="29" cy="72" r="3.5"/>
|
||||||
|
<circle cx="71" cy="72" r="3.5"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#B8860B">
|
||||||
|
<circle cx="20" cy="30" r="1.6"/>
|
||||||
|
<circle cx="80" cy="30" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Lesklý přeliv přes horní křídla -->
|
||||||
|
<path fill="url(#goldSheen)" opacity="0.55" d="M48,33 C34,15 16,17 12,31 C24,26 40,27 48,40 Z"/>
|
||||||
|
|
||||||
|
<!-- Tykadla -->
|
||||||
|
<g stroke="#6A4A00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,17 40,13 36,10"/>
|
||||||
|
<path d="M51,28 C56,17 60,13 64,10"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Tělo -->
|
||||||
|
<g fill="#5A3E00" stroke="none">
|
||||||
|
<circle cx="36" cy="10" r="2"/>
|
||||||
|
<circle cx="64" cy="10" r="2"/>
|
||||||
|
<rect x="47" y="24" width="6" height="52" rx="3"/>
|
||||||
|
<circle cx="50" cy="24" r="5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Jiskry -->
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<path d="M50 2 l1.4 3.4 l3.4 1.4 l-3.4 1.4 l-1.4 3.4 l-1.4 -3.4 l-3.4 -1.4 l3.4 -1.4 z"/>
|
||||||
|
<path d="M90 20 l1 2.6 l2.6 1 l-2.6 1 l-1 2.6 l-1 -2.6 l-2.6 -1 l2.6 -1 z" opacity="0.85"/>
|
||||||
|
<path d="M12 66 l0.9 2.3 l2.3 0.9 l-2.3 0.9 l-0.9 2.3 l-0.9 -2.3 l-2.3 -0.9 l2.3 -0.9 z" opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,79 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="100"
|
||||||
|
height="100">
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="netFillGold" x1="0" y1="0" x2="0.4" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFF3C4" stop-opacity="0.28"/>
|
||||||
|
<stop offset="100%" stop-color="#F0C24A" stop-opacity="0.12"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="netBagClipGold">
|
||||||
|
<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(#netFillGold)"
|
||||||
|
stroke="#E8B23A"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.85"/>
|
||||||
|
|
||||||
|
<!-- Výplet síťky -->
|
||||||
|
<g clip-path="url(#netBagClipGold)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#E8B23A"
|
||||||
|
stroke-width="0.8"
|
||||||
|
opacity="0.7"
|
||||||
|
stroke-linecap="round">
|
||||||
|
<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"/>
|
||||||
|
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||||
|
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||||
|
<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="#B8860B" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#FFE79A" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Zlatá obruč -->
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#C8891A" stroke-width="4.5"/>
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#FFD24A" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Třpytky -->
|
||||||
|
<g fill="#FFF7D6" stroke="none">
|
||||||
|
<path d="M 33 6 l 1.2 3 l 3 1.2 l -3 1.2 l -1.2 3 l -1.2 -3 l -3 -1.2 l 3 -1.2 z"/>
|
||||||
|
<path d="M 60 40 l 1 2.4 l 2.4 1 l -2.4 1 l -1 2.4 l -1 -2.4 l -2.4 -1 l 2.4 -1 z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="100"
|
||||||
|
height="100">
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="netFillTorn" x1="0" y1="0" x2="0.4" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.14"/>
|
||||||
|
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.06"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Vak s vyříznutou dírou uprostřed -->
|
||||||
|
<clipPath id="netBagClipTorn">
|
||||||
|
<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
|
||||||
|
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Síťový vak (s dírou) -->
|
||||||
|
<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
|
||||||
|
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||||
|
fill="url(#netFillTorn)"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.7"
|
||||||
|
fill-rule="evenodd"/>
|
||||||
|
|
||||||
|
<!-- Výplet síťky (oříznutý dírou) -->
|
||||||
|
<g clip-path="url(#netBagClipTorn)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="0.8"
|
||||||
|
opacity="0.55"
|
||||||
|
stroke-linecap="round">
|
||||||
|
<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"/>
|
||||||
|
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||||
|
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||||
|
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Roztřepená vlákna kolem díry -->
|
||||||
|
<g stroke="#9AA7B4" stroke-width="0.8" fill="none" stroke-linecap="round" opacity="0.8">
|
||||||
|
<path d="M 27 40 l -3 -2"/>
|
||||||
|
<path d="M 43 46 l 3 -1"/>
|
||||||
|
<path d="M 30 58 l -1 3"/>
|
||||||
|
<path d="M 38 52 l 3 2"/>
|
||||||
|
<path d="M 24 49 l -3 1"/>
|
||||||
|
</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"/>
|
||||||
|
|
||||||
|
<!-- Obruč -->
|
||||||
|
<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 |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="coinFace" cx="0.4" cy="0.35" r="0.75">
|
||||||
|
<stop offset="0%" stop-color="#FFE9A3"/>
|
||||||
|
<stop offset="60%" stop-color="#FFCB3D"/>
|
||||||
|
<stop offset="100%" stop-color="#E0A200"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="50" cy="50" r="42" fill="#C88A12"/>
|
||||||
|
<circle cx="50" cy="50" r="38" fill="url(#coinFace)" stroke="#F2D774" stroke-width="2"/>
|
||||||
|
<circle cx="50" cy="50" r="30" fill="none" stroke="#B8860B" stroke-width="2" opacity="0.6"/>
|
||||||
|
<!-- stylizovaný motýlek na minci -->
|
||||||
|
<g fill="#B8860B" opacity="0.85">
|
||||||
|
<path d="M50,42 C42,32 30,34 30,44 C30,52 42,52 50,50 Z"/>
|
||||||
|
<path d="M50,42 C58,32 70,34 70,44 C70,52 58,52 50,50 Z"/>
|
||||||
|
<path d="M49,50 C42,52 34,56 36,64 C38,70 48,66 49,58 Z"/>
|
||||||
|
<path d="M51,50 C58,52 66,56 64,64 C62,70 52,66 51,58 Z"/>
|
||||||
|
<rect x="48.5" y="40" width="3" height="26" rx="1.5"/>
|
||||||
|
</g>
|
||||||
|
<!-- lesk -->
|
||||||
|
<path d="M 30 30 C 38 24, 50 24, 58 28" fill="none" stroke="#FFF7DC" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -36,6 +36,31 @@
|
|||||||
background-size: contain;
|
background-size: contain;
|
||||||
animation: butterfly-flap 0.45s ease-in-out infinite;
|
animation: butterfly-flap 0.45s ease-in-out infinite;
|
||||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
|
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
|
||||||
|
|
||||||
|
// Vzácný zlatý motýl – WOW: silná pulzující zlatá záře + duhový nádech
|
||||||
|
&.golden {
|
||||||
|
animation:
|
||||||
|
butterfly-flap 0.35s ease-in-out infinite,
|
||||||
|
butterfly-golden-glow 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-golden-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter:
|
||||||
|
saturate(1.15) hue-rotate(0deg)
|
||||||
|
drop-shadow(0 0 5px rgba(255, 215, 80, 0.85))
|
||||||
|
drop-shadow(0 0 10px rgba(255, 180, 40, 0.55))
|
||||||
|
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter:
|
||||||
|
saturate(1.4) hue-rotate(-16deg)
|
||||||
|
drop-shadow(0 0 13px rgba(255, 235, 130, 1))
|
||||||
|
drop-shadow(0 0 24px rgba(255, 190, 50, 0.95))
|
||||||
|
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes butterfly-flap {
|
@keyframes butterfly-flap {
|
||||||
@@ -48,6 +73,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Havěť (ptáci a vosy) ----------------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-critter {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.2));
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
// Vosa jde zaklikat (plácačka)
|
||||||
|
&.wasp {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Síťka na chytání --------------------------------------------------------
|
// --- Síťka na chytání --------------------------------------------------------
|
||||||
|
|
||||||
.butterfly-net {
|
.butterfly-net {
|
||||||
@@ -63,7 +110,6 @@
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
transition: filter 0.15s ease;
|
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;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
@@ -72,9 +118,45 @@
|
|||||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35));
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prémiová (zlatá) síťka – zlatá záře a jemný pulz
|
||||||
|
&.premium {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 200, 60, 0.85)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
animation: butterfly-net-glow 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protržená síťka – nedá se chytat
|
||||||
|
&.torn {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.9;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(150, 30, 30, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
&.catch-pop {
|
&.catch-pop {
|
||||||
animation: butterfly-net-pop 0.3s ease-out;
|
animation: butterfly-net-pop 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.stung {
|
||||||
|
animation: butterfly-net-stung 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Omráčená síťka (po žihnutí vosou) – po celou dobu, kdy nejde chytat.
|
||||||
|
// Má přednost před grabbed kurzorem.
|
||||||
|
&.stunned {
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
animation: butterfly-net-dizzy 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zešednutí + červený nádech, ať je jasné, že síťka teď nechytá.
|
||||||
|
// NEanimujeme transform (ten řídí JS inline) – jen filter.
|
||||||
|
@keyframes butterfly-net-dizzy {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: grayscale(0.7) brightness(0.85) drop-shadow(0 0 5px rgba(217, 72, 15, 0.55));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: grayscale(0.85) brightness(0.8) drop-shadow(0 0 9px rgba(217, 72, 15, 0.8));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes butterfly-net-pop {
|
@keyframes butterfly-net-pop {
|
||||||
@@ -89,17 +171,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prchavé „+1" v místě chycení
|
@keyframes butterfly-net-glow {
|
||||||
.butterfly-catch-fx {
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(255, 200, 60, 0.6)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: drop-shadow(0 0 12px rgba(255, 220, 100, 1)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pozn.: NEanimujeme transform – ten nastavuje JS inline (poloha síťky).
|
||||||
|
// Žihnutí vosou proto naznačíme jen červeným zábleskem filtru.
|
||||||
|
@keyframes butterfly-net-stung {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(217, 72, 15, 0.85)) brightness(1.1) hue-rotate(-15deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prchavé texty (chycení, protržení, žihnutí)
|
||||||
|
.butterfly-catch-fx,
|
||||||
|
.butterfly-tear-fx,
|
||||||
|
.butterfly-sting-fx {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
margin: -12px 0 0 10px;
|
margin: -12px 0 0 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #2f9e44;
|
|
||||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
animation: butterfly-catch-float 0.75s ease-out forwards;
|
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
||||||
|
animation: butterfly-catch-float 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-catch-fx {
|
||||||
|
color: #2f9e44;
|
||||||
|
|
||||||
|
&.golden {
|
||||||
|
color: #e0a200;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-tear-fx {
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-sting-fx {
|
||||||
|
color: #d9480f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes butterfly-catch-float {
|
@keyframes butterfly-catch-float {
|
||||||
@@ -117,48 +240,214 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Počítadlo chycených motýlů ----------------------------------------------
|
// --- HUD: počítadlo, mince, úroveň, kombo, hlášky ----------------------------
|
||||||
|
|
||||||
.butterfly-counter {
|
.butterfly-hud {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
display: inline-flex;
|
z-index: 5;
|
||||||
align-items: center;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px 6px 10px;
|
// HUD sám o sobě neblokuje klikání; interaktivní je jen tlačítko opravy
|
||||||
background: rgba(255, 255, 255, 0.85);
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
border-radius: 999px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #333;
|
color: #333;
|
||||||
z-index: 5;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
&.bump {
|
&.bump {
|
||||||
animation: butterfly-counter-bump 0.3s ease-out;
|
animation: butterfly-counter-bump 0.3s ease-out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.butterfly-counter-icon {
|
.butterfly-counter-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-icon,
|
||||||
|
.butterfly-coin-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background-image: url(butterfly-orange.svg);
|
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-icon {
|
||||||
|
background-image: url(/butterfly-orange.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-coin-icon {
|
||||||
|
background-image: url(/coin.svg);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.butterfly-counter-value {
|
.butterfly-counter-value {
|
||||||
min-width: 1ch;
|
min-width: 1ch;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.butterfly-level {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-level-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b5b2e;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ffcb3d, #e0a200);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-pests {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-pest {
|
||||||
|
color: #868e96;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
color: #d9480f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #2f9e44;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-action-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1c7ed6;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1971c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #adb5bd;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background: #c92a2a;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #e03131;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Efekt při plácnutí vosy
|
||||||
|
.butterfly-swat-fx {
|
||||||
|
position: fixed;
|
||||||
|
margin: -14px 0 0 -6px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
animation: butterfly-catch-float 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-combo {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(224, 162, 0, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
animation: butterfly-counter-bump 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-flash {
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(33, 37, 41, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
|
||||||
|
animation: butterfly-flash-in 0.25s ease-out;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-flash-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes butterfly-counter-bump {
|
@keyframes butterfly-counter-bump {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
@@ -173,12 +462,27 @@
|
|||||||
|
|
||||||
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
|
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.butterfly-sprite {
|
.butterfly-sprite,
|
||||||
|
.butterfly-sprite.golden {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.butterfly-net.catch-pop,
|
.butterfly-net.catch-pop,
|
||||||
.butterfly-counter.bump {
|
.butterfly-net.premium,
|
||||||
|
.butterfly-net.stung,
|
||||||
|
.butterfly-net.stunned,
|
||||||
|
.butterfly-counter.bump,
|
||||||
|
.butterfly-combo {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bez animace aspoň staticky ukážeme, že síťka je omráčená
|
||||||
|
.butterfly-net.stunned {
|
||||||
|
filter: grayscale(0.8) brightness(0.82) drop-shadow(0 0 6px rgba(217, 72, 15, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zlatý motýl bez animace aspoň staticky září
|
||||||
|
.butterfly-sprite.golden {
|
||||||
|
filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+515
-183
@@ -1,30 +1,48 @@
|
|||||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats';
|
||||||
|
import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal';
|
||||||
|
|
||||||
// Různé barevné varianty motýlů
|
// Různé barevné varianty motýlů (soubory v public/, referencované root-absolutně)
|
||||||
const BUTTERFLY_VARIANTS = [
|
const BUTTERFLY_VARIANTS = [
|
||||||
'butterfly-orange.svg', // Oranžová (monarcha)
|
'/butterfly-orange.svg', // Oranžová (monarcha)
|
||||||
'butterfly-blue.svg', // Modrá
|
'/butterfly-blue.svg', // Modrá
|
||||||
'butterfly-yellow.svg', // Žlutá (otakárek)
|
'/butterfly-yellow.svg', // Žlutá (otakárek)
|
||||||
'butterfly-pink.svg', // Růžová
|
'/butterfly-pink.svg', // Růžová
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Klíč pro uložení počtu chycených motýlů do local storage
|
// Vzácný zlatý motýl
|
||||||
const CAUGHT_STORAGE_KEY = 'flyingButterfliesCaught';
|
const GOLDEN_VARIANT = '/butterfly-golden.svg';
|
||||||
|
const GOLDEN_CHANCE = 0.025; // ~2,5 % motýlů je zlatých
|
||||||
|
|
||||||
|
// Grafika síťky (root-absolutní cesty kvůli produkčnímu servírování z kořene)
|
||||||
|
const NET_NORMAL = '/butterfly-net.svg';
|
||||||
|
const NET_GOLDEN = '/butterfly-net-golden.svg';
|
||||||
|
const NET_TORN = '/butterfly-net-torn.svg';
|
||||||
|
|
||||||
|
// Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts)
|
||||||
|
const PREMIUM_MILESTONE = 50;
|
||||||
|
const PREMIUM_DURATION_MS = 60_000;
|
||||||
|
export const REPAIR_COST = 10;
|
||||||
|
export const REPELLENT_COST = 25;
|
||||||
|
const GOLD_VALUE = 25;
|
||||||
|
|
||||||
|
/** Naformátuje zbývající čas jako „m:ss" nebo „s s". */
|
||||||
|
function formatRemaining(ms: number): string {
|
||||||
|
const s = Math.ceil(ms / 1000);
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const ss = s % 60;
|
||||||
|
return m > 0 ? `${m}:${String(ss).padStart(2, '0')}` : `${ss} s`;
|
||||||
|
}
|
||||||
|
|
||||||
interface ButterflyData {
|
interface ButterflyData {
|
||||||
/** Vnější element – nese pozici, natočení a velikost */
|
|
||||||
el: HTMLDivElement;
|
el: HTMLDivElement;
|
||||||
/** Vnitřní element – nese CSS animaci mávání křídly */
|
|
||||||
sprite: HTMLDivElement;
|
sprite: HTMLDivElement;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
/** Směr letu vodorovně: +1 doprava, -1 doleva */
|
|
||||||
dir: number;
|
dir: number;
|
||||||
/** Vodorovná rychlost (px/snímek) */
|
|
||||||
speed: number;
|
speed: number;
|
||||||
/** Velikost (měřítko sprite) */
|
|
||||||
size: number;
|
size: number;
|
||||||
// Dvě sinusovky pro přirozené zvlněné stoupání a klesání
|
golden: boolean;
|
||||||
bobFreq1: number;
|
bobFreq1: number;
|
||||||
bobPhase1: number;
|
bobPhase1: number;
|
||||||
bobAmp1: number;
|
bobAmp1: number;
|
||||||
@@ -33,23 +51,45 @@ interface ButterflyData {
|
|||||||
bobAmp2: number;
|
bobAmp2: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Létající havěť (pták nebo vosa). */
|
||||||
|
interface CritterData {
|
||||||
|
el: HTMLDivElement;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
dir: number;
|
||||||
|
speed: number;
|
||||||
|
size: number;
|
||||||
|
bobPhase: number;
|
||||||
|
bobAmp: number;
|
||||||
|
/** Pták: už protrhl síťku v tomto průletu (reset při obletu) */
|
||||||
|
triggered: boolean;
|
||||||
|
/** Uklízeč posluchače kliknutí (jen u vos) */
|
||||||
|
cleanup?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callbacky, kterými scéna komunikuje s React obalem. */
|
||||||
|
interface SceneCallbacks {
|
||||||
|
onCatch: (golden: boolean) => void;
|
||||||
|
getCoins: () => number;
|
||||||
|
onTornChange: (torn: boolean) => void;
|
||||||
|
onCombo: (count: number) => void;
|
||||||
|
onSting: () => void;
|
||||||
|
onSwatWasp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface FlyingButterfliesProps {
|
interface FlyingButterfliesProps {
|
||||||
/** Počet poletujících motýlů (výchozí: 9) */
|
|
||||||
numButterflies?: number;
|
numButterflies?: number;
|
||||||
/** CSS třída pro kontejner (výchozí: 'flying-butterflies') */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Barevné varianty motýlů k použití (výchozí: všechny) */
|
|
||||||
butterflyVariants?: readonly string[];
|
butterflyVariants?: readonly string[];
|
||||||
/** Zapne síťku na chytání motýlů (výchozí: true) */
|
|
||||||
enableNet?: boolean;
|
enableNet?: boolean;
|
||||||
/** Callback při chycení motýla (obdrží nový celkový počet) */
|
|
||||||
onCatch?: (total: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ButterflyScene {
|
class ButterflyScene {
|
||||||
private viewport: HTMLElement;
|
private viewport: HTMLElement;
|
||||||
private world: HTMLDivElement;
|
private world: HTMLDivElement;
|
||||||
private butterflies: ButterflyData[] = [];
|
private butterflies: ButterflyData[] = [];
|
||||||
|
private birds: CritterData[] = [];
|
||||||
|
private wasps: CritterData[] = [];
|
||||||
private numButterflies: number;
|
private numButterflies: number;
|
||||||
private variants: readonly string[];
|
private variants: readonly string[];
|
||||||
private width: number;
|
private width: number;
|
||||||
@@ -58,65 +98,81 @@ class ButterflyScene {
|
|||||||
private animationId: number | null = null;
|
private animationId: number | null = null;
|
||||||
private handleResize: () => void;
|
private handleResize: () => void;
|
||||||
|
|
||||||
// Síťka na chytání
|
|
||||||
private net: HTMLDivElement;
|
private net: HTMLDivElement;
|
||||||
private netEnabled: boolean;
|
private netEnabled: boolean;
|
||||||
private netGrabbed: boolean = false;
|
private netGrabbed: boolean = false;
|
||||||
private netX: number = 0;
|
private netX: number = 0;
|
||||||
private netY: number = 0;
|
private netY: number = 0;
|
||||||
private onCatch?: () => void;
|
private cb: SceneCallbacks;
|
||||||
|
|
||||||
|
private torn: boolean = false;
|
||||||
|
private premiumUntil: number = 0;
|
||||||
|
private stunUntil: number = 0;
|
||||||
|
private lastMagnetAt: number = 0;
|
||||||
|
|
||||||
|
private comboCount: number = 0;
|
||||||
|
private lastCatchTs: number = 0;
|
||||||
|
private comboTimer: number = 0;
|
||||||
|
|
||||||
// Základní velikost sprite v px (dále se násobí náhodným měřítkem)
|
|
||||||
private static readonly BASE_SIZE = 34;
|
private static readonly BASE_SIZE = 34;
|
||||||
|
private static readonly NET_SIZE_NORMAL = 120;
|
||||||
|
private static readonly NET_SIZE_PREMIUM = 168;
|
||||||
|
private static readonly CATCH_RADIUS_NORMAL = 40;
|
||||||
|
private static readonly CATCH_RADIUS_PREMIUM = 62;
|
||||||
|
private static readonly HOOP_FRAC_X = 0.36;
|
||||||
|
private static readonly HOOP_FRAC_Y = 0.35;
|
||||||
|
|
||||||
// Rozměry síťky (px) a poloha obruče v rámci SVG (viewBox 100×100, střed 36×35)
|
private static readonly BIRD_SIZE = 64;
|
||||||
private static readonly NET_SIZE = 120;
|
private static readonly WASP_SIZE = 44;
|
||||||
private static readonly HOOP_OFFSET_X = ButterflyScene.NET_SIZE * 0.36;
|
private static readonly TEAR_RADIUS = 46;
|
||||||
private static readonly HOOP_OFFSET_Y = ButterflyScene.NET_SIZE * 0.35;
|
private static readonly STING_RADIUS = 46;
|
||||||
// Dosah chytání kolem středu obruče
|
private static readonly STUN_FRAMES = 300; // ~5 s omráčení
|
||||||
private static readonly CATCH_RADIUS = 40;
|
private static readonly MAGNET_RADIUS = 150;
|
||||||
|
private static readonly MAGNET_INTERVAL = 42;
|
||||||
|
private static readonly COMBO_WINDOW_MS = 1200;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
el: HTMLElement,
|
el: HTMLElement,
|
||||||
numButterflies: number = 9,
|
numButterflies: number,
|
||||||
variants: readonly string[] = BUTTERFLY_VARIANTS,
|
variants: readonly string[],
|
||||||
netEnabled: boolean = true,
|
netEnabled: boolean,
|
||||||
onCatch?: () => void,
|
cb: SceneCallbacks,
|
||||||
) {
|
) {
|
||||||
this.viewport = el;
|
this.viewport = el;
|
||||||
this.world = document.createElement('div');
|
this.world = document.createElement('div');
|
||||||
this.numButterflies = numButterflies;
|
this.numButterflies = numButterflies;
|
||||||
this.variants = variants;
|
this.variants = variants;
|
||||||
this.netEnabled = netEnabled;
|
this.netEnabled = netEnabled;
|
||||||
this.onCatch = onCatch;
|
this.cb = cb;
|
||||||
this.width = this.viewport.offsetWidth;
|
this.width = this.viewport.offsetWidth;
|
||||||
this.height = this.viewport.offsetHeight;
|
this.height = this.viewport.offsetHeight;
|
||||||
|
|
||||||
this.net = document.createElement('div');
|
this.net = document.createElement('div');
|
||||||
|
|
||||||
this.handleResize = () => {
|
this.handleResize = () => {
|
||||||
this.width = this.viewport.offsetWidth;
|
this.width = this.viewport.offsetWidth;
|
||||||
this.height = this.viewport.offsetHeight;
|
this.height = this.viewport.offsetHeight;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nastaví motýlovi nové náhodné parametry a umístí ho na okraj obrazovky
|
private get premiumActive(): boolean { return Date.now() < this.premiumUntil; }
|
||||||
|
private get netSize(): number { return this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL; }
|
||||||
|
private get catchRadius(): number { return this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; }
|
||||||
|
private get hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; }
|
||||||
|
private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; }
|
||||||
|
|
||||||
|
// --- Motýli --------------------------------------------------------------
|
||||||
|
|
||||||
private resetButterfly = (b: ButterflyData): void => {
|
private resetButterfly = (b: ButterflyData): void => {
|
||||||
// Vletí zleva nebo zprava
|
|
||||||
b.dir = Math.random() > 0.5 ? 1 : -1;
|
b.dir = Math.random() > 0.5 ? 1 : -1;
|
||||||
b.speed = Math.random() * 1.1 + 0.7; // 0.7 – 1.8 px/snímek
|
b.speed = Math.random() * 1.1 + 0.7;
|
||||||
|
|
||||||
// Na začátku (timer 0) rozprostřeme motýly po celé šířce, ať není obrazovka prázdná
|
|
||||||
if (this.timer === 0) {
|
if (this.timer === 0) {
|
||||||
b.x = Math.random() * this.width;
|
b.x = Math.random() * this.width;
|
||||||
} else {
|
} else {
|
||||||
b.x = b.dir === 1 ? -40 : this.width + 40;
|
b.x = b.dir === 1 ? -40 : this.width + 40;
|
||||||
}
|
}
|
||||||
b.y = Math.random() * (this.height - 60) + 30;
|
b.y = Math.random() * (this.height - 60) + 30;
|
||||||
|
b.size = Math.random() * 0.6 + 0.6;
|
||||||
|
|
||||||
b.size = Math.random() * 0.6 + 0.6; // 0.6 – 1.2
|
|
||||||
|
|
||||||
// Dvě sinusovky s různou frekvencí → nepravidelné, měkké křivky letu
|
|
||||||
b.bobFreq1 = Math.random() * 0.02 + 0.03;
|
b.bobFreq1 = Math.random() * 0.02 + 0.03;
|
||||||
b.bobPhase1 = Math.random() * Math.PI * 2;
|
b.bobPhase1 = Math.random() * Math.PI * 2;
|
||||||
b.bobAmp1 = Math.random() * 0.8 + 0.6;
|
b.bobAmp1 = Math.random() * 0.8 + 0.6;
|
||||||
@@ -124,44 +180,56 @@ class ButterflyScene {
|
|||||||
b.bobPhase2 = Math.random() * Math.PI * 2;
|
b.bobPhase2 = Math.random() * Math.PI * 2;
|
||||||
b.bobAmp2 = Math.random() * 0.6 + 0.4;
|
b.bobAmp2 = Math.random() * 0.6 + 0.4;
|
||||||
|
|
||||||
// Náhodná varianta a rychlost mávání křídly
|
b.golden = Math.random() < GOLDEN_CHANCE;
|
||||||
|
if (b.golden) {
|
||||||
|
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
|
||||||
|
b.sprite.classList.add('golden');
|
||||||
|
b.size = Math.random() * 0.5 + 1.0; // zlatý je nápadně větší
|
||||||
|
} else {
|
||||||
const variant = this.variants[Math.floor(Math.random() * this.variants.length)];
|
const variant = this.variants[Math.floor(Math.random() * this.variants.length)];
|
||||||
b.sprite.style.backgroundImage = `url(${variant})`;
|
b.sprite.style.backgroundImage = `url(${variant})`;
|
||||||
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`; // 0.3 – 0.55 s
|
b.sprite.classList.remove('golden');
|
||||||
|
}
|
||||||
|
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateButterfly = (b: ButterflyData): void => {
|
private updateButterfly = (b: ButterflyData): void => {
|
||||||
const vx = b.dir * b.speed;
|
const vx = b.dir * b.speed;
|
||||||
// Svislá rychlost jako součet dvou sinusovek – přirozené plachtění
|
|
||||||
const vy =
|
const vy =
|
||||||
Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 +
|
Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 +
|
||||||
Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2;
|
Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2;
|
||||||
|
|
||||||
b.x += vx;
|
b.x += vx;
|
||||||
b.y += vy;
|
b.y += vy;
|
||||||
|
|
||||||
// Sprite natočíme po směru letu (motýl je nakreslený hlavou nahoru)
|
|
||||||
const angle = Math.atan2(vy, vx) * (180 / Math.PI) + 90;
|
const angle = Math.atan2(vy, vx) * (180 / Math.PI) + 90;
|
||||||
|
|
||||||
b.el.style.transform = `translate(${b.x}px, ${b.y}px) rotate(${angle}deg) scale(${b.size})`;
|
b.el.style.transform = `translate(${b.x}px, ${b.y}px) rotate(${angle}deg) scale(${b.size})`;
|
||||||
|
|
||||||
// Reset po opuštění obrazovky (s rezervou na natočení)
|
|
||||||
if (
|
if (
|
||||||
(b.dir === 1 && b.x > this.width + 50) ||
|
(b.dir === 1 && b.x > this.width + 50) ||
|
||||||
(b.dir === -1 && b.x < -50) ||
|
(b.dir === -1 && b.x < -50) ||
|
||||||
b.y < -60 ||
|
b.y < -60 || b.y > this.height + 60
|
||||||
b.y > this.height + 60
|
|
||||||
) {
|
) {
|
||||||
this.resetButterfly(b);
|
this.resetButterfly(b);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Síťka na chytání ---------------------------------------------------
|
// --- Síťka ---------------------------------------------------------------
|
||||||
|
|
||||||
private updateNetTransform = (): void => {
|
private updateNetTransform = (): void => {
|
||||||
this.net.style.transform =
|
this.net.style.transform =
|
||||||
`translate(${this.netX - ButterflyScene.HOOP_OFFSET_X}px, ` +
|
`translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`;
|
||||||
`${this.netY - ButterflyScene.HOOP_OFFSET_Y}px)`;
|
};
|
||||||
|
|
||||||
|
private applyNetAppearance = (): void => {
|
||||||
|
const size = this.netSize;
|
||||||
|
this.net.style.width = `${size}px`;
|
||||||
|
this.net.style.height = `${size}px`;
|
||||||
|
let image = NET_NORMAL;
|
||||||
|
if (this.torn) image = NET_TORN;
|
||||||
|
else if (this.premiumActive) image = NET_GOLDEN;
|
||||||
|
this.net.style.backgroundImage = `url(${image})`;
|
||||||
|
this.net.classList.toggle('premium', this.premiumActive && !this.torn);
|
||||||
|
this.net.classList.toggle('torn', this.torn);
|
||||||
|
this.updateNetTransform();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onPointerDown = (e: PointerEvent): void => {
|
private onPointerDown = (e: PointerEvent): void => {
|
||||||
@@ -186,70 +254,231 @@ class ButterflyScene {
|
|||||||
this.net.classList.remove('grabbed');
|
this.net.classList.remove('grabbed');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Zobrazí prchavé „+1" v místě chycení
|
private spawnFx = (text: string, cssClass: string, x: number, y: number): void => {
|
||||||
private spawnCatchFx = (): void => {
|
|
||||||
const fx = document.createElement('div');
|
const fx = document.createElement('div');
|
||||||
fx.className = 'butterfly-catch-fx';
|
fx.className = cssClass;
|
||||||
fx.textContent = '+1';
|
fx.textContent = text;
|
||||||
fx.style.left = `${this.netX}px`;
|
fx.style.left = `${x}px`;
|
||||||
fx.style.top = `${this.netY}px`;
|
fx.style.top = `${y}px`;
|
||||||
this.viewport.appendChild(fx);
|
this.viewport.appendChild(fx);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => { if (fx.parentNode) fx.parentNode.removeChild(fx); }, 800);
|
||||||
if (fx.parentNode) fx.parentNode.removeChild(fx);
|
|
||||||
}, 750);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Zkontroluje, zda obruč překrývá nějakého motýla – a chytí ho
|
private popNet = (): void => {
|
||||||
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');
|
this.net.classList.remove('catch-pop');
|
||||||
// vynucení reflow pro restart animace
|
|
||||||
void this.net.offsetWidth;
|
void this.net.offsetWidth;
|
||||||
this.net.classList.add('catch-pop');
|
this.net.classList.add('catch-pop');
|
||||||
|
};
|
||||||
|
|
||||||
|
private registerCatch = (b: ButterflyData, atX: number, atY: number): void => {
|
||||||
|
this.cb.onCatch(b.golden);
|
||||||
|
this.spawnFx(b.golden ? `+${GOLD_VALUE}` : '+1', b.golden ? 'butterfly-catch-fx golden' : 'butterfly-catch-fx', atX, atY);
|
||||||
|
this.resetButterfly(b);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1;
|
||||||
|
this.lastCatchTs = now;
|
||||||
|
if (this.comboCount >= 2) {
|
||||||
|
this.cb.onCombo(this.comboCount);
|
||||||
|
if (this.comboTimer) window.clearTimeout(this.comboTimer);
|
||||||
|
this.comboTimer = window.setTimeout(() => this.cb.onCombo(0), ButterflyScene.COMBO_WINDOW_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private checkCatches = (): void => {
|
||||||
|
if (this.torn || this.timer < this.stunUntil) return;
|
||||||
|
const r2 = this.catchRadius * this.catchRadius;
|
||||||
|
const half = ButterflyScene.BASE_SIZE / 2;
|
||||||
|
let caughtAny = false;
|
||||||
|
for (const b of this.butterflies) {
|
||||||
|
const dx = (b.x + half * b.size) - this.netX;
|
||||||
|
const dy = (b.y + half * b.size) - this.netY;
|
||||||
|
if (dx * dx + dy * dy < r2) {
|
||||||
|
caughtAny = true;
|
||||||
|
this.registerCatch(b, this.netX, this.netY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (caughtAny) this.popNet();
|
||||||
|
};
|
||||||
|
|
||||||
|
private runMagnet = (): void => {
|
||||||
|
if (this.torn || this.timer < this.stunUntil) return;
|
||||||
|
if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return;
|
||||||
|
this.lastMagnetAt = this.timer;
|
||||||
|
const half = ButterflyScene.BASE_SIZE / 2;
|
||||||
|
let nearest: ButterflyData | null = null;
|
||||||
|
let nearestD2 = ButterflyScene.MAGNET_RADIUS * ButterflyScene.MAGNET_RADIUS;
|
||||||
|
for (const b of this.butterflies) {
|
||||||
|
const dx = (b.x + half * b.size) - this.netX;
|
||||||
|
const dy = (b.y + half * b.size) - this.netY;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < nearestD2) { nearestD2 = d2; nearest = b; }
|
||||||
|
}
|
||||||
|
if (nearest) { this.registerCatch(nearest, this.netX, this.netY); this.popNet(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
public activatePremiumNet = (): void => {
|
||||||
|
this.premiumUntil = Date.now() + PREMIUM_DURATION_MS;
|
||||||
|
this.applyNetAppearance();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vnější (zaplacená) oprava protržené síťky. */
|
||||||
|
public repairNetExternally = (): void => {
|
||||||
|
if (!this.torn) return;
|
||||||
|
this.torn = false;
|
||||||
|
this.applyNetAppearance();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nastaví protržení podle serveru (init/poll), bez hlášení zpět. */
|
||||||
|
public setTornFromServer = (torn: boolean): void => {
|
||||||
|
if (this.torn === torn) return;
|
||||||
|
this.torn = torn;
|
||||||
|
this.applyNetAppearance();
|
||||||
|
};
|
||||||
|
|
||||||
|
private tearNet = (atX: number, atY: number): void => {
|
||||||
|
if (this.torn) return;
|
||||||
|
this.torn = true;
|
||||||
|
this.applyNetAppearance();
|
||||||
|
this.spawnFx('Ratata!', 'butterfly-tear-fx', atX, atY);
|
||||||
|
this.cb.onTornChange(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Havěť (ptáci a vosy) -----------------------------------------------
|
||||||
|
|
||||||
|
private critterHitsHoop = (c: CritterData, radius: number): boolean => {
|
||||||
|
const dx = (c.x + c.size / 2) - this.netX;
|
||||||
|
const dy = (c.y + c.size / 2) - this.netY;
|
||||||
|
return dx * dx + dy * dy < radius * radius;
|
||||||
|
};
|
||||||
|
|
||||||
|
private spawnCritter = (type: 'wasp' | 'bird'): CritterData => {
|
||||||
|
const size = type === 'bird' ? ButterflyScene.BIRD_SIZE : ButterflyScene.WASP_SIZE;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = type === 'bird' ? 'butterfly-critter bird' : 'butterfly-critter wasp';
|
||||||
|
el.style.width = `${size}px`;
|
||||||
|
el.style.height = `${size}px`;
|
||||||
|
el.style.backgroundImage = `url(${type === 'bird' ? '/bird.svg' : '/bee.svg'})`;
|
||||||
|
|
||||||
|
const dir = Math.random() > 0.5 ? 1 : -1;
|
||||||
|
const c: CritterData = {
|
||||||
|
el,
|
||||||
|
x: dir === 1 ? -size : this.width + size,
|
||||||
|
y: Math.random() * (this.height * 0.7) + this.height * 0.1,
|
||||||
|
dir,
|
||||||
|
speed: type === 'bird' ? Math.random() * 2 + 3 : Math.random() * 1.4 + 2,
|
||||||
|
size,
|
||||||
|
bobPhase: Math.random() * Math.PI * 2,
|
||||||
|
bobAmp: Math.random() * 0.8 + 0.5,
|
||||||
|
triggered: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'wasp') {
|
||||||
|
el.title = 'Plácni vosu!';
|
||||||
|
const swat = (ev: Event) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.spawnFx('💥', 'butterfly-swat-fx', c.x + c.size / 2, c.y + c.size / 2);
|
||||||
|
const idx = this.wasps.indexOf(c);
|
||||||
|
if (idx >= 0) this.wasps.splice(idx, 1);
|
||||||
|
if (c.el.parentNode) c.el.parentNode.removeChild(c.el);
|
||||||
|
this.cb.onSwatWasp();
|
||||||
|
};
|
||||||
|
el.addEventListener('pointerdown', swat);
|
||||||
|
c.cleanup = () => el.removeEventListener('pointerdown', swat);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.world.appendChild(el);
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
private wrapCritter = (c: CritterData): void => {
|
||||||
|
c.dir = Math.random() > 0.5 ? 1 : -1;
|
||||||
|
c.x = c.dir === 1 ? -c.size : this.width + c.size;
|
||||||
|
c.y = Math.random() * (this.height * 0.7) + this.height * 0.1;
|
||||||
|
c.triggered = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateWasp = (c: CritterData): void => {
|
||||||
|
c.x += c.dir * c.speed;
|
||||||
|
// mírné míření k obruči, aby vosy otravovaly zrovna síťku
|
||||||
|
const targetY = this.netY - c.size / 2;
|
||||||
|
c.y += (targetY - c.y) * 0.015 + Math.sin(this.timer * 0.06 + c.bobPhase) * c.bobAmp;
|
||||||
|
const flip = c.dir === -1 ? -1 : 1;
|
||||||
|
c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`;
|
||||||
|
if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) {
|
||||||
|
this.wrapCritter(c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateBird = (c: CritterData): void => {
|
||||||
|
c.x += c.dir * c.speed;
|
||||||
|
c.y += Math.sin(this.timer * 0.03 + c.bobPhase) * c.bobAmp;
|
||||||
|
const flip = c.dir === -1 ? -1 : 1;
|
||||||
|
c.el.style.transform = `translate(${c.x}px, ${c.y}px) scaleX(${flip})`;
|
||||||
|
if ((c.dir === 1 && c.x > this.width + c.size) || (c.dir === -1 && c.x < -c.size * 2)) {
|
||||||
|
this.wrapCritter(c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private removeCritter = (arr: CritterData[]): void => {
|
||||||
|
const c = arr.pop();
|
||||||
|
if (c) {
|
||||||
|
c.cleanup?.();
|
||||||
|
if (c.el.parentNode) c.el.parentNode.removeChild(c.el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sladí počet vos a ptáků ve scéně s hodnotami ze serveru. */
|
||||||
|
public syncPests = (wasps: number, birds: number): void => {
|
||||||
|
if (!this.netEnabled) return;
|
||||||
|
while (this.wasps.length < wasps) this.wasps.push(this.spawnCritter('wasp'));
|
||||||
|
while (this.wasps.length > wasps) this.removeCritter(this.wasps);
|
||||||
|
while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird'));
|
||||||
|
while (this.birds.length > birds) this.removeCritter(this.birds);
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateWasps = (): void => {
|
||||||
|
for (const wasp of this.wasps) {
|
||||||
|
this.updateWasp(wasp);
|
||||||
|
// Vosa žihne síťku, jakmile pomine předchozí omráčení
|
||||||
|
if (!this.torn && this.timer >= this.stunUntil && this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) {
|
||||||
|
this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES;
|
||||||
|
this.net.classList.remove('stung');
|
||||||
|
void this.net.offsetWidth;
|
||||||
|
this.net.classList.add('stung');
|
||||||
|
this.spawnFx('Au!', 'butterfly-sting-fx', this.netX, this.netY);
|
||||||
|
this.cb.onSting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateBirds = (): void => {
|
||||||
|
for (const bird of this.birds) {
|
||||||
|
this.updateBird(bird);
|
||||||
|
// Prémiová síťka je z pevnějšího materiálu; protrhne se jen když má hráč na opravu
|
||||||
|
if (!bird.triggered && this.netGrabbed && !this.torn && !this.premiumActive
|
||||||
|
&& this.cb.getCoins() >= REPAIR_COST
|
||||||
|
&& this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) {
|
||||||
|
bird.triggered = true;
|
||||||
|
this.tearNet(this.netX, this.netY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private initNet = (): void => {
|
private initNet = (): void => {
|
||||||
this.net.className = 'butterfly-net';
|
this.net.className = 'butterfly-net';
|
||||||
this.net.style.width = `${ButterflyScene.NET_SIZE}px`;
|
this.applyNetAppearance();
|
||||||
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.netX = this.width * 0.82;
|
||||||
this.netY = this.height * 0.72;
|
this.netY = this.height * 0.72;
|
||||||
this.updateNetTransform();
|
this.updateNetTransform();
|
||||||
|
|
||||||
this.net.addEventListener('pointerdown', this.onPointerDown);
|
this.net.addEventListener('pointerdown', this.onPointerDown);
|
||||||
window.addEventListener('pointermove', this.onPointerMove);
|
window.addEventListener('pointermove', this.onPointerMove);
|
||||||
window.addEventListener('pointerup', this.onPointerUp);
|
window.addEventListener('pointerup', this.onPointerUp);
|
||||||
window.addEventListener('pointercancel', this.onPointerUp);
|
window.addEventListener('pointercancel', this.onPointerUp);
|
||||||
|
|
||||||
this.viewport.appendChild(this.net);
|
this.viewport.appendChild(this.net);
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
public init = (): void => {
|
public init = (): void => {
|
||||||
this.butterflies = [];
|
this.butterflies = [];
|
||||||
this.world.innerHTML = '';
|
this.world.innerHTML = '';
|
||||||
@@ -260,44 +489,42 @@ class ButterflyScene {
|
|||||||
const sprite = document.createElement('div');
|
const sprite = document.createElement('div');
|
||||||
sprite.className = 'butterfly-sprite';
|
sprite.className = 'butterfly-sprite';
|
||||||
el.appendChild(sprite);
|
el.appendChild(sprite);
|
||||||
|
|
||||||
const b: ButterflyData = {
|
const b: ButterflyData = {
|
||||||
el,
|
el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false,
|
||||||
sprite,
|
bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0,
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
dir: 1,
|
|
||||||
speed: 1,
|
|
||||||
size: 1,
|
|
||||||
bobFreq1: 0,
|
|
||||||
bobPhase1: 0,
|
|
||||||
bobAmp1: 0,
|
|
||||||
bobFreq2: 0,
|
|
||||||
bobPhase2: 0,
|
|
||||||
bobAmp2: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.resetButterfly(b);
|
this.resetButterfly(b);
|
||||||
this.butterflies.push(b);
|
this.butterflies.push(b);
|
||||||
this.world.appendChild(el);
|
this.world.appendChild(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.viewport.appendChild(this.world);
|
this.viewport.appendChild(this.world);
|
||||||
|
if (this.netEnabled) this.initNet();
|
||||||
if (this.netEnabled) {
|
|
||||||
this.initNet();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', this.handleResize);
|
window.addEventListener('resize', this.handleResize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private wasPremium: boolean = false;
|
||||||
|
private wasStunned: boolean = false;
|
||||||
|
|
||||||
public render = (): void => {
|
public render = (): void => {
|
||||||
for (let i = 0; i < this.butterflies.length; i++) {
|
for (const b of this.butterflies) this.updateButterfly(b);
|
||||||
this.updateButterfly(this.butterflies[i]);
|
|
||||||
|
if (this.netEnabled) {
|
||||||
|
const nowPremium = this.premiumActive;
|
||||||
|
if (this.wasPremium && !nowPremium) this.applyNetAppearance();
|
||||||
|
this.wasPremium = nowPremium;
|
||||||
|
|
||||||
|
this.updateWasps();
|
||||||
|
this.updateBirds();
|
||||||
|
|
||||||
|
const stunned = this.timer < this.stunUntil;
|
||||||
|
if (stunned !== this.wasStunned) {
|
||||||
|
this.net.classList.toggle('stunned', stunned);
|
||||||
|
this.wasStunned = stunned;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.netEnabled && this.netGrabbed) {
|
if (this.netGrabbed) this.checkCatches();
|
||||||
this.checkCatches();
|
if (nowPremium) this.runMagnet();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.timer++;
|
this.timer++;
|
||||||
@@ -305,99 +532,112 @@ class ButterflyScene {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public destroy = (): void => {
|
public destroy = (): void => {
|
||||||
if (this.animationId) {
|
if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; }
|
||||||
cancelAnimationFrame(this.animationId);
|
if (this.comboTimer) window.clearTimeout(this.comboTimer);
|
||||||
this.animationId = null;
|
for (const w of this.wasps) w.cleanup?.();
|
||||||
}
|
|
||||||
|
|
||||||
if (this.world && this.world.parentNode) {
|
|
||||||
this.world.parentNode.removeChild(this.world);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world);
|
||||||
this.net.removeEventListener('pointerdown', this.onPointerDown);
|
this.net.removeEventListener('pointerdown', this.onPointerDown);
|
||||||
window.removeEventListener('pointermove', this.onPointerMove);
|
window.removeEventListener('pointermove', this.onPointerMove);
|
||||||
window.removeEventListener('pointerup', this.onPointerUp);
|
window.removeEventListener('pointerup', this.onPointerUp);
|
||||||
window.removeEventListener('pointercancel', this.onPointerUp);
|
window.removeEventListener('pointercancel', this.onPointerUp);
|
||||||
if (this.net.parentNode) {
|
if (this.net.parentNode) this.net.parentNode.removeChild(this.net);
|
||||||
this.net.parentNode.removeChild(this.net);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.removeEventListener('resize', this.handleResize);
|
window.removeEventListener('resize', this.handleResize);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Komponenta pro zobrazení poletujících motýlů na pozadí stránky.
|
* Komponenta minihry chytání motýlků: mince, úrovně, prémiová zlatá síťka za
|
||||||
* Motýli plachtí vodorovně po měkkých zvlněných křivkách a mávají křídly.
|
* milník, magnet, vzácní zlatí motýli, kombo, denní bonus a perzistentní škůdci
|
||||||
* Volitelná síťka umožňuje motýly chytat – po chycení přiletí nový a počet
|
* (vosy na zaklikání, ptáci trhající síťku, placený plašič). Stav se ukládá na
|
||||||
* chycených se ukládá do local storage.
|
* server přes {@link useButterflyStats}.
|
||||||
*
|
|
||||||
* @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> = ({
|
const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
||||||
numButterflies = 9,
|
numButterflies = 9,
|
||||||
className = 'flying-butterflies',
|
className = 'flying-butterflies',
|
||||||
butterflyVariants = BUTTERFLY_VARIANTS,
|
butterflyVariants = BUTTERFLY_VARIANTS,
|
||||||
enableNet = true,
|
enableNet = true,
|
||||||
onCatch,
|
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const sceneRef = useRef<ButterflyScene | null>(null);
|
const sceneRef = useRef<ButterflyScene | null>(null);
|
||||||
const badgeRef = useRef<HTMLDivElement>(null);
|
const badgeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [caught, setCaught] = useState<number>(() => {
|
const [torn, setTorn] = useState(false);
|
||||||
try {
|
const [combo, setCombo] = useState(0);
|
||||||
const stored = Number(localStorage.getItem(CAUGHT_STORAGE_KEY));
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
return Number.isFinite(stored) && stored > 0 ? stored : 0;
|
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
|
||||||
} catch {
|
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||||
return 0;
|
const flashTimer = useRef<number | null>(null);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stabilní callback – scéna se kvůli změně počtu nemusí přegenerovat
|
const showFlash = useCallback((msg: string) => {
|
||||||
const handleCatch = useCallback(() => {
|
setFlash(msg);
|
||||||
setCaught((prev) => {
|
if (flashTimer.current) window.clearTimeout(flashTimer.current);
|
||||||
const next = prev + 1;
|
flashTimer.current = window.setTimeout(() => setFlash(null), 6000);
|
||||||
try {
|
}, []);
|
||||||
localStorage.setItem(CAUGHT_STORAGE_KEY, String(next));
|
|
||||||
} catch {
|
const handleReward = useCallback((e: RewardEvent) => {
|
||||||
/* local storage nedostupné – počet se prostě neuloží */
|
if (e.premiumUnlocked) {
|
||||||
|
sceneRef.current?.activatePremiumNet();
|
||||||
|
showFlash('🪄 Zlatá síťka aktivní! Větší, magnetická a odolná proti ptákům.');
|
||||||
|
} else if (e.leveledUp) {
|
||||||
|
showFlash(`⭐ Nová úroveň: ${e.newTitle}!`);
|
||||||
|
} else if (e.dailyBonusApplied) {
|
||||||
|
showFlash('☀️ Denní bonus +10 mincí!');
|
||||||
}
|
}
|
||||||
if (onCatch) onCatch(next);
|
}, [showFlash]);
|
||||||
return next;
|
|
||||||
|
const { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear } = useButterflyStats(handleReward);
|
||||||
|
|
||||||
|
const callbacksRef = useRef<SceneCallbacks>({
|
||||||
|
onCatch: () => { }, getCoins: () => 0, onTornChange: () => { },
|
||||||
|
onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { },
|
||||||
});
|
});
|
||||||
}, [onCatch]);
|
callbacksRef.current = {
|
||||||
|
onCatch: (golden) => reportCatch(golden),
|
||||||
|
getCoins: () => coinsRef.current,
|
||||||
|
onTornChange: (t) => { setTorn(t); if (t) void reportTear(); },
|
||||||
|
onCombo: (c) => setCombo(c),
|
||||||
|
onSting: () => showFlash('🐝 Au! Vosa tě žihla – síťka teď 5 s nechytá. Zaklikej vosy!'),
|
||||||
|
onSwatWasp: () => void killWasp(),
|
||||||
|
};
|
||||||
|
|
||||||
const initialize = useCallback(() => {
|
const initialize = useCallback(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
sceneRef.current = new ButterflyScene(
|
sceneRef.current = new ButterflyScene(
|
||||||
containerRef.current,
|
containerRef.current, numButterflies, butterflyVariants, enableNet,
|
||||||
numButterflies,
|
{
|
||||||
butterflyVariants,
|
onCatch: (g) => callbacksRef.current.onCatch(g),
|
||||||
enableNet,
|
getCoins: () => callbacksRef.current.getCoins(),
|
||||||
handleCatch,
|
onTornChange: (t) => callbacksRef.current.onTornChange(t),
|
||||||
|
onCombo: (c) => callbacksRef.current.onCombo(c),
|
||||||
|
onSting: () => callbacksRef.current.onSting(),
|
||||||
|
onSwatWasp: () => callbacksRef.current.onSwatWasp(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
sceneRef.current.init();
|
sceneRef.current.init();
|
||||||
sceneRef.current.render();
|
sceneRef.current.render();
|
||||||
}
|
}
|
||||||
}, [numButterflies, butterflyVariants, enableNet, handleCatch]);
|
}, [numButterflies, butterflyVariants, enableNet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialize();
|
initialize();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (sceneRef.current) {
|
if (sceneRef.current) { sceneRef.current.destroy(); sceneRef.current = null; }
|
||||||
sceneRef.current.destroy();
|
if (flashTimer.current) window.clearTimeout(flashTimer.current);
|
||||||
sceneRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [initialize]);
|
}, [initialize]);
|
||||||
|
|
||||||
// Krátké „poskočení" počítadla při každém chycení
|
// Synchronizace škůdců a protržení ze serveru do scény
|
||||||
|
const wasps = stats?.wasps ?? 0;
|
||||||
|
const birds = stats?.birds ?? 0;
|
||||||
|
const netTorn = stats?.netTorn ?? false;
|
||||||
|
useEffect(() => { sceneRef.current?.syncPests(wasps, birds); }, [wasps, birds]);
|
||||||
|
useEffect(() => {
|
||||||
|
sceneRef.current?.setTornFromServer(netTorn);
|
||||||
|
setTorn(netTorn);
|
||||||
|
}, [netTorn]);
|
||||||
|
|
||||||
|
const caught = stats?.caught ?? 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = badgeRef.current;
|
const node = badgeRef.current;
|
||||||
if (!node || caught === 0) return;
|
if (!node || caught === 0) return;
|
||||||
@@ -406,31 +646,123 @@ const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
|||||||
node.classList.add('bump');
|
node.classList.add('bump');
|
||||||
}, [caught]);
|
}, [caught]);
|
||||||
|
|
||||||
|
const coins = stats?.coins ?? 0;
|
||||||
|
const canAffordRepair = coins >= REPAIR_COST;
|
||||||
|
const canAffordRepellent = coins >= REPELLENT_COST;
|
||||||
|
const progress = Math.round(((caught % PREMIUM_MILESTONE) / PREMIUM_MILESTONE) * 100);
|
||||||
|
const repellentUntil = stats?.repellentUntil ?? 0;
|
||||||
|
const repellentRemainingMs = Math.max(0, repellentUntil - nowTs);
|
||||||
|
const repellentActive = repellentRemainingMs > 0;
|
||||||
|
|
||||||
|
// Tik po sekundách pro odpočet plašiče (jen dokud běží)
|
||||||
|
useEffect(() => {
|
||||||
|
if (repellentUntil <= Date.now()) return;
|
||||||
|
const id = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [repellentUntil]);
|
||||||
|
|
||||||
|
const onRepairClick = useCallback(async () => {
|
||||||
|
const ok = await repair();
|
||||||
|
if (ok) sceneRef.current?.repairNetExternally();
|
||||||
|
}, [repair]);
|
||||||
|
|
||||||
|
const onRepellentClick = useCallback(async () => {
|
||||||
|
const ok = await buyRepellent();
|
||||||
|
if (ok) showFlash('🦅🚫 Plašič ptáků koupen – ptáci na chvíli zmizeli.');
|
||||||
|
}, [buyRepellent, showFlash]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={containerRef} className={className} />
|
<div ref={containerRef} className={className} />
|
||||||
{enableNet && (
|
{enableNet && (
|
||||||
<div ref={badgeRef} className="butterfly-counter" title="Chycení motýli">
|
<div className="butterfly-hud">
|
||||||
|
{flash && (
|
||||||
|
<div className="butterfly-flash" onClick={() => setFlash(null)} title="Klikni pro zavření">
|
||||||
|
{flash}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{combo > 1 && <div className="butterfly-combo">Kombo ×{combo}!</div>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={badgeRef}
|
||||||
|
className="butterfly-counter"
|
||||||
|
title="Zobrazit žebříček a statistiky"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setLeaderboardOpen(true)}
|
||||||
|
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') setLeaderboardOpen(true); }}
|
||||||
|
>
|
||||||
|
<div className="butterfly-counter-row">
|
||||||
<span className="butterfly-counter-icon" aria-hidden="true" />
|
<span className="butterfly-counter-icon" aria-hidden="true" />
|
||||||
<span className="butterfly-counter-value">{caught}</span>
|
<span className="butterfly-counter-value">{caught}</span>
|
||||||
|
<span className="butterfly-coin-icon" aria-hidden="true" />
|
||||||
|
<span className="butterfly-counter-value">{coins}</span>
|
||||||
|
</div>
|
||||||
|
<div className="butterfly-level">
|
||||||
|
<span className="butterfly-level-title">
|
||||||
|
{stats ? `${stats.level}. ${stats.title}` : '…'}
|
||||||
|
</span>
|
||||||
|
<div className="butterfly-progress" title="Postup k další zlaté síťce">
|
||||||
|
<div className="butterfly-progress-bar" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="butterfly-pests" title="Škůdci u síťky">
|
||||||
|
<span className={wasps > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🐝 {wasps}</span>
|
||||||
|
<span className={birds > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds}</span>
|
||||||
|
{repellentActive && (
|
||||||
|
<span className="butterfly-pest ok" title="Zbývající doba plašiče">
|
||||||
|
🚫 plašič {formatRemaining(repellentRemainingMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{torn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="butterfly-action-btn danger"
|
||||||
|
onClick={(ev) => { ev.stopPropagation(); void onRepairClick(); }}
|
||||||
|
disabled={!canAffordRepair}
|
||||||
|
title={canAffordRepair ? 'Zašít protrženou síťku' : 'Nemáš dost mincí – nachytej další motýly'}
|
||||||
|
>
|
||||||
|
🪡 Zašít síťku za {REPAIR_COST} 🪙
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{birds > 0 && !repellentActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="butterfly-action-btn"
|
||||||
|
onClick={(ev) => { ev.stopPropagation(); void onRepellentClick(); }}
|
||||||
|
disabled={!canAffordRepellent}
|
||||||
|
title={canAffordRepellent ? 'Vyžene všechny ptáky a chvíli brání novým' : 'Nemáš dost mincí na plašič'}
|
||||||
|
>
|
||||||
|
🦅🚫 Plašič za {REPELLENT_COST} 🪙
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButterflyLeaderboardModal
|
||||||
|
isOpen={leaderboardOpen}
|
||||||
|
onClose={() => setLeaderboardOpen(false)}
|
||||||
|
myStats={stats}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Přednastavení množství motýlů pro různé účely
|
|
||||||
export const BUTTERFLY_PRESETS = {
|
export const BUTTERFLY_PRESETS = {
|
||||||
LIGHT: 5, // Pár motýlů
|
LIGHT: 5,
|
||||||
NORMAL: 9, // Standardní množství
|
NORMAL: 9,
|
||||||
HEAVY: 16, // Rušná letní louka
|
HEAVY: 16,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Přednastavené barevné kombinace
|
|
||||||
export const BUTTERFLY_COLOR_THEMES = {
|
export const BUTTERFLY_COLOR_THEMES = {
|
||||||
ALL: BUTTERFLY_VARIANTS,
|
ALL: BUTTERFLY_VARIANTS,
|
||||||
WARM: ['butterfly-orange.svg', 'butterfly-yellow.svg', 'butterfly-pink.svg'] as const,
|
WARM: ['/butterfly-orange.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg'] as const,
|
||||||
COOL: ['butterfly-blue.svg'] as const,
|
COOL: ['/butterfly-blue.svg'] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default FlyingButterflies;
|
export default FlyingButterflies;
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
.butterfly-mystats {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 203, 61, 0.16), rgba(224, 162, 0, 0.08));
|
||||||
|
border: 1px solid rgba(224, 162, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-mystats-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6b5b2e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-mystats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
|
||||||
|
&.gold {
|
||||||
|
color: #e0a200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.l {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-heading {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
|
&.me {
|
||||||
|
background: rgba(255, 203, 61, 0.22);
|
||||||
|
border: 1px solid rgba(224, 162, 0, 0.5);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-rank {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b5b2e;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-stats {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-golden {
|
||||||
|
color: #e0a200;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal } from "react-bootstrap";
|
||||||
|
import { ButterflyLeaderboardEntry, ButterflyStats, getButterflyLeaderboard } from "../../../../types";
|
||||||
|
import { useAuth } from "../../context/auth";
|
||||||
|
import "./ButterflyLeaderboardModal.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
myStats?: ButterflyStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
/** Modální dialog s osobními statistikami a týmovým žebříčkem chytačů motýlů. */
|
||||||
|
export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
getButterflyLeaderboard({ query: { limit: 20 } })
|
||||||
|
.then(res => { if (!cancelled) setEntries(res.data ?? []); })
|
||||||
|
.catch(() => { if (!cancelled) setEntries([]); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>🦋 Chytání motýlků</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{myStats && (
|
||||||
|
<div className="butterfly-mystats">
|
||||||
|
<div className="butterfly-mystats-title">
|
||||||
|
{myStats.level}. {myStats.title}
|
||||||
|
</div>
|
||||||
|
<div className="butterfly-mystats-grid">
|
||||||
|
<div><span className="v">{myStats.caught}</span><span className="l">🦋 chyceno</span></div>
|
||||||
|
<div><span className="v gold">{myStats.goldenCaught}</span><span className="l">✨ zlatých</span></div>
|
||||||
|
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
|
||||||
|
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
|
||||||
|
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h6 className="butterfly-leaderboard-heading">Žebříček týmu</h6>
|
||||||
|
{loading && <p className="text-center text-muted mb-0">Načítám žebříček…</p>}
|
||||||
|
{!loading && entries && entries.length === 0 && (
|
||||||
|
<p className="text-center text-muted mb-0">Zatím nikdo nic nechytil. Buď první!</p>
|
||||||
|
)}
|
||||||
|
{!loading && entries && entries.length > 0 && (
|
||||||
|
<ol className="butterfly-leaderboard">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<li
|
||||||
|
key={e.login}
|
||||||
|
className={e.login === auth?.login ? 'butterfly-leaderboard-row me' : 'butterfly-leaderboard-row'}
|
||||||
|
>
|
||||||
|
<span className="butterfly-leaderboard-rank">{MEDALS[i] ?? `${i + 1}.`}</span>
|
||||||
|
<span className="butterfly-leaderboard-name" title={e.title}>
|
||||||
|
{e.login}
|
||||||
|
<span className="butterfly-leaderboard-title">{e.level}. {e.title}</span>
|
||||||
|
</span>
|
||||||
|
<span className="butterfly-leaderboard-stats">
|
||||||
|
<span className="butterfly-leaderboard-caught">{e.caught} 🦋</span>
|
||||||
|
{e.goldenCaught > 0 && (
|
||||||
|
<span className="butterfly-leaderboard-golden">{e.goldenCaught} ✨</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ButterflyStats,
|
||||||
|
getButterflyStats,
|
||||||
|
catchButterflies,
|
||||||
|
repairNet as repairNetApi,
|
||||||
|
killWasp as killWaspApi,
|
||||||
|
buyRepellent as buyRepellentApi,
|
||||||
|
reportNetTorn as reportTearApi,
|
||||||
|
} from '../../../types';
|
||||||
|
|
||||||
|
/** Starý klíč, pod kterým se počet chycených ukládal jen do localStorage. */
|
||||||
|
const LEGACY_KEY = 'flyingButterfliesCaught';
|
||||||
|
/** Příznak, že už proběhla jednorázová migrace localStorage → server. */
|
||||||
|
const MIGRATED_KEY = 'flyingButterfliesMigrated';
|
||||||
|
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
|
||||||
|
const FLUSH_DEBOUNCE_MS = 1500;
|
||||||
|
/** Server přijme max 100 motýlů na dávku – migraci proto posíláme po částech. */
|
||||||
|
const MIGRATION_CHUNK = 100;
|
||||||
|
/** Bezpečnostní strop migrovaných úlovků (proti nesmyslným hodnotám v localStorage). */
|
||||||
|
const MIGRATION_MAX = 1000;
|
||||||
|
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
|
||||||
|
const POLL_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
/** Událost odměny hlášená scéně po úspěšném odeslání dávky. */
|
||||||
|
export interface RewardEvent {
|
||||||
|
coinsAwarded: number;
|
||||||
|
dailyBonusApplied: boolean;
|
||||||
|
leveledUp: boolean;
|
||||||
|
premiumUnlocked: boolean;
|
||||||
|
newLevel: number;
|
||||||
|
newTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu,
|
||||||
|
* jednorázovou migraci z localStorage, dávkové odesílání úlovků, hubení vos,
|
||||||
|
* plašič ptáků, protržení/opravu síťky a periodické přenačítání (růst škůdců).
|
||||||
|
*/
|
||||||
|
export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
||||||
|
const [stats, setStats] = useState<ButterflyStats | undefined>();
|
||||||
|
/** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
|
||||||
|
const coinsRef = useRef(0);
|
||||||
|
const pending = useRef({ normal: 0, golden: 0 });
|
||||||
|
const flushTimer = useRef<number | null>(null);
|
||||||
|
const onRewardRef = useRef(onReward);
|
||||||
|
onRewardRef.current = onReward;
|
||||||
|
|
||||||
|
/** Zapíše statistiky ze serveru, ale nedovolí „couvnout" počtu chycených
|
||||||
|
* (kvůli optimistickému počítadlu, které může být napřed před flushem). */
|
||||||
|
const applyStats = useCallback((incoming: ButterflyStats) => {
|
||||||
|
setStats(prev => {
|
||||||
|
const merged: ButterflyStats = { ...incoming };
|
||||||
|
if (prev && prev.caught > incoming.caught) {
|
||||||
|
merged.caught = prev.caught;
|
||||||
|
merged.goldenCaught = Math.max(prev.goldenCaught, incoming.goldenCaught);
|
||||||
|
}
|
||||||
|
coinsRef.current = merged.coins;
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flush = useCallback(async () => {
|
||||||
|
if (flushTimer.current) {
|
||||||
|
clearTimeout(flushTimer.current);
|
||||||
|
flushTimer.current = null;
|
||||||
|
}
|
||||||
|
const batch = pending.current;
|
||||||
|
if (batch.normal === 0 && batch.golden === 0) return;
|
||||||
|
pending.current = { normal: 0, golden: 0 };
|
||||||
|
try {
|
||||||
|
const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
|
||||||
|
if (res.data) {
|
||||||
|
applyStats(res.data.stats);
|
||||||
|
onRewardRef.current?.({
|
||||||
|
coinsAwarded: res.data.coinsAwarded,
|
||||||
|
dailyBonusApplied: res.data.dailyBonusApplied,
|
||||||
|
leveledUp: res.data.leveledUp,
|
||||||
|
premiumUnlocked: res.data.premiumUnlocked,
|
||||||
|
newLevel: res.data.stats.level,
|
||||||
|
newTitle: res.data.stats.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pending.current.normal += batch.normal;
|
||||||
|
pending.current.golden += batch.golden;
|
||||||
|
}
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Nahlásí chycení jednoho motýla (optimisticky + naplánuje odeslání dávky). */
|
||||||
|
const reportCatch = useCallback((golden: boolean) => {
|
||||||
|
if (golden) pending.current.golden += 1;
|
||||||
|
else pending.current.normal += 1;
|
||||||
|
setStats(prev => prev
|
||||||
|
? { ...prev, caught: prev.caught + 1, goldenCaught: prev.goldenCaught + (golden ? 1 : 0) }
|
||||||
|
: prev);
|
||||||
|
if (!flushTimer.current) {
|
||||||
|
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
}, [flush]);
|
||||||
|
|
||||||
|
/** Zaplatí opravu síťky. Vrátí true při úspěchu, false při nedostatku mincí/chybě. */
|
||||||
|
const repair = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await repairNetApi();
|
||||||
|
if (res.data) { applyStats(res.data); return true; }
|
||||||
|
return false;
|
||||||
|
} catch { return false; }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Zahubí jednu vosu (plácačka). */
|
||||||
|
const killWasp = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await killWaspApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Koupí plašič ptáků. Vrátí true při úspěchu, false při nedostatku mincí. */
|
||||||
|
const buyRepellent = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await buyRepellentApi();
|
||||||
|
if (res.data) { applyStats(res.data); return true; }
|
||||||
|
return false;
|
||||||
|
} catch { return false; }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Nahlásí serveru, že pták protrhl síťku (perzistentně). */
|
||||||
|
const reportTear = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await reportTearApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getButterflyStats();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
// Načtení stavu + jednorázová migrace z localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getButterflyStats();
|
||||||
|
if (cancelled || !res.data) return;
|
||||||
|
let current = res.data;
|
||||||
|
try {
|
||||||
|
if (!localStorage.getItem(MIGRATED_KEY)) {
|
||||||
|
const legacy = Number(localStorage.getItem(LEGACY_KEY));
|
||||||
|
let diff = Number.isFinite(legacy) ? Math.min(legacy - current.caught, MIGRATION_MAX) : 0;
|
||||||
|
while (diff > 0 && !cancelled) {
|
||||||
|
const chunk = Math.min(diff, MIGRATION_CHUNK);
|
||||||
|
const migRes = await catchButterflies({ body: { normal: chunk, golden: 0 } });
|
||||||
|
if (migRes.data) current = migRes.data.stats;
|
||||||
|
diff -= chunk;
|
||||||
|
}
|
||||||
|
localStorage.setItem(MIGRATED_KEY, '1');
|
||||||
|
localStorage.removeItem(LEGACY_KEY);
|
||||||
|
}
|
||||||
|
} catch { /* localStorage nedostupné – migraci přeskočíme */ }
|
||||||
|
if (!cancelled) applyStats(current);
|
||||||
|
} catch { /* server nedostupný – hra jede dál bez perzistence */ }
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
// Periodické přenačítání (růst škůdců) + při návratu na záložku
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
|
||||||
|
const onVisible = () => { if (document.visibilityState === 'visible') void refresh(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Odeslání rozdělané dávky při skrytí/opuštění stránky a při odmontování
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisibility = () => { if (document.visibilityState === 'hidden') void flush(); };
|
||||||
|
const onPageHide = () => { void flush(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
window.addEventListener('pagehide', onPageHide);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
|
void flush();
|
||||||
|
};
|
||||||
|
}, [flush]);
|
||||||
|
|
||||||
|
return { stats, coinsRef, reportCatch, repair, killWasp, buyRepellent, reportTear };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
"Chytání motýlků má teď mince, úrovně a ukládá se na server, takže o úlovky nepřijdeš ani po přihlášení na jiném zařízení",
|
||||||
|
"Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům",
|
||||||
|
"Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu",
|
||||||
|
"Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat",
|
||||||
|
"Ptáci přibývají a trhají síťku (oprava za mince); můžeš si koupit plašič, který je na chvíli vyžene",
|
||||||
|
"Vosy i ptáci se počítají na serveru, takže je nevynuluješ obnovením stránky",
|
||||||
|
"Denní bonus, kombo za rychlé chytání, týmový žebříček a osobní statistiky (zlatí motýli, zabité vosy, vyplašení ptáci) po kliknutí na počítadlo",
|
||||||
|
"Oprava: v nasazené verzi se v počítadle vlevo dole opět zobrazuje ikonka motýlka"
|
||||||
|
]
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { ButterflyStats, ButterflyCatchResult, ButterflyLeaderboardEntry } from "../../types/gen/types.gen";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { formatDate } from "./utils";
|
||||||
|
|
||||||
|
/** Interní reprezentace statistik jednoho uživatele uložená ve storage. */
|
||||||
|
interface StoredButterflyStats {
|
||||||
|
/** Celkový počet chycených motýlů */
|
||||||
|
caught: number;
|
||||||
|
/** Aktuální počet mincí k utracení */
|
||||||
|
coins: number;
|
||||||
|
/** Počet chycených vzácných zlatých motýlů */
|
||||||
|
goldenCaught: number;
|
||||||
|
/** Datum posledního úlovku (YYYY-MM-DD) pro denní bonus */
|
||||||
|
lastCatchDay?: string;
|
||||||
|
|
||||||
|
// --- Perzistentní škůdci (aby je nešlo obejít obnovením stránky) ---
|
||||||
|
/** Aktuální počet vos */
|
||||||
|
wasps: number;
|
||||||
|
/** Aktuální počet ptáků */
|
||||||
|
birds: number;
|
||||||
|
/** Celkový počet zahubených vos */
|
||||||
|
waspsKilled: number;
|
||||||
|
/** Celkový počet vyplašených ptáků */
|
||||||
|
birdsScared: number;
|
||||||
|
/** Do kdy platí plašič ptáků (ms epoch), 0 = neaktivní */
|
||||||
|
repellentUntil: number;
|
||||||
|
/** Zda je síťka protržená */
|
||||||
|
netTorn: boolean;
|
||||||
|
/** Kdy naposledy „dorostla" vosa (ms epoch) */
|
||||||
|
lastWaspAt: number;
|
||||||
|
/** Kdy naposledy „dorostl" pták (ms epoch) */
|
||||||
|
lastBirdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
const STORAGE_KEY = 'butterflyStats';
|
||||||
|
|
||||||
|
// --- Herní konstanty ---------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mince za jednoho běžného motýla */
|
||||||
|
export const COIN_BASE = 1;
|
||||||
|
/** Mince za jednoho vzácného zlatého motýla */
|
||||||
|
export const GOLD_VALUE = 25;
|
||||||
|
/** Cena zašití protržené síťky v mincích */
|
||||||
|
export const REPAIR_COST = 10;
|
||||||
|
/** Bonus mincí za první úlovek dne */
|
||||||
|
export const DAILY_BONUS = 10;
|
||||||
|
/** Po každých kolika chycených se odemkne prémiová síťka */
|
||||||
|
export const PREMIUM_MILESTONE = 50;
|
||||||
|
|
||||||
|
/** Maximální počet současně poletujících vos */
|
||||||
|
export const WASP_MAX = 5;
|
||||||
|
/** Maximální počet současně poletujících ptáků */
|
||||||
|
export const BIRD_MAX = 4;
|
||||||
|
/** Jak často (ms) přibude jedna vosa */
|
||||||
|
export const WASP_GROWTH_MS = 30_000;
|
||||||
|
/** Jak často (ms) přibude jeden pták */
|
||||||
|
export const BIRD_GROWTH_MS = 120_000;
|
||||||
|
/** Cena plašiče ptáků v mincích */
|
||||||
|
export const REPELLENT_COST = 25;
|
||||||
|
/** Jak dlouho (ms) plašič ptáků drží ptáky pryč */
|
||||||
|
export const REPELLENT_DURATION_MS = 180_000;
|
||||||
|
|
||||||
|
/** Od jaké velikosti dávky se začíná počítat kombo bonus */
|
||||||
|
const COMBO_THRESHOLD = 3;
|
||||||
|
/** Horní strop komba, aby dávka nedala nesmyslně moc mincí */
|
||||||
|
const COMBO_CAP = 20;
|
||||||
|
/** Maximální počet motýlů akceptovaný v jedné dávce (sanity limit) */
|
||||||
|
const MAX_BATCH = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Úrovně sběratele a jejich tituly. `min` je hranice celkového počtu chycených,
|
||||||
|
* od které úroveň platí. Pole je vzestupně dle `min`.
|
||||||
|
*/
|
||||||
|
const LEVELS: { min: number; title: string }[] = [
|
||||||
|
{ min: 0, title: 'Začátečník se síťkou' },
|
||||||
|
{ min: 10, title: 'Nedělní chytač' },
|
||||||
|
{ min: 30, title: 'Lovec luk' },
|
||||||
|
{ min: 60, title: 'Sběratel křídel' },
|
||||||
|
{ min: 120, title: 'Mistr síťky' },
|
||||||
|
{ min: 200, title: 'Motýlí šeptač' },
|
||||||
|
{ min: 350, title: 'Legendární entomolog' },
|
||||||
|
{ min: 600, title: 'Vládce louky' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Čisté pomocné funkce ----------------------------------------------------
|
||||||
|
|
||||||
|
/** Vrátí úroveň (1-based) odpovídající celkovému počtu chycených motýlů. */
|
||||||
|
export function levelForCaught(caught: number): number {
|
||||||
|
let level = 1;
|
||||||
|
for (let i = 0; i < LEVELS.length; i++) {
|
||||||
|
if (caught >= LEVELS[i].min) {
|
||||||
|
level = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí titul odpovídající dané úrovni (1-based). */
|
||||||
|
export function titleForLevel(level: number): string {
|
||||||
|
const idx = Math.min(Math.max(level - 1, 0), LEVELS.length - 1);
|
||||||
|
return LEVELS[idx].title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bonus mincí za kombo (dávku chycenou najednou). */
|
||||||
|
export function comboBonus(batchTotal: number): number {
|
||||||
|
if (batchTotal < COMBO_THRESHOLD) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(batchTotal - (COMBO_THRESHOLD - 1), COMBO_CAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vytvoří výchozí (prázdné) statistiky. */
|
||||||
|
function defaultStats(now: number): StoredButterflyStats {
|
||||||
|
return {
|
||||||
|
caught: 0, coins: 0, goldenCaught: 0,
|
||||||
|
wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0,
|
||||||
|
repellentUntil: 0, netTorn: false,
|
||||||
|
lastWaspAt: now, lastBirdAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Doplní chybějící pole u starších uložených záznamů (migrace za běhu). */
|
||||||
|
function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
|
||||||
|
return {
|
||||||
|
caught: s.caught ?? 0,
|
||||||
|
coins: s.coins ?? 0,
|
||||||
|
goldenCaught: s.goldenCaught ?? 0,
|
||||||
|
lastCatchDay: s.lastCatchDay,
|
||||||
|
wasps: s.wasps ?? 0,
|
||||||
|
birds: s.birds ?? 0,
|
||||||
|
waspsKilled: s.waspsKilled ?? 0,
|
||||||
|
birdsScared: s.birdsScared ?? 0,
|
||||||
|
repellentUntil: s.repellentUntil ?? 0,
|
||||||
|
netTorn: s.netTorn ?? false,
|
||||||
|
lastWaspAt: s.lastWaspAt ?? now,
|
||||||
|
lastBirdAt: s.lastBirdAt ?? now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nechá v čase „dorůst" škůdce do maxima. Zachovává rozpracovaný čas (posouvá
|
||||||
|
* razítko jen o spotřebované celé intervaly). Ptáci nerostou, když je aktivní plašič.
|
||||||
|
*/
|
||||||
|
export function growPests(s: StoredButterflyStats, now: number): void {
|
||||||
|
// Vosy
|
||||||
|
if (s.wasps < WASP_MAX) {
|
||||||
|
const add = Math.floor((now - s.lastWaspAt) / WASP_GROWTH_MS);
|
||||||
|
if (add > 0) {
|
||||||
|
s.wasps = Math.min(s.wasps + add, WASP_MAX);
|
||||||
|
s.lastWaspAt = s.wasps >= WASP_MAX ? now : s.lastWaspAt + add * WASP_GROWTH_MS;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.lastWaspAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ptáci – jen když neběží plašič
|
||||||
|
const repellentActive = s.repellentUntil > now;
|
||||||
|
if (repellentActive) {
|
||||||
|
s.lastBirdAt = now;
|
||||||
|
} else if (s.birds < BIRD_MAX) {
|
||||||
|
const add = Math.floor((now - s.lastBirdAt) / BIRD_GROWTH_MS);
|
||||||
|
if (add > 0) {
|
||||||
|
s.birds = Math.min(s.birds + add, BIRD_MAX);
|
||||||
|
s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * BIRD_GROWTH_MS;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.lastBirdAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Převede interní statistiky na DTO (doplní odvozenou úroveň a titul). */
|
||||||
|
function toDto(s: StoredButterflyStats): ButterflyStats {
|
||||||
|
const level = levelForCaught(s.caught);
|
||||||
|
return {
|
||||||
|
caught: s.caught,
|
||||||
|
coins: s.coins,
|
||||||
|
goldenCaught: s.goldenCaught,
|
||||||
|
level,
|
||||||
|
title: titleForLevel(level),
|
||||||
|
lastCatchDay: s.lastCatchDay,
|
||||||
|
wasps: s.wasps,
|
||||||
|
birds: s.birds,
|
||||||
|
waspsKilled: s.waspsKilled,
|
||||||
|
birdsScared: s.birdsScared,
|
||||||
|
repellentUntil: s.repellentUntil,
|
||||||
|
netTorn: s.netTorn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Načte mapu všech uživatelských statistik ze storage. */
|
||||||
|
async function loadAll(): Promise<Record<string, StoredButterflyStats>> {
|
||||||
|
return (await storage.getData<Record<string, StoredButterflyStats>>(STORAGE_KEY)) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomicky zmutuje statistiky jednoho uživatele. Před zavoláním `mutator`
|
||||||
|
* záznam znormalizuje a nechá dorůst škůdce.
|
||||||
|
*/
|
||||||
|
async function mutateUser(
|
||||||
|
login: string,
|
||||||
|
now: number,
|
||||||
|
mutator: (mine: StoredButterflyStats) => void,
|
||||||
|
): Promise<StoredButterflyStats> {
|
||||||
|
const updated = await storage.updateData<Record<string, StoredButterflyStats>>(STORAGE_KEY, (current) => {
|
||||||
|
const all = current ?? {};
|
||||||
|
const mine = normalize(all[login] ?? defaultStats(now), now);
|
||||||
|
growPests(mine, now);
|
||||||
|
mutator(mine);
|
||||||
|
all[login] = mine;
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
return updated[login];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chyby -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Chyba vyhozená, když uživatel nemá dost mincí. */
|
||||||
|
export class InsufficientCoinsError extends Error { }
|
||||||
|
|
||||||
|
// --- Veřejné API -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí statistiky chytání motýlků daného uživatele (a nechá dorůst škůdce).
|
||||||
|
*/
|
||||||
|
export async function getStats(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
const mine = await mutateUser(login, now, () => { /* jen dorůst škůdce */ });
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaznamená dávku nachytaných motýlů, atomicky připíše mince (základ, kombo,
|
||||||
|
* denní bonus) a aktualizuje statistiky. Vrátí i přehled odměn.
|
||||||
|
*/
|
||||||
|
export async function recordCatches(login: string, normal: number, golden: number, now: number = Date.now()): Promise<ButterflyCatchResult> {
|
||||||
|
const n = Math.min(Math.max(Math.floor(normal) || 0, 0), MAX_BATCH);
|
||||||
|
const g = Math.min(Math.max(Math.floor(golden) || 0, 0), MAX_BATCH);
|
||||||
|
const batchTotal = n + g;
|
||||||
|
const today = formatDate(new Date(now));
|
||||||
|
|
||||||
|
let coinsAwarded = 0;
|
||||||
|
let dailyBonusApplied = false;
|
||||||
|
let leveledUp = false;
|
||||||
|
let premiumUnlocked = false;
|
||||||
|
|
||||||
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
const oldCaught = s.caught;
|
||||||
|
const oldLevel = levelForCaught(oldCaught);
|
||||||
|
|
||||||
|
dailyBonusApplied = batchTotal > 0 && s.lastCatchDay !== today;
|
||||||
|
const daily = dailyBonusApplied ? DAILY_BONUS : 0;
|
||||||
|
|
||||||
|
coinsAwarded = n * COIN_BASE + g * GOLD_VALUE + comboBonus(batchTotal) + daily;
|
||||||
|
|
||||||
|
const newCaught = oldCaught + batchTotal;
|
||||||
|
s.caught = newCaught;
|
||||||
|
s.coins += coinsAwarded;
|
||||||
|
s.goldenCaught += g;
|
||||||
|
if (batchTotal > 0) s.lastCatchDay = today;
|
||||||
|
|
||||||
|
leveledUp = levelForCaught(newCaught) > oldLevel;
|
||||||
|
premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stats: toDto(mine), coinsAwarded, dailyBonusApplied, leveledUp, premiumUnlocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zašije protrženou síťku za mince. Vyhodí {@link InsufficientCoinsError}, pokud
|
||||||
|
* uživatel nemá dost mincí. Když síťka není protržená, jen vrátí aktuální stav.
|
||||||
|
*/
|
||||||
|
export async function repairNet(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
let failed = false;
|
||||||
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
if (!s.netTorn) return;
|
||||||
|
if (s.coins < REPAIR_COST) { failed = true; return; }
|
||||||
|
s.coins -= REPAIR_COST;
|
||||||
|
s.netTorn = false;
|
||||||
|
});
|
||||||
|
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na opravu síťky');
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zahubí jednu vosu (plácačkou) a zvýší statistiku zahubených vos. */
|
||||||
|
export async function killWasp(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
if (s.wasps > 0) {
|
||||||
|
s.wasps -= 1;
|
||||||
|
s.waspsKilled += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Koupí plašič ptáků: vyžene všechny ptáky a po dobu platnosti brání příletu
|
||||||
|
* nových. Vyhodí {@link InsufficientCoinsError} při nedostatku mincí.
|
||||||
|
*/
|
||||||
|
export async function buyRepellent(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
let failed = false;
|
||||||
|
const mine = await mutateUser(login, now, (s) => {
|
||||||
|
if (s.coins < REPELLENT_COST) { failed = true; return; }
|
||||||
|
s.coins -= REPELLENT_COST;
|
||||||
|
s.birdsScared += s.birds;
|
||||||
|
s.birds = 0;
|
||||||
|
s.repellentUntil = now + REPELLENT_DURATION_MS;
|
||||||
|
s.lastBirdAt = now;
|
||||||
|
});
|
||||||
|
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na plašič ptáků');
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaznamená, že pták protrhl síťku (perzistentně). */
|
||||||
|
export async function reportNetTorn(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||||
|
const mine = await mutateUser(login, now, (s) => { s.netTorn = true; });
|
||||||
|
return toDto(mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí žebříček nejlepších chytačů seřazený sestupně dle počtu chycených
|
||||||
|
* (při shodě dle počtu zlatých motýlů).
|
||||||
|
*/
|
||||||
|
export async function getLeaderboard(limit = 10): Promise<ButterflyLeaderboardEntry[]> {
|
||||||
|
const all = await loadAll();
|
||||||
|
return Object.entries(all)
|
||||||
|
.map(([login, s]) => {
|
||||||
|
const level = levelForCaught(s.caught ?? 0);
|
||||||
|
return {
|
||||||
|
login,
|
||||||
|
caught: s.caught ?? 0,
|
||||||
|
goldenCaught: s.goldenCaught ?? 0,
|
||||||
|
level,
|
||||||
|
title: titleForLevel(level),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.caught - a.caught || b.goldenCaught - a.goldenCaught)
|
||||||
|
.slice(0, Math.max(1, limit));
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import devRoutes from "./routes/devRoutes";
|
|||||||
import changelogRoutes from "./routes/changelogRoutes";
|
import changelogRoutes from "./routes/changelogRoutes";
|
||||||
import groupRoutes from "./routes/groupRoutes";
|
import groupRoutes from "./routes/groupRoutes";
|
||||||
import storeRoutes from "./routes/storeRoutes";
|
import storeRoutes from "./routes/storeRoutes";
|
||||||
|
import butterflyRoutes from "./routes/butterflyRoutes";
|
||||||
|
|
||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
@@ -273,6 +274,7 @@ app.use("/api/dev", devRoutes);
|
|||||||
app.use("/api/changelogs", changelogRoutes);
|
app.use("/api/changelogs", changelogRoutes);
|
||||||
app.use("/api/groups", groupRoutes);
|
app.use("/api/groups", groupRoutes);
|
||||||
app.use("/api/stores", storeRoutes);
|
app.use("/api/stores", storeRoutes);
|
||||||
|
app.use("/api/butterflies", butterflyRoutes);
|
||||||
|
|
||||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||||
app.get('*splat', (_req, res) => {
|
app.get('*splat', (_req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import express, { Request } from "express";
|
||||||
|
import { getLogin } from "../auth";
|
||||||
|
import { parseToken } from "../utils";
|
||||||
|
import {
|
||||||
|
getStats,
|
||||||
|
recordCatches,
|
||||||
|
repairNet,
|
||||||
|
killWasp,
|
||||||
|
buyRepellent,
|
||||||
|
reportNetTorn,
|
||||||
|
getLeaderboard,
|
||||||
|
InsufficientCoinsError,
|
||||||
|
} from "../butterflies";
|
||||||
|
import { CatchButterfliesData } from "../../../types";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await getStats(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/catch", async (req: Request<{}, any, CatchButterfliesData["body"]>, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
if (typeof req.body?.normal !== 'number' || typeof req.body?.golden !== 'number') {
|
||||||
|
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||||
|
}
|
||||||
|
const data = await recordCatches(login, req.body.normal, req.body.golden);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/repair", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await repairNet(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof InsufficientCoinsError) {
|
||||||
|
return res.status(402).json({ error: e.message });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/killWasp", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await killWasp(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/repellent", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await buyRepellent(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof InsufficientCoinsError) {
|
||||||
|
return res.status(402).json({ error: e.message });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/tear", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await reportNetTorn(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/leaderboard", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
getLogin(parseToken(req));
|
||||||
|
const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : undefined;
|
||||||
|
const data = await getLeaderboard(Number.isFinite(limit) ? limit : undefined);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import getStorage from '../storage';
|
||||||
|
import {
|
||||||
|
getStats,
|
||||||
|
recordCatches,
|
||||||
|
repairNet,
|
||||||
|
killWasp,
|
||||||
|
buyRepellent,
|
||||||
|
reportNetTorn,
|
||||||
|
getLeaderboard,
|
||||||
|
growPests,
|
||||||
|
levelForCaught,
|
||||||
|
titleForLevel,
|
||||||
|
comboBonus,
|
||||||
|
InsufficientCoinsError,
|
||||||
|
COIN_BASE,
|
||||||
|
GOLD_VALUE,
|
||||||
|
REPAIR_COST,
|
||||||
|
DAILY_BONUS,
|
||||||
|
PREMIUM_MILESTONE,
|
||||||
|
WASP_MAX,
|
||||||
|
BIRD_MAX,
|
||||||
|
WASP_GROWTH_MS,
|
||||||
|
BIRD_GROWTH_MS,
|
||||||
|
REPELLENT_COST,
|
||||||
|
REPELLENT_DURATION_MS,
|
||||||
|
} from '../butterflies';
|
||||||
|
import { formatDate } from '../utils';
|
||||||
|
|
||||||
|
const USER = 'tomas';
|
||||||
|
const OTHER = 'petr';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Nastaví uživateli přímo uložené statistiky (simulace zásahu do dat). */
|
||||||
|
async function seed(login: string, data: any) {
|
||||||
|
const storage = getStorage();
|
||||||
|
const all = (await storage.getData<any>('butterflyStats')) ?? {};
|
||||||
|
all[login] = data;
|
||||||
|
await storage.setData('butterflyStats', all);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('levelForCaught / titleForLevel', () => {
|
||||||
|
test('začíná na úrovni 1', () => {
|
||||||
|
expect(levelForCaught(0)).toBe(1);
|
||||||
|
expect(titleForLevel(1)).toBe('Začátečník se síťkou');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roste s počtem chycených', () => {
|
||||||
|
expect(levelForCaught(9)).toBe(1);
|
||||||
|
expect(levelForCaught(10)).toBe(2);
|
||||||
|
expect(levelForCaught(30)).toBe(3);
|
||||||
|
expect(levelForCaught(10000)).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comboBonus', () => {
|
||||||
|
test('malá dávka nedává kombo', () => {
|
||||||
|
expect(comboBonus(1)).toBe(0);
|
||||||
|
expect(comboBonus(2)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('větší dávka dává rostoucí bonus se stropem', () => {
|
||||||
|
expect(comboBonus(3)).toBe(1);
|
||||||
|
expect(comboBonus(4)).toBe(2);
|
||||||
|
expect(comboBonus(1000)).toBe(20); // COMBO_CAP
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStats', () => {
|
||||||
|
test('pro nového uživatele vrací výchozí prázdné statistiky', async () => {
|
||||||
|
const stats = await getStats(USER);
|
||||||
|
expect(stats.caught).toBe(0);
|
||||||
|
expect(stats.coins).toBe(0);
|
||||||
|
expect(stats.goldenCaught).toBe(0);
|
||||||
|
expect(stats.level).toBe(1);
|
||||||
|
expect(stats.title).toBe('Začátečník se síťkou');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordCatches – mince', () => {
|
||||||
|
test('běžný motýl dá základní minci', async () => {
|
||||||
|
const result = await recordCatches(USER, 1, 0);
|
||||||
|
// 1 běžný + první úlovek dne => denní bonus
|
||||||
|
expect(result.coinsAwarded).toBe(COIN_BASE + DAILY_BONUS);
|
||||||
|
expect(result.dailyBonusApplied).toBe(true);
|
||||||
|
expect(result.stats.caught).toBe(1);
|
||||||
|
expect(result.stats.coins).toBe(COIN_BASE + DAILY_BONUS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zlatý motýl má vyšší hodnotu a počítá se zvlášť', async () => {
|
||||||
|
// předvyplníme dnešní den, aby nezasáhl denní bonus
|
||||||
|
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||||
|
const result = await recordCatches(USER, 0, 1);
|
||||||
|
expect(result.coinsAwarded).toBe(GOLD_VALUE);
|
||||||
|
expect(result.dailyBonusApplied).toBe(false);
|
||||||
|
expect(result.stats.goldenCaught).toBe(1);
|
||||||
|
expect(result.stats.caught).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kombo se připočítá u větší dávky', async () => {
|
||||||
|
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||||
|
const result = await recordCatches(USER, 4, 0);
|
||||||
|
// 4 * COIN_BASE + comboBonus(4)=2
|
||||||
|
expect(result.coinsAwarded).toBe(4 * COIN_BASE + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanity limit ořízne absurdní dávku a odmítne záporné', async () => {
|
||||||
|
const result = await recordCatches(USER, 100000, -5);
|
||||||
|
expect(result.stats.caught).toBe(100); // MAX_BATCH
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordCatches – denní bonus', () => {
|
||||||
|
test('denní bonus se připíše jen jednou za den', async () => {
|
||||||
|
const first = await recordCatches(USER, 1, 0);
|
||||||
|
expect(first.dailyBonusApplied).toBe(true);
|
||||||
|
const second = await recordCatches(USER, 1, 0);
|
||||||
|
expect(second.dailyBonusApplied).toBe(false);
|
||||||
|
expect(second.coinsAwarded).toBe(COIN_BASE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bonus se znovu připíše po změně dne', async () => {
|
||||||
|
await seed(USER, { caught: 3, coins: 3, goldenCaught: 0, lastCatchDay: '2000-01-01' });
|
||||||
|
const result = await recordCatches(USER, 1, 0);
|
||||||
|
expect(result.dailyBonusApplied).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordCatches – progres a milníky', () => {
|
||||||
|
test('leveledUp se nastaví při překročení hranice úrovně', async () => {
|
||||||
|
await seed(USER, { caught: 9, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||||
|
const result = await recordCatches(USER, 1, 0); // 9 -> 10 => úroveň 2
|
||||||
|
expect(result.leveledUp).toBe(true);
|
||||||
|
expect(result.stats.level).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('premiumUnlocked se nastaví při překročení milníku', async () => {
|
||||||
|
await seed(USER, {
|
||||||
|
caught: PREMIUM_MILESTONE - 1,
|
||||||
|
coins: 0,
|
||||||
|
goldenCaught: 0,
|
||||||
|
lastCatchDay: formatDate(new Date()),
|
||||||
|
});
|
||||||
|
const result = await recordCatches(USER, 1, 0);
|
||||||
|
expect(result.premiumUnlocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bez překročení milníku premiumUnlocked zůstane false', async () => {
|
||||||
|
await seed(USER, { caught: 5, coins: 0, goldenCaught: 0, lastCatchDay: formatDate(new Date()) });
|
||||||
|
const result = await recordCatches(USER, 1, 0);
|
||||||
|
expect(result.premiumUnlocked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairNet', () => {
|
||||||
|
test('odečte cenu opravy při dostatku mincí a zruší protržení', async () => {
|
||||||
|
await seed(USER, { caught: 20, coins: REPAIR_COST + 5, goldenCaught: 0, netTorn: true });
|
||||||
|
const stats = await repairNet(USER);
|
||||||
|
expect(stats.coins).toBe(5);
|
||||||
|
expect(stats.netTorn).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhodí chybu při nedostatku mincí', async () => {
|
||||||
|
await seed(USER, { caught: 1, coins: REPAIR_COST - 1, goldenCaught: 0, netTorn: true });
|
||||||
|
await expect(repairNet(USER)).rejects.toThrow(InsufficientCoinsError);
|
||||||
|
// mince zůstanou nezměněné a síťka stále protržená
|
||||||
|
const stats = await getStats(USER);
|
||||||
|
expect(stats.coins).toBe(REPAIR_COST - 1);
|
||||||
|
expect(stats.netTorn).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neúčtuje, když síťka není protržená', async () => {
|
||||||
|
await seed(USER, { caught: 1, coins: 50, goldenCaught: 0, netTorn: false });
|
||||||
|
const stats = await repairNet(USER);
|
||||||
|
expect(stats.coins).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('growPests', () => {
|
||||||
|
test('vosy přibývají v čase do maxima', () => {
|
||||||
|
const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
|
||||||
|
growPests(s, 2 * WASP_GROWTH_MS + 100);
|
||||||
|
expect(s.wasps).toBe(2);
|
||||||
|
growPests(s, 1_000_000 * WASP_GROWTH_MS);
|
||||||
|
expect(s.wasps).toBe(WASP_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zachovává rozpracovaný čas (dvě poloviční okna = jedna vosa)', () => {
|
||||||
|
const s: any = { wasps: 0, birds: 0, repellentUntil: 0, lastWaspAt: 0, lastBirdAt: 0 };
|
||||||
|
growPests(s, Math.floor(WASP_GROWTH_MS * 0.6));
|
||||||
|
expect(s.wasps).toBe(0);
|
||||||
|
growPests(s, Math.floor(WASP_GROWTH_MS * 1.2));
|
||||||
|
expect(s.wasps).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ptáci nepřibývají při aktivním plašiči', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
const s: any = { wasps: 0, birds: 0, repellentUntil: now + 10 * BIRD_GROWTH_MS, lastWaspAt: now, lastBirdAt: now };
|
||||||
|
growPests(s, now + 3 * BIRD_GROWTH_MS);
|
||||||
|
expect(s.birds).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('killWasp', () => {
|
||||||
|
test('sníží počet vos a zvýší statistiku zahubených', async () => {
|
||||||
|
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, wasps: 3, lastWaspAt: Date.now() });
|
||||||
|
const stats = await killWasp(USER);
|
||||||
|
expect(stats.wasps).toBe(2);
|
||||||
|
expect(stats.waspsKilled).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('při nula vosách nic nepokazí', async () => {
|
||||||
|
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0, wasps: 0, lastWaspAt: Date.now() });
|
||||||
|
const stats = await killWasp(USER);
|
||||||
|
expect(stats.wasps).toBe(0);
|
||||||
|
expect(stats.waspsKilled).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buyRepellent', () => {
|
||||||
|
test('vyžene ptáky, aktivuje plašič a odečte mince', async () => {
|
||||||
|
const now = 5_000_000;
|
||||||
|
await seed(USER, { caught: 0, coins: REPELLENT_COST + 3, goldenCaught: 0, birds: 3, lastBirdAt: now });
|
||||||
|
const stats = await buyRepellent(USER, now);
|
||||||
|
expect(stats.coins).toBe(3);
|
||||||
|
expect(stats.birds).toBe(0);
|
||||||
|
expect(stats.birdsScared).toBe(3);
|
||||||
|
expect(stats.repellentUntil).toBe(now + REPELLENT_DURATION_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhodí chybu při nedostatku mincí', async () => {
|
||||||
|
await seed(USER, { caught: 0, coins: REPELLENT_COST - 1, goldenCaught: 0, birds: 2 });
|
||||||
|
await expect(buyRepellent(USER)).rejects.toThrow(InsufficientCoinsError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reportNetTorn', () => {
|
||||||
|
test('nastaví protrženou síťku (perzistentně)', async () => {
|
||||||
|
await seed(USER, { caught: 0, coins: 0, goldenCaught: 0 });
|
||||||
|
const stats = await reportNetTorn(USER);
|
||||||
|
expect(stats.netTorn).toBe(true);
|
||||||
|
// stav přežije další čtení (F5)
|
||||||
|
expect((await getStats(USER)).netTorn).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeaderboard', () => {
|
||||||
|
test('řadí sestupně dle počtu chycených a respektuje limit', async () => {
|
||||||
|
await seed(USER, { caught: 100, coins: 0, goldenCaught: 2 });
|
||||||
|
await seed(OTHER, { caught: 200, coins: 0, goldenCaught: 0 });
|
||||||
|
await seed('jana', { caught: 50, coins: 0, goldenCaught: 10 });
|
||||||
|
|
||||||
|
const all = await getLeaderboard(10);
|
||||||
|
expect(all.map(e => e.login)).toEqual([OTHER, USER, 'jana']);
|
||||||
|
|
||||||
|
const top = await getLeaderboard(2);
|
||||||
|
expect(top).toHaveLength(2);
|
||||||
|
expect(top[0].login).toBe(OTHER);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,22 @@ paths:
|
|||||||
/suggestions/delete:
|
/suggestions/delete:
|
||||||
$ref: "./paths/suggestions/delete.yml"
|
$ref: "./paths/suggestions/delete.yml"
|
||||||
|
|
||||||
|
# Chytání motýlků (/api/butterflies)
|
||||||
|
/butterflies:
|
||||||
|
$ref: "./paths/butterflies/getStats.yml"
|
||||||
|
/butterflies/catch:
|
||||||
|
$ref: "./paths/butterflies/catch.yml"
|
||||||
|
/butterflies/repair:
|
||||||
|
$ref: "./paths/butterflies/repair.yml"
|
||||||
|
/butterflies/killWasp:
|
||||||
|
$ref: "./paths/butterflies/killWasp.yml"
|
||||||
|
/butterflies/repellent:
|
||||||
|
$ref: "./paths/butterflies/repellent.yml"
|
||||||
|
/butterflies/tear:
|
||||||
|
$ref: "./paths/butterflies/tear.yml"
|
||||||
|
/butterflies/leaderboard:
|
||||||
|
$ref: "./paths/butterflies/leaderboard.yml"
|
||||||
|
|
||||||
# Changelog (/api/changelogs)
|
# Changelog (/api/changelogs)
|
||||||
/changelogs:
|
/changelogs:
|
||||||
$ref: "./paths/changelogs/getChangelogs.yml"
|
$ref: "./paths/changelogs/getChangelogs.yml"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
post:
|
||||||
|
operationId: catchButterflies
|
||||||
|
summary: >-
|
||||||
|
Zaznamená dávku nachytaných motýlů, připíše mince (základ, kombo a případný
|
||||||
|
denní bonus) a vrátí aktualizované statistiky.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyCatchRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky a přehled připsaných odměn.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyCatchResult"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
get:
|
||||||
|
operationId: getButterflyStats
|
||||||
|
summary: Vrátí statistiky chytání motýlků přihlášeného uživatele.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Statistiky chytání motýlků přihlášeného uživatele.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
post:
|
||||||
|
operationId: killWasp
|
||||||
|
summary: Zahubí jednu vosu (plácačkou) a zvýší statistiku zahubených vos.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky po zahubení vosy.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
get:
|
||||||
|
operationId: getButterflyLeaderboard
|
||||||
|
summary: Vrátí žebříček nejlepších chytačů motýlů seřazený dle počtu chycených.
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: Maximální počet záznamů žebříčku (výchozí 10)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Žebříček chytačů motýlů.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyLeaderboard"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
post:
|
||||||
|
operationId: repairNet
|
||||||
|
summary: >-
|
||||||
|
Zašije protrženou síťku za mince. Vrátí aktualizované statistiky, nebo 402
|
||||||
|
pokud uživatel nemá dostatek mincí.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky po zaplacení opravy.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
|
"402":
|
||||||
|
description: Uživatel nemá dostatek mincí na opravu síťky.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
post:
|
||||||
|
operationId: buyRepellent
|
||||||
|
summary: >-
|
||||||
|
Koupí plašič ptáků za mince – vyžene všechny ptáky a po dobu platnosti brání
|
||||||
|
příletu nových. Vrátí 402 při nedostatku mincí.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky po koupi plašiče.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
|
"402":
|
||||||
|
description: Uživatel nemá dostatek mincí na plašič.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
post:
|
||||||
|
operationId: reportNetTorn
|
||||||
|
summary: >-
|
||||||
|
Zaznamená, že pták protrhl síťku (perzistentně, aby se stav neobešel
|
||||||
|
obnovením stránky).
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované statistiky s protrženou síťkou.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ButterflyStats"
|
||||||
@@ -926,3 +926,128 @@ PendingQr:
|
|||||||
groupId:
|
groupId:
|
||||||
description: ID skupiny objednávky, ke které QR patří
|
description: ID skupiny objednávky, ke které QR patří
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
# --- CHYTÁNÍ MOTÝLKŮ ---
|
||||||
|
ButterflyStats:
|
||||||
|
description: Statistiky chytání motýlků pro jednoho uživatele
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- caught
|
||||||
|
- coins
|
||||||
|
- goldenCaught
|
||||||
|
- level
|
||||||
|
- title
|
||||||
|
- wasps
|
||||||
|
- birds
|
||||||
|
- waspsKilled
|
||||||
|
- birdsScared
|
||||||
|
- repellentUntil
|
||||||
|
- netTorn
|
||||||
|
properties:
|
||||||
|
caught:
|
||||||
|
description: Celkový počet chycených motýlů
|
||||||
|
type: integer
|
||||||
|
coins:
|
||||||
|
description: Aktuální počet mincí k utracení
|
||||||
|
type: integer
|
||||||
|
goldenCaught:
|
||||||
|
description: Počet chycených vzácných zlatých motýlů
|
||||||
|
type: integer
|
||||||
|
level:
|
||||||
|
description: Aktuální úroveň sběratele (odvozená z počtu chycených)
|
||||||
|
type: integer
|
||||||
|
title:
|
||||||
|
description: Titul odpovídající aktuální úrovni
|
||||||
|
type: string
|
||||||
|
lastCatchDay:
|
||||||
|
description: Datum posledního úlovku (YYYY-MM-DD) pro denní bonus
|
||||||
|
type: string
|
||||||
|
wasps:
|
||||||
|
description: Aktuální počet poletujících vos (perzistentní, přibývají v čase)
|
||||||
|
type: integer
|
||||||
|
birds:
|
||||||
|
description: Aktuální počet poletujících ptáků (perzistentní, přibývají v čase)
|
||||||
|
type: integer
|
||||||
|
waspsKilled:
|
||||||
|
description: Celkový počet zahubených vos
|
||||||
|
type: integer
|
||||||
|
birdsScared:
|
||||||
|
description: Celkový počet vyplašených ptáků (plašičem)
|
||||||
|
type: integer
|
||||||
|
repellentUntil:
|
||||||
|
description: Časové razítko (ms epoch), do kdy platí plašič ptáků; 0 = neaktivní
|
||||||
|
type: integer
|
||||||
|
netTorn:
|
||||||
|
description: Zda je síťka aktuálně protržená (nutná placená oprava)
|
||||||
|
type: boolean
|
||||||
|
ButterflyCatchRequest:
|
||||||
|
description: Dávka nachytaných motýlů k zaznamenání
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- normal
|
||||||
|
- golden
|
||||||
|
properties:
|
||||||
|
normal:
|
||||||
|
description: Počet běžných motýlů chycených v této dávce
|
||||||
|
type: integer
|
||||||
|
golden:
|
||||||
|
description: Počet zlatých motýlů chycených v této dávce
|
||||||
|
type: integer
|
||||||
|
ButterflyCatchResult:
|
||||||
|
description: Výsledek zaznamenání dávky úlovků včetně přehledu odměn
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- stats
|
||||||
|
- coinsAwarded
|
||||||
|
- dailyBonusApplied
|
||||||
|
- leveledUp
|
||||||
|
- premiumUnlocked
|
||||||
|
properties:
|
||||||
|
stats:
|
||||||
|
$ref: "#/ButterflyStats"
|
||||||
|
coinsAwarded:
|
||||||
|
description: Počet mincí připsaných za tuto dávku (základ + kombo + denní bonus)
|
||||||
|
type: integer
|
||||||
|
dailyBonusApplied:
|
||||||
|
description: Zda byl v této dávce připsán denní bonus za první úlovek dne
|
||||||
|
type: boolean
|
||||||
|
leveledUp:
|
||||||
|
description: Zda uživatel touto dávkou postoupil alespoň o jednu úroveň
|
||||||
|
type: boolean
|
||||||
|
premiumUnlocked:
|
||||||
|
description: Zda uživatel touto dávkou dosáhl milníku pro prémiovou síťku
|
||||||
|
type: boolean
|
||||||
|
ButterflyLeaderboardEntry:
|
||||||
|
description: Jeden záznam v žebříčku chytačů motýlů
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- login
|
||||||
|
- caught
|
||||||
|
- goldenCaught
|
||||||
|
- level
|
||||||
|
- title
|
||||||
|
properties:
|
||||||
|
login:
|
||||||
|
description: Přihlašovací jméno chytače
|
||||||
|
type: string
|
||||||
|
caught:
|
||||||
|
description: Celkový počet chycených motýlů
|
||||||
|
type: integer
|
||||||
|
goldenCaught:
|
||||||
|
description: Počet chycených zlatých motýlů
|
||||||
|
type: integer
|
||||||
|
level:
|
||||||
|
description: Aktuální úroveň sběratele
|
||||||
|
type: integer
|
||||||
|
title:
|
||||||
|
description: Titul odpovídající aktuální úrovni
|
||||||
|
type: string
|
||||||
|
ButterflyLeaderboard:
|
||||||
|
description: Žebříček chytačů motýlů seřazený sestupně dle počtu chycených
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/ButterflyLeaderboardEntry"
|
||||||
|
|||||||
Reference in New Issue
Block a user