diff --git a/api/routers/crafting.py b/api/routers/crafting.py
index 03f98d5..0215806 100644
--- a/api/routers/crafting.py
+++ b/api/routers/crafting.py
@@ -375,6 +375,7 @@ async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get
class UncraftItemRequest(BaseModel):
inventory_id: int
+ quantity: int = 1
@router.post("/api/game/uncraft_item")
@@ -402,6 +403,14 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
if not inv_item:
raise HTTPException(status_code=404, detail="Item not found in inventory")
+
+ # Check quantity
+ if request.quantity <= 0:
+ raise HTTPException(status_code=400, detail="Quantity must be greater than 0")
+
+ current_quantity = inv_item.get('quantity', 1)
+ if request.quantity > current_quantity:
+ raise HTTPException(status_code=400, detail=f"Not enough items. Have {current_quantity}, requested {request.quantity}")
# Get item definition
item_def = ITEMS_MANAGER.items.get(inv_item['item_id'])
@@ -415,29 +424,50 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
if not uncraft_yield:
raise HTTPException(status_code=400, detail="No uncraft recipe found")
- # Check tools requirement
+ # Check tools requirement (once per operation? or per item?)
+ # Usually tools are checked once for the operation, but durability cost might be per item.
+ # Logic above for crafting consumes tool durability for the batch?
+ # In craft_item above, it loops through craft_tools but seemingly only once?
+ # Wait, craft_item does NOT loop for quantity because craft_item only crafts 1 at a time (request has no quantity).
+
+ # For uncrafting multiple, we should multiply tool cost.
uncraft_tools = getattr(item_def, 'uncraft_tools', [])
+ tools_consumed = []
+
if uncraft_tools:
- success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], uncraft_tools, inventory)
+ # Scale tool cost by quantity
+ scaled_uncraft_tools = []
+ for tool_req in uncraft_tools:
+ scaled_req = tool_req.copy()
+ scaled_req['durability_cost'] = tool_req['durability_cost'] * request.quantity
+ scaled_uncraft_tools.append(scaled_req)
+
+ success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], scaled_uncraft_tools, inventory)
if not success:
raise HTTPException(status_code=400, detail=error_msg)
- else:
- tools_consumed = []
# Calculate stamina cost
- stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
+ base_stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
+ total_stamina_cost = base_stamina_cost * request.quantity
# Check stamina
- if player['stamina'] < stamina_cost:
- raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
+ if player['stamina'] < total_stamina_cost:
+ raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {total_stamina_cost}, have {player['stamina']}")
# Deduct stamina
- new_stamina = max(0, player['stamina'] - stamina_cost)
+ new_stamina = max(0, player['stamina'] - total_stamina_cost)
await db.update_player_stamina(current_user['id'], new_stamina)
- # Remove the item from inventory
- # Use remove_inventory_row since we have the inventory ID
- await db.remove_inventory_row(inv_item['id'])
+ # Update inventory item
+ if request.quantity == current_quantity:
+ # Remove the item row entirely
+ await db.remove_inventory_row(inv_item['id'])
+ else:
+ # Update quantity
+ await db.update_inventory_item(
+ inv_item['id'],
+ quantity=current_quantity - request.quantity
+ )
# Calculate durability ratio for yield reduction
durability_ratio = 1.0 # Default: full yield
@@ -449,96 +479,120 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
if max_durability > 0:
durability_ratio = current_durability / max_durability
- # Re-fetch inventory to get updated capacity after removing the item
+ # Re-fetch inventory to get updated capacity
inventory = await db.get_inventory(current_user['id'])
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
- # Calculate materials with loss chance and durability reduction
+ # Calculate materials
import random
loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3)
- yield_info = {
- 'base_yield': uncraft_yield,
- 'loss_chance': loss_chance,
- 'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
- }
+
+ materials_yielded_dict = {}
+ materials_lost_dict = {}
+ materials_dropped_dict = {}
+
+ # Loop for each item being uncrafted to calculate yield fairly
+ for _ in range(request.quantity):
+ for material in uncraft_yield:
+ # Apply durability reduction first
+ base_quantity = material['quantity']
+
+ # Calculate adjusted quantity based on durability
+ adjusted_quantity = int(round(base_quantity * durability_ratio))
+
+ mat_def = ITEMS_MANAGER.items.get(material['item_id'])
+ mat_name = mat_def.name if mat_def else material['item_id']
+
+ loss_key = (material['item_id'], mat_name)
+
+ # If durability is too low (< 10%), yield nothing for this material
+ if durability_ratio < 0.1 or adjusted_quantity <= 0:
+ if loss_key not in materials_lost_dict:
+ materials_lost_dict[loss_key] = 0
+ materials_lost_dict[loss_key] += base_quantity
+ continue
+
+ # Roll for loss chance
+ if random.random() < loss_chance:
+ # Lost this material
+ if loss_key not in materials_lost_dict:
+ materials_lost_dict[loss_key] = 0
+ materials_lost_dict[loss_key] += adjusted_quantity
+ else:
+ # Check if it fits in inventory (incremental check?)
+ # For simplicity, check per unit or accumulate and check at end.
+ # Checking per unit is safer but slower.
+ # Since we are modifying inventory in loop (potentially), we should be careful.
+ # Actually, we should accumulate yield then add to inventory at end to optimize DB calls?
+ # But we need to check capacity.
+ # Let's accumulate pending yield.
+
+ yield_key = (material['item_id'], mat_name, mat_def.emoji if mat_def else '๐ฆ', mat_def)
+ if yield_key not in materials_yielded_dict:
+ materials_yielded_dict[yield_key] = 0
+ materials_yielded_dict[yield_key] += adjusted_quantity
+
+ # Now process the accumulated yield
materials_yielded = []
materials_lost = []
materials_dropped = []
- for material in uncraft_yield:
- # Apply durability reduction first
- base_quantity = material['quantity']
+ # Convert lost dict to list
+ for (item_id, name), qty in materials_lost_dict.items():
+ materials_lost.append({
+ 'item_id': item_id,
+ 'name': name,
+ 'quantity': qty,
+ 'reason': 'lost_or_low_durability'
+ })
- # Calculate adjusted quantity based on durability
- # Use round() to ensure minimum yield of 1 for high durability items (e.g. 90% of 1 = 0.9 -> 1)
- adjusted_quantity = int(round(base_quantity * durability_ratio))
+ # Process yield
+ for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items():
+ mat_weight = getattr(mat_def, 'weight', 0) * qty
+ mat_volume = getattr(mat_def, 'volume', 0) * qty
- mat_def = ITEMS_MANAGER.items.get(material['item_id'])
+ # Simple check against capacity (assuming current_weight was just updated from DB)
+ # Note: we might fill up mid-loop. ideally we add one by one or check total.
+ # Let's check total.
- # If durability is too low (< 10%), yield nothing for this material
- if durability_ratio < 0.1 or adjusted_quantity <= 0:
- materials_lost.append({
- 'item_id': material['item_id'],
- 'name': mat_def.name if mat_def else material['item_id'],
- 'quantity': base_quantity,
- 'reason': 'durability_too_low'
- })
- continue
-
- # Roll for each material separately with loss chance
- if random.random() < loss_chance:
- # Lost this material
- materials_lost.append({
- 'item_id': material['item_id'],
- 'name': mat_def.name if mat_def else material['item_id'],
- 'quantity': adjusted_quantity,
- 'reason': 'random_loss'
+ if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
+ # Fits
+ await db.add_item_to_inventory(
+ player_id=current_user['id'],
+ item_id=item_id,
+ quantity=qty
+ )
+ current_weight += mat_weight
+ current_volume += mat_volume
+
+ materials_yielded.append({
+ 'item_id': item_id,
+ 'name': name,
+ 'emoji': emoji,
+ 'quantity': qty
})
else:
- # Check if it fits in inventory
- mat_weight = getattr(mat_def, 'weight', 0) * adjusted_quantity
- mat_volume = getattr(mat_def, 'volume', 0) * adjusted_quantity
+ # Drop
+ await db.drop_item_to_world(
+ item_id=item_id,
+ quantity=qty,
+ location_id=player['location_id']
+ )
- if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
- # Fits in inventory
- await db.add_item_to_inventory(
- player_id=current_user['id'],
- item_id=material['item_id'],
- quantity=adjusted_quantity
- )
-
- # Update current capacity tracking
- current_weight += mat_weight
- current_volume += mat_volume
-
- materials_yielded.append({
- 'item_id': material['item_id'],
- 'name': mat_def.name if mat_def else material['item_id'],
- 'emoji': mat_def.emoji if mat_def else '๐ฆ',
- 'quantity': adjusted_quantity
- })
- else:
- # Inventory full - drop to ground
- await db.drop_item_to_world(
- item_id=material['item_id'],
- quantity=adjusted_quantity,
- location_id=player['location_id']
- )
-
- materials_dropped.append({
- 'item_id': material['item_id'],
- 'name': mat_def.name if mat_def else material['item_id'],
- 'emoji': mat_def.emoji if mat_def else '๐ฆ',
- 'quantity': adjusted_quantity
- })
+ materials_dropped.append({
+ 'item_id': item_id,
+ 'name': name,
+ 'emoji': emoji,
+ 'quantity': qty
+ })
- message = f"Uncrafted {item_def.name}!"
+ message = f"Uncrafted {request.quantity}x {item_def.name}!"
if durability_ratio < 1.0:
- message += f" (Item condition reduced yield by {int((1 - durability_ratio) * 100)}%)"
+ message += f" (Condition reduced yield)"
if materials_lost:
- message += f" Lost {len(materials_lost)} material type(s)."
+ message += f" Lost materials."
if materials_dropped:
- message += f" Inventory full! Dropped {len(materials_dropped)} item(s) to the ground."
+ message += f" Inventory full! Dropped items."
return {
'success': True,
@@ -550,7 +604,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
'tools_consumed': tools_consumed,
'loss_chance': loss_chance,
'durability_ratio': round(durability_ratio, 2),
- 'stamina_cost': stamina_cost,
+ 'stamina_cost': total_stamina_cost,
'new_stamina': new_stamina
}
diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py
index 7764ce5..5eb0440 100644
--- a/api/routers/game_routes.py
+++ b/api/routers/game_routes.py
@@ -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")
diff --git a/api/routers/loot.py b/api/routers/loot.py
index 4fd048a..c6a9f2b 100644
--- a/api/routers/loot.py
+++ b/api/routers/loot.py
@@ -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({
diff --git a/gamedata/locations.json b/gamedata/locations.json
index 7a98fcd..eddf7f6 100644
--- a/gamedata/locations.json
+++ b/gamedata/locations.json
@@ -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!"
}
}
}
diff --git a/images-source/make_webp.sh b/images-source/make_webp.sh
index 335cd33..f4ca93e 100755
--- a/images-source/make_webp.sh
+++ b/images-source/make_webp.sh
@@ -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"
diff --git a/images-source/npcs/feral_dog.png b/images-source/npcs/feral_dog.png
index 211bc02..6f86c0b 100644
Binary files a/images-source/npcs/feral_dog.png and b/images-source/npcs/feral_dog.png differ
diff --git a/images-source/npcs/infected_human.png b/images-source/npcs/infected_human.png
index b2c6335..f63aa5d 100644
Binary files a/images-source/npcs/infected_human.png and b/images-source/npcs/infected_human.png differ
diff --git a/images-source/npcs/mutant_rat.png b/images-source/npcs/mutant_rat.png
index 6fcf7af..2d61de7 100644
Binary files a/images-source/npcs/mutant_rat.png and b/images-source/npcs/mutant_rat.png differ
diff --git a/images-source/npcs/raider_scout.png b/images-source/npcs/raider_scout.png
index e24c0f0..058cd50 100644
Binary files a/images-source/npcs/raider_scout.png and b/images-source/npcs/raider_scout.png differ
diff --git a/images-source/npcs/scavenger.png b/images-source/npcs/scavenger.png
index 2eec5af..22dc490 100644
Binary files a/images-source/npcs/scavenger.png and b/images-source/npcs/scavenger.png differ
diff --git a/images-source/static_npcs/mechanic_mike.png b/images-source/static_npcs/mechanic_mike.png
new file mode 100644
index 0000000..e832824
Binary files /dev/null and b/images-source/static_npcs/mechanic_mike.png differ
diff --git a/images/npcs/feral_dog.webp b/images/npcs/feral_dog.webp
index cae26aa..5f83ae7 100644
Binary files a/images/npcs/feral_dog.webp and b/images/npcs/feral_dog.webp differ
diff --git a/images/npcs/infected_human.webp b/images/npcs/infected_human.webp
index 704a5c7..57dd71a 100644
Binary files a/images/npcs/infected_human.webp and b/images/npcs/infected_human.webp differ
diff --git a/images/npcs/mutant_rat.webp b/images/npcs/mutant_rat.webp
index 9c0234c..b74fd7d 100644
Binary files a/images/npcs/mutant_rat.webp and b/images/npcs/mutant_rat.webp differ
diff --git a/images/npcs/raider_scout.webp b/images/npcs/raider_scout.webp
index dc4a445..eae8586 100644
Binary files a/images/npcs/raider_scout.webp and b/images/npcs/raider_scout.webp differ
diff --git a/images/npcs/scavenger.webp b/images/npcs/scavenger.webp
index fa83a35..01a2dbe 100644
Binary files a/images/npcs/scavenger.webp and b/images/npcs/scavenger.webp differ
diff --git a/images/static_npcs/mechanic_mike.webp b/images/static_npcs/mechanic_mike.webp
new file mode 100644
index 0000000..89984ba
Binary files /dev/null and b/images/static_npcs/mechanic_mike.webp differ
diff --git a/pwa/src/App.css b/pwa/src/App.css
index e9444f8..69a7a76 100644
--- a/pwa/src/App.css
+++ b/pwa/src/App.css
@@ -78,7 +78,7 @@ textarea:focus {
margin-top: 0.5rem;
}
-.success {
+.message-success {
color: #51cf66;
margin-top: 0.5rem;
}
diff --git a/pwa/src/components/AccountPage.css b/pwa/src/components/AccountPage.css
index 954aae8..6aeea0d 100644
--- a/pwa/src/components/AccountPage.css
+++ b/pwa/src/components/AccountPage.css
@@ -323,7 +323,7 @@
text-align: center;
}
-.success {
+.message-success {
background: rgba(40, 167, 69, 0.1);
color: #5ddc6c;
padding: 1rem;
diff --git a/pwa/src/components/AccountPage.tsx b/pwa/src/components/AccountPage.tsx
index e6823d7..7ebcd30 100644
--- a/pwa/src/components/AccountPage.tsx
+++ b/pwa/src/components/AccountPage.tsx
@@ -334,7 +334,7 @@ function AccountPage() {
/>
{emailError &&
{emailError}
}
- {emailSuccess && {emailSuccess}
}
+ {emailSuccess && {emailSuccess}
}
@@ -392,7 +392,7 @@ function AccountPage() {
/>
{passwordError && {passwordError}
}
- {passwordSuccess && {passwordSuccess}
}
+ {passwordSuccess && {passwordSuccess}
}
diff --git a/pwa/src/components/Game.css b/pwa/src/components/Game.css
index 41a1746..e8b5567 100644
--- a/pwa/src/components/Game.css
+++ b/pwa/src/components/Game.css
@@ -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;
diff --git a/pwa/src/components/Game.tsx b/pwa/src/components/Game.tsx
index 6c440e8..9fee843 100644
--- a/pwa/src/components/Game.tsx
+++ b/pwa/src/components/Game.tsx
@@ -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 (
+
- {/* 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)}
diff --git a/pwa/src/components/GameHeader.css b/pwa/src/components/GameHeader.css
index 6bfdbbf..047278e 100644
--- a/pwa/src/components/GameHeader.css
+++ b/pwa/src/components/GameHeader.css
@@ -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;
diff --git a/pwa/src/components/GameHeader.tsx b/pwa/src/components/GameHeader.tsx
index 27460be..f9ff37a 100644
--- a/pwa/src/components/GameHeader.tsx
+++ b/pwa/src/components/GameHeader.tsx
@@ -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
(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 (
+ {/* Left: Logo and Version */}
Echoes of the Ash
@@ -70,22 +101,45 @@ export default function GameHeader({ className = '' }: GameHeaderProps) {
-
+ {/* Center: Location/Combat Title */}
+
+ {combatInfo ? (
+
+ โ๏ธ
+ {combatInfo.enemyName}
+
+ {combatInfo.yourTurn ? t('combat.yourTurn') : t('combat.enemyTurn')}
+
+
+ ) : locationName ? (
+
+ {locationName}
+ {dangerLevel !== undefined && (
+
+ {dangerLevel === 0 ? t('danger.safe') : `โ ๏ธ ${dangerLevel}`}
+
+ )}
+
+ ) : null}
+
+
+ {/* Right: Navigation + User Info */}
+
+
-
diff --git a/pwa/src/components/GameLayout.tsx b/pwa/src/components/GameLayout.tsx
index 5600d12..1f54dda 100644
--- a/pwa/src/components/GameLayout.tsx
+++ b/pwa/src/components/GameLayout.tsx
@@ -1,11 +1,9 @@
import { Outlet } from 'react-router-dom'
-import GameHeader from './GameHeader'
import './Game.css'
export default function GameLayout() {
return (
-
diff --git a/pwa/src/components/common/GameButton.css b/pwa/src/components/common/GameButton.css
index 6ddff7e..9727b4b 100644
--- a/pwa/src/components/common/GameButton.css
+++ b/pwa/src/components/common/GameButton.css
@@ -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 */
diff --git a/pwa/src/components/common/GameDropdown.css b/pwa/src/components/common/GameDropdown.css
index 8fc09b8..15174fb 100644
--- a/pwa/src/components/common/GameDropdown.css
+++ b/pwa/src/components/common/GameDropdown.css
@@ -94,4 +94,12 @@
letter-spacing: 0.05em;
border-bottom: 1px solid var(--game-border-color, #4a5568);
margin-bottom: 0.25rem;
+}
+
+/* Vertical pickup options layout */
+.pickup-options-vertical {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ gap: 0.5rem;
}
\ No newline at end of file
diff --git a/pwa/src/components/common/ItemTooltipContent.tsx b/pwa/src/components/common/ItemTooltipContent.tsx
new file mode 100644
index 0000000..c769d47
--- /dev/null
+++ b/pwa/src/components/common/ItemTooltipContent.tsx
@@ -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 (
+
+ {/* Header */}
+
+ {item.emoji} {getTranslatedText(item.name)}
+
+
+ {/* Description */}
+ {item.description && (
+
{getTranslatedText(item.description)}
+ )}
+
+ {/* Weight/Volume */}
+
+
โ๏ธ {item.weight}kg {item.quantity > 1 && `(x${item.quantity})`}
+
๐ฆ {item.volume}L {item.quantity > 1 && `(x${item.quantity})`}
+
+
+ {/* Value (for trading) */}
+ {showValue && item.value !== undefined && (
+
+ ๐ฐ {t('game.value')}: {item.value * (item.quantity || 1)} coins
+
+ )}
+
+ {/* Stat Badges */}
+
+ {/* Capacity */}
+ {(stats.weight_capacity) && (
+
+ โ๏ธ +{stats.weight_capacity}kg
+
+ )}
+ {(stats.volume_capacity) && (
+
+ ๐ฆ +{stats.volume_capacity}L
+
+ )}
+
+ {/* Combat */}
+ {(stats.damage_min) && (
+
+ โ๏ธ {stats.damage_min}-{stats.damage_max}
+
+ )}
+ {(stats.armor) && (
+
+ ๐ก๏ธ +{stats.armor}
+
+ )}
+ {(stats.armor_penetration) && (
+
+ ๐ +{stats.armor_penetration} {t('stats.pen')}
+
+ )}
+ {(stats.crit_chance) && (
+
+ ๐ฏ +{Math.round(stats.crit_chance * 100)}% {t('stats.crit')}
+
+ )}
+ {(stats.accuracy) && (
+
+ ๐๏ธ +{Math.round(stats.accuracy * 100)}% {t('stats.acc')}
+
+ )}
+ {(stats.dodge_chance) && (
+
+ ๐จ +{Math.round(stats.dodge_chance * 100)}% Dodge
+
+ )}
+ {(stats.lifesteal) && (
+
+ ๐ง +{Math.round(stats.lifesteal * 100)}% {t('stats.life')}
+
+ )}
+
+ {/* Attributes */}
+ {(stats.strength_bonus) && (
+
+ ๐ช +{stats.strength_bonus} {t('stats.str')}
+
+ )}
+ {(stats.agility_bonus) && (
+
+ ๐ +{stats.agility_bonus} {t('stats.agi')}
+
+ )}
+ {(stats.endurance_bonus) && (
+
+ ๐๏ธ +{stats.endurance_bonus} {t('stats.end')}
+
+ )}
+ {(stats.hp_bonus) && (
+
+ โค๏ธ +{stats.hp_bonus} {t('stats.hpMax')}
+
+ )}
+ {(stats.stamina_bonus) && (
+
+ โก +{stats.stamina_bonus} {t('stats.stmMax')}
+
+ )}
+
+ {/* Consumables */}
+ {(item.hp_restore || effects.hp_restore) && (
+
+ โค๏ธ +{item.hp_restore || effects.hp_restore} HP
+
+ )}
+ {(item.stamina_restore || effects.stamina_restore) && (
+
+ โก +{item.stamina_restore || effects.stamina_restore} Stm
+
+ )}
+
+ {/* Status Effects */}
+ {effects.status_effect && (
+
+ )}
+
+ {effects.cures && effects.cures.length > 0 && (
+
+ ๐ {t('game.cures')}: {effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
+
+ )}
+
+
+ {/* Durability Bar */}
+ {hasDurability && (
+
+
+ {t('game.durability')}
+
+ {currentDurability} / {maxDurability}
+
+
+
+
+ )}
+
+ );
+};
diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx
index 7355843..5aafd09 100644
--- a/pwa/src/components/game/Combat.tsx
+++ b/pwa/src/components/game/Combat.tsx
@@ -13,6 +13,7 @@ interface CombatProps {
profile: any;
playerState: any;
equipment: any;
+ locationImage?: string;
onCombatAction: (action: string) => Promise
;
onPvPAction: (action: string, targetId: number) => Promise;
onExitCombat: () => void;
@@ -31,6 +32,7 @@ export const Combat: React.FC = ({
profile,
playerState,
equipment: _equipment,
+ locationImage,
onCombatAction,
onPvPAction,
onExitCombat,
@@ -734,6 +736,7 @@ export const Combat: React.FC = ({
combatResult={combatResult}
equipment={_equipment}
playerName={profile?.name}
+ locationImage={locationImage}
/>
{/* Supplies modal */}
= ({
e.stopPropagation()}>
{t('combat.modal.supplies_title')}
-
+ ร
@@ -201,9 +202,9 @@ export const CombatInventoryModal: React.FC = ({
{/* Action Button */}
-
+
))
)}
diff --git a/pwa/src/components/game/CombatView.tsx b/pwa/src/components/game/CombatView.tsx
index 1e5c935..8c861da 100644
--- a/pwa/src/components/game/CombatView.tsx
+++ b/pwa/src/components/game/CombatView.tsx
@@ -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 = ({
@@ -29,7 +32,8 @@ export const CombatView: React.FC = ({
isProcessing,
combatResult,
equipment,
- playerName
+ playerName,
+ locationImage
}) => {
const { t } = useTranslation();
const { playSfx } = useAudio();
@@ -117,44 +121,62 @@ export const CombatView: React.FC = ({
return (
- {/* Header (Location View Style) */}
-
-
- {state.isPvP ? t('combat.pvp_title') : t('combat.title')}
- vs
- {state.npcName || t('combat.unknown_enemy')}
-
- {state.turnTimeRemaining !== undefined && (
-
- โณ {state.turnTimeRemaining} s
-
- )}
- {state.isPvP && (
-
- {state.yourTurn ? '๐ฏ ' + t('combat.your_turn') : 'โณ ' + t('combat.opponent_turn')}
-
- )}
-
-
-
{/* Main Content Vertical Stack */}
- {/* 1. Enemy Avatar (Location Image Style) */}
- {/* Shake on npcHit, Attack on enemyAttacking, Dead on victory */}
-
-
+ {/* 1. Combat Scene: Location Background with NPC Overlay */}
+
+ {/* Location Background */}
+ {locationImage ? (
+

+ ) : (
+
๐
+ )}
+
+ {/* NPC Overlay (bottom-right corner) */}
+
{state.npcImage ? (
-

+

) : (
-
๐
+
๐
)}
+ {/* PvP Timer & Turn Indicator */}
+ {state.isPvP && (
+
+ {state.turnTimeRemaining !== undefined && (
+
+ โณ {state.turnTimeRemaining} s
+
+ )}
+
+ {state.yourTurn ?
+ ๐ฏ {t('combat.yourTurn') || 'Your Turn'} :
+ โณ {t('combat.enemyTurn') || 'Enemy Turn'}
+ }
+
+
+ )}
+
{/* 2. HP Bars (Character Sheet Style) - Staggered Lines */}
@@ -197,7 +219,7 @@ export const CombatView: React.FC
= ({
{/* Enemy HP (Left) */}
= ({
{/* 3. Actions */}
-
-
-
-
+ {t('common.close')}
+
+ )}
-
+ {!combatResult && (
+
+ onAction('attack')}
+ disabled={isProcessing || !state.yourTurn}
+ >
+ ๐ {t('combat.actions.attack')}
+
-
+ onAction('defend')}
+ disabled={isProcessing || !state.yourTurn}
+ >
+ ๐ก๏ธ {t('combat.actions.defend')}
+
-
-
+
+ ๐ {t('combat.actions.supplies')}
+
+
+
onAction('flee')}
+ disabled={isProcessing || !state.yourTurn}
+ >
+ ๐ {t('combat.actions.flee')}
+
+
+ )}
{/* 4. Log (Table) */}
@@ -276,7 +302,7 @@ export const CombatView: React.FC = ({
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 = ({
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 = ({
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 = ({
return (
| [{time}] |
- {text} |
+ {text} |
);
})}
diff --git a/pwa/src/components/game/DialogModal.css b/pwa/src/components/game/DialogModal.css
index 5898b80..abfef5f 100644
--- a/pwa/src/components/game/DialogModal.css
+++ b/pwa/src/components/game/DialogModal.css
@@ -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;
}
diff --git a/pwa/src/components/game/DialogModal.tsx b/pwa/src/components/game/DialogModal.tsx
index f1141d5..d7a974e 100644
--- a/pwa/src/components/game/DialogModal.tsx
+++ b/pwa/src/components/game/DialogModal.tsx
@@ -214,7 +214,7 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos
@@ -227,14 +227,14 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos
{/* BACK BUTTON */}
{(viewState === 'topic' || viewState === 'quest_preview') && (
-
+
← Back
)}
{/* NPC TOPICS */}
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
- handleTopicClick(topic)}>
+ handleTopicClick(topic)}>
๐ฌ {getLocalized(topic.title)}
))}
@@ -244,6 +244,7 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos
handleQuestClick(q)}
variant={q.status === 'active' ? 'warning' : 'info'}
>
@@ -254,7 +255,7 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos
{/* CONFIRM QUEST ACTION */}
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
-
+
Accept Quest
@@ -264,6 +265,7 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos
= ({ npcId, npcData, onClos
{/* TRADE - Only show in greeting */}
{viewState === 'greeting' && npcData.trade?.enabled && (
-
+
๐ฐ Trade
)}
{/* EXIT - Span full width */}
{viewState === 'greeting' && (
-
+
Goodbye
)}
diff --git a/pwa/src/components/game/InventoryModal.css b/pwa/src/components/game/InventoryModal.css
index cfa31bb..a0e49bc 100644
--- a/pwa/src/components/game/InventoryModal.css
+++ b/pwa/src/components/game/InventoryModal.css
@@ -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 {
diff --git a/pwa/src/components/game/LocationView.css b/pwa/src/components/game/LocationView.css
index b72ab40..15ceb8b 100644
--- a/pwa/src/components/game/LocationView.css
+++ b/pwa/src/components/game/LocationView.css
@@ -1,4 +1,32 @@
/* Grid View Styles */
+.entities-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1.5rem;
+ align-items: flex-start;
+}
+
+.entity-section {
+ flex: 1 1 300px;
+ max-width: 100%;
+ min-width: 0;
+}
+
+/* Ground item images */
+.ground-item-image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+/* Corpse images - grayed out */
+.corpse-image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ filter: grayscale(100%) brightness(0.6);
+}
+
.entity-list.grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
@@ -190,6 +218,14 @@
border-color: rgba(76, 175, 80, 1);
}
+.player-card.grid-card {
+ border-color: rgba(66, 153, 225, 0.5);
+}
+
+.player-card.grid-card:hover {
+ border-color: rgba(66, 153, 225, 1);
+}
+
.corpse-details-header {
display: flex;
justify-content: space-between;
@@ -318,4 +354,21 @@
.close-btn:hover {
color: #fff;
+}
+
+/* Corpse Loot Count - Bottom Right Badge */
+.corpse-loot-count {
+ position: absolute;
+ bottom: 4px;
+ right: 4px;
+ background: rgba(0, 0, 0, 0.8);
+ color: #ce93d8;
+ font-size: 0.75rem;
+ padding: 2px 6px;
+ font-weight: bold;
+ border: 1px solid rgba(206, 147, 216, 0.3);
+ clip-path: var(--game-clip-path-sm);
+ z-index: 5;
+ pointer-events: none;
+ /* Let clicks pass through to card */
}
\ No newline at end of file
diff --git a/pwa/src/components/game/LocationView.tsx b/pwa/src/components/game/LocationView.tsx
index 94109a4..6691271 100644
--- a/pwa/src/components/game/LocationView.tsx
+++ b/pwa/src/components/game/LocationView.tsx
@@ -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 (
-
- {getTranslatedText(location.name)}
- {location.danger_level !== undefined && location.danger_level === 0 && (
-
- โ Safe
-
- )}
- {location.danger_level !== undefined && location.danger_level > 0 && (
-
-
- โ ๏ธ {location.danger_level}
-
-
- )}
-
-
{location.tags && location.tags.length > 0 && (
{location.tags.map((tag: string, i: number) => {
@@ -232,15 +217,24 @@ function LocationView({
else if (tag === 'repair_station' && onOpenRepair) onOpenRepair()
}
- return (
-
-
- {tag === 'workbench' && t('tags.workbench')}
- {tag === 'repair_station' && t('tags.repairStation')}
+ {tag === 'workbench' ? t('tags.workbench') : t('tags.repairStation')}
+
+ )
+ }
+
+ // Regular span for non-interactive tags
+ return (
+
+
{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) => (
{msg.time}
- {msg.message}
+ {getTranslatedText(msg.message)}
+ {msg.location_name && (
+ [{msg.location_name}]
+ )}
))}
)}
+ {locationMessages.length === 0 && (
+
+
{t('location.recentActivity')}
+
+
+
+ {t('location.noRecentActivity', 'No recent activity')}
+
+
+
+
+ )}
- {/* Enemies */}
- {location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
-
-
{t('location.enemies')}
-
- {location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => {
- const id = `enemy-${enemy.id || i}`;
- return (
-
handleDropdownClick(e, id)}>
- {enemy.id && (
-
-

{ e.currentTarget.style.display = 'none' }}
- />
-
- )}
+ {/* Combined Entities Container for Grid Layout */}
+
+ {/* Enemies */}
+ {location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
+
+
{t('location.enemies')}
+
+ {location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any) => {
+ const isShaking = failedActionItemId == enemy.id;
+ const id = `enemy-${enemy.id}`;
-
- {getTranslatedText(enemy.name)}
- {t('location.level')} {enemy.level}
- Click for actions
-
- }>
-
-
-
- {/* Dropdown for Grid View */}
- {activeDropdown === id && (
-
setActiveDropdown(null)}
- width="160px"
- >
- {getTranslatedText(enemy.name)}
- { onInitiateCombat(enemy.id); setActiveDropdown(null); }}
- style={{ width: '100%', justifyContent: 'flex-start' }}
- >
- โ๏ธ {t('common.fight')}
-
-
-
- {t('location.level')} {enemy.level}
-
-
- )}
-
- );
- })}
-
-
- )}
-
- {/* Corpses */}
- {location.corpses && location.corpses.length > 0 && (
-
-
{t('location.corpses')}
-
- {location.corpses.map((corpse: any) => (
-
-
handleDropdownClick(e, `corpse-${corpse.id}`)}
- >
-
- {corpse.emoji} {getTranslatedText(corpse.name)}
- {corpse.loot_count} {t('location.items')}
-
- }>
-
-
{corpse.emoji}
-
{corpse.loot_count} items
-
-
-
- {activeDropdown === `corpse-${corpse.id}` && (
-
setActiveDropdown(null)}
- width="160px"
- >
- {getTranslatedText(corpse.name)}
- {
- 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')}
-
-
- )}
-
-
- ))}
-
-
- )}
-
- {/* Friendly NPCs */}
- {location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
-
-
{t('location.npcs')}
-
- {location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
-
handleNpcClick(npc)}
- style={{ cursor: 'pointer', position: 'relative' }}
- >
- {npc.image_path ? (
-
})
- ) : (
-
๐ง
- )}
-
- {renderIndicator(npc.id)}
-
-
- {getTranslatedText(npc.name)}
- Click to Interact
-
- }>
-
-
-
- ))}
-
-
- )}
-
- {/* Items on Ground - Stable Sort */}
- {location.items.length > 0 && (
-
-
{t('location.itemsOnGround')}
-
- {[...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 => (
-
{
- e.stopPropagation(); // Prevent closing
- onPickup(item.id, opt.qty);
- }}
- style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '2px' }}
- >
- ๐ค {t('common.pickUp')} ({opt.label})
-
- ));
- };
+ // Only render if valid
+ if (!enemy || !enemy.id) return null;
return (
-
handleDropdownClick(e, itemId)}
+
handleDropdownClick(e, id)}
>
+ {/* Enemy Image */}
+ {enemy.id && (
+
+

{ e.currentTarget.style.display = 'none' }}
+ />
+
+ )}
+
- {item.description && {getTranslatedText(item.description)}
}
- {item.weight !== undefined && item.weight > 0 && (
-
- โ๏ธ {t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
-
- )}
- {item.volume !== undefined && item.volume > 0 && (
-
- ๐ฆ {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
-
- )}
+
+
{getTranslatedText(enemy.name)}
+
{t('location.level')} {enemy.level}
+
Click for actions
}>
-
- {item.image_path ? (
-
})
{
- (e.target as HTMLImageElement).style.display = 'none';
- const icon = (e.target as HTMLImageElement).nextElementSibling;
- if (icon) icon.classList.remove('hidden');
- }}
- />
- ) : null}
-
{item.emoji || '๐ฆ'}
-
- {item.quantity > 1 && (
-
x{item.quantity}
- )}
-
+
- {activeDropdown === itemId && (
+ {/* Dropdown for Grid View */}
+ {activeDropdown === id && (
setActiveDropdown(null)}
width="160px"
>
- {getTranslatedText(item.name)}
-
- {renderPickupOptions()}
+
{getTranslatedText(enemy.name)}
+
{ onInitiateCombat(enemy.id); setActiveDropdown(null); }}
+ style={{ width: '100%', justifyContent: 'flex-start' }}
+ >
+ โ๏ธ {t('common.fight')}
+
+
+
+ {t('location.level')} {enemy.level}
)}
);
})}
+
-
- )}
+ )}
- {/* Other Players */}
- {location.other_players && location.other_players.length > 0 && (
-
-
๐ฅ Other Players
-
- {location.other_players.map((player: any, i: number) => (
-
-
๐ง
-
-
{player.name || player.username}
-
Lv. {player.level}
- {player.level_diff !== undefined && (
-
- {player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
-
- )}
+ {/* Corpses */}
+ {location.corpses && location.corpses.length > 0 && (
+
+
{t('location.corpses')}
+
+ {location.corpses.map((corpse: any) => (
+
+
handleDropdownClick(e, `corpse-${corpse.id}`)}
+ >
+
+ {corpse.emoji} {getTranslatedText(corpse.name)}
+ {corpse.loot_count} {t('location.items')}
+
+ }>
+
+ {corpse.image_path ? (
+
})
{
+ (e.target as HTMLImageElement).style.display = 'none';
+ (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
+ }}
+ />
+ ) : null}
+
+ {corpse.emoji}
+
+
{corpse.loot_count} items
+
+
+
+ {activeDropdown === `corpse-${corpse.id}` && (
+
setActiveDropdown(null)}
+ width="160px"
+ >
+ {getTranslatedText(corpse.name)}
+ {
+ 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')}
+
+
+ )}
+
- {player.can_pvp && (
-
-
-
- )}
- {!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
-
{t('game.levelDifferenceTooHigh')}
- )}
- {!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
-
{t('game.areaTooSafeForPvP')}
- )}
-
- ))}
+ ))}
+
-
- )}
+ )}
+
+ {/* Friendly NPCs */}
+ {location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
+
+
{t('location.npcs')}
+
+ {location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
+
handleNpcClick(npc)}
+ style={{ cursor: 'pointer', position: 'relative' }}
+ >
+ {npc.image_path ? (
+
})
+ ) : (
+
๐ง
+ )}
+
+ {renderIndicator(npc.id)}
+
+
+ {getTranslatedText(npc.name)}
+ Click to Interact
+
+ }>
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Items on Ground - Stable Sort */}
+ {location.items.length > 0 && (
+
+
{t('location.itemsOnGround')}
+
+ {[...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 (
+
+ {options.map((opt) => (
+ {
+ e.stopPropagation();
+ playSfx('/audio/sfx/pickup.wav');
+ onPickup(Number(item.id), opt.qty);
+ setActiveDropdown(null);
+ }}
+ style={{ width: '100%', justifyContent: 'center' }}
+ >
+ {opt.label}
+
+ ))}
+
+ );
+ };
+
+ return (
+
handleDropdownClick(e, itemId)}
+ >
+
+
+ Click to Interact
+ >
+ }>
+
+ {item.image_path ? (
+
})
{
+ (e.target as HTMLImageElement).style.display = 'none';
+ (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
+ }}
+ />
+ ) : null}
+
+ {item.emoji}
+
+ {item.quantity > 1 && (
+
x{item.quantity}
+ )}
+
+
+
+ {activeDropdown === itemId && (
+
setActiveDropdown(null)}
+ width="200px" // Wider for split buttons
+ >
+ {getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}
+
+ {/* Primary Action: Pick Up 1 */}
+ {
+ playSfx('/audio/sfx/pickup.wav');
+ onPickup(Number(item.id), 1);
+ setActiveDropdown(null);
+ }}
+ style={{ width: '100%', justifyContent: 'center', marginBottom: '8px' }}
+ >
+ โ {t('common.pickUp')}
+
+
+ {/* Quantity Options if > 1 */}
+ {item.quantity > 1 && (
+ <>
+
+ {renderPickupOptions()}
+ >
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Other Players */}
+ {location.other_players && location.other_players.length > 0 && (
+
+
๐ฅ {t('location.otherPlayers', 'Other Players')}
+
+ {location.other_players.map((player: any, i: number) => {
+ const playerId = `player-${player.id}-${i}`;
+ const canPvP = player.can_pvp;
+
+ return (
+
handleDropdownClick(e, playerId)}
+ >
+
+ {player.name || player.username}
+ {t('location.level', 'Level')} {player.level}
+ {player.level_diff !== undefined && (
+ 0 ? '#f56565' : '#48bb78' }}>
+ {player.level_diff > 0 ? `+${player.level_diff}` : player.level_diff} levels
+
+ )}
+ Click for actions
+
+ }>
+
+ {/* Placeholder for player image or avatar */}
+
+ ๐ง
+
+
+ Lv.{player.level}
+
+
+
+
+ {activeDropdown === playerId && (
+
setActiveDropdown(null)}
+ width="180px"
+ >
+ {player.name || player.username}
+
+ {canPvP ? (
+ {
+ onInitiatePvP(player.id);
+ setActiveDropdown(null);
+ }}
+ style={{ width: '100%', justifyContent: 'flex-start' }}
+ >
+ โ๏ธ {t('game.attack')}
+
+ ) : (
+
+ {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"}
+
+ )}
+
+
+
+ Level {player.level}
+
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
{/* Corpse Loot Overlay Modal */}
- {expandedCorpse && corpseDetails && corpseDetails.loot_items && (
- onSetExpandedCorpse(null)}>
-
e.stopPropagation()}>
-
-
{t('location.lootableItems')}
+ {
+ expandedCorpse && corpseDetails && corpseDetails.loot_items && (
+
onSetExpandedCorpse(null)}>
+
e.stopPropagation()}>
+
+
{t('location.lootableItems')}
+
+
+
+ {corpseDetails.loot_items.map((item: any) => (
+
+ {/* Item Image */}
+
+ {item.image_path ? (
+
})
{
+ (e.target as HTMLImageElement).style.display = 'none';
+ }}
+ />
+ ) :
{item.emoji || '๐ฆ'}}
+
+
+
+
+ {getTranslatedText(item.item_name)}
+
+ {item.description &&
{getTranslatedText(item.description)}
}
+
+ {t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
+
+ {item.required_tool && (
+
+ ๐ง {getTranslatedText(item.required_tool_name)} {item.has_tool ? 'โ' : 'โ'}
+
+ )}
+
+
+
+
+
+
+ ))}
+
-
- {corpseDetails.loot_items.map((item: any) => (
-
- {/* Item Image */}
-
- {item.image_path ? (
-
})
{
- (e.target as HTMLImageElement).style.display = 'none';
- }}
- />
- ) :
{item.emoji || '๐ฆ'}}
-
-
-
-
- {getTranslatedText(item.item_name)}
-
- {item.description &&
{getTranslatedText(item.description)}
}
-
- {t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
-
- {item.required_tool && (
-
- ๐ง {getTranslatedText(item.required_tool_name)} {item.has_tool ? 'โ' : 'โ'}
-
- )}
-
-
-
-
-
-
- ))}
-
-
-
- )}
+ )
+ }
- {(showCraftingMenu || showRepairMenu) && (
-
- )}
+ {
+ (showCraftingMenu || showRepairMenu) && (
+
+ )
+ }
- {activeDialogNpc && activeNpcData && (
-
setActiveDialogNpc(null)}
- onTrade={() => {
- setActiveDialogNpc(null);
- setShowTradeModal(true);
- }}
- />
- )}
+ {
+ activeDialogNpc && activeNpcData && (
+ setActiveDialogNpc(null)}
+ onTrade={() => {
+ setActiveDialogNpc(null);
+ setShowTradeModal(true);
+ }}
+ />
+ )
+ }
- {showTradeModal && activeNpcData && (
- setShowTradeModal(false)}
- />
- )}
-
+ {
+ showTradeModal && activeNpcData && (
+
setShowTradeModal(false)}
+ />
+ )
+ }
+
)
}
diff --git a/pwa/src/components/game/MovementControls.tsx b/pwa/src/components/game/MovementControls.tsx
index 9d5604e..3fe35c0 100644
--- a/pwa/src/components/game/MovementControls.tsx
+++ b/pwa/src/components/game/MovementControls.tsx
@@ -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)
}>
-
+
)
})}
diff --git a/pwa/src/components/game/PlayerSidebar.tsx b/pwa/src/components/game/PlayerSidebar.tsx
index fb7d436..7dcdf31 100644
--- a/pwa/src/components/game/PlayerSidebar.tsx
+++ b/pwa/src/components/game/PlayerSidebar.tsx
@@ -296,7 +296,7 @@ function PlayerSidebar({
setShowInventory(true)}
style={{ width: '100%', justifyContent: 'center' }}
>
@@ -306,7 +306,7 @@ function PlayerSidebar({
diff --git a/pwa/src/components/game/Workbench.css b/pwa/src/components/game/Workbench.css
index c7c1dca..1ba51b0 100644
--- a/pwa/src/components/game/Workbench.css
+++ b/pwa/src/components/game/Workbench.css
@@ -173,7 +173,7 @@
display: flex;
flex-direction: column;
border-right: 1px solid var(--game-border-color);
- background: var(--game-bg-app);
+ background: var(--game-bg-panel);
overflow: hidden;
}
@@ -199,7 +199,7 @@
display: flex;
align-items: center;
padding: 0.75rem;
- background: var(--game-bg-card);
+ background: #ffffff08;
border: 1px solid var(--game-border-color);
clip-path: var(--game-clip-path);
cursor: pointer;
@@ -485,7 +485,7 @@
width: 100%;
height: 100%;
object-fit: contain;
- padding: 10px;
+ background: var(--game-bg-card);
}
.detail-title {
@@ -501,8 +501,6 @@
}
.detail-requirements {
- width: 100%;
- max-width: 600px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
clip-path: var(--game-clip-path);
@@ -619,4 +617,23 @@
letter-spacing: 0.5px;
display: inline-block;
vertical-align: middle;
+}
+
+/* Action Info Labels */
+.action-info {
+ text-align: center;
+ font-size: 0.9rem;
+ color: #a0aec0;
+ padding: 0.5rem 0;
+ margin-bottom: 0.5rem;
+}
+
+.action-info.warning {
+ color: #ed8936;
+ font-weight: 600;
+}
+
+.action-info.success {
+ color: #48bb78;
+ font-weight: 600;
}
\ No newline at end of file
diff --git a/pwa/src/components/game/Workbench.tsx b/pwa/src/components/game/Workbench.tsx
index 6927eaf..9209e72 100644
--- a/pwa/src/components/game/Workbench.tsx
+++ b/pwa/src/components/game/Workbench.tsx
@@ -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(null)
+ const [salvageQuantity, setSalvageQuantity] = useState(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({
-
+ )}
+ {item.meets_level && !item.can_craft && (
+ {t('crafting.missingRequirements')}
+ )}
+ {item.can_craft && (
+ {t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}
+ )}
+ onCraft(item.item_id)}
- style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
+ style={{ width: '100%' }}
>
-
- {!item.meets_level ? t('crafting.levelRequired', { level: item.craft_level }) :
- !item.can_craft ? t('crafting.missingRequirements') : t('crafting.craftItem')}
-
- {item.can_craft && (
-
- {t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}
-
- )}
-
+ {t('crafting.craftItem')}
+
>
)}
@@ -324,22 +333,23 @@ function Workbench({
-
+ )}
+ {item.needs_repair && !item.can_repair && (
+ {t('crafting.missingRequirements')}
+ )}
+ {item.needs_repair && item.can_repair && (
+ {t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}
+ )}
+ 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%' }}
>
-
- {!item.needs_repair ? t('crafting.alreadyFull') :
- !item.can_repair ? t('crafting.missingRequirements') : t('crafting.repairItem')}
-
- {item.needs_repair && item.can_repair && (
-
- {t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}
-
- )}
-
+ {t('crafting.repairItem')}
+
>
)}
@@ -403,21 +413,49 @@ function Workbench({
-
+
+ {/* Quantity Selector for Salvage */}
+ {item.quantity > 1 && (
+
+ setSalvageQuantity(Math.max(1, salvageQuantity - 1))}
+ >
+ -
+
+ {salvageQuantity} / {item.quantity}
+ setSalvageQuantity(Math.min(item.quantity, salvageQuantity + 1))}
+ >
+ +
+
+ setSalvageQuantity(item.quantity)}
+ >
+ Max
+
+
+ )}
+
+
{
- 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%' }}
>
- โป๏ธ {t('game.salvage')}
-
- {t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
-
-
+ โป๏ธ {t('game.salvage')} {item.quantity > 1 ? `(x${salvageQuantity})` : ''}
+
>
)}
diff --git a/pwa/src/components/game/hooks/useGameEngine.ts b/pwa/src/components/game/hooks/useGameEngine.ts
index dd726c0..bd24669 100644
--- a/pwa/src/components/game/hooks/useGameEngine.ts
+++ b/pwa/src/components/game/hooks/useGameEngine.ts
@@ -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
handleEquipItem: (inventoryId: number) => Promise
handleUnequipItem: (slot: string) => Promise
- handleDropItem: (itemId: string, quantity?: number) => Promise
+ handleDropItem: (itemId: string, quantity?: number, inventoryId?: number) => Promise
// Crafting/Workbench
handleOpenCrafting: () => Promise
@@ -92,7 +93,7 @@ export interface GameEngineActions {
handleCraft: (itemId: string) => Promise
handleOpenRepair: () => Promise
handleRepairFromMenu: (uniqueItemId: number, inventoryId?: number) => Promise
- handleUncraft: (uniqueItemId: number, inventoryId: number) => Promise
+ handleUncraft: (uniqueItemId: number, inventoryId: number, quantity?: number) => Promise
handleSwitchWorkbenchTab: (tab: WorkbenchTab) => Promise
// Combat
@@ -189,7 +190,14 @@ export function useGameEngine(
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState('none')
const [mobileHeaderOpen, setMobileHeaderOpen] = useState(false)
- const [locationMessages, setLocationMessages] = useState([])
+ const [locationMessages, setLocationMessages] = useState(() => {
+ try {
+ const saved = sessionStorage.getItem('locationMessages')
+ return saved ? JSON.parse(saved) : []
+ } catch {
+ return []
+ }
+ })
const [interactableCooldowns, setInteractableCooldowns] = useState>({})
const [loadedTabs, setLoadedTabs] = useState>(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!'
diff --git a/pwa/src/components/game/types.ts b/pwa/src/components/game/types.ts
index 02daaae..d22cd1c 100644
--- a/pwa/src/components/game/types.ts
+++ b/pwa/src/components/game/types.ts
@@ -77,6 +77,7 @@ export interface CombatLogEntry {
export interface LocationMessage {
time: string
message: string
+ location_name?: string
}
export interface Equipment {
diff --git a/pwa/src/contexts/GameContext.tsx b/pwa/src/contexts/GameContext.tsx
index 76ff8d9..65f5721 100644
--- a/pwa/src/contexts/GameContext.tsx
+++ b/pwa/src/contexts/GameContext.tsx
@@ -24,3 +24,8 @@ export const useGame = () => {
}
return context;
};
+
+// Optional hook that doesn't throw when outside GameProvider
+export const useOptionalGame = () => {
+ return useContext(GameContext);
+};
diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json
index 070f168..3d4101c 100644
--- a/pwa/src/i18n/locales/en.json
+++ b/pwa/src/i18n/locales/en.json
@@ -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",
diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json
index 57e91db..ec59456 100644
--- a/pwa/src/i18n/locales/es.json
+++ b/pwa/src/i18n/locales/es.json
@@ -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.",
diff --git a/pwa/src/index.css b/pwa/src/index.css
index de05416..339b72b 100644
--- a/pwa/src/index.css
+++ b/pwa/src/index.css
@@ -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);
diff --git a/pwa/src/utils/assetPath.ts b/pwa/src/utils/assetPath.ts
index 6376f1e..5f54871 100644
--- a/pwa/src/utils/assetPath.ts
+++ b/pwa/src/utils/assetPath.ts
@@ -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}`
}
/**