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:
127
Frontend/src/__tests__/cosmetics.test.ts
Normal file
127
Frontend/src/__tests__/cosmetics.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
COSMETICS,
|
||||
shouldUnlockCosmetic,
|
||||
computeNewUnlocks,
|
||||
equipCosmetic,
|
||||
unequipSlot,
|
||||
addToInventory,
|
||||
DEFAULT_COSMETIC_STATE,
|
||||
} from "../core/cosmetics";
|
||||
import { DEFAULT_STATE } from "../core/economy";
|
||||
|
||||
describe("Cosmetics system", () => {
|
||||
describe("COSMETICS catalog", () => {
|
||||
it("a 10 cosmétiques", () => {
|
||||
expect(COSMETICS.length).toBe(10);
|
||||
});
|
||||
|
||||
it("2 cosmétiques par slot", () => {
|
||||
const slots = ["hat", "eyes", "body", "tail", "accessory"];
|
||||
for (const slot of slots) {
|
||||
expect(COSMETICS.filter((c) => c.slot === slot).length).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("ids uniques", () => {
|
||||
const ids = COSMETICS.map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUnlockCosmetic", () => {
|
||||
it("unlock prestige_3 si prestigeCount >= 3", () => {
|
||||
const cos = COSMETICS.find((c) => c.sourceId === "prestige_3")!;
|
||||
const state = { ...DEFAULT_STATE, prestigeCount: 3 };
|
||||
expect(shouldUnlockCosmetic(cos, state)).toBe(true);
|
||||
});
|
||||
|
||||
it("pas d'unlock prestige_10 si prestigeCount < 10", () => {
|
||||
const cos = COSMETICS.find((c) => c.sourceId === "prestige_10")!;
|
||||
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
|
||||
expect(shouldUnlockCosmetic(cos, state)).toBe(false);
|
||||
});
|
||||
|
||||
it("unlock achievement 'first_prestige' si prestigeCount >= 1", () => {
|
||||
const cos = COSMETICS.find((c) => c.sourceId === "first_prestige")!;
|
||||
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
|
||||
expect(shouldUnlockCosmetic(cos, state)).toBe(true);
|
||||
});
|
||||
|
||||
it("pas d'unlock achievement si condition non remplie", () => {
|
||||
const cos = COSMETICS.find((c) => c.sourceId === "empire")!;
|
||||
expect(shouldUnlockCosmetic(cos, DEFAULT_STATE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeNewUnlocks", () => {
|
||||
it("retourne les cosmétiques nouvellement débloqués", () => {
|
||||
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
|
||||
const newUnlocks = computeNewUnlocks(state, DEFAULT_COSMETIC_STATE);
|
||||
// prestige_3 (particles_gold) + prestige_5 (glasses_savant) + first_prestige (ribbon) + veteran (aura_swamp)
|
||||
expect(newUnlocks).toContain("particles_gold");
|
||||
expect(newUnlocks).toContain("glasses_savant");
|
||||
expect(newUnlocks).toContain("ribbon");
|
||||
});
|
||||
|
||||
it("ne retourne pas les cosmétiques déjà dans l'inventaire", () => {
|
||||
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
|
||||
const cosState = { ...DEFAULT_COSMETIC_STATE, inventory: ["particles_gold"] };
|
||||
const newUnlocks = computeNewUnlocks(state, cosState);
|
||||
expect(newUnlocks).not.toContain("particles_gold");
|
||||
});
|
||||
|
||||
it("retourne vide si rien à débloquer", () => {
|
||||
expect(computeNewUnlocks(DEFAULT_STATE, DEFAULT_COSMETIC_STATE)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("equipCosmetic", () => {
|
||||
it("équipe un cosmétique dans le bon slot", () => {
|
||||
const cosState = { inventory: ["ribbon"], equipped: {} };
|
||||
const result = equipCosmetic(cosState, "ribbon");
|
||||
expect(result.equipped.tail).toBe("ribbon");
|
||||
});
|
||||
|
||||
it("ne fait rien si cosmétique pas dans l'inventaire", () => {
|
||||
const result = equipCosmetic(DEFAULT_COSMETIC_STATE, "ribbon");
|
||||
expect(result.equipped).toEqual({});
|
||||
});
|
||||
|
||||
it("remplace le cosmétique déjà équipé dans le même slot", () => {
|
||||
const cosState = { inventory: ["ribbon", "flame_tail"], equipped: { tail: "ribbon" } };
|
||||
const result = equipCosmetic(cosState, "flame_tail");
|
||||
expect(result.equipped.tail).toBe("flame_tail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unequipSlot", () => {
|
||||
it("retire le cosmétique du slot", () => {
|
||||
const cosState = { inventory: ["ribbon"], equipped: { tail: "ribbon" } };
|
||||
const result = unequipSlot(cosState, "tail");
|
||||
expect(result.equipped.tail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ne touche pas les autres slots", () => {
|
||||
const cosState = {
|
||||
inventory: ["ribbon", "crown"],
|
||||
equipped: { tail: "ribbon", hat: "crown" },
|
||||
};
|
||||
const result = unequipSlot(cosState, "tail");
|
||||
expect(result.equipped.hat).toBe("crown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addToInventory", () => {
|
||||
it("ajoute des ids", () => {
|
||||
const result = addToInventory(DEFAULT_COSMETIC_STATE, ["ribbon", "crown"]);
|
||||
expect(result.inventory).toEqual(["ribbon", "crown"]);
|
||||
});
|
||||
|
||||
it("pas de doublons", () => {
|
||||
const cosState = { ...DEFAULT_COSMETIC_STATE, inventory: ["ribbon"] };
|
||||
const result = addToInventory(cosState, ["ribbon", "crown"]);
|
||||
expect(result.inventory).toEqual(["ribbon", "crown"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
68
Frontend/src/components/CosmeticsPanel.tsx
Normal file
68
Frontend/src/components/CosmeticsPanel.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// CosmeticsPanel.tsx — Inventaire cosmétique dans la sidebar
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { COSMETICS, type CosmeticSlot } from "../core/cosmetics";
|
||||
|
||||
const SLOT_LABELS: Record<CosmeticSlot, string> = {
|
||||
hat: "Tête",
|
||||
eyes: "Yeux",
|
||||
body: "Corps",
|
||||
tail: "Queue",
|
||||
accessory: "Aura",
|
||||
};
|
||||
|
||||
const SLOT_ORDER: CosmeticSlot[] = ["hat", "eyes", "body", "tail", "accessory"];
|
||||
|
||||
export function CosmeticsPanel() {
|
||||
const inventory = useGameStore((s) => s.state.cosmeticInventory);
|
||||
const equipped = useGameStore((s) => s.state.cosmeticEquipped);
|
||||
const equip = useGameStore((s) => s.equipCosmetic);
|
||||
const unequip = useGameStore((s) => s.unequipCosmetic);
|
||||
|
||||
if (inventory.length === 0) return null;
|
||||
|
||||
const ownedCosmetics = COSMETICS.filter((c) => inventory.includes(c.id));
|
||||
|
||||
return (
|
||||
<div className="gp">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="gp-title">Cosmétiques</span>
|
||||
<span className="gp-label">{inventory.length}/{COSMETICS.length}</span>
|
||||
</div>
|
||||
|
||||
{SLOT_ORDER.map((slot) => {
|
||||
const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot);
|
||||
if (slotCosmetics.length === 0) return null;
|
||||
|
||||
const equippedId = equipped[slot];
|
||||
|
||||
return (
|
||||
<div key={slot} className="flex flex-col gap-0.5">
|
||||
<span className="gp-zone-label">{SLOT_LABELS[slot]}</span>
|
||||
{slotCosmetics.map((cos) => {
|
||||
const isEquipped = equippedId === cos.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cos.id}
|
||||
className={`gp-row ${isEquipped ? "gp-row--unlocked" : "gp-row--active"}`}
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="gp-value text-[0.7rem]!">{cos.name}</span>
|
||||
<span className="gp-label">{cos.description}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => isEquipped ? unequip(slot) : equip(cos.id)}
|
||||
className={`gp-btn ${isEquipped ? "gp-btn--disabled" : "gp-btn--buy"}`}
|
||||
>
|
||||
{isEquipped ? "Retirer" : "Équiper"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
Frontend/src/components/TadpoleSprite.tsx
Normal file
40
Frontend/src/components/TadpoleSprite.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// TadpoleSprite.tsx — Sprite têtard avec overlays cosmétiques équipés
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { COSMETICS, type CosmeticSlot } from "../core/cosmetics";
|
||||
|
||||
const SLOT_ORDER: CosmeticSlot[] = ["body", "tail", "eyes", "hat", "accessory"];
|
||||
|
||||
export function TadpoleSprite() {
|
||||
const equipped = useGameStore((s) => s.state.cosmeticEquipped);
|
||||
|
||||
const overlays = SLOT_ORDER
|
||||
.map((slot) => {
|
||||
const cosId = equipped[slot];
|
||||
if (!cosId) return null;
|
||||
return COSMETICS.find((c) => c.id === cosId);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="relative w-[280px] h-[280px] md:w-[320px] md:h-[320px]">
|
||||
{/* Base sprite */}
|
||||
<img
|
||||
src="/svg/tadpole.svg"
|
||||
alt="Têtard"
|
||||
className="w-full h-full object-contain transition-transform duration-100"
|
||||
draggable={false}
|
||||
/>
|
||||
{/* Cosmetic overlays */}
|
||||
{overlays.map((cos) => (
|
||||
<img
|
||||
key={cos!.id}
|
||||
src={cos!.svg}
|
||||
alt={cos!.name}
|
||||
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
Frontend/src/core/cosmetics.ts
Normal file
97
Frontend/src/core/cosmetics.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// cosmetics.ts — Système cosmétique (récompenses achievements + prestige)
|
||||
|
||||
import type { GameState } from "./economy";
|
||||
import { ACHIEVEMENTS } from "../data/achievements";
|
||||
|
||||
export type CosmeticSlot = "hat" | "eyes" | "body" | "tail" | "accessory";
|
||||
|
||||
export interface Cosmetic {
|
||||
id: string;
|
||||
name: string;
|
||||
slot: CosmeticSlot;
|
||||
svg: string; // chemin vers le SVG overlay (/svg/cosmetics/...)
|
||||
source: "achievement" | "prestige";
|
||||
sourceId: string; // achievement id ou "prestige_N"
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CosmeticState {
|
||||
inventory: string[]; // ids des cosmétiques débloqués
|
||||
equipped: Partial<Record<CosmeticSlot, string>>; // slot → cosmetic id
|
||||
}
|
||||
|
||||
export const DEFAULT_COSMETIC_STATE: CosmeticState = {
|
||||
inventory: [],
|
||||
equipped: {},
|
||||
};
|
||||
|
||||
// --- Catalogue des cosmétiques ---
|
||||
|
||||
export const COSMETICS: Cosmetic[] = [
|
||||
// Hat
|
||||
{ id: "crown", name: "Couronne Ancestrale", slot: "hat", svg: "/svg/cosmetics/crown.svg", source: "prestige", sourceId: "prestige_10", description: "10 prestiges — la royauté du marais" },
|
||||
{ id: "cap_swamp", name: "Casquette du Marais", slot: "hat", svg: "/svg/cosmetics/cap-swamp.svg", source: "achievement", sourceId: "industriel", description: "10 générateurs au total" },
|
||||
|
||||
// Eyes
|
||||
{ id: "glasses_savant", name: "Lunettes du Savant", slot: "eyes", svg: "/svg/cosmetics/glasses-savant.svg", source: "prestige", sourceId: "prestige_5", description: "5 prestiges — la sagesse" },
|
||||
{ id: "mask_frog", name: "Masque Grenouille", slot: "eyes", svg: "/svg/cosmetics/mask-frog.svg", source: "achievement", sourceId: "empire", description: "1M têtards — le regard de l'empire" },
|
||||
|
||||
// Body
|
||||
{ id: "cape_algae", name: "Cape d'Algues", slot: "body", svg: "/svg/cosmetics/cape-algae.svg", source: "prestige", sourceId: "prestige_25", description: "25 prestiges — tissée par le marais" },
|
||||
{ id: "armor_scales", name: "Armure d'Écailles", slot: "body", svg: "/svg/cosmetics/armor-scales.svg", source: "achievement", sourceId: "tycoon", description: "100 générateurs — blindage total" },
|
||||
|
||||
// Tail
|
||||
{ id: "flame_tail", name: "Queue Enflammée", slot: "tail", svg: "/svg/cosmetics/flame-tail.svg", source: "prestige", sourceId: "prestige_50", description: "50 prestiges — la traîne de feu" },
|
||||
{ id: "ribbon", name: "Ruban du Nouveau-Né", slot: "tail", svg: "/svg/cosmetics/ribbon.svg", source: "achievement", sourceId: "first_prestige", description: "Premier prestige — le début de tout" },
|
||||
|
||||
// Accessory
|
||||
{ id: "aura_swamp", name: "Aura du Marais", slot: "accessory", svg: "/svg/aura-swamp.svg", source: "achievement", sourceId: "veteran", description: "5 prestiges — l'aura des anciens" },
|
||||
{ id: "particles_gold", name: "Particules Dorées", slot: "accessory", svg: "/svg/cosmetics/particles-gold.svg", source: "prestige", sourceId: "prestige_3", description: "3 prestiges — poussière d'étoiles" },
|
||||
];
|
||||
|
||||
// --- Fonctions cosmétiques ---
|
||||
|
||||
// Vérifie si un cosmétique devrait être débloqué
|
||||
export function shouldUnlockCosmetic(cosmetic: Cosmetic, state: GameState): boolean {
|
||||
if (cosmetic.source === "prestige") {
|
||||
const tier = parseInt(cosmetic.sourceId.replace("prestige_", ""), 10);
|
||||
return state.prestigeCount >= tier;
|
||||
}
|
||||
if (cosmetic.source === "achievement") {
|
||||
const achievement = ACHIEVEMENTS.find((a) => a.id === cosmetic.sourceId);
|
||||
return achievement ? achievement.check(state) : false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calcule les cosmétiques nouvellement débloqués (pas encore dans l'inventaire)
|
||||
export function computeNewUnlocks(state: GameState, cosmeticState: CosmeticState): string[] {
|
||||
return COSMETICS
|
||||
.filter((c) => !cosmeticState.inventory.includes(c.id) && shouldUnlockCosmetic(c, state))
|
||||
.map((c) => c.id);
|
||||
}
|
||||
|
||||
// Équiper un cosmétique (retourne le nouvel état)
|
||||
export function equipCosmetic(cosmeticState: CosmeticState, cosmeticId: string): CosmeticState {
|
||||
const cosmetic = COSMETICS.find((c) => c.id === cosmeticId);
|
||||
if (!cosmetic) return cosmeticState;
|
||||
if (!cosmeticState.inventory.includes(cosmeticId)) return cosmeticState;
|
||||
|
||||
return {
|
||||
...cosmeticState,
|
||||
equipped: { ...cosmeticState.equipped, [cosmetic.slot]: cosmeticId },
|
||||
};
|
||||
}
|
||||
|
||||
// Déséquiper un slot
|
||||
export function unequipSlot(cosmeticState: CosmeticState, slot: CosmeticSlot): CosmeticState {
|
||||
const { [slot]: _, ...rest } = cosmeticState.equipped;
|
||||
return { ...cosmeticState, equipped: rest };
|
||||
}
|
||||
|
||||
// Ajouter des cosmétiques à l'inventaire
|
||||
export function addToInventory(cosmeticState: CosmeticState, ids: string[]): CosmeticState {
|
||||
const newIds = ids.filter((id) => !cosmeticState.inventory.includes(id));
|
||||
if (newIds.length === 0) return cosmeticState;
|
||||
return { ...cosmeticState, inventory: [...cosmeticState.inventory, ...newIds] };
|
||||
}
|
||||
@@ -38,6 +38,10 @@ export interface EvolutionNode {
|
||||
exclusive_with?: string; // id du nœud alternatif (pick one)
|
||||
}
|
||||
|
||||
export interface CosmeticSlotMap {
|
||||
[slot: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
resources: number;
|
||||
clickMultiplier: number;
|
||||
@@ -49,6 +53,8 @@ export interface GameState {
|
||||
ancestralDna: number;
|
||||
evolutionTree: EvolutionNode[];
|
||||
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN)
|
||||
cosmeticInventory: string[]; // ids des cosmétiques débloqués
|
||||
cosmeticEquipped: CosmeticSlotMap; // slot → cosmetic id
|
||||
}
|
||||
|
||||
// --- Arbre d'Évolution ---
|
||||
@@ -387,4 +393,6 @@ export const DEFAULT_STATE: GameState = {
|
||||
ancestralDna: 0,
|
||||
evolutionTree: DEFAULT_EVOLUTION_TREE,
|
||||
lifetimeTadpoles: 0,
|
||||
cosmeticInventory: [],
|
||||
cosmeticEquipped: {},
|
||||
};
|
||||
|
||||
@@ -286,19 +286,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tadpole-sprite {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
background: url("/svg/tadpole.svg") no-repeat center / contain;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.tadpole-sprite {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
.click-zone:active .tadpole-sprite {
|
||||
.click-zone:active img {
|
||||
transform: scale(0.95) rotate(2deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { PrestigePanel } from "../components/PrestigePanel";
|
||||
import { EvolutionTree } from "../components/EvolutionTree";
|
||||
import { MilestoneBar } from "../components/MilestoneBar";
|
||||
import { CockpitHeader } from "../components/CockpitHeader";
|
||||
import { TadpoleSprite } from "../components/TadpoleSprite";
|
||||
import { CosmeticsPanel } from "../components/CosmeticsPanel";
|
||||
import { ACHIEVEMENTS } from "../data/achievements";
|
||||
|
||||
export default function Home() {
|
||||
@@ -127,7 +129,7 @@ export default function Home() {
|
||||
|
||||
{/* Clicker area — centre */}
|
||||
<div className="click-zone" onClick={handleIncrement}>
|
||||
<div className="tadpole-sprite" />
|
||||
<TadpoleSprite />
|
||||
<div className="click-zone-counter">
|
||||
{formatNumber(resources)}
|
||||
</div>
|
||||
@@ -142,6 +144,7 @@ export default function Home() {
|
||||
<div className="gp-sep" />
|
||||
<PrestigePanel />
|
||||
<EvolutionTree />
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" className="achieve-badge">
|
||||
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès
|
||||
</a>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user