Files
TetaRdPG/frontend/src/pages/CombatPage.tsx
Tetardtek 9eff6d541e
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
refacto: découpage composants — 5 extractions
- MonsterCard, CombatViews (Log+Multi+History), CreateCharacter
- RarityBadge + RarityDot partagés (Guide, Drawer, pages)
- CombatPage 341→215 lignes (−37%)
- DashboardPage 368→307 lignes (−17%)
- 9 composants dans components/
2026-03-24 23:50:55 +01:00

216 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { combatApi, characterApi } from '../api/endpoints';
import type { Monster, CombatResult, MultiCombatResult } from '../api/types';
import { Swords, Clock, Zap, Heart, Lock } from 'lucide-react';
import { COMBAT_COST, REST_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
import { MonsterCard } from '../components/MonsterCard';
import { CombatLogView, MultiCombatView, HistoryEntry } from '../components/CombatViews';
export function CombatPage() {
const qc = useQueryClient();
const [selectedMonster, setSelectedMonster] = useState<Monster | null>(null);
const [attackType, setAttackType] = useState('melee');
const [lastResult, setLastResult] = useState<CombatResult | null>(null);
const [lastMultiResult, setLastMultiResult] = useState<MultiCombatResult | null>(null);
const [cooldown, setCooldown] = useState(false);
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const endurance = char?.enduranceCurrent ?? 0;
const playerLevel = char?.level ?? 1;
const canFight = endurance >= COMBAT_COST;
const needsHeal = char ? char.hpCurrent < char.hpMax : false;
const canHeal = needsHeal && endurance >= REST_COST;
const healMut = useMutation({
mutationFn: () => characterApi.rest(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
const { data: monsters, isLoading } = useQuery({
queryKey: ['monsters'],
queryFn: combatApi.monsters,
});
const { data: zones } = useQuery({
queryKey: ['zones'],
queryFn: combatApi.zones,
});
const { data: history } = useQuery({
queryKey: ['combatHistory'],
queryFn: combatApi.history,
});
const startCooldown = useCallback(() => {
setCooldown(true);
setTimeout(() => setCooldown(false), 1500);
}, []);
const fight = useMutation({
mutationFn: (count: number = 1) => combatApi.start(selectedMonster!.id, attackType, count),
onSuccess: (result) => {
if (result.mode === 'multi') {
setLastMultiResult(result as MultiCombatResult);
setLastResult(null);
} else {
setLastResult(result as CombatResult);
setLastMultiResult(null);
}
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['combatHistory'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
qc.invalidateQueries({ queryKey: ['materialsInventory'] });
startCooldown();
},
onError: (err: Error) => { toast.error(err.message); startCooldown(); },
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement des monstres</div>;
// Group monsters by zone
const monstersByZone = new Map<string, Monster[]>();
for (const m of (monsters ?? [])) {
const zone = (m as any).zone ?? 'marais';
const list = monstersByZone.get(zone) ?? [];
list.push(m);
monstersByZone.set(zone, list);
}
const ZONE_LABELS = ZONE_INFO;
// Locked zones (zones not in monsters response = locked)
const lockedZones = (zones ?? []).filter((z: any) => !z.unlocked);
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}> Combat</h2>
<div className="grid-2" style={{ marginBottom: '1rem' }}>
{/* Choix monstre par zone */}
<div>
{Array.from(monstersByZone.entries()).map(([zone, zoneMonsters]) => {
const info = ZONE_LABELS[zone] ?? { name: zone, emoji: '📍' };
return (
<div key={zone} style={{ marginBottom: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#9ca3af' }}>
{info.emoji} {info.name}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{zoneMonsters
.sort((a, b) => a.minLevel - b.minLevel)
.map(m => (
<MonsterCard
key={m.id}
m={m}
selected={selectedMonster?.id === m.id}
onSelect={() => setSelectedMonster(m)}
playerLevel={playerLevel}
/>
))}
</div>
</div>
);
})}
{/* Zones verrouillées */}
{lockedZones.map((z: any) => (
<div key={z.id} className="card" style={{ marginBottom: '0.5rem', opacity: 0.4, textAlign: 'center', padding: '1rem' }}>
<Lock size={16} color="#6b7a99" style={{ display: 'inline', marginRight: 6 }} />
<span style={{ fontSize: 13, color: '#6b7a99' }}>
{z.emoji} {z.name} Complétez l'arc précédent pour débloquer
</span>
</div>
))}
</div>
{/* Panneau droite */}
<div>
{/* Type d'attaque */}
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Type d'attaque
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: '1rem' }}>
{ATTACK_TYPES.map(a => (
<div
key={a.id}
className={`card card-hover ${attackType === a.id ? 'card-gold' : ''}`}
onClick={() => setAttackType(a.id)}
style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}
>
<span style={{ fontSize: 18 }}>{a.emoji}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13, color: attackType === a.id ? '#f4c94e' : '#dce4f0' }}>{a.label}</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{a.stat}</div>
</div>
</div>
))}
</div>
{/* Soins rapide */}
{needsHeal && (
<button
className="btn btn-ghost"
style={{ width: '100%', marginBottom: 8, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center', opacity: canHeal ? 1 : 0.5 }}
disabled={healMut.isPending || !canHeal}
onClick={() => healMut.mutate()}
>
<Heart size={12} color="#e84040" />
{healMut.isPending ? 'Soins' : `Soins (+50% PV, ${REST_COST}⚡)`}
<span style={{ color: '#6b7a99', fontSize: 11 }}>— {char!.hpCurrent}/{char!.hpMax} PV</span>
</button>
)}
{/* Coût endurance */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 6, fontSize: 12, color: canFight ? '#5ba4f5' : '#e84040' }}>
<Zap size={12} /> Coût : {COMBAT_COST} endurance — Disponible : {endurance}
{canFight && <span style={{ color: '#6b7a99' }}>({Math.floor(endurance / COMBAT_COST)} combats)</span>}
</div>
{/* Boutons combattre */}
<div style={{ display: 'flex', gap: 6 }}>
{[1, 5, 10].map(n => (
<button
key={n}
className="btn btn-red"
style={{ flex: n === 1 ? 2 : 1, fontSize: n === 1 ? 14 : 12, padding: '0.75rem 0.5rem', opacity: canFight && !cooldown ? 1 : 0.5 }}
disabled={!selectedMonster || fight.isPending || !canFight || cooldown}
onClick={() => fight.mutate(n)}
>
{fight.isPending ? (
<span><Swords size={14} style={{ display: 'inline', marginRight: 4 }} />Combat…</span>
) : (
n === 1
? <span>⚔️ Combat ({COMBAT_COST}⚡)</span>
: <span>×{n} ({COMBAT_COST * n}⚡)</span>
)}
</button>
))}
</div>
{fight.isError && (
<p style={{ color: '#e84040', fontSize: 12, marginTop: 8 }}>{(fight.error as Error).message}</p>
)}
{/* Historique récent */}
{history && history.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', display:'flex', alignItems:'center', gap:4 }}>
<Clock size={11} /> Historique récent
</p>
<div className="card" style={{ padding: '0.75rem' }}>
{history.slice(0, 10).map(h => <HistoryEntry key={h.id} h={h} />)}
</div>
</div>
)}
</div>
</div>
{/* Résultat du dernier combat */}
{lastMultiResult && <MultiCombatView result={lastMultiResult} />}
{lastResult && <CombatLogView result={lastResult} />}
</div>
);
}