Compare commits
3 Commits
d996f5806d
...
ce5b3c7285
| Author | SHA1 | Date | |
|---|---|---|---|
| ce5b3c7285 | |||
| 3ccb4a867c | |||
| 06e082b11c |
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ function RegenTimer({ endurance, enduranceMax, lastEnduranceTs }: { endurance: n
|
|||||||
|
|
||||||
if (endurance >= enduranceMax) return null;
|
if (endurance >= enduranceMax) return null;
|
||||||
|
|
||||||
// Regen = 1pt every 3min = 180s
|
|
||||||
const elapsedMs = now - new Date(lastEnduranceTs).getTime();
|
const elapsedMs = now - new Date(lastEnduranceTs).getTime();
|
||||||
const elapsedInCycle = elapsedMs % (3 * 60 * 1000);
|
const elapsedInCycle = elapsedMs % (3 * 60 * 1000);
|
||||||
const remainingMs = 3 * 60 * 1000 - elapsedInCycle;
|
const remainingMs = 3 * 60 * 1000 - elapsedInCycle;
|
||||||
@@ -24,8 +23,8 @@ function RegenTimer({ endurance, enduranceMax, lastEnduranceTs }: { endurance: n
|
|||||||
const sec = remainingSec % 60;
|
const sec = remainingSec % 60;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span style={{ fontSize: 9, color: '#5ba4f5' }}>
|
<span className="hud-regen text-[9px] text-rpg-blue inline-flex items-center gap-0.5">
|
||||||
<Clock size={8} style={{ display: 'inline', marginRight: 2 }} />
|
<Clock size={8} className="inline" />
|
||||||
+1 dans {min}:{sec.toString().padStart(2, '0')}
|
+1 dans {min}:{sec.toString().padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -35,7 +34,7 @@ export function HudBar() {
|
|||||||
const { data: char } = useQuery({
|
const { data: char } = useQuery({
|
||||||
queryKey: ['character'],
|
queryKey: ['character'],
|
||||||
queryFn: characterApi.me,
|
queryFn: characterApi.me,
|
||||||
refetchInterval: 30_000, // refresh every 30s for endurance updates
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,41 +55,31 @@ export function HudBar() {
|
|||||||
const questReady = activeQuests?.filter((pq: any) => pq.status === 'completed').length ?? 0;
|
const questReady = activeQuests?.filter((pq: any) => pq.status === 'completed').length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hud-bar" style={{
|
<div className="hud-bar bg-[#111620] border-b border-[#1e2535] px-4 py-1 flex items-center gap-4 text-[11px] text-rpg-muted flex-wrap">
|
||||||
background: '#111620',
|
|
||||||
borderBottom: '1px solid #1e2535',
|
|
||||||
padding: '4px 1rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 16,
|
|
||||||
fontSize: 11,
|
|
||||||
color: '#6b7a99',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}>
|
|
||||||
{/* Name + Level */}
|
{/* Name + Level */}
|
||||||
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
|
<Link to="/dashboard" className="no-underline flex items-center gap-1.5">
|
||||||
<span style={{ fontSize: 14 }}>🐸</span>
|
<span className="text-sm">🐸</span>
|
||||||
<span style={{ fontWeight: 700, color: '#dce4f0', fontSize: 12 }}>{char.name}</span>
|
<span className="font-bold text-rpg-text text-xs">{char.name}</span>
|
||||||
<span style={{ color: '#6b7a99' }}>Niv.{char.level}</span>
|
<span className="hud-label text-rpg-muted">Niv.{char.level}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<span style={{ color: '#2a3448' }}>|</span>
|
<span className="hud-sep text-[#2a3448]">|</span>
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<Link to="/dashboard" className="no-underline flex items-center gap-1">
|
||||||
<Heart size={10} color="#e84040" />
|
<Heart size={10} className="text-rpg-red" />
|
||||||
<span style={{ color: char.hpCurrent < char.hpMax ? '#e84040' : '#6b7a99' }}>
|
<span className={char.hpCurrent < char.hpMax ? 'text-rpg-red' : 'text-rpg-muted'}>
|
||||||
{char.hpCurrent}/{char.hpMax}
|
{char.hpCurrent}<span className="hud-label">/{char.hpMax}</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<span style={{ color: '#2a3448' }}>|</span>
|
<span className="hud-sep text-[#2a3448]">|</span>
|
||||||
|
|
||||||
{/* Endurance + timer */}
|
{/* Endurance + timer */}
|
||||||
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<Link to="/dashboard" className="no-underline flex items-center gap-1">
|
||||||
<Zap size={10} color="#5ba4f5" />
|
<Zap size={10} className="text-rpg-blue" />
|
||||||
<span style={{ color: endurance < 5 ? '#e84040' : '#6b7a99' }}>
|
<span className={endurance < 5 ? 'text-rpg-red' : 'text-rpg-muted'}>
|
||||||
{endurance}/{char.enduranceMax}
|
{endurance}<span className="hud-label">/{char.enduranceMax}</span>
|
||||||
</span>
|
</span>
|
||||||
{char.lastEnduranceTs && (
|
{char.lastEnduranceTs && (
|
||||||
<RegenTimer
|
<RegenTimer
|
||||||
@@ -101,30 +90,30 @@ export function HudBar() {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<span style={{ color: '#2a3448' }}>|</span>
|
<span className="hud-sep text-[#2a3448]">|</span>
|
||||||
|
|
||||||
{/* XP */}
|
{/* XP */}
|
||||||
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<Link to="/dashboard" className="no-underline flex items-center gap-1">
|
||||||
<Star size={10} color="#a78bfa" />
|
<Star size={10} className="text-rpg-purple" />
|
||||||
<span>{char.xp}/{xpNext}</span>
|
<span>{char.xp}<span className="hud-label">/{xpNext}</span></span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<span style={{ color: '#2a3448' }}>|</span>
|
<span className="hud-sep text-[#2a3448]">|</span>
|
||||||
|
|
||||||
{/* Gold */}
|
{/* Gold */}
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<span className="flex items-center gap-1">
|
||||||
<Coins size={10} color="#f4c94e" />
|
<Coins size={10} className="text-rpg-gold" />
|
||||||
<span>{char.gold}</span>
|
<span>{char.gold}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span style={{ color: '#2a3448' }}>|</span>
|
<span className="hud-sep text-[#2a3448]">|</span>
|
||||||
|
|
||||||
{/* Quests */}
|
{/* Quests */}
|
||||||
<Link to="/quests" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<Link to="/quests" className="no-underline flex items-center gap-1">
|
||||||
<Scroll size={10} color={questReady > 0 ? '#f4c94e' : '#6b7a99'} />
|
<Scroll size={10} className={questReady > 0 ? 'text-rpg-gold' : 'text-rpg-muted'} />
|
||||||
<span>{questCount} quête{questCount !== 1 ? 's' : ''}</span>
|
<span className="hud-label">{questCount} quête{questCount !== 1 ? 's' : ''}</span>
|
||||||
{questReady > 0 && (
|
{questReady > 0 && (
|
||||||
<span style={{ color: '#f4c94e', fontWeight: 700 }}>({questReady} prête{questReady > 1 ? 's' : ''} !)</span>
|
<span className="text-rpg-gold font-bold">({questReady} prête{questReady > 1 ? 's' : ''} !)</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ body {
|
|||||||
|
|
||||||
/* HudBar compact */
|
/* HudBar compact */
|
||||||
.hud-bar { font-size: 10px; gap: 6px; padding: 4px 8px; flex-wrap: wrap; }
|
.hud-bar { font-size: 10px; gap: 6px; padding: 4px 8px; flex-wrap: wrap; }
|
||||||
|
.hud-regen { display: none; }
|
||||||
|
|
||||||
/* Guide drawer full width mobile */
|
/* Guide drawer full width mobile */
|
||||||
.guide-drawer { width: 100% !important; }
|
.guide-drawer { width: 100% !important; }
|
||||||
@@ -147,3 +148,10 @@ body {
|
|||||||
/* Header compact */
|
/* Header compact */
|
||||||
.header-username { display: none; }
|
.header-username { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Ultra-compact mobile (petit écran) ── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.hud-bar { gap: 4px; padding: 3px 6px; font-size: 9px; }
|
||||||
|
.hud-sep { display: none; }
|
||||||
|
.hud-label { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { questApi } from '../api/endpoints';
|
import { questApi } from '../api/endpoints';
|
||||||
import { Scroll, CheckCircle, Circle, Trophy, ChevronDown, ChevronRight, Star, Coins, Swords, Lock } from 'lucide-react';
|
import { Scroll, CheckCircle, Circle, Trophy, ChevronDown, ChevronRight, Star, Coins, Swords, Lock } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
const OBJ_LABELS: Record<string, string> = {
|
const OBJ_LABELS: Record<string, string> = {
|
||||||
kill_monster: 'Tuer',
|
kill_monster: 'Tuer',
|
||||||
@@ -11,14 +11,9 @@ const OBJ_LABELS: Record<string, string> = {
|
|||||||
forge_item: 'Forger',
|
forge_item: 'Forger',
|
||||||
};
|
};
|
||||||
|
|
||||||
function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'completed' }) {
|
function useInvalidateQuests() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const quest = mode === 'active' ? pq.quest : pq;
|
return () => {
|
||||||
const progress = mode === 'active' ? pq.progress : 0;
|
|
||||||
const status = mode === 'active' ? pq.status : 'available';
|
|
||||||
const pct = Math.min(100, Math.floor((progress / quest.objectiveCount) * 100));
|
|
||||||
|
|
||||||
const invalidateAll = () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['quests'] });
|
qc.invalidateQueries({ queryKey: ['quests'] });
|
||||||
qc.invalidateQueries({ queryKey: ['questsActive'] });
|
qc.invalidateQueries({ queryKey: ['questsActive'] });
|
||||||
qc.invalidateQueries({ queryKey: ['questsAvailable'] });
|
qc.invalidateQueries({ queryKey: ['questsAvailable'] });
|
||||||
@@ -26,6 +21,14 @@ function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'comp
|
|||||||
qc.invalidateQueries({ queryKey: ['questArcs'] });
|
qc.invalidateQueries({ queryKey: ['questArcs'] });
|
||||||
qc.invalidateQueries({ queryKey: ['character'] });
|
qc.invalidateQueries({ queryKey: ['character'] });
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'completed' }) {
|
||||||
|
const invalidateAll = useInvalidateQuests();
|
||||||
|
const quest = mode === 'active' ? pq.quest : pq;
|
||||||
|
const progress = mode === 'active' ? pq.progress : 0;
|
||||||
|
const status = mode === 'active' ? pq.status : 'available';
|
||||||
|
const pct = Math.min(100, Math.floor((progress / quest.objectiveCount) * 100));
|
||||||
|
|
||||||
const acceptMut = useMutation({
|
const acceptMut = useMutation({
|
||||||
mutationFn: () => questApi.accept(quest.id),
|
mutationFn: () => questApi.accept(quest.id),
|
||||||
@@ -46,72 +49,57 @@ function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'comp
|
|||||||
const isClaimed = status === 'claimed';
|
const isClaimed = status === 'claimed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`card ${isCompleted ? 'card-gold' : ''}`} style={{ padding: '0.75rem 1rem' }}>
|
<div className={`card ${isCompleted ? 'card-gold' : ''} py-3 px-4`}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 }}>
|
<div className="flex justify-between items-start mb-1">
|
||||||
<div style={{ flex: 1 }}>
|
<div className="flex-1">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div className="flex items-center gap-1.5">
|
||||||
{isClaimed ? <CheckCircle size={14} color="#3ddc84" /> : isCompleted ? <Trophy size={14} color="#f4c94e" /> : <Circle size={13} color="#6b7a99" />}
|
{isClaimed ? <CheckCircle size={14} className="text-rpg-green" /> : isCompleted ? <Trophy size={14} className="text-rpg-gold" /> : <Circle size={13} className="text-rpg-muted" />}
|
||||||
<span style={{ fontWeight: 700, fontSize: 13, color: isCompleted ? '#f4c94e' : '#dce4f0' }}>{quest.name}</span>
|
<span className={`font-bold text-[13px] ${isCompleted ? 'text-rpg-gold' : 'text-rpg-text'}`}>{quest.name}</span>
|
||||||
{quest.repeatable && <span style={{ fontSize: 9, color: '#5ba4f5', background: '#1a2540', padding: '1px 5px', borderRadius: 4 }}>répétable</span>}
|
{quest.repeatable && <span className="text-[9px] text-rpg-blue bg-[#1a2540] px-1.5 py-px rounded">répétable</span>}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: '4px 0 0', fontSize: 11, color: '#6b7a99' }}>{quest.description}</p>
|
<p className="mt-1 mb-0 text-[11px] text-rpg-muted">{quest.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Objectif */}
|
{/* Objectif */}
|
||||||
<div style={{ fontSize: 11, color: '#9ca3af', margin: '6px 0 4px' }}>
|
<div className="text-[11px] text-[#9ca3af] mt-1.5 mb-1">
|
||||||
{OBJ_LABELS[quest.objectiveType] ?? quest.objectiveType} — {mode === 'active' ? `${progress}/${quest.objectiveCount}` : `×${quest.objectiveCount}`}
|
{OBJ_LABELS[quest.objectiveType] ?? quest.objectiveType} — {mode === 'active' ? `${progress}/${quest.objectiveCount}` : `×${quest.objectiveCount}`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar (active quests only) */}
|
{/* Progress bar (active quests only) */}
|
||||||
{mode === 'active' && (
|
{mode === 'active' && (
|
||||||
<div style={{ background: '#1e2535', borderRadius: 4, height: 6, marginBottom: 6, overflow: 'hidden' }}>
|
<div className="bar-track mb-1.5" style={{ height: 6 }}>
|
||||||
<div style={{ width: `${pct}%`, height: '100%', background: isCompleted ? '#f4c94e' : '#5ba4f5', borderRadius: 4, transition: 'width 0.3s' }} />
|
<div className={isCompleted ? 'bar-fill-xp' : 'bar-fill-end'} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rewards */}
|
{/* Rewards */}
|
||||||
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: '#6b7a99', marginBottom: 6 }}>
|
<div className="flex gap-3 text-[11px] text-rpg-muted mb-1.5">
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Star size={10} color="#a78bfa" /> {quest.rewardXp} XP</span>
|
<span className="flex items-center gap-1"><Star size={10} className="text-rpg-purple" /> {quest.rewardXp} XP</span>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Coins size={10} color="#f4c94e" /> {quest.rewardGold} or</span>
|
<span className="flex items-center gap-1"><Coins size={10} className="text-rpg-gold" /> {quest.rewardGold} or</span>
|
||||||
{quest.rewardTitle && <span style={{ color: '#f4c94e' }}>🏅 {quest.rewardTitle}</span>}
|
{quest.rewardTitle && <span className="text-rpg-gold">🏅 {quest.rewardTitle}</span>}
|
||||||
{quest.minLevel > 1 && <span>Niv. {quest.minLevel}+</span>}
|
{quest.minLevel > 1 && <span>Niv. {quest.minLevel}+</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{mode === 'available' && (
|
{mode === 'available' && (
|
||||||
<button
|
<button className="btn btn-ghost text-[11px] py-1 px-3" disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 11, padding: '0.25rem 0.75rem' }}
|
|
||||||
disabled={acceptMut.isPending}
|
|
||||||
onClick={() => acceptMut.mutate()}
|
|
||||||
>
|
|
||||||
{acceptMut.isPending ? 'Acceptation…' : '+ Accepter'}
|
{acceptMut.isPending ? 'Acceptation…' : '+ Accepter'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{mode === 'active' && isCompleted && (
|
{mode === 'active' && isCompleted && (
|
||||||
<button
|
<button className="btn btn-gold text-[11px] py-1 px-3" disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
|
||||||
className="btn btn-gold"
|
|
||||||
style={{ fontSize: 11, padding: '0.25rem 0.75rem' }}
|
|
||||||
disabled={claimMut.isPending}
|
|
||||||
onClick={() => claimMut.mutate()}
|
|
||||||
>
|
|
||||||
{claimMut.isPending ? 'Réclamation…' : '🎁 Réclamer la récompense'}
|
{claimMut.isPending ? 'Réclamation…' : '🎁 Réclamer la récompense'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{mode === 'active' && !isCompleted && (
|
{mode === 'active' && !isCompleted && (
|
||||||
<button
|
<button className="btn btn-ghost text-[10px] py-0.5 px-2 text-rpg-muted" disabled={abandonMut.isPending} onClick={() => abandonMut.mutate()}>
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 10, padding: '0.2rem 0.5rem', color: '#6b7a99' }}
|
|
||||||
disabled={abandonMut.isPending}
|
|
||||||
onClick={() => abandonMut.mutate()}
|
|
||||||
>
|
|
||||||
{abandonMut.isPending ? '…' : '✕ Abandonner'}
|
{abandonMut.isPending ? '…' : '✕ Abandonner'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{acceptMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(acceptMut.error as Error).message}</p>}
|
{acceptMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(acceptMut.error as Error).message}</p>}
|
||||||
{claimMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(claimMut.error as Error).message}</p>}
|
{claimMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(claimMut.error as Error).message}</p>}
|
||||||
{abandonMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(abandonMut.error as Error).message}</p>}
|
{abandonMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(abandonMut.error as Error).message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,68 +125,83 @@ function ArcQuestRow({ q }: { q: any }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '4px 0', borderBottom: '1px solid #1a2030' }}>
|
<div className="flex items-center gap-2 text-xs py-1 border-b border-[#1a2030]">
|
||||||
{q.playerStatus === 'claimed'
|
{q.playerStatus === 'claimed'
|
||||||
? <CheckCircle size={12} color="#3ddc84" />
|
? <CheckCircle size={12} className="text-rpg-green shrink-0" />
|
||||||
: q.playerStatus === 'completed'
|
: q.playerStatus === 'completed'
|
||||||
? <Trophy size={12} color="#f4c94e" />
|
? <Trophy size={12} className="text-rpg-gold shrink-0" />
|
||||||
: q.playerStatus === 'active'
|
: q.playerStatus === 'active'
|
||||||
? <Swords size={12} color="#5ba4f5" />
|
? <Swords size={12} className="text-rpg-blue shrink-0" />
|
||||||
: <Circle size={11} color="#3a4560" />
|
: <Circle size={11} className="text-[#3a4560] shrink-0" />
|
||||||
}
|
}
|
||||||
<div style={{ flex: 1 }}>
|
<div className="flex-1 min-w-0">
|
||||||
<span style={{
|
<span className={
|
||||||
color: q.playerStatus === 'claimed' ? '#3ddc84' : q.playerStatus === 'active' ? '#dce4f0' : '#6b7a99',
|
q.playerStatus === 'claimed' ? 'text-rpg-green' : q.playerStatus === 'active' ? 'text-rpg-text' : 'text-rpg-muted'
|
||||||
}}>{q.name}</span>
|
}>{q.name}</span>
|
||||||
{q.playerStatus === 'active' && (
|
{q.playerStatus === 'active' && (
|
||||||
<span style={{ fontSize: 10, color: '#5ba4f5', marginLeft: 6 }}>{q.progress}/{q.objectiveCount}</span>
|
<span className="text-[10px] text-rpg-blue ml-1.5">{q.progress}/{q.objectiveCount}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 10, color: '#6b7a99' }}>{q.rewardXp} XP</span>
|
<span className="text-[10px] text-rpg-muted">{q.rewardXp} XP</span>
|
||||||
{q.minLevel > 1 && !q.levelOk && <span style={{ fontSize: 9, color: '#e84040' }}>Niv.{q.minLevel}</span>}
|
{q.minLevel > 1 && !q.levelOk && <span className="text-[9px] text-rpg-red">Niv.{q.minLevel}</span>}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{q.canAccept && (
|
{q.canAccept && (
|
||||||
<button className="btn btn-ghost" style={{ fontSize: 10, padding: '0.1rem 0.4rem' }}
|
<button className="btn btn-ghost text-[10px] py-px px-1.5" disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
|
||||||
disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
|
|
||||||
{acceptMut.isPending ? '...' : '+ Accepter'}
|
{acceptMut.isPending ? '...' : '+ Accepter'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{q.playerStatus === 'completed' && (
|
{q.playerStatus === 'completed' && (
|
||||||
<button className="btn btn-gold" style={{ fontSize: 10, padding: '0.1rem 0.4rem' }}
|
<button className="btn btn-gold text-[10px] py-px px-1.5" disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
|
||||||
disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
|
|
||||||
{claimMut.isPending ? '...' : '🎁 Réclamer'}
|
{claimMut.isPending ? '...' : '🎁 Réclamer'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{acceptMut.isError && <span style={{ color: '#e84040', fontSize: 9 }}>{(acceptMut.error as Error).message}</span>}
|
{acceptMut.isError && <span className="text-rpg-red text-[9px]">{(acceptMut.error as Error).message}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArcSection({ arc }: { arc: any }) {
|
/** Détermine si un arc doit être ouvert par défaut */
|
||||||
const [open, setOpen] = useState(true);
|
function shouldArcBeOpen(arc: any): boolean {
|
||||||
|
if (!arc.zoneUnlocked) return false;
|
||||||
|
if (arc.completed) return false;
|
||||||
|
// Ouvert si au moins une quête est active ou prête à réclamer
|
||||||
|
return arc.quests.some((q: any) => q.playerStatus === 'active' || q.playerStatus === 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArcSection({ arc, defaultOpen }: { arc: any; defaultOpen: boolean }) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const { completed, total } = arc.progress;
|
const { completed, total } = arc.progress;
|
||||||
const locked = !arc.zoneUnlocked;
|
const locked = !arc.zoneUnlocked;
|
||||||
|
const pct = total > 0 ? Math.floor((completed / total) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`card ${locked ? '' : arc.completed ? '' : 'card-gold'}`} style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', opacity: locked ? 0.4 : 1 }}>
|
<div className={`card ${locked ? '' : arc.completed ? '' : 'card-gold'} py-3 px-4 mb-2 ${locked ? 'opacity-40' : ''}`}>
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: open && !locked ? 8 : 0 }}
|
className={`flex items-center gap-2 cursor-pointer ${open && !locked ? 'mb-2' : ''}`}
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
>
|
>
|
||||||
{locked ? <Lock size={14} color="#6b7a99" /> : open ? <ChevronDown size={14} color="#6b7a99" /> : <ChevronRight size={14} color="#6b7a99" />}
|
{locked ? <Lock size={14} className="text-rpg-muted shrink-0" /> : open ? <ChevronDown size={14} className="text-rpg-muted shrink-0" /> : <ChevronRight size={14} className="text-rpg-muted shrink-0" />}
|
||||||
<Scroll size={14} color={arc.completed ? '#3ddc84' : locked ? '#6b7a99' : '#f4c94e'} />
|
<Scroll size={14} className={`shrink-0 ${arc.completed ? 'text-rpg-green' : locked ? 'text-rpg-muted' : 'text-rpg-gold'}`} />
|
||||||
<span style={{ fontWeight: 700, fontSize: 14, color: arc.completed ? '#3ddc84' : locked ? '#6b7a99' : '#f4c94e', flex: 1 }}>
|
<span className={`font-bold text-sm flex-1 ${arc.completed ? 'text-rpg-green' : locked ? 'text-rpg-muted' : 'text-rpg-gold'}`}>
|
||||||
{arc.name}
|
{arc.name}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: '#6b7a99' }}>{completed}/{total}</span>
|
<span className="text-[11px] text-rpg-muted">{completed}/{total}</span>
|
||||||
{arc.completed && <CheckCircle size={14} color="#3ddc84" />}
|
{arc.completed && <CheckCircle size={14} className="text-rpg-green shrink-0" />}
|
||||||
{locked && <span style={{ fontSize: 10, color: '#6b7a99' }}>🔒 Complétez l'arc précédent</span>}
|
{locked && <span className="text-[10px] text-rpg-muted">🔒 Complétez l'arc précédent</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{!locked && (
|
||||||
|
<div className="bar-track mb-2" style={{ height: 4 }}>
|
||||||
|
<div className={arc.completed ? 'bar-fill-hp' : 'bar-fill-xp'} style={{ width: `${pct}%`, background: arc.completed ? '#3ddc84' : undefined }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{open && !locked && (
|
{open && !locked && (
|
||||||
<>
|
<>
|
||||||
<p style={{ fontSize: 11, color: '#6b7a99', margin: '0 0 8px', paddingLeft: 28 }}>{arc.description}</p>
|
<p className="text-[11px] text-rpg-muted mb-2 pl-7">{arc.description}</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', paddingLeft: 12 }}>
|
<div className="flex flex-col pl-3">
|
||||||
{arc.quests.map((q: any) => <ArcQuestRow key={q.id} q={q} />)}
|
{arc.quests.map((q: any) => <ArcQuestRow key={q.id} q={q} />)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -213,12 +216,21 @@ export function QuestPage() {
|
|||||||
const { data: arcs } = useQuery({ queryKey: ['questArcs'], queryFn: questApi.arcs });
|
const { data: arcs } = useQuery({ queryKey: ['questArcs'], queryFn: questApi.arcs });
|
||||||
const [showAllCombat, setShowAllCombat] = useState(false);
|
const [showAllCombat, setShowAllCombat] = useState(false);
|
||||||
|
|
||||||
if (loadActive || loadAvail) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement…</div>;
|
// Pré-calculer quels arcs sont ouverts par défaut (stable entre renders)
|
||||||
|
const arcDefaultOpen = useMemo(() => {
|
||||||
|
if (!arcs) return {};
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
for (const arc of arcs) {
|
||||||
|
map[arc.id] = shouldArcBeOpen(arc);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [arcs]);
|
||||||
|
|
||||||
|
if (loadActive || loadAvail) return <div className="p-8 text-rpg-muted">Chargement…</div>;
|
||||||
|
|
||||||
const isCraftQuest = (q: any) => ['forge_item', 'craft_item'].includes(q.objectiveType ?? q.quest?.objectiveType);
|
const isCraftQuest = (q: any) => ['forge_item', 'craft_item'].includes(q.objectiveType ?? q.quest?.objectiveType);
|
||||||
const isCombatQuest = (q: any) => !isCraftQuest(q);
|
const isCombatQuest = (q: any) => !isCraftQuest(q);
|
||||||
|
|
||||||
// Split by category
|
|
||||||
const activeAll = active ?? [];
|
const activeAll = active ?? [];
|
||||||
const activeCombat = activeAll.filter((pq: any) => !pq.quest.repeatable && isCombatQuest(pq));
|
const activeCombat = activeAll.filter((pq: any) => !pq.quest.repeatable && isCombatQuest(pq));
|
||||||
const activeCraft = activeAll.filter((pq: any) => !pq.quest.repeatable && isCraftQuest(pq));
|
const activeCraft = activeAll.filter((pq: any) => !pq.quest.repeatable && isCraftQuest(pq));
|
||||||
@@ -233,37 +245,36 @@ export function QuestPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>📜 Quêtes</h2>
|
<h2 className="mb-4 text-rpg-gold text-xl font-bold">📜 Quêtes</h2>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
<div className="grid-2">
|
||||||
{/* Active combat quests */}
|
{/* Active combat quests */}
|
||||||
<div>
|
<div>
|
||||||
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
|
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">
|
||||||
Quêtes actives ({activeCombat.length}/3)
|
Quêtes actives ({activeCombat.length}/3)
|
||||||
</p>
|
</p>
|
||||||
{activeCombat.length > 0 ? (
|
{activeCombat.length > 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="flex flex-col gap-1.5">
|
||||||
{activeCombat.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
|
{activeCombat.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card" style={{ padding: '1.5rem', textAlign: 'center', color: '#6b7a99', fontSize: 13 }}>
|
<div className="card py-6 text-center text-rpg-muted text-[13px]">
|
||||||
Aucune quête active — acceptez-en à droite
|
Aucune quête active — acceptez-en à droite
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Available combat quests (staggered) */}
|
{/* Available combat quests */}
|
||||||
<div>
|
<div>
|
||||||
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
|
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">
|
||||||
Quêtes de combat
|
Quêtes de combat
|
||||||
</p>
|
</p>
|
||||||
{shownCombat.length > 0 ? (
|
{shownCombat.length > 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="flex flex-col gap-1.5">
|
||||||
{shownCombat.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
|
{shownCombat.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost w-full text-[11px] py-1 mt-0.5"
|
||||||
style={{ width: '100%', fontSize: 11, padding: '0.3rem', marginTop: 2 }}
|
|
||||||
onClick={() => setShowAllCombat(!showAllCombat)}
|
onClick={() => setShowAllCombat(!showAllCombat)}
|
||||||
>
|
>
|
||||||
{showAllCombat ? 'Réduire' : `Voir tout (+${hiddenCount} quête${hiddenCount > 1 ? 's' : ''})`}
|
{showAllCombat ? 'Réduire' : `Voir tout (+${hiddenCount} quête${hiddenCount > 1 ? 's' : ''})`}
|
||||||
@@ -271,32 +282,28 @@ export function QuestPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card" style={{ padding: '1.5rem', textAlign: 'center', color: '#6b7a99', fontSize: 13 }}>
|
<div className="card py-6 text-center text-rpg-muted text-[13px]">
|
||||||
Toutes les quêtes de combat sont complétées
|
Toutes les quêtes de combat sont complétées
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Métiers (craft/forge — hors pool, comme les dailies) */}
|
{/* Métiers */}
|
||||||
{(activeCraft.length > 0 || availableCraft.length > 0) && (
|
{(activeCraft.length > 0 || availableCraft.length > 0) && (
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
<div className="mt-6">
|
||||||
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
|
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">🔨 Métiers</p>
|
||||||
🔨 Métiers
|
<div className="grid-2-cards">
|
||||||
</p>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
|
|
||||||
{activeCraft.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
|
{activeCraft.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
|
||||||
{availableCraft.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
|
{availableCraft.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tâches quotidiennes (répétables — toujours en fond) */}
|
{/* Tâches quotidiennes */}
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
<div className="mt-6">
|
||||||
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
|
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">🔄 Tâches quotidiennes</p>
|
||||||
🔄 Tâches quotidiennes
|
<div className="grid-2-cards">
|
||||||
</p>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
|
|
||||||
{activeDaily.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
|
{activeDaily.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
|
||||||
{availableDaily.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
|
{availableDaily.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
|
||||||
</div>
|
</div>
|
||||||
@@ -304,11 +311,11 @@ export function QuestPage() {
|
|||||||
|
|
||||||
{/* Arcs narratifs */}
|
{/* Arcs narratifs */}
|
||||||
{arcs && arcs.length > 0 && (
|
{arcs && arcs.length > 0 && (
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
<div className="mt-6">
|
||||||
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
|
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">📖 Arcs narratifs</p>
|
||||||
📖 Arcs narratifs
|
{arcs.map((arc: any) => (
|
||||||
</p>
|
<ArcSection key={arc.id} arc={arc} defaultOpen={arcDefaultOpen[arc.id] ?? false} />
|
||||||
{arcs.map((arc: any) => <ArcSection key={arc.id} arc={arc} />)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -467,6 +467,7 @@ export class CombatService {
|
|||||||
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: txResult.totals.wins });
|
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: txResult.totals.wins });
|
||||||
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_gold_earned', increment: txResult.totals.gold });
|
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_gold_earned', increment: txResult.totals.gold });
|
||||||
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: txResult.totals.wins, zone: monster.zone });
|
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: txResult.totals.wins, zone: monster.zone });
|
||||||
|
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: monster.id, increment: txResult.totals.wins, zone: monster.zone });
|
||||||
for (const matId of txResult.lootedMaterialIds) {
|
for (const matId of txResult.lootedMaterialIds) {
|
||||||
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: matId, increment: 1 });
|
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: matId, increment: 1 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user