chore: save progress before layout changes

This commit is contained in:
Joan
2026-02-10 10:48:53 +01:00
parent 70dc35b4b2
commit bba5d1d9dd
48 changed files with 1535 additions and 690 deletions

View File

@@ -375,6 +375,7 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
class UncraftItemRequest(BaseModel):
inventory_id: int
quantity: int = 1
@router.post("/api/game/uncraft_item")
@@ -402,6 +403,14 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
if not inv_item:
raise HTTPException(status_code=404, detail="Item not found in inventory")
# Check quantity
if request.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity must be greater than 0")
current_quantity = inv_item.get('quantity', 1)
if request.quantity > current_quantity:
raise HTTPException(status_code=400, detail=f"Not enough items. Have {current_quantity}, requested {request.quantity}")
# Get item definition
item_def = ITEMS_MANAGER.items.get(inv_item['item_id'])
@@ -415,29 +424,50 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
if not uncraft_yield:
raise HTTPException(status_code=400, detail="No uncraft recipe found")
# Check tools requirement
# Check tools requirement (once per operation? or per item?)
# Usually tools are checked once for the operation, but durability cost might be per item.
# Logic above for crafting consumes tool durability for the batch?
# In craft_item above, it loops through craft_tools but seemingly only once?
# Wait, craft_item does NOT loop for quantity because craft_item only crafts 1 at a time (request has no quantity).
# For uncrafting multiple, we should multiply tool cost.
uncraft_tools = getattr(item_def, 'uncraft_tools', [])
tools_consumed = []
if uncraft_tools:
success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], uncraft_tools, inventory)
# Scale tool cost by quantity
scaled_uncraft_tools = []
for tool_req in uncraft_tools:
scaled_req = tool_req.copy()
scaled_req['durability_cost'] = tool_req['durability_cost'] * request.quantity
scaled_uncraft_tools.append(scaled_req)
success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], scaled_uncraft_tools, inventory)
if not success:
raise HTTPException(status_code=400, detail=error_msg)
else:
tools_consumed = []
# Calculate stamina cost
stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
base_stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
total_stamina_cost = base_stamina_cost * request.quantity
# Check stamina
if player['stamina'] < stamina_cost:
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
if player['stamina'] < total_stamina_cost:
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {total_stamina_cost}, have {player['stamina']}")
# Deduct stamina
new_stamina = max(0, player['stamina'] - stamina_cost)
new_stamina = max(0, player['stamina'] - total_stamina_cost)
await db.update_player_stamina(current_user['id'], new_stamina)
# Remove the item from inventory
# Use remove_inventory_row since we have the inventory ID
await db.remove_inventory_row(inv_item['id'])
# Update inventory item
if request.quantity == current_quantity:
# Remove the item row entirely
await db.remove_inventory_row(inv_item['id'])
else:
# Update quantity
await db.update_inventory_item(
inv_item['id'],
quantity=current_quantity - request.quantity
)
# Calculate durability ratio for yield reduction
durability_ratio = 1.0 # Default: full yield
@@ -449,96 +479,120 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
if max_durability > 0:
durability_ratio = current_durability / max_durability
# Re-fetch inventory to get updated capacity after removing the item
# Re-fetch inventory to get updated capacity
inventory = await db.get_inventory(current_user['id'])
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
# Calculate materials with loss chance and durability reduction
# Calculate materials
import random
loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3)
yield_info = {
'base_yield': uncraft_yield,
'loss_chance': loss_chance,
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
}
materials_yielded_dict = {}
materials_lost_dict = {}
materials_dropped_dict = {}
# Loop for each item being uncrafted to calculate yield fairly
for _ in range(request.quantity):
for material in uncraft_yield:
# Apply durability reduction first
base_quantity = material['quantity']
# Calculate adjusted quantity based on durability
adjusted_quantity = int(round(base_quantity * durability_ratio))
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
mat_name = mat_def.name if mat_def else material['item_id']
loss_key = (material['item_id'], mat_name)
# If durability is too low (< 10%), yield nothing for this material
if durability_ratio < 0.1 or adjusted_quantity <= 0:
if loss_key not in materials_lost_dict:
materials_lost_dict[loss_key] = 0
materials_lost_dict[loss_key] += base_quantity
continue
# Roll for loss chance
if random.random() < loss_chance:
# Lost this material
if loss_key not in materials_lost_dict:
materials_lost_dict[loss_key] = 0
materials_lost_dict[loss_key] += adjusted_quantity
else:
# Check if it fits in inventory (incremental check?)
# For simplicity, check per unit or accumulate and check at end.
# Checking per unit is safer but slower.
# Since we are modifying inventory in loop (potentially), we should be careful.
# Actually, we should accumulate yield then add to inventory at end to optimize DB calls?
# But we need to check capacity.
# Let's accumulate pending yield.
yield_key = (material['item_id'], mat_name, mat_def.emoji if mat_def else '📦', mat_def)
if yield_key not in materials_yielded_dict:
materials_yielded_dict[yield_key] = 0
materials_yielded_dict[yield_key] += adjusted_quantity
# Now process the accumulated yield
materials_yielded = []
materials_lost = []
materials_dropped = []
for material in uncraft_yield:
# Apply durability reduction first
base_quantity = material['quantity']
# Convert lost dict to list
for (item_id, name), qty in materials_lost_dict.items():
materials_lost.append({
'item_id': item_id,
'name': name,
'quantity': qty,
'reason': 'lost_or_low_durability'
})
# Calculate adjusted quantity based on durability
# Use round() to ensure minimum yield of 1 for high durability items (e.g. 90% of 1 = 0.9 -> 1)
adjusted_quantity = int(round(base_quantity * durability_ratio))
# Process yield
for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items():
mat_weight = getattr(mat_def, 'weight', 0) * qty
mat_volume = getattr(mat_def, 'volume', 0) * qty
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
# Simple check against capacity (assuming current_weight was just updated from DB)
# Note: we might fill up mid-loop. ideally we add one by one or check total.
# Let's check total.
# If durability is too low (< 10%), yield nothing for this material
if durability_ratio < 0.1 or adjusted_quantity <= 0:
materials_lost.append({
'item_id': material['item_id'],
'name': mat_def.name if mat_def else material['item_id'],
'quantity': base_quantity,
'reason': 'durability_too_low'
})
continue
# Roll for each material separately with loss chance
if random.random() < loss_chance:
# Lost this material
materials_lost.append({
'item_id': material['item_id'],
'name': mat_def.name if mat_def else material['item_id'],
'quantity': adjusted_quantity,
'reason': 'random_loss'
if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
# Fits
await db.add_item_to_inventory(
player_id=current_user['id'],
item_id=item_id,
quantity=qty
)
current_weight += mat_weight
current_volume += mat_volume
materials_yielded.append({
'item_id': item_id,
'name': name,
'emoji': emoji,
'quantity': qty
})
else:
# Check if it fits in inventory
mat_weight = getattr(mat_def, 'weight', 0) * adjusted_quantity
mat_volume = getattr(mat_def, 'volume', 0) * adjusted_quantity
# Drop
await db.drop_item_to_world(
item_id=item_id,
quantity=qty,
location_id=player['location_id']
)
if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
# Fits in inventory
await db.add_item_to_inventory(
player_id=current_user['id'],
item_id=material['item_id'],
quantity=adjusted_quantity
)
# Update current capacity tracking
current_weight += mat_weight
current_volume += mat_volume
materials_yielded.append({
'item_id': material['item_id'],
'name': mat_def.name if mat_def else material['item_id'],
'emoji': mat_def.emoji if mat_def else '📦',
'quantity': adjusted_quantity
})
else:
# Inventory full - drop to ground
await db.drop_item_to_world(
item_id=material['item_id'],
quantity=adjusted_quantity,
location_id=player['location_id']
)
materials_dropped.append({
'item_id': material['item_id'],
'name': mat_def.name if mat_def else material['item_id'],
'emoji': mat_def.emoji if mat_def else '📦',
'quantity': adjusted_quantity
})
materials_dropped.append({
'item_id': item_id,
'name': name,
'emoji': emoji,
'quantity': qty
})
message = f"Uncrafted {item_def.name}!"
message = f"Uncrafted {request.quantity}x {item_def.name}!"
if durability_ratio < 1.0:
message += f" (Item condition reduced yield by {int((1 - durability_ratio) * 100)}%)"
message += f" (Condition reduced yield)"
if materials_lost:
message += f" Lost {len(materials_lost)} material type(s)."
message += f" Lost materials."
if materials_dropped:
message += f" Inventory full! Dropped {len(materials_dropped)} item(s) to the ground."
message += f" Inventory full! Dropped items."
return {
'success': True,
@@ -550,7 +604,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
'tools_consumed': tools_consumed,
'loss_chance': loss_chance,
'durability_ratio': round(durability_ratio, 2),
'stamina_cost': stamina_cost,
'stamina_cost': total_stamina_cost,
'new_stamina': new_stamina
}

View File

@@ -783,7 +783,8 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
"name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse",
"emoji": "💀",
"loot_count": len(loot),
"timestamp": corpse['death_timestamp']
"timestamp": corpse['death_timestamp'],
"image_path": npc_def.image_path if npc_def else None
})
for corpse in player_corpses:
@@ -957,7 +958,8 @@ async def move(
response = {
"success": True,
"message": message,
"new_location_id": new_location_id
"new_location_id": new_location_id,
"new_location_name": new_location.name if new_location else "Unknown" # Add location name for frontend
}
# Add encounter info if triggered
@@ -1469,10 +1471,21 @@ async def drop_item(
# Get inventory item by item_id (string), not database id
inventory = await db.get_inventory(player_id)
inv_item = None
for item in inventory:
if item['item_id'] == item_id:
inv_item = item
break
# If inventory_id is provided, use it to find precise item
inventory_id = drop_req.get('inventory_id')
if inventory_id:
for item in inventory:
if item['id'] == inventory_id:
inv_item = item
break
else:
# Fallback to legacy behavior (first matching item_id)
for item in inventory:
if item['item_id'] == item_id:
inv_item = item
break
if not inv_item:
raise HTTPException(status_code=404, detail="Item not found in inventory")

View File

@@ -62,7 +62,22 @@ async def get_corpse_details(
# Get player's inventory to check available tools
inventory = await db.get_inventory(player['id'])
available_tools = set([item['item_id'] for item in inventory])
# Map item_id to max durability found in inventory for that item
tools_durability = {}
for item in inventory:
item_id = item['item_id']
durability = 0
# Helper to get actual durability from unique item data
if item.get('unique_item_id'):
unique_item = await db.get_unique_item(item['unique_item_id'])
if unique_item:
durability = unique_item.get('durability', 0)
if item_id not in tools_durability or durability > tools_durability[item_id]:
tools_durability[item_id] = durability
available_tools = set(tools_durability.keys())
if corpse_type == 'npc':
# Get NPC corpse
@@ -80,9 +95,16 @@ async def get_corpse_details(
loot_items = []
for idx, loot_item in enumerate(loot_remaining):
required_tool = loot_item.get('required_tool')
durability_cost = loot_item.get('tool_durability_cost', 5)
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
has_tool = required_tool is None or required_tool in available_tools
has_tool = True
if required_tool:
if required_tool not in tools_durability:
has_tool = False
elif tools_durability[required_tool] < durability_cost:
has_tool = False
tool_def = ITEMS_MANAGER.get_item(required_tool) if required_tool else None
loot_items.append({

View File

@@ -341,16 +341,16 @@
"text": {
"crit_failure": {
"en": "The floor collapses beneath you! (-10 HP)",
"es": ""
"es": "¡El suelo se derrumba bajo ti! (-10 HP)"
},
"crit_success": "",
"failure": {
"en": "The house has already been thoroughly looted. Nothing remains.",
"es": ""
"es": "La casa ya ha sido despojada de todo. No queda nada."
},
"success": {
"en": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!",
"es": ""
"en": "You find some useful supplies!",
"es": "¡Encuentras algunos suministros útiles!"
}
}
}

View File

@@ -39,14 +39,16 @@ for category in items locations npcs interactables characters placeholder static
continue
fi
if [[ "$category" == "items" || "$category" == "placeholder" || "$category" == "static_npcs" ]]; then
if [[ "$category" == "items" || "$category" == "placeholder" || "$category" == "static_npcs" || "$category" == "npcs" ]]; then
# Special processing for items: remove white background and resize
echo " ➜ Converting item: $filename"
tmp="/tmp/${base}_clean.png"
if [[ "$category" == "static_npcs" ]]; then
convert "$img" -fuzz 10% -transparent white -resize "$PORTRAIT_SIZE" "$tmp"
else
elif [[ "$category" == "items" || "$category" == "placeholder" ]]; then
convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp"
else
convert "$img" -fuzz 10% -transparent white "$tmp"
fi
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
rm "$tmp"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 735 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -78,7 +78,7 @@ textarea:focus {
margin-top: 0.5rem;
}
.success {
.message-success {
color: #51cf66;
margin-top: 0.5rem;
}

View File

@@ -323,7 +323,7 @@
text-align: center;
}
.success {
.message-success {
background: rgba(40, 167, 69, 0.1);
color: #5ddc6c;
padding: 1rem;

View File

@@ -334,7 +334,7 @@ function AccountPage() {
/>
</div>
{emailError && <div className="error">{emailError}</div>}
{emailSuccess && <div className="success">{emailSuccess}</div>}
{emailSuccess && <div className="message-success">{emailSuccess}</div>}
<button type="submit" className="button-primary" disabled={emailLoading}>
{emailLoading ? 'Updating...' : 'Update Email'}
</button>
@@ -392,7 +392,7 @@ function AccountPage() {
/>
</div>
{passwordError && <div className="error">{passwordError}</div>}
{passwordSuccess && <div className="success">{passwordSuccess}</div>}
{passwordSuccess && <div className="message-success">{passwordSuccess}</div>}
<button type="submit" className="button-primary" disabled={passwordLoading}>
{passwordLoading ? 'Updating...' : 'Update Password'}
</button>

View File

@@ -723,8 +723,8 @@ html {
}
.messages-scroll {
height: 5.5rem;
/* Compact fixed height (~3 lines) */
height: 6rem;
/* Always visible activity log */
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -752,6 +752,13 @@ html {
flex: 1;
}
.message-location {
color: rgba(107, 185, 240, 0.7);
font-size: 0.75rem;
flex-shrink: 0;
font-style: italic;
}
.movement-controls {
background: var(--game-bg-panel);
padding: 1.5rem;
@@ -2086,11 +2093,12 @@ body.no-scroll {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
width: 24px;
height: 24px;
background: rgba(244, 67, 54, 0.9);
border: 1px solid rgba(255, 255, 255, 0.3);
clip-path: var(--game-clip-path-sm);
/* clip-path removed to ensure square box */
aspect-ratio: 1;
color: #fff;
font-size: 0.7rem;
font-weight: bold;

View File

@@ -10,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
import { GameProvider } from '../contexts/GameContext'
import { QuestJournal } from './game/QuestJournal'
import GameHeader from './GameHeader'
import './Game.css'
function Game() {
@@ -350,9 +351,8 @@ function Game() {
return (
<GameProvider value={gameContextValue}>
<GameHeader />
<div className="game-container">
{/* Game Header is now in GameLayout */}
{/* Quest Journal Toggle Button - Add to header or float?
Let's add it floating for now or in the top right.
*/}
@@ -396,6 +396,7 @@ function Game() {
profile={state.profile}
playerState={state.playerState}
equipment={state.equipment}
locationImage={state.location?.image_url}
onCombatAction={actions.handleCombatAction}
onPvPAction={async (action: string) => {
try {
@@ -478,7 +479,7 @@ function Game() {
onSetCraftCategoryFilter={actions.setCraftCategoryFilter}
onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())}
onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)}
onUncraft={(uniqueItemId: string, inventoryId: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId)}
onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)}
failedActionItemId={state.failedActionItemId}
quests={state.quests}
/>
@@ -501,8 +502,8 @@ function Game() {
}}
onEquipItem={actions.handleEquipItem}
onUnequipItem={actions.handleUnequipItem}
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
await actions.handleDropItem(itemId.toString(), quantity)
onDropItem={async (itemId: number, invId: number, quantity: number) => {
await actions.handleDropItem(itemId.toString(), quantity, invId)
}}
onSpendPoint={actions.handleSpendPoint}
onOpenQuestJournal={() => setShowQuestJournal(true)}

View File

@@ -57,6 +57,85 @@
opacity: 0.8;
}
/* --- Center Section: Location/Combat Title --- */
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
align-items: center;
padding: 0 1rem;
z-index: 10;
}
.header-location-title {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 1.2rem;
background: linear-gradient(135deg, rgba(107, 185, 240, 0.1) 0%, transparent 100%);
border: 1px solid rgba(107, 185, 240, 0.3);
clip-path: var(--game-clip-path);
}
.header-location-title.combat {
background: linear-gradient(135deg, rgba(225, 29, 72, 0.15) 0%, transparent 100%);
border-color: rgba(225, 29, 72, 0.4);
animation: combat-pulse 2s infinite;
}
@keyframes combat-pulse {
0%,
100% {
box-shadow: 0 0 5px rgba(225, 29, 72, 0.3);
}
50% {
box-shadow: 0 0 15px rgba(225, 29, 72, 0.5);
}
}
.header-location-title .location-name {
font-size: 1.1rem;
font-weight: 700;
color: #fff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.header-location-title .combat-indicator {
font-size: 1.2rem;
}
.header-location-title .turn-indicator {
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
text-transform: uppercase;
clip-path: var(--game-clip-path-sm);
}
.header-location-title .turn-indicator.your-turn {
background: rgba(76, 175, 80, 0.3);
color: #4caf50;
border: 1px solid rgba(76, 175, 80, 0.5);
}
.header-location-title .turn-indicator.enemy-turn {
background: rgba(244, 67, 54, 0.3);
color: #f44336;
border: 1px solid rgba(244, 67, 54, 0.5);
}
/* --- Right Section: Navigation + User Info --- */
.header-right {
display: flex;
align-items: center;
gap: 0.8rem;
}
/* --- Navigation Tabs (Unified Angled Style) --- */
.nav-links {
display: flex;
@@ -64,12 +143,11 @@
/* Tight gaps for HUD feel */
align-items: center;
height: 100%;
margin-left: 1.5rem;
}
.nav-link {
height: 70%;
/* Not full height, more like buttons */
height: 36px;
min-height: 36px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 0 1.2rem;

View File

@@ -5,6 +5,8 @@ import { useGameWebSocket } from '../hooks/useGameWebSocket'
import api from '../services/api'
import { useTranslation } from 'react-i18next'
import LanguageSelector from './LanguageSelector'
import { useOptionalGame } from '../contexts/GameContext'
import { getTranslatedText } from '../utils/i18nUtils'
import './Game.css'
import { GameTooltip } from './common/GameTooltip'
@@ -12,17 +14,39 @@ import { GameTooltip } from './common/GameTooltip'
// Import the new specific header styles
import './GameHeader.css'
interface GameHeaderProps {
className?: string
interface CombatInfo {
enemyName: string
yourTurn: boolean
}
export default function GameHeader({ className = '' }: GameHeaderProps) {
interface GameHeaderProps {
className?: string
locationName?: string
combatInfo?: CombatInfo | null
dangerLevel?: number
}
export default function GameHeader({
className = ''
}: GameHeaderProps) {
const navigate = useNavigate()
const location = useLocation()
const { currentCharacter, logout } = useAuth()
const { t } = useTranslation()
const [playerCount, setPlayerCount] = useState<number>(0)
// Get game state from context (undefined when outside GameProvider)
const gameContext = useOptionalGame()
const gameState = gameContext?.state
// Extract location and combat info from game state
const locationName = gameState?.location?.name ? getTranslatedText(gameState.location.name) : undefined
const dangerLevel = gameState?.location?.danger_level
const combatInfo = gameState?.combatState ? {
enemyName: getTranslatedText(gameState.enemyName) || 'Enemy',
yourTurn: gameState.combatState.yourTurn || false
} : null
// Fetch initial player count
useEffect(() => {
const fetchPlayerCount = async () => {
@@ -61,8 +85,15 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
const isOnOwnProfile = location.pathname === `/profile/${currentCharacter?.id}`
// Helper for danger badge class
const getDangerClass = (level: number | undefined) => {
if (level === undefined || level === 0) return 'danger-safe'
return `danger-${Math.min(level, 5)}`
}
return (
<header className={`game-header ${className}`}>
{/* Left: Logo and Version */}
<div className="header-left">
<div className="header-title-container">
<h1>Echoes of the Ash</h1>
@@ -70,22 +101,45 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
</div>
</div>
<nav className="nav-links">
<button
onClick={() => navigate('/game')}
className={`nav-link ${isActive('/game') ? 'active' : ''}`}
>
<span style={{ fontSize: '1.1em' }}>🎮</span> {t('common.game')}
</button>
<button
onClick={() => navigate('/leaderboards')}
className={`nav-link ${isActive('/leaderboards') ? 'active' : ''}`}
>
<span style={{ fontSize: '1.1em' }}>🏆</span> {t('common.leaderboards')}
</button>
</nav>
{/* Center: Location/Combat Title */}
<div className="header-center">
{combatInfo ? (
<div className="header-location-title combat">
<span className="combat-indicator"></span>
<span className="location-name">{combatInfo.enemyName}</span>
<span className={`turn-indicator ${combatInfo.yourTurn ? 'your-turn' : 'enemy-turn'}`}>
{combatInfo.yourTurn ? t('combat.yourTurn') : t('combat.enemyTurn')}
</span>
</div>
) : locationName ? (
<div className="header-location-title">
<span className="location-name">{locationName}</span>
{dangerLevel !== undefined && (
<span className={`danger-badge ${getDangerClass(dangerLevel)}`}>
{dangerLevel === 0 ? t('danger.safe') : `⚠️ ${dangerLevel}`}
</span>
)}
</div>
) : null}
</div>
{/* Right: Navigation + User Info */}
<div className="header-right">
<nav className="nav-links">
<button
onClick={() => navigate('/game')}
className={`nav-link ${isActive('/game') ? 'active' : ''}`}
>
🎮 {t('common.game')}
</button>
<button
onClick={() => navigate('/leaderboards')}
className={`nav-link ${isActive('/leaderboards') ? 'active' : ''}`}
>
🏆 {t('common.leaderboards')}
</button>
</nav>
<div className="user-info">
<GameTooltip content={t('game.onlineCount', { count: playerCount })}>
<div className="player-count-badge">
<span className="status-dot"></span>

View File

@@ -1,11 +1,9 @@
import { Outlet } from 'react-router-dom'
import GameHeader from './GameHeader'
import './Game.css'
export default function GameLayout() {
return (
<div className="game-layout">
<GameHeader />
<div className="game-content">
<Outlet />
</div>

View File

@@ -14,6 +14,7 @@
letter-spacing: 0.5px;
position: relative;
overflow: hidden;
min-height: 32px;
}
.game-btn:disabled {
@@ -51,13 +52,13 @@
/* Primary - Blue (Default) */
.game-btn.primary {
background: linear-gradient(135deg, #6bb9f0, #89d4ff);
box-shadow: 0 2px 8px rgba(107, 185, 240, 0.3);
background: linear-gradient(135deg, #2980b9, #3498db);
box-shadow: 0 2px 8px rgba(41, 128, 185, 0.3);
}
.game-btn.primary:not(:disabled):hover {
background: linear-gradient(135deg, #89d4ff, #6bb9f0);
box-shadow: 0 4px 12px rgba(107, 185, 240, 0.5);
background: linear-gradient(135deg, #3498db, #2980b9);
box-shadow: 0 4px 12px rgba(41, 128, 185, 0.5);
}
/* Secondary - Grey/Dark */

View File

@@ -94,4 +94,12 @@
letter-spacing: 0.05em;
border-bottom: 1px solid var(--game-border-color, #4a5568);
margin-bottom: 0.25rem;
}
/* Vertical pickup options layout */
.pickup-options-vertical {
display: flex;
flex-direction: column;
width: 100%;
gap: 0.5rem;
}

View File

@@ -0,0 +1,182 @@
import { useTranslation } from 'react-i18next';
import { getTranslatedText } from '../../utils/i18nUtils';
import { EffectBadge } from '../game/EffectBadge';
interface ItemTooltipContentProps {
item: any;
showValue?: boolean; // Show item value (for trading)
showDurability?: boolean; // Show durability bar (default: true if available)
}
/**
* Reusable component for rendering rich item tooltip content.
* Used in inventory, ground items, trading, and equipped items.
*/
export const ItemTooltipContent = ({
item,
showValue = false,
showDurability = true
}: ItemTooltipContentProps) => {
const { t } = useTranslation();
const stats = item.unique_stats || item.stats || {};
const effects = item.effects || {};
const maxDurability = item.max_durability;
const currentDurability = item.durability;
const hasDurability = showDurability && maxDurability && maxDurability > 0;
return (
<div className="item-tooltip-content">
{/* Header */}
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
{item.emoji} {getTranslatedText(item.name)}
</div>
{/* Description */}
{item.description && (
<div className="tooltip-desc">{getTranslatedText(item.description)}</div>
)}
{/* Weight/Volume */}
<div className="tooltip-stats">
<div> {item.weight}kg {item.quantity > 1 && `(x${item.quantity})`}</div>
<div>📦 {item.volume}L {item.quantity > 1 && `(x${item.quantity})`}</div>
</div>
{/* Value (for trading) */}
{showValue && item.value !== undefined && (
<div className="tooltip-value">
💰 {t('game.value')}: {item.value * (item.quantity || 1)} coins
</div>
)}
{/* Stat Badges */}
<div className="stat-badges-container">
{/* Capacity */}
{(stats.weight_capacity) && (
<span className="stat-badge capacity">
+{stats.weight_capacity}kg
</span>
)}
{(stats.volume_capacity) && (
<span className="stat-badge capacity">
📦 +{stats.volume_capacity}L
</span>
)}
{/* Combat */}
{(stats.damage_min) && (
<span className="stat-badge damage">
{stats.damage_min}-{stats.damage_max}
</span>
)}
{(stats.armor) && (
<span className="stat-badge armor">
🛡 +{stats.armor}
</span>
)}
{(stats.armor_penetration) && (
<span className="stat-badge penetration">
💔 +{stats.armor_penetration} {t('stats.pen')}
</span>
)}
{(stats.crit_chance) && (
<span className="stat-badge crit">
🎯 +{Math.round(stats.crit_chance * 100)}% {t('stats.crit')}
</span>
)}
{(stats.accuracy) && (
<span className="stat-badge accuracy">
👁 +{Math.round(stats.accuracy * 100)}% {t('stats.acc')}
</span>
)}
{(stats.dodge_chance) && (
<span className="stat-badge dodge">
💨 +{Math.round(stats.dodge_chance * 100)}% Dodge
</span>
)}
{(stats.lifesteal) && (
<span className="stat-badge lifesteal">
🧛 +{Math.round(stats.lifesteal * 100)}% {t('stats.life')}
</span>
)}
{/* Attributes */}
{(stats.strength_bonus) && (
<span className="stat-badge strength">
💪 +{stats.strength_bonus} {t('stats.str')}
</span>
)}
{(stats.agility_bonus) && (
<span className="stat-badge agility">
🏃 +{stats.agility_bonus} {t('stats.agi')}
</span>
)}
{(stats.endurance_bonus) && (
<span className="stat-badge endurance">
🏋 +{stats.endurance_bonus} {t('stats.end')}
</span>
)}
{(stats.hp_bonus) && (
<span className="stat-badge health">
+{stats.hp_bonus} {t('stats.hpMax')}
</span>
)}
{(stats.stamina_bonus) && (
<span className="stat-badge stamina">
+{stats.stamina_bonus} {t('stats.stmMax')}
</span>
)}
{/* Consumables */}
{(item.hp_restore || effects.hp_restore) && (
<span className="stat-badge health">
+{item.hp_restore || effects.hp_restore} HP
</span>
)}
{(item.stamina_restore || effects.stamina_restore) && (
<span className="stat-badge stamina">
+{item.stamina_restore || effects.stamina_restore} Stm
</span>
)}
{/* Status Effects */}
{effects.status_effect && (
<EffectBadge effect={effects.status_effect} />
)}
{effects.cures && effects.cures.length > 0 && (
<span className="stat-badge cure">
💊 {t('game.cures')}: {effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
</span>
)}
</div>
{/* Durability Bar */}
{hasDurability && (
<div className="durability-container">
<div className="durability-header">
<span>{t('game.durability')}</span>
<span className={currentDurability < maxDurability * 0.2 ? "durability-text-low" : ""}>
{currentDurability} / {maxDurability}
</span>
</div>
<div className="durability-track">
<div
className={`durability-fill ${currentDurability < maxDurability * 0.2
? "low"
: currentDurability < maxDurability * 0.5
? "medium"
: "high"
}`}
style={{
width: `${Math.min(100, Math.max(0, (currentDurability / maxDurability) * 100))}%`
}}
/>
</div>
</div>
)}
</div>
);
};

View File

@@ -13,6 +13,7 @@ interface CombatProps {
profile: any;
playerState: any;
equipment: any;
locationImage?: string;
onCombatAction: (action: string) => Promise<any>;
onPvPAction: (action: string, targetId: number) => Promise<any>;
onExitCombat: () => void;
@@ -31,6 +32,7 @@ export const Combat: React.FC<CombatProps> = ({
profile,
playerState,
equipment: _equipment,
locationImage,
onCombatAction,
onPvPAction,
onExitCombat,
@@ -734,6 +736,7 @@ export const Combat: React.FC<CombatProps> = ({
combatResult={combatResult}
equipment={_equipment}
playerName={profile?.name}
locationImage={locationImage}
/>
{/* Supplies modal */}
<CombatInventoryModal

View File

@@ -25,6 +25,93 @@
transition: filter 1s ease;
}
/* Combat Scene: Location Background with NPC Overlay */
.combat-scene-container {
position: relative;
width: 100%;
max-width: 800px;
margin: 1rem auto;
aspect-ratio: 10 / 7;
overflow: hidden;
clip-path: var(--game-clip-path);
border: 2px solid rgba(255, 107, 107, 0.3);
}
.combat-location-bg {
width: 100%;
height: 100%;
object-fit: cover;
}
.combat-location-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
}
.combat-npc-overlay-container {
position: absolute;
bottom: 1rem;
left: 1rem;
width: 50%;
height: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.combat-npc-overlay {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.8)) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.6));
transition: filter 0.3s, transform 0.3s;
}
.combat-npc-overlay.attacking {
animation: lunge 0.3s;
}
.combat-npc-overlay-container.dead .combat-npc-overlay {
filter: grayscale(100%) brightness(0.5) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.8));
}
/* Enemy shake effect - opposite direction (enemy faces right, recoils right) */
.combat-npc-overlay-container.shake-effect {
animation: shake-right 0.5s cubic-bezier(.36, .07, .19, .97) both;
}
@keyframes shake-right {
10%,
90% {
transform: translate3d(1px, 0, 0);
}
20%,
80% {
transform: translate3d(-2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(4px, 0, 0);
}
40%,
60% {
transform: translate3d(-4px, 0, 0);
}
}
.combat-npc-placeholder {
font-size: 4rem;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.8));
}
/* Enemy avatar now uses shared .location-image styles from Game.css */
/* ... existing code ... */

View File

@@ -4,6 +4,7 @@ import { getAssetPath } from '../../utils/assetPath';
import { getTranslatedText } from '../../utils/i18nUtils';
import './CombatInventoryModal.css';
import { EffectBadge } from './EffectBadge';
import { GameButton } from '../common/GameButton';
interface CombatInventoryModalProps {
isOpen: boolean;
@@ -51,7 +52,7 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
<div className="combat-inventory-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{t('combat.modal.supplies_title')}</h3>
<button className="close-btn" onClick={onClose}>&times;</button>
<GameButton variant="danger" size="sm" onClick={onClose}>×</GameButton>
</div>
<div className="modal-body">
@@ -201,9 +202,9 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
</div>
{/* Action Button */}
<button className="btn-use">
<GameButton variant="success" size="sm" style={{ flexShrink: 0 }}>
{t('game.use')}
</button>
</GameButton>
</div>
))
)}

View File

@@ -1,10 +1,12 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getTranslatedText } from '../../utils/i18nUtils';
import { useAudio } from '../../contexts/AudioContext';
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
import { Equipment } from './types';
import './CombatEffects.css';
import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton';
interface CombatViewProps {
state: CombatState;
@@ -17,6 +19,7 @@ interface CombatViewProps {
combatResult: 'victory' | 'defeat' | 'fled' | null;
equipment?: Equipment | any;
playerName?: string;
locationImage?: string;
}
export const CombatView: React.FC<CombatViewProps> = ({
@@ -29,7 +32,8 @@ export const CombatView: React.FC<CombatViewProps> = ({
isProcessing,
combatResult,
equipment,
playerName
playerName,
locationImage
}) => {
const { t } = useTranslation();
const { playSfx } = useAudio();
@@ -117,44 +121,62 @@ export const CombatView: React.FC<CombatViewProps> = ({
return (
<div className="combat-container">
{/* Header (Location View Style) */}
<div className="combat-header">
<h2 className="centered-heading">
{state.isPvP ? t('combat.pvp_title') : t('combat.title')}
<span style={{ margin: '0 0.5rem', color: '#aaa', fontSize: '0.9em' }}>vs</span>
{state.npcName || t('combat.unknown_enemy')}
{state.turnTimeRemaining !== undefined && (
<span className="danger-badge danger-2" style={{ fontSize: '0.8rem', marginLeft: '0.5rem' }}>
{state.turnTimeRemaining} s
</span>
)}
{state.isPvP && (
<span
className={`danger-badge ${state.yourTurn ? 'danger-1' : 'danger-3'}`}
style={{ fontSize: '0.8rem', marginLeft: '0.5rem', fontWeight: 'bold' }}
>
{state.yourTurn ? '🎯 ' + t('combat.your_turn') : '⏳ ' + t('combat.opponent_turn')}
</span>
)}
</h2>
</div>
{/* Main Content Vertical Stack */}
<div className="combat-main-content">
{/* 1. Enemy Avatar (Location Image Style) */}
{/* Shake on npcHit, Attack on enemyAttacking, Dead on victory */}
<div className={`enemy-display ${animState.enemyAttacking ? 'attacking' : ''} ${animState.npcHit ? 'shake-effect flash-hit' : ''} ${combatResult === 'victory' ? 'dead' : ''}`}>
<div className="location-image-container">
{/* 1. Combat Scene: Location Background with NPC Overlay */}
<div className="combat-scene-container">
{/* Location Background */}
{locationImage ? (
<img src={locationImage} alt="Location" className="combat-location-bg" />
) : (
<div className="combat-location-bg combat-location-placeholder">🌄</div>
)}
{/* NPC Overlay (bottom-right corner) */}
<div className={`combat-npc-overlay-container ${animState.npcHit ? 'shake-effect flash-hit' : ''} ${combatResult === 'victory' ? 'dead' : ''}`}>
{state.npcImage ? (
<img src={state.npcImage} alt={state.npcName} className="location-image" />
<img
src={state.npcImage}
alt={state.npcName}
className={`combat-npc-overlay ${animState.enemyAttacking ? 'attacking' : ''}`}
/>
) : (
<div className="enemy-placeholder">💀</div>
<div className="combat-npc-overlay combat-npc-placeholder">💀</div>
)}
</div>
</div>
{/* PvP Timer & Turn Indicator */}
{state.isPvP && (
<div className="combat-pvp-status" style={{
position: 'absolute',
top: '60px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 100,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem'
}}>
{state.turnTimeRemaining !== undefined && (
<span className="danger-badge danger-2" style={{ fontSize: '1rem', padding: '4px 12px', boxShadow: '0 2px 5px rgba(0,0,0,0.5)' }}>
{state.turnTimeRemaining} s
</span>
)}
<span
className={`danger-badge ${state.yourTurn ? 'danger-1' : 'danger-3'}`}
style={{ fontSize: '0.9rem', fontWeight: 'bold' }}
>
{state.yourTurn ?
<span>🎯 {t('combat.yourTurn') || 'Your Turn'}</span> :
<span> {t('combat.enemyTurn') || 'Enemy Turn'}</span>
}
</span>
</div>
)}
{/* 2. HP Bars (Character Sheet Style) - Staggered Lines */}
<div className="combat-stats-container" style={{ position: 'relative' }}>
@@ -197,7 +219,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
{/* Enemy HP (Left) */}
<div className={`stat-block enemy ${animState.npcHit ? 'shake-effect' : ''}`}>
<GameProgressBar
label={state.npcName || t('common.enemy')}
label={getTranslatedText(state.npcName) || t('common.enemy')}
value={state.npcHp}
max={state.npcMaxHp}
type="enemy_health"
@@ -224,47 +246,51 @@ export const CombatView: React.FC<CombatViewProps> = ({
{/* 3. Actions */}
<div className="combat-actions">
<button
className="btn btn-primary full-width glow-effect"
onClick={onClose}
style={{ display: combatResult ? 'block' : 'none', margin: '0 auto' }}
>
{t('common.close')}
</button>
<div className="combat-actions-group" style={{ display: !combatResult ? 'grid' : 'none', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
<button
className="btn btn-attack"
onClick={() => onAction('attack')}
disabled={isProcessing || !state.yourTurn}
{combatResult && (
<GameButton
variant="primary"
onClick={onClose}
style={{ width: '100%', maxWidth: '200px', margin: '0 auto' }}
>
👊 {t('combat.actions.attack')}
</button>
{t('common.close')}
</GameButton>
)}
<button
className="btn btn-defend"
onClick={() => onAction('defend')}
disabled={isProcessing || !state.yourTurn}
>
🛡 {t('combat.actions.defend')}
</button>
{!combatResult && (
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
<GameButton
variant="danger"
onClick={() => onAction('attack')}
disabled={isProcessing || !state.yourTurn}
>
👊 {t('combat.actions.attack')}
</GameButton>
<button
className="btn btn-supplies"
onClick={onShowSupplies}
disabled={isProcessing || !state.yourTurn}
>
🎒 {t('combat.actions.supplies')}
</button>
<GameButton
variant="primary"
onClick={() => onAction('defend')}
disabled={isProcessing || !state.yourTurn}
>
🛡 {t('combat.actions.defend')}
</GameButton>
<button
className="btn btn-flee"
onClick={() => onAction('flee')}
disabled={isProcessing || !state.yourTurn}
>
🏃 {t('combat.actions.flee')}
</button>
</div>
<GameButton
variant="secondary"
onClick={onShowSupplies}
disabled={isProcessing || !state.yourTurn}
>
🎒 {t('combat.actions.supplies')}
</GameButton>
<GameButton
variant="warning"
onClick={() => onAction('flee')}
disabled={isProcessing || !state.yourTurn}
>
🏃 {t('combat.actions.flee')}
</GameButton>
</div>
)}
</div>
{/* 4. Log (Table) */}
@@ -276,7 +302,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
let className = `log-row log-${msg.type}`;
if (msg.data && msg.data.message) {
text = msg.data.message;
text = getTranslatedText(msg.data.message);
} else {
switch (msg.type) {
case 'combat_start': text = t('combat.start'); break;
@@ -299,7 +325,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
case 'flee_success': text = t('combat.flee.success'); break;
case 'flee_fail': text = t('combat.flee.fail'); break;
case 'item_broken': text = t('combat.item_broken', { item: msg.data?.item_name }); break;
case 'item_broken': text = t('combat.item_broken', { item: getTranslatedText(msg.data?.item_name) }); break;
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
case 'damage':
if (msg.origin === 'enemy') {
@@ -309,15 +335,15 @@ export const CombatView: React.FC<CombatViewProps> = ({
text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 });
}
break;
case 'text': text = msg.data?.text || ""; break;
case 'text': text = getTranslatedText(msg.data?.text) || ""; break;
case 'item_used':
text = t('combat.log.item_used', { item: msg.data?.item_name || '' });
if (msg.data?.effects) text += msg.data.effects; // Append effects string if backend still sends it
text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' });
if (msg.data?.effects) text += getTranslatedText(msg.data.effects); // Append effects string if backend still sends it
className += " text-info";
break;
case 'effect_applied':
text = t('combat.log.effect_applied', {
effect: msg.data?.effect_name,
effect: getTranslatedText(msg.data?.effect_name),
target: msg.data?.target === 'enemy' ? t('common.enemy') : t('common.you')
});
className += " text-warning";
@@ -330,7 +356,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
return (
<tr key={index} className={className}>
<td className="log-time">[{time}]</td>
<td className="log-event">{text}</td>
<td className="log-event"><span>{text}</span></td>
</tr>
);
})}

View File

@@ -26,13 +26,27 @@
min-height: 300px;
}
.npc-image {
width: 100px;
height: 100px;
border-radius: 50%;
border: 2px solid #555;
.npc-portrait-container {
width: 130px;
height: 130px;
aspect-ratio: 1;
border: 1px solid rgba(107, 185, 240, 0.5);
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin: 0 auto 15px auto;
clip-path: var(--game-clip-path);
box-shadow: var(--game-shadow-sm);
}
.npc-portrait {
width: 100%;
height: 100%;
object-fit: cover;
align-self: center;
border: none;
border-radius: 0;
}
.npc-name {
@@ -54,23 +68,19 @@
/* Renamed from .options-container to match JSX */
.options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
/* grid-auto-rows: 1fr; Removed to prevent forced height expansion */
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: auto;
}
/* Make back button and exit button span full width if needed, or keep grid */
/* Let's make the 'Back' button span full width for better UX */
.options-grid>.option-btn:first-child:nth-last-child(1) {
/* Make the last item span full width if it's the only one in the row (odd number of items) */
.options-grid>*:last-child:nth-child(odd) {
grid-column: span 2;
}
.option-btn {
/* Base styles handled by GameButton, but we can override */
/* Base styles handled by GameButton, but ensure consistent height */
width: 100%;
/* height: 100%; Removed to prevent stretching */
/* Fill the grid cell */
margin: 0;
}

View File

@@ -214,7 +214,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
<div className="npc-portrait-container">
<img
className="npc-portrait"
src={npcData.image ? getAssetPath(npcData.image) : ''}
src={npcData.image_path || npcData.image ? getAssetPath(npcData.image_path || npcData.image) : ''}
alt={npcName}
/>
</div>
@@ -227,14 +227,14 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
<div className="options-grid">
{/* BACK BUTTON */}
{(viewState === 'topic' || viewState === 'quest_preview') && (
<GameButton className="option-btn" onClick={resetToGreeting}>
<GameButton className="option-btn" size="sm" onClick={resetToGreeting}>
&larr; Back
</GameButton>
)}
{/* NPC TOPICS */}
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
<GameButton key={topic.id} className="option-btn" onClick={() => handleTopicClick(topic)}>
<GameButton key={topic.id} className="option-btn" size="sm" onClick={() => handleTopicClick(topic)}>
💬 {getLocalized(topic.title)}
</GameButton>
))}
@@ -244,6 +244,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
<GameButton
key={q.quest_id}
className="option-btn quest-btn"
size="sm"
onClick={() => handleQuestClick(q)}
variant={q.status === 'active' ? 'warning' : 'info'}
>
@@ -254,7 +255,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
{/* CONFIRM QUEST ACTION */}
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
<div style={{ gridColumn: 'span 2' }}>
<GameButton className="option-btn action-btn" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
<GameButton className="option-btn action-btn" size="sm" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
Accept Quest
</GameButton>
</div>
@@ -264,6 +265,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
<div style={{ gridColumn: 'span 2' }}>
<GameButton
className="option-btn action-btn"
size="sm"
variant="warning"
onClick={handInQuest}
style={{ width: '100%' }}
@@ -278,14 +280,14 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
{/* TRADE - Only show in greeting */}
{viewState === 'greeting' && npcData.trade?.enabled && (
<GameButton className="option-btn trade-btn" variant="success" onClick={onTrade}>
<GameButton className="option-btn trade-btn" size="sm" variant="success" onClick={onTrade}>
💰 Trade
</GameButton>
)}
{/* EXIT - Span full width */}
{viewState === 'greeting' && (
<GameButton className="option-btn exit-btn" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
<GameButton className="option-btn exit-btn" size="sm" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
Goodbye
</GameButton>
)}

View File

@@ -128,7 +128,7 @@
gap: 0.75rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.05);
clip-path: var(--game-clip-path-sm);
clip-path: var(--game-clip-path);
}
.backpack-status.active {
@@ -229,7 +229,7 @@
flex-direction: column;
padding: 1.5rem;
overflow: hidden;
background: var(--game-bg-app);
background-color: var(--game-bg-panel);
}
.game-search-container {
@@ -308,7 +308,7 @@
/* Grid View Layout */
.items-container.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
grid-auto-rows: max-content;
gap: 1rem;
align-content: start;
@@ -386,7 +386,6 @@
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.25rem;
}
.item-image-section.small {

View File

@@ -1,4 +1,32 @@
/* Grid View Styles */
.entities-container {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: flex-start;
}
.entity-section {
flex: 1 1 300px;
max-width: 100%;
min-width: 0;
}
/* Ground item images */
.ground-item-image {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Corpse images - grayed out */
.corpse-image {
width: 100%;
height: 100%;
object-fit: contain;
filter: grayscale(100%) brightness(0.6);
}
.entity-list.grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
@@ -190,6 +218,14 @@
border-color: rgba(76, 175, 80, 1);
}
.player-card.grid-card {
border-color: rgba(66, 153, 225, 0.5);
}
.player-card.grid-card:hover {
border-color: rgba(66, 153, 225, 1);
}
.corpse-details-header {
display: flex;
justify-content: space-between;
@@ -318,4 +354,21 @@
.close-btn:hover {
color: #fff;
}
/* Corpse Loot Count - Bottom Right Badge */
.corpse-loot-count {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.8);
color: #ce93d8;
font-size: 0.75rem;
padding: 2px 6px;
font-weight: bold;
border: 1px solid rgba(206, 147, 216, 0.3);
clip-path: var(--game-clip-path-sm);
z-index: 5;
pointer-events: none;
/* Let clicks pass through to card */
}

View File

@@ -10,6 +10,7 @@ import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import { DialogModal } from './DialogModal'
import { TradeModal } from './TradeModal'
import { ItemTooltipContent } from '../common/ItemTooltipContent'
import './LocationView.css'
interface LocationViewProps {
@@ -17,7 +18,7 @@ interface LocationViewProps {
playerState: PlayerState | null
combatState: CombatState | null
message: string
locationMessages: Array<{ time: string; message: string }>
locationMessages: Array<{ time: string; message: string; location_name?: string }>
expandedCorpse: string | null
corpseDetails: any
mobileMenuOpen: string
@@ -49,7 +50,7 @@ interface LocationViewProps {
onSetCraftCategoryFilter: (category: string) => void
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
failedActionItemId: string | number | null
quests: { active: any[], available: any[] }
}
@@ -207,22 +208,6 @@ function LocationView({
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) => {
@@ -232,15 +217,24 @@ function LocationView({
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}
// Use GameButton for workbench and repair_station
if (isClickable) {
return (
<GameButton
key={i}
variant="secondary"
size="sm"
onClick={handleClick}
>
{tag === 'workbench' && t('tags.workbench')}
{tag === 'repair_station' && t('tags.repairStation')}
{tag === 'workbench' ? t('tags.workbench') : t('tags.repairStation')}
</GameButton>
)
}
// Regular span for non-interactive tags
return (
<GameTooltip key={i} content={`This location has: ${tag}`}>
<span className={`location-tag tag-${tag}`}>
{tag === 'safe_zone' && t('tags.safeZone')}
{tag === 'shop' && t('tags.shop')}
{tag === 'shelter' && t('tags.shelter')}
@@ -279,406 +273,508 @@ function LocationView({
{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>
<span className="message-text">{getTranslatedText(msg.message)}</span>
{msg.location_name && (
<span className="message-location">[{msg.location_name}]</span>
)}
</div>
))}
</div>
</div>
)}
{locationMessages.length === 0 && (
<div className="location-messages-log location-messages-empty">
<h4>{t('location.recentActivity')}</h4>
<div className="messages-scroll">
<div className="location-message-item empty-state">
<span className="message-text" style={{ opacity: 0.5, fontStyle: 'italic' }}>
{t('location.noRecentActivity', 'No recent activity')}
</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 grid-view">
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => {
const id = `enemy-${enemy.id || i}`;
return (
<div key={i} className="entity-card enemy-card grid-card"
onClick={(e) => handleDropdownClick(e, id)}>
{enemy.id && (
<div className="entity-image padded-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>
)}
{/* Combined Entities Container for Grid Layout */}
<div className="entities-container">
{/* 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 grid-view">
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any) => {
const isShaking = failedActionItemId == enemy.id;
const id = `enemy-${enemy.id}`;
<GameTooltip content={
<div>
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
<div>{t('location.level')} {enemy.level}</div>
<div style={{ color: '#f56565', fontSize: '0.8rem' }}>Click for actions</div>
</div>
}>
<div className="grid-overlay"></div>
</GameTooltip>
{/* Dropdown for Grid View */}
{activeDropdown === id && (
<GameDropdown
isOpen={true}
onClose={() => setActiveDropdown(null)}
width="160px"
>
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
<GameButton
variant="danger"
size="sm"
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); }}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
{t('common.fight')}
</GameButton>
<div className="game-dropdown-divider" />
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
{t('location.level')} {enemy.level}
</div>
</GameDropdown>
)}
</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 grid-view">
{location.corpses.map((corpse: any) => (
<div key={corpse.id} className="corpse-container">
<div className="entity-card corpse-card grid-card"
onClick={(e) => handleDropdownClick(e, `corpse-${corpse.id}`)}
>
<GameTooltip content={
<div>
<div className="tooltip-title">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
<div>{corpse.loot_count} {t('location.items')}</div>
</div>
}>
<div className="grid-corpse-content">
<div style={{ fontSize: '2rem' }}>{corpse.emoji}</div>
<div style={{ fontSize: '0.8rem', color: '#ce93d8' }}>{corpse.loot_count} items</div>
</div>
</GameTooltip>
{activeDropdown === `corpse-${corpse.id}` && (
<GameDropdown
isOpen={true}
onClose={() => setActiveDropdown(null)}
width="160px"
>
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
<GameButton
variant="secondary"
size="sm"
onClick={() => {
playSfx('/audio/sfx/interact.wav')
onLootCorpse(String(corpse.id))
setActiveDropdown(null)
}}
disabled={corpse.loot_count === 0}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
🔍 {t('common.examine')}
</GameButton>
</GameDropdown>
)}
</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 grid-view">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card grid-card"
onClick={() => handleNpcClick(npc)}
style={{ cursor: 'pointer', position: 'relative' }}
>
{npc.image_path ? (
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
)}
{renderIndicator(npc.id)}
<GameTooltip content={
<div>
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
</div>
}>
<div className="grid-overlay"></div>
</GameTooltip>
</div>
))}
</div>
</div>
)}
{/* Items on Ground - Stable Sort */}
{location.items.length > 0 && (
<div className="entity-section items-section">
<h3>{t('location.itemsOnGround')}</h3>
<div className="entity-list grid-view">
{[...location.items]
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
.map((item: any, i: number) => {
const isShaking = failedActionItemId == item.id;
const itemId = `item-${item.id}-${i}`;
// Pickup Options Helper
const renderPickupOptions = () => {
const options = [];
options.push({ label: 'x1', qty: 1 });
if (item.quantity >= 5) options.push({ label: 'x5', qty: 5 });
if (item.quantity >= 10) options.push({ label: 'x10', qty: 10 });
if (item.quantity > 1) options.push({ label: t('common.all'), qty: item.quantity });
return options.map(opt => (
<GameButton
key={opt.label}
variant="success"
size="sm"
onClick={(e) => {
e.stopPropagation(); // Prevent closing
onPickup(item.id, opt.qty);
}}
style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '2px' }}
>
🤚 {t('common.pickUp')} ({opt.label})
</GameButton>
));
};
// Only render if valid
if (!enemy || !enemy.id) return null;
return (
<div key={item.id} className={`entity-card item-card ${isShaking ? 'shake' : ''} grid-card`}
onClick={(e) => handleDropdownClick(e, itemId)}
<div key={enemy.id} className={`entity-card enemy-card grid-card ${isShaking ? 'shake-animation' : ''}`}
onClick={(e) => handleDropdownClick(e, id)}
>
{/* Enemy Image */}
{enemy.id && (
<div className="entity-image padded-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>
)}
<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>
)}
<div>
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
<div>{t('location.level')} {enemy.level}</div>
<div style={{ color: '#f56565', fontSize: '0.8rem' }}>Click for actions</div>
</div>
}>
<div className="entity-content-wrapper grid-content">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="entity-icon"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
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' : ''}`} style={!item.image_path ? { fontSize: '2rem' } : {}}>{item.emoji || '📦'}</span>
{item.quantity > 1 && (
<div className="grid-quantity">x{item.quantity}</div>
)}
</div>
<div className="grid-overlay"></div>
</GameTooltip>
{activeDropdown === itemId && (
{/* Dropdown for Grid View */}
{activeDropdown === id && (
<GameDropdown
isOpen={true}
onClose={() => setActiveDropdown(null)}
width="160px"
>
<div className="game-dropdown-header">{getTranslatedText(item.name)}</div>
<div className="pickup-options">
{renderPickupOptions()}
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
<GameButton
variant="danger"
size="sm"
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); }}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
{t('common.fight')}
</GameButton>
<div className="game-dropdown-divider" />
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
{t('location.level')} {enemy.level}
</div>
</GameDropdown>
)}
</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>
)}
{/* Corpses */}
{location.corpses && location.corpses.length > 0 && (
<div className="entity-section corpses-section">
<h3>{t('location.corpses')}</h3>
<div className="entity-list grid-view">
{location.corpses.map((corpse: any) => (
<div key={corpse.id} className="corpse-container">
<div className="entity-card corpse-card grid-card"
onClick={(e) => handleDropdownClick(e, `corpse-${corpse.id}`)}
>
<GameTooltip content={
<div>
<div className="tooltip-title">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
<div>{corpse.loot_count} {t('location.items')}</div>
</div>
}>
<div className="grid-corpse-content">
{corpse.image_path ? (
<img
src={getAssetPath(corpse.image_path)}
alt={getTranslatedText(corpse.name)}
className="corpse-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div style={{ fontSize: '2rem', display: corpse.image_path ? 'none' : 'block' }} className={corpse.image_path ? 'hidden' : ''}>
{corpse.emoji}
</div>
<div className="corpse-loot-count">{corpse.loot_count} items</div>
</div>
</GameTooltip>
{activeDropdown === `corpse-${corpse.id}` && (
<GameDropdown
isOpen={true}
onClose={() => setActiveDropdown(null)}
width="160px"
>
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
<GameButton
variant="secondary"
size="sm"
onClick={() => {
playSfx('/audio/sfx/interact.wav')
onLootCorpse(String(corpse.id))
setActiveDropdown(null)
}}
disabled={corpse.loot_count === 0}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
🔍 {t('common.examine')}
</GameButton>
</GameDropdown>
)}
</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>
)}
)}
{/* 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 grid-view">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card grid-card"
onClick={() => handleNpcClick(npc)}
style={{ cursor: 'pointer', position: 'relative' }}
>
{npc.image_path ? (
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
)}
{renderIndicator(npc.id)}
<GameTooltip content={
<div>
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
</div>
}>
<div className="grid-overlay"></div>
</GameTooltip>
</div>
))}
</div>
</div>
)}
{/* Items on Ground - Stable Sort */}
{location.items.length > 0 && (
<div className="entity-section items-section">
<h3>{t('location.itemsOnGround')}</h3>
<div className="entity-list grid-view">
{[...location.items]
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
.map((item: any, i: number) => {
const isShaking = failedActionItemId == item.id;
const itemId = `item-${item.id}-${i}`;
// Pickup Options Helper - Vertical Layout
const renderPickupOptions = () => {
const options = [];
options.push({ label: `${t('common.pickUp')} (x1)`, qty: 1 });
if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 });
if (item.quantity > 1) options.push({ label: t('common.pickUpAll'), qty: item.quantity });
return (
<div className="pickup-options-vertical">
{options.map((opt) => (
<GameButton
key={opt.label}
variant="success"
size="sm"
onClick={(e) => {
e.stopPropagation();
playSfx('/audio/sfx/pickup.wav');
onPickup(Number(item.id), opt.qty);
setActiveDropdown(null);
}}
style={{ width: '100%', justifyContent: 'center' }}
>
{opt.label}
</GameButton>
))}
</div>
);
};
return (
<div key={itemId} className={`entity-card item-card grid-card ${isShaking ? 'shake-animation' : ''}`}
onClick={(e) => handleDropdownClick(e, itemId)}
>
<GameTooltip content={
<>
<ItemTooltipContent item={item} />
<div style={{ color: '#4caf50', fontSize: '0.8rem', marginTop: '0.5rem', textAlign: 'center' }}>Click to Interact</div>
</>
}>
<div className="grid-corpse-content">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={getTranslatedText(item.name)}
className="ground-item-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div style={{ fontSize: '2rem', display: item.image_path ? 'none' : 'block' }} className={item.image_path ? 'hidden' : ''}>
{item.emoji}
</div>
{item.quantity > 1 && (
<div className="grid-quantity">x{item.quantity}</div>
)}
</div>
</GameTooltip>
{activeDropdown === itemId && (
<GameDropdown
isOpen={true}
onClose={() => setActiveDropdown(null)}
width="200px" // Wider for split buttons
>
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
{/* Primary Action: Pick Up 1 */}
<GameButton
variant="success"
size="sm"
className="pickup-main-btn"
onClick={() => {
playSfx('/audio/sfx/pickup.wav');
onPickup(Number(item.id), 1);
setActiveDropdown(null);
}}
style={{ width: '100%', justifyContent: 'center', marginBottom: '8px' }}
>
{t('common.pickUp')}
</GameButton>
{/* Quantity Options if > 1 */}
{item.quantity > 1 && (
<>
<div className="game-dropdown-divider" style={{ margin: '8px 0' }} />
{renderPickupOptions()}
</>
)}
</GameDropdown>
)}
</div>
);
})}
</div>
</div>
)}
{/* Other Players */}
{location.other_players && location.other_players.length > 0 && (
<div className="entity-section players-section">
<h3>👥 {t('location.otherPlayers', 'Other Players')}</h3>
<div className="entity-list grid-view">
{location.other_players.map((player: any, i: number) => {
const playerId = `player-${player.id}-${i}`;
const canPvP = player.can_pvp;
return (
<div key={i} className="entity-card player-card grid-card"
onClick={(e) => handleDropdownClick(e, playerId)}
>
<GameTooltip content={
<div>
<div className="tooltip-title">{player.name || player.username}</div>
<div>{t('location.level', 'Level')} {player.level}</div>
{player.level_diff !== undefined && (
<div style={{ fontSize: '0.8rem', color: player.level_diff > 0 ? '#f56565' : '#48bb78' }}>
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
</div>
)}
<div style={{ color: '#ebf8ff', fontSize: '0.8rem', marginTop: '0.5rem' }}>Click for actions</div>
</div>
}>
<div className="grid-corpse-content">
{/* Placeholder for player image or avatar */}
<div style={{ fontSize: '2.5rem' }}>
🧍
</div>
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
Lv.{player.level}
</div>
</div>
</GameTooltip>
{activeDropdown === playerId && (
<GameDropdown
isOpen={true}
onClose={() => setActiveDropdown(null)}
width="180px"
>
<div className="game-dropdown-header">{player.name || player.username}</div>
{canPvP ? (
<GameButton
variant="danger"
size="sm"
onClick={() => {
onInitiatePvP(player.id);
setActiveDropdown(null);
}}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
{t('game.attack')}
</GameButton>
) : (
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0', fontStyle: 'italic' }}>
{location.danger_level !== undefined && location.danger_level < 3
? t('game.areaTooSafeForPvP')
: (player.level_diff !== undefined && Math.abs(player.level_diff) > 3)
? t('game.levelDifferenceTooHigh')
: "PvP Unavailable"}
</div>
)}
<div className="game-dropdown-divider" />
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
Level {player.level}
</div>
</GameDropdown>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Corpse Loot Overlay Modal */}
{expandedCorpse && corpseDetails && corpseDetails.loot_items && (
<div className="corpse-loot-overlay" onClick={() => onSetExpandedCorpse(null)}>
<div className="corpse-loot-modal" onClick={(e) => e.stopPropagation()}>
<div className="corpse-details-header">
<h4>{t('location.lootableItems')}</h4>
{
expandedCorpse && corpseDetails && corpseDetails.loot_items && (
<div className="corpse-loot-overlay" onClick={() => onSetExpandedCorpse(null)}>
<div className="corpse-loot-modal" onClick={(e) => e.stopPropagation()}>
<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' : ''}`}>
{/* Item Image */}
<div className="corpse-item-image">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={item.item_name}
className="item-img-thumb"
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
</div>
<div className="corpse-item-info" style={{ flex: 1 }}>
<div className="corpse-item-name">
{getTranslatedText(item.item_name)}
</div>
{item.description && <div className="corpse-item-desc" style={{ fontSize: '0.75rem', color: '#a0aec0' }}>{getTranslatedText(item.description)}</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(expandedCorpse, item.index)}
disabled={!item.can_loot}
>
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
</button>
</GameTooltip>
</div>
))}
</div>
<button
className="close-btn"
onClick={() => {
onSetExpandedCorpse(null)
}}
className="loot-all-btn"
onClick={() => onLootCorpseItem(expandedCorpse, null)}
>
📦 {t('common.lootAll')}
</button>
</div>
<div className="corpse-items-list">
{corpseDetails.loot_items.map((item: any) => (
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
{/* Item Image */}
<div className="corpse-item-image">
{item.image_path ? (
<img
src={getAssetPath(item.image_path)}
alt={item.item_name}
className="item-img-thumb"
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
</div>
<div className="corpse-item-info" style={{ flex: 1 }}>
<div className="corpse-item-name">
{getTranslatedText(item.item_name)}
</div>
{item.description && <div className="corpse-item-desc" style={{ fontSize: '0.75rem', color: '#a0aec0' }}>{getTranslatedText(item.description)}</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(expandedCorpse, item.index)}
disabled={!item.can_loot}
>
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
</button>
</GameTooltip>
</div>
))}
</div>
<button
className="loot-all-btn"
onClick={() => onLootCorpseItem(expandedCorpse, null)}
>
📦 {t('common.lootAll')}
</button>
</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}
/>
)}
{
(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}
/>
)
}
{activeDialogNpc && activeNpcData && (
<DialogModal
npcId={activeDialogNpc}
npcData={activeNpcData}
onClose={() => setActiveDialogNpc(null)}
onTrade={() => {
setActiveDialogNpc(null);
setShowTradeModal(true);
}}
/>
)}
{
activeDialogNpc && activeNpcData && (
<DialogModal
npcId={activeDialogNpc}
npcData={activeNpcData}
onClose={() => setActiveDialogNpc(null)}
onTrade={() => {
setActiveDialogNpc(null);
setShowTradeModal(true);
}}
/>
)
}
{showTradeModal && activeNpcData && (
<TradeModal
npcId={activeNpcData.id}
onClose={() => setShowTradeModal(false)}
/>
)}
</div>
{
showTradeModal && activeNpcData && (
<TradeModal
npcId={activeNpcData.id}
onClose={() => setShowTradeModal(false)}
/>
)
}
</div >
)
}

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import { GameTooltip } from '../common/GameTooltip'
import { GameButton } from '../common/GameButton'
interface MovementControlsProps {
location: Location
@@ -281,16 +282,19 @@ function MovementControls({
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
: getTranslatedText(action.description)
}>
<button
<GameButton
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
variant="primary"
size="sm"
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
style={{ width: '100%', justifyContent: 'space-between' }}
>
{getTranslatedText(action.name)}
<span className="stamina-cost">
{cooldownRemaining > 0 ? `${cooldownRemaining}s` : `${staminaCost}`}
</span>
</button>
</GameButton>
</GameTooltip>
)
})}

View File

@@ -296,7 +296,7 @@ function PlayerSidebar({
<GameButton
className="open-inventory-btn"
variant="primary"
size="md"
size="sm"
onClick={() => setShowInventory(true)}
style={{ width: '100%', justifyContent: 'center' }}
>
@@ -306,7 +306,7 @@ function PlayerSidebar({
<GameButton
className="quest-journal-btn"
variant="secondary" // Different color as requested
size="md"
size="sm"
onClick={onOpenQuestJournal}
style={{ width: '100%', justifyContent: 'center' }}
>

View File

@@ -173,7 +173,7 @@
display: flex;
flex-direction: column;
border-right: 1px solid var(--game-border-color);
background: var(--game-bg-app);
background: var(--game-bg-panel);
overflow: hidden;
}
@@ -199,7 +199,7 @@
display: flex;
align-items: center;
padding: 0.75rem;
background: var(--game-bg-card);
background: #ffffff08;
border: 1px solid var(--game-border-color);
clip-path: var(--game-clip-path);
cursor: pointer;
@@ -485,7 +485,7 @@
width: 100%;
height: 100%;
object-fit: contain;
padding: 10px;
background: var(--game-bg-card);
}
.detail-title {
@@ -501,8 +501,6 @@
}
.detail-requirements {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
clip-path: var(--game-clip-path);
@@ -619,4 +617,23 @@
letter-spacing: 0.5px;
display: inline-block;
vertical-align: middle;
}
/* Action Info Labels */
.action-info {
text-align: center;
font-size: 0.9rem;
color: #a0aec0;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
}
.action-info.warning {
color: #ed8936;
font-weight: 600;
}
.action-info.success {
color: #48bb78;
font-weight: 600;
}

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import type { Profile, WorkbenchTab } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
import { GameButton } from '../common/GameButton'
import './Workbench.css'
interface WorkbenchProps {
@@ -25,7 +26,7 @@ interface WorkbenchProps {
onSetCraftCategoryFilter: (category: string) => void
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
}
function Workbench({
@@ -53,12 +54,19 @@ function Workbench({
const { t } = useTranslation()
const [selectedItem, setSelectedItem] = useState<any>(null)
const [salvageQuantity, setSalvageQuantity] = useState<number>(1)
// Reset selection when tab changes
useEffect(() => {
setSelectedItem(null)
setSalvageQuantity(1)
}, [workbenchTab])
// Reset quantity when selected item changes
useEffect(() => {
setSalvageQuantity(1)
}, [selectedItem])
// Update selectedItem when items list changes (after repair/craft/salvage)
useEffect(() => {
if (selectedItem) {
@@ -243,22 +251,23 @@ function Workbench({
</div>
<div className="detail-actions">
<button
className="craft-btn"
{!item.meets_level && (
<div className="action-info warning">{t('crafting.levelRequired', { level: item.craft_level })}</div>
)}
{item.meets_level && !item.can_craft && (
<div className="action-info warning">{t('crafting.missingRequirements')}</div>
)}
{item.can_craft && (
<div className="action-info">{t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}</div>
)}
<GameButton
variant="success"
disabled={!item.can_craft || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onCraft(item.item_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
style={{ width: '100%' }}
>
<span>
{!item.meets_level ? t('crafting.levelRequired', { level: item.craft_level }) :
!item.can_craft ? t('crafting.missingRequirements') : t('crafting.craftItem')}
</span>
{item.can_craft && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}
</span>
)}
</button>
{t('crafting.craftItem')}
</GameButton>
</div>
</>
)}
@@ -324,22 +333,23 @@ function Workbench({
</div>
<div className="detail-actions">
<button
className="repair-btn"
{!item.needs_repair && (
<div className="action-info success">{t('crafting.alreadyFull')}</div>
)}
{item.needs_repair && !item.can_repair && (
<div className="action-info warning">{t('crafting.missingRequirements')}</div>
)}
{item.needs_repair && item.can_repair && (
<div className="action-info">{t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}</div>
)}
<GameButton
variant="info"
disabled={!item.can_repair || (profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => onRepair(item.unique_item_id, item.inventory_id)}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
style={{ width: '100%' }}
>
<span>
{!item.needs_repair ? t('crafting.alreadyFull') :
!item.can_repair ? t('crafting.missingRequirements') : t('crafting.repairItem')}
</span>
{item.needs_repair && item.can_repair && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}
</span>
)}
</button>
{t('crafting.repairItem')}
</GameButton>
</div>
</>
)}
@@ -403,21 +413,49 @@ function Workbench({
</div>
<div className="detail-actions">
<button
className="uncraft-btn"
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
<div className="action-info">{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}</div>
{/* Quantity Selector for Salvage */}
{item.quantity > 1 && (
<div className="quantity-selector" style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1rem' }}>
<GameButton
variant="secondary"
size="sm"
onClick={() => setSalvageQuantity(Math.max(1, salvageQuantity - 1))}
>
-
</GameButton>
<span style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{salvageQuantity} / {item.quantity}</span>
<GameButton
variant="secondary"
size="sm"
onClick={() => setSalvageQuantity(Math.min(item.quantity, salvageQuantity + 1))}
>
+
</GameButton>
<GameButton
variant="secondary"
size="sm"
onClick={() => setSalvageQuantity(item.quantity)}
>
Max
</GameButton>
</div>
)}
<GameButton
variant="danger"
disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)}
onClick={() => {
if (window.confirm(t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }))) {
onUncraft(item.unique_item_id, item.inventory_id)
const confirmMsg = t('crafting.confirmSalvage', { name: getTranslatedText(item.name) })
if (window.confirm(`${confirmMsg} (x${salvageQuantity})`)) {
onUncraft(item.unique_item_id, item.inventory_id, salvageQuantity)
}
}}
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
style={{ width: '100%' }}
>
<span> {t('game.salvage')}</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
</span>
</button>
{t('game.salvage')} {item.quantity > 1 ? `(x${salvageQuantity})` : ''}
</GameButton>
</div>
</>
)}

View File

@@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import api from '../../../services/api'
import { useAudio } from '../../../contexts/AudioContext'
import { getTranslatedText } from '../../../utils/i18nUtils'
import type {
PlayerState,
Location,
@@ -84,7 +85,7 @@ export interface GameEngineActions {
handleUseItem: (itemId: string) => Promise<void>
handleEquipItem: (inventoryId: number) => Promise<void>
handleUnequipItem: (slot: string) => Promise<void>
handleDropItem: (itemId: string, quantity?: number) => Promise<void>
handleDropItem: (itemId: string, quantity?: number, inventoryId?: number) => Promise<void>
// Crafting/Workbench
handleOpenCrafting: () => Promise<void>
@@ -92,7 +93,7 @@ export interface GameEngineActions {
handleCraft: (itemId: string) => Promise<void>
handleOpenRepair: () => Promise<void>
handleRepairFromMenu: (uniqueItemId: number, inventoryId?: number) => Promise<void>
handleUncraft: (uniqueItemId: number, inventoryId: number) => Promise<void>
handleUncraft: (uniqueItemId: number, inventoryId: number, quantity?: number) => Promise<void>
handleSwitchWorkbenchTab: (tab: WorkbenchTab) => Promise<void>
// Combat
@@ -189,7 +190,14 @@ export function useGameEngine(
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
const [mobileHeaderOpen, setMobileHeaderOpen] = useState<boolean>(false)
const [locationMessages, setLocationMessages] = useState<LocationMessage[]>([])
const [locationMessages, setLocationMessages] = useState<LocationMessage[]>(() => {
try {
const saved = sessionStorage.getItem('locationMessages')
return saved ? JSON.parse(saved) : []
} catch {
return []
}
})
const [interactableCooldowns, setInteractableCooldowns] = useState<Record<string, number>>({})
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
const [_forceUpdate, _setForceUpdate] = useState(0)
@@ -226,9 +234,22 @@ export function useGameEngine(
const addLocationMessage = useCallback((msg: string) => {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setLocationMessages((prev: LocationMessage[]) => [...prev, { time: timeStr, message: msg }])
const locationName = location?.name ? (typeof location.name === 'string' ? location.name : location.name.en || Object.values(location.name)[0]) : ''
setLocationMessages((prev: LocationMessage[]) => {
const newMessages = [...prev, { time: timeStr, message: msg, location_name: locationName }]
// Keep only last 50 messages
const trimmed = newMessages.slice(-50)
// Persist to sessionStorage
try {
sessionStorage.setItem('locationMessages', JSON.stringify(trimmed))
} catch (e) {
// Ignore storage errors
}
return trimmed
})
setMessage(msg)
}, [])
}, [location])
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev])
@@ -330,6 +351,17 @@ export function useGameEngine(
const newCombatState = { ...pvpRes.data, is_pvp: true }
setCombatState(newCombatState)
// Extract opponent info for GameHeader and state
const opponent = pvpRes.data.pvp_combat.is_attacker ?
pvpRes.data.pvp_combat.defender :
pvpRes.data.pvp_combat.attacker
if (opponent) {
setEnemyName(opponent.username || opponent.name || 'Unknown Player')
// If players have avatars in the future, set it here. For now default or specific image if available.
setEnemyImage(opponent.image || '')
}
if (pvpRes.data.pvp_combat.last_action &&
pvpRes.data.pvp_combat.last_action !== lastSeenPvPActionRef.current) {
@@ -404,12 +436,42 @@ export function useGameEngine(
setMobileMenuOpen('none')
// Capture old location name before moving (translated)
const oldLocationName = location?.name ? getTranslatedText(location.name) : ''
try {
setMessage('Moving...')
const response = await api.post('/api/game/move', { direction })
setMessage(response.data.message)
playSfx('/audio/sfx/step.wav')
setLocationMessages([])
// Get new location name and stamina from response (translated if possible)
// The response might contain the location object // Add to location log
// Use the location name directly from the response if available (it handles localization on the backend)
let newLocationName = 'Unknown'
if (response.data.new_location_name) {
// Prefer the name from the response object which we just added to the backend
newLocationName = getTranslatedText(response.data.new_location_name)
} else if (response.data.location?.name) {
// Fallback to location object if present in response (legacy)
newLocationName = getTranslatedText(response.data.location.name)
} else if (response.data.new_location_id) {
// Fallback to ID if no name provided (shouldn't happen with new backend)
newLocationName = response.data.new_location_id
}
const staminaSpent = response.data.stamina_spent || 1
const msg = t('location.movedTo', {
from: oldLocationName || 'Unknown',
to: newLocationName,
stamina: staminaSpent
})
// Add movement message to activity log (only if location changed)
if (oldLocationName && newLocationName && oldLocationName !== newLocationName) {
addLocationMessage(msg)
}
if (response.data.encounter && response.data.encounter.triggered) {
const encounter = response.data.encounter
@@ -441,7 +503,7 @@ export function useGameEngine(
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Move failed')
}
}, [combatState, showCraftingMenu, showRepairMenu, fetchGameData])
}, [combatState, showCraftingMenu, showRepairMenu, fetchGameData, location, t, addLocationMessage])
// Simplified placeholder handlers
// (Full implementations would be moved from Game.tsx)
@@ -676,10 +738,14 @@ export function useGameEngine(
}
}
const handleDropItem = async (itemId: string, quantity: number = 1) => {
const handleDropItem = async (itemId: string, quantity: number = 1, inventoryId?: number) => {
try {
setMessage(`Dropping ${quantity} item(s)...`)
const response = await api.post('/api/game/item/drop', { item_id: itemId, quantity })
const payload: any = { item_id: itemId, quantity }
if (inventoryId) {
payload.inventory_id = inventoryId
}
const response = await api.post('/api/game/item/drop', payload)
const msg = response.data.message || 'Item dropped!'
addLocationMessage(msg)
fetchGameData()
@@ -746,12 +812,13 @@ export function useGameEngine(
}
}
const handleUncraft = async (uniqueItemId: number, inventoryId: number) => {
const handleUncraft = async (uniqueItemId: number, inventoryId: number, quantity: number = 1) => {
try {
// setMessage('Salvaging...')
const response = await api.post('/api/game/uncraft_item', {
unique_item_id: uniqueItemId,
inventory_id: inventoryId
inventory_id: inventoryId,
quantity: quantity
})
const data = response.data
let msg = data.message || 'Item salvaged!'

View File

@@ -77,6 +77,7 @@ export interface CombatLogEntry {
export interface LocationMessage {
time: string
message: string
location_name?: string
}
export interface Equipment {

View File

@@ -24,3 +24,8 @@ export const useGame = () => {
}
return context;
};
// Optional hook that doesn't throw when outside GameProvider
export const useOptionalGame = () => {
return useContext(GameContext);
};

View File

@@ -102,7 +102,9 @@
"itemsOnGround": "📦 Items on Ground",
"lootableItems": "Lootable Items:",
"items": "item(s)",
"level": "Lv."
"level": "Lv.",
"movedTo": "You moved from {{from}} to {{to}}, spending {{stamina}} stamina",
"noActivity": "No recent activity"
},
"tags": {
"workbench": "🔧 Workbench",
@@ -115,6 +117,13 @@
"water": "💧 Water",
"food": "🍎 Food"
},
"danger": {
"safe": "Safe Zone",
"low": "Low Danger",
"medium": "Medium Danger",
"high": "High Danger",
"extreme": "Extreme Danger"
},
"stats": {
"hp": "❤️ HP",
"maxHp": "❤️ Max HP",

View File

@@ -100,7 +100,9 @@
"itemsOnGround": "📦 Objetos en el Suelo",
"lootableItems": "Objetos Saqueables:",
"items": "objeto(s)",
"level": "Nv."
"level": "Nv.",
"movedTo": "Te has movido de {{from}} a {{to}}, gastando {{stamina}} de aguante",
"noActivity": "Sin actividad reciente"
},
"tags": {
"workbench": "🔧 Banco de Trabajo",
@@ -113,6 +115,13 @@
"water": "💧 Agua",
"food": "🍎 Comida"
},
"danger": {
"safe": "Zona Segura",
"low": "Peligro Bajo",
"medium": "Peligro Medio",
"high": "Peligro Alto",
"extreme": "Peligro Extremo"
},
"stats": {
"hp": "❤️ Vida",
"maxHp": "❤️ Vida Máx.",

View File

@@ -10,6 +10,7 @@
/* Item slots */
--game-bg-slot-hover: rgba(255, 255, 255, 0.1);
--game-bg-tooltip: #151515;
--game-bg-card: #050505;
/* --- Borders & Separators --- */
--game-border-color: rgba(255, 255, 255, 0.12);

View File

@@ -3,7 +3,7 @@
*
* Resolves asset paths based on runtime environment:
* - Electron: Returns relative path (assets bundled with app, using ./)
* - Browser: Returns full server URL
* - Browser: Returns absolute path (served by PWA nginx)
*/
// Detect if running in Electron - check at runtime, not module load time
@@ -12,10 +12,6 @@ function checkIsElectron(): boolean {
window.location.protocol === 'file:'
}
// Base URL for remote assets (browser mode)
const ASSET_BASE_URL = import.meta.env.VITE_ASSET_URL ||
(import.meta.env.PROD ? 'https://api-staging.echoesoftheash.com' : '')
/**
* Resolves an asset path for the current environment
* @param path - The asset path (e.g., "images/items/knife.webp" or "/images/items/knife.webp")
@@ -32,8 +28,8 @@ export function getAssetPath(path: string): string {
return `./${cleanPath}`
}
// In browser, prepend the server URL
return `${ASSET_BASE_URL}/${cleanPath}`
// In browser, use absolute path - PWA nginx serves images at /images/
return `/${cleanPath}`
}
/**