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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user