Added trading and quests, checkpoint push
This commit is contained in:
297
pwa/src/components/game/DialogModal.tsx
Normal file
297
pwa/src/components/game/DialogModal.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user