feat: cosmétiques V1 — 5 slots SVG, récompenses achievements + prestige

10 cosmétiques (2/slot), unlock auto sur achievements et prestige tiers.
TadpoleSprite composant SVG stack (base + overlays équipés).
CosmeticsPanel dans la sidebar — inventaire, équiper/retirer par slot.
GameState étendu (cosmeticInventory + cosmeticEquipped), backfill saves.
17 nouveaux tests cosmétiques (92 total, tous passent).
This commit is contained in:
2026-03-28 12:09:26 +01:00
parent ae50908bc9
commit 2a242e97cc
8 changed files with 394 additions and 17 deletions

View File

@@ -18,6 +18,14 @@ import {
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
@@ -27,8 +35,10 @@ function loadLocalState(): GameState {
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 lastOnline for old saves
// 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() };
@@ -67,6 +77,8 @@ interface GameStore {
buyNode: (nodeId: string) => void;
prestige: () => void;
resetTree: () => void;
equipCosmetic: (cosmeticId: string) => void;
unequipCosmetic: (slot: CosmeticSlot) => void;
reset: () => void;
loadFromServer: (serverState: GameState) => void;
initGuest: () => void;
@@ -75,8 +87,10 @@ interface GameStore {
}
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
// Backfill lastOnline for old saves
// 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;
@@ -131,8 +145,18 @@ export const useGameStore = create<GameStore>((set, get) => ({
const now = Date.now();
set((s) => {
const updated = applyIdleGains(s.state, now);
// Mark as actively online
updated.lastOnline = now;
// 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,
@@ -197,6 +221,28 @@ export const useGameStore = create<GameStore>((set, get) => ({
});
},
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) => {