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).
310 lines
14 KiB
TypeScript
310 lines
14 KiB
TypeScript
import { useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { characterApi, itemApi } from '../api/endpoints';
|
||
import { Bar } from '../components/Bar';
|
||
import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react';
|
||
|
||
const STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const;
|
||
const STAT_LABELS: Record<string, string> = {
|
||
force: 'Force', agilite: 'Agilité', intelligence: 'Intelligence', chance: 'Chance', vitalite: 'Vitalité',
|
||
};
|
||
|
||
function CreateCharacter() {
|
||
const qc = useQueryClient();
|
||
const [name, setName] = useState('');
|
||
const [pts, setPts] = useState<Record<string, number>>({ force:1, agilite:1, intelligence:1, chance:1, vitalite:1 });
|
||
const used = Object.values(pts).reduce((a, b) => a + b, 0) - 5;
|
||
const remaining = 5 - used;
|
||
|
||
const mut = useMutation({
|
||
mutationFn: () => characterApi.create(name, pts),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
|
||
});
|
||
|
||
const adjust = (stat: string, delta: number) => {
|
||
const next = (pts[stat] ?? 1) + delta;
|
||
if (next < 1 || next > 10) return;
|
||
if (delta > 0 && remaining <= 0) return;
|
||
setPts(p => ({ ...p, [stat]: next }));
|
||
};
|
||
|
||
return (
|
||
<div style={{ maxWidth: 420, margin: '4rem auto' }}>
|
||
<div className="card card-gold" style={{ padding: '1.5rem' }}>
|
||
<h2 style={{ margin: '0 0 4px', color: '#f4c94e', fontSize: 20 }}>Créer ton personnage</h2>
|
||
<p style={{ margin: '0 0 1.25rem', color: '#6b7a99', fontSize: 13 }}>
|
||
{remaining > 0 ? `${remaining} point${remaining > 1 ? 's' : ''} à répartir` : 'Tous les points répartis'}
|
||
</p>
|
||
|
||
<input
|
||
className="input-rpg"
|
||
placeholder="Nom du personnage"
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
style={{ marginBottom: '1rem' }}
|
||
maxLength={30}
|
||
/>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: '1.25rem' }}>
|
||
{STATS.map(s => (
|
||
<div key={s} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<span style={{ fontSize: 13, width: 110, color: '#dce4f0' }}>{STAT_LABELS[s]}</span>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<button className="btn btn-ghost" style={{ padding: '0.15rem 0.5rem', fontSize: 14 }} onClick={() => adjust(s, -1)}>−</button>
|
||
<span style={{ width: 20, textAlign: 'center', fontWeight: 700, color: '#f4c94e' }}>{pts[s]}</span>
|
||
<button className="btn btn-ghost" style={{ padding: '0.15rem 0.5rem', fontSize: 14 }} onClick={() => adjust(s, +1)}>+</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
className="btn btn-gold"
|
||
style={{ width: '100%' }}
|
||
disabled={!name.trim() || remaining !== 0 || mut.isPending}
|
||
onClick={() => mut.mutate()}
|
||
>
|
||
{mut.isPending ? 'Création…' : 'Commencer l\'aventure ⚔️'}
|
||
</button>
|
||
|
||
{mut.isError && <p style={{ color: '#e84040', fontSize: 12, marginTop: 8 }}>{(mut.error as Error).message}</p>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatDistributor({ char }: { char: any }) {
|
||
const qc = useQueryClient();
|
||
const [pts, setPts] = useState<Record<string, number>>({ force: 0, agilite: 0, intelligence: 0, chance: 0, vitalite: 0 });
|
||
const used = Object.values(pts).reduce((a, b) => a + b, 0);
|
||
const remaining = (char.statPoints ?? 0) - used;
|
||
|
||
const mut = useMutation({
|
||
mutationFn: () => characterApi.distributeStats(pts),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['character'] });
|
||
setPts({ force: 0, agilite: 0, intelligence: 0, chance: 0, vitalite: 0 });
|
||
},
|
||
});
|
||
|
||
const adjust = (stat: string, delta: number) => {
|
||
const next = (pts[stat] ?? 0) + delta;
|
||
if (next < 0) return;
|
||
if (delta > 0 && remaining <= 0) return;
|
||
setPts(p => ({ ...p, [stat]: next }));
|
||
};
|
||
|
||
return (
|
||
<div className="card card-gold">
|
||
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#f4c94e' }}>
|
||
Répartir {char.statPoints} point{char.statPoints > 1 ? 's' : ''} de stats
|
||
</p>
|
||
<p style={{ margin: '0 0 0.75rem', fontSize: 11, color: '#6b7a99' }}>
|
||
{remaining > 0 ? `${remaining} restant${remaining > 1 ? 's' : ''}` : 'Prêt à valider'}
|
||
</p>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: '0.75rem' }}>
|
||
{STATS.map(s => (
|
||
<div key={s} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<span style={{ fontSize: 12, color: '#dce4f0', width: 100 }}>
|
||
{STAT_LABELS[s]} <span style={{ color: '#6b7a99' }}>({char[s]})</span>
|
||
</span>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<button className="btn btn-ghost" style={{ padding: '0.1rem 0.4rem', fontSize: 13 }} onClick={() => adjust(s, -1)} disabled={pts[s] <= 0}>−</button>
|
||
<span style={{ width: 20, textAlign: 'center', fontWeight: 700, color: pts[s] > 0 ? '#3ddc84' : '#6b7a99', fontSize: 13 }}>
|
||
{pts[s] > 0 ? `+${pts[s]}` : '0'}
|
||
</span>
|
||
<button className="btn btn-ghost" style={{ padding: '0.1rem 0.4rem', fontSize: 13 }} onClick={() => adjust(s, +1)} disabled={remaining <= 0}>+</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
className="btn btn-gold"
|
||
style={{ width: '100%', fontSize: 13 }}
|
||
disabled={used === 0 || mut.isPending}
|
||
onClick={() => mut.mutate()}
|
||
>
|
||
{mut.isPending ? 'Application…' : `Valider (+${used} pts)`}
|
||
</button>
|
||
|
||
{mut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 6 }}>{(mut.error as Error).message}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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() {
|
||
const qc = useQueryClient();
|
||
const { data: char, isLoading, isError } = useQuery({
|
||
queryKey: ['character'],
|
||
queryFn: characterApi.me,
|
||
retry: 1,
|
||
});
|
||
|
||
const restMut = useMutation({
|
||
mutationFn: () => characterApi.rest(),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
|
||
});
|
||
|
||
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement…</div>;
|
||
if (isError || !char) return <CreateCharacter />;
|
||
|
||
const xpNext = char.xpToNextLevel;
|
||
const statPoints = char.statPoints ?? 0;
|
||
const needsHeal = char.hpCurrent < char.hpMax;
|
||
const endurance = char.enduranceCurrent;
|
||
const REST_COST = 10;
|
||
const COMBAT_COST = 5;
|
||
const FORGE_COST = 10;
|
||
const canRest = endurance >= REST_COST && needsHeal;
|
||
|
||
return (
|
||
<div>
|
||
{/* Header perso */}
|
||
<div className="card card-gold" style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', padding: '1rem 1.25rem' }}>
|
||
<div style={{ fontSize: 48 }}>🐸</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||
<h2 style={{ margin: 0, fontSize: 22, color: '#f4c94e' }}>{char.name}</h2>
|
||
<span style={{ fontSize: 13, color: '#6b7a99' }}>Niveau {char.level}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Coins size={12} color="#f4c94e" /> {char.gold} or
|
||
</span>
|
||
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
|
||
</span>
|
||
{statPoints > 0 && (
|
||
<span className="badge badge-gold">+{statPoints} pts à répartir</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||
{/* Barres vitales */}
|
||
<div className="card">
|
||
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>État</p>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||
<span style={{ fontSize: 12, color: '#e84040', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Heart size={11} /> PV
|
||
</span>
|
||
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.hpCurrent} / {char.hpMax}</span>
|
||
</div>
|
||
<Bar value={char.hpCurrent} max={char.hpMax} type="hp" showValues={false} />
|
||
</div>
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||
<span style={{ fontSize: 12, color: '#5ba4f5', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Zap size={11} /> Endurance
|
||
</span>
|
||
<span style={{ fontSize: 11, color: '#6b7a99' }}>{endurance} / {char.enduranceMax}</span>
|
||
</div>
|
||
<Bar value={endurance} max={char.enduranceMax} type="end" showValues={false} />
|
||
</div>
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||
<span style={{ fontSize: 12, color: '#a78bfa', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<Star size={11} /> XP
|
||
</span>
|
||
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.xp} / {xpNext}</span>
|
||
</div>
|
||
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
|
||
</div>
|
||
{/* Budget endurance */}
|
||
<div style={{ marginTop: 8, padding: '6px 8px', background: '#111620', borderRadius: 6, fontSize: 11, color: '#6b7a99' }}>
|
||
<span style={{ fontWeight: 700, color: '#5ba4f5' }}>⚡ Budget :</span>
|
||
{' '}{Math.floor(endurance / COMBAT_COST)} combats
|
||
{' · '}{Math.floor(endurance / FORGE_COST)} forges
|
||
{' · '}{Math.floor(endurance / REST_COST)} soins
|
||
</div>
|
||
|
||
{needsHeal && (
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ marginTop: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center', opacity: canRest ? 1 : 0.5 }}
|
||
disabled={restMut.isPending || !canRest}
|
||
onClick={() => restMut.mutate()}
|
||
>
|
||
<BedDouble size={13} />
|
||
{restMut.isPending ? 'Soins…' : `Soins (+50% PV, ${REST_COST}⚡)`}
|
||
</button>
|
||
)}
|
||
{needsHeal && !canRest && endurance < REST_COST && (
|
||
<p style={{ fontSize: 10, color: '#e84040', textAlign: 'center', margin: '2px 0 0' }}>Endurance insuffisante pour les soins</p>
|
||
)}
|
||
{restMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 2 }}>{(restMut.error as Error).message}</p>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div className="card">
|
||
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Statistiques</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px' }}>
|
||
{STATS.map(s => (
|
||
<div key={s} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span style={{ fontSize: 12, color: '#6b7a99' }}>{STAT_LABELS[s]}</span>
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char[s]}</span>
|
||
</div>
|
||
))}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span style={{ fontSize: 12, color: '#e84040', display:'flex', alignItems:'center', gap:3 }}><Heart size={10}/> PV max</span>
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char.hpMax}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Distributeur de stats */}
|
||
{statPoints > 0 && (
|
||
<div style={{ gridColumn: '1 / -1' }}>
|
||
<StatDistributor char={char} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Équipement résumé */}
|
||
<CombatStatsPanel char={char} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|