Added trading and quests, checkpoint push

This commit is contained in:
Joan
2026-02-08 20:18:42 +01:00
parent 8820cd897e
commit 70dc35b4b2
36 changed files with 3583 additions and 279 deletions

View File

@@ -7,11 +7,16 @@ import { Combat } from './game/Combat'
import LocationView from './game/LocationView'
import MovementControls from './game/MovementControls'
import PlayerSidebar from './game/PlayerSidebar'
import { GameProvider } from '../contexts/GameContext'
import { QuestJournal } from './game/QuestJournal'
import './Game.css'
function Game() {
const { t, i18n } = useTranslation()
const [token] = useState(() => localStorage.getItem('token'))
const [showQuestJournal, setShowQuestJournal] = useState(false)
// Handle WebSocket messages
const handleWebSocketMessage = async (message: any) => {
@@ -323,6 +328,17 @@ function Game() {
)
}
// Create context value
const gameContextValue = {
token,
locale: i18n.language,
inventory: state.playerState?.inventory || [],
state,
actions
}
// No location loaded yet
if (!state.location) {
return (
@@ -333,213 +349,233 @@ function Game() {
}
return (
<div className="game-container">
{/* Game Header is now in GameLayout */}
<GameProvider value={gameContextValue}>
<div className="game-container">
{/* Game Header is now in GameLayout */}
{/* Mobile Header Toggle - only show in main view */}
{state.mobileMenuOpen === 'none' && (
<button
className="mobile-header-toggle"
onClick={() => actions.setMobileHeaderOpen(!state.mobileHeaderOpen)}
>
{state.mobileHeaderOpen ? '✕' : '☰'}
</button>
)}
{/* Quest Journal Toggle Button - Add to header or float?
Let's add it floating for now or in the top right.
*/}
{/* Main game area */}
<div className="game-main">
<div className="explore-tab-desktop">
{/* Left Sidebar: Movement & Surroundings */}
<div className={`left-sidebar mobile-menu-panel ${state.mobileMenuOpen === 'left' ? 'open' : ''}`}>
{state.location && state.profile && (
<MovementControls
location={state.location}
profile={state.profile}
combatState={state.combatState}
movementCooldown={state.movementCooldown}
interactableCooldowns={state.interactableCooldowns}
onMove={actions.handleMove}
onInteract={actions.handleInteract}
/>
)}
</div>
{/* Center: Location view or Combat */}
<div className="center-area">
{/* Combat view (when in combat) */}
{state.combatState && state.playerState && (
<Combat
combatState={state.combatState}
combatLog={state.combatLog}
profile={state.profile}
{/* Mobile Header Toggle - only show in main view */}
{state.mobileMenuOpen === 'none' && (
<button
className="mobile-header-toggle"
onClick={() => actions.setMobileHeaderOpen(!state.mobileHeaderOpen)}
>
{state.mobileHeaderOpen ? '✕' : '☰'}
</button>
)}
{/* Main game area */}
<div className="game-main">
<div className="explore-tab-desktop">
{/* Left Sidebar: Movement & Surroundings */}
<div className={`left-sidebar mobile-menu-panel ${state.mobileMenuOpen === 'left' ? 'open' : ''}`}>
{state.location && state.profile && (
<MovementControls
location={state.location}
profile={state.profile}
combatState={state.combatState}
movementCooldown={state.movementCooldown}
interactableCooldowns={state.interactableCooldowns}
onMove={actions.handleMove}
onInteract={actions.handleInteract}
/>
)}
</div>
{/* Center: Location view or Combat */}
<div className="center-area">
{/* Combat view (when in combat) */}
{state.combatState && state.playerState && (
<Combat
combatState={state.combatState}
combatLog={state.combatLog}
profile={state.profile}
playerState={state.playerState}
equipment={state.equipment}
onCombatAction={actions.handleCombatAction}
onPvPAction={async (action: string) => {
try {
const response = await api.post('/api/game/pvp/action', { action })
actions.setMessage(response.data.message || 'Action performed!')
// We don't need to fetchGameData here because the websocket update will handle it?
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
// So we should probably update state from response if possible, OR fetch.
// Let's return the data so Combat.tsx can use it for animations.
// And let's fetchGameData to be safe, but maybe skip if we trust the websocket?
// Let's keep fetchGameData for now as a fallback.
await actions.fetchGameData()
return response.data
} catch (error: any) {
actions.setMessage(error.response?.data?.detail || 'PvP action failed')
return null
}
}}
onExitCombat={() => {
actions.handleExitCombat()
}}
onExitPvPCombat={actions.handleExitPvPCombat}
addCombatLogEntry={actions.addCombatLogEntry}
updatePlayerState={actions.updatePlayerState}
updateCombatState={actions.updateCombatState}
/>
)}
{/* Location view (when not in combat) */}
{!state.combatState && state.location && state.playerState && (
<LocationView
key={state.location.id}
location={state.location}
playerState={state.playerState}
combatState={state.combatState || null}
message={state.message}
locationMessages={state.locationMessages}
expandedCorpse={state.expandedCorpse}
corpseDetails={state.corpseDetails}
mobileMenuOpen={state.mobileMenuOpen}
showCraftingMenu={state.showCraftingMenu}
showRepairMenu={state.showRepairMenu}
workbenchTab={state.workbenchTab}
craftableItems={state.craftableItems}
repairableItems={state.repairableItems}
uncraftableItems={state.uncraftableItems}
craftFilter={state.craftFilter}
repairFilter={state.repairFilter}
uncraftFilter={state.uncraftFilter}
craftCategoryFilter={state.craftCategoryFilter}
profile={state.profile}
onSetMessage={actions.setMessage}
onInitiateCombat={actions.handleInitiateCombat}
onInitiatePvP={async (playerId: number) => {
try {
const response = await api.post('/api/game/pvp/initiate', { target_player_id: playerId })
actions.setMessage(response.data.message || 'PvP combat initiated!')
await actions.fetchGameData()
} catch (error: any) {
actions.setMessage(error.response?.data?.detail || 'Failed to initiate PvP')
}
}}
onPickup={actions.handlePickup}
onLootCorpse={actions.handleLootCorpse}
onLootCorpseItem={actions.handleLootCorpseItem}
onSetExpandedCorpse={(corpseId: string | null) => {
if (corpseId === null) {
actions.handleCloseCorpseDetails()
} else {
actions.handleViewCorpseDetails(corpseId)
}
}}
onOpenCrafting={actions.handleOpenCrafting}
onOpenRepair={actions.handleOpenRepair}
onCloseCrafting={actions.handleCloseCrafting}
onSwitchWorkbenchTab={actions.handleSwitchWorkbenchTab}
onSetCraftFilter={actions.setCraftFilter}
onSetRepairFilter={actions.setRepairFilter}
onSetUncraftFilter={actions.setUncraftFilter}
onSetCraftCategoryFilter={actions.setCraftCategoryFilter}
onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())}
onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)}
onUncraft={(uniqueItemId: string, inventoryId: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId)}
failedActionItemId={state.failedActionItemId}
quests={state.quests}
/>
)}
</div>
{/* Right sidebar: Stats + Inventory */}
{state.playerState && state.profile && (
<PlayerSidebar
playerState={state.playerState}
profile={state.profile}
equipment={state.equipment}
onCombatAction={actions.handleCombatAction}
onPvPAction={async (action: string) => {
try {
const response = await api.post('/api/game/pvp/action', { action })
actions.setMessage(response.data.message || 'Action performed!')
// We don't need to fetchGameData here because the websocket update will handle it?
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
// So we should probably update state from response if possible, OR fetch.
// Let's return the data so Combat.tsx can use it for animations.
// And let's fetchGameData to be safe, but maybe skip if we trust the websocket?
// Let's keep fetchGameData for now as a fallback.
await actions.fetchGameData()
return response.data
} catch (error: any) {
actions.setMessage(error.response?.data?.detail || 'PvP action failed')
return null
}
}}
onExitCombat={() => {
actions.handleExitCombat()
}}
onExitPvPCombat={actions.handleExitPvPCombat}
addCombatLogEntry={actions.addCombatLogEntry}
updatePlayerState={actions.updatePlayerState}
updateCombatState={actions.updateCombatState}
/>
)}
{/* Location view (when not in combat) */}
{!state.combatState && state.location && state.playerState && (
<LocationView
key={state.location.id}
location={state.location}
playerState={state.playerState}
combatState={state.combatState || null}
message={state.message}
locationMessages={state.locationMessages}
expandedCorpse={state.expandedCorpse}
corpseDetails={state.corpseDetails}
inventoryFilter={state.inventoryFilter}
inventoryCategoryFilter={state.inventoryCategoryFilter}
mobileMenuOpen={state.mobileMenuOpen}
showCraftingMenu={state.showCraftingMenu}
showRepairMenu={state.showRepairMenu}
workbenchTab={state.workbenchTab}
craftableItems={state.craftableItems}
repairableItems={state.repairableItems}
uncraftableItems={state.uncraftableItems}
craftFilter={state.craftFilter}
repairFilter={state.repairFilter}
uncraftFilter={state.uncraftFilter}
craftCategoryFilter={state.craftCategoryFilter}
profile={state.profile}
onSetMessage={actions.setMessage}
onInitiateCombat={actions.handleInitiateCombat}
onInitiatePvP={async (playerId: number) => {
try {
const response = await api.post('/api/game/pvp/initiate', { target_player_id: playerId })
actions.setMessage(response.data.message || 'PvP combat initiated!')
await actions.fetchGameData()
} catch (error: any) {
actions.setMessage(error.response?.data?.detail || 'Failed to initiate PvP')
}
onSetInventoryFilter={actions.setInventoryFilter}
onSetInventoryCategoryFilter={actions.setInventoryCategoryFilter}
onUseItem={async (itemId: number, _invId: number) => {
await actions.handleUseItem(itemId.toString())
}}
onPickup={actions.handlePickup}
onLootCorpse={actions.handleLootCorpse}
onLootCorpseItem={actions.handleLootCorpseItem}
onSetExpandedCorpse={(corpseId: string | null) => {
if (corpseId === null) {
actions.handleCloseCorpseDetails()
} else {
actions.handleViewCorpseDetails(corpseId)
}
onEquipItem={actions.handleEquipItem}
onUnequipItem={actions.handleUnequipItem}
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
await actions.handleDropItem(itemId.toString(), quantity)
}}
onOpenCrafting={actions.handleOpenCrafting}
onOpenRepair={actions.handleOpenRepair}
onCloseCrafting={actions.handleCloseCrafting}
onSwitchWorkbenchTab={actions.handleSwitchWorkbenchTab}
onSetCraftFilter={actions.setCraftFilter}
onSetRepairFilter={actions.setRepairFilter}
onSetUncraftFilter={actions.setUncraftFilter}
onSetCraftCategoryFilter={actions.setCraftCategoryFilter}
onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())}
onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)}
onUncraft={(uniqueItemId: string, inventoryId: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId)}
failedActionItemId={state.failedActionItemId}
onSpendPoint={actions.handleSpendPoint}
onOpenQuestJournal={() => setShowQuestJournal(true)}
/>
)}
</div>
{/* Right sidebar: Stats + Inventory */}
{state.playerState && state.profile && (
<PlayerSidebar
playerState={state.playerState}
profile={state.profile}
equipment={state.equipment}
inventoryFilter={state.inventoryFilter}
inventoryCategoryFilter={state.inventoryCategoryFilter}
mobileMenuOpen={state.mobileMenuOpen}
onSetInventoryFilter={actions.setInventoryFilter}
onSetInventoryCategoryFilter={actions.setInventoryCategoryFilter}
onUseItem={async (itemId: number, _invId: number) => {
await actions.handleUseItem(itemId.toString())
}}
onEquipItem={actions.handleEquipItem}
onUnequipItem={actions.handleUnequipItem}
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
await actions.handleDropItem(itemId.toString(), quantity)
}}
onSpendPoint={actions.handleSpendPoint}
{/* Mobile Tab Navigation */}
<div className="mobile-menu-buttons">
<button
className={`mobile-menu-btn left-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
>
<span>🧭</span>
</button>
<button
className={`mobile-menu-btn bottom-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
disabled={!!state.combatState}
>
<span>📍</span>
</button>
<button
className={`mobile-menu-btn right-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
>
<span>🎒</span>
</button>
</div>
{/* Mobile Menu Overlays */}
{state.mobileMenuOpen !== 'none' && (
<div
className="mobile-menu-overlay"
onClick={() => actions.setMobileMenuOpen('none')}
/>
)}
</div>
{/* Mobile Tab Navigation */}
<div className="mobile-menu-buttons">
{/* Mobile navigation */}
<div className="mobile-nav">
<button
className={`mobile-menu-btn left-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
className={`mobile-nav-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
>
<span>🧭</span>
🗺<br />Map
</button>
<button
className={`mobile-menu-btn bottom-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
className={`mobile-nav-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
disabled={!!state.combatState}
>
<span>📍</span>
📦<br />Items
</button>
<button
className={`mobile-menu-btn right-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
className={`mobile-nav-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
>
<span>🎒</span>
🎒<br />Inventory
</button>
{/* Mobile Quest Button */}
<button
className={`mobile-nav-btn ${showQuestJournal ? 'active' : ''}`}
onClick={() => setShowQuestJournal(!showQuestJournal)}
>
📜<br />Quests
</button>
</div>
{/* Mobile Menu Overlays */}
{state.mobileMenuOpen !== 'none' && (
<div
className="mobile-menu-overlay"
onClick={() => actions.setMobileMenuOpen('none')}
/>
{showQuestJournal && (
<QuestJournal onClose={() => setShowQuestJournal(false)} />
)}
</div>
{/* Mobile navigation */}
<div className="mobile-nav">
<button
className={`mobile-nav-btn ${state.mobileMenuOpen === 'left' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'left' ? 'none' : 'left')}
>
🗺<br />Map
</button>
<button
className={`mobile-nav-btn ${state.mobileMenuOpen === 'bottom' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
>
📦<br />Items
</button>
<button
className={`mobile-nav-btn ${state.mobileMenuOpen === 'right' ? 'active' : ''}`}
onClick={() => actions.setMobileMenuOpen(state.mobileMenuOpen === 'right' ? 'none' : 'right')}
>
🎒<br />Inventory
</button>
</div>
</div>
</GameProvider>
)
}

View File

@@ -55,14 +55,15 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
// Use mousedown to catch clicks before they might trigger other things
document.addEventListener('mousedown', handleClickOutside);
// Disable scrolling while dropdown is open
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// Handle scroll to close the dropdown (prevents detached menu and layout shifts)
const handleScroll = () => {
onClose();
};
window.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
// Restore scrolling
document.body.style.overflow = originalOverflow;
window.removeEventListener('scroll', handleScroll, true);
};
}, [isOpen, onClose]);

View File

@@ -0,0 +1,112 @@
.dialog-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-container {
background: rgba(20, 20, 20, 0.95);
border: 1px solid #444;
border-radius: 8px;
padding: 20px;
max-width: 600px;
width: 90%;
color: #e0e0e0;
position: relative;
display: flex;
flex-direction: column;
gap: 15px;
min-height: 300px;
}
.npc-image {
width: 100px;
height: 100px;
border-radius: 50%;
border: 2px solid #555;
object-fit: cover;
align-self: center;
}
.npc-name {
text-align: center;
margin: 5px 0 15px 0;
color: #ff9800;
}
.dialogue-text {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
border-radius: 5px;
font-size: 1.1rem;
line-height: 1.4;
min-height: 80px;
border: 1px solid #333;
}
/* Renamed from .options-container to match JSX */
.options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
/* grid-auto-rows: 1fr; Removed to prevent forced height expansion */
gap: 10px;
margin-top: auto;
}
/* Make back button and exit button span full width if needed, or keep grid */
/* Let's make the 'Back' button span full width for better UX */
.options-grid>.option-btn:first-child:nth-last-child(1) {
grid-column: span 2;
}
.option-btn {
/* Base styles handled by GameButton, but we can override */
width: 100%;
/* height: 100%; Removed to prevent stretching */
/* Fill the grid cell */
margin: 0;
}
.option-button {
/* Legacy style - keeping just in case */
background: rgba(255, 255, 255, 0.05);
border: 1px solid #555;
color: #ccc;
padding: 10px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
border-radius: 4px;
}
.option-button:hover {
background: rgba(255, 152, 0, 0.1);
border-color: #ff9800;
color: #fff;
}
.action-button {
border-left: 3px solid #ff9800;
}
.dialog-close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: #666;
font-size: 1.2rem;
cursor: pointer;
}
.dialog-close-btn:hover {
color: #fff;
}

View 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}>
&larr; 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>
);
};

View File

@@ -0,0 +1,102 @@
.game-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease-out;
}
.game-modal-container {
background: #1a1a1a;
border: 1px solid #333;
/* border-radius: 8px; REMOVED */
clip-path: var(--game-clip-path);
width: 90%;
/* Default width */
max-width: 600px;
max-height: 90vh;
min-height: 400px;
display: flex;
flex-direction: column;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
animation: slideUp 0.3s ease-out;
color: #e0e0e0;
}
.game-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #333;
background: linear-gradient(to bottom, #252525, #1a1a1a);
/* border-radius: 8px 8px 0 0; REMOVED */
}
.game-modal-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #fff;
letter-spacing: 0.5px;
}
.game-modal-close-btn {
background: none;
border: none;
color: #888;
font-size: 24px;
cursor: pointer;
line-height: 1;
padding: 0;
transition: color 0.2s;
}
.game-modal-close-btn:hover {
color: #fff;
}
.game-modal-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.game-modal-footer {
padding: 15px 20px;
border-top: 1px solid #333;
background: #151515;
/* border-radius: 0 0 8px 8px; REMOVED */
display: flex;
justify-content: flex-end;
gap: 10px;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,35 @@
import React, { ReactNode } from 'react';
import './GameModal.css';
interface GameModalProps {
title?: string;
onClose: () => void;
children: ReactNode;
className?: string; // For specific styling overrides
footer?: ReactNode;
}
export const GameModal: React.FC<GameModalProps> = ({ title, onClose, children, className = '', footer }) => {
return (
<div className="game-modal-overlay" onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}>
<div className={`game-modal-container ${className}`}>
<div className="game-modal-header">
<h2 className="game-modal-title">{title}</h2>
<button className="game-modal-close-btn" onClick={onClose}>&times;</button>
</div>
<div className="game-modal-content">
{children}
</div>
{footer && (
<div className="game-modal-footer">
{footer}
</div>
)}
</div>
</div>
);
};

View File

@@ -8,6 +8,8 @@ import { GameButton } from '../common/GameButton'
import { GameDropdown } from '../common/GameDropdown'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import { DialogModal } from './DialogModal'
import { TradeModal } from './TradeModal'
import './LocationView.css'
interface LocationViewProps {
@@ -49,6 +51,7 @@ interface LocationViewProps {
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number) => void
failedActionItemId: string | number | null
quests: { active: any[], available: any[] }
}
function LocationView({
@@ -70,6 +73,7 @@ function LocationView({
uncraftFilter,
craftCategoryFilter,
profile,
quests,
onInitiateCombat,
onInitiatePvP,
@@ -91,17 +95,25 @@ function LocationView({
}: LocationViewProps) {
const { t } = useTranslation()
const { playSfx } = useAudio()
// const { token } = useGame() // No longer needed for fetching here
// Dropdown State
const [activeDropdown, setActiveDropdown] = useState<string | null>(null)
// NPC Interaction State
const [activeDialogNpc, setActiveDialogNpc] = useState<string | null>(null)
const [showTradeModal, setShowTradeModal] = useState<boolean>(false)
const [activeNpcData, setActiveNpcData] = useState<any>(null)
// Quest State
const [questIndicators, setQuestIndicators] = useState<{ [npcId: string]: string }>({})
// Handle dropdown toggle
const handleDropdownClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
if (activeDropdown === id) {
setActiveDropdown(null)
} else {
// GameDropdown now auto-detects mouse position if we don't pass it
setActiveDropdown(id)
}
}
@@ -113,6 +125,85 @@ function LocationView({
return () => window.removeEventListener('click', handleClickOutside)
}, [])
// Calculate Quest Indicators from props
useEffect(() => {
const indicators: { [id: string]: string } = {};
if (!quests) return;
const { active, available } = quests;
// Check Available (New Quests)
available.forEach((q: any) => {
// Filter by location if needed? available/ endpoints already filters by location usually?
// Actually /api/quests/available returns quests available *at the current location*.
// But GameEngine fetches it globally?
// Wait, /api/quests/available depends on current_user's location.
// So useGameEngine.ts fetching valid ONLY for current location.
// If player moves, fetchGameData is called, so available quests are refreshed. Correct.
if (q.giver_id) {
if (q.type === 'global') indicators[q.giver_id] = 'blue_exclamation';
else indicators[q.giver_id] = 'yellow_exclamation';
}
});
// Check Active (Ready to turn in or Cooldown)
active.forEach((q: any) => {
if (q.giver_id) {
let allDone = true;
if (q.objectives) {
q.objectives.forEach((obj: any) => {
const current = q.progress?.[obj.target] || 0;
if (current < obj.count) allDone = false;
});
}
if (allDone && q.status === 'active') {
indicators[q.giver_id] = 'yellow_question';
}
if (q.on_cooldown) {
indicators[q.giver_id] = 'gray_loop';
}
}
});
setQuestIndicators(indicators);
}, [quests, location.id]);
const handleNpcClick = (npc: any) => {
setActiveNpcData(npc);
setActiveDialogNpc(npc.id);
};
const renderIndicator = (npcId: string) => {
const type = questIndicators[npcId];
if (!type) return null;
let symbol = '';
let color = '';
switch (type) {
case 'yellow_exclamation': symbol = '!'; color = '#ffeb3b'; break;
case 'blue_exclamation': symbol = '!'; color = '#4fc3f7'; break;
case 'yellow_question': symbol = '?'; color = '#ffeb3b'; break;
case 'gray_loop': symbol = '⟳'; color = '#9e9e9e'; break;
default: return null;
}
return (
<div style={{
position: 'absolute', top: '-10px', right: '-5px',
color: color, fontSize: '1.5rem', fontWeight: 'bold',
textShadow: '0 0 5px black', zIndex: 10,
animation: 'bounce 2s infinite'
}}>
{symbol}
</div>
);
};
return (
<div className="location-view">
<div className="location-info">
@@ -165,8 +256,6 @@ function LocationView({
</div>
)}
{location.image_url && (
<div className="location-image-container">
<img
@@ -183,12 +272,6 @@ function LocationView({
</div>
</div>
{/* {message && (
<div className="message-box" onClick={() => onSetMessage('')}>
{message}
</div>
)} */}
{locationMessages.length > 0 && (
<div className="location-messages-log">
<h4>{t('location.recentActivity')}</h4>
@@ -321,30 +404,25 @@ function LocationView({
<div className="entity-list grid-view">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card grid-card"
onClick={(e) => handleDropdownClick(e, `npc-${i}`)}
onClick={() => handleNpcClick(npc)}
style={{ cursor: 'pointer', position: 'relative' }}
>
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
{npc.image_path ? (
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
)}
{renderIndicator(npc.id)}
<GameTooltip content={
<div>
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
<div>{t('location.level')} {npc.level}</div>
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
</div>
}>
<div className="grid-overlay"></div>
</GameTooltip>
{activeDropdown === `npc-${i}` && (
<GameDropdown
isOpen={true}
onClose={() => setActiveDropdown(null)}
>
<div className="game-dropdown-header">{getTranslatedText(npc.name)}</div>
<GameButton variant="primary" size="sm" style={{ width: '100%', justifyContent: 'flex-start' }}>
💬 {t('common.talk')}
</GameButton>
</GameDropdown>
)}
</div>
))}
</div>
@@ -514,8 +592,6 @@ function LocationView({
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
// Fallback emoji next to it will show if image fails?
// Current logic doesn't have fallback emoji element sibling, just keeping it simple.
}}
/>
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
@@ -583,6 +659,25 @@ function LocationView({
onUncraft={onUncraft}
/>
)}
{activeDialogNpc && activeNpcData && (
<DialogModal
npcId={activeDialogNpc}
npcData={activeNpcData}
onClose={() => setActiveDialogNpc(null)}
onTrade={() => {
setActiveDialogNpc(null);
setShowTradeModal(true);
}}
/>
)}
{showTradeModal && activeNpcData && (
<TradeModal
npcId={activeNpcData.id}
onClose={() => setShowTradeModal(false)}
/>
)}
</div>
)
}

View File

@@ -23,6 +23,7 @@ interface PlayerSidebarProps {
onUnequipItem: (slot: string) => void
onDropItem: (itemId: number, invId: number, quantity: number) => void
onSpendPoint: (stat: string) => void
onOpenQuestJournal: () => void
}
function PlayerSidebar({
@@ -38,7 +39,8 @@ function PlayerSidebar({
onEquipItem,
onUnequipItem,
onDropItem,
onSpendPoint
onSpendPoint,
onOpenQuestJournal
}: PlayerSidebarProps) {
const [showInventory, setShowInventory] = useState(false)
const { t } = useTranslation()
@@ -290,15 +292,27 @@ function PlayerSidebar({
</div>
)}
<GameButton
className="open-inventory-btn"
variant="primary"
size="lg"
onClick={() => setShowInventory(true)}
style={{ width: '100%', justifyContent: 'center' }}
>
{t('game.inventory')}
</GameButton>
<div className="sidebar-buttons" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<GameButton
className="open-inventory-btn"
variant="primary"
size="md"
onClick={() => setShowInventory(true)}
style={{ width: '100%', justifyContent: 'center' }}
>
{t('game.inventory')}
</GameButton>
<GameButton
className="quest-journal-btn"
variant="secondary" // Different color as requested
size="md"
onClick={onOpenQuestJournal}
style={{ width: '100%', justifyContent: 'center' }}
>
📜 {t('common.quests')}
</GameButton>
</div>
</div>
<div className="equipment-sidebar">

View File

@@ -0,0 +1,146 @@
.quest-journal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.journal-container {
background: rgba(20, 20, 20, 0.95);
border: 1px solid #444;
border-radius: 8px;
padding: 20px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
color: #e0e0e0;
position: relative;
}
.journal-title {
color: #ff9800;
border-bottom: 2px solid #555;
padding-bottom: 10px;
margin-top: 0;
}
.journal-close-btn {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
}
.journal-close-btn:hover {
color: #fff;
}
.tab-container {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid #444;
}
.journal-tab {
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: #aaa;
padding: 10px 20px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
.journal-tab:hover {
color: #fff;
background: rgba(255, 255, 255, 0.05);
}
.journal-tab.active {
background: rgba(255, 152, 0, 0.2);
border-bottom: 3px solid #ff9800;
color: #ff9800;
}
.quest-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.quest-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid #555;
border-radius: 5px;
padding: 15px;
}
.quest-card.completed {
border-color: #4caf50;
}
.quest-card h3 {
margin: 0 0 5px 0;
color: #ddd;
display: flex;
justify-content: space-between;
}
.quest-card.completed h3 {
color: #4caf50;
}
.quest-desc {
font-size: 0.9rem;
color: #ccc;
margin-bottom: 10px;
font-style: italic;
}
.objective-list {
list-style: none;
padding: 0;
margin: 10px 0;
}
.objective-item {
color: #aaa;
margin-bottom: 4px;
display: flex;
align-items: center;
}
.objective-item.met {
color: #8bc34a;
}
.objective-item:before {
content: '○';
margin-right: 8px;
font-weight: bold;
color: #777;
}
.objective-item.met:before {
content: '✓';
color: #8bc34a;
}
.empty-message {
text-align: center;
padding: 40px;
color: #777;
font-style: italic;
}

View File

@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { useGame } from '../../contexts/GameContext';
import { GameModal } from './GameModal';
import './QuestJournal.css';
interface Quest {
quest_id: string;
title: { [key: string]: string } | string;
description: { [key: string]: string } | string;
status: string;
progress: { [key: string]: number };
objectives: any[];
rewards: any;
type: string;
completion_text?: { [key: string]: string } | string;
completed_at?: number;
}
interface QuestJournalProps {
onClose: () => void;
}
export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
const { locale, state } = useGame(); // Use global state
const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active');
// Derived from global state
const quests = (state.quests.active || []) as Quest[];
const getLocalizedText = (textObj: any) => {
if (typeof textObj === 'string') return textObj;
if (!textObj) return '';
return textObj[locale] || textObj['en'] || Object.values(textObj)[0] || '';
};
const filteredQuests = quests.filter((q: Quest) => {
if (activeTab === 'active') {
return q.status === 'active';
} else {
return q.status === 'completed';
}
});
const renderObjectives = (quest: Quest) => {
return quest.objectives.map((obj, idx) => {
const current = quest.progress[obj.target] || 0;
const required = obj.count;
const met = current >= required;
let label = obj.target;
if (obj.type === 'kill_count') {
label = `Kill ${obj.target}`;
} else if (obj.type === 'item_delivery') {
label = `Deliver ${obj.target}`;
}
return (
<li key={idx} className={`objective-item ${met ? 'met' : ''}`}>
{label}: {current}/{required}
</li>
);
});
};
return (
<GameModal
title="Quest Journal"
onClose={onClose}
className="quest-journal-modal"
footer={
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div className="tab-container">
<button
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}
onClick={() => setActiveTab('active')}
>
Active
</button>
<button
className={`journal-tab ${activeTab === 'completed' ? 'active' : ''}`}
onClick={() => setActiveTab('completed')}
>
Completed
</button>
</div>
</div>
}
>
<div className="journal-content">
<div className="quest-list">
{filteredQuests.length === 0 ? (
<div className="empty-message">No quests found in this category.</div>
) : (
filteredQuests.map((quest: Quest) => (
<div key={quest.quest_id} className={`quest-card ${quest.status === 'completed' ? 'completed' : ''}`}>
<h3>
{getLocalizedText(quest.title)}
{quest.type === 'global' && <span style={{ fontSize: '0.8rem', color: '#64b5f6', marginLeft: '10px' }}>GLOBAL</span>}
</h3>
<div className="quest-desc">{getLocalizedText(quest.description)}</div>
{quest.status === 'active' && (
<ul className="objective-list">
{renderObjectives(quest)}
</ul>
)}
{quest.status === 'completed' && quest.completion_text && (
<div className="completion-text">
"{getLocalizedText(quest.completion_text)}"
</div>
)}
</div>
))
)}
</div>
</div>
</GameModal>
);
};

View File

@@ -0,0 +1,316 @@
/* Trade container layout */
/* Trade container overrides */
.game-modal-container.trade-modal {
max-width: 1400px;
width: 95vw;
height: 90vh;
}
.trade-modal .game-modal-content {
display: flex;
flex-direction: column;
height: 100%;
}
.trade-container {
display: flex;
flex-direction: column;
height: 100%;
gap: 15px;
overflow: hidden;
}
.trade-content {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 15px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.trade-column {
background: rgba(0, 0, 0, 0.4);
border: 1px solid #444;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.column-header {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
color: #ffd700;
text-align: center;
border-bottom: 1px solid #444;
padding-bottom: 0.5rem;
flex-shrink: 0;
}
.search-bar {
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.5);
border: 1px solid #555;
color: white;
width: 100%;
box-sizing: border-box;
/* Fixes cut-off issue */
clip-path: var(--game-clip-path-sm, polygon(0 0,
100% 0,
100% calc(100% - 5px),
calc(100% - 5px) 100%,
0 100%));
}
.inventory-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
grid-auto-rows: max-content;
/* Ensure rows don't stretch */
gap: 0.5rem;
overflow-y: auto;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.75rem;
padding: 0.5rem;
overflow-y: auto;
}
.trade-item-card {
position: relative;
aspect-ratio: 1;
background: var(--game-bg-card);
border: 1px solid var(--game-border-color);
clip-path: var(--game-clip-path);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
box-shadow: var(--game-shadow-sm);
}
.trade-item-card:hover {
border-color: #63b3ed;
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
z-index: 10;
}
.trade-item-card.text-tier-0 {
border-color: #a0aec0;
}
.trade-item-card.text-tier-1 {
border-color: #ffffff;
}
.trade-item-card.text-tier-2 {
border-color: #68d391;
}
.trade-item-card.text-tier-3 {
border-color: #63b3ed;
}
.trade-item-card.text-tier-4 {
border-color: #9f7aea;
}
.trade-item-card.text-tier-5 {
border-color: #ed8936;
}
.trade-item-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
/* Margins inside container */
}
.trade-item-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
/* Exact match for quantity badge from InventoryModal.css */
.trade-item-qty {
position: absolute;
bottom: 2px;
right: 2px;
background: var(--game-bg-panel);
/* Match source */
border: 1px solid var(--game-border-color);
/* Match source */
color: var(--game-text-primary);
/* Match source */
font-size: 0.7rem;
/* Match source grid adjustment */
padding: 1px 4px;
/* Match source grid adjustment */
clip-path: var(--game-clip-path-sm);
/* Match source */
font-weight: bold;
box-shadow: var(--game-shadow-sm);
/* Match source */
pointer-events: none;
}
.trade-item-value {
position: absolute;
bottom: 2px;
left: 2px;
background: rgba(0, 0, 0, 0.7);
/* Keep visible background for value */
color: #ffd700;
font-size: 0.7rem;
padding: 1px 4px;
clip-path: var(--game-clip-path-sm);
font-weight: bold;
pointer-events: none;
}
/* Cart Grid - Slightly different or same? User checked inventory grid, so same is safe for source lists.
Cart lists might need to remain distinct or use same style.
Currently they use same .trade-item-card class, so they will inherit this. */
.cart-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
/* Smaller for cart? */
gap: 0.5rem;
padding: 0.5rem;
overflow-y: auto;
max-height: 200px;
}
.trade-center-column {
display: flex;
flex-direction: column;
gap: 15px;
overflow: hidden;
}
.trade-cart-section {
flex: 1;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #444;
padding: 10px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.cart-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
grid-auto-rows: max-content;
gap: 8px;
overflow-y: auto;
padding-right: 5px;
margin-top: 10px;
align-content: start;
}
.trade-list-header {
font-size: 1rem;
font-weight: bold;
margin-bottom: 0.5rem;
color: #ddd;
display: flex;
justify-content: space-between;
}
.cart-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-right: 0.25rem;
}
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0, 0, 0, 0.4);
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
}
.cart-item:hover {
background: rgba(255, 0, 0, 0.2);
}
.trade-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #444;
}
.trade-summary {
display: flex;
flex-direction: column;
align-items: center;
font-size: 1.1rem;
}
.trade-total {
font-weight: bold;
color: #ffd700;
}
.trade-action-btn {
padding: 0.75rem 2rem;
font-size: 1.2rem;
font-weight: bold;
text-transform: uppercase;
background: linear-gradient(to bottom, #4caf50, #2e7d32);
border: none;
color: white;
cursor: pointer;
clip-path: var(--game-clip-path);
}
.trade-action-btn:disabled {
background: #555;
cursor: not-allowed;
opacity: 0.7;
}
.quantity-modal {
background: #2d3748;
padding: 1rem;
border: 1px solid #4a5568;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
clip-path: var(--game-clip-path);
}
.qty-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.qty-input {
width: 60px;
text-align: center;
padding: 0.25rem;
background: #1a202c;
border: 1px solid #4a5568;
color: white;
}

View File

@@ -0,0 +1,505 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useGame } from '../../contexts/GameContext';
import { GAME_API_URL } from '../../config';
import { GameModal } from './GameModal';
import { GameButton } from '../common/GameButton';
import { GameTooltip } from '../common/GameTooltip';
import { getAssetPath } from '../../utils/assetPath';
import { getTranslatedText } from '../../utils/i18nUtils';
import './TradeModal.css';
interface TradeItem {
item_id: string;
name: string; // This might be a translatable object or string
emoji?: string;
quantity: number;
value: number;
unique_item_id?: number;
is_infinite?: boolean;
description?: string;
item_type?: string;
stats?: any;
unique_stats?: any;
image_path?: string;
tier?: number;
effects?: any;
weight?: number;
volume?: number;
hp_restore?: number;
stamina_restore?: number;
equippable?: boolean;
consumable?: boolean;
slot?: string;
is_equipped?: boolean;
}
interface Selection {
item_id: string;
quantity: number;
value: number;
unique_item_id?: number;
name: string;
emoji?: string;
image_path?: string;
tier?: number;
}
interface TradeModalProps {
npcId: string;
onClose: () => void;
}
export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
const { token, inventory: playerInv } = useGame();
const [npcStock, setNpcStock] = useState<TradeItem[]>([]);
const [playerItems, setPlayerItems] = useState<TradeItem[]>([]);
const [buying, setBuying] = useState<Selection[]>([]); // Items selected from NPC
const [selling, setSelling] = useState<Selection[]>([]); // Items selected from Player
const [tradeConfig, setTradeConfig] = useState<any>({});
// Filters
const [npcSearch, setNpcSearch] = useState('');
const [playerSearch, setPlayerSearch] = useState('');
// Selection logic
const [selectedItem, setSelectedItem] = useState<TradeItem | null>(null);
const [showQtyModal, setShowQtyModal] = useState(false);
const [qtyInput, setQtyInput] = useState(1);
const [selectionSource, setSelectionSource] = useState<'npc' | 'player'>('npc');
useEffect(() => {
// Determine player items from context inventory
if (!playerInv) return;
const mappedPlayerItems = playerInv.map((i: any) => ({
...i,
// Backend now sends flat structure, but we keep falbacks just in case
value: i.value || i.item_def?.value || 10,
name: i.name || (i.item_def ? i.item_def.name : i.item_id),
emoji: i.emoji || i.item_def?.emoji || '📦',
description: i.description || (i.item_def ? i.item_def.description : ''),
item_type: i.type || i.item_type || i.item_def?.item_type, // 'type' from backend, 'item_type' variable
stats: i.stats || i.item_def?.stats,
unique_stats: i.unique_stats,
image_path: i.image_path || i.item_def?.image_path,
tier: i.tier || i.item_def?.tier,
effects: i.effects || i.item_def?.effects,
weight: i.weight || i.item_def?.weight || 0,
volume: i.volume || i.item_def?.volume || 0,
hp_restore: i.hp_restore || i.item_def?.hp_restore,
stamina_restore: i.stamina_restore || i.item_def?.stamina_restore,
equippable: i.equippable || i.item_def?.equippable,
consumable: i.consumable || i.item_def?.consumable
}));
setPlayerItems(mappedPlayerItems);
}, [playerInv]);
useEffect(() => {
const fetchStock = async () => {
try {
const res = await fetch(`${GAME_API_URL}/trade/${npcId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
// Map NPC stock similarly
// Note: The backend returns item details mixed in usually, but let's verify if we need to map via item_def logic on frontend or if backend sends it all.
// Looking at trade.py, it sends 'name', 'emoji', 'quantity', 'value', 'unique_item_id'.
// If we want stats, we might need more data from backend or map it if we have a global items list.
// Currently only name/emoji/value are strictly returned.
// Ideally backend should send full item_def or we need to access ItemsManager context if available.
// For now, we work with what we have, or assume backend trade endpoint includes simplified data.
// Wait, trade.py returns: "name": item_def.name, "emoji": item_def.emoji... it doesn't return generic stats.
// We'll stick to what we have but if backend is updated we'd use it.
// *Crucial*: To show stats, we'd need them in the API response. I will assume for now we might miss detailed stats for NPC items unless I update backend.
// BUT, for Player items we have full access via context.
setNpcStock(data.stock);
setTradeConfig(data.config);
} catch (e) {
console.error(e);
}
};
if (token) fetchStock();
}, [npcId, token]);
// Computed Lists (Virtual Inventory with Subtraction)
const availableNpcStock = useMemo(() => {
return npcStock.map(item => {
// Find how many are currently in 'buying' list
const inCart = buying.find(b => b.item_id === item.item_id && b.unique_item_id === item.unique_item_id);
const qtyInCart = inCart ? inCart.quantity : 0;
const remaining = item.is_infinite ? 999 : Math.max(0, item.quantity - qtyInCart);
return { ...item, _displayQuantity: remaining };
}).filter(item => {
// Filter by search
const n = getTranslatedText(item.name).toLowerCase();
return n.includes(npcSearch.toLowerCase());
});
}, [npcStock, npcSearch, buying]);
const availablePlayerInv = useMemo(() => {
return playerItems.map(item => {
// Find how many are currently in 'selling' list
const inCart = selling.find(s => s.item_id === item.item_id && s.unique_item_id === item.unique_item_id);
const qtyInCart = inCart ? inCart.quantity : 0;
const remaining = Math.max(0, item.quantity - qtyInCart);
return { ...item, _displayQuantity: remaining };
}).filter(item => {
const n = getTranslatedText(item.name).toLowerCase();
if (!n.includes(playerSearch.toLowerCase())) return false;
// Hide items with 0 quantity remaining?
if (item._displayQuantity <= 0) return false;
if (item.is_equipped) return false; // Usually can't sell equipped items directly
return true;
});
}, [playerItems, playerSearch, selling]);
// Calculations
const buyTotal = buying.reduce((sum, item) => sum + (item.value * (tradeConfig.buy_markup || 1) * item.quantity), 0);
const sellTotal = selling.reduce((sum, item) => sum + (item.value * (tradeConfig.sell_markdown || 1) * item.quantity), 0);
// Validity checking
const isValid = sellTotal >= buyTotal && (buying.length > 0 || selling.length > 0);
const handleItemClick = (item: TradeItem, source: 'npc' | 'player') => {
// Use the displayed quantity which already accounts for cart
// But we need the *original* item to check is_infinite etc.
// Actually, we can just use the mapped item's _displayQuantity as the max available to add *more*.
const maxAvailable = (item as any)._displayQuantity;
if (maxAvailable <= 0) return;
setSelectedItem(item);
setSelectionSource(source);
setQtyInput(1);
setShowQtyModal(true);
};
const confirmSelection = () => {
if (!selectedItem) return;
const list = selectionSource === 'npc' ? buying : selling;
const setList = selectionSource === 'npc' ? setBuying : setSelling;
// Max available to add is displayed quantity
const maxAvailable = (selectedItem as any)._displayQuantity;
let finalQty = qtyInput;
if (finalQty > maxAvailable) finalQty = maxAvailable;
if (finalQty <= 0) {
setShowQtyModal(false);
setSelectedItem(null);
return;
}
const existingIdx = list.findIndex(i => i.item_id === selectedItem.item_id && i.unique_item_id === selectedItem.unique_item_id);
if (existingIdx >= 0) {
// Update quantity
const newList = [...list];
newList[existingIdx].quantity += finalQty;
setList(newList);
} else {
// Add new
setList([...list, {
item_id: selectedItem.item_id,
quantity: finalQty,
value: selectedItem.value,
unique_item_id: selectedItem.unique_item_id,
name: selectedItem.name,
emoji: selectedItem.emoji,
image_path: selectedItem.image_path,
tier: selectedItem.tier
}]);
}
setShowQtyModal(false);
setSelectedItem(null);
};
const executeTrade = async () => {
try {
const res = await fetch(`${GAME_API_URL}/trade/${npcId}/execute`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
buying: buying,
selling: selling
})
});
const result = await res.json();
if (res.ok) {
alert("Trade Successful!");
onClose();
// Should trigger inventory refresh
window.location.reload();
} else {
alert("Trade Failed: " + result.detail);
}
} catch (e) {
console.error(e);
alert("Trade Error");
}
};
// Tooltip Renderer (Reusable) - REMOVED as we use inline now to match InventoryModal structure better
if (!npcStock || !tradeConfig) return <div className="loading-text">Loading trade data...</div>;
return (
<GameModal
title="Trading"
onClose={onClose}
className="trade-modal"
>
<div className="trade-container">
<div className="trade-content">
{/* LEFT: NPC STOCK */}
<div className="trade-column">
<h3 className="column-header">Merchant Stock {tradeConfig.buy_markup && <small>(x{tradeConfig.buy_markup})</small>}</h3>
<input
type="text"
className="search-bar"
placeholder="Filter..."
value={npcSearch}
onChange={(e) => setNpcSearch(e.target.value)}
/>
<div className="inventory-grid">
{availableNpcStock.map((item, idx) => {
// Prepare tooltip content matching InventoryModal
const tooltipContent = (
<div className="item-tooltip-content">
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
{item.emoji} {getTranslatedText(item.name)}
</div>
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
<div className="tooltip-stats">
<div style={{ color: '#ffd700' }}>💰 {Math.round(item.value * (tradeConfig.buy_markup || 1))}</div>
{item.weight !== undefined && <div> {item.weight}kg</div>}
{item.volume !== undefined && <div>📦 {item.volume}L</div>}
</div>
<div className="stat-badges-container">
{/* Capacity */}
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
<span className="stat-badge capacity">
+{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
</span>
)}
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
<span className="stat-badge capacity">
📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
</span>
)}
{/* Combat Stats */}
{(item.unique_stats?.damage_min || item.stats?.damage_min) && (
<span className="stat-badge damage">
{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</span>
)}
{(item.unique_stats?.armor || item.stats?.armor) && (
<span className="stat-badge armor">
🛡 +{item.unique_stats?.armor || item.stats?.armor}
</span>
)}
{/* Consumables */}
{item.hp_restore && (
<span className="stat-badge health">
+{item.hp_restore} HP
</span>
)}
{item.stamina_restore && (
<span className="stat-badge stamina">
+{item.stamina_restore} Stm
</span>
)}
</div>
</div>
);
return (
<GameTooltip key={idx} content={tooltipContent}>
<div className={`trade-item-card text-tier-${item.tier || 0}`} onClick={() => handleItemClick(item, 'npc')}>
<div className="trade-item-image">
{item.image_path ? (
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="trade-item-img" />
) : (
<div className={`item-icon-large tier-${item.tier || 0}`} style={{ fontSize: '2.5rem' }}>{item.emoji || '📦'}</div>
)}
</div>
{(item.is_infinite || (item as any)._displayQuantity > 1) && (
<div className="trade-item-qty">{item.is_infinite ? '∞' : `x${(item as any)._displayQuantity}`}</div>
)}
<div className="trade-item-value">{Math.round(item.value * (tradeConfig.buy_markup || 1))}</div>
</div>
</GameTooltip>
);
})}
</div>
</div>
{/* CENTER: CART */}
<div className="trade-center-column">
<div className="trade-cart-section">
<div className="trade-list-header">
<span>Buying</span>
<span style={{ color: '#ff9800' }}>{Math.round(buyTotal)}</span>
</div>
<div className="cart-grid">
{buying.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>Empty</div>}
{buying.map((b, i) => (
<GameTooltip key={i} content={<div>{getTranslatedText(b.name)}<br />x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}</div>}>
<div className={`trade-item-card text-tier-${b.tier || 0}`} onClick={() => {
const n = [...buying]; n.splice(i, 1); setBuying(n);
}}>
{b.image_path ? (
<img src={getAssetPath(b.image_path)} alt={getTranslatedText(b.name)} className="trade-item-img" />
) : (
<div style={{ fontSize: '24px' }}>{b.emoji || '📦'}</div>
)}
<div className="trade-item-qty">x{b.quantity}</div>
<div className="trade-item-value">{Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}</div>
</div>
</GameTooltip>
))}
</div>
</div>
<div className="trade-cart-section">
<div className="trade-list-header">
<span>Selling</span>
<span style={{ color: '#4caf50' }}>{Math.round(sellTotal)}</span>
</div>
<div className="cart-grid">
{selling.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>Empty</div>}
{selling.map((b, i) => (
<GameTooltip key={i} content={<div>{getTranslatedText(b.name)}<br />x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}</div>}>
<div className={`trade-item-card text-tier-${b.tier || 0}`} onClick={() => {
const n = [...selling]; n.splice(i, 1); setSelling(n);
}}>
{b.image_path ? (
<img src={getAssetPath(b.image_path)} alt={getTranslatedText(b.name)} className="trade-item-img" />
) : (
<div style={{ fontSize: '24px' }}>{b.emoji || '📦'}</div>
)}
<div className="trade-item-qty">x{b.quantity}</div>
<div className="trade-item-value">{Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}</div>
</div>
</GameTooltip>
))}
</div>
</div>
</div>
{/* RIGHT: PLAYER INVENTORY */}
<div className="trade-column">
<h3 className="column-header">Inventory {tradeConfig.sell_markdown && <small>(x{tradeConfig.sell_markdown})</small>}</h3>
<input
type="text"
className="search-bar"
placeholder="Filter..."
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
/>
<div className="inventory-grid">
{availablePlayerInv.map((item, idx) => {
const tooltipContent = (
<div className="item-tooltip-content">
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
{item.emoji} {getTranslatedText(item.name)}
</div>
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
<div className="tooltip-stats">
<div style={{ color: '#4caf50' }}>💰 {Math.round(item.value * (tradeConfig.sell_markdown || 1))}</div>
</div>
<div className="stat-badges-container">
{/* Same badges logic could be extracted but duplicating for speed/safety */}
{(item.unique_stats?.damage_min || item.stats?.damage_min) && (
<span className="stat-badge damage">
{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
</span>
)}
{item.hp_restore && <span className="stat-badge health"> +{item.hp_restore} HP</span>}
</div>
</div>
);
return (
<GameTooltip key={idx} content={tooltipContent}>
<div className={`trade-item-card text-tier-${item.tier || 0}`} onClick={() => handleItemClick(item, 'player')}>
<div className="trade-item-image">
{item.image_path ? (
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="trade-item-img" />
) : (
<div className={`item-icon-large tier-${item.tier || 0}`} style={{ fontSize: '2.5rem' }}>{item.emoji || '📦'}</div>
)}
</div>
{(item as any)._displayQuantity > 1 && <div className="trade-item-qty">x{(item as any)._displayQuantity}</div>}
<div className="trade-item-value">{Math.round(item.value * (tradeConfig.sell_markdown || 1))}</div>
</div>
</GameTooltip>
);
})}
</div>
</div>
</div>
<div className="trade-footer">
<div className="trade-summary">
<span>Balance</span>
<span className={`trade-total ${sellTotal >= buyTotal ? 'text-green' : 'text-red'}`}>
{Math.round(sellTotal - buyTotal)}
</span>
</div>
<button className="trade-action-btn" onClick={executeTrade} disabled={!isValid}>
{isValid ? "CONFIRM TRADE" : "INVALID OFFER"}
</button>
<div style={{ width: '60px' }}></div> {/* Spacer */}
</div>
{showQtyModal && selectedItem && (
<div className="dialog-modal-overlay" style={{ zIndex: 1101 }}>
<div className="quantity-modal">
<h4>How many {getTranslatedText(selectedItem.name)}?</h4>
<div className="qty-controls">
<GameButton size="sm" onClick={() => setQtyInput(Math.max(1, qtyInput - 1))}>-</GameButton>
<input
className="qty-input"
type="number"
value={qtyInput}
onChange={e => setQtyInput(parseInt(e.target.value) || 1)}
min="1"
/>
<GameButton size="sm" onClick={() => setQtyInput(qtyInput + 1)}>+</GameButton>
<GameButton size="sm" onClick={() => {
const max = (selectedItem as any)._displayQuantity || 1;
setQtyInput(max);
}}>Max</GameButton>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<GameButton variant="primary" onClick={confirmSelection}>Confirm</GameButton>
<GameButton variant="secondary" onClick={() => setShowQtyModal(false)}>Cancel</GameButton>
</div>
</div>
</div>
)}
</div>
</GameModal>
);
};

View File

@@ -22,6 +22,7 @@ export interface GameEngineState {
profile: Profile | null
loading: boolean
message: string
quests: { active: any[], available: any[] }
// Combat state
combatState: CombatState | null
@@ -140,6 +141,10 @@ export interface GameEngineActions {
addNPCToLocation: (npc: any) => void
removeNPCFromLocation: (enemyId: string) => void
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
// Quests
updateQuests: (active: any[], available: any[]) => void
handleQuestUpdate: (quest: any) => void
}
export function useGameEngine(
@@ -164,6 +169,7 @@ export function useGameEngine(
const [corpseDetails, setCorpseDetails] = useState<any>(null)
const [movementCooldown, setMovementCooldown] = useState<number>(0)
const [failedActionItemId, setFailedActionItemId] = useState<string | number | null>(null)
const [quests, setQuests] = useState<{ active: any[], available: any[] }>({ active: [], available: [] })
// const [enemyTurnMessage, setEnemyTurnMessage] = useState<string>('') // Moved to Combat.tsx
const [equipment, setEquipment] = useState<Equipment>({})
@@ -265,15 +271,24 @@ export function useGameEngine(
const fetchGameData = useCallback(async (skipCombatLogInit: boolean = false) => {
try {
const [stateRes, locationRes, profileRes, combatRes, pvpRes] = await Promise.all([
const [stateRes, locationRes, profileRes, combatRes, pvpRes, activeQuestsRes, availableQuestsRes] = await Promise.all([
api.get('/api/game/state'),
api.get('/api/game/location'),
api.get('/api/game/profile'),
api.get('/api/game/combat'),
api.get('/api/game/pvp/status')
api.get('/api/game/pvp/status'),
api.get('/api/quests/active'),
api.get('/api/quests/available')
])
const gameState = stateRes.data
// Update quests
setQuests({
active: activeQuestsRes.data || [],
available: availableQuestsRes.data || []
})
setPlayerState({
location_id: gameState.player.location_id,
location_name: gameState.location?.name || 'Unknown',
@@ -508,6 +523,46 @@ export function useGameEngine(
})
}, [])
const updateQuests = useCallback((active: any[], available: any[]) => {
setQuests({ active, available })
}, [])
const handleQuestUpdate = useCallback((quest: any) => {
setQuests(prev => {
// 1. Update active quests list
let newActive = [...prev.active]
const idx = newActive.findIndex(q => q.quest_id === quest.quest_id)
// If quest is active or completed, it should be in the active list
if (quest.status === 'active' || quest.status === 'completed' || quest.status === 'can_turn_in') {
if (idx >= 0) {
// Update existing
newActive[idx] = { ...newActive[idx], ...quest }
} else {
// Add new
newActive.push(quest)
}
} else {
// If failed or cancelled, maybe keep it or update status?
if (idx >= 0) newActive[idx] = { ...newActive[idx], ...quest }
}
// 2. Remove from available list if it was there (since it's now active/completed)
// Only if status is active/completed. If it became available, we'd need logic for that.
let newAvailable = prev.available
if (quest.status === 'active' || quest.status === 'completed') {
newAvailable = prev.available.filter(q => q.quest_id !== quest.quest_id)
} else if (quest.status === 'available') {
// It became available (e.g. repeatable cooldown finished?)
if (!newAvailable.find(q => q.quest_id === quest.quest_id)) {
newAvailable = [...newAvailable, quest]
}
}
return { active: newActive, available: newAvailable }
})
}, [])
// State object
const state: GameEngineState = {
playerState,
@@ -515,6 +570,7 @@ export function useGameEngine(
profile,
loading,
message,
quests,
combatState,
combatLog,
enemyName,
@@ -778,6 +834,11 @@ export function useGameEngine(
}
const response = await api.post('/api/game/combat/action', payload)
if (response.data.quest_updates) {
response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q))
}
return response.data
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Combat action failed')
@@ -1151,7 +1212,9 @@ export function useGameEngine(
return newSet
})
},
updateStatusEffect
updateStatusEffect,
updateQuests,
handleQuestUpdate
}
// Polling fallback for PvP Combat reliability

7
pwa/src/config.ts Normal file
View File

@@ -0,0 +1,7 @@
export const API_URL = import.meta.env.VITE_API_URL || (
import.meta.env.PROD
? 'https://api-staging.echoesoftheash.com'
: 'http://localhost:8000'
);
export const GAME_API_URL = `${API_URL}/api`;

View File

@@ -0,0 +1,26 @@
import React, { createContext, useContext } from 'react';
interface GameContextType {
token: string | null;
locale: string;
inventory: any[];
state: any;
actions: any;
}
const GameContext = createContext<GameContextType | undefined>(undefined);
export const GameProvider: React.FC<{
children: React.ReactNode;
value: GameContextType;
}> = ({ children, value }) => {
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
};
export const useGame = () => {
const context = useContext(GameContext);
if (context === undefined) {
throw new Error('useGame must be used within a GameProvider');
}
return context;
};

View File

@@ -22,6 +22,7 @@
"qty": "Qty",
"enemy": "Enemy",
"you": "You",
"quests": "Quests",
"all": "All"
},
"auth": {

View File

@@ -20,6 +20,7 @@
"pickUp": "Recoger",
"pickUpAll": "Recoger Todo",
"qty": "Cant",
"quests": "Misiones",
"all": "Todo"
},
"auth": {