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

- 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:
Stánek Pavel
2026-07-22 11:59:17 +02:00
co-authored by Claude Opus 4.8
parent c8c5ecc60c
commit c5ca6c9d41
25 changed files with 2454 additions and 205 deletions
+197
View File
@@ -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 };
}