chore: save progress before layout changes
@@ -375,6 +375,7 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
|
|||||||
|
|
||||||
class UncraftItemRequest(BaseModel):
|
class UncraftItemRequest(BaseModel):
|
||||||
inventory_id: int
|
inventory_id: int
|
||||||
|
quantity: int = 1
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/game/uncraft_item")
|
@router.post("/api/game/uncraft_item")
|
||||||
@@ -402,6 +403,14 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
|||||||
|
|
||||||
if not inv_item:
|
if not inv_item:
|
||||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
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
|
# Get item definition
|
||||||
item_def = ITEMS_MANAGER.items.get(inv_item['item_id'])
|
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:
|
if not uncraft_yield:
|
||||||
raise HTTPException(status_code=400, detail="No uncraft recipe found")
|
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', [])
|
uncraft_tools = getattr(item_def, 'uncraft_tools', [])
|
||||||
|
tools_consumed = []
|
||||||
|
|
||||||
if uncraft_tools:
|
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:
|
if not success:
|
||||||
raise HTTPException(status_code=400, detail=error_msg)
|
raise HTTPException(status_code=400, detail=error_msg)
|
||||||
else:
|
|
||||||
tools_consumed = []
|
|
||||||
|
|
||||||
# Calculate stamina cost
|
# 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
|
# Check stamina
|
||||||
if player['stamina'] < stamina_cost:
|
if player['stamina'] < total_stamina_cost:
|
||||||
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
|
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {total_stamina_cost}, have {player['stamina']}")
|
||||||
|
|
||||||
# Deduct 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)
|
await db.update_player_stamina(current_user['id'], new_stamina)
|
||||||
|
|
||||||
# Remove the item from inventory
|
# Update inventory item
|
||||||
# Use remove_inventory_row since we have the inventory ID
|
if request.quantity == current_quantity:
|
||||||
await db.remove_inventory_row(inv_item['id'])
|
# 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
|
# Calculate durability ratio for yield reduction
|
||||||
durability_ratio = 1.0 # Default: full yield
|
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:
|
if max_durability > 0:
|
||||||
durability_ratio = current_durability / max_durability
|
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'])
|
inventory = await db.get_inventory(current_user['id'])
|
||||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
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
|
import random
|
||||||
loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3)
|
loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3)
|
||||||
yield_info = {
|
|
||||||
'base_yield': uncraft_yield,
|
materials_yielded_dict = {}
|
||||||
'loss_chance': loss_chance,
|
materials_lost_dict = {}
|
||||||
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
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_yielded = []
|
||||||
materials_lost = []
|
materials_lost = []
|
||||||
materials_dropped = []
|
materials_dropped = []
|
||||||
|
|
||||||
for material in uncraft_yield:
|
# Convert lost dict to list
|
||||||
# Apply durability reduction first
|
for (item_id, name), qty in materials_lost_dict.items():
|
||||||
base_quantity = material['quantity']
|
materials_lost.append({
|
||||||
|
'item_id': item_id,
|
||||||
|
'name': name,
|
||||||
|
'quantity': qty,
|
||||||
|
'reason': 'lost_or_low_durability'
|
||||||
|
})
|
||||||
|
|
||||||
# Calculate adjusted quantity based on durability
|
# Process yield
|
||||||
# Use round() to ensure minimum yield of 1 for high durability items (e.g. 90% of 1 = 0.9 -> 1)
|
for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items():
|
||||||
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
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 current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
|
||||||
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
# Fits
|
||||||
materials_lost.append({
|
await db.add_item_to_inventory(
|
||||||
'item_id': material['item_id'],
|
player_id=current_user['id'],
|
||||||
'name': mat_def.name if mat_def else material['item_id'],
|
item_id=item_id,
|
||||||
'quantity': base_quantity,
|
quantity=qty
|
||||||
'reason': 'durability_too_low'
|
)
|
||||||
})
|
current_weight += mat_weight
|
||||||
continue
|
current_volume += mat_volume
|
||||||
|
|
||||||
# Roll for each material separately with loss chance
|
materials_yielded.append({
|
||||||
if random.random() < loss_chance:
|
'item_id': item_id,
|
||||||
# Lost this material
|
'name': name,
|
||||||
materials_lost.append({
|
'emoji': emoji,
|
||||||
'item_id': material['item_id'],
|
'quantity': qty
|
||||||
'name': mat_def.name if mat_def else material['item_id'],
|
|
||||||
'quantity': adjusted_quantity,
|
|
||||||
'reason': 'random_loss'
|
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# Check if it fits in inventory
|
# Drop
|
||||||
mat_weight = getattr(mat_def, 'weight', 0) * adjusted_quantity
|
await db.drop_item_to_world(
|
||||||
mat_volume = getattr(mat_def, 'volume', 0) * adjusted_quantity
|
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:
|
materials_dropped.append({
|
||||||
# Fits in inventory
|
'item_id': item_id,
|
||||||
await db.add_item_to_inventory(
|
'name': name,
|
||||||
player_id=current_user['id'],
|
'emoji': emoji,
|
||||||
item_id=material['item_id'],
|
'quantity': qty
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
message = f"Uncrafted {item_def.name}!"
|
message = f"Uncrafted {request.quantity}x {item_def.name}!"
|
||||||
if durability_ratio < 1.0:
|
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:
|
if materials_lost:
|
||||||
message += f" Lost {len(materials_lost)} material type(s)."
|
message += f" Lost materials."
|
||||||
if materials_dropped:
|
if materials_dropped:
|
||||||
message += f" Inventory full! Dropped {len(materials_dropped)} item(s) to the ground."
|
message += f" Inventory full! Dropped items."
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -550,7 +604,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
|||||||
'tools_consumed': tools_consumed,
|
'tools_consumed': tools_consumed,
|
||||||
'loss_chance': loss_chance,
|
'loss_chance': loss_chance,
|
||||||
'durability_ratio': round(durability_ratio, 2),
|
'durability_ratio': round(durability_ratio, 2),
|
||||||
'stamina_cost': stamina_cost,
|
'stamina_cost': total_stamina_cost,
|
||||||
'new_stamina': new_stamina
|
'new_stamina': new_stamina
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse",
|
||||||
"emoji": "💀",
|
"emoji": "💀",
|
||||||
"loot_count": len(loot),
|
"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:
|
for corpse in player_corpses:
|
||||||
@@ -957,7 +958,8 @@ async def move(
|
|||||||
response = {
|
response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": message,
|
"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
|
# Add encounter info if triggered
|
||||||
@@ -1469,10 +1471,21 @@ async def drop_item(
|
|||||||
# Get inventory item by item_id (string), not database id
|
# Get inventory item by item_id (string), not database id
|
||||||
inventory = await db.get_inventory(player_id)
|
inventory = await db.get_inventory(player_id)
|
||||||
inv_item = None
|
inv_item = None
|
||||||
for item in inventory:
|
|
||||||
if item['item_id'] == item_id:
|
# If inventory_id is provided, use it to find precise item
|
||||||
inv_item = item
|
inventory_id = drop_req.get('inventory_id')
|
||||||
break
|
|
||||||
|
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:
|
if not inv_item:
|
||||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||||
|
|||||||
@@ -62,7 +62,22 @@ async def get_corpse_details(
|
|||||||
|
|
||||||
# Get player's inventory to check available tools
|
# Get player's inventory to check available tools
|
||||||
inventory = await db.get_inventory(player['id'])
|
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':
|
if corpse_type == 'npc':
|
||||||
# Get NPC corpse
|
# Get NPC corpse
|
||||||
@@ -80,9 +95,16 @@ async def get_corpse_details(
|
|||||||
loot_items = []
|
loot_items = []
|
||||||
for idx, loot_item in enumerate(loot_remaining):
|
for idx, loot_item in enumerate(loot_remaining):
|
||||||
required_tool = loot_item.get('required_tool')
|
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'])
|
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
|
tool_def = ITEMS_MANAGER.get_item(required_tool) if required_tool else None
|
||||||
|
|
||||||
loot_items.append({
|
loot_items.append({
|
||||||
|
|||||||
@@ -341,16 +341,16 @@
|
|||||||
"text": {
|
"text": {
|
||||||
"crit_failure": {
|
"crit_failure": {
|
||||||
"en": "The floor collapses beneath you! (-10 HP)",
|
"en": "The floor collapses beneath you! (-10 HP)",
|
||||||
"es": ""
|
"es": "¡El suelo se derrumba bajo ti! (-10 HP)"
|
||||||
},
|
},
|
||||||
"crit_success": "",
|
"crit_success": "",
|
||||||
"failure": {
|
"failure": {
|
||||||
"en": "The house has already been thoroughly looted. Nothing remains.",
|
"en": "The house has already been thoroughly looted. Nothing remains.",
|
||||||
"es": ""
|
"es": "La casa ya ha sido despojada de todo. No queda nada."
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"en": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!",
|
"en": "You find some useful supplies!",
|
||||||
"es": ""
|
"es": "¡Encuentras algunos suministros útiles!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,14 +39,16 @@ for category in items locations npcs interactables characters placeholder static
|
|||||||
continue
|
continue
|
||||||
fi
|
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
|
# Special processing for items: remove white background and resize
|
||||||
echo " ➜ Converting item: $filename"
|
echo " ➜ Converting item: $filename"
|
||||||
tmp="/tmp/${base}_clean.png"
|
tmp="/tmp/${base}_clean.png"
|
||||||
if [[ "$category" == "static_npcs" ]]; then
|
if [[ "$category" == "static_npcs" ]]; then
|
||||||
convert "$img" -fuzz 10% -transparent white -resize "$PORTRAIT_SIZE" "$tmp"
|
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"
|
convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp"
|
||||||
|
else
|
||||||
|
convert "$img" -fuzz 10% -transparent white "$tmp"
|
||||||
fi
|
fi
|
||||||
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
|
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
|
||||||
rm "$tmp"
|
rm "$tmp"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 647 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 742 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 735 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 686 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 733 KiB |
BIN
images-source/static_npcs/mechanic_mike.png
Normal file
|
After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 85 KiB |
BIN
images/static_npcs/mechanic_mike.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
@@ -78,7 +78,7 @@ textarea:focus {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.message-success {
|
||||||
color: #51cf66;
|
color: #51cf66;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,7 +323,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.message-success {
|
||||||
background: rgba(40, 167, 69, 0.1);
|
background: rgba(40, 167, 69, 0.1);
|
||||||
color: #5ddc6c;
|
color: #5ddc6c;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ function AccountPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{emailError && <div className="error">{emailError}</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}>
|
<button type="submit" className="button-primary" disabled={emailLoading}>
|
||||||
{emailLoading ? 'Updating...' : 'Update Email'}
|
{emailLoading ? 'Updating...' : 'Update Email'}
|
||||||
</button>
|
</button>
|
||||||
@@ -392,7 +392,7 @@ function AccountPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{passwordError && <div className="error">{passwordError}</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}>
|
<button type="submit" className="button-primary" disabled={passwordLoading}>
|
||||||
{passwordLoading ? 'Updating...' : 'Update Password'}
|
{passwordLoading ? 'Updating...' : 'Update Password'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -723,8 +723,8 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.messages-scroll {
|
.messages-scroll {
|
||||||
height: 5.5rem;
|
height: 6rem;
|
||||||
/* Compact fixed height (~3 lines) */
|
/* Always visible activity log */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -752,6 +752,13 @@ html {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-location {
|
||||||
|
color: rgba(107, 185, 240, 0.7);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.movement-controls {
|
.movement-controls {
|
||||||
background: var(--game-bg-panel);
|
background: var(--game-bg-panel);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -2086,11 +2093,12 @@ body.no-scroll {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
width: 20px;
|
width: 24px;
|
||||||
height: 20px;
|
height: 24px;
|
||||||
background: rgba(244, 67, 54, 0.9);
|
background: rgba(244, 67, 54, 0.9);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
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;
|
color: #fff;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
|
|||||||
|
|
||||||
import { GameProvider } from '../contexts/GameContext'
|
import { GameProvider } from '../contexts/GameContext'
|
||||||
import { QuestJournal } from './game/QuestJournal'
|
import { QuestJournal } from './game/QuestJournal'
|
||||||
|
import GameHeader from './GameHeader'
|
||||||
import './Game.css'
|
import './Game.css'
|
||||||
|
|
||||||
function Game() {
|
function Game() {
|
||||||
@@ -350,9 +351,8 @@ function Game() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GameProvider value={gameContextValue}>
|
<GameProvider value={gameContextValue}>
|
||||||
|
<GameHeader />
|
||||||
<div className="game-container">
|
<div className="game-container">
|
||||||
{/* Game Header is now in GameLayout */}
|
|
||||||
|
|
||||||
{/* Quest Journal Toggle Button - Add to header or float?
|
{/* Quest Journal Toggle Button - Add to header or float?
|
||||||
Let's add it floating for now or in the top right.
|
Let's add it floating for now or in the top right.
|
||||||
*/}
|
*/}
|
||||||
@@ -396,6 +396,7 @@ function Game() {
|
|||||||
profile={state.profile}
|
profile={state.profile}
|
||||||
playerState={state.playerState}
|
playerState={state.playerState}
|
||||||
equipment={state.equipment}
|
equipment={state.equipment}
|
||||||
|
locationImage={state.location?.image_url}
|
||||||
onCombatAction={actions.handleCombatAction}
|
onCombatAction={actions.handleCombatAction}
|
||||||
onPvPAction={async (action: string) => {
|
onPvPAction={async (action: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -478,7 +479,7 @@ function Game() {
|
|||||||
onSetCraftCategoryFilter={actions.setCraftCategoryFilter}
|
onSetCraftCategoryFilter={actions.setCraftCategoryFilter}
|
||||||
onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())}
|
onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())}
|
||||||
onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)}
|
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}
|
failedActionItemId={state.failedActionItemId}
|
||||||
quests={state.quests}
|
quests={state.quests}
|
||||||
/>
|
/>
|
||||||
@@ -501,8 +502,8 @@ function Game() {
|
|||||||
}}
|
}}
|
||||||
onEquipItem={actions.handleEquipItem}
|
onEquipItem={actions.handleEquipItem}
|
||||||
onUnequipItem={actions.handleUnequipItem}
|
onUnequipItem={actions.handleUnequipItem}
|
||||||
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
|
onDropItem={async (itemId: number, invId: number, quantity: number) => {
|
||||||
await actions.handleDropItem(itemId.toString(), quantity)
|
await actions.handleDropItem(itemId.toString(), quantity, invId)
|
||||||
}}
|
}}
|
||||||
onSpendPoint={actions.handleSpendPoint}
|
onSpendPoint={actions.handleSpendPoint}
|
||||||
onOpenQuestJournal={() => setShowQuestJournal(true)}
|
onOpenQuestJournal={() => setShowQuestJournal(true)}
|
||||||
|
|||||||
@@ -57,6 +57,85 @@
|
|||||||
opacity: 0.8;
|
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) --- */
|
/* --- Navigation Tabs (Unified Angled Style) --- */
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -64,12 +143,11 @@
|
|||||||
/* Tight gaps for HUD feel */
|
/* Tight gaps for HUD feel */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-left: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
height: 70%;
|
height: 36px;
|
||||||
/* Not full height, more like buttons */
|
min-height: 36px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
padding: 0 1.2rem;
|
padding: 0 1.2rem;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useGameWebSocket } from '../hooks/useGameWebSocket'
|
|||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import LanguageSelector from './LanguageSelector'
|
import LanguageSelector from './LanguageSelector'
|
||||||
|
import { useOptionalGame } from '../contexts/GameContext'
|
||||||
|
import { getTranslatedText } from '../utils/i18nUtils'
|
||||||
import './Game.css'
|
import './Game.css'
|
||||||
|
|
||||||
import { GameTooltip } from './common/GameTooltip'
|
import { GameTooltip } from './common/GameTooltip'
|
||||||
@@ -12,17 +14,39 @@ import { GameTooltip } from './common/GameTooltip'
|
|||||||
// Import the new specific header styles
|
// Import the new specific header styles
|
||||||
import './GameHeader.css'
|
import './GameHeader.css'
|
||||||
|
|
||||||
interface GameHeaderProps {
|
interface CombatInfo {
|
||||||
className?: string
|
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 navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { currentCharacter, logout } = useAuth()
|
const { currentCharacter, logout } = useAuth()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [playerCount, setPlayerCount] = useState<number>(0)
|
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
|
// Fetch initial player count
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPlayerCount = async () => {
|
const fetchPlayerCount = async () => {
|
||||||
@@ -61,8 +85,15 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
|
|||||||
|
|
||||||
const isOnOwnProfile = location.pathname === `/profile/${currentCharacter?.id}`
|
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 (
|
return (
|
||||||
<header className={`game-header ${className}`}>
|
<header className={`game-header ${className}`}>
|
||||||
|
{/* Left: Logo and Version */}
|
||||||
<div className="header-left">
|
<div className="header-left">
|
||||||
<div className="header-title-container">
|
<div className="header-title-container">
|
||||||
<h1>Echoes of the Ash</h1>
|
<h1>Echoes of the Ash</h1>
|
||||||
@@ -70,22 +101,45 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="nav-links">
|
{/* Center: Location/Combat Title */}
|
||||||
<button
|
<div className="header-center">
|
||||||
onClick={() => navigate('/game')}
|
{combatInfo ? (
|
||||||
className={`nav-link ${isActive('/game') ? 'active' : ''}`}
|
<div className="header-location-title combat">
|
||||||
>
|
<span className="combat-indicator">⚔️</span>
|
||||||
<span style={{ fontSize: '1.1em' }}>🎮</span> {t('common.game')}
|
<span className="location-name">{combatInfo.enemyName}</span>
|
||||||
</button>
|
<span className={`turn-indicator ${combatInfo.yourTurn ? 'your-turn' : 'enemy-turn'}`}>
|
||||||
<button
|
{combatInfo.yourTurn ? t('combat.yourTurn') : t('combat.enemyTurn')}
|
||||||
onClick={() => navigate('/leaderboards')}
|
</span>
|
||||||
className={`nav-link ${isActive('/leaderboards') ? 'active' : ''}`}
|
</div>
|
||||||
>
|
) : locationName ? (
|
||||||
<span style={{ fontSize: '1.1em' }}>🏆</span> {t('common.leaderboards')}
|
<div className="header-location-title">
|
||||||
</button>
|
<span className="location-name">{locationName}</span>
|
||||||
</nav>
|
{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 })}>
|
<GameTooltip content={t('game.onlineCount', { count: playerCount })}>
|
||||||
<div className="player-count-badge">
|
<div className="player-count-badge">
|
||||||
<span className="status-dot"></span>
|
<span className="status-dot"></span>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
import GameHeader from './GameHeader'
|
|
||||||
import './Game.css'
|
import './Game.css'
|
||||||
|
|
||||||
export default function GameLayout() {
|
export default function GameLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="game-layout">
|
<div className="game-layout">
|
||||||
<GameHeader />
|
|
||||||
<div className="game-content">
|
<div className="game-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-btn:disabled {
|
.game-btn:disabled {
|
||||||
@@ -51,13 +52,13 @@
|
|||||||
|
|
||||||
/* Primary - Blue (Default) */
|
/* Primary - Blue (Default) */
|
||||||
.game-btn.primary {
|
.game-btn.primary {
|
||||||
background: linear-gradient(135deg, #6bb9f0, #89d4ff);
|
background: linear-gradient(135deg, #2980b9, #3498db);
|
||||||
box-shadow: 0 2px 8px rgba(107, 185, 240, 0.3);
|
box-shadow: 0 2px 8px rgba(41, 128, 185, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-btn.primary:not(:disabled):hover {
|
.game-btn.primary:not(:disabled):hover {
|
||||||
background: linear-gradient(135deg, #89d4ff, #6bb9f0);
|
background: linear-gradient(135deg, #3498db, #2980b9);
|
||||||
box-shadow: 0 4px 12px rgba(107, 185, 240, 0.5);
|
box-shadow: 0 4px 12px rgba(41, 128, 185, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary - Grey/Dark */
|
/* Secondary - Grey/Dark */
|
||||||
|
|||||||
@@ -94,4 +94,12 @@
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
border-bottom: 1px solid var(--game-border-color, #4a5568);
|
border-bottom: 1px solid var(--game-border-color, #4a5568);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical pickup options layout */
|
||||||
|
.pickup-options-vertical {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
182
pwa/src/components/common/ItemTooltipContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ interface CombatProps {
|
|||||||
profile: any;
|
profile: any;
|
||||||
playerState: any;
|
playerState: any;
|
||||||
equipment: any;
|
equipment: any;
|
||||||
|
locationImage?: string;
|
||||||
onCombatAction: (action: string) => Promise<any>;
|
onCombatAction: (action: string) => Promise<any>;
|
||||||
onPvPAction: (action: string, targetId: number) => Promise<any>;
|
onPvPAction: (action: string, targetId: number) => Promise<any>;
|
||||||
onExitCombat: () => void;
|
onExitCombat: () => void;
|
||||||
@@ -31,6 +32,7 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
profile,
|
profile,
|
||||||
playerState,
|
playerState,
|
||||||
equipment: _equipment,
|
equipment: _equipment,
|
||||||
|
locationImage,
|
||||||
onCombatAction,
|
onCombatAction,
|
||||||
onPvPAction,
|
onPvPAction,
|
||||||
onExitCombat,
|
onExitCombat,
|
||||||
@@ -734,6 +736,7 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
combatResult={combatResult}
|
combatResult={combatResult}
|
||||||
equipment={_equipment}
|
equipment={_equipment}
|
||||||
playerName={profile?.name}
|
playerName={profile?.name}
|
||||||
|
locationImage={locationImage}
|
||||||
/>
|
/>
|
||||||
{/* Supplies modal */}
|
{/* Supplies modal */}
|
||||||
<CombatInventoryModal
|
<CombatInventoryModal
|
||||||
|
|||||||
@@ -25,6 +25,93 @@
|
|||||||
transition: filter 1s ease;
|
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 */
|
/* Enemy avatar now uses shared .location-image styles from Game.css */
|
||||||
|
|
||||||
/* ... existing code ... */
|
/* ... existing code ... */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getAssetPath } from '../../utils/assetPath';
|
|||||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||||
import './CombatInventoryModal.css';
|
import './CombatInventoryModal.css';
|
||||||
import { EffectBadge } from './EffectBadge';
|
import { EffectBadge } from './EffectBadge';
|
||||||
|
import { GameButton } from '../common/GameButton';
|
||||||
|
|
||||||
interface CombatInventoryModalProps {
|
interface CombatInventoryModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -51,7 +52,7 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
|
|||||||
<div className="combat-inventory-modal" onClick={e => e.stopPropagation()}>
|
<div className="combat-inventory-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h3>{t('combat.modal.supplies_title')}</h3>
|
<h3>{t('combat.modal.supplies_title')}</h3>
|
||||||
<button className="close-btn" onClick={onClose}>×</button>
|
<GameButton variant="danger" size="sm" onClick={onClose}>×</GameButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
@@ -201,9 +202,9 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
<button className="btn-use">
|
<GameButton variant="success" size="sm" style={{ flexShrink: 0 }}>
|
||||||
{t('game.use')}
|
{t('game.use')}
|
||||||
</button>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||||
import { useAudio } from '../../contexts/AudioContext';
|
import { useAudio } from '../../contexts/AudioContext';
|
||||||
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
|
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
|
||||||
import { Equipment } from './types';
|
import { Equipment } from './types';
|
||||||
import './CombatEffects.css';
|
import './CombatEffects.css';
|
||||||
import { GameProgressBar } from '../common/GameProgressBar';
|
import { GameProgressBar } from '../common/GameProgressBar';
|
||||||
|
import { GameButton } from '../common/GameButton';
|
||||||
|
|
||||||
interface CombatViewProps {
|
interface CombatViewProps {
|
||||||
state: CombatState;
|
state: CombatState;
|
||||||
@@ -17,6 +19,7 @@ interface CombatViewProps {
|
|||||||
combatResult: 'victory' | 'defeat' | 'fled' | null;
|
combatResult: 'victory' | 'defeat' | 'fled' | null;
|
||||||
equipment?: Equipment | any;
|
equipment?: Equipment | any;
|
||||||
playerName?: string;
|
playerName?: string;
|
||||||
|
locationImage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CombatView: React.FC<CombatViewProps> = ({
|
export const CombatView: React.FC<CombatViewProps> = ({
|
||||||
@@ -29,7 +32,8 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
isProcessing,
|
isProcessing,
|
||||||
combatResult,
|
combatResult,
|
||||||
equipment,
|
equipment,
|
||||||
playerName
|
playerName,
|
||||||
|
locationImage
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { playSfx } = useAudio();
|
const { playSfx } = useAudio();
|
||||||
@@ -117,44 +121,62 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="combat-container">
|
<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 */}
|
{/* Main Content Vertical Stack */}
|
||||||
<div className="combat-main-content">
|
<div className="combat-main-content">
|
||||||
|
|
||||||
{/* 1. Enemy Avatar (Location Image Style) */}
|
{/* 1. Combat Scene: Location Background with NPC Overlay */}
|
||||||
{/* Shake on npcHit, Attack on enemyAttacking, Dead on victory */}
|
<div className="combat-scene-container">
|
||||||
<div className={`enemy-display ${animState.enemyAttacking ? 'attacking' : ''} ${animState.npcHit ? 'shake-effect flash-hit' : ''} ${combatResult === 'victory' ? 'dead' : ''}`}>
|
{/* Location Background */}
|
||||||
<div className="location-image-container">
|
{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 ? (
|
{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>
|
||||||
</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 */}
|
{/* 2. HP Bars (Character Sheet Style) - Staggered Lines */}
|
||||||
<div className="combat-stats-container" style={{ position: 'relative' }}>
|
<div className="combat-stats-container" style={{ position: 'relative' }}>
|
||||||
|
|
||||||
@@ -197,7 +219,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
{/* Enemy HP (Left) */}
|
{/* Enemy HP (Left) */}
|
||||||
<div className={`stat-block enemy ${animState.npcHit ? 'shake-effect' : ''}`}>
|
<div className={`stat-block enemy ${animState.npcHit ? 'shake-effect' : ''}`}>
|
||||||
<GameProgressBar
|
<GameProgressBar
|
||||||
label={state.npcName || t('common.enemy')}
|
label={getTranslatedText(state.npcName) || t('common.enemy')}
|
||||||
value={state.npcHp}
|
value={state.npcHp}
|
||||||
max={state.npcMaxHp}
|
max={state.npcMaxHp}
|
||||||
type="enemy_health"
|
type="enemy_health"
|
||||||
@@ -224,47 +246,51 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
|
|
||||||
{/* 3. Actions */}
|
{/* 3. Actions */}
|
||||||
<div className="combat-actions">
|
<div className="combat-actions">
|
||||||
<button
|
{combatResult && (
|
||||||
className="btn btn-primary full-width glow-effect"
|
<GameButton
|
||||||
onClick={onClose}
|
variant="primary"
|
||||||
style={{ display: combatResult ? 'block' : 'none', margin: '0 auto' }}
|
onClick={onClose}
|
||||||
>
|
style={{ width: '100%', maxWidth: '200px', 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}
|
|
||||||
>
|
>
|
||||||
👊 {t('combat.actions.attack')}
|
{t('common.close')}
|
||||||
</button>
|
</GameButton>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{!combatResult && (
|
||||||
className="btn btn-defend"
|
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
|
||||||
onClick={() => onAction('defend')}
|
<GameButton
|
||||||
disabled={isProcessing || !state.yourTurn}
|
variant="danger"
|
||||||
>
|
onClick={() => onAction('attack')}
|
||||||
🛡️ {t('combat.actions.defend')}
|
disabled={isProcessing || !state.yourTurn}
|
||||||
</button>
|
>
|
||||||
|
👊 {t('combat.actions.attack')}
|
||||||
|
</GameButton>
|
||||||
|
|
||||||
<button
|
<GameButton
|
||||||
className="btn btn-supplies"
|
variant="primary"
|
||||||
onClick={onShowSupplies}
|
onClick={() => onAction('defend')}
|
||||||
disabled={isProcessing || !state.yourTurn}
|
disabled={isProcessing || !state.yourTurn}
|
||||||
>
|
>
|
||||||
🎒 {t('combat.actions.supplies')}
|
🛡️ {t('combat.actions.defend')}
|
||||||
</button>
|
</GameButton>
|
||||||
|
|
||||||
<button
|
<GameButton
|
||||||
className="btn btn-flee"
|
variant="secondary"
|
||||||
onClick={() => onAction('flee')}
|
onClick={onShowSupplies}
|
||||||
disabled={isProcessing || !state.yourTurn}
|
disabled={isProcessing || !state.yourTurn}
|
||||||
>
|
>
|
||||||
🏃 {t('combat.actions.flee')}
|
🎒 {t('combat.actions.supplies')}
|
||||||
</button>
|
</GameButton>
|
||||||
</div>
|
|
||||||
|
<GameButton
|
||||||
|
variant="warning"
|
||||||
|
onClick={() => onAction('flee')}
|
||||||
|
disabled={isProcessing || !state.yourTurn}
|
||||||
|
>
|
||||||
|
🏃 {t('combat.actions.flee')}
|
||||||
|
</GameButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. Log (Table) */}
|
{/* 4. Log (Table) */}
|
||||||
@@ -276,7 +302,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
let className = `log-row log-${msg.type}`;
|
let className = `log-row log-${msg.type}`;
|
||||||
|
|
||||||
if (msg.data && msg.data.message) {
|
if (msg.data && msg.data.message) {
|
||||||
text = msg.data.message;
|
text = getTranslatedText(msg.data.message);
|
||||||
} else {
|
} else {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'combat_start': text = t('combat.start'); break;
|
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 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
|
||||||
case 'flee_success': text = t('combat.flee.success'); break;
|
case 'flee_success': text = t('combat.flee.success'); break;
|
||||||
case 'flee_fail': text = t('combat.flee.fail'); 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 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
|
||||||
case 'damage':
|
case 'damage':
|
||||||
if (msg.origin === 'enemy') {
|
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 });
|
text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'text': text = msg.data?.text || ""; break;
|
case 'text': text = getTranslatedText(msg.data?.text) || ""; break;
|
||||||
case 'item_used':
|
case 'item_used':
|
||||||
text = t('combat.log.item_used', { item: msg.data?.item_name || '' });
|
text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' });
|
||||||
if (msg.data?.effects) text += msg.data.effects; // Append effects string if backend still sends it
|
if (msg.data?.effects) text += getTranslatedText(msg.data.effects); // Append effects string if backend still sends it
|
||||||
className += " text-info";
|
className += " text-info";
|
||||||
break;
|
break;
|
||||||
case 'effect_applied':
|
case 'effect_applied':
|
||||||
text = t('combat.log.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')
|
target: msg.data?.target === 'enemy' ? t('common.enemy') : t('common.you')
|
||||||
});
|
});
|
||||||
className += " text-warning";
|
className += " text-warning";
|
||||||
@@ -330,7 +356,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<tr key={index} className={className}>
|
<tr key={index} className={className}>
|
||||||
<td className="log-time">[{time}]</td>
|
<td className="log-time">[{time}]</td>
|
||||||
<td className="log-event">{text}</td>
|
<td className="log-event"><span>{text}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -26,13 +26,27 @@
|
|||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.npc-image {
|
.npc-portrait-container {
|
||||||
width: 100px;
|
width: 130px;
|
||||||
height: 100px;
|
height: 130px;
|
||||||
border-radius: 50%;
|
aspect-ratio: 1;
|
||||||
border: 2px solid #555;
|
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;
|
object-fit: cover;
|
||||||
align-self: center;
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.npc-name {
|
.npc-name {
|
||||||
@@ -54,23 +68,19 @@
|
|||||||
/* Renamed from .options-container to match JSX */
|
/* Renamed from .options-container to match JSX */
|
||||||
.options-grid {
|
.options-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
/* grid-auto-rows: 1fr; Removed to prevent forced height expansion */
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make back button and exit button span full width if needed, or keep grid */
|
/* Make the last item span full width if it's the only one in the row (odd number of items) */
|
||||||
/* Let's make the 'Back' button span full width for better UX */
|
.options-grid>*:last-child:nth-child(odd) {
|
||||||
.options-grid>.option-btn:first-child:nth-last-child(1) {
|
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-btn {
|
.option-btn {
|
||||||
/* Base styles handled by GameButton, but we can override */
|
/* Base styles handled by GameButton, but ensure consistent height */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* height: 100%; Removed to prevent stretching */
|
|
||||||
/* Fill the grid cell */
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
<div className="npc-portrait-container">
|
<div className="npc-portrait-container">
|
||||||
<img
|
<img
|
||||||
className="npc-portrait"
|
className="npc-portrait"
|
||||||
src={npcData.image ? getAssetPath(npcData.image) : ''}
|
src={npcData.image_path || npcData.image ? getAssetPath(npcData.image_path || npcData.image) : ''}
|
||||||
alt={npcName}
|
alt={npcName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,14 +227,14 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
<div className="options-grid">
|
<div className="options-grid">
|
||||||
{/* BACK BUTTON */}
|
{/* BACK BUTTON */}
|
||||||
{(viewState === 'topic' || viewState === 'quest_preview') && (
|
{(viewState === 'topic' || viewState === 'quest_preview') && (
|
||||||
<GameButton className="option-btn" onClick={resetToGreeting}>
|
<GameButton className="option-btn" size="sm" onClick={resetToGreeting}>
|
||||||
← Back
|
← Back
|
||||||
</GameButton>
|
</GameButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* NPC TOPICS */}
|
{/* NPC TOPICS */}
|
||||||
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
|
{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)}
|
💬 {getLocalized(topic.title)}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
))}
|
))}
|
||||||
@@ -244,6 +244,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
<GameButton
|
<GameButton
|
||||||
key={q.quest_id}
|
key={q.quest_id}
|
||||||
className="option-btn quest-btn"
|
className="option-btn quest-btn"
|
||||||
|
size="sm"
|
||||||
onClick={() => handleQuestClick(q)}
|
onClick={() => handleQuestClick(q)}
|
||||||
variant={q.status === 'active' ? 'warning' : 'info'}
|
variant={q.status === 'active' ? 'warning' : 'info'}
|
||||||
>
|
>
|
||||||
@@ -254,7 +255,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
{/* CONFIRM QUEST ACTION */}
|
{/* CONFIRM QUEST ACTION */}
|
||||||
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
|
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
|
||||||
<div style={{ gridColumn: 'span 2' }}>
|
<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
|
Accept Quest
|
||||||
</GameButton>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,6 +265,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
<div style={{ gridColumn: 'span 2' }}>
|
<div style={{ gridColumn: 'span 2' }}>
|
||||||
<GameButton
|
<GameButton
|
||||||
className="option-btn action-btn"
|
className="option-btn action-btn"
|
||||||
|
size="sm"
|
||||||
variant="warning"
|
variant="warning"
|
||||||
onClick={handInQuest}
|
onClick={handInQuest}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -278,14 +280,14 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
|
|
||||||
{/* TRADE - Only show in greeting */}
|
{/* TRADE - Only show in greeting */}
|
||||||
{viewState === 'greeting' && npcData.trade?.enabled && (
|
{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
|
💰 Trade
|
||||||
</GameButton>
|
</GameButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* EXIT - Span full width */}
|
{/* EXIT - Span full width */}
|
||||||
{viewState === 'greeting' && (
|
{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
|
Goodbye
|
||||||
</GameButton>
|
</GameButton>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
clip-path: var(--game-clip-path-sm);
|
clip-path: var(--game-clip-path);
|
||||||
}
|
}
|
||||||
|
|
||||||
.backpack-status.active {
|
.backpack-status.active {
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--game-bg-app);
|
background-color: var(--game-bg-panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-search-container {
|
.game-search-container {
|
||||||
@@ -308,7 +308,7 @@
|
|||||||
/* Grid View Layout */
|
/* Grid View Layout */
|
||||||
.items-container.grid {
|
.items-container.grid {
|
||||||
display: 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;
|
grid-auto-rows: max-content;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -386,7 +386,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-image-section.small {
|
.item-image-section.small {
|
||||||
|
|||||||
@@ -1,4 +1,32 @@
|
|||||||
/* Grid View Styles */
|
/* 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 {
|
.entity-list.grid-view {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
@@ -190,6 +218,14 @@
|
|||||||
border-color: rgba(76, 175, 80, 1);
|
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 {
|
.corpse-details-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -318,4 +354,21 @@
|
|||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: #fff;
|
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 */
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import { getAssetPath } from '../../utils/assetPath'
|
|||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
import { DialogModal } from './DialogModal'
|
import { DialogModal } from './DialogModal'
|
||||||
import { TradeModal } from './TradeModal'
|
import { TradeModal } from './TradeModal'
|
||||||
|
import { ItemTooltipContent } from '../common/ItemTooltipContent'
|
||||||
import './LocationView.css'
|
import './LocationView.css'
|
||||||
|
|
||||||
interface LocationViewProps {
|
interface LocationViewProps {
|
||||||
@@ -17,7 +18,7 @@ interface LocationViewProps {
|
|||||||
playerState: PlayerState | null
|
playerState: PlayerState | null
|
||||||
combatState: CombatState | null
|
combatState: CombatState | null
|
||||||
message: string
|
message: string
|
||||||
locationMessages: Array<{ time: string; message: string }>
|
locationMessages: Array<{ time: string; message: string; location_name?: string }>
|
||||||
expandedCorpse: string | null
|
expandedCorpse: string | null
|
||||||
corpseDetails: any
|
corpseDetails: any
|
||||||
mobileMenuOpen: string
|
mobileMenuOpen: string
|
||||||
@@ -49,7 +50,7 @@ interface LocationViewProps {
|
|||||||
onSetCraftCategoryFilter: (category: string) => void
|
onSetCraftCategoryFilter: (category: string) => void
|
||||||
onCraft: (itemId: number) => void
|
onCraft: (itemId: number) => void
|
||||||
onRepair: (uniqueItemId: string, inventoryId: 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
|
failedActionItemId: string | number | null
|
||||||
quests: { active: any[], available: any[] }
|
quests: { active: any[], available: any[] }
|
||||||
}
|
}
|
||||||
@@ -207,22 +208,6 @@ function LocationView({
|
|||||||
return (
|
return (
|
||||||
<div className="location-view">
|
<div className="location-view">
|
||||||
<div className="location-info">
|
<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 && (
|
{location.tags && location.tags.length > 0 && (
|
||||||
<div className="location-tags">
|
<div className="location-tags">
|
||||||
{location.tags.map((tag: string, i: number) => {
|
{location.tags.map((tag: string, i: number) => {
|
||||||
@@ -232,15 +217,24 @@ function LocationView({
|
|||||||
else if (tag === 'repair_station' && onOpenRepair) onOpenRepair()
|
else if (tag === 'repair_station' && onOpenRepair) onOpenRepair()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Use GameButton for workbench and repair_station
|
||||||
<GameTooltip key={i} content={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}>
|
if (isClickable) {
|
||||||
<span
|
return (
|
||||||
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
|
<GameButton
|
||||||
onClick={isClickable ? handleClick : undefined}
|
key={i}
|
||||||
style={isClickable ? { cursor: 'pointer' } : undefined}
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{tag === 'workbench' && t('tags.workbench')}
|
{tag === 'workbench' ? t('tags.workbench') : t('tags.repairStation')}
|
||||||
{tag === 'repair_station' && 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 === 'safe_zone' && t('tags.safeZone')}
|
||||||
{tag === 'shop' && t('tags.shop')}
|
{tag === 'shop' && t('tags.shop')}
|
||||||
{tag === 'shelter' && t('tags.shelter')}
|
{tag === 'shelter' && t('tags.shelter')}
|
||||||
@@ -279,406 +273,508 @@ function LocationView({
|
|||||||
{locationMessages.slice(-10).reverse().map((msg, idx) => (
|
{locationMessages.slice(-10).reverse().map((msg, idx) => (
|
||||||
<div key={idx} className="location-message-item">
|
<div key={idx} className="location-message-item">
|
||||||
<span className="message-time">{msg.time}</span>
|
<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>
|
</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' : ''}`}>
|
<div className={`ground-entities mobile-menu-panel bottom ${mobileMenuOpen === 'bottom' ? 'open' : ''}`}>
|
||||||
{/* Enemies */}
|
{/* Combined Entities Container for Grid Layout */}
|
||||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
|
<div className="entities-container">
|
||||||
<div className="entity-section enemies-section">
|
{/* Enemies */}
|
||||||
<h3>{t('location.enemies')}</h3>
|
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
|
||||||
<div className="entity-list grid-view">
|
<div className="entity-section enemies-section">
|
||||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => {
|
<h3>{t('location.enemies')}</h3>
|
||||||
const id = `enemy-${enemy.id || i}`;
|
<div className="entity-list grid-view">
|
||||||
return (
|
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any) => {
|
||||||
<div key={i} className="entity-card enemy-card grid-card"
|
const isShaking = failedActionItemId == enemy.id;
|
||||||
onClick={(e) => handleDropdownClick(e, id)}>
|
const id = `enemy-${enemy.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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<GameTooltip content={
|
// Only render if valid
|
||||||
<div>
|
if (!enemy || !enemy.id) return null;
|
||||||
<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>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className={`entity-card item-card ${isShaking ? 'shake' : ''} grid-card`}
|
<div key={enemy.id} className={`entity-card enemy-card grid-card ${isShaking ? 'shake-animation' : ''}`}
|
||||||
onClick={(e) => handleDropdownClick(e, itemId)}
|
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={
|
<GameTooltip content={
|
||||||
<div className="item-info-tooltip-content">
|
<div>
|
||||||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
|
||||||
{item.weight !== undefined && item.weight > 0 && (
|
<div>{t('location.level')} {enemy.level}</div>
|
||||||
<div className="item-tooltip-stat">
|
<div style={{ color: '#f56565', fontSize: '0.8rem' }}>Click for actions</div>
|
||||||
⚖️ {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>
|
||||||
}>
|
}>
|
||||||
<div className="entity-content-wrapper grid-content">
|
<div className="grid-overlay"></div>
|
||||||
{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>
|
|
||||||
</GameTooltip>
|
</GameTooltip>
|
||||||
|
|
||||||
{activeDropdown === itemId && (
|
{/* Dropdown for Grid View */}
|
||||||
|
{activeDropdown === id && (
|
||||||
<GameDropdown
|
<GameDropdown
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
onClose={() => setActiveDropdown(null)}
|
onClose={() => setActiveDropdown(null)}
|
||||||
width="160px"
|
width="160px"
|
||||||
>
|
>
|
||||||
<div className="game-dropdown-header">{getTranslatedText(item.name)}</div>
|
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
|
||||||
<div className="pickup-options">
|
<GameButton
|
||||||
{renderPickupOptions()}
|
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>
|
</div>
|
||||||
</GameDropdown>
|
</GameDropdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Other Players */}
|
{/* Corpses */}
|
||||||
{location.other_players && location.other_players.length > 0 && (
|
{location.corpses && location.corpses.length > 0 && (
|
||||||
<div className="entity-section players-section">
|
<div className="entity-section corpses-section">
|
||||||
<h3>👥 Other Players</h3>
|
<h3>{t('location.corpses')}</h3>
|
||||||
<div className="entity-list">
|
<div className="entity-list grid-view">
|
||||||
{location.other_players.map((player: any, i: number) => (
|
{location.corpses.map((corpse: any) => (
|
||||||
<div key={i} className="entity-card player-card">
|
<div key={corpse.id} className="corpse-container">
|
||||||
<span className="entity-icon">🧍</span>
|
<div className="entity-card corpse-card grid-card"
|
||||||
<div className="entity-info">
|
onClick={(e) => handleDropdownClick(e, `corpse-${corpse.id}`)}
|
||||||
<div className="entity-name">{player.name || player.username}</div>
|
>
|
||||||
<div className="entity-level">Lv. {player.level}</div>
|
<GameTooltip content={
|
||||||
{player.level_diff !== undefined && (
|
<div>
|
||||||
<div className="level-diff">
|
<div className="tooltip-title">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
|
||||||
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
|
<div>{corpse.loot_count} {t('location.items')}</div>
|
||||||
</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>
|
</div>
|
||||||
{player.can_pvp && (
|
))}
|
||||||
<GameTooltip content={`Attack ${player.name || player.username}`}>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Corpse Loot Overlay Modal */}
|
{/* Corpse Loot Overlay Modal */}
|
||||||
{expandedCorpse && corpseDetails && corpseDetails.loot_items && (
|
{
|
||||||
<div className="corpse-loot-overlay" onClick={() => onSetExpandedCorpse(null)}>
|
expandedCorpse && corpseDetails && corpseDetails.loot_items && (
|
||||||
<div className="corpse-loot-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="corpse-loot-overlay" onClick={() => onSetExpandedCorpse(null)}>
|
||||||
<div className="corpse-details-header">
|
<div className="corpse-loot-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h4>{t('location.lootableItems')}</h4>
|
<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
|
<button
|
||||||
className="close-btn"
|
className="loot-all-btn"
|
||||||
onClick={() => {
|
onClick={() => onLootCorpseItem(expandedCorpse, null)}
|
||||||
onSetExpandedCorpse(null)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
✕
|
📦 {t('common.lootAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
)
|
||||||
)}
|
}
|
||||||
|
|
||||||
|
|
||||||
{(showCraftingMenu || showRepairMenu) && (
|
{
|
||||||
<Workbench
|
(showCraftingMenu || showRepairMenu) && (
|
||||||
showCraftingMenu={showCraftingMenu}
|
<Workbench
|
||||||
showRepairMenu={showRepairMenu}
|
showCraftingMenu={showCraftingMenu}
|
||||||
workbenchTab={workbenchTab}
|
showRepairMenu={showRepairMenu}
|
||||||
craftableItems={craftableItems}
|
workbenchTab={workbenchTab}
|
||||||
repairableItems={repairableItems}
|
craftableItems={craftableItems}
|
||||||
uncraftableItems={uncraftableItems}
|
repairableItems={repairableItems}
|
||||||
craftFilter={craftFilter}
|
uncraftableItems={uncraftableItems}
|
||||||
repairFilter={repairFilter}
|
craftFilter={craftFilter}
|
||||||
uncraftFilter={uncraftFilter}
|
repairFilter={repairFilter}
|
||||||
craftCategoryFilter={craftCategoryFilter}
|
uncraftFilter={uncraftFilter}
|
||||||
profile={profile}
|
craftCategoryFilter={craftCategoryFilter}
|
||||||
onCloseCrafting={onCloseCrafting}
|
profile={profile}
|
||||||
onSwitchTab={onSwitchWorkbenchTab}
|
onCloseCrafting={onCloseCrafting}
|
||||||
onSetCraftFilter={onSetCraftFilter}
|
onSwitchTab={onSwitchWorkbenchTab}
|
||||||
onSetRepairFilter={onSetRepairFilter}
|
onSetCraftFilter={onSetCraftFilter}
|
||||||
onSetUncraftFilter={onSetUncraftFilter}
|
onSetRepairFilter={onSetRepairFilter}
|
||||||
onSetCraftCategoryFilter={onSetCraftCategoryFilter}
|
onSetUncraftFilter={onSetUncraftFilter}
|
||||||
onCraft={onCraft}
|
onSetCraftCategoryFilter={onSetCraftCategoryFilter}
|
||||||
onRepair={onRepair}
|
onCraft={onCraft}
|
||||||
onUncraft={onUncraft}
|
onRepair={onRepair}
|
||||||
/>
|
onUncraft={onUncraft}
|
||||||
)}
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{activeDialogNpc && activeNpcData && (
|
{
|
||||||
<DialogModal
|
activeDialogNpc && activeNpcData && (
|
||||||
npcId={activeDialogNpc}
|
<DialogModal
|
||||||
npcData={activeNpcData}
|
npcId={activeDialogNpc}
|
||||||
onClose={() => setActiveDialogNpc(null)}
|
npcData={activeNpcData}
|
||||||
onTrade={() => {
|
onClose={() => setActiveDialogNpc(null)}
|
||||||
setActiveDialogNpc(null);
|
onTrade={() => {
|
||||||
setShowTradeModal(true);
|
setActiveDialogNpc(null);
|
||||||
}}
|
setShowTradeModal(true);
|
||||||
/>
|
}}
|
||||||
)}
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{showTradeModal && activeNpcData && (
|
{
|
||||||
<TradeModal
|
showTradeModal && activeNpcData && (
|
||||||
npcId={activeNpcData.id}
|
<TradeModal
|
||||||
onClose={() => setShowTradeModal(false)}
|
npcId={activeNpcData.id}
|
||||||
/>
|
onClose={() => setShowTradeModal(false)}
|
||||||
)}
|
/>
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
</div >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
import { GameTooltip } from '../common/GameTooltip'
|
import { GameTooltip } from '../common/GameTooltip'
|
||||||
|
import { GameButton } from '../common/GameButton'
|
||||||
|
|
||||||
interface MovementControlsProps {
|
interface MovementControlsProps {
|
||||||
location: Location
|
location: Location
|
||||||
@@ -281,16 +282,19 @@ function MovementControls({
|
|||||||
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
|
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
|
||||||
: getTranslatedText(action.description)
|
: getTranslatedText(action.description)
|
||||||
}>
|
}>
|
||||||
<button
|
<GameButton
|
||||||
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
|
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
|
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
|
||||||
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
|
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
|
||||||
|
style={{ width: '100%', justifyContent: 'space-between' }}
|
||||||
>
|
>
|
||||||
{getTranslatedText(action.name)}
|
{getTranslatedText(action.name)}
|
||||||
<span className="stamina-cost">
|
<span className="stamina-cost">
|
||||||
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${staminaCost}`}
|
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${staminaCost}`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</GameButton>
|
||||||
</GameTooltip>
|
</GameTooltip>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ function PlayerSidebar({
|
|||||||
<GameButton
|
<GameButton
|
||||||
className="open-inventory-btn"
|
className="open-inventory-btn"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="md"
|
size="sm"
|
||||||
onClick={() => setShowInventory(true)}
|
onClick={() => setShowInventory(true)}
|
||||||
style={{ width: '100%', justifyContent: 'center' }}
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
@@ -306,7 +306,7 @@ function PlayerSidebar({
|
|||||||
<GameButton
|
<GameButton
|
||||||
className="quest-journal-btn"
|
className="quest-journal-btn"
|
||||||
variant="secondary" // Different color as requested
|
variant="secondary" // Different color as requested
|
||||||
size="md"
|
size="sm"
|
||||||
onClick={onOpenQuestJournal}
|
onClick={onOpenQuestJournal}
|
||||||
style={{ width: '100%', justifyContent: 'center' }}
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--game-border-color);
|
border-right: 1px solid var(--game-border-color);
|
||||||
background: var(--game-bg-app);
|
background: var(--game-bg-panel);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--game-bg-card);
|
background: #ffffff08;
|
||||||
border: 1px solid var(--game-border-color);
|
border: 1px solid var(--game-border-color);
|
||||||
clip-path: var(--game-clip-path);
|
clip-path: var(--game-clip-path);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -485,7 +485,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
padding: 10px;
|
background: var(--game-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
@@ -501,8 +501,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-requirements {
|
.detail-requirements {
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
clip-path: var(--game-clip-path);
|
clip-path: var(--game-clip-path);
|
||||||
@@ -619,4 +617,23 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: middle;
|
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;
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import type { Profile, WorkbenchTab } from './types'
|
import type { Profile, WorkbenchTab } from './types'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
|
import { GameButton } from '../common/GameButton'
|
||||||
import './Workbench.css'
|
import './Workbench.css'
|
||||||
|
|
||||||
interface WorkbenchProps {
|
interface WorkbenchProps {
|
||||||
@@ -25,7 +26,7 @@ interface WorkbenchProps {
|
|||||||
onSetCraftCategoryFilter: (category: string) => void
|
onSetCraftCategoryFilter: (category: string) => void
|
||||||
onCraft: (itemId: number) => void
|
onCraft: (itemId: number) => void
|
||||||
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
||||||
onUncraft: (uniqueItemId: string, inventoryId: number) => void
|
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function Workbench({
|
function Workbench({
|
||||||
@@ -53,12 +54,19 @@ function Workbench({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [selectedItem, setSelectedItem] = useState<any>(null)
|
const [selectedItem, setSelectedItem] = useState<any>(null)
|
||||||
|
const [salvageQuantity, setSalvageQuantity] = useState<number>(1)
|
||||||
|
|
||||||
// Reset selection when tab changes
|
// Reset selection when tab changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
|
setSalvageQuantity(1)
|
||||||
}, [workbenchTab])
|
}, [workbenchTab])
|
||||||
|
|
||||||
|
// Reset quantity when selected item changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSalvageQuantity(1)
|
||||||
|
}, [selectedItem])
|
||||||
|
|
||||||
// Update selectedItem when items list changes (after repair/craft/salvage)
|
// Update selectedItem when items list changes (after repair/craft/salvage)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
@@ -243,22 +251,23 @@ function Workbench({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<button
|
{!item.meets_level && (
|
||||||
className="craft-btn"
|
<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)}
|
disabled={!item.can_craft || (profile?.stamina || 0) < (item.stamina_cost || 1)}
|
||||||
onClick={() => onCraft(item.item_id)}
|
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>
|
{t('crafting.craftItem')}
|
||||||
{!item.meets_level ? t('crafting.levelRequired', { level: item.craft_level }) :
|
</GameButton>
|
||||||
!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>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -324,22 +333,23 @@ function Workbench({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<button
|
{!item.needs_repair && (
|
||||||
className="repair-btn"
|
<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)}
|
disabled={!item.can_repair || (profile?.stamina || 0) < (item.stamina_cost || 1)}
|
||||||
onClick={() => onRepair(item.unique_item_id, item.inventory_id)}
|
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>
|
{t('crafting.repairItem')}
|
||||||
{!item.needs_repair ? t('crafting.alreadyFull') :
|
</GameButton>
|
||||||
!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>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -403,21 +413,49 @@ function Workbench({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<button
|
<div className="action-info">{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}</div>
|
||||||
className="uncraft-btn"
|
|
||||||
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
|
{/* 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={() => {
|
onClick={() => {
|
||||||
if (window.confirm(t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }))) {
|
const confirmMsg = t('crafting.confirmSalvage', { name: getTranslatedText(item.name) })
|
||||||
onUncraft(item.unique_item_id, item.inventory_id)
|
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>
|
♻️ {t('game.salvage')} {item.quantity > 1 ? `(x${salvageQuantity})` : ''}
|
||||||
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
</GameButton>
|
||||||
{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../../../services/api'
|
import api from '../../../services/api'
|
||||||
import { useAudio } from '../../../contexts/AudioContext'
|
import { useAudio } from '../../../contexts/AudioContext'
|
||||||
|
import { getTranslatedText } from '../../../utils/i18nUtils'
|
||||||
import type {
|
import type {
|
||||||
PlayerState,
|
PlayerState,
|
||||||
Location,
|
Location,
|
||||||
@@ -84,7 +85,7 @@ export interface GameEngineActions {
|
|||||||
handleUseItem: (itemId: string) => Promise<void>
|
handleUseItem: (itemId: string) => Promise<void>
|
||||||
handleEquipItem: (inventoryId: number) => Promise<void>
|
handleEquipItem: (inventoryId: number) => Promise<void>
|
||||||
handleUnequipItem: (slot: string) => 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
|
// Crafting/Workbench
|
||||||
handleOpenCrafting: () => Promise<void>
|
handleOpenCrafting: () => Promise<void>
|
||||||
@@ -92,7 +93,7 @@ export interface GameEngineActions {
|
|||||||
handleCraft: (itemId: string) => Promise<void>
|
handleCraft: (itemId: string) => Promise<void>
|
||||||
handleOpenRepair: () => Promise<void>
|
handleOpenRepair: () => Promise<void>
|
||||||
handleRepairFromMenu: (uniqueItemId: number, inventoryId?: number) => 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>
|
handleSwitchWorkbenchTab: (tab: WorkbenchTab) => Promise<void>
|
||||||
|
|
||||||
// Combat
|
// Combat
|
||||||
@@ -189,7 +190,14 @@ export function useGameEngine(
|
|||||||
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
|
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
|
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
|
||||||
const [mobileHeaderOpen, setMobileHeaderOpen] = useState<boolean>(false)
|
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 [interactableCooldowns, setInteractableCooldowns] = useState<Record<string, number>>({})
|
||||||
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
|
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
|
||||||
const [_forceUpdate, _setForceUpdate] = useState(0)
|
const [_forceUpdate, _setForceUpdate] = useState(0)
|
||||||
@@ -226,9 +234,22 @@ export function useGameEngine(
|
|||||||
const addLocationMessage = useCallback((msg: string) => {
|
const addLocationMessage = useCallback((msg: string) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
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)
|
setMessage(msg)
|
||||||
}, [])
|
}, [location])
|
||||||
|
|
||||||
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
|
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
|
||||||
setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev])
|
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 }
|
const newCombatState = { ...pvpRes.data, is_pvp: true }
|
||||||
setCombatState(newCombatState)
|
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 &&
|
if (pvpRes.data.pvp_combat.last_action &&
|
||||||
pvpRes.data.pvp_combat.last_action !== lastSeenPvPActionRef.current) {
|
pvpRes.data.pvp_combat.last_action !== lastSeenPvPActionRef.current) {
|
||||||
|
|
||||||
@@ -404,12 +436,42 @@ export function useGameEngine(
|
|||||||
|
|
||||||
setMobileMenuOpen('none')
|
setMobileMenuOpen('none')
|
||||||
|
|
||||||
|
// Capture old location name before moving (translated)
|
||||||
|
const oldLocationName = location?.name ? getTranslatedText(location.name) : ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setMessage('Moving...')
|
setMessage('Moving...')
|
||||||
const response = await api.post('/api/game/move', { direction })
|
const response = await api.post('/api/game/move', { direction })
|
||||||
setMessage(response.data.message)
|
setMessage(response.data.message)
|
||||||
playSfx('/audio/sfx/step.wav')
|
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) {
|
if (response.data.encounter && response.data.encounter.triggered) {
|
||||||
const encounter = response.data.encounter
|
const encounter = response.data.encounter
|
||||||
@@ -441,7 +503,7 @@ export function useGameEngine(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setMessage(error.response?.data?.detail || 'Move failed')
|
setMessage(error.response?.data?.detail || 'Move failed')
|
||||||
}
|
}
|
||||||
}, [combatState, showCraftingMenu, showRepairMenu, fetchGameData])
|
}, [combatState, showCraftingMenu, showRepairMenu, fetchGameData, location, t, addLocationMessage])
|
||||||
|
|
||||||
// Simplified placeholder handlers
|
// Simplified placeholder handlers
|
||||||
// (Full implementations would be moved from Game.tsx)
|
// (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 {
|
try {
|
||||||
setMessage(`Dropping ${quantity} item(s)...`)
|
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!'
|
const msg = response.data.message || 'Item dropped!'
|
||||||
addLocationMessage(msg)
|
addLocationMessage(msg)
|
||||||
fetchGameData()
|
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 {
|
try {
|
||||||
// setMessage('Salvaging...')
|
// setMessage('Salvaging...')
|
||||||
const response = await api.post('/api/game/uncraft_item', {
|
const response = await api.post('/api/game/uncraft_item', {
|
||||||
unique_item_id: uniqueItemId,
|
unique_item_id: uniqueItemId,
|
||||||
inventory_id: inventoryId
|
inventory_id: inventoryId,
|
||||||
|
quantity: quantity
|
||||||
})
|
})
|
||||||
const data = response.data
|
const data = response.data
|
||||||
let msg = data.message || 'Item salvaged!'
|
let msg = data.message || 'Item salvaged!'
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface CombatLogEntry {
|
|||||||
export interface LocationMessage {
|
export interface LocationMessage {
|
||||||
time: string
|
time: string
|
||||||
message: string
|
message: string
|
||||||
|
location_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Equipment {
|
export interface Equipment {
|
||||||
|
|||||||
@@ -24,3 +24,8 @@ export const useGame = () => {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optional hook that doesn't throw when outside GameProvider
|
||||||
|
export const useOptionalGame = () => {
|
||||||
|
return useContext(GameContext);
|
||||||
|
};
|
||||||
|
|||||||
@@ -102,7 +102,9 @@
|
|||||||
"itemsOnGround": "📦 Items on Ground",
|
"itemsOnGround": "📦 Items on Ground",
|
||||||
"lootableItems": "Lootable Items:",
|
"lootableItems": "Lootable Items:",
|
||||||
"items": "item(s)",
|
"items": "item(s)",
|
||||||
"level": "Lv."
|
"level": "Lv.",
|
||||||
|
"movedTo": "You moved from {{from}} to {{to}}, spending {{stamina}} stamina",
|
||||||
|
"noActivity": "No recent activity"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"workbench": "🔧 Workbench",
|
"workbench": "🔧 Workbench",
|
||||||
@@ -115,6 +117,13 @@
|
|||||||
"water": "💧 Water",
|
"water": "💧 Water",
|
||||||
"food": "🍎 Food"
|
"food": "🍎 Food"
|
||||||
},
|
},
|
||||||
|
"danger": {
|
||||||
|
"safe": "Safe Zone",
|
||||||
|
"low": "Low Danger",
|
||||||
|
"medium": "Medium Danger",
|
||||||
|
"high": "High Danger",
|
||||||
|
"extreme": "Extreme Danger"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"hp": "❤️ HP",
|
"hp": "❤️ HP",
|
||||||
"maxHp": "❤️ Max HP",
|
"maxHp": "❤️ Max HP",
|
||||||
|
|||||||
@@ -100,7 +100,9 @@
|
|||||||
"itemsOnGround": "📦 Objetos en el Suelo",
|
"itemsOnGround": "📦 Objetos en el Suelo",
|
||||||
"lootableItems": "Objetos Saqueables:",
|
"lootableItems": "Objetos Saqueables:",
|
||||||
"items": "objeto(s)",
|
"items": "objeto(s)",
|
||||||
"level": "Nv."
|
"level": "Nv.",
|
||||||
|
"movedTo": "Te has movido de {{from}} a {{to}}, gastando {{stamina}} de aguante",
|
||||||
|
"noActivity": "Sin actividad reciente"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"workbench": "🔧 Banco de Trabajo",
|
"workbench": "🔧 Banco de Trabajo",
|
||||||
@@ -113,6 +115,13 @@
|
|||||||
"water": "💧 Agua",
|
"water": "💧 Agua",
|
||||||
"food": "🍎 Comida"
|
"food": "🍎 Comida"
|
||||||
},
|
},
|
||||||
|
"danger": {
|
||||||
|
"safe": "Zona Segura",
|
||||||
|
"low": "Peligro Bajo",
|
||||||
|
"medium": "Peligro Medio",
|
||||||
|
"high": "Peligro Alto",
|
||||||
|
"extreme": "Peligro Extremo"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"hp": "❤️ Vida",
|
"hp": "❤️ Vida",
|
||||||
"maxHp": "❤️ Vida Máx.",
|
"maxHp": "❤️ Vida Máx.",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
/* Item slots */
|
/* Item slots */
|
||||||
--game-bg-slot-hover: rgba(255, 255, 255, 0.1);
|
--game-bg-slot-hover: rgba(255, 255, 255, 0.1);
|
||||||
--game-bg-tooltip: #151515;
|
--game-bg-tooltip: #151515;
|
||||||
|
--game-bg-card: #050505;
|
||||||
|
|
||||||
/* --- Borders & Separators --- */
|
/* --- Borders & Separators --- */
|
||||||
--game-border-color: rgba(255, 255, 255, 0.12);
|
--game-border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Resolves asset paths based on runtime environment:
|
* Resolves asset paths based on runtime environment:
|
||||||
* - Electron: Returns relative path (assets bundled with app, using ./)
|
* - 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
|
// Detect if running in Electron - check at runtime, not module load time
|
||||||
@@ -12,10 +12,6 @@ function checkIsElectron(): boolean {
|
|||||||
window.location.protocol === 'file:'
|
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
|
* Resolves an asset path for the current environment
|
||||||
* @param path - The asset path (e.g., "images/items/knife.webp" or "/images/items/knife.webp")
|
* @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}`
|
return `./${cleanPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// In browser, prepend the server URL
|
// In browser, use absolute path - PWA nginx serves images at /images/
|
||||||
return `${ASSET_BASE_URL}/${cleanPath}`
|
return `/${cleanPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||