feat: Hub Village — page interactive avec 5 zones et PNJ
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 23s
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>
This commit is contained in:
@@ -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>} />
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
275
frontend/src/pages/VillagePage.tsx
Normal file
275
frontend/src/pages/VillagePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user