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