feat: Hub Village — page interactive avec 5 zones et PNJ
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 25s

- 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
This commit is contained in:
2026-04-28 18:08:14 +02:00
parent cc7893ec8f
commit d996f5806d
5 changed files with 297 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ import { ForgePage } from './pages/ForgePage';
import { QuestPage } from './pages/QuestPage'; import { QuestPage } from './pages/QuestPage';
import { AchievementsPage } from './pages/AchievementsPage'; import { AchievementsPage } from './pages/AchievementsPage';
import { ShopPage } from './pages/ShopPage'; import { ShopPage } from './pages/ShopPage';
import { VillagePage } from './pages/VillagePage';
import { GuidePage } from './pages/GuidePage'; import { GuidePage } from './pages/GuidePage';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
@@ -37,6 +38,7 @@ function AppRoutes() {
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/guide" element={<GuidePage />} /> <Route path="/guide" element={<GuidePage />} />
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} /> <Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
<Route path="/village" element={<ProtectedLayout><VillagePage /></ProtectedLayout>} />
<Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} /> <Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} />
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} /> <Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
<Route path="/combat/tactical" element={<ProtectedLayout><TurnCombatPage /></ProtectedLayout>} /> <Route path="/combat/tactical" element={<ProtectedLayout><TurnCombatPage /></ProtectedLayout>} />

View File

@@ -2,7 +2,7 @@ import { api } from './client';
import type { import type {
User, Character, Monster, CombatLog, User, Character, Monster, CombatLog,
CharacterItem, CharacterMaterial, Recipe, CraftJob, Item, CharacterItem, CharacterMaterial, Recipe, CraftJob, Item,
TurnResult, TurnSpell, DaoPathProgress, TurnResult, TurnSpell, DaoPathProgress, NpcView,
} from './types'; } from './types';
// Auth // Auth
@@ -81,6 +81,11 @@ export const questApi = {
arcs: () => api.get<any[]>('/quests/arcs'), arcs: () => api.get<any[]>('/quests/arcs'),
}; };
// NPCs
export const npcApi = {
all: () => api.get<NpcView[]>('/npcs'),
};
// Forge // Forge
export const forgeApi = { export const forgeApi = {
upgrade: (charItemId: string) => upgrade: (charItemId: string) =>

View File

@@ -247,3 +247,15 @@ export interface CraftJob {
collected: boolean; collected: boolean;
status: 'pending' | 'ready'; 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;
}

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; 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 { HudBar } from './HudBar';
import { GuideDrawer } from './GuideDrawer'; import { GuideDrawer } from './GuideDrawer';
const NAV = [ const NAV = [
{ to: '/dashboard', icon: User, label: 'Personnage' }, { to: '/dashboard', icon: User, label: 'Personnage' },
{ to: '/village', icon: Landmark, label: 'Village' },
{ to: '/quests', icon: Scroll, label: 'Quêtes' }, { to: '/quests', icon: Scroll, label: 'Quêtes' },
{ to: '/combat', icon: Swords, label: 'Combat' }, { to: '/combat', icon: Swords, label: 'Combat' },
{ to: '/inventory', icon: Package, label: 'Inventaire' }, { to: '/inventory', icon: Package, label: 'Inventaire' },

View File

@@ -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<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>
);
}