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>) =>
|
create: (name: string, stats: Record<string, number>) =>
|
||||||
api.post<Character>('/characters', { name, ...stats }),
|
api.post<Character>('/characters', { name, ...stats }),
|
||||||
me: () => api.get<Character>('/characters/me'),
|
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
|
// Combat
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 } from '../api/endpoints';
|
||||||
import { Bar } from '../components/Bar';
|
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 STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const;
|
||||||
const STAT_LABELS: Record<string, string> = {
|
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() {
|
export function DashboardPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
const { data: char, isLoading, isError } = useQuery({
|
const { data: char, isLoading, isError } = useQuery({
|
||||||
queryKey: ['character'],
|
queryKey: ['character'],
|
||||||
queryFn: characterApi.me,
|
queryFn: characterApi.me,
|
||||||
retry: 1,
|
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 (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement…</div>;
|
||||||
if (isError || !char) return <CreateCharacter />;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -102,8 +171,8 @@ export function DashboardPage() {
|
|||||||
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
|
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
|
||||||
</span>
|
</span>
|
||||||
{(char as any).statPoints > 0 && (
|
{statPoints > 0 && (
|
||||||
<span className="badge badge-gold">+{(char as any).statPoints} pts à répartir</span>
|
<span className="badge badge-gold">+{statPoints} pts à répartir</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,6 +210,18 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
|
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,6 +242,13 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Distributeur de stats */}
|
||||||
|
{statPoints > 0 && (
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<StatDistributor char={char} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Équipement résumé */}
|
{/* Équipement résumé */}
|
||||||
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||||
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
|
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user