Files
echoes-of-the-ash/pwa/src/components/Game.tsx
2025-11-07 15:27:13 +01:00

2631 lines
118 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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