feat: vente items + stats combat avec équipement + forge visible
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s

Inventaire: bouton Vendre sur items non équipés (40% du prix d'achat).
Stats forge visibles: "+5 ATK (3+2)" montre base + bonus forge.
Dashboard combat: attaque/défense calculés avec arme+armure+forge équipées.
10 side quests Égouts seedées (level 5-7).
This commit is contained in:
2026-03-24 18:58:15 +01:00
parent 9aadc326e1
commit bf896a797f
2 changed files with 70 additions and 26 deletions

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi } from '../api/endpoints'; import { characterApi, itemApi } from '../api/endpoints';
import { Bar } from '../components/Bar'; import { Bar } from '../components/Bar';
import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react'; import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react';
@@ -134,6 +134,42 @@ function StatDistributor({ char }: { char: any }) {
); );
} }
function CombatStatsPanel({ char }: { char: any }) {
const { data: inventory } = useQuery({ queryKey: ['inventory'], queryFn: itemApi.inventory });
const weapon = inventory?.find((ci: any) => ci.equipped && ci.item.type === 'weapon');
const armor = inventory?.find((ci: any) => ci.equipped && ci.item.type === 'armor');
const weaponATK = weapon ? weapon.item.attackBonus + weapon.forgeLevel * 2 : 0;
const armorDEF = armor ? armor.item.defenseBonus + armor.forgeLevel * 2 : 0;
const baseDmg = 3 + weaponATK + Math.floor(char.force * 1.5);
return (
<div className="card" style={{ gridColumn: '1 / -1' }}>
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Sword size={14} color="#f4c94e" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Attaque : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{baseDmg}</span>
{weapon && <span style={{ fontSize: 10, color: '#6b7a99' }}>({weapon.item.name} {weapon.forgeLevel > 0 ? `+${weapon.forgeLevel}` : ''})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Shield size={14} color="#5ba4f5" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Défense : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{armorDEF}</span>
{armor && <span style={{ fontSize: 10, color: '#6b7a99' }}>({armor.item.name} {armor.forgeLevel > 0 ? `+${armor.forgeLevel}` : ''})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Zap size={14} color="#3ddc84" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Critique : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.2).toFixed(1)}%</span>
</div>
</div>
</div>
);
}
export function DashboardPage() { export function DashboardPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { data: char, isLoading, isError } = useQuery({ const { data: char, isLoading, isError } = useQuery({
@@ -266,26 +302,7 @@ export function DashboardPage() {
)} )}
{/* Équipement résumé */} {/* Équipement résumé */}
<div className="card" style={{ gridColumn: '1 / -1' }}> <CombatStatsPanel char={char} />
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
<div style={{ display: 'flex', gap: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Sword size={14} color="#f4c94e" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Attaque : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{Math.floor(char.force * 1.5)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Shield size={14} color="#5ba4f5" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Critique : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.2).toFixed(1)}%</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Zap size={14} color="#3ddc84" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Esquive : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.1).toFixed(1)}%</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,17 +1,26 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { itemApi, materialApi } from '../api/endpoints'; import { itemApi, materialApi } from '../api/endpoints';
import { api } from '../api/client';
import type { CharacterItem } from '../api/types'; import type { CharacterItem } from '../api/types';
import { Package, Sword, Shield } from 'lucide-react'; import { Package, Sword, Shield, Coins } from 'lucide-react';
const RARITY_LABEL: Record<string, string> = { const RARITY_LABEL: Record<string, string> = {
common: 'Commun', rare: 'Rare', epic: 'Épique', legendary: 'Légendaire', common: 'Commun', rare: 'Rare', epic: 'Épique', legendary: 'Légendaire',
}; };
function ItemCard({ ci, onEquip, onUnequip }: { ci: CharacterItem; onEquip: () => void; onUnequip: () => void }) { function ItemCard({ ci, onEquip, onUnequip, onSell, selling }: {
ci: CharacterItem; onEquip: () => void; onUnequip: () => void; onSell: () => void; selling: boolean;
}) {
const { item } = ci; const { item } = ci;
const forgeBonusATK = item.type === 'weapon' ? ci.forgeLevel * 2 : 0;
const forgeBonusDEF = item.type === 'armor' ? ci.forgeLevel * 2 : 0;
const totalATK = item.attackBonus + forgeBonusATK;
const totalDEF = item.defenseBonus + forgeBonusDEF;
const sellPrice = Math.floor((item as any).buyPrice * 0.4) || 0;
const bonuses = [ const bonuses = [
item.attackBonus && `+${item.attackBonus} ATK`, totalATK > 0 && `+${totalATK} ATK${forgeBonusATK > 0 ? ` (${item.attackBonus}+${forgeBonusATK})` : ''}`,
item.defenseBonus && `+${item.defenseBonus} DEF`, totalDEF > 0 && `+${totalDEF} DEF${forgeBonusDEF > 0 ? ` (${item.defenseBonus}+${forgeBonusDEF})` : ''}`,
item.forceBonus && `+${item.forceBonus} FOR`, item.forceBonus && `+${item.forceBonus} FOR`,
item.agiliteBonus && `+${item.agiliteBonus} AGI`, item.agiliteBonus && `+${item.agiliteBonus} AGI`,
item.intelligenceBonus && `+${item.intelligenceBonus} INT`, item.intelligenceBonus && `+${item.intelligenceBonus} INT`,
@@ -37,11 +46,17 @@ function ItemCard({ ci, onEquip, onUnequip }: { ci: CharacterItem; onEquip: () =
</div> </div>
</div> </div>
{bonuses && <div style={{ fontSize: 11, color: '#3ddc84', marginBottom: 8 }}>{bonuses}</div>} {bonuses && <div style={{ fontSize: 11, color: '#3ddc84', marginBottom: 8 }}>{bonuses}</div>}
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{!ci.equipped {!ci.equipped
? <button className="btn btn-ghost" style={{ fontSize: 11, padding: '0.2rem 0.6rem' }} onClick={onEquip}>Équiper</button> ? <button className="btn btn-ghost" style={{ fontSize: 11, padding: '0.2rem 0.6rem' }} onClick={onEquip}>Équiper</button>
: <button className="btn btn-ghost" style={{ fontSize: 11, padding: '0.2rem 0.6rem' }} onClick={onUnequip}>Déséquiper</button> : <button className="btn btn-ghost" style={{ fontSize: 11, padding: '0.2rem 0.6rem' }} onClick={onUnequip}>Déséquiper</button>
} }
{!ci.equipped && sellPrice > 0 && (
<button className="btn btn-ghost" style={{ fontSize: 10, padding: '0.15rem 0.5rem', color: '#6b7a99' }}
disabled={selling} onClick={onSell}>
<Coins size={10} style={{ display: 'inline', marginRight: 3 }} />{selling ? '...' : `Vendre (${sellPrice}💰)`}
</button>
)}
</div> </div>
</div> </div>
); );
@@ -70,6 +85,14 @@ export function InventoryPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['inventory'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['inventory'] }),
}); });
const sellMut = useMutation({
mutationFn: (charItemId: string) => api.post<any>(`/shop/sell/${charItemId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inventory'] });
qc.invalidateQueries({ queryKey: ['character'] });
},
});
if (loadInv || loadMat) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>; if (loadInv || loadMat) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
const weapons = inventory?.filter(ci => ci.item.type === 'weapon') ?? []; const weapons = inventory?.filter(ci => ci.item.type === 'weapon') ?? [];
@@ -99,6 +122,8 @@ export function InventoryPage() {
key={ci.id} ci={ci} key={ci.id} ci={ci}
onEquip={() => equipMut.mutate(ci.id)} onEquip={() => equipMut.mutate(ci.id)}
onUnequip={() => unequipMut.mutate('weapon')} onUnequip={() => unequipMut.mutate('weapon')}
onSell={() => sellMut.mutate(ci.id)}
selling={sellMut.isPending}
/> />
))} ))}
</div> </div>
@@ -117,6 +142,8 @@ export function InventoryPage() {
key={ci.id} ci={ci} key={ci.id} ci={ci}
onEquip={() => equipMut.mutate(ci.id)} onEquip={() => equipMut.mutate(ci.id)}
onUnequip={() => unequipMut.mutate('armor')} onUnequip={() => unequipMut.mutate('armor')}
onSell={() => sellMut.mutate(ci.id)}
selling={sellMut.isPending}
/> />
))} ))}
</div> </div>