feat: stat distribution UI + rest button + xpToNextLevel from backend
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
Dashboard: stat distributor with +/- buttons when statPoints > 0, rest button (+50% HP, -20 endurance) when HP < max, XP bar uses xpToNextLevel from backend instead of local formula. API: distributeStats + rest endpoints added to client.
This commit is contained in:
@@ -17,6 +17,9 @@ export const characterApi = {
|
||||
create: (name: string, stats: Record<string, number>) =>
|
||||
api.post<Character>('/characters', { name, ...stats }),
|
||||
me: () => api.get<Character>('/characters/me'),
|
||||
distributeStats: (stats: Record<string, number>) =>
|
||||
api.post<Character>('/characters/stats', stats),
|
||||
rest: () => api.post<{ hpBefore: number; hpAfter: number; hpMax: number; healed: number }>('/characters/rest'),
|
||||
};
|
||||
|
||||
// Combat
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { characterApi } from '../api/endpoints';
|
||||
import { Bar } from '../components/Bar';
|
||||
import { Zap, Heart, Star, Coins, Sword, Shield } from 'lucide-react';
|
||||
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> = {
|
||||
@@ -73,17 +73,86 @@ function CreateCharacter() {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 = Math.round(100 * Math.pow(char.level, 1.5));
|
||||
const xpNext = (char as any).xpToNextLevel ?? Math.round(100 * Math.pow(char.level, 1.5));
|
||||
const statPoints = (char as any).statPoints ?? 0;
|
||||
const needsHeal = char.hpCurrent < char.hpMax;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -102,8 +171,8 @@ export function DashboardPage() {
|
||||
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
|
||||
</span>
|
||||
{(char as any).statPoints > 0 && (
|
||||
<span className="badge badge-gold">+{(char as any).statPoints} pts à répartir</span>
|
||||
{statPoints > 0 && (
|
||||
<span className="badge badge-gold">+{statPoints} pts à répartir</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,6 +210,18 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
|
||||
</div>
|
||||
{needsHeal && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginTop: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}
|
||||
disabled={restMut.isPending}
|
||||
onClick={() => restMut.mutate()}
|
||||
>
|
||||
<BedDouble size={13} />
|
||||
{restMut.isPending ? 'Repos…' : 'Se reposer (+50% PV, -20 endurance)'}
|
||||
</button>
|
||||
)}
|
||||
{restMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 2 }}>{(restMut.error as Error).message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +242,13 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distributeur de stats */}
|
||||
{statPoints > 0 && (
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<StatDistributor char={char} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Équipement résumé */}
|
||||
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
|
||||
|
||||
Reference in New Issue
Block a user