298 lines
9.5 KiB
TypeScript
298 lines
9.5 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useGame } from '../../contexts/GameContext';
|
|
import { GAME_API_URL } from '../../config';
|
|
import { GameModal } from './GameModal';
|
|
import { GameButton } from '../common/GameButton';
|
|
import { getAssetPath } from '../../utils/assetPath';
|
|
import './DialogModal.css';
|
|
|
|
interface DialogModalProps {
|
|
npcId: string;
|
|
npcData: any;
|
|
onClose: () => void;
|
|
onTrade?: () => void;
|
|
}
|
|
|
|
interface Topic {
|
|
id: string;
|
|
title: { [key: string]: string } | string;
|
|
text: { [key: string]: string } | string;
|
|
}
|
|
|
|
interface Quest {
|
|
quest_id: string;
|
|
title: { [key: string]: string } | string;
|
|
description: { [key: string]: string } | string;
|
|
giver_id: string;
|
|
objectives: any[];
|
|
repeatable?: boolean;
|
|
type?: 'individual' | 'global';
|
|
// Logic for frontend state
|
|
status?: 'available' | 'active' | 'completed' | 'can_turn_in';
|
|
}
|
|
|
|
export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClose, onTrade }) => {
|
|
const { token, locale, actions } = useGame();
|
|
const [dialogData, setDialogData] = useState<any>(null);
|
|
const [currentText, setCurrentText] = useState<string>("");
|
|
const [quests, setQuests] = useState<Quest[]>([]);
|
|
const [viewState, setViewState] = useState<'greeting' | 'topic' | 'quest_preview'>('greeting');
|
|
const [selectedQuest, setSelectedQuest] = useState<Quest | null>(null);
|
|
|
|
// Fetch dialog and quests
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
if (!token || !npcId) return;
|
|
|
|
try {
|
|
// 1. Fetch Dialog
|
|
const dialogRes = await fetch(`${GAME_API_URL}/npcs/${npcId}/dialog`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const dialog = await dialogRes.json();
|
|
setDialogData(dialog);
|
|
|
|
// Initial greeting
|
|
const greeting = getLocalized(dialog.greeting) || "Hello.";
|
|
setCurrentText(greeting);
|
|
|
|
// 2. Fetch Available Quests (Starts)
|
|
const availRes = await fetch(`${GAME_API_URL}/quests/available`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const availableQuests = await availRes.json();
|
|
|
|
// 3. Fetch Active Quests (Turn-ins)
|
|
const activeRes = await fetch(`${GAME_API_URL}/quests/active`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const activeQuests = await activeRes.json();
|
|
|
|
// Filter and Merge for this NPC
|
|
const npcQuests: Quest[] = [];
|
|
|
|
// Add available quests from this NPC
|
|
if (Array.isArray(availableQuests)) {
|
|
availableQuests.forEach((q: any) => {
|
|
if (q.giver_id === npcId) {
|
|
npcQuests.push({ ...q, status: 'available' });
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add active quests from this NPC
|
|
if (Array.isArray(activeQuests)) {
|
|
activeQuests.forEach((q: any) => {
|
|
if (q.giver_id === npcId && q.status === 'active') {
|
|
npcQuests.push({ ...q, status: 'active' });
|
|
}
|
|
});
|
|
}
|
|
|
|
setQuests(npcQuests);
|
|
|
|
} catch (e) {
|
|
console.error("Error fetching NPC data", e);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, [npcId, token, locale]);
|
|
|
|
const getLocalized = (obj: any) => {
|
|
if (typeof obj === 'string') return obj;
|
|
return obj?.[locale] || obj?.['en'] || "";
|
|
};
|
|
|
|
const handleTopicClick = (topic: Topic) => {
|
|
const text = getLocalized(topic.text) || "...";
|
|
setCurrentText(text);
|
|
setViewState('topic');
|
|
};
|
|
|
|
const handleQuestClick = (quest: Quest) => {
|
|
setSelectedQuest(quest);
|
|
const desc = getLocalized(quest.description);
|
|
|
|
if (quest.status === 'active') {
|
|
setCurrentText(desc + "\n\n(Quest in progress...)");
|
|
} else {
|
|
setCurrentText(desc);
|
|
}
|
|
setViewState('quest_preview');
|
|
};
|
|
|
|
const acceptQuest = async () => {
|
|
if (!selectedQuest) return;
|
|
try {
|
|
const res = await fetch(`${GAME_API_URL}/quests/accept/${selectedQuest.quest_id}`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// Refresh or update state
|
|
setCurrentText("Quest accepted! Good luck.");
|
|
|
|
if (data.quest) {
|
|
actions.handleQuestUpdate(data.quest);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
setViewState('greeting');
|
|
// Remove from available, add to active locally (simplification)
|
|
setQuests(prev => prev.map(q => q.quest_id === selectedQuest.quest_id ? { ...q, status: 'active' } : q));
|
|
setSelectedQuest(null);
|
|
resetToGreeting();
|
|
}, 1500);
|
|
} else {
|
|
const err = await res.json();
|
|
alert(err.detail);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handInQuest = async () => {
|
|
if (!selectedQuest) return;
|
|
try {
|
|
const res = await fetch(`${GAME_API_URL}/quests/hand_in/${selectedQuest.quest_id}`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const result = await res.json();
|
|
|
|
if (res.ok) {
|
|
if (result.quest_update) {
|
|
actions.handleQuestUpdate(result.quest_update);
|
|
}
|
|
// Refresh game data to update inventory/stats
|
|
actions.fetchGameData();
|
|
|
|
if (result.is_completed) {
|
|
let msg = getLocalized(result.completion_text) || "Thank you!";
|
|
if (result.rewards && result.rewards.length > 0) {
|
|
msg += "\n\nRewards:\n" + result.rewards.join('\n');
|
|
}
|
|
setCurrentText(msg);
|
|
// Remove from list
|
|
setQuests(prev => prev.filter(q => q.quest_id !== selectedQuest.quest_id));
|
|
} else {
|
|
setCurrentText(`Progress updated.\n${result.items_deducted?.join('\n')}`);
|
|
}
|
|
setTimeout(() => {
|
|
resetToGreeting();
|
|
}, 2000);
|
|
} else {
|
|
alert(result.detail);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const resetToGreeting = () => {
|
|
if (!dialogData) return;
|
|
const greeting = getLocalized(dialogData.greeting) || "Hello.";
|
|
setCurrentText(greeting);
|
|
setViewState('greeting');
|
|
setSelectedQuest(null);
|
|
};
|
|
|
|
if (!dialogData) return null;
|
|
|
|
const npcName = getLocalized(npcData?.name) || "Unknown";
|
|
|
|
return (
|
|
<GameModal
|
|
title={npcName}
|
|
onClose={onClose}
|
|
className="dialog-modal"
|
|
>
|
|
<div className="npc-dialog-layout">
|
|
<div className="npc-portrait-container">
|
|
<img
|
|
className="npc-portrait"
|
|
src={npcData.image ? getAssetPath(npcData.image) : ''}
|
|
alt={npcName}
|
|
/>
|
|
</div>
|
|
|
|
<div className="npc-dialog-content">
|
|
<div className="dialogue-box">
|
|
<p>{currentText}</p>
|
|
</div>
|
|
|
|
<div className="options-grid">
|
|
{/* BACK BUTTON */}
|
|
{(viewState === 'topic' || viewState === 'quest_preview') && (
|
|
<GameButton className="option-btn" onClick={resetToGreeting}>
|
|
← Back
|
|
</GameButton>
|
|
)}
|
|
|
|
{/* NPC TOPICS */}
|
|
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
|
|
<GameButton key={topic.id} className="option-btn" onClick={() => handleTopicClick(topic)}>
|
|
💬 {getLocalized(topic.title)}
|
|
</GameButton>
|
|
))}
|
|
|
|
{/* QUESTS */}
|
|
{viewState === 'greeting' && quests.map(q => (
|
|
<GameButton
|
|
key={q.quest_id}
|
|
className="option-btn quest-btn"
|
|
onClick={() => handleQuestClick(q)}
|
|
variant={q.status === 'active' ? 'warning' : 'info'}
|
|
>
|
|
{q.status === 'available' ? '❗' : '❓'} {getLocalized(q.title)}
|
|
</GameButton>
|
|
))}
|
|
|
|
{/* CONFIRM QUEST ACTION */}
|
|
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
|
|
<div style={{ gridColumn: 'span 2' }}>
|
|
<GameButton className="option-btn action-btn" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
|
|
Accept Quest
|
|
</GameButton>
|
|
</div>
|
|
)}
|
|
|
|
{viewState === 'quest_preview' && selectedQuest?.status === 'active' && (
|
|
<div style={{ gridColumn: 'span 2' }}>
|
|
<GameButton
|
|
className="option-btn action-btn"
|
|
variant="warning"
|
|
onClick={handInQuest}
|
|
style={{ width: '100%' }}
|
|
>
|
|
{/* If it's pure kill quest, 'Complete' makes more sense than 'Hand In' */}
|
|
{selectedQuest.objectives?.some((o: any) => o.type === 'kill_count') && !selectedQuest.objectives?.some((o: any) => o.type === 'item_delivery')
|
|
? "Complete Quest"
|
|
: "Hand In Items"}
|
|
</GameButton>
|
|
</div>
|
|
)}
|
|
|
|
{/* TRADE - Only show in greeting */}
|
|
{viewState === 'greeting' && npcData.trade?.enabled && (
|
|
<GameButton className="option-btn trade-btn" variant="success" onClick={onTrade}>
|
|
💰 Trade
|
|
</GameButton>
|
|
)}
|
|
|
|
{/* EXIT - Span full width */}
|
|
{viewState === 'greeting' && (
|
|
<GameButton className="option-btn exit-btn" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
|
|
Goodbye
|
|
</GameButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</GameModal>
|
|
);
|
|
};
|