Added trading and quests, checkpoint push
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
112
pwa/src/components/game/DialogModal.css
Normal file
112
pwa/src/components/game/DialogModal.css
Normal 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;
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
102
pwa/src/components/game/GameModal.css
Normal file
102
pwa/src/components/game/GameModal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
35
pwa/src/components/game/GameModal.tsx
Normal file
35
pwa/src/components/game/GameModal.tsx
Normal 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}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="game-modal-content">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="game-modal-footer">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
146
pwa/src/components/game/QuestJournal.css
Normal file
146
pwa/src/components/game/QuestJournal.css
Normal 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;
|
||||
}
|
||||
120
pwa/src/components/game/QuestJournal.tsx
Normal file
120
pwa/src/components/game/QuestJournal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
316
pwa/src/components/game/TradeModal.css
Normal file
316
pwa/src/components/game/TradeModal.css
Normal 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;
|
||||
}
|
||||
505
pwa/src/components/game/TradeModal.tsx
Normal file
505
pwa/src/components/game/TradeModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
7
pwa/src/config.ts
Normal 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`;
|
||||
26
pwa/src/contexts/GameContext.tsx
Normal file
26
pwa/src/contexts/GameContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -22,6 +22,7 @@
|
||||
"qty": "Qty",
|
||||
"enemy": "Enemy",
|
||||
"you": "You",
|
||||
"quests": "Quests",
|
||||
"all": "All"
|
||||
},
|
||||
"auth": {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"pickUp": "Recoger",
|
||||
"pickUpAll": "Recoger Todo",
|
||||
"qty": "Cant",
|
||||
"quests": "Misiones",
|
||||
"all": "Todo"
|
||||
},
|
||||
"auth": {
|
||||
|
||||
Reference in New Issue
Block a user