From d996f5806db684cca7784bd32fd51e33c83c71fe Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Tue, 28 Apr 2026 18:08:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Hub=20Village=20=E2=80=94=20page=20inte?= =?UTF-8?q?ractive=20avec=205=20zones=20et=20PNJ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/App.tsx | 2 + frontend/src/api/endpoints.ts | 7 +- frontend/src/api/types.ts | 12 ++ frontend/src/components/Layout.tsx | 3 +- frontend/src/pages/VillagePage.tsx | 275 +++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/VillagePage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ae99c1..95af4f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { ForgePage } from './pages/ForgePage'; import { QuestPage } from './pages/QuestPage'; import { AchievementsPage } from './pages/AchievementsPage'; import { ShopPage } from './pages/ShopPage'; +import { VillagePage } from './pages/VillagePage'; import { GuidePage } from './pages/GuidePage'; import { NotFoundPage } from './pages/NotFoundPage'; @@ -37,6 +38,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 457de2e..0a0ce85 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -2,7 +2,7 @@ import { api } from './client'; import type { User, Character, Monster, CombatLog, CharacterItem, CharacterMaterial, Recipe, CraftJob, Item, - TurnResult, TurnSpell, DaoPathProgress, + TurnResult, TurnSpell, DaoPathProgress, NpcView, } from './types'; // Auth @@ -81,6 +81,11 @@ export const questApi = { arcs: () => api.get('/quests/arcs'), }; +// NPCs +export const npcApi = { + all: () => api.get('/npcs'), +}; + // Forge export const forgeApi = { upgrade: (charItemId: string) => diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3894bcf..5b9a793 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -247,3 +247,15 @@ export interface CraftJob { collected: boolean; status: 'pending' | 'ready'; } + +export interface NpcView { + id: string; + name: string; + role: string; + location: string; + description: string | null; + lore: string | null; + spriteKey: string | null; + dialogue: string; + action?: string; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 489f8d4..ca4c564 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; -import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag, BookOpen } from 'lucide-react'; +import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag, BookOpen, Landmark } from 'lucide-react'; import { HudBar } from './HudBar'; import { GuideDrawer } from './GuideDrawer'; const NAV = [ { to: '/dashboard', icon: User, label: 'Personnage' }, + { to: '/village', icon: Landmark, label: 'Village' }, { to: '/quests', icon: Scroll, label: 'Quêtes' }, { to: '/combat', icon: Swords, label: 'Combat' }, { to: '/inventory', icon: Package, label: 'Inventaire' }, diff --git a/frontend/src/pages/VillagePage.tsx b/frontend/src/pages/VillagePage.tsx new file mode 100644 index 0000000..69fdad2 --- /dev/null +++ b/frontend/src/pages/VillagePage.tsx @@ -0,0 +1,275 @@ +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 = { + mentor: '🧙', + companion: '💚', + merchant: '🛍️', + quest_giver: '📜', + sage: '🔮', + rival: '⚔️', +}; + +const ROLE_LABELS: Record = { + mentor: 'Mentor', + companion: 'Compagnon', + merchant: 'Marchand', + quest_giver: 'Quêtes', + sage: 'Sage', + rival: 'Rival', +}; + +const ROLE_COLORS: Record = { + mentor: '#f4c94e', + companion: '#3ddc84', + merchant: '#5ba4f5', + quest_giver: '#a78bfa', + sage: '#a78bfa', + rival: '#e84040', +}; + +const ACTION_LABELS: Record = { + 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 = { + open_quests: '/quests', + open_shop: '/shop', + open_forge: '/forge', + challenge: '/combat', +}; + +const VILLAGE_LOCATIONS: Record = { + 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 ( +
+ {/* Header */} +
+ {roleEmoji} +
+
+ {npc.name} + + {roleLabel} + +
+ {npc.lore && ( +

{npc.lore}

+ )} +
+
+ + {/* Dialogue bubble */} +
+ « {npc.dialogue} » +
+ + {/* Action */} + {actionInfo && ( +
+ {npc.action === 'heal' ? ( +
+ + {!needsHeal && ( +

Vos PV sont au maximum !

+ )} + {needsHeal && !canHeal && ( +

Endurance insuffisante

+ )} +
+ ) : ( + + )} +
+ )} +
+ ); +} + +function VillageLocation({ locationKey, npcs, character }: { + locationKey: string; + npcs: NpcView[]; + character: any; +}) { + const loc = VILLAGE_LOCATIONS[locationKey]; + if (!loc || npcs.length === 0) return null; + + return ( +
+ {/* Location header */} +
+

+ {loc.emoji} + {loc.name} +

+

{loc.atmosphere}

+
+ + {/* NPC cards */} +
+ {npcs.map((npc) => ( + + ))} +
+
+ ); +} + +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
Chargement du village…
; + + // Group NPCs by location + const byLocation = new Map(); + for (const npc of (npcs ?? [])) { + const list = byLocation.get(npc.location) ?? []; + list.push(npc); + byLocation.set(npc.location, list); + } + + return ( +
+ {/* Village banner */} +
+
+ +

Le Village

+
+

+ L'étang murmure doucement. Les grenouilles vaquent à leurs occupations. + Un lieu de repos, de rencontres et de préparation avant la prochaine aventure. +

+
+ + {/* Location sections */} + {LOCATION_ORDER.map((locKey) => ( + + ))} + + {/* Empty state */} + {(!npcs || npcs.length === 0) && ( +
+ Le village semble désert… Revenez plus tard. +
+ )} +
+ ); +}