chore: save progress before layout changes
@@ -375,6 +375,7 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
|
||||
|
||||
class UncraftItemRequest(BaseModel):
|
||||
inventory_id: int
|
||||
quantity: int = 1
|
||||
|
||||
|
||||
@router.post("/api/game/uncraft_item")
|
||||
@@ -403,6 +404,14 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
# Check quantity
|
||||
if request.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="Quantity must be greater than 0")
|
||||
|
||||
current_quantity = inv_item.get('quantity', 1)
|
||||
if request.quantity > current_quantity:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough items. Have {current_quantity}, requested {request.quantity}")
|
||||
|
||||
# Get item definition
|
||||
item_def = ITEMS_MANAGER.items.get(inv_item['item_id'])
|
||||
if not item_def:
|
||||
@@ -415,29 +424,50 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
if not uncraft_yield:
|
||||
raise HTTPException(status_code=400, detail="No uncraft recipe found")
|
||||
|
||||
# Check tools requirement
|
||||
# Check tools requirement (once per operation? or per item?)
|
||||
# Usually tools are checked once for the operation, but durability cost might be per item.
|
||||
# Logic above for crafting consumes tool durability for the batch?
|
||||
# In craft_item above, it loops through craft_tools but seemingly only once?
|
||||
# Wait, craft_item does NOT loop for quantity because craft_item only crafts 1 at a time (request has no quantity).
|
||||
|
||||
# For uncrafting multiple, we should multiply tool cost.
|
||||
uncraft_tools = getattr(item_def, 'uncraft_tools', [])
|
||||
tools_consumed = []
|
||||
|
||||
if uncraft_tools:
|
||||
success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], uncraft_tools, inventory)
|
||||
# Scale tool cost by quantity
|
||||
scaled_uncraft_tools = []
|
||||
for tool_req in uncraft_tools:
|
||||
scaled_req = tool_req.copy()
|
||||
scaled_req['durability_cost'] = tool_req['durability_cost'] * request.quantity
|
||||
scaled_uncraft_tools.append(scaled_req)
|
||||
|
||||
success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], scaled_uncraft_tools, inventory)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
else:
|
||||
tools_consumed = []
|
||||
|
||||
# Calculate stamina cost
|
||||
stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
||||
base_stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
||||
total_stamina_cost = base_stamina_cost * request.quantity
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < stamina_cost:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
|
||||
if player['stamina'] < total_stamina_cost:
|
||||
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {total_stamina_cost}, have {player['stamina']}")
|
||||
|
||||
# Deduct stamina
|
||||
new_stamina = max(0, player['stamina'] - stamina_cost)
|
||||
new_stamina = max(0, player['stamina'] - total_stamina_cost)
|
||||
await db.update_player_stamina(current_user['id'], new_stamina)
|
||||
|
||||
# Remove the item from inventory
|
||||
# Use remove_inventory_row since we have the inventory ID
|
||||
await db.remove_inventory_row(inv_item['id'])
|
||||
# Update inventory item
|
||||
if request.quantity == current_quantity:
|
||||
# Remove the item row entirely
|
||||
await db.remove_inventory_row(inv_item['id'])
|
||||
else:
|
||||
# Update quantity
|
||||
await db.update_inventory_item(
|
||||
inv_item['id'],
|
||||
quantity=current_quantity - request.quantity
|
||||
)
|
||||
|
||||
# Calculate durability ratio for yield reduction
|
||||
durability_ratio = 1.0 # Default: full yield
|
||||
@@ -449,96 +479,120 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
if max_durability > 0:
|
||||
durability_ratio = current_durability / max_durability
|
||||
|
||||
# Re-fetch inventory to get updated capacity after removing the item
|
||||
# Re-fetch inventory to get updated capacity
|
||||
inventory = await db.get_inventory(current_user['id'])
|
||||
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||
|
||||
# Calculate materials with loss chance and durability reduction
|
||||
# Calculate materials
|
||||
import random
|
||||
loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3)
|
||||
yield_info = {
|
||||
'base_yield': uncraft_yield,
|
||||
'loss_chance': loss_chance,
|
||||
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
|
||||
}
|
||||
|
||||
materials_yielded_dict = {}
|
||||
materials_lost_dict = {}
|
||||
materials_dropped_dict = {}
|
||||
|
||||
# Loop for each item being uncrafted to calculate yield fairly
|
||||
for _ in range(request.quantity):
|
||||
for material in uncraft_yield:
|
||||
# Apply durability reduction first
|
||||
base_quantity = material['quantity']
|
||||
|
||||
# Calculate adjusted quantity based on durability
|
||||
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
||||
|
||||
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||
mat_name = mat_def.name if mat_def else material['item_id']
|
||||
|
||||
loss_key = (material['item_id'], mat_name)
|
||||
|
||||
# If durability is too low (< 10%), yield nothing for this material
|
||||
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
||||
if loss_key not in materials_lost_dict:
|
||||
materials_lost_dict[loss_key] = 0
|
||||
materials_lost_dict[loss_key] += base_quantity
|
||||
continue
|
||||
|
||||
# Roll for loss chance
|
||||
if random.random() < loss_chance:
|
||||
# Lost this material
|
||||
if loss_key not in materials_lost_dict:
|
||||
materials_lost_dict[loss_key] = 0
|
||||
materials_lost_dict[loss_key] += adjusted_quantity
|
||||
else:
|
||||
# Check if it fits in inventory (incremental check?)
|
||||
# For simplicity, check per unit or accumulate and check at end.
|
||||
# Checking per unit is safer but slower.
|
||||
# Since we are modifying inventory in loop (potentially), we should be careful.
|
||||
# Actually, we should accumulate yield then add to inventory at end to optimize DB calls?
|
||||
# But we need to check capacity.
|
||||
# Let's accumulate pending yield.
|
||||
|
||||
yield_key = (material['item_id'], mat_name, mat_def.emoji if mat_def else '📦', mat_def)
|
||||
if yield_key not in materials_yielded_dict:
|
||||
materials_yielded_dict[yield_key] = 0
|
||||
materials_yielded_dict[yield_key] += adjusted_quantity
|
||||
|
||||
# Now process the accumulated yield
|
||||
materials_yielded = []
|
||||
materials_lost = []
|
||||
materials_dropped = []
|
||||
|
||||
for material in uncraft_yield:
|
||||
# Apply durability reduction first
|
||||
base_quantity = material['quantity']
|
||||
# Convert lost dict to list
|
||||
for (item_id, name), qty in materials_lost_dict.items():
|
||||
materials_lost.append({
|
||||
'item_id': item_id,
|
||||
'name': name,
|
||||
'quantity': qty,
|
||||
'reason': 'lost_or_low_durability'
|
||||
})
|
||||
|
||||
# Calculate adjusted quantity based on durability
|
||||
# Use round() to ensure minimum yield of 1 for high durability items (e.g. 90% of 1 = 0.9 -> 1)
|
||||
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
||||
# Process yield
|
||||
for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items():
|
||||
mat_weight = getattr(mat_def, 'weight', 0) * qty
|
||||
mat_volume = getattr(mat_def, 'volume', 0) * qty
|
||||
|
||||
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||
# Simple check against capacity (assuming current_weight was just updated from DB)
|
||||
# Note: we might fill up mid-loop. ideally we add one by one or check total.
|
||||
# Let's check total.
|
||||
|
||||
# If durability is too low (< 10%), yield nothing for this material
|
||||
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
||||
materials_lost.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'quantity': base_quantity,
|
||||
'reason': 'durability_too_low'
|
||||
})
|
||||
continue
|
||||
if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
|
||||
# Fits
|
||||
await db.add_item_to_inventory(
|
||||
player_id=current_user['id'],
|
||||
item_id=item_id,
|
||||
quantity=qty
|
||||
)
|
||||
current_weight += mat_weight
|
||||
current_volume += mat_volume
|
||||
|
||||
# Roll for each material separately with loss chance
|
||||
if random.random() < loss_chance:
|
||||
# Lost this material
|
||||
materials_lost.append({
|
||||
'item_id': material['item_id'],
|
||||
'name': mat_def.name if mat_def else material['item_id'],
|
||||
'quantity': adjusted_quantity,
|
||||
'reason': 'random_loss'
|
||||
materials_yielded.append({
|
||||
'item_id': item_id,
|
||||
'name': name,
|
||||
'emoji': emoji,
|
||||
'quantity': qty
|
||||
})
|
||||
else:
|
||||
# Check if it fits in inventory
|
||||
mat_weight = getattr(mat_def, 'weight', 0) * adjusted_quantity
|
||||
mat_volume = getattr(mat_def, 'volume', 0) * adjusted_quantity
|
||||
# Drop
|
||||
await db.drop_item_to_world(
|
||||
item_id=item_id,
|
||||
quantity=qty,
|
||||
location_id=player['location_id']
|
||||
)
|
||||
|
||||
if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
|
||||
# Fits in inventory
|
||||
await db.add_item_to_inventory(
|
||||
player_id=current_user['id'],
|
||||
item_id=material['item_id'],
|
||||
quantity=adjusted_quantity
|
||||
)
|
||||
materials_dropped.append({
|
||||
'item_id': item_id,
|
||||
'name': name,
|
||||
'emoji': emoji,
|
||||
'quantity': qty
|
||||
})
|
||||
|
||||
# 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:
|
||||
message += f" (Item condition reduced yield by {int((1 - durability_ratio) * 100)}%)"
|
||||
message += f" (Condition reduced yield)"
|
||||
if materials_lost:
|
||||
message += f" Lost {len(materials_lost)} material type(s)."
|
||||
message += f" Lost materials."
|
||||
if materials_dropped:
|
||||
message += f" Inventory full! Dropped {len(materials_dropped)} item(s) to the ground."
|
||||
message += f" Inventory full! Dropped items."
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -550,7 +604,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
'tools_consumed': tools_consumed,
|
||||
'loss_chance': loss_chance,
|
||||
'durability_ratio': round(durability_ratio, 2),
|
||||
'stamina_cost': stamina_cost,
|
||||
'stamina_cost': total_stamina_cost,
|
||||
'new_stamina': new_stamina
|
||||
}
|
||||
|
||||
|
||||
@@ -783,7 +783,8 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
|
||||
"name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse",
|
||||
"emoji": "💀",
|
||||
"loot_count": len(loot),
|
||||
"timestamp": corpse['death_timestamp']
|
||||
"timestamp": corpse['death_timestamp'],
|
||||
"image_path": npc_def.image_path if npc_def else None
|
||||
})
|
||||
|
||||
for corpse in player_corpses:
|
||||
@@ -957,7 +958,8 @@ async def move(
|
||||
response = {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"new_location_id": new_location_id
|
||||
"new_location_id": new_location_id,
|
||||
"new_location_name": new_location.name if new_location else "Unknown" # Add location name for frontend
|
||||
}
|
||||
|
||||
# Add encounter info if triggered
|
||||
@@ -1469,10 +1471,21 @@ async def drop_item(
|
||||
# Get inventory item by item_id (string), not database id
|
||||
inventory = await db.get_inventory(player_id)
|
||||
inv_item = None
|
||||
for item in inventory:
|
||||
if item['item_id'] == item_id:
|
||||
inv_item = item
|
||||
break
|
||||
|
||||
# If inventory_id is provided, use it to find precise item
|
||||
inventory_id = drop_req.get('inventory_id')
|
||||
|
||||
if inventory_id:
|
||||
for item in inventory:
|
||||
if item['id'] == inventory_id:
|
||||
inv_item = item
|
||||
break
|
||||
else:
|
||||
# Fallback to legacy behavior (first matching item_id)
|
||||
for item in inventory:
|
||||
if item['item_id'] == item_id:
|
||||
inv_item = item
|
||||
break
|
||||
|
||||
if not inv_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found in inventory")
|
||||
|
||||
@@ -62,7 +62,22 @@ async def get_corpse_details(
|
||||
|
||||
# Get player's inventory to check available tools
|
||||
inventory = await db.get_inventory(player['id'])
|
||||
available_tools = set([item['item_id'] for item in inventory])
|
||||
# Map item_id to max durability found in inventory for that item
|
||||
tools_durability = {}
|
||||
for item in inventory:
|
||||
item_id = item['item_id']
|
||||
durability = 0
|
||||
|
||||
# Helper to get actual durability from unique item data
|
||||
if item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(item['unique_item_id'])
|
||||
if unique_item:
|
||||
durability = unique_item.get('durability', 0)
|
||||
|
||||
if item_id not in tools_durability or durability > tools_durability[item_id]:
|
||||
tools_durability[item_id] = durability
|
||||
|
||||
available_tools = set(tools_durability.keys())
|
||||
|
||||
if corpse_type == 'npc':
|
||||
# Get NPC corpse
|
||||
@@ -80,9 +95,16 @@ async def get_corpse_details(
|
||||
loot_items = []
|
||||
for idx, loot_item in enumerate(loot_remaining):
|
||||
required_tool = loot_item.get('required_tool')
|
||||
durability_cost = loot_item.get('tool_durability_cost', 5)
|
||||
item_def = ITEMS_MANAGER.get_item(loot_item['item_id'])
|
||||
|
||||
has_tool = required_tool is None or required_tool in available_tools
|
||||
has_tool = True
|
||||
if required_tool:
|
||||
if required_tool not in tools_durability:
|
||||
has_tool = False
|
||||
elif tools_durability[required_tool] < durability_cost:
|
||||
has_tool = False
|
||||
|
||||
tool_def = ITEMS_MANAGER.get_item(required_tool) if required_tool else None
|
||||
|
||||
loot_items.append({
|
||||
|
||||
@@ -341,16 +341,16 @@
|
||||
"text": {
|
||||
"crit_failure": {
|
||||
"en": "The floor collapses beneath you! (-10 HP)",
|
||||
"es": ""
|
||||
"es": "¡El suelo se derrumba bajo ti! (-10 HP)"
|
||||
},
|
||||
"crit_success": "",
|
||||
"failure": {
|
||||
"en": "The house has already been thoroughly looted. Nothing remains.",
|
||||
"es": ""
|
||||
"es": "La casa ya ha sido despojada de todo. No queda nada."
|
||||
},
|
||||
"success": {
|
||||
"en": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!",
|
||||
"es": ""
|
||||
"en": "You find some useful supplies!",
|
||||
"es": "¡Encuentras algunos suministros útiles!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,14 +39,16 @@ for category in items locations npcs interactables characters placeholder static
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$category" == "items" || "$category" == "placeholder" || "$category" == "static_npcs" ]]; then
|
||||
if [[ "$category" == "items" || "$category" == "placeholder" || "$category" == "static_npcs" || "$category" == "npcs" ]]; then
|
||||
# Special processing for items: remove white background and resize
|
||||
echo " ➜ Converting item: $filename"
|
||||
tmp="/tmp/${base}_clean.png"
|
||||
if [[ "$category" == "static_npcs" ]]; then
|
||||
convert "$img" -fuzz 10% -transparent white -resize "$PORTRAIT_SIZE" "$tmp"
|
||||
else
|
||||
elif [[ "$category" == "items" || "$category" == "placeholder" ]]; then
|
||||
convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp"
|
||||
else
|
||||
convert "$img" -fuzz 10% -transparent white "$tmp"
|
||||
fi
|
||||
cwebp "$tmp" -q 85 -o "$out_file" >/dev/null
|
||||
rm "$tmp"
|
||||
|
||||
|
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;
|
||||
}
|
||||
|
||||
.success {
|
||||
.message-success {
|
||||
color: #51cf66;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success {
|
||||
.message-success {
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
color: #5ddc6c;
|
||||
padding: 1rem;
|
||||
|
||||
@@ -334,7 +334,7 @@ function AccountPage() {
|
||||
/>
|
||||
</div>
|
||||
{emailError && <div className="error">{emailError}</div>}
|
||||
{emailSuccess && <div className="success">{emailSuccess}</div>}
|
||||
{emailSuccess && <div className="message-success">{emailSuccess}</div>}
|
||||
<button type="submit" className="button-primary" disabled={emailLoading}>
|
||||
{emailLoading ? 'Updating...' : 'Update Email'}
|
||||
</button>
|
||||
@@ -392,7 +392,7 @@ function AccountPage() {
|
||||
/>
|
||||
</div>
|
||||
{passwordError && <div className="error">{passwordError}</div>}
|
||||
{passwordSuccess && <div className="success">{passwordSuccess}</div>}
|
||||
{passwordSuccess && <div className="message-success">{passwordSuccess}</div>}
|
||||
<button type="submit" className="button-primary" disabled={passwordLoading}>
|
||||
{passwordLoading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
|
||||
@@ -723,8 +723,8 @@ html {
|
||||
}
|
||||
|
||||
.messages-scroll {
|
||||
height: 5.5rem;
|
||||
/* Compact fixed height (~3 lines) */
|
||||
height: 6rem;
|
||||
/* Always visible activity log */
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -752,6 +752,13 @@ html {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-location {
|
||||
color: rgba(107, 185, 240, 0.7);
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.movement-controls {
|
||||
background: var(--game-bg-panel);
|
||||
padding: 1.5rem;
|
||||
@@ -2086,11 +2093,12 @@ body.no-scroll {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(244, 67, 54, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
/* clip-path removed to ensure square box */
|
||||
aspect-ratio: 1;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -10,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
|
||||
|
||||
import { GameProvider } from '../contexts/GameContext'
|
||||
import { QuestJournal } from './game/QuestJournal'
|
||||
import GameHeader from './GameHeader'
|
||||
import './Game.css'
|
||||
|
||||
function Game() {
|
||||
@@ -350,9 +351,8 @@ function Game() {
|
||||
|
||||
return (
|
||||
<GameProvider value={gameContextValue}>
|
||||
<GameHeader />
|
||||
<div className="game-container">
|
||||
{/* Game Header is now in GameLayout */}
|
||||
|
||||
{/* Quest Journal Toggle Button - Add to header or float?
|
||||
Let's add it floating for now or in the top right.
|
||||
*/}
|
||||
@@ -396,6 +396,7 @@ function Game() {
|
||||
profile={state.profile}
|
||||
playerState={state.playerState}
|
||||
equipment={state.equipment}
|
||||
locationImage={state.location?.image_url}
|
||||
onCombatAction={actions.handleCombatAction}
|
||||
onPvPAction={async (action: string) => {
|
||||
try {
|
||||
@@ -478,7 +479,7 @@ function Game() {
|
||||
onSetCraftCategoryFilter={actions.setCraftCategoryFilter}
|
||||
onCraft={async (itemId: number) => await actions.handleCraft(itemId.toString())}
|
||||
onRepair={(uniqueItemId: string, inventoryId: number) => actions.handleRepairFromMenu(Number(uniqueItemId), inventoryId)}
|
||||
onUncraft={(uniqueItemId: string, inventoryId: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId)}
|
||||
onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)}
|
||||
failedActionItemId={state.failedActionItemId}
|
||||
quests={state.quests}
|
||||
/>
|
||||
@@ -501,8 +502,8 @@ function Game() {
|
||||
}}
|
||||
onEquipItem={actions.handleEquipItem}
|
||||
onUnequipItem={actions.handleUnequipItem}
|
||||
onDropItem={async (itemId: number, _invId: number, quantity: number) => {
|
||||
await actions.handleDropItem(itemId.toString(), quantity)
|
||||
onDropItem={async (itemId: number, invId: number, quantity: number) => {
|
||||
await actions.handleDropItem(itemId.toString(), quantity, invId)
|
||||
}}
|
||||
onSpendPoint={actions.handleSpendPoint}
|
||||
onOpenQuestJournal={() => setShowQuestJournal(true)}
|
||||
|
||||
@@ -57,6 +57,85 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* --- Center Section: Location/Combat Title --- */
|
||||
.header-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-location-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 1.2rem;
|
||||
background: linear-gradient(135deg, rgba(107, 185, 240, 0.1) 0%, transparent 100%);
|
||||
border: 1px solid rgba(107, 185, 240, 0.3);
|
||||
clip-path: var(--game-clip-path);
|
||||
}
|
||||
|
||||
.header-location-title.combat {
|
||||
background: linear-gradient(135deg, rgba(225, 29, 72, 0.15) 0%, transparent 100%);
|
||||
border-color: rgba(225, 29, 72, 0.4);
|
||||
animation: combat-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes combat-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 5px rgba(225, 29, 72, 0.3);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 15px rgba(225, 29, 72, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.header-location-title .location-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-location-title .combat-indicator {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.header-location-title .turn-indicator {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
text-transform: uppercase;
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
}
|
||||
|
||||
.header-location-title .turn-indicator.your-turn {
|
||||
background: rgba(76, 175, 80, 0.3);
|
||||
color: #4caf50;
|
||||
border: 1px solid rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.header-location-title .turn-indicator.enemy-turn {
|
||||
background: rgba(244, 67, 54, 0.3);
|
||||
color: #f44336;
|
||||
border: 1px solid rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
|
||||
/* --- Right Section: Navigation + User Info --- */
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
/* --- Navigation Tabs (Unified Angled Style) --- */
|
||||
.nav-links {
|
||||
display: flex;
|
||||
@@ -64,12 +143,11 @@
|
||||
/* Tight gaps for HUD feel */
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
height: 70%;
|
||||
/* Not full height, more like buttons */
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 0 1.2rem;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useGameWebSocket } from '../hooks/useGameWebSocket'
|
||||
import api from '../services/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LanguageSelector from './LanguageSelector'
|
||||
import { useOptionalGame } from '../contexts/GameContext'
|
||||
import { getTranslatedText } from '../utils/i18nUtils'
|
||||
import './Game.css'
|
||||
|
||||
import { GameTooltip } from './common/GameTooltip'
|
||||
@@ -12,17 +14,39 @@ import { GameTooltip } from './common/GameTooltip'
|
||||
// Import the new specific header styles
|
||||
import './GameHeader.css'
|
||||
|
||||
interface GameHeaderProps {
|
||||
className?: string
|
||||
interface CombatInfo {
|
||||
enemyName: string
|
||||
yourTurn: boolean
|
||||
}
|
||||
|
||||
export default function GameHeader({ className = '' }: GameHeaderProps) {
|
||||
interface GameHeaderProps {
|
||||
className?: string
|
||||
locationName?: string
|
||||
combatInfo?: CombatInfo | null
|
||||
dangerLevel?: number
|
||||
}
|
||||
|
||||
export default function GameHeader({
|
||||
className = ''
|
||||
}: GameHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { currentCharacter, logout } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const [playerCount, setPlayerCount] = useState<number>(0)
|
||||
|
||||
// Get game state from context (undefined when outside GameProvider)
|
||||
const gameContext = useOptionalGame()
|
||||
const gameState = gameContext?.state
|
||||
|
||||
// Extract location and combat info from game state
|
||||
const locationName = gameState?.location?.name ? getTranslatedText(gameState.location.name) : undefined
|
||||
const dangerLevel = gameState?.location?.danger_level
|
||||
const combatInfo = gameState?.combatState ? {
|
||||
enemyName: getTranslatedText(gameState.enemyName) || 'Enemy',
|
||||
yourTurn: gameState.combatState.yourTurn || false
|
||||
} : null
|
||||
|
||||
// Fetch initial player count
|
||||
useEffect(() => {
|
||||
const fetchPlayerCount = async () => {
|
||||
@@ -61,8 +85,15 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
|
||||
|
||||
const isOnOwnProfile = location.pathname === `/profile/${currentCharacter?.id}`
|
||||
|
||||
// Helper for danger badge class
|
||||
const getDangerClass = (level: number | undefined) => {
|
||||
if (level === undefined || level === 0) return 'danger-safe'
|
||||
return `danger-${Math.min(level, 5)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`game-header ${className}`}>
|
||||
{/* Left: Logo and Version */}
|
||||
<div className="header-left">
|
||||
<div className="header-title-container">
|
||||
<h1>Echoes of the Ash</h1>
|
||||
@@ -70,22 +101,45 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="nav-links">
|
||||
<button
|
||||
onClick={() => navigate('/game')}
|
||||
className={`nav-link ${isActive('/game') ? 'active' : ''}`}
|
||||
>
|
||||
<span style={{ fontSize: '1.1em' }}>🎮</span> {t('common.game')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/leaderboards')}
|
||||
className={`nav-link ${isActive('/leaderboards') ? 'active' : ''}`}
|
||||
>
|
||||
<span style={{ fontSize: '1.1em' }}>🏆</span> {t('common.leaderboards')}
|
||||
</button>
|
||||
</nav>
|
||||
{/* Center: Location/Combat Title */}
|
||||
<div className="header-center">
|
||||
{combatInfo ? (
|
||||
<div className="header-location-title combat">
|
||||
<span className="combat-indicator">⚔️</span>
|
||||
<span className="location-name">{combatInfo.enemyName}</span>
|
||||
<span className={`turn-indicator ${combatInfo.yourTurn ? 'your-turn' : 'enemy-turn'}`}>
|
||||
{combatInfo.yourTurn ? t('combat.yourTurn') : t('combat.enemyTurn')}
|
||||
</span>
|
||||
</div>
|
||||
) : locationName ? (
|
||||
<div className="header-location-title">
|
||||
<span className="location-name">{locationName}</span>
|
||||
{dangerLevel !== undefined && (
|
||||
<span className={`danger-badge ${getDangerClass(dangerLevel)}`}>
|
||||
{dangerLevel === 0 ? t('danger.safe') : `⚠️ ${dangerLevel}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: Navigation + User Info */}
|
||||
<div className="header-right">
|
||||
<nav className="nav-links">
|
||||
<button
|
||||
onClick={() => navigate('/game')}
|
||||
className={`nav-link ${isActive('/game') ? 'active' : ''}`}
|
||||
>
|
||||
🎮 {t('common.game')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/leaderboards')}
|
||||
className={`nav-link ${isActive('/leaderboards') ? 'active' : ''}`}
|
||||
>
|
||||
🏆 {t('common.leaderboards')}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="user-info">
|
||||
<GameTooltip content={t('game.onlineCount', { count: playerCount })}>
|
||||
<div className="player-count-badge">
|
||||
<span className="status-dot"></span>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import GameHeader from './GameHeader'
|
||||
import './Game.css'
|
||||
|
||||
export default function GameLayout() {
|
||||
return (
|
||||
<div className="game-layout">
|
||||
<GameHeader />
|
||||
<div className="game-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.game-btn:disabled {
|
||||
@@ -51,13 +52,13 @@
|
||||
|
||||
/* Primary - Blue (Default) */
|
||||
.game-btn.primary {
|
||||
background: linear-gradient(135deg, #6bb9f0, #89d4ff);
|
||||
box-shadow: 0 2px 8px rgba(107, 185, 240, 0.3);
|
||||
background: linear-gradient(135deg, #2980b9, #3498db);
|
||||
box-shadow: 0 2px 8px rgba(41, 128, 185, 0.3);
|
||||
}
|
||||
|
||||
.game-btn.primary:not(:disabled):hover {
|
||||
background: linear-gradient(135deg, #89d4ff, #6bb9f0);
|
||||
box-shadow: 0 4px 12px rgba(107, 185, 240, 0.5);
|
||||
background: linear-gradient(135deg, #3498db, #2980b9);
|
||||
box-shadow: 0 4px 12px rgba(41, 128, 185, 0.5);
|
||||
}
|
||||
|
||||
/* Secondary - Grey/Dark */
|
||||
|
||||
@@ -95,3 +95,11 @@
|
||||
border-bottom: 1px solid var(--game-border-color, #4a5568);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Vertical pickup options layout */
|
||||
.pickup-options-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
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;
|
||||
playerState: any;
|
||||
equipment: any;
|
||||
locationImage?: string;
|
||||
onCombatAction: (action: string) => Promise<any>;
|
||||
onPvPAction: (action: string, targetId: number) => Promise<any>;
|
||||
onExitCombat: () => void;
|
||||
@@ -31,6 +32,7 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
profile,
|
||||
playerState,
|
||||
equipment: _equipment,
|
||||
locationImage,
|
||||
onCombatAction,
|
||||
onPvPAction,
|
||||
onExitCombat,
|
||||
@@ -734,6 +736,7 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
combatResult={combatResult}
|
||||
equipment={_equipment}
|
||||
playerName={profile?.name}
|
||||
locationImage={locationImage}
|
||||
/>
|
||||
{/* Supplies modal */}
|
||||
<CombatInventoryModal
|
||||
|
||||
@@ -25,6 +25,93 @@
|
||||
transition: filter 1s ease;
|
||||
}
|
||||
|
||||
/* Combat Scene: Location Background with NPC Overlay */
|
||||
.combat-scene-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 1rem auto;
|
||||
aspect-ratio: 10 / 7;
|
||||
overflow: hidden;
|
||||
clip-path: var(--game-clip-path);
|
||||
border: 2px solid rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.combat-location-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.combat-location-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
}
|
||||
|
||||
.combat-npc-overlay-container {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.combat-npc-overlay {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.8)) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.6));
|
||||
transition: filter 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.combat-npc-overlay.attacking {
|
||||
animation: lunge 0.3s;
|
||||
}
|
||||
|
||||
.combat-npc-overlay-container.dead .combat-npc-overlay {
|
||||
filter: grayscale(100%) brightness(0.5) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
|
||||
/* Enemy shake effect - opposite direction (enemy faces right, recoils right) */
|
||||
.combat-npc-overlay-container.shake-effect {
|
||||
animation: shake-right 0.5s cubic-bezier(.36, .07, .19, .97) both;
|
||||
}
|
||||
|
||||
@keyframes shake-right {
|
||||
|
||||
10%,
|
||||
90% {
|
||||
transform: translate3d(1px, 0, 0);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translate3d(-2px, 0, 0);
|
||||
}
|
||||
|
||||
30%,
|
||||
50%,
|
||||
70% {
|
||||
transform: translate3d(4px, 0, 0);
|
||||
}
|
||||
|
||||
40%,
|
||||
60% {
|
||||
transform: translate3d(-4px, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.combat-npc-placeholder {
|
||||
font-size: 4rem;
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
|
||||
/* Enemy avatar now uses shared .location-image styles from Game.css */
|
||||
|
||||
/* ... existing code ... */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAssetPath } from '../../utils/assetPath';
|
||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||
import './CombatInventoryModal.css';
|
||||
import { EffectBadge } from './EffectBadge';
|
||||
import { GameButton } from '../common/GameButton';
|
||||
|
||||
interface CombatInventoryModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -51,7 +52,7 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
|
||||
<div className="combat-inventory-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{t('combat.modal.supplies_title')}</h3>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
<GameButton variant="danger" size="sm" onClick={onClose}>×</GameButton>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
@@ -201,9 +202,9 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button className="btn-use">
|
||||
<GameButton variant="success" size="sm" style={{ flexShrink: 0 }}>
|
||||
{t('game.use')}
|
||||
</button>
|
||||
</GameButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||
import { useAudio } from '../../contexts/AudioContext';
|
||||
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
|
||||
import { Equipment } from './types';
|
||||
import './CombatEffects.css';
|
||||
import { GameProgressBar } from '../common/GameProgressBar';
|
||||
import { GameButton } from '../common/GameButton';
|
||||
|
||||
interface CombatViewProps {
|
||||
state: CombatState;
|
||||
@@ -17,6 +19,7 @@ interface CombatViewProps {
|
||||
combatResult: 'victory' | 'defeat' | 'fled' | null;
|
||||
equipment?: Equipment | any;
|
||||
playerName?: string;
|
||||
locationImage?: string;
|
||||
}
|
||||
|
||||
export const CombatView: React.FC<CombatViewProps> = ({
|
||||
@@ -29,7 +32,8 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
isProcessing,
|
||||
combatResult,
|
||||
equipment,
|
||||
playerName
|
||||
playerName,
|
||||
locationImage
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { playSfx } = useAudio();
|
||||
@@ -117,44 +121,62 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
return (
|
||||
<div className="combat-container">
|
||||
|
||||
{/* Header (Location View Style) */}
|
||||
<div className="combat-header">
|
||||
<h2 className="centered-heading">
|
||||
{state.isPvP ? t('combat.pvp_title') : t('combat.title')}
|
||||
<span style={{ margin: '0 0.5rem', color: '#aaa', fontSize: '0.9em' }}>vs</span>
|
||||
{state.npcName || t('combat.unknown_enemy')}
|
||||
|
||||
{state.turnTimeRemaining !== undefined && (
|
||||
<span className="danger-badge danger-2" style={{ fontSize: '0.8rem', marginLeft: '0.5rem' }}>
|
||||
⏳ {state.turnTimeRemaining} s
|
||||
</span>
|
||||
)}
|
||||
{state.isPvP && (
|
||||
<span
|
||||
className={`danger-badge ${state.yourTurn ? 'danger-1' : 'danger-3'}`}
|
||||
style={{ fontSize: '0.8rem', marginLeft: '0.5rem', fontWeight: 'bold' }}
|
||||
>
|
||||
{state.yourTurn ? '🎯 ' + t('combat.your_turn') : '⏳ ' + t('combat.opponent_turn')}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Main Content Vertical Stack */}
|
||||
<div className="combat-main-content">
|
||||
|
||||
{/* 1. Enemy Avatar (Location Image Style) */}
|
||||
{/* Shake on npcHit, Attack on enemyAttacking, Dead on victory */}
|
||||
<div className={`enemy-display ${animState.enemyAttacking ? 'attacking' : ''} ${animState.npcHit ? 'shake-effect flash-hit' : ''} ${combatResult === 'victory' ? 'dead' : ''}`}>
|
||||
<div className="location-image-container">
|
||||
{/* 1. Combat Scene: Location Background with NPC Overlay */}
|
||||
<div className="combat-scene-container">
|
||||
{/* Location Background */}
|
||||
{locationImage ? (
|
||||
<img src={locationImage} alt="Location" className="combat-location-bg" />
|
||||
) : (
|
||||
<div className="combat-location-bg combat-location-placeholder">🌄</div>
|
||||
)}
|
||||
|
||||
{/* NPC Overlay (bottom-right corner) */}
|
||||
<div className={`combat-npc-overlay-container ${animState.npcHit ? 'shake-effect flash-hit' : ''} ${combatResult === 'victory' ? 'dead' : ''}`}>
|
||||
{state.npcImage ? (
|
||||
<img src={state.npcImage} alt={state.npcName} className="location-image" />
|
||||
<img
|
||||
src={state.npcImage}
|
||||
alt={state.npcName}
|
||||
className={`combat-npc-overlay ${animState.enemyAttacking ? 'attacking' : ''}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="enemy-placeholder">💀</div>
|
||||
<div className="combat-npc-overlay combat-npc-placeholder">💀</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PvP Timer & Turn Indicator */}
|
||||
{state.isPvP && (
|
||||
<div className="combat-pvp-status" style={{
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem'
|
||||
}}>
|
||||
{state.turnTimeRemaining !== undefined && (
|
||||
<span className="danger-badge danger-2" style={{ fontSize: '1rem', padding: '4px 12px', boxShadow: '0 2px 5px rgba(0,0,0,0.5)' }}>
|
||||
⏳ {state.turnTimeRemaining} s
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`danger-badge ${state.yourTurn ? 'danger-1' : 'danger-3'}`}
|
||||
style={{ fontSize: '0.9rem', fontWeight: 'bold' }}
|
||||
>
|
||||
{state.yourTurn ?
|
||||
<span>🎯 {t('combat.yourTurn') || 'Your Turn'}</span> :
|
||||
<span>⏳ {t('combat.enemyTurn') || 'Enemy Turn'}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. HP Bars (Character Sheet Style) - Staggered Lines */}
|
||||
<div className="combat-stats-container" style={{ position: 'relative' }}>
|
||||
|
||||
@@ -197,7 +219,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
{/* Enemy HP (Left) */}
|
||||
<div className={`stat-block enemy ${animState.npcHit ? 'shake-effect' : ''}`}>
|
||||
<GameProgressBar
|
||||
label={state.npcName || t('common.enemy')}
|
||||
label={getTranslatedText(state.npcName) || t('common.enemy')}
|
||||
value={state.npcHp}
|
||||
max={state.npcMaxHp}
|
||||
type="enemy_health"
|
||||
@@ -224,47 +246,51 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
|
||||
{/* 3. Actions */}
|
||||
<div className="combat-actions">
|
||||
<button
|
||||
className="btn btn-primary full-width glow-effect"
|
||||
onClick={onClose}
|
||||
style={{ display: combatResult ? 'block' : 'none', margin: '0 auto' }}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
|
||||
<div className="combat-actions-group" style={{ display: !combatResult ? 'grid' : 'none', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
|
||||
<button
|
||||
className="btn btn-attack"
|
||||
onClick={() => onAction('attack')}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
{combatResult && (
|
||||
<GameButton
|
||||
variant="primary"
|
||||
onClick={onClose}
|
||||
style={{ width: '100%', maxWidth: '200px', margin: '0 auto' }}
|
||||
>
|
||||
👊 {t('combat.actions.attack')}
|
||||
</button>
|
||||
{t('common.close')}
|
||||
</GameButton>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-defend"
|
||||
onClick={() => onAction('defend')}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🛡️ {t('combat.actions.defend')}
|
||||
</button>
|
||||
{!combatResult && (
|
||||
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
|
||||
<GameButton
|
||||
variant="danger"
|
||||
onClick={() => onAction('attack')}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
👊 {t('combat.actions.attack')}
|
||||
</GameButton>
|
||||
|
||||
<button
|
||||
className="btn btn-supplies"
|
||||
onClick={onShowSupplies}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🎒 {t('combat.actions.supplies')}
|
||||
</button>
|
||||
<GameButton
|
||||
variant="primary"
|
||||
onClick={() => onAction('defend')}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🛡️ {t('combat.actions.defend')}
|
||||
</GameButton>
|
||||
|
||||
<button
|
||||
className="btn btn-flee"
|
||||
onClick={() => onAction('flee')}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🏃 {t('combat.actions.flee')}
|
||||
</button>
|
||||
</div>
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
onClick={onShowSupplies}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🎒 {t('combat.actions.supplies')}
|
||||
</GameButton>
|
||||
|
||||
<GameButton
|
||||
variant="warning"
|
||||
onClick={() => onAction('flee')}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🏃 {t('combat.actions.flee')}
|
||||
</GameButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Log (Table) */}
|
||||
@@ -276,7 +302,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
let className = `log-row log-${msg.type}`;
|
||||
|
||||
if (msg.data && msg.data.message) {
|
||||
text = msg.data.message;
|
||||
text = getTranslatedText(msg.data.message);
|
||||
} else {
|
||||
switch (msg.type) {
|
||||
case 'combat_start': text = t('combat.start'); break;
|
||||
@@ -299,7 +325,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
|
||||
case 'flee_success': text = t('combat.flee.success'); break;
|
||||
case 'flee_fail': text = t('combat.flee.fail'); break;
|
||||
case 'item_broken': text = t('combat.item_broken', { item: msg.data?.item_name }); break;
|
||||
case 'item_broken': text = t('combat.item_broken', { item: getTranslatedText(msg.data?.item_name) }); break;
|
||||
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
|
||||
case 'damage':
|
||||
if (msg.origin === 'enemy') {
|
||||
@@ -309,15 +335,15 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 });
|
||||
}
|
||||
break;
|
||||
case 'text': text = msg.data?.text || ""; break;
|
||||
case 'text': text = getTranslatedText(msg.data?.text) || ""; break;
|
||||
case 'item_used':
|
||||
text = t('combat.log.item_used', { item: msg.data?.item_name || '' });
|
||||
if (msg.data?.effects) text += msg.data.effects; // Append effects string if backend still sends it
|
||||
text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' });
|
||||
if (msg.data?.effects) text += getTranslatedText(msg.data.effects); // Append effects string if backend still sends it
|
||||
className += " text-info";
|
||||
break;
|
||||
case 'effect_applied':
|
||||
text = t('combat.log.effect_applied', {
|
||||
effect: msg.data?.effect_name,
|
||||
effect: getTranslatedText(msg.data?.effect_name),
|
||||
target: msg.data?.target === 'enemy' ? t('common.enemy') : t('common.you')
|
||||
});
|
||||
className += " text-warning";
|
||||
@@ -330,7 +356,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
return (
|
||||
<tr key={index} className={className}>
|
||||
<td className="log-time">[{time}]</td>
|
||||
<td className="log-event">{text}</td>
|
||||
<td className="log-event"><span>{text}</span></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,13 +26,27 @@
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.npc-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #555;
|
||||
.npc-portrait-container {
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid rgba(107, 185, 240, 0.5);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 15px auto;
|
||||
clip-path: var(--game-clip-path);
|
||||
box-shadow: var(--game-shadow-sm);
|
||||
}
|
||||
|
||||
.npc-portrait {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
align-self: center;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.npc-name {
|
||||
@@ -54,23 +68,19 @@
|
||||
/* Renamed from .options-container to match JSX */
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
/* grid-auto-rows: 1fr; Removed to prevent forced height expansion */
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Make back button and exit button span full width if needed, or keep grid */
|
||||
/* Let's make the 'Back' button span full width for better UX */
|
||||
.options-grid>.option-btn:first-child:nth-last-child(1) {
|
||||
/* Make the last item span full width if it's the only one in the row (odd number of items) */
|
||||
.options-grid>*:last-child:nth-child(odd) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
/* Base styles handled by GameButton, but we can override */
|
||||
/* Base styles handled by GameButton, but ensure consistent height */
|
||||
width: 100%;
|
||||
/* height: 100%; Removed to prevent stretching */
|
||||
/* Fill the grid cell */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
||||
<div className="npc-portrait-container">
|
||||
<img
|
||||
className="npc-portrait"
|
||||
src={npcData.image ? getAssetPath(npcData.image) : ''}
|
||||
src={npcData.image_path || npcData.image ? getAssetPath(npcData.image_path || npcData.image) : ''}
|
||||
alt={npcName}
|
||||
/>
|
||||
</div>
|
||||
@@ -227,14 +227,14 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
||||
<div className="options-grid">
|
||||
{/* BACK BUTTON */}
|
||||
{(viewState === 'topic' || viewState === 'quest_preview') && (
|
||||
<GameButton className="option-btn" onClick={resetToGreeting}>
|
||||
<GameButton className="option-btn" size="sm" onClick={resetToGreeting}>
|
||||
← Back
|
||||
</GameButton>
|
||||
)}
|
||||
|
||||
{/* NPC TOPICS */}
|
||||
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
|
||||
<GameButton key={topic.id} className="option-btn" onClick={() => handleTopicClick(topic)}>
|
||||
<GameButton key={topic.id} className="option-btn" size="sm" onClick={() => handleTopicClick(topic)}>
|
||||
💬 {getLocalized(topic.title)}
|
||||
</GameButton>
|
||||
))}
|
||||
@@ -244,6 +244,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
||||
<GameButton
|
||||
key={q.quest_id}
|
||||
className="option-btn quest-btn"
|
||||
size="sm"
|
||||
onClick={() => handleQuestClick(q)}
|
||||
variant={q.status === 'active' ? 'warning' : 'info'}
|
||||
>
|
||||
@@ -254,7 +255,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
||||
{/* CONFIRM QUEST ACTION */}
|
||||
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<GameButton className="option-btn action-btn" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
|
||||
<GameButton className="option-btn action-btn" size="sm" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
|
||||
Accept Quest
|
||||
</GameButton>
|
||||
</div>
|
||||
@@ -264,6 +265,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<GameButton
|
||||
className="option-btn action-btn"
|
||||
size="sm"
|
||||
variant="warning"
|
||||
onClick={handInQuest}
|
||||
style={{ width: '100%' }}
|
||||
@@ -278,14 +280,14 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
||||
|
||||
{/* TRADE - Only show in greeting */}
|
||||
{viewState === 'greeting' && npcData.trade?.enabled && (
|
||||
<GameButton className="option-btn trade-btn" variant="success" onClick={onTrade}>
|
||||
<GameButton className="option-btn trade-btn" size="sm" variant="success" onClick={onTrade}>
|
||||
💰 Trade
|
||||
</GameButton>
|
||||
)}
|
||||
|
||||
{/* EXIT - Span full width */}
|
||||
{viewState === 'greeting' && (
|
||||
<GameButton className="option-btn exit-btn" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
|
||||
<GameButton className="option-btn exit-btn" size="sm" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
|
||||
Goodbye
|
||||
</GameButton>
|
||||
)}
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
clip-path: var(--game-clip-path);
|
||||
}
|
||||
|
||||
.backpack-status.active {
|
||||
@@ -229,7 +229,7 @@
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--game-bg-app);
|
||||
background-color: var(--game-bg-panel);
|
||||
}
|
||||
|
||||
.game-search-container {
|
||||
@@ -308,7 +308,7 @@
|
||||
/* Grid View Layout */
|
||||
.items-container.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
grid-auto-rows: max-content;
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
@@ -386,7 +386,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-image-section.small {
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
/* Grid View Styles */
|
||||
.entities-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.entity-section {
|
||||
flex: 1 1 300px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Ground item images */
|
||||
.ground-item-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Corpse images - grayed out */
|
||||
.corpse-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: grayscale(100%) brightness(0.6);
|
||||
}
|
||||
|
||||
.entity-list.grid-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
@@ -190,6 +218,14 @@
|
||||
border-color: rgba(76, 175, 80, 1);
|
||||
}
|
||||
|
||||
.player-card.grid-card {
|
||||
border-color: rgba(66, 153, 225, 0.5);
|
||||
}
|
||||
|
||||
.player-card.grid-card:hover {
|
||||
border-color: rgba(66, 153, 225, 1);
|
||||
}
|
||||
|
||||
.corpse-details-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -319,3 +355,20 @@
|
||||
.close-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Corpse Loot Count - Bottom Right Badge */
|
||||
.corpse-loot-count {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #ce93d8;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
font-weight: bold;
|
||||
border: 1px solid rgba(206, 147, 216, 0.3);
|
||||
clip-path: var(--game-clip-path-sm);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
/* Let clicks pass through to card */
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import { DialogModal } from './DialogModal'
|
||||
import { TradeModal } from './TradeModal'
|
||||
import { ItemTooltipContent } from '../common/ItemTooltipContent'
|
||||
import './LocationView.css'
|
||||
|
||||
interface LocationViewProps {
|
||||
@@ -17,7 +18,7 @@ interface LocationViewProps {
|
||||
playerState: PlayerState | null
|
||||
combatState: CombatState | null
|
||||
message: string
|
||||
locationMessages: Array<{ time: string; message: string }>
|
||||
locationMessages: Array<{ time: string; message: string; location_name?: string }>
|
||||
expandedCorpse: string | null
|
||||
corpseDetails: any
|
||||
mobileMenuOpen: string
|
||||
@@ -49,7 +50,7 @@ interface LocationViewProps {
|
||||
onSetCraftCategoryFilter: (category: string) => void
|
||||
onCraft: (itemId: number) => void
|
||||
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
||||
onUncraft: (uniqueItemId: string, inventoryId: number) => void
|
||||
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
|
||||
failedActionItemId: string | number | null
|
||||
quests: { active: any[], available: any[] }
|
||||
}
|
||||
@@ -207,22 +208,6 @@ function LocationView({
|
||||
return (
|
||||
<div className="location-view">
|
||||
<div className="location-info">
|
||||
<h2 className="centered-heading">
|
||||
{getTranslatedText(location.name)}
|
||||
{location.danger_level !== undefined && location.danger_level === 0 && (
|
||||
<GameTooltip content="Safe Zone">
|
||||
<span className="danger-badge danger-safe">✓ Safe</span>
|
||||
</GameTooltip>
|
||||
)}
|
||||
{location.danger_level !== undefined && location.danger_level > 0 && (
|
||||
<GameTooltip content={`Danger Level: ${location.danger_level}`}>
|
||||
<span className={`danger-badge danger-${location.danger_level}`}>
|
||||
⚠️ {location.danger_level}
|
||||
</span>
|
||||
</GameTooltip>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{location.tags && location.tags.length > 0 && (
|
||||
<div className="location-tags">
|
||||
{location.tags.map((tag: string, i: number) => {
|
||||
@@ -232,15 +217,24 @@ function LocationView({
|
||||
else if (tag === 'repair_station' && onOpenRepair) onOpenRepair()
|
||||
}
|
||||
|
||||
return (
|
||||
<GameTooltip key={i} content={isClickable ? `Click to ${tag === 'workbench' ? 'craft items' : 'repair items'}` : `This location has: ${tag}`}>
|
||||
<span
|
||||
className={`location-tag tag-${tag} ${isClickable ? 'clickable' : ''}`}
|
||||
onClick={isClickable ? handleClick : undefined}
|
||||
style={isClickable ? { cursor: 'pointer' } : undefined}
|
||||
// Use GameButton for workbench and repair_station
|
||||
if (isClickable) {
|
||||
return (
|
||||
<GameButton
|
||||
key={i}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{tag === 'workbench' && t('tags.workbench')}
|
||||
{tag === 'repair_station' && t('tags.repairStation')}
|
||||
{tag === 'workbench' ? t('tags.workbench') : t('tags.repairStation')}
|
||||
</GameButton>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular span for non-interactive tags
|
||||
return (
|
||||
<GameTooltip key={i} content={`This location has: ${tag}`}>
|
||||
<span className={`location-tag tag-${tag}`}>
|
||||
{tag === 'safe_zone' && t('tags.safeZone')}
|
||||
{tag === 'shop' && t('tags.shop')}
|
||||
{tag === 'shelter' && t('tags.shelter')}
|
||||
@@ -279,406 +273,508 @@ function LocationView({
|
||||
{locationMessages.slice(-10).reverse().map((msg, idx) => (
|
||||
<div key={idx} className="location-message-item">
|
||||
<span className="message-time">{msg.time}</span>
|
||||
<span className="message-text">{msg.message}</span>
|
||||
<span className="message-text">{getTranslatedText(msg.message)}</span>
|
||||
{msg.location_name && (
|
||||
<span className="message-location">[{msg.location_name}]</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{locationMessages.length === 0 && (
|
||||
<div className="location-messages-log location-messages-empty">
|
||||
<h4>{t('location.recentActivity')}</h4>
|
||||
<div className="messages-scroll">
|
||||
<div className="location-message-item empty-state">
|
||||
<span className="message-text" style={{ opacity: 0.5, fontStyle: 'italic' }}>
|
||||
{t('location.noRecentActivity', 'No recent activity')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`ground-entities mobile-menu-panel bottom ${mobileMenuOpen === 'bottom' ? 'open' : ''}`}>
|
||||
{/* Enemies */}
|
||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
|
||||
<div className="entity-section enemies-section">
|
||||
<h3>{t('location.enemies')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => {
|
||||
const id = `enemy-${enemy.id || i}`;
|
||||
return (
|
||||
<div key={i} className="entity-card enemy-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, id)}>
|
||||
{enemy.id && (
|
||||
<div className="entity-image padded-image">
|
||||
<img
|
||||
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
||||
alt={getTranslatedText(enemy.name)}
|
||||
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Combined Entities Container for Grid Layout */}
|
||||
<div className="entities-container">
|
||||
{/* Enemies */}
|
||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
|
||||
<div className="entity-section enemies-section">
|
||||
<h3>{t('location.enemies')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any) => {
|
||||
const isShaking = failedActionItemId == enemy.id;
|
||||
const id = `enemy-${enemy.id}`;
|
||||
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
|
||||
<div>{t('location.level')} {enemy.level}</div>
|
||||
<div style={{ color: '#f56565', fontSize: '0.8rem' }}>Click for actions</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
|
||||
{/* Dropdown for Grid View */}
|
||||
{activeDropdown === id && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="160px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
|
||||
<GameButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); }}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('common.fight')}
|
||||
</GameButton>
|
||||
<div className="game-dropdown-divider" />
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
|
||||
{t('location.level')} {enemy.level}
|
||||
</div>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Corpses */}
|
||||
{location.corpses && location.corpses.length > 0 && (
|
||||
<div className="entity-section corpses-section">
|
||||
<h3>{t('location.corpses')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.corpses.map((corpse: any) => (
|
||||
<div key={corpse.id} className="corpse-container">
|
||||
<div className="entity-card corpse-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, `corpse-${corpse.id}`)}
|
||||
>
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
|
||||
<div>{corpse.loot_count} {t('location.items')}</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
<div style={{ fontSize: '2rem' }}>{corpse.emoji}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#ce93d8' }}>{corpse.loot_count} items</div>
|
||||
</div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === `corpse-${corpse.id}` && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="160px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
playSfx('/audio/sfx/interact.wav')
|
||||
onLootCorpse(String(corpse.id))
|
||||
setActiveDropdown(null)
|
||||
}}
|
||||
disabled={corpse.loot_count === 0}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
🔍 {t('common.examine')}
|
||||
</GameButton>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friendly NPCs */}
|
||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
|
||||
<div className="entity-section npcs-section">
|
||||
<h3>{t('location.npcs')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
||||
<div key={i} className="entity-card npc-card grid-card"
|
||||
onClick={() => handleNpcClick(npc)}
|
||||
style={{ cursor: 'pointer', position: 'relative' }}
|
||||
>
|
||||
{npc.image_path ? (
|
||||
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
|
||||
)}
|
||||
|
||||
{renderIndicator(npc.id)}
|
||||
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
|
||||
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items on Ground - Stable Sort */}
|
||||
{location.items.length > 0 && (
|
||||
<div className="entity-section items-section">
|
||||
<h3>{t('location.itemsOnGround')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{[...location.items]
|
||||
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
|
||||
.map((item: any, i: number) => {
|
||||
const isShaking = failedActionItemId == item.id;
|
||||
const itemId = `item-${item.id}-${i}`;
|
||||
|
||||
// Pickup Options Helper
|
||||
const renderPickupOptions = () => {
|
||||
const options = [];
|
||||
options.push({ label: 'x1', qty: 1 });
|
||||
if (item.quantity >= 5) options.push({ label: 'x5', qty: 5 });
|
||||
if (item.quantity >= 10) options.push({ label: 'x10', qty: 10 });
|
||||
if (item.quantity > 1) options.push({ label: t('common.all'), qty: item.quantity });
|
||||
|
||||
return options.map(opt => (
|
||||
<GameButton
|
||||
key={opt.label}
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent closing
|
||||
onPickup(item.id, opt.qty);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '2px' }}
|
||||
>
|
||||
🤚 {t('common.pickUp')} ({opt.label})
|
||||
</GameButton>
|
||||
));
|
||||
};
|
||||
// Only render if valid
|
||||
if (!enemy || !enemy.id) return null;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`entity-card item-card ${isShaking ? 'shake' : ''} grid-card`}
|
||||
onClick={(e) => handleDropdownClick(e, itemId)}
|
||||
<div key={enemy.id} className={`entity-card enemy-card grid-card ${isShaking ? 'shake-animation' : ''}`}
|
||||
onClick={(e) => handleDropdownClick(e, id)}
|
||||
>
|
||||
{/* Enemy Image */}
|
||||
{enemy.id && (
|
||||
<div className="entity-image padded-image">
|
||||
<img
|
||||
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
||||
alt={getTranslatedText(enemy.name)}
|
||||
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GameTooltip content={
|
||||
<div className="item-info-tooltip-content">
|
||||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
||||
{item.weight !== undefined && item.weight > 0 && (
|
||||
<div className="item-tooltip-stat">
|
||||
⚖️ {t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
|
||||
</div>
|
||||
)}
|
||||
{item.volume !== undefined && item.volume > 0 && (
|
||||
<div className="item-tooltip-stat">
|
||||
📦 {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
|
||||
<div>{t('location.level')} {enemy.level}</div>
|
||||
<div style={{ color: '#f56565', fontSize: '0.8rem' }}>Click for actions</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="entity-content-wrapper grid-content">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="entity-icon"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
const icon = (e.target as HTMLImageElement).nextElementSibling;
|
||||
if (icon) icon.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={`entity-icon ${item.image_path ? 'hidden' : ''}`} style={!item.image_path ? { fontSize: '2rem' } : {}}>{item.emoji || '📦'}</span>
|
||||
|
||||
{item.quantity > 1 && (
|
||||
<div className="grid-quantity">x{item.quantity}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === itemId && (
|
||||
{/* Dropdown for Grid View */}
|
||||
{activeDropdown === id && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="160px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(item.name)}</div>
|
||||
<div className="pickup-options">
|
||||
{renderPickupOptions()}
|
||||
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
|
||||
<GameButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); }}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('common.fight')}
|
||||
</GameButton>
|
||||
<div className="game-dropdown-divider" />
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
|
||||
{t('location.level')} {enemy.level}
|
||||
</div>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Other Players */}
|
||||
{location.other_players && location.other_players.length > 0 && (
|
||||
<div className="entity-section players-section">
|
||||
<h3>👥 Other Players</h3>
|
||||
<div className="entity-list">
|
||||
{location.other_players.map((player: any, i: number) => (
|
||||
<div key={i} className="entity-card player-card">
|
||||
<span className="entity-icon">🧍</span>
|
||||
<div className="entity-info">
|
||||
<div className="entity-name">{player.name || player.username}</div>
|
||||
<div className="entity-level">Lv. {player.level}</div>
|
||||
{player.level_diff !== undefined && (
|
||||
<div className="level-diff">
|
||||
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
|
||||
</div>
|
||||
)}
|
||||
{/* Corpses */}
|
||||
{location.corpses && location.corpses.length > 0 && (
|
||||
<div className="entity-section corpses-section">
|
||||
<h3>{t('location.corpses')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.corpses.map((corpse: any) => (
|
||||
<div key={corpse.id} className="corpse-container">
|
||||
<div className="entity-card corpse-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, `corpse-${corpse.id}`)}
|
||||
>
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
|
||||
<div>{corpse.loot_count} {t('location.items')}</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
{corpse.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(corpse.image_path)}
|
||||
alt={getTranslatedText(corpse.name)}
|
||||
className="corpse-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ fontSize: '2rem', display: corpse.image_path ? 'none' : 'block' }} className={corpse.image_path ? 'hidden' : ''}>
|
||||
{corpse.emoji}
|
||||
</div>
|
||||
<div className="corpse-loot-count">{corpse.loot_count} items</div>
|
||||
</div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === `corpse-${corpse.id}` && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="160px"
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
playSfx('/audio/sfx/interact.wav')
|
||||
onLootCorpse(String(corpse.id))
|
||||
setActiveDropdown(null)
|
||||
}}
|
||||
disabled={corpse.loot_count === 0}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
🔍 {t('common.examine')}
|
||||
</GameButton>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{player.can_pvp && (
|
||||
<GameTooltip content={`Attack ${player.name || player.username}`}>
|
||||
<button
|
||||
className="pvp-btn"
|
||||
onClick={() => onInitiatePvP(player.id)}
|
||||
>
|
||||
{t('game.attack')}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
)}
|
||||
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
|
||||
<div className="pvp-disabled-reason">{t('game.levelDifferenceTooHigh')}</div>
|
||||
)}
|
||||
{!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
|
||||
<div className="pvp-disabled-reason">{t('game.areaTooSafeForPvP')}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Friendly NPCs */}
|
||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
|
||||
<div className="entity-section npcs-section">
|
||||
<h3>{t('location.npcs')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
||||
<div key={i} className="entity-card npc-card grid-card"
|
||||
onClick={() => handleNpcClick(npc)}
|
||||
style={{ cursor: 'pointer', position: 'relative' }}
|
||||
>
|
||||
{npc.image_path ? (
|
||||
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>
|
||||
)}
|
||||
|
||||
{renderIndicator(npc.id)}
|
||||
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{getTranslatedText(npc.name)}</div>
|
||||
<div style={{ color: '#ff9800', fontSize: '0.8rem' }}>Click to Interact</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-overlay"></div>
|
||||
</GameTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items on Ground - Stable Sort */}
|
||||
{location.items.length > 0 && (
|
||||
<div className="entity-section items-section">
|
||||
<h3>{t('location.itemsOnGround')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{[...location.items]
|
||||
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
|
||||
.map((item: any, i: number) => {
|
||||
const isShaking = failedActionItemId == item.id;
|
||||
const itemId = `item-${item.id}-${i}`;
|
||||
|
||||
// Pickup Options Helper - Vertical Layout
|
||||
const renderPickupOptions = () => {
|
||||
const options = [];
|
||||
options.push({ label: `${t('common.pickUp')} (x1)`, qty: 1 });
|
||||
if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
|
||||
if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 });
|
||||
if (item.quantity > 1) options.push({ label: t('common.pickUpAll'), qty: item.quantity });
|
||||
|
||||
return (
|
||||
<div className="pickup-options-vertical">
|
||||
{options.map((opt) => (
|
||||
<GameButton
|
||||
key={opt.label}
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playSfx('/audio/sfx/pickup.wav');
|
||||
onPickup(Number(item.id), opt.qty);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
{opt.label}
|
||||
</GameButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={itemId} className={`entity-card item-card grid-card ${isShaking ? 'shake-animation' : ''}`}
|
||||
onClick={(e) => handleDropdownClick(e, itemId)}
|
||||
>
|
||||
<GameTooltip content={
|
||||
<>
|
||||
<ItemTooltipContent item={item} />
|
||||
<div style={{ color: '#4caf50', fontSize: '0.8rem', marginTop: '0.5rem', textAlign: 'center' }}>Click to Interact</div>
|
||||
</>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="ground-item-image"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{ fontSize: '2rem', display: item.image_path ? 'none' : 'block' }} className={item.image_path ? 'hidden' : ''}>
|
||||
{item.emoji}
|
||||
</div>
|
||||
{item.quantity > 1 && (
|
||||
<div className="grid-quantity">x{item.quantity}</div>
|
||||
)}
|
||||
</div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === itemId && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="200px" // Wider for split buttons
|
||||
>
|
||||
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
|
||||
|
||||
{/* Primary Action: Pick Up 1 */}
|
||||
<GameButton
|
||||
variant="success"
|
||||
size="sm"
|
||||
className="pickup-main-btn"
|
||||
onClick={() => {
|
||||
playSfx('/audio/sfx/pickup.wav');
|
||||
onPickup(Number(item.id), 1);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'center', marginBottom: '8px' }}
|
||||
>
|
||||
✋ {t('common.pickUp')}
|
||||
</GameButton>
|
||||
|
||||
{/* Quantity Options if > 1 */}
|
||||
{item.quantity > 1 && (
|
||||
<>
|
||||
<div className="game-dropdown-divider" style={{ margin: '8px 0' }} />
|
||||
{renderPickupOptions()}
|
||||
</>
|
||||
)}
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Players */}
|
||||
{location.other_players && location.other_players.length > 0 && (
|
||||
<div className="entity-section players-section">
|
||||
<h3>👥 {t('location.otherPlayers', 'Other Players')}</h3>
|
||||
<div className="entity-list grid-view">
|
||||
{location.other_players.map((player: any, i: number) => {
|
||||
const playerId = `player-${player.id}-${i}`;
|
||||
const canPvP = player.can_pvp;
|
||||
|
||||
return (
|
||||
<div key={i} className="entity-card player-card grid-card"
|
||||
onClick={(e) => handleDropdownClick(e, playerId)}
|
||||
>
|
||||
<GameTooltip content={
|
||||
<div>
|
||||
<div className="tooltip-title">{player.name || player.username}</div>
|
||||
<div>{t('location.level', 'Level')} {player.level}</div>
|
||||
{player.level_diff !== undefined && (
|
||||
<div style={{ fontSize: '0.8rem', color: player.level_diff > 0 ? '#f56565' : '#48bb78' }}>
|
||||
{player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#ebf8ff', fontSize: '0.8rem', marginTop: '0.5rem' }}>Click for actions</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="grid-corpse-content">
|
||||
{/* Placeholder for player image or avatar */}
|
||||
<div style={{ fontSize: '2.5rem' }}>
|
||||
🧍
|
||||
</div>
|
||||
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
|
||||
Lv.{player.level}
|
||||
</div>
|
||||
</div>
|
||||
</GameTooltip>
|
||||
|
||||
{activeDropdown === playerId && (
|
||||
<GameDropdown
|
||||
isOpen={true}
|
||||
onClose={() => setActiveDropdown(null)}
|
||||
width="180px"
|
||||
>
|
||||
<div className="game-dropdown-header">{player.name || player.username}</div>
|
||||
|
||||
{canPvP ? (
|
||||
<GameButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onInitiatePvP(player.id);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
⚔️ {t('game.attack')}
|
||||
</GameButton>
|
||||
) : (
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0', fontStyle: 'italic' }}>
|
||||
{location.danger_level !== undefined && location.danger_level < 3
|
||||
? t('game.areaTooSafeForPvP')
|
||||
: (player.level_diff !== undefined && Math.abs(player.level_diff) > 3)
|
||||
? t('game.levelDifferenceTooHigh')
|
||||
: "PvP Unavailable"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="game-dropdown-divider" />
|
||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>
|
||||
Level {player.level}
|
||||
</div>
|
||||
</GameDropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corpse Loot Overlay Modal */}
|
||||
{expandedCorpse && corpseDetails && corpseDetails.loot_items && (
|
||||
<div className="corpse-loot-overlay" onClick={() => onSetExpandedCorpse(null)}>
|
||||
<div className="corpse-loot-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="corpse-details-header">
|
||||
<h4>{t('location.lootableItems')}</h4>
|
||||
{
|
||||
expandedCorpse && corpseDetails && corpseDetails.loot_items && (
|
||||
<div className="corpse-loot-overlay" onClick={() => onSetExpandedCorpse(null)}>
|
||||
<div className="corpse-loot-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="corpse-details-header">
|
||||
<h4>{t('location.lootableItems')}</h4>
|
||||
<button
|
||||
className="close-btn"
|
||||
onClick={() => {
|
||||
onSetExpandedCorpse(null)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="corpse-items-list">
|
||||
{corpseDetails.loot_items.map((item: any) => (
|
||||
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
|
||||
{/* Item Image */}
|
||||
<div className="corpse-item-image">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={item.item_name}
|
||||
className="item-img-thumb"
|
||||
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
|
||||
</div>
|
||||
|
||||
<div className="corpse-item-info" style={{ flex: 1 }}>
|
||||
<div className="corpse-item-name">
|
||||
{getTranslatedText(item.item_name)}
|
||||
</div>
|
||||
{item.description && <div className="corpse-item-desc" style={{ fontSize: '0.75rem', color: '#a0aec0' }}>{getTranslatedText(item.description)}</div>}
|
||||
<div className="corpse-item-qty">
|
||||
{t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
|
||||
</div>
|
||||
{item.required_tool && (
|
||||
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
|
||||
🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
|
||||
<button
|
||||
className="corpse-item-loot-btn"
|
||||
onClick={() => onLootCorpseItem(expandedCorpse, item.index)}
|
||||
disabled={!item.can_loot}
|
||||
>
|
||||
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="close-btn"
|
||||
onClick={() => {
|
||||
onSetExpandedCorpse(null)
|
||||
}}
|
||||
className="loot-all-btn"
|
||||
onClick={() => onLootCorpseItem(expandedCorpse, null)}
|
||||
>
|
||||
✕
|
||||
📦 {t('common.lootAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="corpse-items-list">
|
||||
{corpseDetails.loot_items.map((item: any) => (
|
||||
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
|
||||
{/* Item Image */}
|
||||
<div className="corpse-item-image">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={item.item_name}
|
||||
className="item-img-thumb"
|
||||
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
|
||||
</div>
|
||||
|
||||
<div className="corpse-item-info" style={{ flex: 1 }}>
|
||||
<div className="corpse-item-name">
|
||||
{getTranslatedText(item.item_name)}
|
||||
</div>
|
||||
{item.description && <div className="corpse-item-desc" style={{ fontSize: '0.75rem', color: '#a0aec0' }}>{getTranslatedText(item.description)}</div>}
|
||||
<div className="corpse-item-qty">
|
||||
{t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
|
||||
</div>
|
||||
{item.required_tool && (
|
||||
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
|
||||
🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
|
||||
<button
|
||||
className="corpse-item-loot-btn"
|
||||
onClick={() => onLootCorpseItem(expandedCorpse, item.index)}
|
||||
disabled={!item.can_loot}
|
||||
>
|
||||
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
||||
</button>
|
||||
</GameTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="loot-all-btn"
|
||||
onClick={() => onLootCorpseItem(expandedCorpse, null)}
|
||||
>
|
||||
📦 {t('common.lootAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
{(showCraftingMenu || showRepairMenu) && (
|
||||
<Workbench
|
||||
showCraftingMenu={showCraftingMenu}
|
||||
showRepairMenu={showRepairMenu}
|
||||
workbenchTab={workbenchTab}
|
||||
craftableItems={craftableItems}
|
||||
repairableItems={repairableItems}
|
||||
uncraftableItems={uncraftableItems}
|
||||
craftFilter={craftFilter}
|
||||
repairFilter={repairFilter}
|
||||
uncraftFilter={uncraftFilter}
|
||||
craftCategoryFilter={craftCategoryFilter}
|
||||
profile={profile}
|
||||
onCloseCrafting={onCloseCrafting}
|
||||
onSwitchTab={onSwitchWorkbenchTab}
|
||||
onSetCraftFilter={onSetCraftFilter}
|
||||
onSetRepairFilter={onSetRepairFilter}
|
||||
onSetUncraftFilter={onSetUncraftFilter}
|
||||
onSetCraftCategoryFilter={onSetCraftCategoryFilter}
|
||||
onCraft={onCraft}
|
||||
onRepair={onRepair}
|
||||
onUncraft={onUncraft}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
(showCraftingMenu || showRepairMenu) && (
|
||||
<Workbench
|
||||
showCraftingMenu={showCraftingMenu}
|
||||
showRepairMenu={showRepairMenu}
|
||||
workbenchTab={workbenchTab}
|
||||
craftableItems={craftableItems}
|
||||
repairableItems={repairableItems}
|
||||
uncraftableItems={uncraftableItems}
|
||||
craftFilter={craftFilter}
|
||||
repairFilter={repairFilter}
|
||||
uncraftFilter={uncraftFilter}
|
||||
craftCategoryFilter={craftCategoryFilter}
|
||||
profile={profile}
|
||||
onCloseCrafting={onCloseCrafting}
|
||||
onSwitchTab={onSwitchWorkbenchTab}
|
||||
onSetCraftFilter={onSetCraftFilter}
|
||||
onSetRepairFilter={onSetRepairFilter}
|
||||
onSetUncraftFilter={onSetUncraftFilter}
|
||||
onSetCraftCategoryFilter={onSetCraftCategoryFilter}
|
||||
onCraft={onCraft}
|
||||
onRepair={onRepair}
|
||||
onUncraft={onUncraft}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{activeDialogNpc && activeNpcData && (
|
||||
<DialogModal
|
||||
npcId={activeDialogNpc}
|
||||
npcData={activeNpcData}
|
||||
onClose={() => setActiveDialogNpc(null)}
|
||||
onTrade={() => {
|
||||
setActiveDialogNpc(null);
|
||||
setShowTradeModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
activeDialogNpc && activeNpcData && (
|
||||
<DialogModal
|
||||
npcId={activeDialogNpc}
|
||||
npcData={activeNpcData}
|
||||
onClose={() => setActiveDialogNpc(null)}
|
||||
onTrade={() => {
|
||||
setActiveDialogNpc(null);
|
||||
setShowTradeModal(true);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{showTradeModal && activeNpcData && (
|
||||
<TradeModal
|
||||
npcId={activeNpcData.id}
|
||||
onClose={() => setShowTradeModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
showTradeModal && activeNpcData && (
|
||||
<TradeModal
|
||||
npcId={activeNpcData.id}
|
||||
onClose={() => setShowTradeModal(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import { GameTooltip } from '../common/GameTooltip'
|
||||
import { GameButton } from '../common/GameButton'
|
||||
|
||||
interface MovementControlsProps {
|
||||
location: Location
|
||||
@@ -281,16 +282,19 @@ function MovementControls({
|
||||
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
|
||||
: getTranslatedText(action.description)
|
||||
}>
|
||||
<button
|
||||
<GameButton
|
||||
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
|
||||
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
|
||||
style={{ width: '100%', justifyContent: 'space-between' }}
|
||||
>
|
||||
{getTranslatedText(action.name)}
|
||||
<span className="stamina-cost">
|
||||
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${staminaCost}`}
|
||||
</span>
|
||||
</button>
|
||||
</GameButton>
|
||||
</GameTooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -296,7 +296,7 @@ function PlayerSidebar({
|
||||
<GameButton
|
||||
className="open-inventory-btn"
|
||||
variant="primary"
|
||||
size="md"
|
||||
size="sm"
|
||||
onClick={() => setShowInventory(true)}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
@@ -306,7 +306,7 @@ function PlayerSidebar({
|
||||
<GameButton
|
||||
className="quest-journal-btn"
|
||||
variant="secondary" // Different color as requested
|
||||
size="md"
|
||||
size="sm"
|
||||
onClick={onOpenQuestJournal}
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
>
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--game-border-color);
|
||||
background: var(--game-bg-app);
|
||||
background: var(--game-bg-panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--game-bg-card);
|
||||
background: #ffffff08;
|
||||
border: 1px solid var(--game-border-color);
|
||||
clip-path: var(--game-clip-path);
|
||||
cursor: pointer;
|
||||
@@ -485,7 +485,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 10px;
|
||||
background: var(--game-bg-card);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
@@ -501,8 +501,6 @@
|
||||
}
|
||||
|
||||
.detail-requirements {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
clip-path: var(--game-clip-path);
|
||||
@@ -620,3 +618,22 @@
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Action Info Labels */
|
||||
.action-info {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: #a0aec0;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.action-info.warning {
|
||||
color: #ed8936;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-info.success {
|
||||
color: #48bb78;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { Profile, WorkbenchTab } from './types'
|
||||
import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import { GameButton } from '../common/GameButton'
|
||||
import './Workbench.css'
|
||||
|
||||
interface WorkbenchProps {
|
||||
@@ -25,7 +26,7 @@ interface WorkbenchProps {
|
||||
onSetCraftCategoryFilter: (category: string) => void
|
||||
onCraft: (itemId: number) => void
|
||||
onRepair: (uniqueItemId: string, inventoryId: number) => void
|
||||
onUncraft: (uniqueItemId: string, inventoryId: number) => void
|
||||
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
|
||||
}
|
||||
|
||||
function Workbench({
|
||||
@@ -53,12 +54,19 @@ function Workbench({
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null)
|
||||
const [salvageQuantity, setSalvageQuantity] = useState<number>(1)
|
||||
|
||||
// Reset selection when tab changes
|
||||
useEffect(() => {
|
||||
setSelectedItem(null)
|
||||
setSalvageQuantity(1)
|
||||
}, [workbenchTab])
|
||||
|
||||
// Reset quantity when selected item changes
|
||||
useEffect(() => {
|
||||
setSalvageQuantity(1)
|
||||
}, [selectedItem])
|
||||
|
||||
// Update selectedItem when items list changes (after repair/craft/salvage)
|
||||
useEffect(() => {
|
||||
if (selectedItem) {
|
||||
@@ -243,22 +251,23 @@ function Workbench({
|
||||
</div>
|
||||
|
||||
<div className="detail-actions">
|
||||
<button
|
||||
className="craft-btn"
|
||||
{!item.meets_level && (
|
||||
<div className="action-info warning">{t('crafting.levelRequired', { level: item.craft_level })}</div>
|
||||
)}
|
||||
{item.meets_level && !item.can_craft && (
|
||||
<div className="action-info warning">{t('crafting.missingRequirements')}</div>
|
||||
)}
|
||||
{item.can_craft && (
|
||||
<div className="action-info">{t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}</div>
|
||||
)}
|
||||
<GameButton
|
||||
variant="success"
|
||||
disabled={!item.can_craft || (profile?.stamina || 0) < (item.stamina_cost || 1)}
|
||||
onClick={() => onCraft(item.item_id)}
|
||||
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span>
|
||||
{!item.meets_level ? t('crafting.levelRequired', { level: item.craft_level }) :
|
||||
!item.can_craft ? t('crafting.missingRequirements') : t('crafting.craftItem')}
|
||||
</span>
|
||||
{item.can_craft && (
|
||||
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
||||
{t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{t('crafting.craftItem')}
|
||||
</GameButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -324,22 +333,23 @@ function Workbench({
|
||||
</div>
|
||||
|
||||
<div className="detail-actions">
|
||||
<button
|
||||
className="repair-btn"
|
||||
{!item.needs_repair && (
|
||||
<div className="action-info success">{t('crafting.alreadyFull')}</div>
|
||||
)}
|
||||
{item.needs_repair && !item.can_repair && (
|
||||
<div className="action-info warning">{t('crafting.missingRequirements')}</div>
|
||||
)}
|
||||
{item.needs_repair && item.can_repair && (
|
||||
<div className="action-info">{t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}</div>
|
||||
)}
|
||||
<GameButton
|
||||
variant="info"
|
||||
disabled={!item.can_repair || (profile?.stamina || 0) < (item.stamina_cost || 1)}
|
||||
onClick={() => onRepair(item.unique_item_id, item.inventory_id)}
|
||||
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span>
|
||||
{!item.needs_repair ? t('crafting.alreadyFull') :
|
||||
!item.can_repair ? t('crafting.missingRequirements') : t('crafting.repairItem')}
|
||||
</span>
|
||||
{item.needs_repair && item.can_repair && (
|
||||
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
||||
{t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{t('crafting.repairItem')}
|
||||
</GameButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -403,21 +413,49 @@ function Workbench({
|
||||
</div>
|
||||
|
||||
<div className="detail-actions">
|
||||
<button
|
||||
className="uncraft-btn"
|
||||
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
|
||||
<div className="action-info">{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}</div>
|
||||
|
||||
{/* Quantity Selector for Salvage */}
|
||||
{item.quantity > 1 && (
|
||||
<div className="quantity-selector" style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1rem' }}>
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setSalvageQuantity(Math.max(1, salvageQuantity - 1))}
|
||||
>
|
||||
-
|
||||
</GameButton>
|
||||
<span style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{salvageQuantity} / {item.quantity}</span>
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setSalvageQuantity(Math.min(item.quantity, salvageQuantity + 1))}
|
||||
>
|
||||
+
|
||||
</GameButton>
|
||||
<GameButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setSalvageQuantity(item.quantity)}
|
||||
>
|
||||
Max
|
||||
</GameButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GameButton
|
||||
variant="danger"
|
||||
disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }))) {
|
||||
onUncraft(item.unique_item_id, item.inventory_id)
|
||||
const confirmMsg = t('crafting.confirmSalvage', { name: getTranslatedText(item.name) })
|
||||
if (window.confirm(`${confirmMsg} (x${salvageQuantity})`)) {
|
||||
onUncraft(item.unique_item_id, item.inventory_id, salvageQuantity)
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span>♻️ {t('game.salvage')}</span>
|
||||
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
||||
{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
|
||||
</span>
|
||||
</button>
|
||||
♻️ {t('game.salvage')} {item.quantity > 1 ? `(x${salvageQuantity})` : ''}
|
||||
</GameButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import api from '../../../services/api'
|
||||
import { useAudio } from '../../../contexts/AudioContext'
|
||||
import { getTranslatedText } from '../../../utils/i18nUtils'
|
||||
import type {
|
||||
PlayerState,
|
||||
Location,
|
||||
@@ -84,7 +85,7 @@ export interface GameEngineActions {
|
||||
handleUseItem: (itemId: string) => Promise<void>
|
||||
handleEquipItem: (inventoryId: number) => Promise<void>
|
||||
handleUnequipItem: (slot: string) => Promise<void>
|
||||
handleDropItem: (itemId: string, quantity?: number) => Promise<void>
|
||||
handleDropItem: (itemId: string, quantity?: number, inventoryId?: number) => Promise<void>
|
||||
|
||||
// Crafting/Workbench
|
||||
handleOpenCrafting: () => Promise<void>
|
||||
@@ -92,7 +93,7 @@ export interface GameEngineActions {
|
||||
handleCraft: (itemId: string) => Promise<void>
|
||||
handleOpenRepair: () => Promise<void>
|
||||
handleRepairFromMenu: (uniqueItemId: number, inventoryId?: number) => Promise<void>
|
||||
handleUncraft: (uniqueItemId: number, inventoryId: number) => Promise<void>
|
||||
handleUncraft: (uniqueItemId: number, inventoryId: number, quantity?: number) => Promise<void>
|
||||
handleSwitchWorkbenchTab: (tab: WorkbenchTab) => Promise<void>
|
||||
|
||||
// Combat
|
||||
@@ -189,7 +190,14 @@ export function useGameEngine(
|
||||
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
|
||||
const [mobileHeaderOpen, setMobileHeaderOpen] = useState<boolean>(false)
|
||||
const [locationMessages, setLocationMessages] = useState<LocationMessage[]>([])
|
||||
const [locationMessages, setLocationMessages] = useState<LocationMessage[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('locationMessages')
|
||||
return saved ? JSON.parse(saved) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const [interactableCooldowns, setInteractableCooldowns] = useState<Record<string, number>>({})
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
|
||||
const [_forceUpdate, _setForceUpdate] = useState(0)
|
||||
@@ -226,9 +234,22 @@ export function useGameEngine(
|
||||
const addLocationMessage = useCallback((msg: string) => {
|
||||
const now = new Date()
|
||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||
setLocationMessages((prev: LocationMessage[]) => [...prev, { time: timeStr, message: msg }])
|
||||
const locationName = location?.name ? (typeof location.name === 'string' ? location.name : location.name.en || Object.values(location.name)[0]) : ''
|
||||
|
||||
setLocationMessages((prev: LocationMessage[]) => {
|
||||
const newMessages = [...prev, { time: timeStr, message: msg, location_name: locationName }]
|
||||
// Keep only last 50 messages
|
||||
const trimmed = newMessages.slice(-50)
|
||||
// Persist to sessionStorage
|
||||
try {
|
||||
sessionStorage.setItem('locationMessages', JSON.stringify(trimmed))
|
||||
} catch (e) {
|
||||
// Ignore storage errors
|
||||
}
|
||||
return trimmed
|
||||
})
|
||||
setMessage(msg)
|
||||
}, [])
|
||||
}, [location])
|
||||
|
||||
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
|
||||
setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev])
|
||||
@@ -330,6 +351,17 @@ export function useGameEngine(
|
||||
const newCombatState = { ...pvpRes.data, is_pvp: true }
|
||||
setCombatState(newCombatState)
|
||||
|
||||
// Extract opponent info for GameHeader and state
|
||||
const opponent = pvpRes.data.pvp_combat.is_attacker ?
|
||||
pvpRes.data.pvp_combat.defender :
|
||||
pvpRes.data.pvp_combat.attacker
|
||||
|
||||
if (opponent) {
|
||||
setEnemyName(opponent.username || opponent.name || 'Unknown Player')
|
||||
// If players have avatars in the future, set it here. For now default or specific image if available.
|
||||
setEnemyImage(opponent.image || '')
|
||||
}
|
||||
|
||||
if (pvpRes.data.pvp_combat.last_action &&
|
||||
pvpRes.data.pvp_combat.last_action !== lastSeenPvPActionRef.current) {
|
||||
|
||||
@@ -404,12 +436,42 @@ export function useGameEngine(
|
||||
|
||||
setMobileMenuOpen('none')
|
||||
|
||||
// Capture old location name before moving (translated)
|
||||
const oldLocationName = location?.name ? getTranslatedText(location.name) : ''
|
||||
|
||||
try {
|
||||
setMessage('Moving...')
|
||||
const response = await api.post('/api/game/move', { direction })
|
||||
setMessage(response.data.message)
|
||||
playSfx('/audio/sfx/step.wav')
|
||||
setLocationMessages([])
|
||||
|
||||
// Get new location name and stamina from response (translated if possible)
|
||||
// The response might contain the location object // Add to location log
|
||||
// Use the location name directly from the response if available (it handles localization on the backend)
|
||||
let newLocationName = 'Unknown'
|
||||
|
||||
if (response.data.new_location_name) {
|
||||
// Prefer the name from the response object which we just added to the backend
|
||||
newLocationName = getTranslatedText(response.data.new_location_name)
|
||||
} else if (response.data.location?.name) {
|
||||
// Fallback to location object if present in response (legacy)
|
||||
newLocationName = getTranslatedText(response.data.location.name)
|
||||
} else if (response.data.new_location_id) {
|
||||
// Fallback to ID if no name provided (shouldn't happen with new backend)
|
||||
newLocationName = response.data.new_location_id
|
||||
}
|
||||
|
||||
const staminaSpent = response.data.stamina_spent || 1
|
||||
|
||||
const msg = t('location.movedTo', {
|
||||
from: oldLocationName || 'Unknown',
|
||||
to: newLocationName,
|
||||
stamina: staminaSpent
|
||||
})
|
||||
// Add movement message to activity log (only if location changed)
|
||||
if (oldLocationName && newLocationName && oldLocationName !== newLocationName) {
|
||||
addLocationMessage(msg)
|
||||
}
|
||||
|
||||
if (response.data.encounter && response.data.encounter.triggered) {
|
||||
const encounter = response.data.encounter
|
||||
@@ -441,7 +503,7 @@ export function useGameEngine(
|
||||
} catch (error: any) {
|
||||
setMessage(error.response?.data?.detail || 'Move failed')
|
||||
}
|
||||
}, [combatState, showCraftingMenu, showRepairMenu, fetchGameData])
|
||||
}, [combatState, showCraftingMenu, showRepairMenu, fetchGameData, location, t, addLocationMessage])
|
||||
|
||||
// Simplified placeholder handlers
|
||||
// (Full implementations would be moved from Game.tsx)
|
||||
@@ -676,10 +738,14 @@ export function useGameEngine(
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropItem = async (itemId: string, quantity: number = 1) => {
|
||||
const handleDropItem = async (itemId: string, quantity: number = 1, inventoryId?: number) => {
|
||||
try {
|
||||
setMessage(`Dropping ${quantity} item(s)...`)
|
||||
const response = await api.post('/api/game/item/drop', { item_id: itemId, quantity })
|
||||
const payload: any = { item_id: itemId, quantity }
|
||||
if (inventoryId) {
|
||||
payload.inventory_id = inventoryId
|
||||
}
|
||||
const response = await api.post('/api/game/item/drop', payload)
|
||||
const msg = response.data.message || 'Item dropped!'
|
||||
addLocationMessage(msg)
|
||||
fetchGameData()
|
||||
@@ -746,12 +812,13 @@ export function useGameEngine(
|
||||
}
|
||||
}
|
||||
|
||||
const handleUncraft = async (uniqueItemId: number, inventoryId: number) => {
|
||||
const handleUncraft = async (uniqueItemId: number, inventoryId: number, quantity: number = 1) => {
|
||||
try {
|
||||
// setMessage('Salvaging...')
|
||||
const response = await api.post('/api/game/uncraft_item', {
|
||||
unique_item_id: uniqueItemId,
|
||||
inventory_id: inventoryId
|
||||
inventory_id: inventoryId,
|
||||
quantity: quantity
|
||||
})
|
||||
const data = response.data
|
||||
let msg = data.message || 'Item salvaged!'
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface CombatLogEntry {
|
||||
export interface LocationMessage {
|
||||
time: string
|
||||
message: string
|
||||
location_name?: string
|
||||
}
|
||||
|
||||
export interface Equipment {
|
||||
|
||||
@@ -24,3 +24,8 @@ export const useGame = () => {
|
||||
}
|
||||
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",
|
||||
"lootableItems": "Lootable Items:",
|
||||
"items": "item(s)",
|
||||
"level": "Lv."
|
||||
"level": "Lv.",
|
||||
"movedTo": "You moved from {{from}} to {{to}}, spending {{stamina}} stamina",
|
||||
"noActivity": "No recent activity"
|
||||
},
|
||||
"tags": {
|
||||
"workbench": "🔧 Workbench",
|
||||
@@ -115,6 +117,13 @@
|
||||
"water": "💧 Water",
|
||||
"food": "🍎 Food"
|
||||
},
|
||||
"danger": {
|
||||
"safe": "Safe Zone",
|
||||
"low": "Low Danger",
|
||||
"medium": "Medium Danger",
|
||||
"high": "High Danger",
|
||||
"extreme": "Extreme Danger"
|
||||
},
|
||||
"stats": {
|
||||
"hp": "❤️ HP",
|
||||
"maxHp": "❤️ Max HP",
|
||||
|
||||
@@ -100,7 +100,9 @@
|
||||
"itemsOnGround": "📦 Objetos en el Suelo",
|
||||
"lootableItems": "Objetos Saqueables:",
|
||||
"items": "objeto(s)",
|
||||
"level": "Nv."
|
||||
"level": "Nv.",
|
||||
"movedTo": "Te has movido de {{from}} a {{to}}, gastando {{stamina}} de aguante",
|
||||
"noActivity": "Sin actividad reciente"
|
||||
},
|
||||
"tags": {
|
||||
"workbench": "🔧 Banco de Trabajo",
|
||||
@@ -113,6 +115,13 @@
|
||||
"water": "💧 Agua",
|
||||
"food": "🍎 Comida"
|
||||
},
|
||||
"danger": {
|
||||
"safe": "Zona Segura",
|
||||
"low": "Peligro Bajo",
|
||||
"medium": "Peligro Medio",
|
||||
"high": "Peligro Alto",
|
||||
"extreme": "Peligro Extremo"
|
||||
},
|
||||
"stats": {
|
||||
"hp": "❤️ Vida",
|
||||
"maxHp": "❤️ Vida Máx.",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/* Item slots */
|
||||
--game-bg-slot-hover: rgba(255, 255, 255, 0.1);
|
||||
--game-bg-tooltip: #151515;
|
||||
--game-bg-card: #050505;
|
||||
|
||||
/* --- Borders & Separators --- */
|
||||
--game-border-color: rgba(255, 255, 255, 0.12);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Resolves asset paths based on runtime environment:
|
||||
* - Electron: Returns relative path (assets bundled with app, using ./)
|
||||
* - Browser: Returns full server URL
|
||||
* - Browser: Returns absolute path (served by PWA nginx)
|
||||
*/
|
||||
|
||||
// Detect if running in Electron - check at runtime, not module load time
|
||||
@@ -12,10 +12,6 @@ function checkIsElectron(): boolean {
|
||||
window.location.protocol === 'file:'
|
||||
}
|
||||
|
||||
// Base URL for remote assets (browser mode)
|
||||
const ASSET_BASE_URL = import.meta.env.VITE_ASSET_URL ||
|
||||
(import.meta.env.PROD ? 'https://api-staging.echoesoftheash.com' : '')
|
||||
|
||||
/**
|
||||
* Resolves an asset path for the current environment
|
||||
* @param path - The asset path (e.g., "images/items/knife.webp" or "/images/items/knife.webp")
|
||||
@@ -32,8 +28,8 @@ export function getAssetPath(path: string): string {
|
||||
return `./${cleanPath}`
|
||||
}
|
||||
|
||||
// In browser, prepend the server URL
|
||||
return `${ASSET_BASE_URL}/${cleanPath}`
|
||||
// In browser, use absolute path - PWA nginx serves images at /images/
|
||||
return `/${cleanPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||