All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
- 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/
216 lines
8.8 KiB
TypeScript
216 lines
8.8 KiB
TypeScript
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>
|
||
);
|
||
}
|