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

@@ -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"]);
});
});
});

View 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>
);
}

View 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>
);
}

View 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] };
}

View File

@@ -38,6 +38,10 @@ export interface EvolutionNode {
exclusive_with?: string; // id du nœud alternatif (pick one) exclusive_with?: string; // id du nœud alternatif (pick one)
} }
export interface CosmeticSlotMap {
[slot: string]: string | undefined;
}
export interface GameState { export interface GameState {
resources: number; resources: number;
clickMultiplier: number; clickMultiplier: number;
@@ -49,6 +53,8 @@ export interface GameState {
ancestralDna: number; ancestralDna: number;
evolutionTree: EvolutionNode[]; evolutionTree: EvolutionNode[];
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN) 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 --- // --- Arbre d'Évolution ---
@@ -387,4 +393,6 @@ export const DEFAULT_STATE: GameState = {
ancestralDna: 0, ancestralDna: 0,
evolutionTree: DEFAULT_EVOLUTION_TREE, evolutionTree: DEFAULT_EVOLUTION_TREE,
lifetimeTadpoles: 0, lifetimeTadpoles: 0,
cosmeticInventory: [],
cosmeticEquipped: {},
}; };

View File

@@ -286,19 +286,7 @@
} }
} }
.tadpole-sprite { .click-zone:active img {
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 {
transform: scale(0.95) rotate(2deg); transform: scale(0.95) rotate(2deg);
} }

View File

@@ -10,6 +10,8 @@ import { PrestigePanel } from "../components/PrestigePanel";
import { EvolutionTree } from "../components/EvolutionTree"; import { EvolutionTree } from "../components/EvolutionTree";
import { MilestoneBar } from "../components/MilestoneBar"; import { MilestoneBar } from "../components/MilestoneBar";
import { CockpitHeader } from "../components/CockpitHeader"; import { CockpitHeader } from "../components/CockpitHeader";
import { TadpoleSprite } from "../components/TadpoleSprite";
import { CosmeticsPanel } from "../components/CosmeticsPanel";
import { ACHIEVEMENTS } from "../data/achievements"; import { ACHIEVEMENTS } from "../data/achievements";
export default function Home() { export default function Home() {
@@ -127,7 +129,7 @@ export default function Home() {
{/* Clicker area — centre */} {/* Clicker area — centre */}
<div className="click-zone" onClick={handleIncrement}> <div className="click-zone" onClick={handleIncrement}>
<div className="tadpole-sprite" /> <TadpoleSprite />
<div className="click-zone-counter"> <div className="click-zone-counter">
{formatNumber(resources)} {formatNumber(resources)}
</div> </div>
@@ -142,6 +144,7 @@ export default function Home() {
<div className="gp-sep" /> <div className="gp-sep" />
<PrestigePanel /> <PrestigePanel />
<EvolutionTree /> <EvolutionTree />
<CosmeticsPanel />
<a href="/achievements" className="achieve-badge"> <a href="/achievements" className="achieve-badge">
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès {ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès
</a> </a>

View File

@@ -18,6 +18,14 @@ import {
computeOfflineGains, computeOfflineGains,
offlineEfficiency, offlineEfficiency,
} from "../core/economy"; } 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 SAVE_KEY = "clickerz_state";
const OFFLINE_THRESHOLD = 60_000; // 60s — same as economy.ts const OFFLINE_THRESHOLD = 60_000; // 60s — same as economy.ts
@@ -27,8 +35,10 @@ function loadLocalState(): GameState {
const raw = localStorage.getItem(SAVE_KEY); const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
const saved = JSON.parse(raw) as GameState; 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.lastOnline) saved.lastOnline = saved.lastTick;
if (!saved.cosmeticInventory) saved.cosmeticInventory = [];
if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {};
return applyIdleGains(saved, Date.now()); return applyIdleGains(saved, Date.now());
} catch { } catch {
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
@@ -67,6 +77,8 @@ interface GameStore {
buyNode: (nodeId: string) => void; buyNode: (nodeId: string) => void;
prestige: () => void; prestige: () => void;
resetTree: () => void; resetTree: () => void;
equipCosmetic: (cosmeticId: string) => void;
unequipCosmetic: (slot: CosmeticSlot) => void;
reset: () => void; reset: () => void;
loadFromServer: (serverState: GameState) => void; loadFromServer: (serverState: GameState) => void;
initGuest: () => void; initGuest: () => void;
@@ -75,8 +87,10 @@ interface GameStore {
} }
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } { 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.lastOnline) saved.lastOnline = saved.lastTick;
if (!saved.cosmeticInventory) saved.cosmeticInventory = [];
if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {};
const elapsed = now - saved.lastTick; const elapsed = now - saved.lastTick;
@@ -131,8 +145,18 @@ export const useGameStore = create<GameStore>((set, get) => ({
const now = Date.now(); const now = Date.now();
set((s) => { set((s) => {
const updated = applyIdleGains(s.state, now); const updated = applyIdleGains(s.state, now);
// Mark as actively online
updated.lastOnline = now; 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); saveLocal(updated);
return { return {
state: updated, 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: () => { resetTree: () => {
if (!get().ready) return; if (!get().ready) return;
set((s) => { set((s) => {