Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 23s
- VillagePage: 5 zones (place, arène, quêtes, forge, échoppe) avec ambiance - NPC cards: dialogue résolu par niveau/arc, actions directes (soins, navigation) - Mira heal via POST /characters/rest + toast feedback - Navigation actions → pages existantes (quêtes, boutique, forge, combat) - NpcView type + npcApi endpoint frontend - Route /village + icône Landmark dans la sidebar Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
276 lines
8.4 KiB
TypeScript
276 lines
8.4 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import toast from 'react-hot-toast';
|
|
import { npcApi, characterApi } from '../api/endpoints';
|
|
import type { NpcView } from '../api/types';
|
|
import { Landmark, Heart, Zap, ArrowRight } from 'lucide-react';
|
|
import { REST_COST } from '../constants';
|
|
|
|
// ── Constants ──
|
|
|
|
const ROLE_EMOJI: Record<string, string> = {
|
|
mentor: '🧙',
|
|
companion: '💚',
|
|
merchant: '🛍️',
|
|
quest_giver: '📜',
|
|
sage: '🔮',
|
|
rival: '⚔️',
|
|
};
|
|
|
|
const ROLE_LABELS: Record<string, string> = {
|
|
mentor: 'Mentor',
|
|
companion: 'Compagnon',
|
|
merchant: 'Marchand',
|
|
quest_giver: 'Quêtes',
|
|
sage: 'Sage',
|
|
rival: 'Rival',
|
|
};
|
|
|
|
const ROLE_COLORS: Record<string, string> = {
|
|
mentor: '#f4c94e',
|
|
companion: '#3ddc84',
|
|
merchant: '#5ba4f5',
|
|
quest_giver: '#a78bfa',
|
|
sage: '#a78bfa',
|
|
rival: '#e84040',
|
|
};
|
|
|
|
const ACTION_LABELS: Record<string, { label: string; emoji: string }> = {
|
|
heal: { label: 'Soins', emoji: '🩹' },
|
|
open_quests: { label: 'Voir les quêtes', emoji: '📜' },
|
|
open_shop: { label: 'Voir la boutique', emoji: '🛍️' },
|
|
open_forge: { label: 'Voir la forge', emoji: '🔨' },
|
|
challenge: { label: 'Défier', emoji: '⚔️' },
|
|
};
|
|
|
|
const ACTION_ROUTES: Record<string, string> = {
|
|
open_quests: '/quests',
|
|
open_shop: '/shop',
|
|
open_forge: '/forge',
|
|
challenge: '/combat',
|
|
};
|
|
|
|
const VILLAGE_LOCATIONS: Record<string, { name: string; emoji: string; atmosphere: string }> = {
|
|
village_plaza: {
|
|
name: 'Place du Village',
|
|
emoji: '🌸',
|
|
atmosphere: 'Les nénuphars flottent doucement sous la lumière filtrée. Un air familier résonne.',
|
|
},
|
|
village_arena: {
|
|
name: 'Arène',
|
|
emoji: '🏟️',
|
|
atmosphere: 'L\'écho des combats passés résonne sur les pierres mouillées.',
|
|
},
|
|
village_quests: {
|
|
name: 'Source aux Quêtes',
|
|
emoji: '📜',
|
|
atmosphere: 'Le murmure du savoir coule comme un ruisseau entre les rochers moussus.',
|
|
},
|
|
village_forge: {
|
|
name: 'La Forge',
|
|
emoji: '🔨',
|
|
atmosphere: 'Des étincelles dansent et le métal chante sous les coups du marteau.',
|
|
},
|
|
village_shop: {
|
|
name: 'L\'Échoppe',
|
|
emoji: '🏪',
|
|
atmosphere: 'Des marchandises exotiques s\'étalent sur des feuilles de nénuphar géantes.',
|
|
},
|
|
};
|
|
|
|
const LOCATION_ORDER = ['village_plaza', 'village_arena', 'village_quests', 'village_forge', 'village_shop'];
|
|
|
|
// ── Components ──
|
|
|
|
function NpcCard({ npc, character }: { npc: NpcView; character: any }) {
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
const roleColor = ROLE_COLORS[npc.role] ?? '#6b7a99';
|
|
const roleEmoji = ROLE_EMOJI[npc.role] ?? '🐸';
|
|
const roleLabel = ROLE_LABELS[npc.role] ?? npc.role;
|
|
|
|
const healMut = useMutation({
|
|
mutationFn: () => characterApi.rest(),
|
|
onSuccess: (data) => {
|
|
toast.success(`${npc.name} vous soigne ! +${data.healed} PV`);
|
|
qc.invalidateQueries({ queryKey: ['character'] });
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const endurance = character?.enduranceCurrent ?? 0;
|
|
const needsHeal = character && character.hpCurrent < character.hpMax;
|
|
const canHeal = needsHeal && endurance >= REST_COST;
|
|
|
|
const handleAction = () => {
|
|
if (!npc.action) return;
|
|
if (npc.action === 'heal') {
|
|
healMut.mutate();
|
|
return;
|
|
}
|
|
const route = ACTION_ROUTES[npc.action];
|
|
if (route) navigate(route);
|
|
};
|
|
|
|
const actionInfo = npc.action ? ACTION_LABELS[npc.action] : null;
|
|
|
|
return (
|
|
<div className="card flex-1 min-w-[280px] py-4 px-5">
|
|
{/* Header */}
|
|
<div className="flex items-start gap-3 mb-3">
|
|
<span className="text-[32px] leading-none">{roleEmoji}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-bold text-sm text-rpg-text">{npc.name}</span>
|
|
<span
|
|
className="text-[9px] font-bold px-1.5 py-px rounded uppercase"
|
|
style={{ background: roleColor + '22', color: roleColor }}
|
|
>
|
|
{roleLabel}
|
|
</span>
|
|
</div>
|
|
{npc.lore && (
|
|
<p className="mt-1 mb-0 text-[11px] text-rpg-muted italic line-clamp-2">{npc.lore}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dialogue bubble */}
|
|
<div
|
|
className="rounded-md px-3 py-2.5 mb-3 text-xs text-rpg-text leading-relaxed"
|
|
style={{
|
|
background: '#111620',
|
|
borderLeft: `3px solid ${roleColor}`,
|
|
}}
|
|
>
|
|
« {npc.dialogue} »
|
|
</div>
|
|
|
|
{/* Action */}
|
|
{actionInfo && (
|
|
<div>
|
|
{npc.action === 'heal' ? (
|
|
<div>
|
|
<button
|
|
className={`btn ${canHeal ? 'btn-gold' : 'btn-ghost'} w-full text-xs py-1.5 flex items-center justify-center gap-2`}
|
|
disabled={!canHeal || healMut.isPending}
|
|
onClick={handleAction}
|
|
>
|
|
{healMut.isPending ? (
|
|
'Soins en cours…'
|
|
) : (
|
|
<>
|
|
<Heart size={12} />
|
|
{actionInfo.label}
|
|
<span className="text-[10px] opacity-70 flex items-center gap-0.5">
|
|
({REST_COST} <Zap size={9} />)
|
|
</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
{!needsHeal && (
|
|
<p className="text-[10px] text-rpg-green text-center mt-1.5">Vos PV sont au maximum !</p>
|
|
)}
|
|
{needsHeal && !canHeal && (
|
|
<p className="text-[10px] text-rpg-red text-center mt-1.5">Endurance insuffisante</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="btn btn-ghost w-full text-xs py-1.5 flex items-center justify-center gap-2"
|
|
onClick={handleAction}
|
|
>
|
|
{actionInfo.emoji} {actionInfo.label}
|
|
<ArrowRight size={12} className="opacity-50" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VillageLocation({ locationKey, npcs, character }: {
|
|
locationKey: string;
|
|
npcs: NpcView[];
|
|
character: any;
|
|
}) {
|
|
const loc = VILLAGE_LOCATIONS[locationKey];
|
|
if (!loc || npcs.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mb-6">
|
|
{/* Location header */}
|
|
<div className="mb-3">
|
|
<h3 className="text-sm font-bold text-rpg-text flex items-center gap-2 mb-1">
|
|
<span className="text-base">{loc.emoji}</span>
|
|
{loc.name}
|
|
</h3>
|
|
<p className="text-[11px] text-rpg-muted italic pl-7">{loc.atmosphere}</p>
|
|
</div>
|
|
|
|
{/* NPC cards */}
|
|
<div className="flex gap-3 flex-wrap">
|
|
{npcs.map((npc) => (
|
|
<NpcCard key={npc.id} npc={npc} character={character} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function VillagePage() {
|
|
const { data: npcs, isLoading } = useQuery({
|
|
queryKey: ['npcs'],
|
|
queryFn: npcApi.all,
|
|
});
|
|
|
|
const { data: character } = useQuery({
|
|
queryKey: ['character'],
|
|
queryFn: characterApi.me,
|
|
});
|
|
|
|
if (isLoading) return <div className="p-8 text-rpg-muted">Chargement du village…</div>;
|
|
|
|
// Group NPCs by location
|
|
const byLocation = new Map<string, NpcView[]>();
|
|
for (const npc of (npcs ?? [])) {
|
|
const list = byLocation.get(npc.location) ?? [];
|
|
list.push(npc);
|
|
byLocation.set(npc.location, list);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Village banner */}
|
|
<div className="card card-gold mb-6 py-4 px-5">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Landmark size={20} className="text-rpg-gold" />
|
|
<h2 className="text-lg font-bold text-rpg-gold m-0">Le Village</h2>
|
|
</div>
|
|
<p className="text-xs text-rpg-muted m-0">
|
|
L'étang murmure doucement. Les grenouilles vaquent à leurs occupations.
|
|
Un lieu de repos, de rencontres et de préparation avant la prochaine aventure.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Location sections */}
|
|
{LOCATION_ORDER.map((locKey) => (
|
|
<VillageLocation
|
|
key={locKey}
|
|
locationKey={locKey}
|
|
npcs={byLocation.get(locKey) ?? []}
|
|
character={character}
|
|
/>
|
|
))}
|
|
|
|
{/* Empty state */}
|
|
{(!npcs || npcs.length === 0) && (
|
|
<div className="card py-8 text-center text-rpg-muted text-sm">
|
|
Le village semble désert… Revenez plus tard.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|