From 2a242e97cce04a54af8cd6aa8adf80885dbf5e16 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sat, 28 Mar 2026 12:09:26 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20cosm=C3=A9tiques=20V1=20=E2=80=94=205?= =?UTF-8?q?=20slots=20SVG,=20r=C3=A9compenses=20achievements=20+=20prestig?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Frontend/src/__tests__/cosmetics.test.ts | 127 +++++++++++++++++++++ Frontend/src/components/CosmeticsPanel.tsx | 68 +++++++++++ Frontend/src/components/TadpoleSprite.tsx | 40 +++++++ Frontend/src/core/cosmetics.ts | 97 ++++++++++++++++ Frontend/src/core/economy.ts | 8 ++ Frontend/src/index.css | 14 +-- Frontend/src/pages/Home.jsx | 5 +- Frontend/src/store/useGameStore.ts | 52 ++++++++- 8 files changed, 394 insertions(+), 17 deletions(-) create mode 100644 Frontend/src/__tests__/cosmetics.test.ts create mode 100644 Frontend/src/components/CosmeticsPanel.tsx create mode 100644 Frontend/src/components/TadpoleSprite.tsx create mode 100644 Frontend/src/core/cosmetics.ts diff --git a/Frontend/src/__tests__/cosmetics.test.ts b/Frontend/src/__tests__/cosmetics.test.ts new file mode 100644 index 0000000..e18bb61 --- /dev/null +++ b/Frontend/src/__tests__/cosmetics.test.ts @@ -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"]); + }); + }); +}); diff --git a/Frontend/src/components/CosmeticsPanel.tsx b/Frontend/src/components/CosmeticsPanel.tsx new file mode 100644 index 0000000..e770d7a --- /dev/null +++ b/Frontend/src/components/CosmeticsPanel.tsx @@ -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 = { + 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 ( +
+
+ Cosmétiques + {inventory.length}/{COSMETICS.length} +
+ + {SLOT_ORDER.map((slot) => { + const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot); + if (slotCosmetics.length === 0) return null; + + const equippedId = equipped[slot]; + + return ( +
+ {SLOT_LABELS[slot]} + {slotCosmetics.map((cos) => { + const isEquipped = equippedId === cos.id; + + return ( +
+
+ {cos.name} + {cos.description} +
+ +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/Frontend/src/components/TadpoleSprite.tsx b/Frontend/src/components/TadpoleSprite.tsx new file mode 100644 index 0000000..cc6fca2 --- /dev/null +++ b/Frontend/src/components/TadpoleSprite.tsx @@ -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 ( +
+ {/* Base sprite */} + Têtard + {/* Cosmetic overlays */} + {overlays.map((cos) => ( + {cos!.name} + ))} +
+ ); +} diff --git a/Frontend/src/core/cosmetics.ts b/Frontend/src/core/cosmetics.ts new file mode 100644 index 0000000..c829ab7 --- /dev/null +++ b/Frontend/src/core/cosmetics.ts @@ -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>; // 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] }; +} diff --git a/Frontend/src/core/economy.ts b/Frontend/src/core/economy.ts index a9580de..4403c05 100644 --- a/Frontend/src/core/economy.ts +++ b/Frontend/src/core/economy.ts @@ -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: {}, }; diff --git a/Frontend/src/index.css b/Frontend/src/index.css index e77c071..5c105e0 100755 --- a/Frontend/src/index.css +++ b/Frontend/src/index.css @@ -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); } diff --git a/Frontend/src/pages/Home.jsx b/Frontend/src/pages/Home.jsx index 14cae96..5aeb171 100755 --- a/Frontend/src/pages/Home.jsx +++ b/Frontend/src/pages/Home.jsx @@ -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 */}
-
+
{formatNumber(resources)}
@@ -142,6 +144,7 @@ export default function Home() {
+ {ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès diff --git a/Frontend/src/store/useGameStore.ts b/Frontend/src/store/useGameStore.ts index 596710d..a3381ff 100644 --- a/Frontend/src/store/useGameStore.ts +++ b/Frontend/src/store/useGameStore.ts @@ -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((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((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) => {