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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user