Files
ClickerZ/Frontend/src/store/useGameStore.ts
Tetardtek 2c924c1e4a fix: câbler tous les effets arbre + cleanup dette Sprint 2
- double_click_chance + crit_click_chance câblés dans applyClick (RNG)
- auto_click câblé dans le tick (auto-pontes/s)
- unlock_generator (Résilience) → 1 Lac Mystique gratuit au prestige
- ponte_critique requires double_ponte (fix branche morte)
- achievement_scaling retiré (nœud absent), full_tree + symbiose fixés
- Particule feedback coloré (crit=ambre, double=violet)
- 99 tests (tous passent)
2026-03-28 12:41:12 +01:00

320 lines
8.9 KiB
TypeScript

// useGameStore.ts — Zustand store, source unique de l'état game
// Serveur = autorité. localStorage = fallback invité uniquement.
// Lazy calculation pattern : gains passifs calculés au read depuis lastTick
import { create } from "zustand";
import {
GameState,
DEFAULT_STATE,
applyIdleGains,
applyClick,
getClickGain,
getAutoClicksPerSecond,
buyGenerator,
buyEvolutionNode,
resetEvolutionTree,
applyPrestige,
canPrestige as canPrestigeCheck,
totalProductionPerSecond,
generatorCost as genCost,
computeOfflineGains,
offlineEfficiency,
} from "../core/economy";
import {
computeNewUnlocks,
equipCosmetic as equipCosmeticFn,
unequipSlot as unequipSlotFn,
addToInventory,
DEFAULT_COSMETIC_STATE,
type CosmeticSlot,
} from "../core/cosmetics";
const SAVE_KEY = "clickerz_state";
const OFFLINE_THRESHOLD = 60_000; // 60s — same as economy.ts
function loadLocalState(): GameState {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
const saved = JSON.parse(raw) as GameState;
// Backfill for old saves
if (!saved.lastOnline) saved.lastOnline = saved.lastTick;
if (!saved.cosmeticInventory) saved.cosmeticInventory = [];
if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {};
return applyIdleGains(saved, Date.now());
} catch {
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
}
}
function saveLocal(state: GameState): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
}
export interface OfflineReport {
wasOffline: boolean;
duration: number; // ms
gains: number;
efficiency: number; // 0-1 average
}
interface GameStore {
// State
state: GameState;
playSeconds: number;
ready: boolean;
// Offline report (shown once after load)
offlineReport: OfflineReport | null;
dismissOfflineReport: () => void;
// Last click result (for particle feedback)
lastClickGain: number;
lastClickDouble: boolean;
lastClickCrit: boolean;
// Derived (recalculated on tick)
canPrestige: boolean;
productionPerSecond: number;
// Actions
tick: () => void;
click: () => void;
buy: (genId: string) => void;
buyNode: (nodeId: string) => void;
prestige: () => void;
resetTree: () => void;
equipCosmetic: (cosmeticId: string) => void;
unequipCosmetic: (slot: CosmeticSlot) => void;
reset: () => void;
loadFromServer: (serverState: GameState) => void;
initGuest: () => void;
generatorCost: typeof genCost;
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => number;
}
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
// Backfill for old saves
if (!saved.lastOnline) saved.lastOnline = saved.lastTick;
if (!saved.cosmeticInventory) saved.cosmeticInventory = [];
if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {};
const elapsed = now - saved.lastTick;
if (elapsed <= OFFLINE_THRESHOLD) {
// Normal idle — no offline report
const hydrated = applyIdleGains(saved, now);
return {
state: { ...hydrated, lastOnline: now },
report: null,
};
}
// Offline — use degraded curve
const gains = computeOfflineGains(saved, now);
const pps = totalProductionPerSecond(saved);
const fullGains = pps * (elapsed / 1000);
const avgEfficiency = fullGains > 0 ? gains / fullGains : 0;
const hydrated: GameState = {
...saved,
resources: saved.resources + gains,
lifetimeTadpoles: saved.lifetimeTadpoles + gains,
lastTick: now,
lastOnline: now,
};
return {
state: hydrated,
report: {
wasOffline: true,
duration: elapsed,
gains,
efficiency: avgEfficiency,
},
};
}
const initialState = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
export const useGameStore = create<GameStore>((set, get) => ({
state: initialState,
playSeconds: 0,
ready: false,
offlineReport: null,
lastClickGain: 0,
lastClickDouble: false,
lastClickCrit: false,
canPrestige: false,
productionPerSecond: 0,
dismissOfflineReport: () => set({ offlineReport: null }),
tick: () => {
if (!get().ready) return;
const now = Date.now();
set((s) => {
const updated = applyIdleGains(s.state, now);
updated.lastOnline = now;
// Auto-click from evolution tree
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks;
updated.resources += autoGain;
updated.lifetimeTadpoles += autoGain;
}
// Check cosmetic unlocks every 5s
if (s.playSeconds % 5 === 0) {
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
const newUnlocks = computeNewUnlocks(updated, cosState);
if (newUnlocks.length > 0) {
const newCos = addToInventory(cosState, newUnlocks);
updated.cosmeticInventory = newCos.inventory;
}
}
saveLocal(updated);
return {
state: updated,
playSeconds: s.playSeconds + 1,
canPrestige: canPrestigeCheck(updated),
productionPerSecond: totalProductionPerSecond(updated),
};
});
},
click: () => {
if (!get().ready) return;
set((s) => {
const result = applyClick(applyIdleGains(s.state, Date.now()));
saveLocal(result.state);
return {
state: result.state,
lastClickGain: result.gain,
lastClickDouble: result.isDouble,
lastClickCrit: result.isCrit,
canPrestige: canPrestigeCheck(result.state),
productionPerSecond: totalProductionPerSecond(result.state),
};
});
},
buy: (genId: string) => {
if (!get().ready) return;
set((s) => {
const withIdle = applyIdleGains(s.state, Date.now());
const updated = buyGenerator(withIdle, genId);
if (!updated) return s;
saveLocal(updated);
return {
state: updated,
productionPerSecond: totalProductionPerSecond(updated),
};
});
},
buyNode: (nodeId: string) => {
if (!get().ready) return;
set((s) => {
const updated = buyEvolutionNode(s.state, nodeId);
if (!updated) return s;
saveLocal(updated);
return {
state: updated,
productionPerSecond: totalProductionPerSecond(updated),
};
});
},
prestige: () => {
if (!get().ready) return;
set((s) => {
if (!canPrestigeCheck(s.state)) return s;
const updated = applyPrestige(s.state);
saveLocal(updated);
return {
state: updated,
canPrestige: canPrestigeCheck(updated),
productionPerSecond: totalProductionPerSecond(updated),
};
});
},
equipCosmetic: (cosmeticId: string) => {
if (!get().ready) return;
set((s) => {
const cosState = { inventory: s.state.cosmeticInventory, equipped: s.state.cosmeticEquipped };
const updated = equipCosmeticFn(cosState, cosmeticId);
const newState = { ...s.state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
return { state: newState };
});
},
unequipCosmetic: (slot: CosmeticSlot) => {
if (!get().ready) return;
set((s) => {
const cosState = { inventory: s.state.cosmeticInventory, equipped: s.state.cosmeticEquipped };
const updated = unequipSlotFn(cosState, slot);
const newState = { ...s.state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
return { state: newState };
});
},
resetTree: () => {
if (!get().ready) return;
set((s) => {
const updated = resetEvolutionTree(s.state);
saveLocal(updated);
return {
state: updated,
productionPerSecond: totalProductionPerSecond(updated),
};
});
},
reset: () => {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh);
set({
state: fresh,
playSeconds: 0,
ready: true,
offlineReport: null,
canPrestige: false,
productionPerSecond: 0,
});
},
loadFromServer: (serverState: GameState) => {
const { state: hydrated, report } = hydrateWithOffline(serverState, Date.now());
saveLocal(hydrated);
set({
state: hydrated,
ready: true,
offlineReport: report,
canPrestige: canPrestigeCheck(hydrated),
productionPerSecond: totalProductionPerSecond(hydrated),
});
},
initGuest: () => {
const local = loadLocalState();
const { state: hydrated, report } = hydrateWithOffline(local, Date.now());
saveLocal(hydrated);
set({
state: hydrated,
ready: true,
offlineReport: report,
canPrestige: canPrestigeCheck(hydrated),
productionPerSecond: totalProductionPerSecond(hydrated),
});
},
generatorCost: genCost,
generatorCostWithTree: (gen) => genCost(gen, get().state.evolutionTree),
}));