2631 lines
118 KiB
TypeScript
2631 lines
118 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
||
import api from '../services/api'
|
||
import GameHeader from './GameHeader'
|
||
import './Game.css'
|
||
|
||
interface PlayerState {
|
||
location_id: string
|
||
location_name: string
|
||
health: number
|
||
max_health: number
|
||
stamina: number
|
||
max_stamina: number
|
||
inventory: any[]
|
||
status_effects: any[]
|
||
}
|
||
|
||
interface DirectionDetail {
|
||
direction: string
|
||
stamina_cost: number
|
||
distance: number
|
||
destination: string
|
||
destination_name?: string
|
||
}
|
||
|
||
interface Location {
|
||
id: string
|
||
name: string
|
||
description: string
|
||
directions: string[]
|
||
directions_detailed?: DirectionDetail[]
|
||
danger_level?: number
|
||
npcs: any[]
|
||
items: any[]
|
||
image_url?: string
|
||
interactables?: any[]
|
||
other_players?: any[]
|
||
corpses?: any[]
|
||
tags?: string[] // Tags for special location features like workbench
|
||
}
|
||
|
||
interface Profile {
|
||
name: string
|
||
level: number
|
||
xp: number
|
||
hp: number
|
||
max_hp: number
|
||
stamina: number
|
||
max_stamina: number
|
||
strength: number
|
||
agility: number
|
||
endurance: number
|
||
intellect: number
|
||
unspent_points: number
|
||
is_dead: boolean
|
||
max_weight?: number
|
||
current_weight?: number
|
||
max_volume?: number
|
||
current_volume?: number
|
||
}
|
||
|
||
function Game() {
|
||
const [playerState, setPlayerState] = useState<PlayerState | null>(null)
|
||
const [location, setLocation] = useState<Location | null>(null)
|
||
const [profile, setProfile] = useState<Profile | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [message, setMessage] = useState('')
|
||
const [selectedItem, setSelectedItem] = useState<any>(null)
|
||
const [combatState, setCombatState] = useState<any>(null)
|
||
const [combatLog, setCombatLog] = useState<Array<{time: string, message: string, isPlayer: boolean}>>([])
|
||
const [enemyName, setEnemyName] = useState<string>('')
|
||
const [enemyImage, setEnemyImage] = useState<string>('')
|
||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
|
||
const [expandedCorpse, setExpandedCorpse] = useState<string | null>(null)
|
||
const [corpseDetails, setCorpseDetails] = useState<any>(null)
|
||
const [movementCooldown, setMovementCooldown] = useState<number>(0)
|
||
const [enemyTurnMessage, setEnemyTurnMessage] = useState<string>('')
|
||
const [equipment, setEquipment] = useState<any>({})
|
||
const [showCraftingMenu, setShowCraftingMenu] = useState<boolean>(false)
|
||
const [showRepairMenu, setShowRepairMenu] = useState<boolean>(false)
|
||
const [craftableItems, setCraftableItems] = useState<any[]>([])
|
||
const [repairableItems, setRepairableItems] = useState<any[]>([])
|
||
const [workbenchTab, setWorkbenchTab] = useState<'craft' | 'repair' | 'uncraft'>('craft')
|
||
const [craftFilter, setCraftFilter] = useState<string>('')
|
||
const [craftCategoryFilter, setCraftCategoryFilter] = useState<string>('all')
|
||
const [repairFilter, setRepairFilter] = useState<string>('')
|
||
const [uncraftFilter, setUncraftFilter] = useState<string>('')
|
||
const [uncraftableItems, setUncraftableItems] = useState<any[]>([])
|
||
const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null)
|
||
|
||
// Use ref for synchronous duplicate checking (state updates are async)
|
||
const lastSeenPvPActionRef = useRef<string | null>(null)
|
||
|
||
// Mobile menu state
|
||
const [mobileMenuOpen, setMobileMenuOpen] = useState<'none' | 'left' | 'right' | 'bottom'>('none')
|
||
const [mobileHeaderOpen, setMobileHeaderOpen] = useState<boolean>(false)
|
||
|
||
useEffect(() => {
|
||
fetchGameData()
|
||
|
||
// Set up polling for location updates and PvP combat detection
|
||
const pollInterval = setInterval(() => {
|
||
// Stop polling if combat is over (save server resources)
|
||
if (combatState?.pvp_combat?.combat_over) {
|
||
return
|
||
}
|
||
|
||
// Always poll if page is visible - need to detect incoming PvP
|
||
if (!document.hidden) {
|
||
// Check combat state at the time of polling (not from stale closure)
|
||
fetchGameData(true)
|
||
}
|
||
}, 5000) // Poll every 5 seconds
|
||
|
||
// Cleanup on unmount
|
||
return () => clearInterval(pollInterval)
|
||
}, [combatState?.pvp_combat?.combat_over]) // Re-run if combat_over state changes
|
||
|
||
// Auto-dismiss messages after 4 seconds on mobile
|
||
useEffect(() => {
|
||
if (message && window.innerWidth <= 768) {
|
||
const timer = setTimeout(() => {
|
||
setMessage('')
|
||
}, 4000)
|
||
return () => clearTimeout(timer)
|
||
}
|
||
}, [message])
|
||
|
||
// Countdown effect for movement cooldown
|
||
useEffect(() => {
|
||
if (movementCooldown > 0) {
|
||
const timer = setTimeout(() => {
|
||
setMovementCooldown(prev => Math.max(0, prev - 1))
|
||
}, 1000)
|
||
return () => clearTimeout(timer)
|
||
}
|
||
}, [movementCooldown])
|
||
|
||
const fetchGameData = async (skipCombatLogInit: boolean = false) => {
|
||
try {
|
||
const [stateRes, locationRes, profileRes, combatRes, pvpRes] = 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')
|
||
])
|
||
|
||
// Map game state to player state format
|
||
const gameState = stateRes.data
|
||
setPlayerState({
|
||
location_id: gameState.player.location_id,
|
||
location_name: gameState.location?.name || 'Unknown',
|
||
health: gameState.player.hp,
|
||
max_health: gameState.player.max_hp,
|
||
stamina: gameState.player.stamina,
|
||
max_stamina: gameState.player.max_stamina,
|
||
inventory: gameState.inventory || [],
|
||
status_effects: []
|
||
})
|
||
|
||
setLocation(locationRes.data)
|
||
setProfile(profileRes.data.player || profileRes.data)
|
||
setEquipment(gameState.equipment || {})
|
||
|
||
// Set movement cooldown if available (add 1 second buffer only if there's actual cooldown)
|
||
if (gameState.player.movement_cooldown !== undefined) {
|
||
const cooldown = gameState.player.movement_cooldown
|
||
setMovementCooldown(cooldown > 0 ? Math.ceil(cooldown) + 1 : 0)
|
||
}
|
||
|
||
// Check for PvP combat first (takes priority)
|
||
if (pvpRes.data.in_pvp_combat) {
|
||
const newCombatState = {
|
||
...pvpRes.data,
|
||
is_pvp: true
|
||
}
|
||
|
||
setCombatState(newCombatState)
|
||
|
||
// Check if there's a new last_action to add to combat log (avoid duplicates)
|
||
// Use ref for synchronous check to prevent race conditions with state updates
|
||
if (pvpRes.data.pvp_combat.last_action &&
|
||
pvpRes.data.pvp_combat.last_action !== lastSeenPvPActionRef.current) {
|
||
|
||
// Update both state and ref
|
||
setLastSeenPvPAction(pvpRes.data.pvp_combat.last_action)
|
||
lastSeenPvPActionRef.current = pvpRes.data.pvp_combat.last_action
|
||
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
|
||
// Parse the action message (format: "message|timestamp")
|
||
const lastActionRaw = pvpRes.data.pvp_combat.last_action
|
||
const [lastAction, _actionTimestamp] = lastActionRaw.split('|')
|
||
|
||
const yourUsername = pvpRes.data.pvp_combat.is_attacker ?
|
||
pvpRes.data.pvp_combat.attacker.username :
|
||
pvpRes.data.pvp_combat.defender.username
|
||
|
||
// Check if the message starts with your username (e.g., "YourName attacks" or "YourName fled")
|
||
const isYourAction = lastAction.startsWith(yourUsername + ' ')
|
||
|
||
setCombatLog((prev: any) => [{
|
||
time: timeStr,
|
||
message: lastAction,
|
||
isPlayer: isYourAction
|
||
}, ...prev])
|
||
}
|
||
|
||
// Initialize combat log if empty
|
||
if (!skipCombatLogInit && combatLog.length === 0) {
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
const opponent = pvpRes.data.pvp_combat.is_attacker ?
|
||
pvpRes.data.pvp_combat.defender :
|
||
pvpRes.data.pvp_combat.attacker
|
||
setCombatLog([{
|
||
time: timeStr,
|
||
message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`,
|
||
isPlayer: true
|
||
}])
|
||
}
|
||
|
||
// Combat over state is handled in the UI with an acknowledgment button
|
||
// Don't auto-close anymore
|
||
}
|
||
// If not in PvP combat anymore, clear the tracking
|
||
else if (lastSeenPvPAction !== null) {
|
||
setLastSeenPvPAction(null)
|
||
lastSeenPvPActionRef.current = null
|
||
}
|
||
// Check for active PvE combat
|
||
else if (combatRes.data.in_combat) {
|
||
setCombatState(combatRes.data)
|
||
// Only initialize combat log if it's empty AND we're not skipping initialization
|
||
// Skip initialization after encounters since they already set the combat log
|
||
if (!skipCombatLogInit && combatLog.length === 0) {
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
setCombatLog([{
|
||
time: timeStr,
|
||
message: 'Combat in progress...',
|
||
isPlayer: true
|
||
}])
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch game data:', error)
|
||
setMessage('Failed to load game data')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleMove = async (direction: string) => {
|
||
// Prevent movement during combat
|
||
if (combatState) {
|
||
setMessage('Cannot move while in combat!')
|
||
return
|
||
}
|
||
|
||
// Close workbench menu when moving
|
||
if (showCraftingMenu || showRepairMenu) {
|
||
handleCloseCrafting()
|
||
}
|
||
|
||
// Close mobile menu after movement
|
||
setMobileMenuOpen('none')
|
||
|
||
try {
|
||
setMessage('Moving...')
|
||
const response = await api.post('/api/game/move', { direction })
|
||
setMessage(response.data.message)
|
||
|
||
// Check if an encounter was triggered
|
||
if (response.data.encounter && response.data.encounter.triggered) {
|
||
const encounter = response.data.encounter
|
||
setMessage(encounter.message)
|
||
|
||
// Store enemy info
|
||
setEnemyName(encounter.combat.npc_name)
|
||
setEnemyImage(encounter.combat.npc_image)
|
||
|
||
// Set combat state
|
||
setCombatState({
|
||
in_combat: true,
|
||
combat_over: false,
|
||
player_won: false,
|
||
combat: encounter.combat
|
||
})
|
||
|
||
// Clear combat log for new encounter
|
||
setCombatLog([])
|
||
|
||
// Add initial message to combat log
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
setCombatLog([{
|
||
time: timeStr,
|
||
message: `⚠️ ${encounter.combat.npc_name} ambushes you!`,
|
||
isPlayer: false
|
||
}])
|
||
|
||
// Refresh all game data after movement, but skip combat log init since we just set it
|
||
await fetchGameData(true)
|
||
} else {
|
||
// Normal movement, refresh game data normally
|
||
await fetchGameData()
|
||
}
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Move failed')
|
||
}
|
||
}
|
||
|
||
const handlePickup = async (itemId: number, quantity: number = 1) => {
|
||
try {
|
||
setMessage(`Picking up ${quantity > 1 ? quantity + ' items' : 'item'}...`)
|
||
const response = await api.post('/api/game/pickup', { item_id: itemId, quantity })
|
||
setMessage(response.data.message || 'Item picked up!')
|
||
fetchGameData() // Refresh to update inventory and ground items
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to pick up item')
|
||
// Refresh to remove items that no longer exist
|
||
fetchGameData()
|
||
}
|
||
}
|
||
|
||
const handleOpenCrafting = async () => {
|
||
try {
|
||
const response = await api.get('/api/game/craftable')
|
||
setCraftableItems(response.data.craftable_items)
|
||
setShowCraftingMenu(true)
|
||
setShowRepairMenu(false)
|
||
setWorkbenchTab('craft')
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to load crafting menu')
|
||
}
|
||
}
|
||
|
||
const handleCloseCrafting = () => {
|
||
setShowCraftingMenu(false)
|
||
setShowRepairMenu(false)
|
||
setCraftableItems([])
|
||
setRepairableItems([])
|
||
setUncraftableItems([])
|
||
setCraftFilter('')
|
||
setRepairFilter('')
|
||
setUncraftFilter('')
|
||
}
|
||
|
||
const handleCraft = async (itemId: string) => {
|
||
try {
|
||
setMessage('Crafting...')
|
||
const response = await api.post('/api/game/craft_item', { item_id: itemId })
|
||
setMessage(response.data.message || 'Item crafted!')
|
||
await fetchGameData()
|
||
// Refresh craftable items list
|
||
const craftableRes = await api.get('/api/game/craftable')
|
||
setCraftableItems(craftableRes.data.craftable_items)
|
||
// Refresh salvageable items if on that tab
|
||
if (workbenchTab === 'uncraft') {
|
||
const salvageableRes = await api.get('/api/game/salvageable')
|
||
setUncraftableItems(salvageableRes.data.salvageable_items)
|
||
}
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to craft item')
|
||
}
|
||
}
|
||
|
||
const handleOpenRepair = async () => {
|
||
try {
|
||
const response = await api.get('/api/game/repairable')
|
||
setRepairableItems(response.data.repairable_items)
|
||
setShowRepairMenu(true)
|
||
setShowCraftingMenu(false)
|
||
setWorkbenchTab('repair')
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to load repair menu')
|
||
}
|
||
}
|
||
|
||
const handleRepairFromMenu = async (uniqueItemId: number, inventoryId?: number) => {
|
||
try {
|
||
setMessage('Repairing...')
|
||
const response = await api.post('/api/game/repair_item', {
|
||
unique_item_id: uniqueItemId,
|
||
inventory_id: inventoryId
|
||
})
|
||
setMessage(response.data.message || 'Item repaired!')
|
||
await fetchGameData()
|
||
// Refresh repairable items list
|
||
const repairableRes = await api.get('/api/game/repairable')
|
||
setRepairableItems(repairableRes.data.repairable_items)
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to repair item')
|
||
}
|
||
}
|
||
|
||
const handleUncraft = async (uniqueItemId: number, inventoryId: number) => {
|
||
try {
|
||
setMessage('Salvaging...')
|
||
const response = await api.post('/api/game/uncraft_item', {
|
||
unique_item_id: uniqueItemId,
|
||
inventory_id: inventoryId
|
||
})
|
||
const data = response.data
|
||
let msg = data.message || 'Item salvaged!'
|
||
if (data.materials_yielded && data.materials_yielded.length > 0) {
|
||
msg += '\n✅ Yielded: ' + data.materials_yielded.map((m: any) => `${m.emoji} ${m.name} x${m.quantity}`).join(', ')
|
||
}
|
||
if (data.materials_lost && data.materials_lost.length > 0) {
|
||
msg += '\n⚠️ Lost: ' + data.materials_lost.map((m: any) => `${m.emoji} ${m.name} x${m.quantity}`).join(', ')
|
||
}
|
||
setMessage(msg)
|
||
await fetchGameData()
|
||
// Refresh salvageable items list
|
||
const salvageableRes = await api.get('/api/game/salvageable')
|
||
setUncraftableItems(salvageableRes.data.salvageable_items)
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to uncraft item')
|
||
}
|
||
}
|
||
|
||
const handleSwitchWorkbenchTab = async (tab: 'craft' | 'repair' | 'uncraft') => {
|
||
setWorkbenchTab(tab)
|
||
try {
|
||
if (tab === 'craft') {
|
||
const response = await api.get('/api/game/craftable')
|
||
setCraftableItems(response.data.craftable_items)
|
||
} else if (tab === 'repair') {
|
||
const response = await api.get('/api/game/repairable')
|
||
setRepairableItems(response.data.repairable_items)
|
||
} else if (tab === 'uncraft') {
|
||
const salvageableRes = await api.get('/api/game/salvageable')
|
||
setUncraftableItems(salvageableRes.data.salvageable_items)
|
||
}
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to load items')
|
||
}
|
||
}
|
||
|
||
const handleSpendPoint = async (stat: string) => {
|
||
try {
|
||
setMessage(`Increasing ${stat}...`)
|
||
const response = await api.post(`/api/game/spend_point?stat=${stat}`)
|
||
setMessage(response.data.message || 'Stat increased!')
|
||
fetchGameData() // Refresh to update stats
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to spend point')
|
||
}
|
||
}
|
||
|
||
const handleUseItem = async (itemId: string) => {
|
||
try {
|
||
setMessage('Using item...')
|
||
const response = await api.post('/api/game/use_item', { item_id: itemId })
|
||
const data = response.data
|
||
|
||
// If in combat, add to combat log
|
||
if (combatState && data.in_combat) {
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
||
const newEntries = messages.map((msg: string) => ({
|
||
time: timeStr,
|
||
message: msg,
|
||
isPlayer: !msg.includes('attacks')
|
||
}))
|
||
setCombatLog((prev: any) => [...newEntries, ...prev])
|
||
|
||
// Check if combat ended
|
||
if (data.combat_over) {
|
||
setCombatState({
|
||
...combatState,
|
||
combat_over: true,
|
||
player_won: data.player_won
|
||
})
|
||
}
|
||
} else {
|
||
setMessage(data.message || 'Item used!')
|
||
}
|
||
|
||
fetchGameData()
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to use item')
|
||
}
|
||
}
|
||
|
||
const handleEquipItem = async (inventoryId: number) => {
|
||
try {
|
||
setMessage('Equipping item...')
|
||
const response = await api.post('/api/game/equip', { inventory_id: inventoryId })
|
||
setMessage(response.data.message || 'Item equipped!')
|
||
fetchGameData()
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to equip item')
|
||
}
|
||
}
|
||
|
||
const handleUnequipItem = async (slot: string) => {
|
||
try {
|
||
setMessage('Unequipping item...')
|
||
const response = await api.post('/api/game/unequip', { slot })
|
||
setMessage(response.data.message || 'Item unequipped!')
|
||
fetchGameData()
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to unequip item')
|
||
}
|
||
}
|
||
|
||
const handleDropItem = async (itemId: string, quantity: number = 1) => {
|
||
try {
|
||
setMessage(`Dropping ${quantity} item(s)...`)
|
||
const response = await api.post('/api/game/item/drop', { item_id: itemId, quantity })
|
||
setMessage(response.data.message || 'Item dropped!')
|
||
fetchGameData()
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to drop item')
|
||
}
|
||
}
|
||
|
||
const handleInteract = async (interactableId: string, actionId: string) => {
|
||
if (combatState) {
|
||
setMessage('Cannot interact with objects while in combat!')
|
||
return
|
||
}
|
||
|
||
// Close mobile menu to show result
|
||
setMobileMenuOpen('none')
|
||
|
||
try {
|
||
const response = await api.post('/api/game/interact', {
|
||
interactable_id: interactableId,
|
||
action_id: actionId
|
||
})
|
||
const data = response.data
|
||
let msg = data.message
|
||
if (data.items_found && data.items_found.length > 0) {
|
||
// items_found is already an array of strings like "Item Name x2"
|
||
msg += '\n\n📦 Found: ' + data.items_found.join(', ')
|
||
}
|
||
if (data.hp_change) {
|
||
msg += `\n❤️ HP: ${data.hp_change > 0 ? '+' : ''}${data.hp_change}`
|
||
}
|
||
setMessage(msg)
|
||
fetchGameData() // Refresh stats
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Interaction failed')
|
||
}
|
||
}
|
||
|
||
const handleViewCorpseDetails = async (corpseId: string) => {
|
||
try {
|
||
const response = await api.get(`/api/game/corpse/${corpseId}`)
|
||
setCorpseDetails(response.data)
|
||
setExpandedCorpse(corpseId)
|
||
// Don't show "examining" message - just open the details
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to examine corpse')
|
||
}
|
||
}
|
||
|
||
const handleLootCorpseItem = async (corpseId: string, itemIndex: number | null = null) => {
|
||
try {
|
||
setMessage('Looting...')
|
||
const response = await api.post('/api/game/loot_corpse', {
|
||
corpse_id: corpseId,
|
||
item_index: itemIndex
|
||
})
|
||
|
||
// Show message for longer
|
||
setMessage(response.data.message)
|
||
setTimeout(() => {
|
||
// Keep message visible for 5 seconds
|
||
}, 5000)
|
||
|
||
// If corpse is empty, close the details view
|
||
if (response.data.corpse_empty) {
|
||
setExpandedCorpse(null)
|
||
setCorpseDetails(null)
|
||
} else if (expandedCorpse === corpseId) {
|
||
// Refresh corpse details if still viewing (without clearing message)
|
||
try {
|
||
const detailsResponse = await api.get(`/api/game/corpse/${corpseId}`)
|
||
setCorpseDetails(detailsResponse.data)
|
||
} catch (err) {
|
||
// If corpse details fail, just close
|
||
setExpandedCorpse(null)
|
||
setCorpseDetails(null)
|
||
}
|
||
}
|
||
|
||
fetchGameData() // Refresh location and inventory
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to loot corpse')
|
||
}
|
||
}
|
||
|
||
const handleLootCorpse = async (corpseId: string) => {
|
||
// Show corpse details instead of looting all at once
|
||
handleViewCorpseDetails(corpseId)
|
||
}
|
||
|
||
const handleInitiateCombat = async (enemyId: number) => {
|
||
try {
|
||
// Close mobile menu to show combat
|
||
setMobileMenuOpen('none')
|
||
|
||
const response = await api.post('/api/game/combat/initiate', { enemy_id: enemyId })
|
||
setCombatState(response.data)
|
||
|
||
// Store enemy info to prevent it from disappearing
|
||
setEnemyName(response.data.combat.npc_name)
|
||
setEnemyImage(response.data.combat.npc_image)
|
||
|
||
// Initialize combat log with timestamp
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
setCombatLog([{
|
||
time: timeStr,
|
||
message: `Combat started with ${response.data.combat.npc_name}!`,
|
||
isPlayer: true
|
||
}])
|
||
|
||
// Refresh location to remove enemy from list
|
||
const locationRes = await api.get('/api/game/location')
|
||
setLocation(locationRes.data)
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to initiate combat')
|
||
}
|
||
}
|
||
|
||
const handleCombatAction = async (action: string) => {
|
||
try {
|
||
const response = await api.post('/api/game/combat/action', { action })
|
||
const data = response.data
|
||
|
||
// Add message to combat log with timestamp
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
|
||
// Parse the message to separate player and enemy actions
|
||
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
||
|
||
// Find player action and enemy action
|
||
const playerMessages = messages.filter((msg: string) => msg.includes('You ') || msg.includes('Your '))
|
||
const enemyMessages = messages.filter((msg: string) => msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
|
||
|
||
// Add player actions immediately
|
||
if (playerMessages.length > 0) {
|
||
const playerEntries = playerMessages.map((msg: string) => ({
|
||
time: timeStr,
|
||
message: msg,
|
||
isPlayer: true
|
||
}))
|
||
setCombatLog((prev: any) => [...playerEntries, ...prev])
|
||
|
||
// Update enemy HP immediately (but not player HP)
|
||
if (data.combat && !data.combat_over) {
|
||
setCombatState({
|
||
...combatState,
|
||
combat: {
|
||
...combatState.combat,
|
||
npc_hp: data.combat.npc_hp,
|
||
turn: data.combat.turn
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// If there are enemy actions and combat is not over, show "Enemy's turn..." then delay
|
||
if (enemyMessages.length > 0 && !data.combat_over) {
|
||
// Show "Enemy's turn..." message
|
||
setEnemyTurnMessage("🗡️ Enemy's turn...")
|
||
|
||
// Wait 2 seconds before showing enemy attack
|
||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||
|
||
// Clear the turn message and add enemy actions to log
|
||
setEnemyTurnMessage('')
|
||
const enemyEntries = enemyMessages.map((msg: string) => ({
|
||
time: timeStr,
|
||
message: msg,
|
||
isPlayer: false
|
||
}))
|
||
setCombatLog((prev: any) => [...enemyEntries, ...prev])
|
||
|
||
// NOW refresh to show updated player HP after enemy attack
|
||
fetchGameData()
|
||
} else if (enemyMessages.length > 0) {
|
||
// Combat is over, add enemy messages without delay
|
||
const enemyEntries = enemyMessages.map((msg: string) => ({
|
||
time: timeStr,
|
||
message: msg,
|
||
isPlayer: false
|
||
}))
|
||
setCombatLog((prev: any) => [...enemyEntries, ...prev])
|
||
}
|
||
|
||
if (data.combat_over) {
|
||
// Combat ended - keep combat view but show result with preserved enemy info
|
||
// Check if player fled successfully (message contains "fled")
|
||
const playerFled = data.message && data.message.toLowerCase().includes('fled')
|
||
|
||
setCombatState({
|
||
...combatState, // Keep existing state
|
||
combat_over: true,
|
||
player_won: data.player_won,
|
||
player_fled: playerFled, // Track if player fled
|
||
combat: {
|
||
...combatState.combat,
|
||
npc_name: enemyName, // Keep original enemy name
|
||
npc_image: enemyImage, // Keep original enemy image
|
||
npc_hp: data.player_won ? 0 : (combatState.combat?.npc_hp || 0) // Don't set HP to 0 on flee
|
||
}
|
||
})
|
||
} else {
|
||
// Update combat state for next turn, but preserve enemy info
|
||
// Keep the original stored enemy name/image (from state variables)
|
||
setCombatState({
|
||
...data,
|
||
combat: {
|
||
...data.combat,
|
||
npc_name: enemyName, // Use stored enemy name
|
||
npc_image: enemyImage // Use stored enemy image
|
||
}
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
setCombatLog((prev: any) => [{
|
||
time: timeStr,
|
||
message: error.response?.data?.detail || 'Combat action failed',
|
||
isPlayer: false
|
||
}, ...prev])
|
||
}
|
||
}
|
||
|
||
const handleExitCombat = () => {
|
||
setCombatState(null)
|
||
setCombatLog([])
|
||
setEnemyName('')
|
||
setEnemyImage('')
|
||
fetchGameData() // Refresh game state
|
||
}
|
||
|
||
const handleExitPvPCombat = async () => {
|
||
if (combatState?.pvp_combat?.id) {
|
||
try {
|
||
await api.post('/api/game/pvp/acknowledge', { combat_id: combatState.pvp_combat.id })
|
||
} catch (error) {
|
||
console.error('Failed to acknowledge PvP combat:', error)
|
||
}
|
||
}
|
||
setCombatState(null)
|
||
setCombatLog([])
|
||
setLastSeenPvPAction(null)
|
||
lastSeenPvPActionRef.current = null // Clear ref too
|
||
fetchGameData() // Refresh game state
|
||
}
|
||
|
||
const handleInitiatePvP = async (targetPlayerId: number) => {
|
||
try {
|
||
const response = await api.post('/api/game/pvp/initiate', { target_player_id: targetPlayerId })
|
||
setMessage(response.data.message || 'PvP combat initiated!')
|
||
await fetchGameData() // Refresh to show combat state
|
||
} catch (error: any) {
|
||
setMessage(error.response?.data?.detail || 'Failed to initiate PvP')
|
||
}
|
||
}
|
||
|
||
const handlePvPAction = async (action: string) => {
|
||
try {
|
||
const response = await api.post('/api/game/pvp/action', { action })
|
||
const data = response.data
|
||
|
||
// Add message to combat log
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
|
||
if (data.message) {
|
||
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
||
const logEntries = messages.map((msg: string) => ({
|
||
time: timeStr,
|
||
message: msg,
|
||
isPlayer: msg.includes('You ') || msg.includes('Your ')
|
||
}))
|
||
setCombatLog((prev: any) => [...logEntries, ...prev])
|
||
}
|
||
|
||
// Refresh combat state
|
||
await fetchGameData()
|
||
|
||
// If combat is over, show message
|
||
if (data.combat_over) {
|
||
setMessage(data.message || 'Combat ended!')
|
||
}
|
||
} catch (error: any) {
|
||
const now = new Date()
|
||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||
setCombatLog((prev: any) => [{
|
||
time: timeStr,
|
||
message: error.response?.data?.detail || 'PvP action failed',
|
||
isPlayer: false
|
||
}, ...prev])
|
||
}
|
||
}
|
||
|
||
const handleItemAction = async (action: string, itemId: number) => {
|
||
switch (action) {
|
||
case 'use':
|
||
await handleUseItem(itemId.toString())
|
||
break
|
||
case 'equip':
|
||
await handleEquipItem(itemId)
|
||
break
|
||
case 'unequip':
|
||
// Find the slot this item is equipped in
|
||
const equippedSlot = Object.keys(equipment).find(slot => equipment[slot]?.id === itemId)
|
||
if (equippedSlot) {
|
||
await handleUnequipItem(equippedSlot)
|
||
}
|
||
break
|
||
case 'drop':
|
||
await handleDropItem(itemId.toString(), 1)
|
||
break
|
||
}
|
||
setSelectedItem(null)
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="loading">Loading game...</div>
|
||
}
|
||
|
||
if (!playerState || !location) {
|
||
return <div className="error">Failed to load game state</div>
|
||
}
|
||
|
||
// Helper function to get direction details
|
||
const getDirectionDetail = (direction: string) => {
|
||
if (!location.directions_detailed) return null
|
||
return location.directions_detailed.find(d => d.direction === direction)
|
||
}
|
||
|
||
// Helper function to get stamina cost for a direction
|
||
const getStaminaCost = (direction: string): number => {
|
||
const detail = getDirectionDetail(direction)
|
||
return detail ? detail.stamina_cost : 5
|
||
}
|
||
|
||
// Helper function to get destination name for a direction
|
||
const getDestinationName = (direction: string): string => {
|
||
const detail = getDirectionDetail(direction)
|
||
return detail ? (detail.destination_name || detail.destination) : ''
|
||
}
|
||
|
||
// Helper function to get distance for a direction
|
||
const getDistance = (direction: string): number => {
|
||
const detail = getDirectionDetail(direction)
|
||
return detail ? detail.distance : 0
|
||
}
|
||
|
||
// Helper function to check if direction is available
|
||
const hasDirection = (direction: string): boolean => {
|
||
return location.directions.includes(direction)
|
||
}
|
||
|
||
// Helper function to render compass button
|
||
const renderCompassButton = (direction: string, arrow: string, className: string) => {
|
||
const available = hasDirection(direction)
|
||
const stamina = getStaminaCost(direction)
|
||
const destination = getDestinationName(direction)
|
||
const distance = getDistance(direction)
|
||
const disabled = !available || !!combatState || movementCooldown > 0
|
||
|
||
// Build detailed tooltip text
|
||
const tooltipText = movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` :
|
||
combatState ? 'Cannot travel during combat' :
|
||
available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` :
|
||
`Cannot go ${direction}`
|
||
|
||
return (
|
||
<button
|
||
onClick={() => handleMove(direction)}
|
||
disabled={disabled}
|
||
className={`compass-btn ${className} ${disabled ? 'disabled' : ''}`}
|
||
title={tooltipText}
|
||
>
|
||
<span className="compass-arrow">{arrow}</span>
|
||
{available && movementCooldown > 0 ? (
|
||
<span className="compass-cost">⏳{movementCooldown}s</span>
|
||
) : available && (
|
||
<span className="compass-cost">⚡{stamina}</span>
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
const renderExploreTab = () => (
|
||
<div className="explore-tab-desktop">
|
||
{/* Left Sidebar: Movement & Surroundings */}
|
||
<div className={`left-sidebar mobile-menu-panel ${mobileMenuOpen === 'left' ? 'open' : ''}`}>
|
||
{/* Movement Controls */}
|
||
<div className="movement-controls">
|
||
<h3>🧭 Travel</h3>
|
||
<div className="compass-grid">
|
||
{/* Top row */}
|
||
{renderCompassButton('northwest', '↖', 'nw')}
|
||
{renderCompassButton('north', '↑', 'n')}
|
||
{renderCompassButton('northeast', '↗', 'ne')}
|
||
|
||
{/* Middle row */}
|
||
{renderCompassButton('west', '←', 'w')}
|
||
<div className="compass-center">
|
||
<div className="compass-icon">🧭</div>
|
||
</div>
|
||
{renderCompassButton('east', '→', 'e')}
|
||
|
||
{/* Bottom row */}
|
||
{renderCompassButton('southwest', '↙', 'sw')}
|
||
{renderCompassButton('south', '↓', 's')}
|
||
{renderCompassButton('southeast', '↘', 'se')}
|
||
</div>
|
||
|
||
{/* Cooldown indicator */}
|
||
{movementCooldown > 0 && (
|
||
<div className="cooldown-indicator">
|
||
⏳ Wait {movementCooldown}s before moving
|
||
</div>
|
||
)}
|
||
|
||
{/* Special movements */}
|
||
<div className="special-moves">
|
||
{location.directions.includes('up') && (
|
||
<button
|
||
onClick={() => handleMove('up')}
|
||
className="special-btn"
|
||
disabled={!!combatState || movementCooldown > 0}
|
||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go up\nStamina: ${getStaminaCost('up')}`}
|
||
>
|
||
⬆️ Up <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('up')}`}</span>
|
||
</button>
|
||
)}
|
||
{location.directions.includes('down') && (
|
||
<button
|
||
onClick={() => handleMove('down')}
|
||
className="special-btn"
|
||
disabled={!!combatState || movementCooldown > 0}
|
||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go down\nStamina: ${getStaminaCost('down')}`}
|
||
>
|
||
⬇️ Down <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`}</span>
|
||
</button>
|
||
)}
|
||
{location.directions.includes('enter') && (
|
||
<button
|
||
onClick={() => handleMove('enter')}
|
||
className="special-btn"
|
||
disabled={!!combatState || movementCooldown > 0}
|
||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Enter\nStamina: ${getStaminaCost('enter')}`}
|
||
>
|
||
🚪 Enter <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('enter')}`}</span>
|
||
</button>
|
||
)}
|
||
{location.directions.includes('inside') && (
|
||
<button
|
||
onClick={() => handleMove('inside')}
|
||
className="special-btn"
|
||
disabled={!!combatState || movementCooldown > 0}
|
||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go inside\nStamina: ${getStaminaCost('inside')}`}
|
||
>
|
||
🚪 Inside <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('inside')}`}</span>
|
||
</button>
|
||
)}
|
||
{location.directions.includes('exit') && (
|
||
<button
|
||
onClick={() => handleMove('exit')}
|
||
className="special-btn"
|
||
disabled={!!combatState || movementCooldown > 0}
|
||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : 'Exit'}
|
||
>
|
||
🚪 Exit
|
||
</button>
|
||
)}
|
||
{location.directions.includes('outside') && (
|
||
<button
|
||
onClick={() => handleMove('outside')}
|
||
className="special-btn"
|
||
disabled={!!combatState || movementCooldown > 0}
|
||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go outside\nStamina: ${getStaminaCost('outside')}`}
|
||
>
|
||
🚪 Outside <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('outside')}`}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Surroundings */}
|
||
{(location.interactables && location.interactables.length > 0) && (
|
||
<div className="interactables-section">
|
||
<h3>🌿 Surroundings</h3>
|
||
|
||
{/* Interactables */}
|
||
{location.interactables && location.interactables.map((interactable: any) => (
|
||
<div key={interactable.instance_id} className="interactable-card">
|
||
{interactable.image_path && (
|
||
<div className="interactable-image-container">
|
||
<img
|
||
src={`/${interactable.image_path}`}
|
||
alt={interactable.name}
|
||
className="interactable-image"
|
||
onError={(e: any) => {
|
||
e.currentTarget.style.display = 'none';
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="interactable-content">
|
||
<div className="interactable-header">
|
||
<span className="interactable-name">
|
||
{interactable.name}
|
||
{interactable.on_cooldown && <span className="cooldown-emoji" title={`Cooldown: ${interactable.cooldown_remaining}s`}> ⏳</span>}
|
||
</span>
|
||
</div>
|
||
{interactable.actions && interactable.actions.length > 0 && (
|
||
<div className="interactable-actions">
|
||
{interactable.actions.map((action: any) => (
|
||
<button
|
||
key={action.id}
|
||
onClick={() => handleInteract(interactable.instance_id, action.id)}
|
||
className="interact-btn"
|
||
disabled={interactable.on_cooldown || !!combatState}
|
||
title={
|
||
combatState ? 'Cannot interact during combat' :
|
||
interactable.on_cooldown ? `On cooldown (${interactable.cooldown_remaining}s)` :
|
||
action.description
|
||
}
|
||
>
|
||
{action.name} <span className="stamina-cost">⚡{action.stamina_cost}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div> {/* Close left-sidebar */}
|
||
|
||
{/* Center: Location/Combat Content */}
|
||
<div className="center-content">
|
||
{combatState ? (
|
||
/* Combat View */
|
||
<div className="combat-view">
|
||
<div className="combat-header-inline">
|
||
<h2>
|
||
{combatState.is_pvp ? '⚔️ PvP Combat' : `⚔️ Combat - ${enemyName || combatState.combat?.npc_name || 'Enemy'}`}
|
||
</h2>
|
||
</div>
|
||
|
||
{combatState.is_pvp ? (
|
||
/* PvP Combat UI */
|
||
<div className="pvp-combat-display">
|
||
<div className="pvp-players">
|
||
{/* Opponent Info */}
|
||
<div className="pvp-player-card">
|
||
{(() => {
|
||
const opponent = combatState.pvp_combat.is_attacker ?
|
||
combatState.pvp_combat.defender :
|
||
combatState.pvp_combat.attacker
|
||
return (
|
||
<>
|
||
<h3>🗡️ {opponent.username}</h3>
|
||
<div className="pvp-level">Level {opponent.level}</div>
|
||
<div className="combat-hp-bar-container-inline">
|
||
<div className="combat-hp-bar-inline">
|
||
<div className="combat-stat-label-inline">
|
||
HP: {opponent.hp} / {opponent.max_hp}
|
||
</div>
|
||
<div
|
||
className="combat-hp-fill-inline"
|
||
style={{
|
||
width: `${(opponent.hp / opponent.max_hp) * 100}%`
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* Your Info */}
|
||
<div className="pvp-player-card your-card">
|
||
{(() => {
|
||
const you = combatState.pvp_combat.is_attacker ?
|
||
combatState.pvp_combat.attacker :
|
||
combatState.pvp_combat.defender
|
||
return (
|
||
<>
|
||
<h3>🛡️ You</h3>
|
||
<div className="pvp-level">Level {you.level}</div>
|
||
<div className="combat-hp-bar-container-inline">
|
||
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
|
||
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
|
||
HP: {you.hp} / {you.max_hp}
|
||
</div>
|
||
<div
|
||
className="combat-hp-fill-inline"
|
||
style={{
|
||
width: `${(you.hp / you.max_hp) * 100}%`,
|
||
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="combat-turn-indicator-inline">
|
||
{combatState.pvp_combat.combat_over ? (
|
||
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
|
||
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "🏃 Combat Ended" : "💀 Combat Over"}
|
||
</span>
|
||
) : combatState.pvp_combat.your_turn ? (
|
||
<span className="your-turn">✅ Your Turn ({combatState.pvp_combat.time_remaining}s)</span>
|
||
) : (
|
||
<span className="enemy-turn">⏳ Opponent's Turn ({combatState.pvp_combat.time_remaining}s)</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="combat-actions-inline">
|
||
{!combatState.pvp_combat.combat_over ? (
|
||
<>
|
||
<button
|
||
className="combat-action-btn attack-btn"
|
||
onClick={() => handlePvPAction('attack')}
|
||
disabled={!combatState.pvp_combat.your_turn}
|
||
>
|
||
⚔️ Attack
|
||
</button>
|
||
<button
|
||
className="combat-action-btn flee-btn"
|
||
onClick={() => handlePvPAction('flee')}
|
||
disabled={!combatState.pvp_combat.your_turn}
|
||
>
|
||
🏃 Flee
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
className="combat-action-btn exit-btn"
|
||
onClick={handleExitPvPCombat}
|
||
>
|
||
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? '✅ Continue' : '💀 Return'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* PvE Combat UI */
|
||
<>
|
||
<div className="combat-enemy-display-inline">
|
||
<div className="combat-enemy-image-large">
|
||
<img
|
||
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
|
||
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
|
||
/>
|
||
</div>
|
||
<div className="combat-enemy-info-inline">
|
||
<div className="combat-hp-bar-container-inline">
|
||
<div className="combat-hp-bar-inline">
|
||
<div className="combat-stat-label-inline">
|
||
Enemy HP: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
|
||
</div>
|
||
<div
|
||
className="combat-hp-fill-inline"
|
||
style={{
|
||
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{playerState && (
|
||
<div className="combat-hp-bar-container-inline" style={{ marginTop: '0.75rem' }}>
|
||
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
|
||
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
|
||
Your HP: {playerState.health} / {playerState.max_health}
|
||
</div>
|
||
<div
|
||
className="combat-hp-fill-inline"
|
||
style={{
|
||
width: `${(playerState.health / playerState.max_health) * 100}%`,
|
||
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
|
||
{!combatState.combat_over ? (
|
||
enemyTurnMessage ? (
|
||
<span className="enemy-turn">🗡️ Enemy's turn...</span>
|
||
) : combatState.combat?.turn === 'player' ? (
|
||
<span className="your-turn">✅ Your Turn</span>
|
||
) : (
|
||
<span className="enemy-turn">⚠️ Enemy Turn</span>
|
||
)
|
||
) : (
|
||
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
|
||
{combatState.player_won ? "✅ Victory!" : combatState.player_fled ? "🏃 Escaped!" : "💀 Defeated"}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="combat-actions-inline">
|
||
{!combatState.combat_over ? (
|
||
<>
|
||
<button
|
||
className="combat-action-btn attack-btn"
|
||
onClick={() => handleCombatAction('attack')}
|
||
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage}
|
||
>
|
||
⚔️ Attack
|
||
</button>
|
||
<button
|
||
className="combat-action-btn flee-btn"
|
||
onClick={() => handleCombatAction('flee')}
|
||
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage}
|
||
>
|
||
🏃 Flee
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
className="combat-action-btn exit-btn"
|
||
onClick={handleExitCombat}
|
||
>
|
||
{combatState.player_won ? '✅ Victory! Continue' : '💀 Return'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Combat Log */}
|
||
<div className="combat-log-container">
|
||
<h4>Combat Log:</h4>
|
||
<div className="combat-log-messages">
|
||
{combatLog.map((entry: any, i: number) => (
|
||
<div key={i} className={`combat-log-entry ${entry.isPlayer ? 'player-action' : 'enemy-action'}`}>
|
||
<span className="log-time">{entry.time}</span>
|
||
<span className="log-separator">{entry.isPlayer ? '→' : '←'}</span>
|
||
<span className="log-message">{entry.message}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* Normal Location View */
|
||
<>
|
||
<div className="location-info">
|
||
<h2 className="centered-heading">
|
||
{location.name}
|
||
{location.danger_level !== undefined && location.danger_level === 0 && (
|
||
<span className="danger-badge danger-safe" title="Safe Zone">
|
||
✓ Safe
|
||
</span>
|
||
)}
|
||
{location.danger_level !== undefined && location.danger_level > 0 && (
|
||
<span className={`danger-badge danger-${location.danger_level}`} title={`Danger Level: ${location.danger_level}`}>
|
||
⚠️ {location.danger_level}
|
||
</span>
|
||
)}
|
||
</h2>
|
||
{location.tags && location.tags.length > 0 && (
|
||
<div className="location-tags">
|
||
{location.tags.map((tag: string, i: number) => {
|
||
const isClickable = tag === 'workbench' || tag === 'repair_station'
|
||
const handleClick = () => {
|
||
if (tag === 'workbench') handleOpenCrafting()
|
||
else if (tag === 'repair_station') handleOpenRepair()
|
||
}
|
||
|
||
return (
|
||
<span
|
||
key={i}
|
||
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
|
||
title={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}
|
||
onClick={isClickable ? handleClick : undefined}
|
||
style={isClickable ? { cursor: 'pointer' } : undefined}
|
||
>
|
||
{tag === 'workbench' && '🔧 Workbench'}
|
||
{tag === 'repair_station' && '🛠️ Repair Station'}
|
||
{tag === 'safe_zone' && '🛡️ Safe Zone'}
|
||
{tag === 'shop' && '🏪 Shop'}
|
||
{tag === 'shelter' && '🏠 Shelter'}
|
||
{tag === 'medical' && '⚕️ Medical'}
|
||
{tag === 'storage' && '📦 Storage'}
|
||
{tag === 'water_source' && '💧 Water'}
|
||
{tag === 'food_source' && '🍎 Food'}
|
||
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Workbench Menu (Crafting, Repair, Uncraft) */}
|
||
{(showCraftingMenu || showRepairMenu) && (
|
||
<div className="workbench-menu">
|
||
<div className="workbench-header">
|
||
<h3>🔧 Workbench</h3>
|
||
<button className="close-btn" onClick={handleCloseCrafting}>✕</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="workbench-tabs">
|
||
<button
|
||
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
|
||
onClick={() => handleSwitchWorkbenchTab('craft')}
|
||
>
|
||
🔨 Craft
|
||
</button>
|
||
<button
|
||
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
|
||
onClick={() => handleSwitchWorkbenchTab('repair')}
|
||
>
|
||
🛠️ Repair
|
||
</button>
|
||
<button
|
||
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
|
||
onClick={() => handleSwitchWorkbenchTab('uncraft')}
|
||
>
|
||
♻️ Salvage
|
||
</button>
|
||
</div>
|
||
|
||
{/* Craft Tab */}
|
||
{workbenchTab === 'craft' && (
|
||
<div className="workbench-content">
|
||
<div className="filter-box">
|
||
<input
|
||
type="text"
|
||
placeholder="🔍 Filter items..."
|
||
value={craftFilter}
|
||
onChange={(e) => setCraftFilter(e.target.value)}
|
||
className="filter-input"
|
||
/>
|
||
<select
|
||
value={craftCategoryFilter}
|
||
onChange={(e) => setCraftCategoryFilter(e.target.value)}
|
||
className="filter-select"
|
||
>
|
||
<option value="all">All Categories</option>
|
||
<option value="weapon">⚔️ Weapons</option>
|
||
<option value="armor">🛡️ Armor</option>
|
||
<option value="consumable">🍎 Consumables</option>
|
||
<option value="material">🪵 Materials</option>
|
||
<option value="tool">🔨 Tools</option>
|
||
<option value="other">📦 Other</option>
|
||
</select>
|
||
</div>
|
||
<div className="craftable-items-list">
|
||
{craftableItems.filter(item =>
|
||
item.name.toLowerCase().includes(craftFilter.toLowerCase()) &&
|
||
(craftCategoryFilter === 'all' || item.category === craftCategoryFilter)
|
||
).length === 0 && <p className="no-items">No craftable items found</p>}
|
||
{craftableItems
|
||
.filter(item =>
|
||
item.name.toLowerCase().includes(craftFilter.toLowerCase()) &&
|
||
(craftCategoryFilter === 'all' || item.category === craftCategoryFilter)
|
||
)
|
||
.map((item: any) => (
|
||
<div key={item.item_id} className={`craftable-item ${!item.can_craft ? 'disabled' : ''}`}>
|
||
<div className="item-header">
|
||
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
|
||
{item.emoji} {item.name}
|
||
</span>
|
||
{item.slot && <span className="item-slot">[{item.slot}]</span>}
|
||
</div>
|
||
{item.description && <p className="item-description">{item.description}</p>}
|
||
|
||
{/* Level requirement */}
|
||
{item.craft_level && item.craft_level > 1 && (
|
||
<div className={`level-requirement ${item.meets_level ? 'met' : 'not-met'}`}>
|
||
📊 Requires Level {item.craft_level} {item.meets_level ? '✅' : `❌ (You are level ${profile?.level || 1})`}
|
||
</div>
|
||
)}
|
||
|
||
{/* Tool requirements */}
|
||
{item.tools && item.tools.length > 0 && (
|
||
<div className="tools-list">
|
||
<p className="tools-label">🔧 Required Tools:</p>
|
||
{item.tools.map((tool: any, i: number) => (
|
||
<div key={i} className={`tool-requirement ${tool.has_tool ? 'has-tool' : 'missing-tool'}`}>
|
||
<span>{tool.emoji} {tool.name}</span>
|
||
<span className="tool-durability">
|
||
(-{tool.durability_cost} durability)
|
||
{tool.has_tool && ` [${tool.tool_durability} available]`}
|
||
{!tool.has_tool && ' ❌'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Materials */}
|
||
<div className="materials-list">
|
||
<p className="materials-label">📦 Materials:</p>
|
||
{item.materials.map((mat: any, i: number) => (
|
||
<div key={i} className={`material ${mat.has_enough ? 'has-enough' : 'missing'}`}>
|
||
<span>{mat.emoji} {mat.name}</span>
|
||
<span className="material-count">{mat.available}/{mat.required}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
className="craft-btn"
|
||
disabled={!item.can_craft}
|
||
onClick={() => handleCraft(item.item_id)}
|
||
>
|
||
{!item.meets_level ? `Need Level ${item.craft_level}` :
|
||
!item.can_craft ? 'Missing Requirements' : 'Craft'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Repair Tab */}
|
||
{workbenchTab === 'repair' && (
|
||
<div className="workbench-content">
|
||
<div className="filter-box">
|
||
<input
|
||
type="text"
|
||
placeholder="🔍 Filter items..."
|
||
value={repairFilter}
|
||
onChange={(e) => setRepairFilter(e.target.value)}
|
||
className="filter-input"
|
||
/>
|
||
</div>
|
||
<div className="repairable-items-list">
|
||
{repairableItems.filter(item =>
|
||
item.name.toLowerCase().includes(repairFilter.toLowerCase())
|
||
).length === 0 && <p className="no-items">No repairable items found</p>}
|
||
{repairableItems
|
||
.filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase()))
|
||
.map((item: any, idx: number) => (
|
||
<div key={idx} className={`repairable-item ${!item.can_repair ? 'disabled' : ''}`}>
|
||
<div className="item-header">
|
||
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
|
||
{item.emoji} {item.name}
|
||
</span>
|
||
{item.location === 'equipped' && <span className="equipped-badge">⚔️ Equipped</span>}
|
||
{item.location === 'inventory' && <span className="inventory-badge">🎒 Inventory</span>}
|
||
</div>
|
||
|
||
<div className="durability-bar">
|
||
<div
|
||
className={`durability-fill ${item.durability_percent === 100 ? 'full' : ''}`}
|
||
style={{ width: `${item.durability_percent}%` }}
|
||
></div>
|
||
<span className="durability-text">{item.current_durability}/{item.max_durability}</span>
|
||
</div>
|
||
|
||
{!item.needs_repair && (
|
||
<p className="repair-info full-durability">✅ At full durability</p>
|
||
)}
|
||
|
||
{item.needs_repair && (
|
||
<>
|
||
{/* Tool requirements */}
|
||
{item.tools && item.tools.length > 0 && (
|
||
<div className="tools-list">
|
||
<p className="tools-label">🔧 Required Tools:</p>
|
||
{item.tools.map((tool: any, i: number) => (
|
||
<div key={i} className={`tool-requirement ${tool.has_tool ? 'has-tool' : 'missing-tool'}`}>
|
||
<span>{tool.emoji} {tool.name}</span>
|
||
<span className="tool-durability">
|
||
(-{tool.durability_cost} durability)
|
||
{tool.has_tool && ` [${tool.tool_durability} available]`}
|
||
{!tool.has_tool && ' ❌'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Materials */}
|
||
<div className="materials-list">
|
||
<p className="repair-info">Restores {item.repair_percentage}% durability</p>
|
||
{item.materials.map((mat: any, i: number) => (
|
||
<div key={i} className={`material ${mat.has_enough ? 'has-enough' : 'missing'}`}>
|
||
<span>{mat.emoji} {mat.name}</span>
|
||
<span className="material-count">{mat.available}/{mat.quantity}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<button
|
||
className="repair-btn"
|
||
disabled={!item.can_repair}
|
||
onClick={() => handleRepairFromMenu(item.unique_item_id, item.inventory_id)}
|
||
>
|
||
{!item.needs_repair ? 'Already Full' :
|
||
!item.can_repair ? 'Missing Requirements' : 'Repair'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Uncraft Tab */}
|
||
{workbenchTab === 'uncraft' && (
|
||
<div className="workbench-content">
|
||
<div className="filter-box">
|
||
<input
|
||
type="text"
|
||
placeholder="🔍 Filter items..."
|
||
value={uncraftFilter}
|
||
onChange={(e) => setUncraftFilter(e.target.value)}
|
||
className="filter-input"
|
||
/>
|
||
</div>
|
||
<div className="uncraftable-items-list">
|
||
{uncraftableItems.filter(item =>
|
||
item.name.toLowerCase().includes(uncraftFilter.toLowerCase())
|
||
).length === 0 && <p className="no-items">No uncraftable items found</p>}
|
||
{uncraftableItems
|
||
.filter((item: any) => item.name.toLowerCase().includes(uncraftFilter.toLowerCase()))
|
||
.map((item: any, idx: number) => {
|
||
// Calculate adjusted yield based on durability
|
||
const durabilityRatio = item.unique_item_data
|
||
? item.unique_item_data.durability_percent / 100
|
||
: 1.0
|
||
const adjustedYield = item.base_yield.map((mat: any) => ({
|
||
...mat,
|
||
adjusted_quantity: Math.floor(mat.quantity * durabilityRatio)
|
||
}))
|
||
|
||
return (
|
||
<div key={idx} className={`uncraftable-item`}>
|
||
<div className="item-header">
|
||
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
|
||
{item.emoji} {item.name}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Unique item details */}
|
||
{item.unique_item_data && (
|
||
<div className="unique-item-details">
|
||
{/* Durability bar */}
|
||
<div className="durability-display">
|
||
<div className="durability-label">
|
||
🔧 Durability: {item.unique_item_data.current_durability}/{item.unique_item_data.max_durability} ({item.unique_item_data.durability_percent}%)
|
||
</div>
|
||
<div className="durability-bar-bg">
|
||
<div
|
||
className={`durability-bar ${
|
||
item.unique_item_data.durability_percent < 25 ? 'critical' :
|
||
item.unique_item_data.durability_percent < 50 ? 'low' :
|
||
'good'
|
||
}`}
|
||
style={{width: `${item.unique_item_data.durability_percent}%`}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Format stats nicely */}
|
||
{item.unique_item_data.unique_stats && Object.keys(item.unique_item_data.unique_stats).length > 0 && (
|
||
<div className="unique-stats">
|
||
{Object.entries(item.unique_item_data.unique_stats).map(([stat, value]: [string, any]) => {
|
||
// Format stat names and values
|
||
let displayName = stat.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||
let displayValue = value
|
||
|
||
// Combine min/max stats
|
||
if (stat === 'damage_min' && item.unique_item_data.unique_stats.damage_max) {
|
||
displayName = 'Damage'
|
||
displayValue = `${value}-${item.unique_item_data.unique_stats.damage_max}`
|
||
return (
|
||
<span key={stat} className="stat-badge">
|
||
⚔️ {displayName}: {displayValue}
|
||
</span>
|
||
)
|
||
} else if (stat === 'damage_max') {
|
||
return null // Skip, already shown with damage_min
|
||
} else if (stat === 'armor') {
|
||
return (
|
||
<span key={stat} className="stat-badge">
|
||
🛡️ {displayName}: {displayValue}
|
||
</span>
|
||
)
|
||
} else {
|
||
return (
|
||
<span key={stat} className="stat-badge">
|
||
{displayName}: {displayValue}
|
||
</span>
|
||
)
|
||
}
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Durability impact warning */}
|
||
{durabilityRatio < 1.0 && (
|
||
<div className="uncraft-warning">
|
||
⚠️ Item condition will reduce yield by {Math.round((1 - durabilityRatio) * 100)}%
|
||
</div>
|
||
)}
|
||
|
||
{durabilityRatio < 0.1 && (
|
||
<div className="uncraft-warning" style={{color: 'red'}}>
|
||
❌ Item too damaged - will yield NO materials!
|
||
</div>
|
||
)}
|
||
|
||
{/* Loss chance warning */}
|
||
{item.loss_chance && (
|
||
<div className="uncraft-warning">
|
||
⚠️ {Math.round(item.loss_chance * 100)}% chance to lose each material
|
||
</div>
|
||
)}
|
||
|
||
{/* Yield materials with durability adjustment */}
|
||
{adjustedYield && adjustedYield.length > 0 && (
|
||
<div className="materials-list">
|
||
<p className="materials-label">♻️ Expected yield:</p>
|
||
{adjustedYield.map((mat: any, i: number) => (
|
||
<div key={i} className="material">
|
||
<span>{mat.emoji} {mat.name}</span>
|
||
<span className="material-count">
|
||
{durabilityRatio < 1.0 && durabilityRatio >= 0.1 ? (
|
||
<>
|
||
<span style={{textDecoration: 'line-through', opacity: 0.5}}>x{mat.quantity}</span>
|
||
{' → '}
|
||
<span style={{color: '#ffa500'}}>x{mat.adjusted_quantity}</span>
|
||
</>
|
||
) : durabilityRatio < 0.1 ? (
|
||
<span style={{color: 'red'}}>x0</span>
|
||
) : (
|
||
<>x{mat.quantity}</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{durabilityRatio >= 0.1 && (
|
||
<p className="materials-note" style={{fontSize: '0.85em', opacity: 0.7, marginTop: '5px'}}>
|
||
* Subject to {Math.round((item.loss_chance || 0.3) * 100)}% random loss per material
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
className="uncraft-btn"
|
||
onClick={() => {
|
||
const yieldPreview = adjustedYield.map((m: any) =>
|
||
durabilityRatio < 0.1 ? `${m.emoji} ${m.name} x0` :
|
||
`${m.emoji} ${m.name} x${m.adjusted_quantity}`
|
||
).join(', ')
|
||
if (window.confirm(
|
||
`⚠️ Salvage ${item.name}?\n\n` +
|
||
`Expected yield: ${yieldPreview}\n` +
|
||
(durabilityRatio < 1.0 ? `(Reduced due to ${item.unique_item_data.durability_percent}% condition)\n` : '') +
|
||
`Each material has ${Math.round((item.loss_chance || 0.3) * 100)}% chance to be lost!\n\n` +
|
||
`This will destroy the item permanently.`
|
||
)) {
|
||
handleUncraft(item.unique_item_id, item.inventory_id)
|
||
}
|
||
}}
|
||
>
|
||
Salvage
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{location.image_url && (
|
||
<div className="location-image-container">
|
||
<img src={location.image_url} alt={location.name} className="location-image" onError={(e: any) => (e.currentTarget.style.display = 'none')} />
|
||
</div>
|
||
)}
|
||
<div className="location-description-box">
|
||
<p className="location-description">{location.description}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{message && (
|
||
<div className="message-box" onClick={() => setMessage('')}>
|
||
{message}
|
||
</div>
|
||
)}
|
||
|
||
{/* NPCs, Items, and Entities on ground - below the location image */}
|
||
<div className={`ground-entities mobile-menu-panel bottom ${mobileMenuOpen === 'bottom' ? 'open' : ''}`}>
|
||
{/* Enemies */}
|
||
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
|
||
<div className="entity-section enemies-section">
|
||
<h3>⚔️ Enemies</h3>
|
||
<div className="entity-list">
|
||
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
|
||
<div key={i} className="entity-card enemy-card">
|
||
{enemy.id && (
|
||
<div className="entity-image">
|
||
<img
|
||
src={`/images/npcs/${enemy.name.toLowerCase().replace(/ /g, '_')}.png`}
|
||
alt={enemy.name}
|
||
onError={(e: any) => {
|
||
e.currentTarget.style.display = 'none';
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="entity-info">
|
||
<div className="entity-name enemy-name">{enemy.name}</div>
|
||
{enemy.level && <div className="entity-level">Lv. {enemy.level}</div>}
|
||
</div>
|
||
<button
|
||
className="entity-action-btn combat-btn"
|
||
onClick={() => handleInitiateCombat(enemy.id)}
|
||
>
|
||
⚔️ Fight
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Corpses */}
|
||
{location.corpses && location.corpses.length > 0 && (
|
||
<div className="entity-section corpses-section">
|
||
<h3>💀 Corpses</h3>
|
||
<div className="entity-list">
|
||
{location.corpses.map((corpse: any) => (
|
||
<div key={corpse.id} className="corpse-container">
|
||
<div className="entity-card corpse-card">
|
||
<div className="entity-info">
|
||
<div className="entity-name">{corpse.emoji} {corpse.name}</div>
|
||
<div className="corpse-loot-count">{corpse.loot_count} item(s)</div>
|
||
</div>
|
||
<button
|
||
className="entity-action-btn loot-btn"
|
||
onClick={() => handleLootCorpse(corpse.id)}
|
||
disabled={corpse.loot_count === 0}
|
||
>
|
||
🔍 Examine
|
||
</button>
|
||
</div>
|
||
|
||
{/* Expanded corpse details */}
|
||
{expandedCorpse === corpse.id && corpseDetails && (
|
||
<div className="corpse-details">
|
||
<div className="corpse-details-header">
|
||
<h4>Lootable Items:</h4>
|
||
<button
|
||
className="close-btn"
|
||
onClick={() => {
|
||
setExpandedCorpse(null)
|
||
setCorpseDetails(null)
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="corpse-items-list">
|
||
{corpseDetails.loot_items.map((item: any) => (
|
||
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
|
||
<div className="corpse-item-info">
|
||
<div className="corpse-item-name">
|
||
{item.emoji} {item.item_name}
|
||
</div>
|
||
<div className="corpse-item-qty">
|
||
Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
|
||
</div>
|
||
{item.required_tool && (
|
||
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
|
||
🔧 {item.required_tool_name} {item.has_tool ? '✓' : '✗'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
className="corpse-item-loot-btn"
|
||
onClick={() => handleLootCorpseItem(corpse.id, item.index)}
|
||
disabled={!item.can_loot}
|
||
title={!item.can_loot ? `Requires ${item.required_tool_name}` : 'Loot this item'}
|
||
>
|
||
{item.can_loot ? '📦 Loot' : '🔒'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
className="loot-all-btn"
|
||
onClick={() => handleLootCorpseItem(corpse.id, null)}
|
||
>
|
||
📦 Loot All Available
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* NPCs */}
|
||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
|
||
<div className="entity-section npcs-section">
|
||
<h3>👥 NPCs</h3>
|
||
<div className="entity-list">
|
||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
||
<div key={i} className="entity-card npc-card">
|
||
<span className="entity-icon">🧑</span>
|
||
<div className="entity-info">
|
||
<div className="entity-name">{npc.name}</div>
|
||
{npc.level && <div className="entity-level">Lv. {npc.level}</div>}
|
||
</div>
|
||
<button className="entity-action-btn">Talk</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{location.items.length > 0 && (
|
||
<div className="entity-section items-section">
|
||
<h3>📦 Items on Ground</h3>
|
||
<div className="entity-list">
|
||
{location.items.map((item: any, i: number) => (
|
||
<div key={i} className="entity-card item-card">
|
||
<span className="entity-icon">
|
||
{item.emoji || '📦'}
|
||
</span>
|
||
<div className="entity-info">
|
||
<div className={`entity-name ${item.tier ? `tier-${item.tier}` : ''}`}>{item.name || 'Unknown Item'}</div>
|
||
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
|
||
</div>
|
||
<div className="item-info-btn-container">
|
||
<button className="entity-action-btn info" title="Item Info">Info</button>
|
||
<div className="item-info-tooltip">
|
||
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
|
||
{item.weight !== undefined && item.weight > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⚖️ Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
|
||
</div>
|
||
)}
|
||
{item.volume !== undefined && item.volume > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
|
||
</div>
|
||
)}
|
||
{item.hp_restore && item.hp_restore > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
❤️ HP Restore: +{item.hp_restore}
|
||
</div>
|
||
)}
|
||
{item.stamina_restore && item.stamina_restore > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⚡ Stamina Restore: +{item.stamina_restore}
|
||
</div>
|
||
)}
|
||
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
|
||
<div className="item-tooltip-stat">
|
||
⚔️ Damage: {item.damage_min}-{item.damage_max}
|
||
</div>
|
||
)}
|
||
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
|
||
<div className="item-tooltip-stat">
|
||
🔧 Durability: {item.durability}/{item.max_durability}
|
||
</div>
|
||
)}
|
||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⭐ Tier: {item.tier}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{item.quantity === 1 ? (
|
||
<button
|
||
className="entity-action-btn pickup"
|
||
onClick={() => handlePickup(item.id, 1)}
|
||
>
|
||
Pick Up
|
||
</button>
|
||
) : (
|
||
<div className="item-pickup-btn-container">
|
||
<button className="entity-action-btn pickup">Pick Up ▼</button>
|
||
<div className="item-pickup-menu">
|
||
<button className="item-pickup-option" onClick={() => handlePickup(item.id, 1)}>Pick Up 1</button>
|
||
{item.quantity >= 5 && (
|
||
<button className="item-pickup-option" onClick={() => handlePickup(item.id, 5)}>Pick Up 5</button>
|
||
)}
|
||
{item.quantity >= 10 && (
|
||
<button className="item-pickup-option" onClick={() => handlePickup(item.id, 10)}>Pick Up 10</button>
|
||
)}
|
||
<button className="item-pickup-option" onClick={() => handlePickup(item.id, item.quantity)}>Pick Up All ({item.quantity})</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Other Players */}
|
||
{location.other_players && location.other_players.length > 0 && (
|
||
<div className="entity-section players-section">
|
||
<h3>👥 Other Players</h3>
|
||
<div className="entity-list">
|
||
{location.other_players.map((player: any, i: number) => (
|
||
<div key={i} className="entity-card player-card">
|
||
<span className="entity-icon">🧍</span>
|
||
<div className="entity-info">
|
||
<div className="entity-name">{player.username}</div>
|
||
<div className="entity-level">Lv. {player.level}</div>
|
||
{player.level_diff !== undefined && (
|
||
<div className="level-diff">
|
||
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
|
||
</div>
|
||
)}
|
||
</div>
|
||
{player.can_pvp && (
|
||
<button
|
||
className="pvp-btn"
|
||
onClick={() => handleInitiatePvP(player.id)}
|
||
title={`Attack ${player.username}`}
|
||
>
|
||
⚔️ Attack
|
||
</button>
|
||
)}
|
||
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
|
||
<div className="pvp-disabled-reason">
|
||
Level difference too high
|
||
</div>
|
||
)}
|
||
{!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
|
||
<div className="pvp-disabled-reason">
|
||
Area too safe for PvP
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right Sidebar: Profile & Inventory */}
|
||
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
|
||
{/* Profile Stats */}
|
||
<div className="profile-sidebar">
|
||
<h3>👤 Character</h3>
|
||
|
||
{/* Health & Stamina Bars */}
|
||
<div className="sidebar-stat-bars">
|
||
<div className="sidebar-stat-bar">
|
||
<div className="sidebar-stat-header">
|
||
<span className="sidebar-stat-label">❤️ HP</span>
|
||
<span className="sidebar-stat-numbers">{playerState.health}/{playerState.max_health}</span>
|
||
</div>
|
||
<div className="sidebar-progress-bar">
|
||
<div
|
||
className="sidebar-progress-fill health"
|
||
style={{ width: `${(playerState.health / playerState.max_health) * 100}%` }}
|
||
></div>
|
||
<span className="progress-percentage">{Math.round((playerState.health / playerState.max_health) * 100)}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sidebar-stat-bar">
|
||
<div className="sidebar-stat-header">
|
||
<span className="sidebar-stat-label">⚡ Stamina</span>
|
||
<span className="sidebar-stat-numbers">{playerState.stamina}/{playerState.max_stamina}</span>
|
||
</div>
|
||
<div className="sidebar-progress-bar">
|
||
<div
|
||
className="sidebar-progress-fill stamina"
|
||
style={{ width: `${(playerState.stamina / playerState.max_stamina) * 100}%` }}
|
||
></div>
|
||
<span className="progress-percentage">{Math.round((playerState.stamina / playerState.max_stamina) * 100)}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Character Info */}
|
||
{profile && (
|
||
<div className="sidebar-stats">
|
||
<div className="sidebar-stat-row">
|
||
<span className="sidebar-label">Level:</span>
|
||
<span className="sidebar-value">{profile.level}</span>
|
||
</div>
|
||
|
||
{/* XP Progress Bar */}
|
||
<div className="sidebar-stat-bar">
|
||
<div className="sidebar-stat-header">
|
||
<span className="sidebar-stat-label">⭐ XP</span>
|
||
<span className="sidebar-stat-numbers">{profile.xp} / {(profile.level * 100)}</span>
|
||
</div>
|
||
<div className="sidebar-progress-bar">
|
||
<div
|
||
className="sidebar-progress-fill xp"
|
||
style={{ width: `${(profile.xp / (profile.level * 100)) * 100}%` }}
|
||
></div>
|
||
<span className="progress-percentage">{Math.round((profile.xp / (profile.level * 100)) * 100)}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
{profile.unspent_points > 0 && (
|
||
<div className="sidebar-stat-row highlight">
|
||
<span className="sidebar-label">⭐ Unspent:</span>
|
||
<span className="sidebar-value">{profile.unspent_points}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="sidebar-divider"></div>
|
||
|
||
<div className="sidebar-stat-row">
|
||
<span className="sidebar-label">💪 STR:</span>
|
||
<span className="sidebar-value">{profile.strength}</span>
|
||
{profile.unspent_points > 0 && (
|
||
<button className="stat-plus-btn" onClick={() => handleSpendPoint('strength')}>+</button>
|
||
)}
|
||
</div>
|
||
<div className="sidebar-stat-row">
|
||
<span className="sidebar-label">🏃 AGI:</span>
|
||
<span className="sidebar-value">{profile.agility}</span>
|
||
{profile.unspent_points > 0 && (
|
||
<button className="stat-plus-btn" onClick={() => handleSpendPoint('agility')}>+</button>
|
||
)}
|
||
</div>
|
||
<div className="sidebar-stat-row">
|
||
<span className="sidebar-label">🛡️ END:</span>
|
||
<span className="sidebar-value">{profile.endurance}</span>
|
||
{profile.unspent_points > 0 && (
|
||
<button className="stat-plus-btn" onClick={() => handleSpendPoint('endurance')}>+</button>
|
||
)}
|
||
</div>
|
||
<div className="sidebar-stat-row">
|
||
<span className="sidebar-label">🧠 INT:</span>
|
||
<span className="sidebar-value">{profile.intellect}</span>
|
||
{profile.unspent_points > 0 && (
|
||
<button className="stat-plus-btn" onClick={() => handleSpendPoint('intellect')}>+</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Equipment Display */}
|
||
<div className="equipment-sidebar">
|
||
<h3>⚔️ Equipment</h3>
|
||
<div className="equipment-grid">
|
||
{/* Row 1: Head */}
|
||
<div className="equipment-row">
|
||
<div className={`equipment-slot ${equipment.head ? 'filled' : 'empty'}`}>
|
||
{equipment.head ? (
|
||
<>
|
||
<div className="equipment-item-content">
|
||
<span className="equipment-emoji">{equipment.head.emoji}</span>
|
||
<span className={`equipment-name ${equipment.head.tier ? `tier-${equipment.head.tier}` : ''}`}>{equipment.head.name}</span>
|
||
{equipment.head.durability && equipment.head.durability !== null && (
|
||
<span className="equipment-durability">{equipment.head.durability}/{equipment.head.max_durability}</span>
|
||
)}
|
||
</div>
|
||
<div className="equipment-actions">
|
||
<div className="item-info-btn-container">
|
||
<button className="equipment-action-btn info" title="Item Info">ℹ️</button>
|
||
<div className="item-info-tooltip">
|
||
{equipment.head.description && <div className="item-tooltip-desc">{equipment.head.description}</div>}
|
||
{equipment.head.stats && Object.keys(equipment.head.stats).length > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
📊 Stats: {Object.entries(equipment.head.stats).map(([key, val]) => `${key}: ${val}`).join(', ')}
|
||
</div>
|
||
)}
|
||
{equipment.head.durability !== undefined && equipment.head.durability !== null && (
|
||
<div className="item-tooltip-stat">
|
||
🔧 Durability: {equipment.head.durability}/{equipment.head.max_durability}
|
||
</div>
|
||
)}
|
||
{equipment.head.tier !== undefined && equipment.head.tier !== null && equipment.head.tier > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⭐ Tier: {equipment.head.tier}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button className="equipment-action-btn unequip" onClick={() => handleUnequipItem('head')} title="Unequip">❌</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="equipment-emoji">🪖</span>
|
||
<span className="equipment-slot-label">Head</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: Weapon, Torso, Backpack */}
|
||
<div className="equipment-row three-cols">
|
||
<div className={`equipment-slot ${equipment.weapon ? 'filled' : 'empty'}`}>
|
||
{equipment.weapon ? (
|
||
<>
|
||
<div className="equipment-item-content">
|
||
<span className="equipment-emoji">{equipment.weapon.emoji}</span>
|
||
<span className={`equipment-name ${equipment.weapon.tier ? `tier-${equipment.weapon.tier}` : ''}`}>{equipment.weapon.name}</span>
|
||
{equipment.weapon.durability && equipment.weapon.durability !== null && (
|
||
<span className="equipment-durability">{equipment.weapon.durability}/{equipment.weapon.max_durability}</span>
|
||
)}
|
||
</div>
|
||
<div className="equipment-actions">
|
||
<div className="item-info-btn-container">
|
||
<button className="equipment-action-btn info" title="Item Info">ℹ️</button>
|
||
<div className="item-info-tooltip">
|
||
{equipment.weapon.description && <div className="item-tooltip-desc">{equipment.weapon.description}</div>}
|
||
{equipment.weapon.stats && Object.keys(equipment.weapon.stats).length > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⚔️ Damage: {equipment.weapon.stats.damage_min}-{equipment.weapon.stats.damage_max}
|
||
</div>
|
||
)}
|
||
{equipment.weapon.weapon_effects && Object.keys(equipment.weapon.weapon_effects).length > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
✨ Effects: {Object.entries(equipment.weapon.weapon_effects).map(([key, val]: [string, any]) => `${key} (${(val.chance * 100).toFixed(0)}%)`).join(', ')}
|
||
</div>
|
||
)}
|
||
{equipment.weapon.durability !== undefined && equipment.weapon.durability !== null && (
|
||
<div className="item-tooltip-stat">
|
||
🔧 Durability: {equipment.weapon.durability}/{equipment.weapon.max_durability}
|
||
</div>
|
||
)}
|
||
{equipment.weapon.tier !== undefined && equipment.weapon.tier !== null && equipment.weapon.tier > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⭐ Tier: {equipment.weapon.tier}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button className="equipment-action-btn unequip" onClick={() => handleUnequipItem('weapon')} title="Unequip">❌</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="equipment-emoji">⚔️</span>
|
||
<span className="equipment-slot-label">Weapon</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className={`equipment-slot ${equipment.torso ? 'filled' : 'empty'}`}>
|
||
{equipment.torso ? (
|
||
<>
|
||
<div className="equipment-item-content">
|
||
<span className="equipment-emoji">{equipment.torso.emoji}</span>
|
||
<span className={`equipment-name ${equipment.torso.tier ? `tier-${equipment.torso.tier}` : ''}`}>{equipment.torso.name}</span>
|
||
{equipment.torso.durability && equipment.torso.durability !== null && (
|
||
<span className="equipment-durability">{equipment.torso.durability}/{equipment.torso.max_durability}</span>
|
||
)}
|
||
</div>
|
||
<div className="equipment-actions">
|
||
<div className="item-info-btn-container">
|
||
<button className="equipment-action-btn info" title="Item Info">ℹ️</button>
|
||
<div className="item-info-tooltip">
|
||
{equipment.torso.description && <div className="item-tooltip-desc">{equipment.torso.description}</div>}
|
||
{equipment.torso.stats && Object.keys(equipment.torso.stats).length > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
📊 Stats: {Object.entries(equipment.torso.stats).map(([key, val]) => `${key}: ${val}`).join(', ')}
|
||
</div>
|
||
)}
|
||
{equipment.torso.durability !== undefined && equipment.torso.durability !== null && (
|
||
<div className="item-tooltip-stat">
|
||
🔧 Durability: {equipment.torso.durability}/{equipment.torso.max_durability}
|
||
</div>
|
||
)}
|
||
{equipment.torso.tier !== undefined && equipment.torso.tier !== null && equipment.torso.tier > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⭐ Tier: {equipment.torso.tier}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button className="equipment-action-btn unequip" onClick={() => handleUnequipItem('torso')} title="Unequip">❌</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="equipment-emoji">👕</span>
|
||
<span className="equipment-slot-label">Torso</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className={`equipment-slot ${equipment.backpack ? 'filled' : 'empty'}`}>
|
||
{equipment.backpack ? (
|
||
<>
|
||
<div className="equipment-item-content">
|
||
<span className="equipment-emoji">{equipment.backpack.emoji}</span>
|
||
<span className={`equipment-name ${equipment.backpack.tier ? `tier-${equipment.backpack.tier}` : ''}`}>{equipment.backpack.name}</span>
|
||
{equipment.backpack.durability && equipment.backpack.durability !== null && (
|
||
<span className="equipment-durability">{equipment.backpack.durability}/{equipment.backpack.max_durability}</span>
|
||
)}
|
||
</div>
|
||
<div className="equipment-actions">
|
||
<div className="item-info-btn-container">
|
||
<button className="equipment-action-btn info" title="Item Info">ℹ️</button>
|
||
<div className="item-info-tooltip">
|
||
{equipment.backpack.description && <div className="item-tooltip-desc">{equipment.backpack.description}</div>}
|
||
{equipment.backpack.stats && Object.keys(equipment.backpack.stats).length > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
📦 Capacity: Weight +{equipment.backpack.stats.weight_capacity}kg, Volume +{equipment.backpack.stats.volume_capacity}L
|
||
</div>
|
||
)}
|
||
{equipment.backpack.durability !== undefined && equipment.backpack.durability !== null && (
|
||
<div className="item-tooltip-stat">
|
||
🔧 Durability: {equipment.backpack.durability}/{equipment.backpack.max_durability}
|
||
</div>
|
||
)}
|
||
{equipment.backpack.tier !== undefined && equipment.backpack.tier !== null && equipment.backpack.tier > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⭐ Tier: {equipment.backpack.tier}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button className="equipment-action-btn unequip" onClick={() => handleUnequipItem('backpack')} title="Unequip">❌</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="equipment-emoji">🎒</span>
|
||
<span className="equipment-slot-label">Backpack</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 3: Legs */}
|
||
<div className="equipment-row">
|
||
<div className={`equipment-slot ${equipment.legs ? 'filled' : 'empty'}`}>
|
||
{equipment.legs ? (
|
||
<>
|
||
<div className="equipment-item-content">
|
||
<span className="equipment-emoji">{equipment.legs.emoji}</span>
|
||
<span className={`equipment-name ${equipment.legs.tier ? `tier-${equipment.legs.tier}` : ''}`}>{equipment.legs.name}</span>
|
||
{equipment.legs.durability && equipment.legs.durability !== null && (
|
||
<span className="equipment-durability">{equipment.legs.durability}/{equipment.legs.max_durability}</span>
|
||
)}
|
||
</div>
|
||
<div className="equipment-actions">
|
||
<div className="item-info-btn-container">
|
||
<button className="equipment-action-btn info" title="Item Info">ℹ️</button>
|
||
<div className="item-info-tooltip">
|
||
{equipment.legs.description && <div className="item-tooltip-desc">{equipment.legs.description}</div>}
|
||
{equipment.legs.stats && Object.keys(equipment.legs.stats).length > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
📊 Stats: {Object.entries(equipment.legs.stats).map(([key, val]) => `${key}: ${val}`).join(', ')}
|
||
</div>
|
||
)}
|
||
{equipment.legs.durability !== undefined && equipment.legs.durability !== null && (
|
||
<div className="item-tooltip-stat">
|
||
🔧 Durability: {equipment.legs.durability}/{equipment.legs.max_durability}
|
||
</div>
|
||
)}
|
||
{equipment.legs.tier !== undefined && equipment.legs.tier !== null && equipment.legs.tier > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⭐ Tier: {equipment.legs.tier}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button className="equipment-action-btn unequip" onClick={() => handleUnequipItem('legs')} title="Unequip">❌</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="equipment-emoji">👖</span>
|
||
<span className="equipment-slot-label">Legs</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 4: Feet */}
|
||
<div className="equipment-row">
|
||
<div className={`equipment-slot ${equipment.feet ? 'filled' : 'empty'}`}>
|
||
{equipment.feet ? (
|
||
<>
|
||
<div className="equipment-item-content">
|
||
<span className="equipment-emoji">{equipment.feet.emoji}</span>
|
||
<span className={`equipment-name ${equipment.feet.tier ? `tier-${equipment.feet.tier}` : ''}`}>{equipment.feet.name}</span>
|
||
{equipment.feet.durability && equipment.feet.durability !== null && (
|
||
<span className="equipment-durability">{equipment.feet.durability}/{equipment.feet.max_durability}</span>
|
||
)}
|
||
</div>
|
||
<div className="equipment-actions">
|
||
<div className="item-info-btn-container">
|
||
<button className="equipment-action-btn info" title="Item Info">ℹ️</button>
|
||
<div className="item-info-tooltip">
|
||
{equipment.feet.description && <div className="item-tooltip-desc">{equipment.feet.description}</div>}
|
||
{equipment.feet.stats && Object.keys(equipment.feet.stats).length > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
📊 Stats: {Object.entries(equipment.feet.stats).map(([key, val]) => `${key}: ${val}`).join(', ')}
|
||
</div>
|
||
)}
|
||
{equipment.feet.durability !== undefined && equipment.feet.durability !== null && (
|
||
<div className="item-tooltip-stat">
|
||
🔧 Durability: {equipment.feet.durability}/{equipment.feet.max_durability}
|
||
</div>
|
||
)}
|
||
{equipment.feet.tier !== undefined && equipment.feet.tier !== null && equipment.feet.tier > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⭐ Tier: {equipment.feet.tier}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button className="equipment-action-btn unequip" onClick={() => handleUnequipItem('feet')} title="Unequip">❌</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="equipment-emoji">👟</span>
|
||
<span className="equipment-slot-label">Feet</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Enhanced Inventory */}
|
||
<div className="inventory-sidebar">
|
||
<h3>🎒 Inventory</h3>
|
||
|
||
{/* Weight and Volume Bars */}
|
||
<div className="sidebar-stat-bars">
|
||
<div className="sidebar-stat-bar">
|
||
<div className="sidebar-stat-header">
|
||
<span className="sidebar-stat-label">⚖️ Weight</span>
|
||
<span className="sidebar-stat-numbers">
|
||
{profile?.current_weight || 0}/{profile?.max_weight || 100}
|
||
</span>
|
||
</div>
|
||
<div className="sidebar-progress-bar">
|
||
<div
|
||
className="sidebar-progress-fill weight"
|
||
style={{
|
||
width: `${Math.min(((profile?.current_weight || 0) / (profile?.max_weight || 100)) * 100, 100)}%`
|
||
}}
|
||
></div>
|
||
<span className="progress-percentage">
|
||
{Math.round(Math.min(((profile?.current_weight || 0) / (profile?.max_weight || 100)) * 100, 100))}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sidebar-stat-bar">
|
||
<div className="sidebar-stat-header">
|
||
<span className="sidebar-stat-label">📦 Volume</span>
|
||
<span className="sidebar-stat-numbers">
|
||
{profile?.current_volume || 0}/{profile?.max_volume || 100}
|
||
</span>
|
||
</div>
|
||
<div className="sidebar-progress-bar">
|
||
<div
|
||
className="sidebar-progress-fill volume"
|
||
style={{
|
||
width: `${Math.min(((profile?.current_volume || 0) / (profile?.max_volume || 100)) * 100, 100)}%`
|
||
}}
|
||
></div>
|
||
<span className="progress-percentage">
|
||
{Math.round(Math.min(((profile?.current_volume || 0) / (profile?.max_volume || 100)) * 100, 100))}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Inventory Items - Grouped by Category */}
|
||
<div className="inventory-items-scrollable">
|
||
{playerState.inventory.filter((item: any) => !item.is_equipped).length === 0 ? (
|
||
<p className="sidebar-empty">No items</p>
|
||
) : (
|
||
Object.entries(
|
||
playerState.inventory
|
||
.filter((item: any) => !item.is_equipped)
|
||
.reduce((acc: any, item: any) => {
|
||
const category = item.type || 'misc'
|
||
if (!acc[category]) acc[category] = []
|
||
acc[category].push(item)
|
||
return acc
|
||
}, {})
|
||
).sort(([catA], [catB]) => catA.localeCompare(catB))
|
||
.map(([category, items]: [string, any]) => {
|
||
const isCollapsed = collapsedCategories.has(category)
|
||
const sortedItems = (items as any[]).sort((a, b) => a.name.localeCompare(b.name))
|
||
|
||
return (
|
||
<div key={category} className="inventory-category-group">
|
||
<div
|
||
className="category-header clickable"
|
||
onClick={() => {
|
||
const newSet = new Set(collapsedCategories)
|
||
if (isCollapsed) {
|
||
newSet.delete(category)
|
||
} else {
|
||
newSet.add(category)
|
||
}
|
||
setCollapsedCategories(newSet)
|
||
}}
|
||
>
|
||
<span className="category-toggle">{isCollapsed ? '▶' : '▼'}</span>
|
||
{category === 'weapon' ? '⚔️ Weapons' :
|
||
category === 'armor' ? '🛡️ Armor' :
|
||
category === 'consumable' ? '🍖 Consumables' :
|
||
category === 'resource' ? '📦 Resources' :
|
||
category === 'quest' ? '📜 Quest Items' :
|
||
`📦 ${category.charAt(0).toUpperCase() + category.slice(1)}`}
|
||
<span className="category-count">({sortedItems.length})</span>
|
||
</div>
|
||
{!isCollapsed && sortedItems.map((item: any, i: number) => (
|
||
<div
|
||
key={i}
|
||
className="inventory-item-row-hover"
|
||
>
|
||
<div className="item-header-row">
|
||
<div className="item-icon-small">
|
||
<span>{item.emoji || '📦'}</span>
|
||
</div>
|
||
<div className="item-name-qty">
|
||
<span className={`item-name ${item.tier ? `tier-${item.tier}` : ''}`}>
|
||
{item.name}
|
||
{item.quantity > 1 && <span className="item-qty"> ×{item.quantity}</span>}
|
||
{item.hp_restore > 0 && <span className="item-effect"> +{item.hp_restore}❤️</span>}
|
||
{item.stamina_restore > 0 && <span className="item-effect"> +{item.stamina_restore}⚡</span>}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="item-actions-hover">
|
||
{item.consumable && (
|
||
<button className="item-action-btn use" onClick={() => handleUseItem(item.item_id)}>Use</button>
|
||
)}
|
||
{item.equippable && !item.is_equipped && (
|
||
<button className="item-action-btn equip" onClick={() => handleEquipItem(item.id)}>Equip</button>
|
||
)}
|
||
<div className="item-info-btn-container">
|
||
<button className="item-action-btn info" title="Item Info">Info</button>
|
||
<div className="item-info-tooltip">
|
||
{item.description && <div className="item-tooltip-desc">{item.description}</div>}
|
||
{item.weight !== undefined && item.weight > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⚖️ Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
|
||
</div>
|
||
)}
|
||
{item.volume !== undefined && item.volume > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
|
||
</div>
|
||
)}
|
||
{item.hp_restore && item.hp_restore > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
❤️ HP Restore: +{item.hp_restore}
|
||
</div>
|
||
)}
|
||
{item.stamina_restore && item.stamina_restore > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⚡ Stamina Restore: +{item.stamina_restore}
|
||
</div>
|
||
)}
|
||
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
|
||
<div className="item-tooltip-stat">
|
||
⚔️ Damage: {item.damage_min}-{item.damage_max}
|
||
</div>
|
||
)}
|
||
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
|
||
<div className="item-tooltip-stat">
|
||
🔧 Durability: {item.durability}/{item.max_durability}
|
||
</div>
|
||
)}
|
||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⭐ Tier: {item.tier}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{item.quantity === 1 ? (
|
||
<button className="item-action-btn drop" onClick={() => handleDropItem(item.item_id, 1)}>Drop</button>
|
||
) : (
|
||
<div className="item-drop-btn-container">
|
||
<button className="item-action-btn drop">Drop ▼</button>
|
||
<div className="item-drop-menu">
|
||
<button className="item-drop-option" onClick={() => handleDropItem(item.item_id, 1)}>Drop 1</button>
|
||
{item.quantity >= 5 && (
|
||
<button className="item-drop-option" onClick={() => handleDropItem(item.item_id, 5)}>Drop 5</button>
|
||
)}
|
||
{item.quantity >= 10 && (
|
||
<button className="item-drop-option" onClick={() => handleDropItem(item.item_id, 10)}>Drop 10</button>
|
||
)}
|
||
<button className="item-drop-option" onClick={() => handleDropItem(item.item_id, item.quantity)}>Drop All ({item.quantity})</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Item Actions Panel */}
|
||
{selectedItem && (
|
||
<div className="item-actions-panel">
|
||
<div className="item-details-header">
|
||
<strong>{selectedItem.name}</strong>
|
||
<button className="close-btn" onClick={() => setSelectedItem(null)}>×</button>
|
||
</div>
|
||
{selectedItem.description && (
|
||
<p className="item-description">{selectedItem.description}</p>
|
||
)}
|
||
<div className="item-action-buttons">
|
||
{selectedItem.usable && (
|
||
<button className="item-action-btn use-btn" onClick={() => handleItemAction('use', selectedItem.id)}>
|
||
🍷 Use
|
||
</button>
|
||
)}
|
||
{selectedItem.equippable && !selectedItem.is_equipped && (
|
||
<button className="item-action-btn equip-btn" onClick={() => handleItemAction('equip', selectedItem.id)}>
|
||
⚔️ Equip
|
||
</button>
|
||
)}
|
||
{selectedItem.is_equipped && (
|
||
<button className="item-action-btn unequip-btn" onClick={() => handleItemAction('unequip', selectedItem.id)}>
|
||
🎒 Unequip
|
||
</button>
|
||
)}
|
||
<button className="item-action-btn drop-btn" onClick={() => handleItemAction('drop', selectedItem.id)}>
|
||
🗑️ Drop
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="game-container">
|
||
<GameHeader
|
||
className={mobileHeaderOpen ? 'open' : ''}
|
||
/>
|
||
|
||
{/* Mobile Header Toggle - only show in main view */}
|
||
{mobileMenuOpen === 'none' && (
|
||
<button
|
||
className="mobile-header-toggle"
|
||
onClick={() => setMobileHeaderOpen(!mobileHeaderOpen)}
|
||
>
|
||
{mobileHeaderOpen ? '✕' : '☰'}
|
||
</button>
|
||
)}
|
||
|
||
<main className="game-main">
|
||
{renderExploreTab()}
|
||
|
||
{/* Mobile Tab Navigation */}
|
||
<div className="mobile-menu-buttons">
|
||
<button
|
||
className={`mobile-menu-btn left-btn ${mobileMenuOpen === 'left' ? 'active' : ''}`}
|
||
onClick={() => setMobileMenuOpen(mobileMenuOpen === 'left' ? 'none' : 'left')}
|
||
>
|
||
<span>🧭</span>
|
||
</button>
|
||
<button
|
||
className={`mobile-menu-btn bottom-btn ${mobileMenuOpen === 'bottom' ? 'active' : ''}`}
|
||
onClick={() => setMobileMenuOpen(mobileMenuOpen === 'bottom' ? 'none' : 'bottom')}
|
||
disabled={!!combatState}
|
||
>
|
||
<span>📍</span>
|
||
</button>
|
||
<button
|
||
className={`mobile-menu-btn right-btn ${mobileMenuOpen === 'right' ? 'active' : ''}`}
|
||
onClick={() => setMobileMenuOpen(mobileMenuOpen === 'right' ? 'none' : 'right')}
|
||
>
|
||
<span>🎒</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Mobile Menu Overlays */}
|
||
{mobileMenuOpen !== 'none' && (
|
||
<div
|
||
className="mobile-menu-overlay"
|
||
onClick={() => setMobileMenuOpen('none')}
|
||
/>
|
||
)}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Game
|