- 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)
320 lines
8.9 KiB
TypeScript
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),
|
|
}));
|