Files
echoes-of-the-ash/pwa/src/components/game/LocationView.tsx
2026-02-05 16:09:34 +01:00

488 lines
21 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 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