488 lines
21 KiB
TypeScript
488 lines
21 KiB
TypeScript
|
||
import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useAudio } from '../../contexts/AudioContext'
|
||
import Workbench from './Workbench'
|
||
import { GameTooltip } from '../common/GameTooltip'
|
||
import { getAssetPath } from '../../utils/assetPath'
|
||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||
|
||
interface LocationViewProps {
|
||
location: Location
|
||
playerState: PlayerState | null
|
||
combatState: CombatState | null
|
||
message: string
|
||
locationMessages: Array<{ time: string; message: string }>
|
||
expandedCorpse: string | null
|
||
corpseDetails: any
|
||
mobileMenuOpen: string
|
||
showCraftingMenu: boolean
|
||
showRepairMenu: boolean
|
||
workbenchTab: WorkbenchTab
|
||
craftableItems: any[]
|
||
repairableItems: any[]
|
||
uncraftableItems: any[]
|
||
craftFilter: string
|
||
repairFilter: string
|
||
uncraftFilter: string
|
||
craftCategoryFilter: string
|
||
profile: Profile | null
|
||
onSetMessage: (msg: string) => void
|
||
onInitiateCombat: (npcId: number) => void
|
||
onInitiatePvP: (playerId: number) => void
|
||
onPickup: (itemId: number, quantity: number) => void
|
||
onLootCorpse: (corpseId: string) => void
|
||
onLootCorpseItem: (corpseId: string, itemIndex: number | null) => void
|
||
onSetExpandedCorpse: (corpseId: string | null) => void
|
||
onOpenCrafting?: () => void
|
||
onOpenRepair?: () => void
|
||
onCloseCrafting: () => void
|
||
onSwitchWorkbenchTab: (tab: WorkbenchTab) => void
|
||
onSetCraftFilter: (filter: string) => void
|
||
onSetRepairFilter: (filter: string) => void
|
||
onSetUncraftFilter: (filter: string) => void
|
||
onSetCraftCategoryFilter: (category: string) => void
|
||
onCraft: (itemId: number) => void
|
||
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
||
onUncraft: (uniqueItemId: string, inventoryId: number) => void
|
||
}
|
||
|
||
function LocationView({
|
||
location,
|
||
message,
|
||
locationMessages,
|
||
expandedCorpse,
|
||
corpseDetails,
|
||
mobileMenuOpen,
|
||
showCraftingMenu,
|
||
showRepairMenu,
|
||
workbenchTab,
|
||
craftableItems,
|
||
repairableItems,
|
||
uncraftableItems,
|
||
craftFilter,
|
||
repairFilter,
|
||
uncraftFilter,
|
||
craftCategoryFilter,
|
||
profile,
|
||
onSetMessage,
|
||
onInitiateCombat,
|
||
onInitiatePvP,
|
||
onPickup,
|
||
onLootCorpse,
|
||
onLootCorpseItem,
|
||
onSetExpandedCorpse,
|
||
onOpenCrafting,
|
||
onOpenRepair,
|
||
onCloseCrafting,
|
||
onSwitchWorkbenchTab,
|
||
onSetCraftFilter,
|
||
onSetRepairFilter,
|
||
onSetUncraftFilter,
|
||
onSetCraftCategoryFilter,
|
||
onCraft,
|
||
onRepair,
|
||
onUncraft
|
||
}: LocationViewProps) {
|
||
const { t } = useTranslation()
|
||
const { playSfx } = useAudio()
|
||
return (
|
||
<div className="location-view">
|
||
<div className="location-info">
|
||
<h2 className="centered-heading">
|
||
{getTranslatedText(location.name)}
|
||
{location.danger_level !== undefined && location.danger_level === 0 && (
|
||
<GameTooltip content="Safe Zone">
|
||
<span className="danger-badge danger-safe">✓ Safe</span>
|
||
</GameTooltip>
|
||
)}
|
||
{location.danger_level !== undefined && location.danger_level > 0 && (
|
||
<GameTooltip content={`Danger Level: ${location.danger_level}`}>
|
||
<span className={`danger-badge danger-${location.danger_level}`}>
|
||
⚠️ {location.danger_level}
|
||
</span>
|
||
</GameTooltip>
|
||
)}
|
||
</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' && onOpenCrafting) onOpenCrafting()
|
||
else if (tag === 'repair_station' && onOpenRepair) onOpenRepair()
|
||
}
|
||
|
||
return (
|
||
<GameTooltip key={i} content={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}>
|
||
<span
|
||
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
|
||
onClick={isClickable ? handleClick : undefined}
|
||
style={isClickable ? { cursor: 'pointer' } : undefined}
|
||
>
|
||
{tag === 'workbench' && t('tags.workbench')}
|
||
{tag === 'repair_station' && t('tags.repairStation')}
|
||
{tag === 'safe_zone' && t('tags.safeZone')}
|
||
{tag === 'shop' && t('tags.shop')}
|
||
{tag === 'shelter' && t('tags.shelter')}
|
||
{tag === 'medical' && t('tags.medical')}
|
||
{tag === 'storage' && t('tags.storage')}
|
||
{tag === 'water_source' && t('tags.water')}
|
||
{tag === 'food_source' && t('tags.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>
|
||
</GameTooltip>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
|
||
|
||
{location.image_url && (
|
||
<div className="location-image-container">
|
||
<img
|
||
src={getAssetPath(location.image_url)}
|
||
alt={getTranslatedText(location.name)}
|
||
className="location-image"
|
||
onError={(e: any) => (e.currentTarget.style.display = 'none')}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="location-description-box">
|
||
<p className="location-description">{getTranslatedText(location.description)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{message && (
|
||
<div className="message-box" onClick={() => onSetMessage('')}>
|
||
{message}
|
||
</div>
|
||
)}
|
||
|
||
{locationMessages.length > 0 && (
|
||
<div className="location-messages-log">
|
||
<h4>{t('location.recentActivity')}</h4>
|
||
<div className="messages-scroll">
|
||
{locationMessages.slice(-10).reverse().map((msg, idx) => (
|
||
<div key={idx} className="location-message-item">
|
||
<span className="message-time">{msg.time}</span>
|
||
<span className="message-text">{msg.message}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<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>{t('location.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={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
||
alt={getTranslatedText(enemy.name)}
|
||
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="entity-info">
|
||
<div className="entity-name enemy-name">{getTranslatedText(enemy.name)}</div>
|
||
{enemy.level && <div className="entity-level">{t('location.level')} {enemy.level}</div>}
|
||
</div>
|
||
<button
|
||
className="entity-action-btn combat-btn"
|
||
onClick={() => onInitiateCombat(enemy.id)}
|
||
>
|
||
{t('common.fight')}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Corpses */}
|
||
{location.corpses && location.corpses.length > 0 && (
|
||
<div className="entity-section corpses-section">
|
||
<h3>{t('location.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} {getTranslatedText(corpse.name)}</div>
|
||
<div className="corpse-loot-count">{corpse.loot_count} {t('location.items')}</div>
|
||
</div>
|
||
<button
|
||
className="entity-action-btn loot-btn"
|
||
onClick={() => {
|
||
playSfx('/audio/sfx/interact.wav')
|
||
onLootCorpse(String(corpse.id))
|
||
}}
|
||
disabled={corpse.loot_count === 0}
|
||
>
|
||
🔍 {t('common.examine')}
|
||
</button>
|
||
</div>
|
||
|
||
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
|
||
<div className="corpse-details">
|
||
<div className="corpse-details-header">
|
||
<h4>{t('location.lootableItems')}</h4>
|
||
<button
|
||
className="close-btn"
|
||
onClick={() => {
|
||
onSetExpandedCorpse(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} {getTranslatedText(item.item_name)}
|
||
</div>
|
||
<div className="corpse-item-qty">
|
||
{t('common.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'}`}>
|
||
🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
|
||
<button
|
||
className="corpse-item-loot-btn"
|
||
onClick={() => onLootCorpseItem(String(corpse.id), item.index)}
|
||
disabled={!item.can_loot}
|
||
>
|
||
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
||
</button>
|
||
</GameTooltip>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
className="loot-all-btn"
|
||
onClick={() => onLootCorpseItem(String(corpse.id), null)}
|
||
>
|
||
📦 {t('common.lootAll')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Friendly NPCs */}
|
||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
|
||
<div className="entity-section npcs-section">
|
||
<h3>{t('location.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">{getTranslatedText(npc.name)}</div>
|
||
{npc.level && <div className="entity-level">{t('location.level')} {npc.level}</div>}
|
||
</div>
|
||
<button className="entity-action-btn">{t('common.talk')}</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Items on Ground */}
|
||
{location.items.length > 0 && (
|
||
<div className="entity-section items-section">
|
||
<h3>{t('location.itemsOnGround')}</h3>
|
||
<div className="entity-list">
|
||
{location.items.map((item: any, i: number) => (
|
||
<div key={i} className="entity-card item-card">
|
||
{item.image_path ? (
|
||
<img
|
||
src={getAssetPath(item.image_path)}
|
||
alt={getTranslatedText(item.name)}
|
||
className="entity-icon"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
const icon = (e.target as HTMLImageElement).nextElementSibling;
|
||
if (icon) icon.classList.remove('hidden');
|
||
}}
|
||
/>
|
||
) : null}
|
||
<span className={`entity-icon ${item.image_path ? 'hidden' : ''}`}>{item.emoji || '📦'}</span>
|
||
<div className="entity-info">
|
||
<div className={`entity-name ${item.tier ? `tier-${item.tier}` : ''}`}>
|
||
{getTranslatedText(item.name) || 'Unknown Item'}
|
||
</div>
|
||
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
|
||
</div>
|
||
<div className="item-info-btn-container">
|
||
<GameTooltip content={
|
||
<div className="item-info-tooltip-content">
|
||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
||
{item.weight !== undefined && item.weight > 0 && (
|
||
<div className="item-tooltip-stat">
|
||
⚖️ {t('stats.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">
|
||
📦 {t('stats.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">❤️ {t('stats.hpRestore')}: +{item.hp_restore}</div>
|
||
)}
|
||
{item.stamina_restore && item.stamina_restore > 0 && (
|
||
<div className="item-tooltip-stat">⚡ {t('stats.staminaRestore')}: +{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">
|
||
⚔️ {t('stats.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">
|
||
🔧 {t('stats.durability')}: {item.durability}/{item.max_durability}
|
||
</div>
|
||
)}
|
||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||
<div className="item-tooltip-stat">⭐ {t('stats.tier')}: {item.tier}</div>
|
||
)}
|
||
</div>
|
||
}>
|
||
<button className="entity-action-btn info">{t('common.info')}</button>
|
||
</GameTooltip>
|
||
</div>
|
||
{item.quantity === 1 ? (
|
||
<button
|
||
className="entity-action-btn pickup"
|
||
onClick={() => {
|
||
playSfx('/audio/sfx/pickup.wav')
|
||
onPickup(item.id, 1)
|
||
}}
|
||
>
|
||
{t('common.pickUp')}
|
||
</button>
|
||
) : (
|
||
<div className="item-pickup-btn-container">
|
||
<button className="entity-action-btn pickup">{t('common.pickUp')} ▼</button>
|
||
<div className="item-pickup-menu">
|
||
<button className="item-pickup-option" onClick={() => {
|
||
playSfx('/audio/sfx/pickup.wav')
|
||
onPickup(item.id, 1)
|
||
}}>{t('common.pickUp')} 1</button>
|
||
{item.quantity >= 5 && (
|
||
<button className="item-pickup-option" onClick={() => {
|
||
playSfx('/audio/sfx/pickup.wav')
|
||
onPickup(item.id, 5)
|
||
}}>{t('common.pickUp')} 5</button>
|
||
)}
|
||
{item.quantity >= 10 && (
|
||
<button className="item-pickup-option" onClick={() => {
|
||
playSfx('/audio/sfx/pickup.wav')
|
||
onPickup(item.id, 10)
|
||
}}>{t('common.pickUp')} 10</button>
|
||
)}
|
||
<button className="item-pickup-option" onClick={() => {
|
||
playSfx('/audio/sfx/pickup.wav')
|
||
onPickup(item.id, item.quantity)
|
||
}}>
|
||
{t('common.pickUpAll')} ({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.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 && (
|
||
<GameTooltip content={`Attack ${player.name || player.username}`}>
|
||
<button
|
||
className="pvp-btn"
|
||
onClick={() => onInitiatePvP(player.id)}
|
||
>
|
||
{t('game.attack')}
|
||
</button>
|
||
</GameTooltip>
|
||
)}
|
||
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
|
||
<div className="pvp-disabled-reason">{t('game.levelDifferenceTooHigh')}</div>
|
||
)}
|
||
{!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
|
||
<div className="pvp-disabled-reason">{t('game.areaTooSafeForPvP')}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{(showCraftingMenu || showRepairMenu) && (
|
||
<Workbench
|
||
showCraftingMenu={showCraftingMenu}
|
||
showRepairMenu={showRepairMenu}
|
||
workbenchTab={workbenchTab}
|
||
craftableItems={craftableItems}
|
||
repairableItems={repairableItems}
|
||
uncraftableItems={uncraftableItems}
|
||
craftFilter={craftFilter}
|
||
repairFilter={repairFilter}
|
||
uncraftFilter={uncraftFilter}
|
||
craftCategoryFilter={craftCategoryFilter}
|
||
profile={profile}
|
||
onCloseCrafting={onCloseCrafting}
|
||
onSwitchTab={onSwitchWorkbenchTab}
|
||
onSetCraftFilter={onSetCraftFilter}
|
||
onSetRepairFilter={onSetRepairFilter}
|
||
onSetUncraftFilter={onSetUncraftFilter}
|
||
onSetCraftCategoryFilter={onSetCraftCategoryFilter}
|
||
onCraft={onCraft}
|
||
onRepair={onRepair}
|
||
onUncraft={onUncraft}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default LocationView
|