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 ? ( + Location + ) : ( +
๐ŸŒ„
+ )} + + {/* NPC Overlay (bottom-right corner) */} +
{state.npcImage ? ( - {state.npcName} + {state.npcName} ) : ( -
๐Ÿ’€
+
๐Ÿ’€
)}
+ {/* 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
{npcName}
@@ -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 && ( -
- {getTranslatedText(enemy.name)} { 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 ? ( - {getTranslatedText(npc.name)} - ) : ( - ๐Ÿง‘ - )} - - {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 && ( +
+ {getTranslatedText(enemy.name)} { 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 ? ( - {getTranslatedText(item.name)} { - (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 ? ( + {getTranslatedText(corpse.name)} { + (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 ? ( + {getTranslatedText(npc.name)} + ) : ( + ๐Ÿง‘ + )} + + {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 ? ( + {getTranslatedText(item.name)} { + (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 ? ( + {item.item_name} { + (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 ? ( - {item.item_name} { - (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}` } /**