Files
echoes-of-the-ash/pwa/src/components/game/LocationView.tsx

458 lines
19 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 Workbench from './Workbench'
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) {
useTranslation()
return (
<div className="location-view">
<div className="location-info">
<h2 className="centered-heading">
{getTranslatedText(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' && onOpenCrafting) onOpenCrafting()
else if (tag === 'repair_station' && onOpenRepair) onOpenRepair()
}
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>
)}
{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>📜 Recent Activity</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> 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/${enemy.name.toLowerCase().replace(/ /g, '_')}.webp`)}
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={() => onInitiateCombat(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={() => onLootCorpse(String(corpse.id))}
disabled={corpse.loot_count === 0}
>
🔍 Examine
</button>
</div>
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
<div className="corpse-details">
<div className="corpse-details-header">
<h4>Lootable Items:</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} {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={() => onLootCorpseItem(String(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={() => onLootCorpseItem(String(corpse.id), null)}
>
📦 Loot All Available
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Friendly 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>
)}
{/* Items on Ground */}
{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">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={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}` : ''}`}>
{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={() => onPickup(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={() => onPickup(item.id, 1)}>Pick Up 1</button>
{item.quantity >= 5 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>Pick Up 5</button>
)}
{item.quantity >= 10 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>Pick Up 10</button>
)}
<button className="item-pickup-option" onClick={() => onPickup(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.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={() => onInitiatePvP(player.id)}
title={`Attack ${player.name || 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>
{(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