Files
TetaRdPG/frontend/src/pages/TurnCombatPage.tsx
Tetardtek 2001c867cb
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
feat: écran choix voie du Dao — s'affiche avant le premier combat tactique
2026-03-25 01:33:19 +01:00

486 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { combatApi, turnCombatApi, characterApi } from '../api/endpoints';
import type { Monster, TurnResult, TurnBuff } from '../api/types';
import { Swords, Sparkles, PackageOpen, ArrowLeft, Zap, Shield, Skull, Trophy, Users } from 'lucide-react';
import { COMBAT_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
import { MonsterCard } from '../components/MonsterCard';
type Phase = 'select' | 'combat' | 'result';
export function TurnCombatPage() {
const qc = useQueryClient();
const [phase, setPhase] = useState<Phase>('select');
const [selectedMonster, setSelectedMonster] = useState<Monster | null>(null);
const [attackType, setAttackType] = useState('melee');
const [combat, setCombat] = useState<TurnResult | null>(null);
const [companion, setCompanion] = useState<'mira' | 'vell' | null>(null);
const [spellMenuOpen, setSpellMenuOpen] = useState(false);
const logRef = useRef<HTMLDivElement>(null);
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const { data: monsters } = useQuery({ queryKey: ['monsters'], queryFn: combatApi.monsters });
const { data: zones } = useQuery({ queryKey: ['zones'], queryFn: combatApi.zones });
const { data: spells } = useQuery({ queryKey: ['turnSpells'], queryFn: turnCombatApi.unlockedSpells });
const { data: daoPaths } = useQuery({ queryKey: ['daoPaths'], queryFn: turnCombatApi.dao });
const hasDaoPath = daoPaths && daoPaths.length > 0 && daoPaths.some((p: any) => p.isPrimary || p.is_primary);
const chooseDaoMut = useMutation({
mutationFn: (path: string) => turnCombatApi.chooseDaoPath(path),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['daoPaths'] });
qc.invalidateQueries({ queryKey: ['turnSpells'] });
toast.success('Voie du Dao choisie !');
},
onError: (err: Error) => toast.error(err.message),
});
const endurance = char?.enduranceCurrent ?? 0;
const canFight = endurance >= COMBAT_COST;
// Scroll log vers le bas
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [combat?.events]);
// --- Start combat ---
const startMut = useMutation({
mutationFn: () => turnCombatApi.start(selectedMonster!.id, attackType, companion),
onSuccess: (result) => {
setCombat(result);
setPhase('combat');
setSpellMenuOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
// --- Submit action ---
const actionMut = useMutation({
mutationFn: (params: { type: string; spellId?: string }) =>
turnCombatApi.action(combat!.sessionId, params.type, params.spellId),
onSuccess: (result) => {
setCombat(result);
setSpellMenuOpen(false);
if (result.status === 'finished') {
setPhase('result');
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['combatHistory'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
}
},
onError: (err: Error) => toast.error(err.message),
});
const doAction = (type: string, spellId?: string) => {
if (actionMut.isPending) return;
actionMut.mutate({ type, spellId });
};
// ========== PHASE: CHOOSE DAO PATH ==========
if (!hasDaoPath) {
const paths = [
{ id: 'ecoute', name: 'Écoute', color: '#88c8e8', icon: '👁️', archetype: 'Le stratège',
desc: 'Perception du flux, chant offensif, ancrage mémoriel. Tu deviens ce que Gorn t\'a appris : observer, comprendre.',
spell: 'Perception du Flux (révèle faiblesses, +20% dégâts)' },
{ id: 'resonance', name: 'Résonance', color: '#f4c94e', icon: '💪', archetype: 'Le protecteur',
desc: 'Onde de choc, bouclier, contre-attaque. Tu deviens ce que Vell a appris : la vraie force protège.',
spell: 'Onde de Choc (dégâts AoE, Force ×1.5)' },
{ id: 'harmonie', name: 'Harmonie', color: '#3ddc84', icon: '🎵', archetype: 'L\'harmoniste',
desc: 'Chant apaisant, purge, soin d\'équipe. Tu deviens ce que Mira est : le chant qui guérit.',
spell: 'Chant Apaisant (soin Int ×2 + 10% HP max)' },
];
return (
<div>
<h2 style={{ margin: '0 0 0.5rem', color: '#d4af37', fontSize: 20 }}>Le Dao du Courant s'éveille</h2>
<p style={{ color: '#9ca3af', fontSize: 13, margin: '0 0 1.5rem', lineHeight: 1.6 }}>
Gorn est parti. Le Serment est prêté. Le courant coule en toi.<br />
<strong style={{ color: '#dce4f0' }}>Quelle voie du Dao vas-tu suivre ?</strong>
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
{paths.map(p => (
<button
key={p.id}
onClick={() => chooseDaoMut.mutate(p.id)}
disabled={chooseDaoMut.isPending}
className="card"
style={{
padding: '1rem', cursor: 'pointer', border: '2px solid transparent',
textAlign: 'left', transition: 'border-color 0.2s',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = p.color)}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'transparent')}
>
<div style={{ fontSize: 28, marginBottom: 8 }}>{p.icon}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: p.color }}>{p.name}</div>
<div style={{ fontSize: 11, color: '#6b7a99', marginBottom: 8 }}>{p.archetype}</div>
<div style={{ fontSize: 12, color: '#9ca3af', lineHeight: 1.5, marginBottom: 10 }}>{p.desc}</div>
<div style={{ fontSize: 11, color: '#dce4f0', padding: '6px 8px', background: '#1e2535', borderRadius: 6 }}>
✨ Sort offert : {p.spell}
</div>
</button>
))}
</div>
<p style={{ color: '#6b7a99', fontSize: 11, marginTop: 12, textAlign: 'center' }}>
Tu pourras explorer les autres voies plus tard — ta voie principale progresse plus vite.
</p>
</div>
);
}
// ========== PHASE: SELECT ==========
if (phase === 'select') {
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 lockedZones = (zones ?? []).filter((z: any) => !z.unlocked);
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Swords size={18} style={{ display: 'inline', marginRight: 6 }} />
Combat Tactique
</h2>
<div className="grid-2" style={{ marginBottom: '1rem' }}>
<div>
{Array.from(monstersByZone.entries()).map(([zone, zoneMonsters]) => {
const info = ZONE_INFO[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={char?.level ?? 1}
/>
))}
</div>
</div>
);
})}
{lockedZones.map((z: any) => (
<div key={z.id} className="card" style={{ marginBottom: '0.5rem', opacity: 0.4, textAlign: 'center', padding: '1rem' }}>
<span style={{ fontSize: 13, color: '#6b7a99' }}>{z.emoji} {z.name} — Verrouillee</span>
</div>
))}
</div>
<div>
<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>
{/* Compagnon */}
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
<Users size={11} style={{ display: 'inline', marginRight: 4 }} />
Compagnon (optionnel)
</p>
<div style={{ display: 'flex', gap: 6, marginBottom: '1rem' }}>
{[
{ id: null as 'mira' | 'vell' | null, label: 'Solo', emoji: '🐸', desc: '' },
{ id: 'mira' as const, label: 'Mira', emoji: '🌊', desc: 'Support — heal + buff' },
{ id: 'vell' as const, label: 'Vell', emoji: '🪨', desc: 'Tank — taunt + DPS' },
].map(c => (
<div
key={c.label}
className={`card card-hover ${companion === c.id ? 'card-gold' : ''}`}
onClick={() => setCompanion(c.id)}
style={{ flex: 1, cursor: 'pointer', textAlign: 'center', padding: '0.5rem' }}
>
<div style={{ fontSize: 20 }}>{c.emoji}</div>
<div style={{ fontWeight: 700, fontSize: 12, color: companion === c.id ? '#f4c94e' : '#dce4f0' }}>{c.label}</div>
{c.desc && <div style={{ fontSize: 10, color: '#6b7a99' }}>{c.desc}</div>}
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 8, fontSize: 12, color: canFight ? '#5ba4f5' : '#e84040' }}>
<Zap size={12} /> Cout : {COMBAT_COST} endurance Dispo : {endurance}
</div>
<button
className="btn btn-red"
style={{ width: '100%', fontSize: 14, padding: '0.75rem', opacity: canFight && selectedMonster ? 1 : 0.5 }}
disabled={!selectedMonster || startMut.isPending || !canFight}
onClick={() => startMut.mutate()}
>
{startMut.isPending ? 'Lancement...' : `Lancer le combat tactique (${COMBAT_COST} endurance)`}
</button>
</div>
</div>
</div>
);
}
// ========== PHASE: COMBAT ==========
if (phase === 'combat' && combat) {
const playerHpPct = Math.round((combat.playerHp / combat.playerHpMax) * 100);
const monsterHpPct = Math.round((combat.monsterHp / combat.monsterHpMax) * 100);
const manaPct = Math.round((combat.playerMana / combat.playerManaMax) * 100);
const isActing = actionMut.isPending;
return (
<div>
<h2 style={{ margin: '0 0 0.5rem', color: '#f4c94e', fontSize: 16 }}>
Tour {combat.round}
</h2>
{/* Combatants */}
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
{/* Player */}
<div className="card" style={{ flex: 1, padding: '0.75rem' }}>
<div style={{ fontWeight: 700, fontSize: 14, color: '#3ddc84', marginBottom: 4 }}>
{combat.playerName}
</div>
<BarDisplay label="HP" value={combat.playerHp} max={combat.playerHpMax} pct={playerHpPct} color="#3ddc84" />
<BarDisplay label="MP" value={combat.playerMana} max={combat.playerManaMax} pct={manaPct} color="#5ba4f5" />
<BuffList buffs={combat.activeBuffs} debuffs={combat.activeDebuffs} />
</div>
{/* Companion */}
{combat.companion && (
<div className="card" style={{ flex: 1, padding: '0.75rem', opacity: combat.companion.hpCurrent <= 0 ? 0.4 : 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: combat.companion.type === 'mira' ? '#5ba4f5' : '#f4c94e', marginBottom: 4 }}>
{combat.companion.type === 'mira' ? '🌊' : '🪨'} {combat.companion.name}
{combat.companion.hpCurrent <= 0 && ' (KO)'}
</div>
<BarDisplay
label="HP"
value={combat.companion.hpCurrent}
max={combat.companion.hpMax}
pct={Math.round((combat.companion.hpCurrent / combat.companion.hpMax) * 100)}
color={combat.companion.type === 'mira' ? '#5ba4f5' : '#f4c94e'}
/>
<BarDisplay
label="MP"
value={combat.companion.manaCurrent}
max={combat.companion.manaMax}
pct={Math.round((combat.companion.manaCurrent / combat.companion.manaMax) * 100)}
color="#a78bfa"
/>
<BuffList buffs={combat.companion.activeBuffs} debuffs={combat.companion.activeDebuffs} />
</div>
)}
{/* Monster */}
<div className="card" style={{ flex: 1, padding: '0.75rem' }}>
<div style={{ fontWeight: 700, fontSize: 14, color: '#e84040', marginBottom: 4 }}>
{combat.monsterName}
</div>
<BarDisplay label="HP" value={combat.monsterHp} max={combat.monsterHpMax} pct={monsterHpPct} color="#e84040" />
<BuffList buffs={combat.monsterBuffs} debuffs={combat.monsterDebuffs} />
</div>
</div>
{/* Log */}
<div
ref={logRef}
className="card"
style={{ padding: '0.75rem', marginBottom: 12, maxHeight: 200, overflowY: 'auto', fontSize: 12 }}
>
{combat.events.length === 0 && (
<p style={{ color: '#6b7a99', margin: 0 }}>Le combat commence...</p>
)}
{combat.events.map((e, i) => (
<div key={i} style={{ marginBottom: 2, color: eventColor(e.actor, combat!) }}>
<span style={{ color: '#6b7a99' }}>[T{e.round}]</span> {e.detail}
</div>
))}
</div>
{/* Actions */}
{!spellMenuOpen ? (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-red" style={{ flex: 2 }} disabled={isActing} onClick={() => doAction('attack')}>
<Swords size={14} style={{ display: 'inline', marginRight: 4 }} />
Attaque
</button>
<button
className="btn btn-blue"
style={{ flex: 2 }}
disabled={isActing || !spells?.length}
onClick={() => setSpellMenuOpen(true)}
>
<Sparkles size={14} style={{ display: 'inline', marginRight: 4 }} />
Sorts
</button>
<button className="btn btn-ghost" style={{ flex: 1 }} disabled={isActing} onClick={() => doAction('item')}>
<PackageOpen size={14} style={{ display: 'inline', marginRight: 4 }} />
Items
</button>
<button className="btn btn-ghost" style={{ flex: 1 }} disabled={isActing} onClick={() => doAction('flee')}>
<ArrowLeft size={14} style={{ display: 'inline', marginRight: 4 }} />
Fuir
</button>
</div>
) : (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(spells ?? []).map(spell => {
const cd = combat.spellCooldowns[spell.id] ?? 0;
const notEnoughMana = combat.playerMana < spell.manaCost;
const disabled = isActing || cd > 0 || notEnoughMana;
return (
<button
key={spell.id}
className={`card card-hover ${disabled ? '' : 'card-gold'}`}
style={{ textAlign: 'left', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, padding: '0.5rem 0.75rem' }}
disabled={disabled}
onClick={() => !disabled && doAction('spell', spell.id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ fontWeight: 700, fontSize: 13, color: pathColor(spell.path) }}>{spell.name}</span>
<span style={{ fontSize: 11, color: '#6b7a99', marginLeft: 8 }}>{spell.manaCost} MP</span>
</div>
<div style={{ fontSize: 11 }}>
{cd > 0 ? (
<span style={{ color: '#e84040' }}>CD: {cd}</span>
) : (
<span style={{ color: '#3ddc84' }}>PRET</span>
)}
</div>
</div>
<div style={{ fontSize: 11, color: '#6b7a99', marginTop: 2 }}>{spell.description}</div>
</button>
);
})}
</div>
<button
className="btn btn-ghost"
style={{ width: '100%', marginTop: 8, fontSize: 12 }}
onClick={() => setSpellMenuOpen(false)}
>
Retour
</button>
</div>
)}
</div>
);
}
// ========== PHASE: RESULT ==========
if (phase === 'result' && combat) {
const won = combat.winner === 'player';
return (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div style={{ fontSize: 48, marginBottom: 8 }}>
{won ? <Trophy size={48} color="#f4c94e" /> : <Skull size={48} color="#e84040" />}
</div>
<h2 style={{ color: won ? '#f4c94e' : '#e84040', fontSize: 24, margin: '0 0 8px' }}>
{won ? 'Victoire !' : 'Defaite...'}
</h2>
<p style={{ color: '#6b7a99', fontSize: 14, margin: '0 0 16px' }}>
Combat termine en {combat.round} tour{combat.round > 1 ? 's' : ''}
</p>
{won && combat.rewards && (
<div className="card" style={{ display: 'inline-block', padding: '1rem 2rem', textAlign: 'left', marginBottom: 16 }}>
<div style={{ fontSize: 13, color: '#dce4f0', marginBottom: 4 }}>
+{combat.rewards.xp} XP &nbsp; +{combat.rewards.gold} Or
</div>
{combat.rewards.levelUp && (
<div style={{ fontSize: 14, color: '#f4c94e', fontWeight: 700 }}>
LEVEL UP ! Niveau {combat.rewards.newLevel} (+{combat.rewards.statPointsGained} stat points)
</div>
)}
</div>
)}
<div>
<button
className="btn btn-gold"
style={{ fontSize: 14, padding: '0.75rem 2rem' }}
onClick={() => { setPhase('select'); setCombat(null); }}
>
Retour
</button>
</div>
</div>
);
}
return null;
}
// ========== Sous-composants ==========
function BarDisplay({ label, value, max, pct, color }: { label: string; value: number; max: number; pct: number; color: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#6b7a99', marginBottom: 2 }}>
<span>{label}</span>
<span>{value}/{max}</span>
</div>
<div style={{ height: 6, background: '#1a1f2e', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 3, transition: 'width 0.3s' }} />
</div>
</div>
);
}
function BuffList({ buffs, debuffs }: { buffs: TurnBuff[]; debuffs: TurnBuff[] }) {
if (!buffs.length && !debuffs.length) return null;
return (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
{buffs.map(b => (
<span key={b.id} className="badge badge-green" style={{ fontSize: 10 }}>
<Shield size={8} style={{ display: 'inline', marginRight: 2 }} />
{b.name} ({b.remainingTurns})
</span>
))}
{debuffs.map(d => (
<span key={d.id} className="badge badge-red" style={{ fontSize: 10 }}>
{d.name} ({d.remainingTurns})
</span>
))}
</div>
);
}
function eventColor(actor: string, combat: TurnResult): string {
if (actor === combat.playerName) return '#3ddc84';
if (actor === combat.monsterName) return '#e84040';
if (actor === 'Mira') return '#5ba4f5';
if (actor === 'Vell') return '#f4c94e';
return '#dce4f0';
}
function pathColor(path: string): string {
switch (path) {
case 'ecoute': return '#5ba4f5';
case 'resonance': return '#e84040';
case 'harmonie': return '#3ddc84';
default: return '#dce4f0';
}
}