diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2f6c588 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md - Echoes of the Ash + +## Project Overview +- **Type**: Dark Fantasy RPG Adventure +- **Stack**: Monorepo with Python/FastAPI backend and React/Vite/TypeScript frontend. +- **Infrastructure**: Docker Compose (Postgres, Redis, Traefik). +- **Primary Target**: Web (PWA + API). Electron is secondary. + +## Commands + +### Development & Deployment +- **Start (Dev)**: `docker compose up -d` +- **Apply Changes**: `docker compose build && docker compose up -d` (Required for both code and env changes) +- **Restart API**: `docker compose restart echoes_of_the_ashes_api` +- **View Logs**: `docker compose logs -f [service_name]` (e.g., `echoes_of_the_ashes_api`, `echoes_of_the_ashes_pwa`) + +### Frontend (PWA) +- **Directory**: `pwa/` +- **Install**: `npm install` +- **Dev Server**: `npm run dev` +- **Build**: `npm run build` +- **Lint**: `npm run lint` + +### Backend (API) +- **Directory**: `api/` +- **Dependencies**: `requirements.txt` +- **Manual Run**: `uvicorn main:app --reload` (Local only, relies on env vars) + +### Testing +- **Directory**: `tests/` +- **Status**: Temporary/Manual scripts. +- **Run**: `python tests/test_api.py` (Run locally or inside container depending on env access) + +## Architecture & Code Structure + +### Backend (`api/`) +- **Entry**: `main.py` +- **Routers**: `routers/` (Modular endpoints: `game_routes.py`, `combat.py`, `auth.py`, etc.) +- **Core**: `core/` (Config, Security, WebSockets) +- **Services**: `services/` (Models, Helpers) +- **Pattern**: + - Use `routers` for new features. + - Register routers in `main.py` (auto-registration logic exists but explicit is clearer). + - Pydantic models in `services/models.py`. + +### Frontend (`pwa/`) +- **Entry**: `src/main.tsx` +- **Styling**: Standard CSS files per component (e.g., `components/Game.css`). No Tailwind/Modules. +- **State**: Zustand stores (`src/stores/`). +- **Translation**: i18next (`src/i18n/`). + +## Style Guidelines +- **Python**: PEP8 standard. No strict linter enforced. +- **TypeScript**: Standard ESLint rules from Vite template. +- **CSS**: Plain CSS. Keep component styles in dedicated files. +- **Docs**: update `QUICK_REFERENCE.md` if simplified logic or architecture changes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8d0959 --- /dev/null +++ b/README.md @@ -0,0 +1,627 @@ +# Echoes of the Ash 🌆 + +A dark fantasy post-apocalyptic survival RPG featuring exploration, combat, crafting, and scavenging in a ruined world. + +## 🎮 Game Features + +### Core Gameplay + +#### 🗺️ Exploration & Movement +- **Grid-based world navigation** with coordinates (x, y) +- **Stamina-based movement system** - each move costs stamina based on distance +- **Multiple biomes and locations** with varying danger levels (0-4) +- **Dynamic location discovery** as you explore +- **Compass-based directional movement** (North, South, East, West) + +#### ⚔️ Combat System +- **Turn-based combat** with real-time intent preview +- **NPC enemy encounters** with weighted spawn tables per location +- **Status effects system**: Bleeding, Infected, Radiation +- **Weapon effects**: Bleeding, Stun, Armor Break +- **Flee mechanics** - escape combat with success/failure chance +- **XP and leveling system** - gain XP from defeating enemies +- **PvP (Player vs Player) combat** - challenge other players +- **Death and respawn mechanics** + +#### 🎒 Inventory & Equipment +- **Weight and volume-based inventory** system +- **Equipment slots**: Weapon, Backpack, Armor, Head, Tool +- **Durability system** - items degrade with use +- **Item tiers** (1-3) affecting quality and stats +- **Encumbrance system** - affects stamina costs +- **Ground item drops** - pick up and drop items + +#### 🔨 Crafting & Repair +- **Crafting system** with material requirements +- **Tool requirements** for certain recipes +- **Repair mechanics** - restore item durability +- **Uncrafting/Disassembly** - break down items for materials +- **Workbench locations** for advanced crafting +- **Craft level requirements** - unlocked through progression + +#### 🔍 Scavenging & Interactables +- **Searchable objects** in each location (dumpsters, cars, houses, etc.) +- **Action-based interaction** system with stamina costs +- **Success/failure mechanics** with critical outcomes +- **Loot tables** with item drop chances +- **One-time and respawning interactables** +- **Status tracking** per player (already looted, depleted, etc.) + +#### 📊 Character Progression +- **Level system** (1-50+) with XP requirements +- **Stat points** - allocate to Strength, Defense, Stamina +- **Character customization** on creation +- **Skill progression** tied to crafting levels + +#### 🌍 World Features +- **Multi-location world** (Downtown, Gas Station, Residential, Clinic, Plaza, Park, Warehouse, Office Buildings, Subway, etc.) +- **Location tags** - workbench, repair_station, safe_zone +- **Danger zones** with varying encounter rates +- **Location-specific loot** and enemy spawns + +#### 💬 Social & Multiplayer +- **Online player tracking** via WebSockets +- **Real-time player position updates** +- **PvP combat system** with challenge mechanics +- **Character browsing** - see other players' stats + +#### 🎨 PWA Features +- **Progressive Web App** - installable on mobile/desktop +- **Multi-language support** (English, Spanish) +- **Responsive UI** with mobile-first design +- **Real-time updates** via WebSockets +- **Offline capabilities** (service worker) + +--- + +## 📁 Gamedata Structure + +The game uses JSON files in the `gamedata/` directory to define all game content. This modular approach makes it easy to add new content without code changes. + +### Directory Layout + +``` +gamedata/ +├── npcs.json # Enemy NPCs and combat encounters +├── items.json # All items, weapons, consumables, and resources +├── locations.json # World map locations and interactables +└── interactables.json # Interactable object templates +``` + +--- + +## 📋 `npcs.json` Structure + +Defines all enemy NPCs, their stats, loot tables, and spawn locations. + +### Top-Level Structure +```json +{ + "npcs": { ... }, // NPC definitions + "danger_levels": { ... }, // Danger settings per location + "spawn_tables": { ... } // Enemy spawn weights per location +} +``` + +### NPC Definition +```json +"npc_id": { + "npc_id": "unique_npc_identifier", + "name": { + "en": "English Name", + "es": "Spanish Name" + }, + "description": { + "en": "English description", + "es": "Spanish description" + }, + "emoji": "🐕", + "hp_min": 15, // Minimum HP when spawned + "hp_max": 25, // Maximum HP when spawned + "damage_min": 3, // Minimum attack damage + "damage_max": 7, // Maximum attack damage + "defense": 0, // Damage reduction + "xp_reward": 10, // XP given on defeat + "loot_table": [ // Items dropped on death (automatic) + { + "item_id": "raw_meat", + "quantity_min": 1, + "quantity_max": 2, + "drop_chance": 0.6 // 60% chance to drop + } + ], + "corpse_loot": [ // Items harvestable from corpse + { + "item_id": "animal_hide", + "quantity_min": 1, + "quantity_max": 1, + "required_tool": "knife" // Tool needed to harvest (null = no requirement) + } + ], + "flee_chance": 0.3, // Chance NPC flees from combat + "status_inflict_chance": 0.15, // Chance to inflict status effect on hit + "image_path": "images/npcs/feral_dog.webp", + "death_message": "The feral dog whimpers and collapses..." +} +``` + +### Danger Levels +```json +"location_id": { + "danger_level": 2, // 0-4 scale + "encounter_rate": 0.2, // 20% chance per movement + "wandering_chance": 0.35 // 35% chance for random encounter while idle +} +``` + +### Spawn Tables +```json +"location_id": [ + { + "npc_id": "raider_scout", + "weight": 50 // Weighted random spawn (higher = more common) + }, + { + "npc_id": "infected_human", + "weight": 30 + } +] +``` + +**Available NPCs:** +- `feral_dog` - Wild, hungry canine (Tier 1) +- `mutant_rat` - Radiation-mutated rodent (Tier 1) +- `raider_scout` - Hostile human raider (Tier 2) +- `scavenger` - Aggressive survivor (Tier 2) +- `infected_human` - Virus-infected zombie-like human (Tier 3) + +--- + +## 🎒 `items.json` Structure + +Defines all items, equipment, weapons, consumables, and crafting materials. + +### Item Categories (Types) +- `resource` - Raw materials for crafting +- `consumable` - Food, medicine, usable items +- `weapon` - Melee and ranged weapons +- `backpack` - Inventory capacity upgrades +- `armor` - Protective equipment +- `tool` - Utility items (flashlight, etc.) +- `quest` - Story/quest items + +### Basic Item Structure +```json +"item_id": { + "name": { + "en": "Item Name", + "es": "Spanish Name" + }, + "description": { + "en": "Description text", + "es": "Spanish description" + }, + "type": "resource", + "weight": 0.5, // Kilograms + "volume": 0.2, // Liters + "emoji": "⚙️", + "image_path": "images/items/scrap_metal.webp" +} +``` + +### Consumable Items +```json +"item_id": { + ...basic fields..., + "type": "consumable", + "hp_restore": 20, // Health restored + "stamina_restore": 10, // Stamina restored + "treats": "Bleeding" // Status effect cured (optional) +} +``` + +### Weapon/Equipment Items +```json +"item_id": { + ...basic fields..., + "type": "weapon", + "equippable": true, + "slot": "weapon", // Equipment slot: weapon, backpack, armor, head, tool + "durability": 100, // Max durability + "tier": 2, // 1-3 quality tier + "encumbrance": 2, // Stamina penalty when equipped + "stats": { + "damage_min": 5, + "damage_max": 10, + "weight_capacity": 20, // For backpacks + "volume_capacity": 20, + "defense": 5 // For armor + }, + "weapon_effects": { // Status effects inflicted (optional) + "bleeding": { + "chance": 0.15, // 15% chance on hit + "damage": 2, // Damage per turn + "duration": 3 // Turns + } + } +} +``` + +### Craftable Items +```json +"item_id": { + ...other fields..., + "craftable": true, + "craft_level": 2, // Required crafting level + "craft_materials": [ + { + "item_id": "scrap_metal", + "quantity": 3 + } + ], + "craft_tools": [ // Tools consumed during crafting + { + "item_id": "hammer", + "durability_cost": 3 // Durability consumed + } + ] +} +``` + +### Repairable Items +```json +"item_id": { + ...other fields..., + "repairable": true, + "repair_materials": [ + { + "item_id": "scrap_metal", + "quantity": 2 + } + ], + "repair_tools": [ + { + "item_id": "hammer", + "durability_cost": 2 + } + ], + "repair_percentage": 30 // % of max durability restored +} +``` + +### Uncraftable Items (Disassembly) +```json +"item_id": { + ...other fields..., + "uncraftable": true, + "uncraft_yield": [ // Materials returned + { + "item_id": "scrap_metal", + "quantity": 2 + } + ], + "uncraft_loss_chance": 0.25, // 25% chance to lose materials + "uncraft_tools": [ + { + "item_id": "hammer", + "durability_cost": 1 + } + ] +} +``` + +**Item Examples:** +- **Resources:** `scrap_metal`, `cloth_scraps`, `wood_planks`, `bone`, `raw_meat` +- **Consumables:** `canned_food`, `water_bottle`, `bandage`, `antibiotics`, `rad_pills` +- **Weapons:** `rusty_knife`, `knife`, `tire_iron`, `makeshift_spear`, `reinforced_bat` +- **Backpacks:** `tattered_rucksack`, `hiking_backpack` +- **Tools:** `flashlight`, `hammer` + +--- + +## 🗺️ `locations.json` Structure + +Defines the game world, all locations, coordinates, and interactable objects. + +### Location Definition +```json +{ + "id": "location_id", + "name": { + "en": "🏚️ Location Name", + "es": "Spanish Name" + }, + "description": { + "en": "Atmospheric description of the location...", + "es": "Spanish description" + }, + "image_path": "images/locations/location.webp", + "x": 0, // Grid X coordinate + "y": 2, // Grid Y coordinate + "tags": [ // Optional tags + "workbench", // Has crafting bench + "repair_station", // Can repair items + "safe_zone" // No random encounters + ], + "interactables": { ... } // Interactable objects at this location +} +``` + +### Interactable Object Instance +```json +"unique_interactable_id": { + "template_id": "dumpster", // References interactables.json + "outcomes": { + "action_id": { + "stamina_cost": 2, + "success_rate": 0.5, // 50% base success chance + "crit_success_chance": 0.1, // 10% chance for critical success + "crit_failure_chance": 0.1, // 10% chance for critical failure + "rewards": { + "damage": 0, // Damage on normal failure + "crit_damage": 8, // Damage on critical failure + "items": [ // Items on normal success + { + "item_id": "plastic_bottles", + "quantity": 3, + "chance": 1.0 // 100% drop rate + } + ], + "crit_items": [ // Items on critical success + { + "item_id": "rare_item", + "quantity": 1, + "chance": 0.5 + } + ] + }, + "text": { // Locale-specific text responses + "success": { + "en": "You find something useful!", + "es": "¡Encuentras algo útil!" + }, + "failure": { + "en": "Nothing here.", + "es": "Nada aquí." + }, + "crit_success": { ... }, + "crit_failure": { ... } + } + } + } +} +``` + +**Available Locations:** +- `start_point` - Ruined Downtown Core (0, 0) - Starting location +- `gas_station` - Abandoned Gas Station (0, 2) - Has workbench +- `residential` - Residential Street (3, 0) +- `clinic` - Old Clinic (2, 3) - Medical supplies +- `plaza` - Shopping Plaza (-2.5, 0) +- `park` - Suburban Park (-1, -2) +- `overpass` - Highway Overpass (1.0, 4.5) +- `warehouse` - Warehouse District +- `office_building` - Office Tower +- `subway` - Subway Station + +--- + +## 🔍 `interactables.json` Structure + +Defines templates for interactable objects that can be placed in locations. + +### Interactable Template +```json +"template_id": { + "id": "template_id", + "name": { + "en": "🗑️ Object Name", + "es": "Spanish Name" + }, + "description": { + "en": "Object description", + "es": "Spanish description" + }, + "image_path": "images/interactables/object.webp", + "actions": { // Available actions for this object + "action_id": { + "id": "action_id", + "label": { + "en": "🔎 Action Label", + "es": "Spanish Label" + }, + "stamina_cost": 2 // Base stamina cost (can be overridden in locations) + } + } +} +``` + +**Available Interactable Templates:** +- `rubble` - Pile of debris (Action: search) +- `dumpster` - Trash container (Action: search_dumpster) +- `sedan` - Abandoned car (Actions: search_glovebox, pop_trunk) +- `house` - Abandoned house (Action: search_house) +- `toolshed` - Tool shed (Action: search_shed) +- `medkit` - Medical supply cabinet (Action: search_medkit) +- `storage_box` - Storage container (Action: search) +- `vending_machine` - Vending machine (Actions: break, search) + +--- + +## 🛠️ Replicating Gamedata + +### Adding a New NPC + +1. **Create NPC definition** in `npcs.json` under `"npcs"`: +```json +"my_new_npc": { + "npc_id": "my_new_npc", + "name": { "en": "My NPC", "es": "Mi NPC" }, + "description": { "en": "Description", "es": "Descripción" }, + "emoji": "👹", + "hp_min": 20, "hp_max": 30, + "damage_min": 4, "damage_max": 8, + "defense": 1, + "xp_reward": 15, + "loot_table": [...], + "corpse_loot": [...], + "flee_chance": 0.2, + "status_inflict_chance": 0.1, + "image_path": "images/npcs/my_new_npc.webp", + "death_message": "The creature falls..." +} +``` + +2. **Add to spawn table** in `npcs.json` under `"spawn_tables"`: +```json +"location_id": [ + { "npc_id": "my_new_npc", "weight": 40 } +] +``` + +3. **Add image** at `images/npcs/my_new_npc.webp` + +### Adding a New Item + +1. **Create item definition** in `items.json`: +```json +"my_new_item": { + "name": { "en": "My Item", "es": "Mi Objeto" }, + "description": { "en": "Description", "es": "Descripción" }, + "type": "resource", + "weight": 1.0, + "volume": 0.5, + "emoji": "🔮", + "image_path": "images/items/my_new_item.webp" +} +``` + +2. **Add to loot tables** (optional) in locations or NPCs + +3. **Add image** at `images/items/my_new_item.webp` + +### Adding a New Location + +1. **Create location** in `locations.json`: +```json +{ + "id": "my_location", + "name": { "en": "🏭 My Location", "es": "Mi Ubicación" }, + "description": { "en": "Description", "es": "Descripción" }, + "image_path": "images/locations/my_location.webp", + "x": 5, + "y": 3, + "tags": ["workbench"], + "interactables": { + "my_location_box": { + "template_id": "storage_box", + "outcomes": { + "search": { ...outcome definition... } + } + } + } +} +``` + +2. **Add danger level** in `npcs.json`: +```json +"my_location": { + "danger_level": 2, + "encounter_rate": 0.15, + "wandering_chance": 0.3 +} +``` + +3. **Add spawn table** in `npcs.json`: +```json +"my_location": [ + { "npc_id": "raider_scout", "weight": 60 }, + { "npc_id": "mutant_rat", "weight": 40 } +] +``` + +4. **Add image** at `images/locations/my_location.webp` + +### Adding a New Interactable Template + +1. **Create template** in `interactables.json`: +```json +"my_interactable": { + "id": "my_interactable", + "name": { "en": "🎰 My Object", "es": "Mi Objeto" }, + "description": { "en": "Description", "es": "Descripción" }, + "image_path": "images/interactables/my_object.webp", + "actions": { + "my_action": { + "id": "my_action", + "label": { "en": "🔨 Do Action", "es": "Hacer Acción" }, + "stamina_cost": 3 + } + } +} +``` + +2. **Use in locations** in `locations.json` interactables + +3. **Add image** at `images/interactables/my_object.webp` + +--- + +## 🎯 Key Game Mechanics + +### Stamina System +- Base stamina pool (increases with Stamina stat) +- Regenerates passively over time +- Consumed by: Movement, Combat Actions, Interactions, Crafting +- Encumbrance from equipment increases stamina costs + +### Combat Flow +1. Player or NPC initiates combat +2. Turn-based with initiative system +3. NPCs show **intent preview** (next planned action) +4. Player chooses: Attack, Defend, Use Item, Flee +5. Status effects tick each turn +6. Combat ends on death or successful flee + +### Loot System +- **Immediate drops** from loot_table (on death) +- **Corpse harvesting** from corpse_loot (requires tools) +- **Interactable loot** with success/failure mechanics +- **Respawn timers** for interactables + +### Crafting Requirements +- Sufficient materials in inventory +- Required tools with durability +- Crafting level unlocked +- Optional: Workbench location tag + +--- + +## 📚 Additional Documentation + +- **[CLAUDE.md](./CLAUDE.md)** - Project structure and development commands +- **[QUICK_REFERENCE.md](./QUICK_REFERENCE.md)** - API endpoints and architecture +- **[docker-compose.yml](./docker-compose.yml)** - Infrastructure setup + +--- + +## 🚀 Quick Start + +```bash +# Start the game +docker compose up -d + +# View API logs +docker compose logs -f echoes_of_the_ashes_api + +# Rebuild after changes +docker compose build && docker compose up -d +``` + +Game runs at: `http://localhost` (PWA) and `http://localhost/api` (API) + +--- + +## 📝 License + +All rights reserved. Post-apocalyptic survival simulation for educational purposes. diff --git a/api/background_tasks.py b/api/background_tasks.py index e531cab..295be0d 100644 --- a/api/background_tasks.py +++ b/api/background_tasks.py @@ -135,18 +135,24 @@ async def spawn_manager_loop(manager=None): if manager: from datetime import datetime npc_def = NPCS.get(npc_id) - npc_name = npc_def.name if npc_def else npc_id.replace('_', ' ').title() + npc_name_obj = npc_def.name if npc_def else npc_id.replace('_', ' ').title() + # Handle localized name for the fallback message + if isinstance(npc_name_obj, dict): + npc_name_en = npc_name_obj.get('en', str(npc_name_obj)) + else: + npc_name_en = str(npc_name_obj) + await manager.send_to_location( location_id=location_id, message={ "type": "location_update", "data": { - "message": f"A {npc_name} appeared!", + "message": f"A {npc_name_en} appeared!", "action": "enemy_spawned", "npc_data": { "id": enemy_data['id'], "npc_id": npc_id, - "name": npc_name, + "name": npc_name_obj, "type": "enemy", "is_wandering": True, "image_path": npc_def.image_path if npc_def else None @@ -209,7 +215,8 @@ async def decay_dropped_items(manager=None): "type": "location_update", "data": { "message": f"{count} dropped item(s) decayed", - "action": "items_decayed" + "action": "items_decayed", + "count": count }, "timestamp": datetime.utcnow().isoformat() } @@ -472,7 +479,8 @@ async def decay_corpses(manager=None): "type": "location_update", "data": { "message": f"{total} {corpse_type} decayed", - "action": "corpses_decayed" + "action": "corpses_decayed", + "count": total }, "timestamp": datetime.utcnow().isoformat() } diff --git a/api/core/security.py b/api/core/security.py index 887858d..3a3f39b 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -70,7 +70,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s detail="No character selected. Please select a character first." ) - player = await db.get_player_by_id(character_id) + player = await db.get_character_by_id(character_id) if player is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/api/database.py b/api/database.py index 20690e0..ca00efa 100644 --- a/api/database.py +++ b/api/database.py @@ -358,15 +358,7 @@ async def init_db(): await conn.execute(text(index_sql)) -# Player operations -async def get_player_by_id(player_id: int) -> Optional[Dict[str, Any]]: - """Get player by internal ID""" - async with DatabaseSession() as session: - result = await session.execute( - select(players).where(players.c.id == player_id) - ) - row = result.first() - return dict(row._mapping) if row else None + async def get_player_by_username(username: str) -> Optional[Dict[str, Any]]: @@ -421,13 +413,7 @@ async def create_player( return dict(row._mapping) if row else None -async def update_player(player_id: int, **kwargs) -> bool: - """Update player fields - OLD FUNCTION, use update_character instead""" - async with DatabaseSession() as session: - stmt = update(characters).where(characters.c.id == player_id).values(**kwargs) - await session.execute(stmt) - await session.commit() - return True + async def update_player_location(player_id: int, location_id: str) -> bool: diff --git a/api/game_logic.py b/api/game_logic.py index 5ab63b7..da5c177 100644 --- a/api/game_logic.py +++ b/api/game_logic.py @@ -6,14 +6,15 @@ import random import time from typing import Dict, Any, Tuple, Optional, List from . import database as db +from .services.helpers import get_locale_string, translate_travel_message, create_combat_message -async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[bool, str, Optional[str], int, int]: +async def move_player(player_id: int, direction: str, locations: Dict, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]: """ Move player in a direction. Returns: (success, message, new_location_id, stamina_cost, distance_meters) """ - player = await db.get_player_by_id(player_id) + player = await db.get_character_by_id(player_id) if not player: return False, "Player not found", None, 0, 0 @@ -69,13 +70,15 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[ return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0 # Update player location and stamina - await db.update_player( + await db.update_character( player_id, location_id=new_location_id, stamina=max(0, player['stamina'] - stamina_cost) ) - return True, f"You travel {direction} to {new_location.name}.", new_location_id, stamina_cost, distance + translated_location = get_locale_string(new_location.name, locale) + travel_message = translate_travel_message(direction, translated_location, locale) + return True, travel_message, new_location_id, stamina_cost, distance async def inspect_area(player_id: int, location, interactables_data: Dict) -> str: @@ -216,7 +219,7 @@ async def interact_with_object( if not item: continue - item_name = item.name if item else item_id + item_name = get_locale_string(item.name) if item else item_id emoji = item.emoji if item and hasattr(item, 'emoji') else '' # Check if item has durability (unique item) @@ -237,7 +240,7 @@ async def interact_with_object( max_durability=item.durability, tier=getattr(item, 'tier', None) ) - items_found.append(f"{emoji} {item_name}") + items_found.append(f"{emoji} {get_locale_string(item_name)}") current_weight += item.weight current_volume += item.volume else: @@ -252,7 +255,7 @@ async def interact_with_object( unique_stats=base_stats ) await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id) - items_dropped.append(f"{emoji} {item_name}") + items_dropped.append(f"{emoji} {get_locale_string(item_name)}") else: # Stackable items - process as before item_weight = item.weight * quantity @@ -262,13 +265,13 @@ async def interact_with_object( current_volume + item_volume <= max_volume): # Add to inventory await db.add_item_to_inventory(player_id, item_id, quantity) - items_found.append(f"{emoji} {item_name} x{quantity}") + items_found.append(f"{emoji} {get_locale_string(item_name)} x{quantity}") current_weight += item_weight current_volume += item_volume else: # Drop to ground await db.drop_item_to_world(item_id, quantity, player['location_id']) - items_dropped.append(f"{emoji} {item_name} x{quantity}") + items_dropped.append(f"{emoji} {get_locale_string(item_name)} x{quantity}") # Apply damage if damage_taken > 0: @@ -283,7 +286,7 @@ async def interact_with_object( await db.set_interactable_cooldown(interactable_id, action_id, 60) # Build message - final_message = outcome.text + final_message = get_locale_string(outcome.text) if items_dropped: final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(items_dropped)}" @@ -565,7 +568,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - heal_amount = int(combat['npc_max_hp'] * 0.05) new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount) await db.update_combat(player_id, {'npc_hp': new_npc_hp}) - message = f"{npc_def.name} defends and recovers {heal_amount} HP!" + message = f"{get_locale_string(npc_def.name)} defends and recovers {heal_amount} HP!" elif intent_type == 'special': # Strong attack (1.5x damage) @@ -574,7 +577,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - actual_damage = max(1, npc_damage - armor_absorbed) new_player_hp = max(0, player['hp'] - actual_damage) - message = f"{npc_def.name} uses a SPECIAL ATTACK for {npc_damage} damage!" + message = f"{get_locale_string(npc_def.name)} uses a SPECIAL ATTACK for {npc_damage} damage!" if armor_absorbed > 0: message += f" (Armor absorbed {armor_absorbed})" @@ -589,7 +592,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - # Enrage bonus if NPC is below 30% HP if combat['npc_hp'] / combat['npc_max_hp'] < 0.3: npc_damage = int(npc_damage * 1.5) - message = f"{npc_def.name} is ENRAGED! " + message = f"{get_locale_string(npc_def.name)} is ENRAGED! " else: message = "" @@ -597,7 +600,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) - actual_damage = max(1, npc_damage - armor_absorbed) new_player_hp = max(0, player['hp'] - actual_damage) - message += f"{npc_def.name} attacks for {npc_damage} damage!" + message += create_combat_message("enemy_attack", npc_name=npc_def.name, damage=npc_damage, armor_absorbed=armor_absorbed) if armor_absorbed > 0: message += f" (Armor absorbed {armor_absorbed})" diff --git a/api/routers/combat.py b/api/routers/combat.py index e7a151e..71502ec 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -12,7 +12,7 @@ import logging from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * -from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity +from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message from .. import database as db from ..items import ItemsManager from .. import game_logic @@ -147,7 +147,7 @@ async def initiate_combat( await manager.send_personal_message(current_user['id'], { "type": "combat_started", "data": { - "message": f"Combat started with {npc_def.name}!", + "message": create_combat_message("combat_start", npc_name=npc_def.name), "combat": { "npc_id": enemy.npc_id, "npc_name": npc_def.name, @@ -167,7 +167,7 @@ async def initiate_combat( message={ "type": "location_update", "data": { - "message": f"{player['name']} entered combat with {npc_def.name}", + "message": f"{player['name']} entered combat with {get_locale_string(npc_def.name)}", "action": "combat_started", "player_id": player['id'] }, @@ -178,7 +178,7 @@ async def initiate_combat( return { "success": True, - "message": f"Combat started with {npc_def.name}!", + "message": create_combat_message("combat_start", npc_name=npc_def.name), "combat": { "npc_id": enemy.npc_id, "npc_name": npc_def.name, @@ -304,7 +304,7 @@ async def combat_action( if new_npc_hp <= 0: # NPC defeated - result_message += f"{npc_def.name} has been defeated!" + result_message += create_combat_message("victory", npc_name=npc_def.name) combat_over = True player_won = True @@ -435,7 +435,7 @@ async def combat_action( # Failed to flee, NPC attacks npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) new_player_hp = max(0, player['hp'] - npc_damage) - result_message = f"Failed to flee! {npc_def.name} attacks for {npc_damage} damage!" + result_message = create_combat_message("flee_fail", npc_name=npc_def.name, damage=npc_damage) if new_player_hp <= 0: result_message += "\nYou have been defeated!" diff --git a/api/routers/crafting.py b/api/routers/crafting.py index 6a980c9..03f98d5 100644 --- a/api/routers/crafting.py +++ b/api/routers/crafting.py @@ -12,7 +12,7 @@ import logging from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * -from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost +from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_locale_string from .. import database as db from ..items import ItemsManager from .. import game_logic @@ -156,7 +156,7 @@ async def get_craftable_items(current_user: dict = Depends(get_current_user)): }) # Sort: craftable items first, then by tier, then by name - craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name'])) + craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], get_locale_string(x['name']))) return {'craftable_items': craftable_items} diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index 8b66f49..6ec6922 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -2,7 +2,7 @@ Game Routes router. Auto-generated from main.py migration. """ -from fastapi import APIRouter, HTTPException, Depends, status +from fastapi import APIRouter, HTTPException, Depends, status, Request from fastapi.security import HTTPAuthorizationCredentials from typing import Optional, Dict, Any from datetime import datetime @@ -391,8 +391,11 @@ async def spend_stat_point( @router.get("/api/game/location") -async def get_current_location(current_user: dict = Depends(get_current_user)): +async def get_current_location(request: Request, current_user: dict = Depends(get_current_user)): """Get current location information""" + # Extract locale from Accept-Language header + locale = request.headers.get('Accept-Language', 'en') + location_id = current_user['location_id'] location = LOCATIONS.get(location_id) @@ -682,7 +685,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)): corpses_data.append({ "id": f"npc_{corpse['id']}", "type": "npc", - "name": f"{npc_def.name if npc_def else corpse['npc_id']} Corpse", + "name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse", "emoji": "💀", "loot_count": len(loot), "timestamp": corpse['death_timestamp'] @@ -719,6 +722,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)): @router.post("/api/game/move") async def move( move_req: MoveRequest, + request: Request, current_user: dict = Depends(get_current_user) ): """Move player in a direction""" @@ -756,10 +760,14 @@ async def move( detail=f"You must wait {int(cooldown_remaining)} seconds before moving again." ) + # Extract locale from Accept-Language header + locale = request.headers.get('Accept-Language', 'en') + success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( current_user['id'], move_req.direction, - LOCATIONS + LOCATIONS, + locale ) if not success: @@ -951,9 +959,13 @@ async def inspect(current_user: dict = Depends(get_current_user)): @router.post("/api/game/interact") async def interact( interact_req: InteractRequest, + request: Request, current_user: dict = Depends(get_current_user) ): - """Interact with an object""" + """Interact with an object in the game world""" + # Extract locale from Accept-Language header + locale = request.headers.get('Accept-Language', 'en') + # Check if player is in combat combat = await db.get_active_combat(current_user['id']) if combat: @@ -1026,7 +1038,7 @@ async def interact( "instance_id": interact_req.interactable_id, "action_id": interact_req.action_id, "cooldown_remaining": cooldown_remaining, - "message": f"{current_user['name']} used {action_display} on {interactable_name}" + "message": f"{current_user['name']} used {get_locale_string(action_display, locale)} on {get_locale_string(interactable_name, locale)}" }, "timestamp": datetime.utcnow().isoformat() } @@ -1035,6 +1047,8 @@ async def interact( return result + + @router.post("/api/game/use_item") async def use_item( use_req: UseItemRequest, @@ -1159,15 +1173,19 @@ async def use_item( @router.post("/api/game/pickup") async def pickup( pickup_req: PickupItemRequest, + request: Request, current_user: dict = Depends(get_current_user) ): """Pick up an item from the ground""" + # Extract locale from Accept-Language header + locale = request.headers.get('Accept-Language', 'en') + # Get item details for broadcast BEFORE picking it up (it will be removed from DB) # pickup_req.item_id is the dropped_item database ID, not the item_id string dropped_item = await db.get_dropped_item(pickup_req.item_id) if dropped_item: item_def = ITEMS_MANAGER.get_item(dropped_item['item_id']) - item_name = item_def.name if item_def else dropped_item['item_id'] + item_name = get_locale_string(item_def.name, locale) if item_def else dropped_item['item_id'] else: item_name = "item" @@ -1392,5 +1410,5 @@ async def drop_item( return { "success": True, - "message": f"Dropped {item_def.emoji} {item_def.name} x{quantity}" + "message": f"Dropped {item_def.emoji} {get_locale_string(item_def.name)} x{quantity}" } \ No newline at end of file diff --git a/api/routers/loot.py b/api/routers/loot.py index 59df101..b1db641 100644 --- a/api/routers/loot.py +++ b/api/routers/loot.py @@ -12,7 +12,7 @@ import logging from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * -from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity +from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string from .. import database as db from ..items import ItemsManager from .. import game_logic @@ -310,13 +310,13 @@ async def loot_corpse( message_parts = [] for item in looted_items: item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] + item_name = get_locale_string(item_def.name) if item_def else item['item_id'] message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}") dropped_parts = [] for item in dropped_items: item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] + item_name = get_locale_string(item_def.name) if item_def else item['item_id'] dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}") message = "" @@ -438,13 +438,13 @@ async def loot_corpse( message_parts = [] for item in looted_items: item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] + item_name = get_locale_string(item_def.name) if item_def else item['item_id'] message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}") dropped_parts = [] for item in dropped_items: item_def = ITEMS_MANAGER.get_item(item['item_id']) - item_name = item_def.name if item_def else item['item_id'] + item_name = get_locale_string(item_def.name) if item_def else item['item_id'] dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}") message = "" diff --git a/api/services/helpers.py b/api/services/helpers.py index a359579..77e8de1 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -15,6 +15,45 @@ def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> st return str(value) +# Translation maps for backend messages +DIRECTION_TRANSLATIONS = { + 'north': {'en': 'north', 'es': 'norte'}, + 'south': {'en': 'south', 'es': 'sur'}, + 'east': {'en': 'east', 'es': 'este'}, + 'west': {'en': 'west', 'es': 'oeste'}, + 'northeast': {'en': 'northeast', 'es': 'noreste'}, + 'northwest': {'en': 'northwest', 'es': 'noroeste'}, + 'southeast': {'en': 'southeast', 'es': 'sureste'}, + 'southwest': {'en': 'southwest', 'es': 'suroeste'}, +} + +def translate_travel_message(direction: str, location_name: str, lang: str = 'en') -> str: + """Translate a travel message to the user's language.""" + dir_translated = DIRECTION_TRANSLATIONS.get(direction, {}).get(lang, direction) + + if lang == 'es': + return f"Viajas al {dir_translated} hacia {location_name}." + else: + return f"You travel {dir_translated} to {location_name}." + + +import json + +def create_combat_message(message_type: str, **data) -> str: + """Create a structured combat message with type and data. + + Args: + message_type: Type of combat message (combat_start, player_attack, etc.) + **data: Dynamic data for the message (damage, npc_name, etc.) + + Returns: + Dictionary with 'type' and 'data' fields + """ + return json.dumps({ + "type": message_type, + "data": data + }) + def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float: """ Calculate distance between two points using Euclidean distance. diff --git a/gamedata/npcs.json b/gamedata/npcs.json index 5417f20..d46fab2 100644 --- a/gamedata/npcs.json +++ b/gamedata/npcs.json @@ -54,11 +54,11 @@ "npc_id": "raider_scout", "name": { "en": "Raider Scout", - "es": "" + "es": "Explorador" }, "description": { "en": "A lone raider wearing makeshift armor. They eye you with hostile intent.", - "es": "" + "es": "Un explorador solitario con ropa improvisada. Te mira con intención hostil." }, "emoji": "🏴‍☠️", "hp_min": 30, @@ -116,11 +116,11 @@ "npc_id": "mutant_rat", "name": { "en": "Mutant Rat", - "es": "" + "es": "Rata mutante" }, "description": { "en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.", - "es": "" + "es": "Una rata grotescamente grande, su pelaje es desgarrado y sus ojos brillan con luz unnatural." }, "emoji": "🐀", "hp_min": 10, @@ -160,11 +160,11 @@ "npc_id": "infected_human", "name": { "en": "Infected Human", - "es": "" + "es": "Humano infectado" }, "description": { "en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.", - "es": "" + "es": "Una vez humano, ahora algo más. Sus movimientos son torpes y su piel muestra signos de infección avanzada." }, "emoji": "🧟", "hp_min": 35, diff --git a/nginx.conf b/nginx.conf index d8ca2d8..972ee32 100644 --- a/nginx.conf +++ b/nginx.conf @@ -34,14 +34,18 @@ server { add_header Expires "0"; } - # Manifest should be cached for a short time + # Manifest should never be cached location /manifest.webmanifest { - add_header Cache-Control "max-age=3600"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; } # SPA fallback - all other requests go to index.html location / { try_files $uri $uri/ /index.html; - add_header Cache-Control "no-cache"; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; } } diff --git a/pwa/index.html b/pwa/index.html index 19cd249..3a9dd3e 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -1,17 +1,24 @@ - - - - - - - - - Echoes of the Ash - - -
- - - + + + + + + + + + + + + + Echoes of the Ash + + + +
+ + + + \ No newline at end of file diff --git a/pwa/src/components/Game.tsx b/pwa/src/components/Game.tsx index 674516b..5dd8eab 100644 --- a/pwa/src/components/Game.tsx +++ b/pwa/src/components/Game.tsx @@ -1,5 +1,6 @@ // Game.tsx - Main game orchestrator (refactored from 3,315 lines to ~350 lines) import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' import api from '../services/api' import { useGameEngine } from './game/hooks/useGameEngine' import Combat from './game/Combat' @@ -9,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar' import './Game.css' function Game() { + const { t, i18n } = useTranslation() const [token] = useState(() => localStorage.getItem('token')) // Handle WebSocket messages @@ -23,11 +25,29 @@ function Game() { case 'location_update': // General location updates - update state directly from message data when possible console.log('🗺️ Location update:', message.data?.action, message.data?.message) - if (message.data?.message) { - actions.addLocationMessage(message.data.message) + + let displayMessage = message.data?.message + const action = message.data?.action + + // Handle translations for specific actions + if (action === 'enemy_spawned' && message.data.npc_data) { + const npcData = message.data.npc_data + let npcName = npcData.name + if (typeof npcName === 'object' && npcName !== null) { + npcName = npcName[i18n.language] || npcName['en'] || npcName['es'] + } + displayMessage = t('messages.enemyAppeared', { name: npcName }) + } else if (action === 'enemy_despawned') { + displayMessage = t('messages.enemyDespawned') + } else if (action === 'corpses_decayed' && message.data.count) { + displayMessage = t('messages.corpsesDecayed', { count: message.data.count }) + } else if (action === 'items_decayed' && message.data.count) { + displayMessage = t('messages.itemsDecayed', { count: message.data.count }) } - const action = message.data?.action + if (displayMessage) { + actions.addLocationMessage(displayMessage) + } if (action === 'player_arrived' && message.data.player_id) { // Add player to location directly without API call actions.addPlayerToLocation({ @@ -326,6 +346,7 @@ function Game() { {/* Location view (when not in combat) */} {!state.combatState && state.location && state.playerState && ( { i18n.changeLanguage(langCode) + // Reload page to ensure all components refresh with new language + // This is necessary because some data comes from API and won't update without refetch + window.location.reload() } const currentLang = languages.find(l => l.code === i18n.language) || languages[0] diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx index f3e026d..d680b11 100644 --- a/pwa/src/components/game/Combat.tsx +++ b/pwa/src/components/game/Combat.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import CombatView from './CombatView' import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types' import api from '../../services/api' +import { getTranslatedText } from '../../utils/i18nUtils' import './CombatEffects.css' interface CombatProps { @@ -46,6 +47,35 @@ const Combat = ({ // Turn timer state for PvE combat const [turnTimeRemaining, setTurnTimeRemaining] = useState(null) + const isMounted = useRef(true) + + // Floating text ID counter to ensure unique IDs + const floatingTextIdCounter = useRef(0) + + // Track all timeout IDs for cleanup + const floatingTextTimeouts = useRef>(new Set()) + + useEffect(() => { + return () => { + isMounted.current = false + // Cancel all pending floating text timeouts to prevent DOM manipulation errors + floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout)) + floatingTextTimeouts.current.clear() + // Clear all floating texts on unmount to prevent DOM manipulation errors + setFloatingTexts([]) + } + }, []) + + // Clean up floating texts when combat ends + useEffect(() => { + if (combatState.combat_over) { + // Cancel all pending timeouts immediately when combat ends + floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout)) + floatingTextTimeouts.current.clear() + // Clear all floating texts + setFloatingTexts([]) + } + }, [combatState.combat_over]) // PvP Timer Effect useEffect(() => { @@ -110,11 +140,17 @@ const Combat = ({ }, [turnTimeRemaining, combatState, updateCombatState]) const addFloatingText = (text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal') => { - const id = Date.now() + Math.random() + const id = ++floatingTextIdCounter.current setFloatingTexts(prev => [...prev, { id, text, x, y, type }]) - setTimeout(() => { - setFloatingTexts(prev => prev.filter(ft => ft.id !== id)) + const timeout = setTimeout(() => { + if (isMounted.current) { + setFloatingTexts(prev => prev.filter(ft => ft.id !== id)) + // Remove this timeout from the tracking set + floatingTextTimeouts.current.delete(timeout) + } }, 2500) + // Track this timeout for cleanup + floatingTextTimeouts.current.add(timeout) } const handlePvEAction = async (action: string) => { @@ -130,38 +166,73 @@ const Combat = ({ const messages = data.message.split('\n').filter((m: string) => m.trim()) // Handle failed flee special case - split combined message - const processedMessages: string[] = [] + const processedMessages: any[] = [] messages.forEach((msg: string) => { + // Try to parse as JSON first (for structured messages) + try { + // Check if it looks like a JSON object before trying to parse + if (msg.trim().startsWith('{')) { + const parsed = JSON.parse(msg) + if (parsed.type && parsed.data) { + processedMessages.push(parsed) // Push object directly + return + } + } + } catch (e) { + // Not valid JSON, treat as string + } + // Check if message contains both flee failure and enemy attack const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/) if (fleeFailMatch) { processedMessages.push(fleeFailMatch[1]) // "Failed to flee!" - processedMessages.push(fleeFailMatch[2]) // Enemy attack message + + // The second part might be a JSON string too + const secondPart = fleeFailMatch[2] + try { + if (secondPart.trim().startsWith('{')) { + const parsed = JSON.parse(secondPart) + if (parsed.type && parsed.data) { + processedMessages.push(parsed) + return + } + } + } catch (e) { } + + processedMessages.push(secondPart) // Enemy attack message (string fallback) } else { processedMessages.push(msg) } }) - const playerMessages = processedMessages.filter((msg: string) => - msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!' - ) - const enemyMessages = processedMessages.filter((msg: string) => - msg !== 'Failed to flee!' && - (msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The ')) - ) + const playerMessages = processedMessages.filter((msg: any) => { + if (typeof msg === 'object') { + return msg.type === 'player_attack' || msg.type === 'victory' || msg.type === 'combat_start' + } + return msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!' + }) + + const enemyMessages = processedMessages.filter((msg: any) => { + if (typeof msg === 'object') { + return msg.type === 'enemy_attack' || msg.type === 'flee_fail' + } + return msg !== 'Failed to flee!' && + (msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The ')) + }) // Check if this is a failed flee attempt const isFailedFlee = playerMessages.some(msg => msg === 'Failed to flee!') // 1. Immediate Player Feedback - playerMessages.forEach((msg: string) => { - addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true }) + playerMessages.forEach((msg: any) => { + const logId = `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true }) // Only show attack animations for actual attacks, not flee failures - if (msg !== 'Failed to flee!') { - const damageMatch = msg.match(/(\d+) damage/) - if (damageMatch) { - addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt') // White text on enemy + if (msg !== 'Failed to flee!' && (typeof msg !== 'object' || msg.type === 'player_attack')) { + const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1] + if (damage) { + addFloatingText(damage.toString(), 50, 30, 'damage-player-dealt') // White text on enemy setFlash(true) setTimeout(() => setFlash(false), 300) } @@ -193,12 +264,13 @@ const Combat = ({ await new Promise(resolve => setTimeout(resolve, 2000)) - enemyMessages.forEach((msg: string) => { - addCombatLogEntry({ time: timeStr, message: msg, isPlayer: false }) + enemyMessages.forEach((msg: any) => { + const logId = `enemy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: false }) - const damageMatch = msg.match(/(\d+) damage/) - if (damageMatch) { - addFloatingText(damageMatch[1], 50, 50, 'damage-player') // Red text over player position + const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1] + if (damage) { + addFloatingText(damage.toString(), 50, 50, 'damage-player') // Red text over player position setShake(true) setTimeout(() => setShake(false), 500) } @@ -293,7 +365,8 @@ const Combat = ({ // Parse message for damage // Example: "You attacked X for 10 damage!" const msg = data.message || '' - addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true }) + const logId = `pvp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true }) const damageMatch = msg.match(/(\d+) damage/) if (damageMatch) { @@ -324,7 +397,7 @@ const Combat = ({ health: tempPlayerHP } : playerState} equipment={equipment} - enemyName={combatState.combat?.npc_name || 'Enemy'} + enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'} enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''} enemyTurnMessage={localEnemyTurnMessage} pvpTimeRemaining={pvpTimer} diff --git a/pwa/src/components/game/CombatView.tsx b/pwa/src/components/game/CombatView.tsx new file mode 100644 index 0000000..e893b78 --- /dev/null +++ b/pwa/src/components/game/CombatView.tsx @@ -0,0 +1,393 @@ +import { useTranslation } from 'react-i18next' +import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types' +import { getTranslatedText } from '../../utils/i18nUtils' + +interface CombatViewProps { + combatState: CombatState + combatLog: CombatLogEntry[] + profile: Profile | null + playerState: PlayerState | null + equipment: Equipment + enemyName: string + enemyImage: string + enemyTurnMessage: string + pvpTimeRemaining: number | null + turnTimeRemaining: number | null + onCombatAction: (action: string) => void + onFlee: () => void + onPvPAction: (action: string) => void + onExitCombat: () => void + onExitPvPCombat: () => void + flashEnemy?: boolean + buttonsDisabled?: boolean + floatingTexts?: { id: number, text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal' }[] +} + +function CombatView({ + combatState, + combatLog, + profile: _profile, + playerState, + enemyName, + enemyImage, + enemyTurnMessage, + pvpTimeRemaining, + turnTimeRemaining, + onCombatAction, + onPvPAction, + onExitCombat, + onExitPvPCombat, + flashEnemy, + buttonsDisabled, + floatingTexts = [] +}: CombatViewProps) { + const { t } = useTranslation() + const displayEnemyName = typeof enemyName === 'object' ? getTranslatedText(enemyName) : (enemyName || 'Enemy') + + // Render structured combat messages + const renderCombatMessage = (msg: any) => { + // Support both old string format and new structured format + if (typeof msg === 'string') { + return msg // Legacy format + } + + if (!msg || !msg.type) { + return String(msg) + } + + const { type, data } = msg + + switch (type) { + case 'combat_start': + return t('combat.messages.combat_start', { enemy: getTranslatedText(data.npc_name) }) + case 'player_attack': + return t('combat.messages.player_attack', { damage: data.damage }) + case 'enemy_attack': + return t('combat.messages.enemy_attack', { + enemy: getTranslatedText(data.npc_name), + damage: data.damage + }) + case 'victory': + return t('combat.messages.victory', { enemy: getTranslatedText(data.npc_name) }) + case 'flee_fail': + return t('combat.messages.flee_fail', { + enemy: getTranslatedText(data.npc_name), + damage: data.damage + }) + default: + return JSON.stringify(msg) + } + } + + return ( +
+
+

+ {combatState.is_pvp ? `⚔️ ${t('combat.title')} - PvP` : `⚔️ ${t('combat.title')} - ${displayEnemyName}`} +

+
+ + {combatState.is_pvp ? ( + /* PvP Combat UI - Unified Layout */ +
+
+ {/* Opponent Display (using same structure as PvE Enemy) */} +
+
+ {floatingTexts.map(ft => ( +
+ {ft.text} +
+ ))} +
+ {(() => { + if (!combatState.pvp_combat) return null + const opponent = combatState.pvp_combat.is_attacker ? + combatState.pvp_combat.defender : + combatState.pvp_combat.attacker + + if (!opponent) return
+ // Use a default avatar if no image, or maybe the class image if available? + // For now, let's use a placeholder or try to get it from profile if passed? + // The opponent object has: username, level, hp, max_hp. + // It might not have an image url. + return ( +
+ 👤 +
{opponent.username} (Lv. {opponent.level})
+
+ ) + })()} +
+ +
+ {/* Opponent HP Bar */} + {(() => { + if (!combatState.pvp_combat) return null + const opponent = combatState.pvp_combat.is_attacker ? + combatState.pvp_combat.defender : + combatState.pvp_combat.attacker + + if (!opponent) return null + + return ( +
+
+
+ {opponent.username}: {opponent.hp} / {opponent.max_hp} +
+
+
+
+ ) + })()} + + {/* Player HP Bar */} + {(() => { + if (!combatState.pvp_combat) return null + const you = combatState.pvp_combat.is_attacker ? + combatState.pvp_combat.attacker : + combatState.pvp_combat.defender + + if (!you) return null + + return ( +
+
+
+ You: {you.hp} / {you.max_hp} +
+
+
+
+ ) + })()} +
+
+ +
+ {combatState.pvp_combat.combat_over ? ( + + {combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "🏃 Combat Ended" : "💀 Combat Over"} + + ) : combatState.pvp_combat.your_turn ? ( + ✅ Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s) + ) : ( + ⏳ Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s) + )} +
+ +
+ {!combatState.pvp_combat.combat_over ? ( + <> + + + + ) : ( + + )} +
+ + {/* Combat Log */} +
+

{t('combat.combatLog')}

+
+
+
+ {combatLog.length > 0 ? ( + combatLog.map((entry: any) => ( +
+ [{entry.time}] + {renderCombatMessage(entry.message)} +
+ )) + ) : ( +
PvP Combat started...
+ )} +
+
+
+
+
+ ) : ( + /* PvE Combat UI */ + <> +
+
+ {/* Intent Bubble - Moved here to avoid overflow:hidden clipping */} + {combatState.combat?.npc_intent && !combatState.combat_over && ( +
+ + {combatState.combat.npc_intent === 'attack' ? '⚔️' : + combatState.combat.npc_intent === 'defend' ? '🛡️' : + combatState.combat.npc_intent === 'special' ? '🔥' : '❓'} + + {combatState.combat.npc_intent} +
+ )} + +
+
+ {floatingTexts.map(ft => ( +
+ {ft.text} +
+ ))} +
+ {enemyName +
+
+
+
+
+ {t('combat.enemyHp')}: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100} +
+
+
+
+ {playerState && ( +
+
+
+ {t('combat.playerHp')}: {playerState.health} / {playerState.max_health} +
+
+
+
+ )} +
+
+ +
+ {!combatState.combat_over ? ( + enemyTurnMessage ? ( + 🗡️ Enemy's turn... + ) : combatState.combat?.turn === 'player' ? ( + <> + ✅ {t('combat.yourTurn')} + {turnTimeRemaining !== null && ( + + ⏱️ {Math.floor(turnTimeRemaining / 60)}:{String(Math.floor(turnTimeRemaining % 60)).padStart(2, '0')} + + )} + + ) : ( + ⚠️ {t('combat.enemyTurn')} + ) + ) : ( + + {combatState.player_won ? `✅ ${t('combat.victory')}` : combatState.player_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`} + + )} +
+ + {/* PvE Combat Actions */} + +
+ {!combatState.combat_over ? ( + <> + + + + ) : ( + + )} +
+ + {/* Combat Log */} +
+

{t('combat.combatLog')}

+
+
+
+ {combatLog.length > 0 ? ( + combatLog.map((entry: any) => ( +
+ [{entry.time}] + {renderCombatMessage(entry.message)} +
+ )) + ) : ( +
Combat started...
+ )} +
+
+
+
+
+ + )} +
+ ) +} + +export default CombatView diff --git a/pwa/src/components/game/InventoryModal.tsx b/pwa/src/components/game/InventoryModal.tsx index d82b24e..5af0882 100644 --- a/pwa/src/components/game/InventoryModal.tsx +++ b/pwa/src/components/game/InventoryModal.tsx @@ -34,19 +34,19 @@ function InventoryModal({ onUnequipItem, onDropItem }: InventoryModalProps) { - useTranslation() + const { t } = useTranslation() // Categories for the sidebar const categories = [ - { id: 'all', label: 'All Items', icon: '🎒' }, - { id: 'weapon', label: 'Weapons', icon: '⚔️' }, - { id: 'armor', label: 'Armor', icon: '🛡️' }, - { id: 'clothing', label: 'Clothing', icon: '👕' }, - { id: 'backpack', label: 'Backpacks', icon: '🎒' }, - { id: 'tool', label: 'Tools', icon: '🛠️' }, - { id: 'consumable', label: 'Consumables', icon: '🍖' }, - { id: 'resource', label: 'Resources', icon: '📦' }, - { id: 'quest', label: 'Quest', icon: '📜' }, - { id: 'misc', label: 'Misc', icon: '📦' } + { id: 'all', label: t('categories.all'), icon: '🎒' }, + { id: 'weapon', label: t('categories.weapon'), icon: '⚔️' }, + { id: 'armor', label: t('categories.armor'), icon: '🛡️' }, + { id: 'clothing', label: t('categories.clothing'), icon: '👕' }, + { id: 'backpack', label: t('categories.backpack'), icon: '🎒' }, + { id: 'tool', label: t('categories.tool'), icon: '🛠️' }, + { id: 'consumable', label: t('categories.consumable'), icon: '🍖' }, + { id: 'resource', label: t('categories.resource'), icon: '📦' }, + { id: 'quest', label: t('categories.quest'), icon: '📜' }, + { id: 'misc', label: t('categories.misc'), icon: '📦' } ] // Use inventory directly as it now includes equipped items @@ -100,7 +100,7 @@ function InventoryModal({
{item.emoji}

{getTranslatedText(item.name)}

- {item.is_equipped && Equipped} + {item.is_equipped && {t('game.equipped')}}
@@ -149,17 +149,17 @@ function InventoryModal({ )} {(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && ( - 💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} Pen + 💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen')} )} {(item.unique_stats?.crit_chance || item.stats?.crit_chance) && ( - 🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% Crit + 🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit')} )} {(item.unique_stats?.accuracy || item.stats?.accuracy) && ( - 👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% Acc + 👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc')} )} {(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && ( @@ -169,34 +169,34 @@ function InventoryModal({ )} {(item.unique_stats?.lifesteal || item.stats?.lifesteal) && ( - 🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% Life + 🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life')} )} {/* Attributes */} {(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && ( - 💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} STR + 💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str')} )} {(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && ( - 🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} AGI + 🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi')} )} {(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && ( - 🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} END + 🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end')} )} {(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && ( - ❤️ +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} HP max + ❤️ +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} {t('stats.hpMax')} )} {(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && ( - ⚡ +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} Stm max + ⚡ +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')} )} @@ -217,7 +217,7 @@ function InventoryModal({ {hasDurability && (
- Durability + {t('game.durability')} {item.consumable && ( - + )} {item.equippable && !item.is_equipped && ( - + )} {item.is_equipped && ( - + )}
{item.quantity > 1 && ( - + )} {item.quantity >= 5 && ( @@ -266,7 +266,7 @@ function InventoryModal({ )}
@@ -288,7 +288,7 @@ function InventoryModal({ ⚖️
- Weight: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg + {t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
📦
- Volume: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L + {t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
🚫 - No Backpack Equipped + {t('game.noBackpack')}
)} @@ -356,7 +356,7 @@ function InventoryModal({ 🔍 ) => onSetInventoryFilter(e.target.value)} /> @@ -366,32 +366,29 @@ function InventoryModal({ {filteredItems.length === 0 ? (
📦 -

No items found in this category

+

{t('game.noItemsFound')}

) : ( inventoryCategoryFilter === 'all' ? ( <> {/* Equipped */} - {filteredItems.some((i: any) => i.is_equipped) && ( + {filteredItems.some((item: any) => item.is_equipped) && ( <> -
⚔️ Equipped
- {filteredItems.filter((i: any) => i.is_equipped).map((item: any, i: number) => renderItemCard(item, i))} +
⚔️ {t('game.equipped')}
+ {filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))} )} - {/* Categories */} - {categories.filter(c => c.id !== 'all').map(cat => { - const categoryItems = filteredItems.filter((i: any) => !i.is_equipped && i.type === cat.id); - if (categoryItems.length === 0) return null; - return ( -
-
{cat.icon} {cat.label}
- {categoryItems.map((item: any, i: number) => renderItemCard(item, i))} -
- ); - })} + {/* Backpack */} + {filteredItems.some((item: any) => !item.is_equipped) && ( + <> +
🎒 {t('game.backpack')}
+ {filteredItems.filter((item: any) => !item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))} + + )} ) : ( + /* Single category */ filteredItems.map((item: any, i: number) => renderItemCard(item, i)) ) )} diff --git a/pwa/src/components/game/LocationView.tsx b/pwa/src/components/game/LocationView.tsx index 18072f8..7368c16 100644 --- a/pwa/src/components/game/LocationView.tsx +++ b/pwa/src/components/game/LocationView.tsx @@ -82,7 +82,7 @@ function LocationView({ onRepair, onUncraft }: LocationViewProps) { - useTranslation() + const { t } = useTranslation() return (
@@ -115,15 +115,15 @@ function LocationView({ onClick={isClickable ? handleClick : undefined} style={isClickable ? { cursor: 'pointer' } : undefined} > - {tag === 'workbench' && '🔧 Workbench'} - {tag === 'repair_station' && '🛠️ Repair Station'} - {tag === 'safe_zone' && '🛡️ Safe Zone'} - {tag === 'shop' && '🏪 Shop'} - {tag === 'shelter' && '🏠 Shelter'} - {tag === 'medical' && '⚕️ Medical'} - {tag === 'storage' && '📦 Storage'} - {tag === 'water_source' && '💧 Water'} - {tag === 'food_source' && '🍎 Food'} + {tag === 'workbench' && t('tags.workbench')} + {tag === 'repair_station' && t('tags.repairStation')} + {tag === 'safe_zone' && t('tags.safeZone')} + {tag === 'shop' && t('tags.shop')} + {tag === 'shelter' && t('tags.shelter')} + {tag === 'medical' && t('tags.medical')} + {tag === 'storage' && t('tags.storage')} + {tag === 'water_source' && t('tags.water')} + {tag === 'food_source' && t('tags.food')} {tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`} ) @@ -157,7 +157,7 @@ function LocationView({ {locationMessages.length > 0 && (
-

📜 Recent Activity

+

{t('location.recentActivity')}

{locationMessages.slice(-10).reverse().map((msg, idx) => (
@@ -173,7 +173,7 @@ function LocationView({ {/* Enemies */} {location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
-

⚔️ Enemies

+

{t('location.enemies')}

{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
@@ -188,13 +188,13 @@ function LocationView({ )}
{getTranslatedText(enemy.name)}
- {enemy.level &&
Lv. {enemy.level}
} + {enemy.level &&
{t('location.level')} {enemy.level}
}
))} @@ -205,28 +205,28 @@ function LocationView({ {/* Corpses */} {location.corpses && location.corpses.length > 0 && (
-

💀 Corpses

+

{t('location.corpses')}

{location.corpses.map((corpse: any) => (
{corpse.emoji} {getTranslatedText(corpse.name)}
-
{corpse.loot_count} item(s)
+
{corpse.loot_count} {t('location.items')}
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
-

Lootable Items:

+

{t('location.lootableItems')}

- Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''} + {t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
{item.required_tool && (
@@ -258,7 +258,7 @@ function LocationView({ disabled={!item.can_loot} title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'} > - {item.can_loot ? '📦 Loot' : '🔒'} + {item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
))} @@ -267,7 +267,7 @@ function LocationView({ className="loot-all-btn" onClick={() => onLootCorpseItem(String(corpse.id), null)} > - 📦 Loot All Available + 📦 {t('common.lootAll')}
)} @@ -280,16 +280,16 @@ function LocationView({ {/* Friendly NPCs */} {location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
-

👥 NPCs

+

{t('location.npcs')}

{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
🧑
{getTranslatedText(npc.name)}
- {npc.level &&
Lv. {npc.level}
} + {npc.level &&
{t('location.level')} {npc.level}
}
- +
))}
@@ -299,7 +299,7 @@ function LocationView({ {/* Items on Ground */} {location.items.length > 0 && (
-

📦 Items on Ground

+

{t('location.itemsOnGround')}

{location.items.map((item: any, i: number) => (
@@ -323,37 +323,37 @@ function LocationView({ {item.quantity > 1 &&
×{item.quantity}
}
- +
{item.description &&
{getTranslatedText(item.description)}
} {item.weight !== undefined && item.weight > 0 && (
- ⚖️ Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`} + ⚖️ {t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
)} {item.volume !== undefined && item.volume > 0 && (
- 📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`} + 📦 {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
)} {item.hp_restore && item.hp_restore > 0 && ( -
❤️ HP Restore: +{item.hp_restore}
+
❤️ {t('stats.hpRestore')}: +{item.hp_restore}
)} {item.stamina_restore && item.stamina_restore > 0 && ( -
⚡ Stamina Restore: +{item.stamina_restore}
+
⚡ {t('stats.staminaRestore')}: +{item.stamina_restore}
)} {item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
- ⚔️ Damage: {item.damage_min}-{item.damage_max} + ⚔️ {t('stats.damage')}: {item.damage_min}-{item.damage_max}
)} {item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
- 🔧 Durability: {item.durability}/{item.max_durability} + 🔧 {t('stats.durability')}: {item.durability}/{item.max_durability}
)} {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( -
⭐ Tier: {item.tier}
+
⭐ {t('stats.tier')}: {item.tier}
)}
@@ -362,21 +362,21 @@ function LocationView({ className="entity-action-btn pickup" onClick={() => onPickup(item.id, 1)} > - Pick Up + {t('common.pickUp')} ) : (
- +
- + {item.quantity >= 5 && ( - + )} {item.quantity >= 10 && ( - + )}
diff --git a/pwa/src/components/game/MovementControls.tsx b/pwa/src/components/game/MovementControls.tsx index 50d5cc1..f387cf0 100644 --- a/pwa/src/components/game/MovementControls.tsx +++ b/pwa/src/components/game/MovementControls.tsx @@ -1,5 +1,6 @@ import type { Location, Profile, CombatState } from './types' import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' import { getAssetPath } from '../../utils/assetPath' import { getTranslatedText } from '../../utils/i18nUtils' @@ -22,6 +23,7 @@ function MovementControls({ onMove, onInteract }: MovementControlsProps) { + const { t } = useTranslation() // Force re-render every second to update cooldown timers const [, forceUpdate] = useState(0) @@ -71,23 +73,24 @@ function MovementControls({ const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false) // Build detailed tooltip text - const tooltipText = profile?.is_dead ? 'You are dead' : - movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : - combatState ? 'Cannot travel during combat' : - insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` : - available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` : - `Cannot go ${direction}` + const tooltipText = profile?.is_dead ? t('messages.youAreDead') : + movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : + combatState ? t('messages.cannotTravelCombat') : + insufficientStamina ? t('messages.notEnoughStamina', { need: stamina, have: profile?.stamina ?? 0 }) : + available ? `${destination}\n${t('game.distance')}: ${distance}m\n${t('game.stamina')}: ${stamina}` : + t('messages.cannotGo', { direction: t('directions.' + direction) }) return ( )} {location.directions.includes('down') && ( @@ -142,9 +145,9 @@ function MovementControls({ onClick={() => onMove('down')} className="special-btn" disabled={!!combatState || movementCooldown > 0} - title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go down\nStamina: ${getStaminaCost('down')}`} + title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.down')}\n${t('game.stamina')}: ${getStaminaCost('down')}`} > - ⬇️ Down {movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`} + ⬇️ {t('directions.down')} {movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`} )} {location.directions.includes('enter') && ( @@ -152,9 +155,9 @@ function MovementControls({ onClick={() => onMove('enter')} className="special-btn" disabled={!!combatState || movementCooldown > 0} - title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Enter\nStamina: ${getStaminaCost('enter')}`} + title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.enter')}\n${t('game.stamina')}: ${getStaminaCost('enter')}`} > - 🚪 Enter {movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('enter')}`} + 🚪 {t('directions.enter')} {movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('enter')}`} )} {location.directions.includes('inside') && ( @@ -162,9 +165,9 @@ function MovementControls({ onClick={() => onMove('inside')} className="special-btn" disabled={!!combatState || movementCooldown > 0} - title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go inside\nStamina: ${getStaminaCost('inside')}`} + title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.inside')}\n${t('game.stamina')}: ${getStaminaCost('inside')}`} > - 🚪 Inside {movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('inside')}`} + 🚪 {t('directions.inside')} {movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('inside')}`} )} {location.directions.includes('exit') && ( @@ -172,9 +175,9 @@ function MovementControls({ onClick={() => onMove('exit')} className="special-btn" disabled={!!combatState || movementCooldown > 0} - title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : 'Exit'} + title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : t('directions.exit')} > - 🚪 Exit + 🚪 {t('directions.exit')} )} {location.directions.includes('outside') && ( @@ -182,9 +185,9 @@ function MovementControls({ onClick={() => onMove('outside')} className="special-btn" disabled={!!combatState || movementCooldown > 0} - title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go outside\nStamina: ${getStaminaCost('outside')}`} + title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.outside')}\n${t('game.stamina')}: ${getStaminaCost('outside')}`} > - 🚪 Outside {movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('outside')}`} + 🚪 {t('directions.outside')} {movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('outside')}`} )}
@@ -193,7 +196,7 @@ function MovementControls({ {/* Surroundings - outside movement controls */} {location.interactables && location.interactables.length > 0 && (
-

🌿 Surroundings

+

{t('game.surroundings')}

{location.interactables.map((interactable: any) => (
{interactable.image_path && ( diff --git a/pwa/src/components/game/Workbench.tsx b/pwa/src/components/game/Workbench.tsx index 6759c4c..01c0dd9 100644 --- a/pwa/src/components/game/Workbench.tsx +++ b/pwa/src/components/game/Workbench.tsx @@ -50,7 +50,7 @@ function Workbench({ onRepair, onUncraft }: WorkbenchProps) { - useTranslation() + const { t } = useTranslation() const [selectedItem, setSelectedItem] = useState(null) @@ -116,8 +116,8 @@ function Workbench({ return (
🔧
-

Select an item to view details

-

Choose an item from the list on the left

+

{t('crafting.selectItem')}

+

{t('crafting.chooseFromList')}

) } @@ -155,13 +155,13 @@ function Workbench({
{Object.entries(item.base_stats || item.stats).map(([key, value]) => { const icons: Record = { - weight_capacity: '⚖️ Weight', - volume_capacity: '📦 Volume', - armor: '🛡️ Armor', - hp_max: '❤️ Max HP', - stamina_max: '⚡ Max Stamina', - damage_min: '⚔️ Damage Min', - damage_max: '⚔️ Damage Max' + weight_capacity: `⚖️ ${t('game.weight')}`, + volume_capacity: `📦 ${t('game.volume')}`, + armor: `🛡️ ${t('stats.armor')}`, + hp_max: `❤️ ${t('stats.maxHp')}`, + stamina_max: `⚡ ${t('stats.maxStamina')}`, + damage_min: `⚔️ ${t('stats.damage')} Min`, + damage_max: `⚔️ ${t('stats.damage')} Max` } const label = icons[key] || key.replace('_', ' ') const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : '' @@ -173,7 +173,7 @@ function Workbench({ })}

- * Potential base stats. Actual stats may vary. + * {t('crafting.potentialBaseStats')}

)} @@ -183,13 +183,13 @@ function Workbench({
{Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => { const icons: Record = { - weight_capacity: '⚖️ Weight', - volume_capacity: '📦 Volume', - armor: '🛡️ Armor', - hp_max: '❤️ Max HP', - stamina_max: '⚡ Max Stamina', - damage_min: '⚔️ Damage Min', - damage_max: '⚔️ Damage Max' + weight_capacity: `⚖️ ${t('game.weight')}`, + volume_capacity: `📦 ${t('game.volume')}`, + armor: `🛡️ ${t('stats.armor')}`, + hp_max: `❤️ ${t('stats.maxHp')}`, + stamina_max: `⚡ ${t('stats.maxStamina')}`, + damage_min: `⚔️ ${t('stats.damage')} Min`, + damage_max: `⚔️ ${t('stats.damage')} Max` } const label = icons[key] || key.replace('_', ' ') const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : '' @@ -206,30 +206,28 @@ function Workbench({ {workbenchTab === 'craft' && ( <>
-

📊 Requirements

+

{t('crafting.requirements')}

- {item.craft_level && item.craft_level > 1 && ( -
- Level {item.craft_level} Required - {item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`} -
- )} +
+ {t('crafting.levelRequired', { level: item.craft_level })} + {item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`} +
{item.tools && item.tools.length > 0 && ( <> -
Tools
+
{t('crafting.tools')}
{item.tools.map((tool: any, i: number) => (
{tool.emoji} {getTranslatedText(tool.name)} - {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`} + {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (${t('crafting.cost')}: ${tool.durability_cost})` : `❌ ${t('crafting.missing')} (${t('crafting.cost')}: ${tool.durability_cost})`}
))} )} -
Materials
+
{t('crafting.materials')}
{item.materials && item.materials.length > 0 ? ( item.materials.map((mat: any, i: number) => (
@@ -239,7 +237,7 @@ function Workbench({ )) ) : (
- No materials required + {t('crafting.noMaterialsRequired')}
)}
@@ -252,12 +250,12 @@ function Workbench({ style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }} > - {!item.meets_level ? `Need Level ${item.craft_level}` : - !item.can_craft ? 'Missing Requirements' : '🔨 Craft Item'} + {!item.meets_level ? t('crafting.levelRequired', { level: item.craft_level }) : + !item.can_craft ? t('crafting.missingRequirements') : t('crafting.craftItem')} {item.can_craft && ( - ⚡ {item.stamina_cost || 5} Stamina + {t('crafting.staminaCost', { cost: item.stamina_cost || 5 })} )} @@ -268,10 +266,10 @@ function Workbench({ {workbenchTab === 'repair' && ( <>
-

🔧 Repair Status

+

🔧 {workbenchTab === 'repair' ? t('game.repair') : t('game.salvage')}

{!item.needs_repair ? ( -

✅ Item is in perfect condition

+

{t('crafting.perfectCondition')}

) : ( <>
@@ -333,12 +331,12 @@ function Workbench({ style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }} > - {!item.needs_repair ? 'Already Full' : - !item.can_repair ? 'Missing Requirements' : '🛠️ Repair Item'} + {!item.needs_repair ? t('crafting.alreadyFull') : + !item.can_repair ? t('crafting.missingRequirements') : t('crafting.repairItem')} {item.needs_repair && item.can_repair && ( - ⚡ {item.stamina_cost || 3} Stamina + {t('crafting.staminaCost', { cost: item.stamina_cost || 3 })} )} @@ -349,7 +347,7 @@ function Workbench({ {workbenchTab === 'uncraft' && ( <>
-

♻️ Salvage Preview

+

♻️ {t('game.salvage')}

{/* Show durability bar if we have durability data */} {(item.unique_item_data || item.durability_percent !== undefined) && ( @@ -382,7 +380,7 @@ function Workbench({ <> {durabilityRatio < 1.0 && (
- ⚠️ Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage + {t('crafting.yieldReduced', { percent: Math.round((1 - durabilityRatio) * 100) })}
)} @@ -409,15 +407,15 @@ function Workbench({ className="uncraft-btn" disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)} onClick={() => { - if (window.confirm(`Are you sure you want to salvage ${getTranslatedText(item.name)}? This cannot be undone.`)) { + if (window.confirm(t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }))) { onUncraft(item.unique_item_id, item.inventory_id) } }} style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }} > - ♻️ Salvage Item + ♻️ {t('game.salvage')} - ⚡ {item.stamina_cost || 2} Stamina + {t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
@@ -429,14 +427,14 @@ function Workbench({ } const categories = [ - { id: 'all', label: 'All', icon: '🎒' }, - { id: 'weapon', label: 'Weapons', icon: '⚔️' }, - { id: 'armor', label: 'Armor', icon: '🛡️' }, - { id: 'clothing', label: 'Clothing', icon: '👕' }, - { id: 'tool', label: 'Tools', icon: '🛠️' }, - { id: 'consumable', label: 'Consumables', icon: '🍖' }, - { id: 'resource', label: 'Resources', icon: '📦' }, - { id: 'misc', label: 'Misc', icon: '📦' } + { id: 'all', label: t('categories.all'), icon: '🎒' }, + { id: 'weapon', label: t('categories.weapon'), icon: '⚔️' }, + { id: 'armor', label: t('categories.armor'), icon: '🛡️' }, + { id: 'clothing', label: t('categories.clothing'), icon: '👕' }, + { id: 'tool', label: t('categories.tool'), icon: '🛠️' }, + { id: 'consumable', label: t('categories.consumable'), icon: '🍖' }, + { id: 'resource', label: t('categories.resource'), icon: '📦' }, + { id: 'misc', label: t('categories.misc'), icon: '📦' } ] return ( @@ -445,25 +443,25 @@ function Workbench({ }}>
-

🔧 Workbench

+

{t('game.workbench')}

@@ -472,7 +470,7 @@ function Workbench({
{/* Column 1: Categories Sidebar */}
-

Categories

+

{t('location.lootableItems').replace(':', '')}

{categories.map(cat => (
diff --git a/pwa/src/components/game/hooks/useGameEngine.ts b/pwa/src/components/game/hooks/useGameEngine.ts index 6cf50dd..b22cef8 100644 --- a/pwa/src/components/game/hooks/useGameEngine.ts +++ b/pwa/src/components/game/hooks/useGameEngine.ts @@ -217,7 +217,7 @@ export function useGameEngine( }, []) const addCombatLogEntry = useCallback((entry: CombatLogEntry) => { - setCombatLog((prev: CombatLogEntry[]) => [entry, ...prev]) + setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev]) }, []) // Fetch functions @@ -337,6 +337,7 @@ export function useGameEngine( pvpRes.data.pvp_combat.defender : pvpRes.data.pvp_combat.attacker setCombatLog([{ + id: 'pvp-combat-init', time: timeStr, message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`, isPlayer: true @@ -351,6 +352,7 @@ export function useGameEngine( const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) setCombatLog([{ + id: 'combat-in-progress', time: timeStr, message: 'Combat in progress...', isPlayer: true @@ -402,8 +404,9 @@ export function useGameEngine( const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) setCombatLog([{ + id: Date.now() + Math.random(), time: timeStr, - message: `⚠️ ${encounter.combat.npc_name} ambushes you!`, + message: { type: 'combat_start', data: { npc_name: encounter.combat.npc_name } }, isPlayer: false }]) @@ -503,10 +506,23 @@ export function useGameEngine( const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) const messages = data.message.split('\n').filter((m: string) => m.trim()) - const newEntries = messages.map((msg: string) => ({ + const parsedMessages = messages.map((msg: string) => { + try { + if (msg.trim().startsWith('{')) { + const parsed = JSON.parse(msg) + if (parsed.type && parsed.data) return parsed + } + } catch (e) { } + return msg + }) + + const newEntries = parsedMessages.map((msg: any) => ({ + id: `item-use-${Date.now()}-${Math.random()}`, time: timeStr, message: msg, - isPlayer: !msg.includes('attacks') + isPlayer: typeof msg === 'object' + ? msg.type !== 'enemy_attack' && msg.type !== 'flee_fail' + : !msg.includes('attacks') && !msg.includes('hits') })) setCombatLog((prev: any) => [...newEntries, ...prev]) @@ -688,8 +704,9 @@ export function useGameEngine( const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) setCombatLog([{ + id: Date.now() + Math.random(), time: timeStr, - message: `Combat started with ${response.data.combat.npc_name}!`, + message: { type: 'combat_start', data: { npc_name: response.data.combat.npc_name } }, isPlayer: true }]) diff --git a/pwa/src/components/game/types.ts b/pwa/src/components/game/types.ts index a1de803..a390fcb 100644 --- a/pwa/src/components/game/types.ts +++ b/pwa/src/components/game/types.ts @@ -56,8 +56,9 @@ export interface Profile { } export interface CombatLogEntry { + id: string | number time: string - message: string + message: string | { type: string; data: any } isPlayer: boolean } diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json index 905b264..11c79db 100644 --- a/pwa/src/i18n/locales/en.json +++ b/pwa/src/i18n/locales/en.json @@ -10,7 +10,16 @@ "no": "No", "game": "Game", "leaderboards": "Leaderboards", - "account": "Account" + "account": "Account", + "info": "Info", + "talk": "Talk", + "loot": "Loot", + "lootAll": "Loot All Available", + "examine": "Examine", + "fight": "Fight", + "pickUp": "Pick Up", + "pickUpAll": "Pick Up All", + "qty": "Qty" }, "auth": { "login": "Login", @@ -22,7 +31,23 @@ "forgotPassword": "Forgot Password?", "createAccount": "Create Account", "alreadyHaveAccount": "Already have an account?", - "dontHaveAccount": "Don't have an account?" + "dontHaveAccount": "Don't have an account?", + "rememberMe": "Remember me", + "loginTitle": "Welcome Back", + "registerTitle": "Create Account", + "loginSubtitle": "Sign in to continue your journey", + "registerSubtitle": "Join the survivors" + }, + "characters": { + "title": "Select Character", + "createNew": "Create New Character", + "play": "Play", + "delete": "Delete", + "noCharacters": "No characters yet", + "createFirst": "Create your first character to begin", + "name": "Character Name", + "class": "Class", + "level": "Level" }, "game": { "travel": "🧭 Travel", @@ -40,10 +65,41 @@ "use": "Use", "equip": "Equip", "unequip": "Unequip", - "attack": "Attack", - "flee": "Flee", + "attack": "⚔️ Attack", + "flee": "🏃 Flee", "rest": "Rest", - "onlineCount": "{{count}} Online" + "onlineCount": "{{count}} Online", + "searchItems": "Search items...", + "equipped": "Equipped", + "backpack": "Backpack", + "noBackpack": "No Backpack Equipped", + "distance": "Distance", + "stamina": "Stamina", + "weight": "Weight", + "volume": "Volume", + "durability": "Durability", + "noItemsFound": "No items found in this category" + }, + "location": { + "recentActivity": "📜 Recent Activity", + "enemies": "⚔️ Enemies", + "corpses": "💀 Corpses", + "npcs": "👥 NPCs", + "itemsOnGround": "📦 Items on Ground", + "lootableItems": "Lootable Items:", + "items": "item(s)", + "level": "Lv." + }, + "tags": { + "workbench": "🔧 Workbench", + "repairStation": "🛠️ Repair Station", + "safeZone": "🛡️ Safe Zone", + "shop": "🏪 Shop", + "shelter": "🏠 Shelter", + "medical": "⚕️ Medical", + "storage": "📦 Storage", + "water": "💧 Water", + "food": "🍎 Food" }, "stats": { "hp": "❤️ HP", @@ -53,8 +109,8 @@ "xp": "⭐ XP", "level": "Level", "unspentPoints": "⭐ Unspent", - "weight": "⚖️ Weight", - "volume": "📦 Volume", + "weight": "Weight", + "volume": "Volume", "strength": "💪 STR", "strengthFull": "Strength", "strengthDesc": "Increases melee damage and carry capacity", @@ -68,10 +124,23 @@ "intellectFull": "Intellect", "intellectDesc": "Enhances crafting and resource gathering", "armor": "🛡️ Armor", - "damage": "⚔️ Damage", - "durability": "Durability" + "damage": "Damage", + "durability": "Durability", + "tier": "Tier", + "hpRestore": "HP Restore", + "staminaRestore": "Stamina Restore", + "pen": "Pen", + "crit": "Crit", + "acc": "Acc", + "life": "Life", + "str": "STR", + "agi": "AGI", + "end": "END", + "hpMax": "HP max", + "stmMax": "Stm max" }, "combat": { + "title": "Combat", "inCombat": "In Combat", "yourTurn": "Your Turn", "enemyTurn": "Enemy's Turn", @@ -80,7 +149,20 @@ "youDied": "You Died", "respawn": "Respawn", "fleeSuccess": "You escaped!", - "fleeFailed": "Failed to escape!" + "fleeFailed": "Failed to escape!", + "enemyHp": "Enemy HP", + "playerHp": "Your HP", + "combatLog": "Combat Log", + "attacking": "Attacking", + "defending": "Defending", + "messages": { + "combat_start": "Combat started with {{enemy}}!", + "player_attack": "You attack for {{damage}} damage!", + "enemy_attack": "{{enemy}} attacks for {{damage}} damage!", + "victory": "Victory! Defeated {{enemy}}", + "flee_fail": "Failed to flee! {{enemy}} attacks for {{damage}} damage!" + }, + "turnTimer": "Turn Timer" }, "equipment": { "head": "Head", @@ -104,7 +186,16 @@ "staminaCost": "⚡ {{cost}} Stamina", "alreadyFull": "Already Full", "perfectCondition": "✅ Item is in perfect condition", - "yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage" + "yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage", + "selectItem": "Select an item to view details", + "chooseFromList": "Choose an item from the list on the left", + "yield": "Yield", + "repairCost": "Repair Cost", + "noMaterialsRequired": "No materials required", + "missing": "Missing", + "cost": "Cost", + "potentialBaseStats": "Potential base stats. Actual stats may vary.", + "confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone." }, "categories": { "all": "All Items", @@ -119,13 +210,38 @@ "misc": "Misc" }, "messages": { - "notEnoughStamina": "Not enough stamina", + "notEnoughStamina": "Not enough stamina (need {{need}}, have {{have}})", "inventoryFull": "Inventory full", "itemDropped": "Item dropped", "itemPickedUp": "Item picked up", "waitBeforeMoving": "Wait {{seconds}}s before moving", "cannotTravelInCombat": "Cannot travel during combat", - "cannotInteractInCombat": "Cannot interact during combat" + "cannotInteractInCombat": "Cannot interact during combat", + "interactionCooldown": "Wait {{seconds}}s before interacting again", + "youAreDead": "You are dead", + "cannotTravelCombat": "Cannot travel during combat", + "cannotGo": "Cannot go {{direction}}", + "enemyAppeared": "A {{name}} has appeared!", + "enemyDespawned": "A wandering enemy has left the area", + "corpsesDecayed": "{{count}} corpses have decayed", + "itemsDecayed": "{{count}} dropped items have decayed", + "waitBeforeMovingSimple": "Wait {{seconds}}s before moving" + }, + "directions": { + "north": "North", + "south": "South", + "east": "East", + "west": "West", + "northeast": "Northeast", + "northwest": "Northwest", + "southeast": "Southeast", + "southwest": "Southwest", + "up": "Up", + "down": "Down", + "inside": "Inside", + "outside": "Outside", + "enter": "Enter", + "exit": "Exit" }, "landing": { "heroTitle": "Echoes of the Ash", diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json index e94e764..183c748 100644 --- a/pwa/src/i18n/locales/es.json +++ b/pwa/src/i18n/locales/es.json @@ -10,19 +10,44 @@ "no": "No", "game": "Juego", "leaderboards": "Clasificación", - "account": "Cuenta" + "account": "Cuenta", + "info": "Info", + "talk": "Hablar", + "loot": "Saquear", + "lootAll": "Saquear Todo", + "examine": "Examinar", + "fight": "Luchar", + "pickUp": "Recoger", + "pickUpAll": "Recoger Todo", + "qty": "Cant" }, "auth": { - "login": "Iniciar Sesión", - "logout": "Cerrar Sesión", + "login": "Iniciar sesión", + "logout": "Cerrar sesión", "register": "Registrarse", "username": "Usuario", "password": "Contraseña", - "email": "Correo", + "email": "Correo electrónico", "forgotPassword": "¿Olvidaste tu contraseña?", - "createAccount": "Crear Cuenta", + "createAccount": "Crear cuenta", "alreadyHaveAccount": "¿Ya tienes una cuenta?", - "dontHaveAccount": "¿No tienes cuenta?" + "dontHaveAccount": "¿No tienes una cuenta?", + "rememberMe": "Recordarme", + "loginTitle": "Bienvenido de nuevo", + "registerTitle": "Crear cuenta", + "loginSubtitle": "Inicia sesión para continuar tu viaje", + "registerSubtitle": "Únete a los supervivientes" + }, + "characters": { + "title": "Seleccionar Personaje", + "createNew": "Crear Nuevo Personaje", + "play": "Jugar", + "delete": "Eliminar", + "noCharacters": "Aún no hay personajes", + "createFirst": "Crea tu primer personaje para comenzar", + "name": "Nombre del Personaje", + "class": "Clase", + "level": "Nivel" }, "game": { "travel": "🧭 Viajar", @@ -33,17 +58,48 @@ "workbench": "🔧 Banco de Trabajo", "craft": "🔨 Fabricar", "repair": "🛠️ Reparar", - "salvage": "♻️ Desmontar", + "salvage": "♻️ Desguazar", "pickUp": "Recoger", "drop": "Soltar", "dropAll": "Todo", "use": "Usar", "equip": "Equipar", "unequip": "Desequipar", - "attack": "Atacar", - "flee": "Huir", + "attack": "⚔️ Atacar", + "flee": "🏃 Huir", "rest": "Descansar", - "onlineCount": "{{count}} En línea" + "onlineCount": "{{count}} En línea", + "searchItems": "Buscar objetos...", + "equipped": "Equipado", + "backpack": "Mochila", + "noBackpack": "Sin mochila equipada", + "distance": "Distancia", + "stamina": "Aguante", + "weight": "Peso", + "volume": "Volumen", + "durability": "Durabilidad", + "noItemsFound": "No se encontraron objetos en esta categoría" + }, + "location": { + "recentActivity": "📜 Actividad Reciente", + "enemies": "⚔️ Enemigos", + "corpses": "💀 Cadáveres", + "npcs": "👥 NPCs", + "itemsOnGround": "📦 Objetos en el Suelo", + "lootableItems": "Objetos Saqueables:", + "items": "objeto(s)", + "level": "Nv." + }, + "tags": { + "workbench": "🔧 Banco de Trabajo", + "repairStation": "🛠️ Estación de Reparación", + "safeZone": "🛡️ Zona Segura", + "shop": "🏪 Tienda", + "shelter": "🏠 Refugio", + "medical": "⚕️ Médico", + "storage": "📦 Almacén", + "water": "💧 Agua", + "food": "🍎 Comida" }, "stats": { "hp": "❤️ Vida", @@ -53,34 +109,60 @@ "xp": "⭐ XP", "level": "Nivel", "unspentPoints": "⭐ Sin gastar", - "weight": "⚖️ Peso", - "volume": "📦 Volumen", + "weight": "Peso", + "volume": "Volumen", "strength": "💪 FUE", "strengthFull": "Fuerza", - "strengthDesc": "Aumenta el daño cuerpo a cuerpo y capacidad de carga", + "strengthDesc": "Aumenta el daño cuerpo a cuerpo y la capacidad de carga", "agility": "🏃 AGI", "agilityFull": "Agilidad", - "agilityDesc": "Mejora la esquiva y golpes críticos", + "agilityDesc": "Mejora la probabilidad de esquivar y los golpes críticos", "endurance": "🛡️ RES", "enduranceFull": "Resistencia", - "enduranceDesc": "Aumenta la vida y energía", + "enduranceDesc": "Aumenta la vida y el aguante", "intellect": "🧠 INT", "intellectFull": "Intelecto", - "intellectDesc": "Mejora la fabricación y recolección", + "intellectDesc": "Mejora la fabricación y recolección de recursos", "armor": "🛡️ Armadura", - "damage": "⚔️ Daño", - "durability": "Durabilidad" + "damage": "Daño", + "durability": "Durabilidad", + "tier": "Nivel", + "hpRestore": "Restaura Vida", + "staminaRestore": "Restaura Aguante", + "pen": "Pen", + "crit": "Crit", + "acc": "Prec", + "life": "Vida", + "str": "FUE", + "agi": "AGI", + "end": "RES", + "hpMax": "Vida máx", + "stmMax": "Agua. máx" }, "combat": { + "title": "Combate", "inCombat": "En Combate", "yourTurn": "Tu Turno", "enemyTurn": "Turno del Enemigo", "victory": "¡Victoria!", "defeat": "Derrota", "youDied": "Has Muerto", - "respawn": "Revivir", - "fleeSuccess": "¡Escapaste!", - "fleeFailed": "¡No pudiste escapar!" + "respawn": "Reaparecer", + "fleeSuccess": "¡Has escapado!", + "fleeFailed": "¡No has podido escapar!", + "enemyHp": "Vida del Enemigo", + "playerHp": "Tu Vida", + "combatLog": "Registro de Combate", + "turnTimer": "Temporizador de Turno", + "attacking": "Atacando", + "defending": "Defendiendo", + "messages": { + "combat_start": "¡Combate iniciado con {{enemy}}!", + "player_attack": "¡Atacas por {{damage}} de daño!", + "enemy_attack": "{{enemy}} ataca por {{damage}} de daño!", + "victory": "¡Victoria! Derrotaste a {{enemy}}", + "flee_fail": "¡Fallaste al huir! {{enemy}} ataca por {{damage}} de daño!" + } }, "equipment": { "head": "Cabeza", @@ -96,20 +178,29 @@ "requirements": "📊 Requisitos", "materials": "Materiales", "tools": "Herramientas", - "levelRequired": "Nivel {{level}} Requerido", + "levelRequired": "Requiere Nivel {{level}}", "missingRequirements": "Faltan Requisitos", "craftItem": "🔨 Fabricar", "repairItem": "🛠️ Reparar", - "salvageItem": "♻️ Desmontar", - "staminaCost": "⚡ {{cost}} Energía", - "alreadyFull": "Ya está Completo", - "perfectCondition": "✅ El objeto está en perfecto estado", - "yieldReduced": "⚠️ Rendimiento reducido {{percent}}% por daño" + "salvageItem": "♻️ Desguazar", + "staminaCost": "⚡ {{cost}} Aguante", + "alreadyFull": "Ya está completo", + "perfectCondition": "✅ El objeto está en perfectas condiciones", + "yieldReduced": "⚠️ Rendimiento reducido un {{percent}}% por daño", + "selectItem": "Selecciona un objeto para ver detalles", + "chooseFromList": "Elige un objeto de la lista de la izquierda", + "yield": "Rendimiento", + "repairCost": "Coste de Reparación", + "noMaterialsRequired": "No requiere materiales", + "missing": "Falta", + "cost": "Coste", + "potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.", + "confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer." }, "categories": { - "all": "Todos", + "all": "Todos los Objetos", "weapon": "Armas", - "armor": "Armadura", + "armor": "Armaduras", "clothing": "Ropa", "backpack": "Mochilas", "tool": "Herramientas", @@ -119,16 +210,41 @@ "misc": "Varios" }, "messages": { - "notEnoughStamina": "No tienes suficiente energía", + "notEnoughStamina": "No tienes suficiente aguante (necesitas {{need}}, tienes {{have}})", "inventoryFull": "Inventario lleno", "itemDropped": "Objeto soltado", "itemPickedUp": "Objeto recogido", "waitBeforeMoving": "Espera {{seconds}}s antes de moverte", - "cannotTravelInCombat": "No puedes viajar en combate", - "cannotInteractInCombat": "No puedes interactuar en combate" + "cannotTravelInCombat": "No puedes viajar durante el combate", + "cannotInteractInCombat": "No puedes interactuar durante el combate", + "interactionCooldown": "Espera {{seconds}}s antes de interactuar de nuevo", + "youAreDead": "Estás muerto", + "cannotTravelCombat": "No puedes viajar durante el combate", + "cannotGo": "No puedes ir al {{direction}}", + "enemyAppeared": "¡Un {{name}} ha aparecido!", + "enemyDespawned": "Un enemigo errante ha abandonado el área", + "corpsesDecayed": "{{count}} cadáveres se han descompuesto", + "itemsDecayed": "{{count}} objetos caídos se han descompuesto", + "waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte" + }, + "directions": { + "north": "Norte", + "south": "Sur", + "east": "Este", + "west": "Oeste", + "northeast": "Noreste", + "northwest": "Noroeste", + "southeast": "Sureste", + "southwest": "Suroeste", + "up": "Arriba", + "down": "Abajo", + "inside": "Adentro", + "outside": "Afuera", + "enter": "Entrar", + "exit": "Salir" }, "landing": { - "heroTitle": "Ecos de la Ceniza", + "heroTitle": "Ecos de las Cenizas", "heroSubtitle": "Un RPG de supervivencia post-apocalíptico", "playNow": "Jugar Ahora", "features": "Características" diff --git a/pwa/src/index.css b/pwa/src/index.css index 1beb82f..66c093a 100644 --- a/pwa/src/index.css +++ b/pwa/src/index.css @@ -1,5 +1,5 @@ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: 'Saira Condensed', system-ui, sans-serif; line-height: 1.5; font-weight: 400; diff --git a/pwa/src/services/api.ts b/pwa/src/services/api.ts index 375d7a2..2c83243 100644 --- a/pwa/src/services/api.ts +++ b/pwa/src/services/api.ts @@ -13,6 +13,13 @@ const api = axios.create({ }, }) +// Add request interceptor to include language preference +api.interceptors.request.use((config) => { + const language = localStorage.getItem('i18nextLng') || 'en' + config.headers['Accept-Language'] = language + return config +}) + // Add token to requests if it exists const token = localStorage.getItem('token') if (token) { diff --git a/pwa/vite.config.ts b/pwa/vite.config.ts index 3cc32ae..c09249d 100644 --- a/pwa/vite.config.ts +++ b/pwa/vite.config.ts @@ -4,11 +4,12 @@ import { VitePWA } from 'vite-plugin-pwa' // https://vitejs.dev/config/ export default defineConfig({ - base: './', // Use relative paths for Electron file:// protocol + base: '/', // Changed from ./ to / for better PWA absolute path resolution plugins: [ react(), VitePWA({ registerType: 'autoUpdate', + injectRegister: 'auto', includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'], manifest: { name: 'Echoes of the Ash', @@ -40,6 +41,9 @@ export default defineConfig({ ] }, workbox: { + cleanupOutdatedCaches: true, + skipWaiting: true, + clientsClaim: true, globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], runtimeCaching: [ {