Pre-combat-rewrite: Backup current state before comprehensive combat frontend rewrite

This commit is contained in:
Joan
2026-01-09 11:07:37 +01:00
parent dc438ae4c1
commit 2875e72b20
29 changed files with 1827 additions and 332 deletions

56
CLAUDE.md Normal file
View File

@@ -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.

627
README.md Normal file
View File

@@ -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.

View File

@@ -135,18 +135,24 @@ async def spawn_manager_loop(manager=None):
if manager: if manager:
from datetime import datetime from datetime import datetime
npc_def = NPCS.get(npc_id) 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( await manager.send_to_location(
location_id=location_id, location_id=location_id,
message={ message={
"type": "location_update", "type": "location_update",
"data": { "data": {
"message": f"A {npc_name} appeared!", "message": f"A {npc_name_en} appeared!",
"action": "enemy_spawned", "action": "enemy_spawned",
"npc_data": { "npc_data": {
"id": enemy_data['id'], "id": enemy_data['id'],
"npc_id": npc_id, "npc_id": npc_id,
"name": npc_name, "name": npc_name_obj,
"type": "enemy", "type": "enemy",
"is_wandering": True, "is_wandering": True,
"image_path": npc_def.image_path if npc_def else None "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", "type": "location_update",
"data": { "data": {
"message": f"{count} dropped item(s) decayed", "message": f"{count} dropped item(s) decayed",
"action": "items_decayed" "action": "items_decayed",
"count": count
}, },
"timestamp": datetime.utcnow().isoformat() "timestamp": datetime.utcnow().isoformat()
} }
@@ -472,7 +479,8 @@ async def decay_corpses(manager=None):
"type": "location_update", "type": "location_update",
"data": { "data": {
"message": f"{total} {corpse_type} decayed", "message": f"{total} {corpse_type} decayed",
"action": "corpses_decayed" "action": "corpses_decayed",
"count": total
}, },
"timestamp": datetime.utcnow().isoformat() "timestamp": datetime.utcnow().isoformat()
} }

View File

@@ -70,7 +70,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
detail="No character selected. Please select a character first." 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: if player is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,

View File

@@ -358,15 +358,7 @@ async def init_db():
await conn.execute(text(index_sql)) 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]]: 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 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: async def update_player_location(player_id: int, location_id: str) -> bool:

View File

@@ -6,14 +6,15 @@ import random
import time import time
from typing import Dict, Any, Tuple, Optional, List from typing import Dict, Any, Tuple, Optional, List
from . import database as db 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. Move player in a direction.
Returns: (success, message, new_location_id, stamina_cost, distance_meters) 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: if not player:
return False, "Player not found", None, 0, 0 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 return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0
# Update player location and stamina # Update player location and stamina
await db.update_player( await db.update_character(
player_id, player_id,
location_id=new_location_id, location_id=new_location_id,
stamina=max(0, player['stamina'] - stamina_cost) 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: async def inspect_area(player_id: int, location, interactables_data: Dict) -> str:
@@ -216,7 +219,7 @@ async def interact_with_object(
if not item: if not item:
continue 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 '' emoji = item.emoji if item and hasattr(item, 'emoji') else ''
# Check if item has durability (unique item) # Check if item has durability (unique item)
@@ -237,7 +240,7 @@ async def interact_with_object(
max_durability=item.durability, max_durability=item.durability,
tier=getattr(item, 'tier', None) 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_weight += item.weight
current_volume += item.volume current_volume += item.volume
else: else:
@@ -252,7 +255,7 @@ async def interact_with_object(
unique_stats=base_stats unique_stats=base_stats
) )
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id) 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: else:
# Stackable items - process as before # Stackable items - process as before
item_weight = item.weight * quantity item_weight = item.weight * quantity
@@ -262,13 +265,13 @@ async def interact_with_object(
current_volume + item_volume <= max_volume): current_volume + item_volume <= max_volume):
# Add to inventory # Add to inventory
await db.add_item_to_inventory(player_id, item_id, quantity) 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_weight += item_weight
current_volume += item_volume current_volume += item_volume
else: else:
# Drop to ground # Drop to ground
await db.drop_item_to_world(item_id, quantity, player['location_id']) 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 # Apply damage
if damage_taken > 0: if damage_taken > 0:
@@ -283,7 +286,7 @@ async def interact_with_object(
await db.set_interactable_cooldown(interactable_id, action_id, 60) await db.set_interactable_cooldown(interactable_id, action_id, 60)
# Build message # Build message
final_message = outcome.text final_message = get_locale_string(outcome.text)
if items_dropped: if items_dropped:
final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(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) heal_amount = int(combat['npc_max_hp'] * 0.05)
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount) new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp}) 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': elif intent_type == 'special':
# Strong attack (1.5x damage) # 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) actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage) 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: if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})" 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 # Enrage bonus if NPC is below 30% HP
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3: if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
npc_damage = int(npc_damage * 1.5) 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: else:
message = "" 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) actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage) 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: if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})" message += f" (Armor absorbed {armor_absorbed})"

View File

@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import * 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 .. import database as db
from ..items import ItemsManager from ..items import ItemsManager
from .. import game_logic from .. import game_logic
@@ -147,7 +147,7 @@ async def initiate_combat(
await manager.send_personal_message(current_user['id'], { await manager.send_personal_message(current_user['id'], {
"type": "combat_started", "type": "combat_started",
"data": { "data": {
"message": f"Combat started with {npc_def.name}!", "message": create_combat_message("combat_start", npc_name=npc_def.name),
"combat": { "combat": {
"npc_id": enemy.npc_id, "npc_id": enemy.npc_id,
"npc_name": npc_def.name, "npc_name": npc_def.name,
@@ -167,7 +167,7 @@ async def initiate_combat(
message={ message={
"type": "location_update", "type": "location_update",
"data": { "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", "action": "combat_started",
"player_id": player['id'] "player_id": player['id']
}, },
@@ -178,7 +178,7 @@ async def initiate_combat(
return { return {
"success": True, "success": True,
"message": f"Combat started with {npc_def.name}!", "message": create_combat_message("combat_start", npc_name=npc_def.name),
"combat": { "combat": {
"npc_id": enemy.npc_id, "npc_id": enemy.npc_id,
"npc_name": npc_def.name, "npc_name": npc_def.name,
@@ -304,7 +304,7 @@ async def combat_action(
if new_npc_hp <= 0: if new_npc_hp <= 0:
# NPC defeated # 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 combat_over = True
player_won = True player_won = True
@@ -435,7 +435,7 @@ async def combat_action(
# Failed to flee, NPC attacks # Failed to flee, NPC attacks
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
new_player_hp = max(0, player['hp'] - npc_damage) 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: if new_player_hp <= 0:
result_message += "\nYou have been defeated!" result_message += "\nYou have been defeated!"

View File

@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import * 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 .. import database as db
from ..items import ItemsManager from ..items import ItemsManager
from .. import game_logic 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 # 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} return {'craftable_items': craftable_items}

View File

@@ -2,7 +2,7 @@
Game Routes router. Game Routes router.
Auto-generated from main.py migration. 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 fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from datetime import datetime from datetime import datetime
@@ -391,8 +391,11 @@ async def spend_stat_point(
@router.get("/api/game/location") @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""" """Get current location information"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
location_id = current_user['location_id'] location_id = current_user['location_id']
location = LOCATIONS.get(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({ corpses_data.append({
"id": f"npc_{corpse['id']}", "id": f"npc_{corpse['id']}",
"type": "npc", "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": "💀", "emoji": "💀",
"loot_count": len(loot), "loot_count": len(loot),
"timestamp": corpse['death_timestamp'] "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") @router.post("/api/game/move")
async def move( async def move(
move_req: MoveRequest, move_req: MoveRequest,
request: Request,
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
"""Move player in a direction""" """Move player in a direction"""
@@ -756,10 +760,14 @@ async def move(
detail=f"You must wait {int(cooldown_remaining)} seconds before moving again." 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( success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
current_user['id'], current_user['id'],
move_req.direction, move_req.direction,
LOCATIONS LOCATIONS,
locale
) )
if not success: if not success:
@@ -951,9 +959,13 @@ async def inspect(current_user: dict = Depends(get_current_user)):
@router.post("/api/game/interact") @router.post("/api/game/interact")
async def interact( async def interact(
interact_req: InteractRequest, interact_req: InteractRequest,
request: Request,
current_user: dict = Depends(get_current_user) 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 # Check if player is in combat
combat = await db.get_active_combat(current_user['id']) combat = await db.get_active_combat(current_user['id'])
if combat: if combat:
@@ -1026,7 +1038,7 @@ async def interact(
"instance_id": interact_req.interactable_id, "instance_id": interact_req.interactable_id,
"action_id": interact_req.action_id, "action_id": interact_req.action_id,
"cooldown_remaining": cooldown_remaining, "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() "timestamp": datetime.utcnow().isoformat()
} }
@@ -1035,6 +1047,8 @@ async def interact(
return result return result
@router.post("/api/game/use_item") @router.post("/api/game/use_item")
async def use_item( async def use_item(
use_req: UseItemRequest, use_req: UseItemRequest,
@@ -1159,15 +1173,19 @@ async def use_item(
@router.post("/api/game/pickup") @router.post("/api/game/pickup")
async def pickup( async def pickup(
pickup_req: PickupItemRequest, pickup_req: PickupItemRequest,
request: Request,
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
"""Pick up an item from the ground""" """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) # 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 # 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) dropped_item = await db.get_dropped_item(pickup_req.item_id)
if dropped_item: if dropped_item:
item_def = ITEMS_MANAGER.get_item(dropped_item['item_id']) 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: else:
item_name = "item" item_name = "item"
@@ -1392,5 +1410,5 @@ async def drop_item(
return { return {
"success": True, "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}"
} }

View File

@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import * 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 .. import database as db
from ..items import ItemsManager from ..items import ItemsManager
from .. import game_logic from .. import game_logic
@@ -310,13 +310,13 @@ async def loot_corpse(
message_parts = [] message_parts = []
for item in looted_items: for item in looted_items:
item_def = ITEMS_MANAGER.get_item(item['item_id']) 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']}") message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
dropped_parts = [] dropped_parts = []
for item in dropped_items: for item in dropped_items:
item_def = ITEMS_MANAGER.get_item(item['item_id']) 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']}") dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
message = "" message = ""
@@ -438,13 +438,13 @@ async def loot_corpse(
message_parts = [] message_parts = []
for item in looted_items: for item in looted_items:
item_def = ITEMS_MANAGER.get_item(item['item_id']) 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']}") message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
dropped_parts = [] dropped_parts = []
for item in dropped_items: for item in dropped_items:
item_def = ITEMS_MANAGER.get_item(item['item_id']) 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']}") dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
message = "" message = ""

View File

@@ -15,6 +15,45 @@ def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> st
return str(value) 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: def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
""" """
Calculate distance between two points using Euclidean distance. Calculate distance between two points using Euclidean distance.

View File

@@ -54,11 +54,11 @@
"npc_id": "raider_scout", "npc_id": "raider_scout",
"name": { "name": {
"en": "Raider Scout", "en": "Raider Scout",
"es": "" "es": "Explorador"
}, },
"description": { "description": {
"en": "A lone raider wearing makeshift armor. They eye you with hostile intent.", "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": "🏴‍☠️", "emoji": "🏴‍☠️",
"hp_min": 30, "hp_min": 30,
@@ -116,11 +116,11 @@
"npc_id": "mutant_rat", "npc_id": "mutant_rat",
"name": { "name": {
"en": "Mutant Rat", "en": "Mutant Rat",
"es": "" "es": "Rata mutante"
}, },
"description": { "description": {
"en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.", "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": "🐀", "emoji": "🐀",
"hp_min": 10, "hp_min": 10,
@@ -160,11 +160,11 @@
"npc_id": "infected_human", "npc_id": "infected_human",
"name": { "name": {
"en": "Infected Human", "en": "Infected Human",
"es": "" "es": "Humano infectado"
}, },
"description": { "description": {
"en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.", "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": "🧟", "emoji": "🧟",
"hp_min": 35, "hp_min": 35,

View File

@@ -34,14 +34,18 @@ server {
add_header Expires "0"; add_header Expires "0";
} }
# Manifest should be cached for a short time # Manifest should never be cached
location /manifest.webmanifest { 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 # SPA fallback - all other requests go to index.html
location / { location / {
try_files $uri $uri/ /index.html; 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";
} }
} }

View File

@@ -1,17 +1,24 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a1a" /> <meta name="theme-color" content="#1a1a1a" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400;500;600;700;800&display=swap"
rel="stylesheet">
<meta name="description" content="A post-apocalyptic survival RPG" /> <meta name="description" content="A post-apocalyptic survival RPG" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>Echoes of the Ash</title> <title>Echoes of the Ash</title>
</head> </head>
<body>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,6 @@
// Game.tsx - Main game orchestrator (refactored from 3,315 lines to ~350 lines) // Game.tsx - Main game orchestrator (refactored from 3,315 lines to ~350 lines)
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import api from '../services/api' import api from '../services/api'
import { useGameEngine } from './game/hooks/useGameEngine' import { useGameEngine } from './game/hooks/useGameEngine'
import Combat from './game/Combat' import Combat from './game/Combat'
@@ -9,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
import './Game.css' import './Game.css'
function Game() { function Game() {
const { t, i18n } = useTranslation()
const [token] = useState(() => localStorage.getItem('token')) const [token] = useState(() => localStorage.getItem('token'))
// Handle WebSocket messages // Handle WebSocket messages
@@ -23,11 +25,29 @@ function Game() {
case 'location_update': case 'location_update':
// General location updates - update state directly from message data when possible // General location updates - update state directly from message data when possible
console.log('🗺️ Location update:', message.data?.action, message.data?.message) 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) { if (action === 'player_arrived' && message.data.player_id) {
// Add player to location directly without API call // Add player to location directly without API call
actions.addPlayerToLocation({ actions.addPlayerToLocation({
@@ -326,6 +346,7 @@ function Game() {
{/* Location view (when not in combat) */} {/* Location view (when not in combat) */}
{!state.combatState && state.location && state.playerState && ( {!state.combatState && state.location && state.playerState && (
<LocationView <LocationView
key={state.location.id}
location={state.location} location={state.location}
playerState={state.playerState} playerState={state.playerState}
combatState={state.combatState || null} combatState={state.combatState || null}

View File

@@ -11,6 +11,9 @@ function LanguageSelector() {
const changeLanguage = (langCode: string) => { const changeLanguage = (langCode: string) => {
i18n.changeLanguage(langCode) 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] const currentLang = languages.find(l => l.code === i18n.language) || languages[0]

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import CombatView from './CombatView' import CombatView from './CombatView'
import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types' import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import api from '../../services/api' import api from '../../services/api'
import { getTranslatedText } from '../../utils/i18nUtils'
import './CombatEffects.css' import './CombatEffects.css'
interface CombatProps { interface CombatProps {
@@ -46,6 +47,35 @@ const Combat = ({
// Turn timer state for PvE combat // Turn timer state for PvE combat
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null) const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(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<Set<NodeJS.Timeout>>(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 // PvP Timer Effect
useEffect(() => { useEffect(() => {
@@ -110,11 +140,17 @@ const Combat = ({
}, [turnTimeRemaining, combatState, updateCombatState]) }, [turnTimeRemaining, combatState, updateCombatState])
const addFloatingText = (text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal') => { 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 }]) setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
setTimeout(() => { const timeout = setTimeout(() => {
if (isMounted.current) {
setFloatingTexts(prev => prev.filter(ft => ft.id !== id)) setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
// Remove this timeout from the tracking set
floatingTextTimeouts.current.delete(timeout)
}
}, 2500) }, 2500)
// Track this timeout for cleanup
floatingTextTimeouts.current.add(timeout)
} }
const handlePvEAction = async (action: string) => { const handlePvEAction = async (action: string) => {
@@ -130,38 +166,73 @@ const Combat = ({
const messages = data.message.split('\n').filter((m: string) => m.trim()) const messages = data.message.split('\n').filter((m: string) => m.trim())
// Handle failed flee special case - split combined message // Handle failed flee special case - split combined message
const processedMessages: string[] = [] const processedMessages: any[] = []
messages.forEach((msg: string) => { 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 // Check if message contains both flee failure and enemy attack
const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/) const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/)
if (fleeFailMatch) { if (fleeFailMatch) {
processedMessages.push(fleeFailMatch[1]) // "Failed to flee!" 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 { } else {
processedMessages.push(msg) processedMessages.push(msg)
} }
}) })
const playerMessages = processedMessages.filter((msg: string) => const playerMessages = processedMessages.filter((msg: any) => {
msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!' if (typeof msg === 'object') {
) return msg.type === 'player_attack' || msg.type === 'victory' || msg.type === 'combat_start'
const enemyMessages = processedMessages.filter((msg: string) => }
msg !== 'Failed to flee!' && 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 ')) (msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
) })
// Check if this is a failed flee attempt // Check if this is a failed flee attempt
const isFailedFlee = playerMessages.some(msg => msg === 'Failed to flee!') const isFailedFlee = playerMessages.some(msg => msg === 'Failed to flee!')
// 1. Immediate Player Feedback // 1. Immediate Player Feedback
playerMessages.forEach((msg: string) => { playerMessages.forEach((msg: any) => {
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true }) 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 // Only show attack animations for actual attacks, not flee failures
if (msg !== 'Failed to flee!') { if (msg !== 'Failed to flee!' && (typeof msg !== 'object' || msg.type === 'player_attack')) {
const damageMatch = msg.match(/(\d+) damage/) const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1]
if (damageMatch) { if (damage) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt') // White text on enemy addFloatingText(damage.toString(), 50, 30, 'damage-player-dealt') // White text on enemy
setFlash(true) setFlash(true)
setTimeout(() => setFlash(false), 300) setTimeout(() => setFlash(false), 300)
} }
@@ -193,12 +264,13 @@ const Combat = ({
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise(resolve => setTimeout(resolve, 2000))
enemyMessages.forEach((msg: string) => { enemyMessages.forEach((msg: any) => {
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: false }) 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/) const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1]
if (damageMatch) { if (damage) {
addFloatingText(damageMatch[1], 50, 50, 'damage-player') // Red text over player position addFloatingText(damage.toString(), 50, 50, 'damage-player') // Red text over player position
setShake(true) setShake(true)
setTimeout(() => setShake(false), 500) setTimeout(() => setShake(false), 500)
} }
@@ -293,7 +365,8 @@ const Combat = ({
// Parse message for damage // Parse message for damage
// Example: "You attacked X for 10 damage!" // Example: "You attacked X for 10 damage!"
const msg = data.message || '' 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/) const damageMatch = msg.match(/(\d+) damage/)
if (damageMatch) { if (damageMatch) {
@@ -324,7 +397,7 @@ const Combat = ({
health: tempPlayerHP health: tempPlayerHP
} : playerState} } : playerState}
equipment={equipment} equipment={equipment}
enemyName={combatState.combat?.npc_name || 'Enemy'} enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'}
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''} enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
enemyTurnMessage={localEnemyTurnMessage} enemyTurnMessage={localEnemyTurnMessage}
pvpTimeRemaining={pvpTimer} pvpTimeRemaining={pvpTimer}

View File

@@ -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 (
<div className="combat-view">
<div className="combat-header-inline">
<h2>
{combatState.is_pvp ? `⚔️ ${t('combat.title')} - PvP` : `⚔️ ${t('combat.title')} - ${displayEnemyName}`}
</h2>
</div>
{combatState.is_pvp ? (
/* PvP Combat UI - Unified Layout */
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Opponent Display (using same structure as PvE Enemy) */}
<div className="combat-enemy-image-large">
<div className="floating-texts-container">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
</div>
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return <div className="pvp-opponent-avatar"></div>
// 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 (
<div className="pvp-opponent-avatar" style={{ fontSize: '4rem', textAlign: 'center' }}>
👤
<div style={{ fontSize: '1rem', marginTop: '0.5rem' }}>{opponent.username} (Lv. {opponent.level})</div>
</div>
)
})()}
</div>
<div className="combat-enemy-info-inline">
{/* 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 (
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{opponent.username}: {opponent.hp} / {opponent.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(opponent.hp / opponent.max_hp) * 100}%`
}}
/>
</div>
</div>
)
})()}
{/* 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 (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
You: {you.hp} / {you.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)
})()}
</div>
</div>
<div className="combat-turn-indicator-inline">
{combatState.pvp_combat.combat_over ? (
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "🏃 Combat Ended" : "💀 Combat Over"}
</span>
) : combatState.pvp_combat.your_turn ? (
<span className="your-turn"> Your Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
) : (
<span className="enemy-turn"> Opponent's Turn ({pvpTimeRemaining ?? combatState.pvp_combat.time_remaining}s)</span>
)}
</div>
<div className="combat-actions-inline">
{!combatState.pvp_combat.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onPvPAction('attack')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('game.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onPvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('game.flee')}
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitPvPCombat}
>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? ' Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
<div className="combat-log-inline">
<div className="log-entries">
<div className="log-list">
{combatLog.length > 0 ? (
combatLog.map((entry: any) => (
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{renderCombatMessage(entry.message)}</span>
</div>
))
) : (
<div className="log-entry"><span className="log-message">PvP Combat started...</span></div>
)}
</div>
</div>
</div>
</div>
</div>
) : (
/* PvE Combat UI */
<>
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Intent Bubble - Moved here to avoid overflow:hidden clipping */}
{combatState.combat?.npc_intent && !combatState.combat_over && (
<div className={`intent-bubble intent-${combatState.combat.npc_intent}`}>
<span className="intent-icon">
{combatState.combat.npc_intent === 'attack' ? '' :
combatState.combat.npc_intent === 'defend' ? '🛡' :
combatState.combat.npc_intent === 'special' ? '🔥' : ''}
</span>
<span className="intent-desc">{combatState.combat.npc_intent}</span>
</div>
)}
<div className="combat-enemy-image-large">
<div className="floating-texts-container">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
</div>
<img
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
className={`${flashEnemy ? 'flash-hit' : ''
} ${combatState.combat_over && combatState.player_won ? 'enemy-dead' : ''
} ${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : ''
}`}
/>
</div>
<div className="combat-enemy-info-inline">
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{t('combat.enemyHp')}: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`
}}
/>
</div>
</div>
{playerState && (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
{t('combat.playerHp')}: {playerState.health} / {playerState.max_health}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(playerState.health / playerState.max_health) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)'
}}
/>
</div>
</div>
)}
</div>
</div>
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? (
enemyTurnMessage ? (
<span className="enemy-turn">🗡️ Enemy's turn...</span>
) : combatState.combat?.turn === 'player' ? (
<>
<span className="your-turn"> {t('combat.yourTurn')}</span>
{turnTimeRemaining !== null && (
<span className="turn-timer" style={{ marginLeft: '1rem', fontSize: '0.9em', opacity: 0.8 }}>
{Math.floor(turnTimeRemaining / 60)}:{String(Math.floor(turnTimeRemaining % 60)).padStart(2, '0')}
</span>
)}
</>
) : (
<span className="enemy-turn"> {t('combat.enemyTurn')}</span>
)
) : (
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
{combatState.player_won ? `${t('combat.victory')}` : combatState.player_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
</span>
)}
</div>
{/* PvE Combat Actions */}
<div className="combat-actions-inline">
{!combatState.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onCombatAction('attack')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('game.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('game.flee')}
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitCombat}
>
{combatState.player_won ? '✅ Continue' : combatState.player_fled ? '✅ Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
<div className="combat-log-inline">
<div className="log-entries">
<div className="log-list">
{combatLog.length > 0 ? (
combatLog.map((entry: any) => (
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{renderCombatMessage(entry.message)}</span>
</div>
))
) : (
<div className="log-entry"><span className="log-message">Combat started...</span></div>
)}
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}
export default CombatView

View File

@@ -34,19 +34,19 @@ function InventoryModal({
onUnequipItem, onUnequipItem,
onDropItem onDropItem
}: InventoryModalProps) { }: InventoryModalProps) {
useTranslation() const { t } = useTranslation()
// Categories for the sidebar // Categories for the sidebar
const categories = [ const categories = [
{ id: 'all', label: 'All Items', icon: '🎒' }, { id: 'all', label: t('categories.all'), icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' }, { id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' }, { id: 'armor', label: t('categories.armor'), icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' }, { id: 'clothing', label: t('categories.clothing'), icon: '👕' },
{ id: 'backpack', label: 'Backpacks', icon: '🎒' }, { id: 'backpack', label: t('categories.backpack'), icon: '🎒' },
{ id: 'tool', label: 'Tools', icon: '🛠️' }, { id: 'tool', label: t('categories.tool'), icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' }, { id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' }, { id: 'resource', label: t('categories.resource'), icon: '📦' },
{ id: 'quest', label: 'Quest', icon: '📜' }, { id: 'quest', label: t('categories.quest'), icon: '📜' },
{ id: 'misc', label: 'Misc', icon: '📦' } { id: 'misc', label: t('categories.misc'), icon: '📦' }
] ]
// Use inventory directly as it now includes equipped items // Use inventory directly as it now includes equipped items
@@ -100,7 +100,7 @@ function InventoryModal({
<div className="item-header-compact"> <div className="item-header-compact">
<span className="item-emoji-inline">{item.emoji}</span> <span className="item-emoji-inline">{item.emoji}</span>
<h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{getTranslatedText(item.name)}</h4> <h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{getTranslatedText(item.name)}</h4>
{item.is_equipped && <span className="item-card-equipped">Equipped</span>} {item.is_equipped && <span className="item-card-equipped">{t('game.equipped')}</span>}
</div> </div>
<div className="item-stats-row"> <div className="item-stats-row">
@@ -149,17 +149,17 @@ function InventoryModal({
)} )}
{(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && ( {(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && (
<span className="stat-badge penetration"> <span className="stat-badge penetration">
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} Pen 💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen')}
</span> </span>
)} )}
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && ( {(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
<span className="stat-badge crit"> <span className="stat-badge crit">
🎯 +{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')}
</span> </span>
)} )}
{(item.unique_stats?.accuracy || item.stats?.accuracy) && ( {(item.unique_stats?.accuracy || item.stats?.accuracy) && (
<span className="stat-badge accuracy"> <span className="stat-badge 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')}
</span> </span>
)} )}
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && ( {(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
@@ -169,34 +169,34 @@ function InventoryModal({
)} )}
{(item.unique_stats?.lifesteal || item.stats?.lifesteal) && ( {(item.unique_stats?.lifesteal || item.stats?.lifesteal) && (
<span className="stat-badge lifesteal"> <span className="stat-badge 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')}
</span> </span>
)} )}
{/* Attributes */} {/* Attributes */}
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && ( {(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
<span className="stat-badge strength"> <span className="stat-badge strength">
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} STR 💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str')}
</span> </span>
)} )}
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && ( {(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
<span className="stat-badge agility"> <span className="stat-badge agility">
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} AGI 🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi')}
</span> </span>
)} )}
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && ( {(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
<span className="stat-badge endurance"> <span className="stat-badge endurance">
🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} END 🏋 +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end')}
</span> </span>
)} )}
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && ( {(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
<span className="stat-badge health"> <span className="stat-badge health">
+{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} HP max +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} {t('stats.hpMax')}
</span> </span>
)} )}
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && ( {(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
<span className="stat-badge stamina"> <span className="stat-badge stamina">
+{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} Stm max +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')}
</span> </span>
)} )}
@@ -217,7 +217,7 @@ function InventoryModal({
{hasDurability && ( {hasDurability && (
<div className="durability-container"> <div className="durability-container">
<div className="durability-header"> <div className="durability-header">
<span>Durability</span> <span>{t('game.durability')}</span>
<span className={ <span className={
currentDurability < maxDurability * 0.2 currentDurability < maxDurability * 0.2
? "durability-text-low" ? "durability-text-low"
@@ -246,18 +246,18 @@ function InventoryModal({
{/* Right: Actions */} {/* Right: Actions */}
<div className="item-actions-section"> <div className="item-actions-section">
{item.consumable && ( {item.consumable && (
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>Use</button> <button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>{t('game.use')}</button>
)} )}
{item.equippable && !item.is_equipped && ( {item.equippable && !item.is_equipped && (
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>Equip</button> <button className="action-btn equip" onClick={() => onEquipItem(item.id)}>{t('game.equip')}</button>
)} )}
{item.is_equipped && ( {item.is_equipped && (
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>Unequip</button> <button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>{t('game.unequip')}</button>
)} )}
<div className="drop-actions-group"> <div className="drop-actions-group">
{item.quantity > 1 && ( {item.quantity > 1 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 1)}>x1</button> <button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, item.quantity)}>{t('game.dropAll')}</button>
)} )}
{item.quantity >= 5 && ( {item.quantity >= 5 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button> <button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
@@ -266,7 +266,7 @@ function InventoryModal({
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button> <button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button>
)} )}
<button className={`action-btn drop ${item.quantity === 1 ? 'single' : ''}`} onClick={() => onDropItem(item.item_id, item.id, item.quantity === 1 ? 1 : item.quantity)}> <button className={`action-btn drop ${item.quantity === 1 ? 'single' : ''}`} onClick={() => onDropItem(item.item_id, item.id, item.quantity === 1 ? 1 : item.quantity)}>
{item.quantity === 1 ? 'Drop' : 'All'} {item.quantity === 1 ? t('game.drop') : t('game.dropAll')}
</button> </button>
</div> </div>
</div> </div>
@@ -288,7 +288,7 @@ function InventoryModal({
<span className="metric-icon"></span> <span className="metric-icon"></span>
<div className="metric-bar-container"> <div className="metric-bar-container">
<div className="metric-text"> <div className="metric-text">
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
</div> </div>
<div className="metric-bar"> <div className="metric-bar">
<div <div
@@ -303,7 +303,7 @@ function InventoryModal({
<span className="metric-icon">📦</span> <span className="metric-icon">📦</span>
<div className="metric-bar-container"> <div className="metric-bar-container">
<div className="metric-text"> <div className="metric-text">
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
</div> </div>
<div className="metric-bar"> <div className="metric-bar">
<div <div
@@ -328,7 +328,7 @@ function InventoryModal({
) : ( ) : (
<div className="backpack-status inactive"> <div className="backpack-status inactive">
<span className="backpack-icon">🚫</span> <span className="backpack-icon">🚫</span>
<span>No Backpack Equipped</span> <span>{t('game.noBackpack')}</span>
</div> </div>
)} )}
<button className="close-btn" onClick={onClose}></button> <button className="close-btn" onClick={onClose}></button>
@@ -356,7 +356,7 @@ function InventoryModal({
<span className="search-icon">🔍</span> <span className="search-icon">🔍</span>
<input <input
type="text" type="text"
placeholder="Search items..." placeholder={t('game.searchItems')}
value={inventoryFilter} value={inventoryFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)} onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
/> />
@@ -366,32 +366,29 @@ function InventoryModal({
{filteredItems.length === 0 ? ( {filteredItems.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<span className="empty-icon">📦</span> <span className="empty-icon">📦</span>
<p>No items found in this category</p> <p>{t('game.noItemsFound')}</p>
</div> </div>
) : ( ) : (
inventoryCategoryFilter === 'all' ? ( inventoryCategoryFilter === 'all' ? (
<> <>
{/* Equipped */} {/* Equipped */}
{filteredItems.some((i: any) => i.is_equipped) && ( {filteredItems.some((item: any) => item.is_equipped) && (
<> <>
<div className="category-header"> Equipped</div> <div className="category-header"> {t('game.equipped')}</div>
{filteredItems.filter((i: any) => i.is_equipped).map((item: any, i: number) => renderItemCard(item, i))} {filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
</> </>
)} )}
{/* Categories */} {/* Backpack */}
{categories.filter(c => c.id !== 'all').map(cat => { {filteredItems.some((item: any) => !item.is_equipped) && (
const categoryItems = filteredItems.filter((i: any) => !i.is_equipped && i.type === cat.id); <>
if (categoryItems.length === 0) return null; <div className="category-header">🎒 {t('game.backpack')}</div>
return ( {filteredItems.filter((item: any) => !item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
<div key={cat.id}> </>
<div className="category-header">{cat.icon} {cat.label}</div> )}
{categoryItems.map((item: any, i: number) => renderItemCard(item, i))}
</div>
);
})}
</> </>
) : ( ) : (
/* Single category */
filteredItems.map((item: any, i: number) => renderItemCard(item, i)) filteredItems.map((item: any, i: number) => renderItemCard(item, i))
) )
)} )}

View File

@@ -82,7 +82,7 @@ function LocationView({
onRepair, onRepair,
onUncraft onUncraft
}: LocationViewProps) { }: LocationViewProps) {
useTranslation() const { t } = useTranslation()
return ( return (
<div className="location-view"> <div className="location-view">
<div className="location-info"> <div className="location-info">
@@ -115,15 +115,15 @@ function LocationView({
onClick={isClickable ? handleClick : undefined} onClick={isClickable ? handleClick : undefined}
style={isClickable ? { cursor: 'pointer' } : undefined} style={isClickable ? { cursor: 'pointer' } : undefined}
> >
{tag === 'workbench' && '🔧 Workbench'} {tag === 'workbench' && t('tags.workbench')}
{tag === 'repair_station' && '🛠️ Repair Station'} {tag === 'repair_station' && t('tags.repairStation')}
{tag === 'safe_zone' && '🛡️ Safe Zone'} {tag === 'safe_zone' && t('tags.safeZone')}
{tag === 'shop' && '🏪 Shop'} {tag === 'shop' && t('tags.shop')}
{tag === 'shelter' && '🏠 Shelter'} {tag === 'shelter' && t('tags.shelter')}
{tag === 'medical' && '⚕️ Medical'} {tag === 'medical' && t('tags.medical')}
{tag === 'storage' && '📦 Storage'} {tag === 'storage' && t('tags.storage')}
{tag === 'water_source' && '💧 Water'} {tag === 'water_source' && t('tags.water')}
{tag === 'food_source' && '🍎 Food'} {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}`} {tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
</span> </span>
) )
@@ -157,7 +157,7 @@ function LocationView({
{locationMessages.length > 0 && ( {locationMessages.length > 0 && (
<div className="location-messages-log"> <div className="location-messages-log">
<h4>📜 Recent Activity</h4> <h4>{t('location.recentActivity')}</h4>
<div className="messages-scroll"> <div className="messages-scroll">
{locationMessages.slice(-10).reverse().map((msg, idx) => ( {locationMessages.slice(-10).reverse().map((msg, idx) => (
<div key={idx} className="location-message-item"> <div key={idx} className="location-message-item">
@@ -173,7 +173,7 @@ function LocationView({
{/* Enemies */} {/* Enemies */}
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && ( {location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
<div className="entity-section enemies-section"> <div className="entity-section enemies-section">
<h3> Enemies</h3> <h3>{t('location.enemies')}</h3>
<div className="entity-list"> <div className="entity-list">
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => ( {location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
<div key={i} className="entity-card enemy-card"> <div key={i} className="entity-card enemy-card">
@@ -188,13 +188,13 @@ function LocationView({
)} )}
<div className="entity-info"> <div className="entity-info">
<div className="entity-name enemy-name">{getTranslatedText(enemy.name)}</div> <div className="entity-name enemy-name">{getTranslatedText(enemy.name)}</div>
{enemy.level && <div className="entity-level">Lv. {enemy.level}</div>} {enemy.level && <div className="entity-level">{t('location.level')} {enemy.level}</div>}
</div> </div>
<button <button
className="entity-action-btn combat-btn" className="entity-action-btn combat-btn"
onClick={() => onInitiateCombat(enemy.id)} onClick={() => onInitiateCombat(enemy.id)}
> >
Fight {t('common.fight')}
</button> </button>
</div> </div>
))} ))}
@@ -205,28 +205,28 @@ function LocationView({
{/* Corpses */} {/* Corpses */}
{location.corpses && location.corpses.length > 0 && ( {location.corpses && location.corpses.length > 0 && (
<div className="entity-section corpses-section"> <div className="entity-section corpses-section">
<h3>💀 Corpses</h3> <h3>{t('location.corpses')}</h3>
<div className="entity-list"> <div className="entity-list">
{location.corpses.map((corpse: any) => ( {location.corpses.map((corpse: any) => (
<div key={corpse.id} className="corpse-container"> <div key={corpse.id} className="corpse-container">
<div className="entity-card corpse-card"> <div className="entity-card corpse-card">
<div className="entity-info"> <div className="entity-info">
<div className="entity-name">{corpse.emoji} {getTranslatedText(corpse.name)}</div> <div className="entity-name">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
<div className="corpse-loot-count">{corpse.loot_count} item(s)</div> <div className="corpse-loot-count">{corpse.loot_count} {t('location.items')}</div>
</div> </div>
<button <button
className="entity-action-btn loot-btn" className="entity-action-btn loot-btn"
onClick={() => onLootCorpse(String(corpse.id))} onClick={() => onLootCorpse(String(corpse.id))}
disabled={corpse.loot_count === 0} disabled={corpse.loot_count === 0}
> >
🔍 Examine 🔍 {t('common.examine')}
</button> </button>
</div> </div>
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && ( {expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
<div className="corpse-details"> <div className="corpse-details">
<div className="corpse-details-header"> <div className="corpse-details-header">
<h4>Lootable Items:</h4> <h4>{t('location.lootableItems')}</h4>
<button <button
className="close-btn" className="close-btn"
onClick={() => { onClick={() => {
@@ -244,7 +244,7 @@ function LocationView({
{item.emoji} {getTranslatedText(item.item_name)} {item.emoji} {getTranslatedText(item.item_name)}
</div> </div>
<div className="corpse-item-qty"> <div className="corpse-item-qty">
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}` : ''}
</div> </div>
{item.required_tool && ( {item.required_tool && (
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}> <div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
@@ -258,7 +258,7 @@ function LocationView({
disabled={!item.can_loot} disabled={!item.can_loot}
title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'} title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}
> >
{item.can_loot ? '📦 Loot' : '🔒'} {item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
</button> </button>
</div> </div>
))} ))}
@@ -267,7 +267,7 @@ function LocationView({
className="loot-all-btn" className="loot-all-btn"
onClick={() => onLootCorpseItem(String(corpse.id), null)} onClick={() => onLootCorpseItem(String(corpse.id), null)}
> >
📦 Loot All Available 📦 {t('common.lootAll')}
</button> </button>
</div> </div>
)} )}
@@ -280,16 +280,16 @@ function LocationView({
{/* Friendly NPCs */} {/* Friendly NPCs */}
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && ( {location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
<div className="entity-section npcs-section"> <div className="entity-section npcs-section">
<h3>👥 NPCs</h3> <h3>{t('location.npcs')}</h3>
<div className="entity-list"> <div className="entity-list">
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => ( {location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
<div key={i} className="entity-card npc-card"> <div key={i} className="entity-card npc-card">
<span className="entity-icon">🧑</span> <span className="entity-icon">🧑</span>
<div className="entity-info"> <div className="entity-info">
<div className="entity-name">{getTranslatedText(npc.name)}</div> <div className="entity-name">{getTranslatedText(npc.name)}</div>
{npc.level && <div className="entity-level">Lv. {npc.level}</div>} {npc.level && <div className="entity-level">{t('location.level')} {npc.level}</div>}
</div> </div>
<button className="entity-action-btn">Talk</button> <button className="entity-action-btn">{t('common.talk')}</button>
</div> </div>
))} ))}
</div> </div>
@@ -299,7 +299,7 @@ function LocationView({
{/* Items on Ground */} {/* Items on Ground */}
{location.items.length > 0 && ( {location.items.length > 0 && (
<div className="entity-section items-section"> <div className="entity-section items-section">
<h3>📦 Items on Ground</h3> <h3>{t('location.itemsOnGround')}</h3>
<div className="entity-list"> <div className="entity-list">
{location.items.map((item: any, i: number) => ( {location.items.map((item: any, i: number) => (
<div key={i} className="entity-card item-card"> <div key={i} className="entity-card item-card">
@@ -323,37 +323,37 @@ function LocationView({
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>} {item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
</div> </div>
<div className="item-info-btn-container"> <div className="item-info-btn-container">
<button className="entity-action-btn info" title="Item Info">Info</button> <button className="entity-action-btn info" title="Item Info">{t('common.info')}</button>
<div className="item-info-tooltip"> <div className="item-info-tooltip">
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>} {item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
{item.weight !== undefined && item.weight > 0 && ( {item.weight !== undefined && item.weight > 0 && (
<div className="item-tooltip-stat"> <div className="item-tooltip-stat">
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)`}
</div> </div>
)} )}
{item.volume !== undefined && item.volume > 0 && ( {item.volume !== undefined && item.volume > 0 && (
<div className="item-tooltip-stat"> <div className="item-tooltip-stat">
📦 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)`}
</div> </div>
)} )}
{item.hp_restore && item.hp_restore > 0 && ( {item.hp_restore && item.hp_restore > 0 && (
<div className="item-tooltip-stat"> HP Restore: +{item.hp_restore}</div> <div className="item-tooltip-stat"> {t('stats.hpRestore')}: +{item.hp_restore}</div>
)} )}
{item.stamina_restore && item.stamina_restore > 0 && ( {item.stamina_restore && item.stamina_restore > 0 && (
<div className="item-tooltip-stat"> Stamina Restore: +{item.stamina_restore}</div> <div className="item-tooltip-stat"> {t('stats.staminaRestore')}: +{item.stamina_restore}</div>
)} )}
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && ( {item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
<div className="item-tooltip-stat"> <div className="item-tooltip-stat">
Damage: {item.damage_min}-{item.damage_max} {t('stats.damage')}: {item.damage_min}-{item.damage_max}
</div> </div>
)} )}
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && ( {item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
<div className="item-tooltip-stat"> <div className="item-tooltip-stat">
🔧 Durability: {item.durability}/{item.max_durability} 🔧 {t('stats.durability')}: {item.durability}/{item.max_durability}
</div> </div>
)} )}
{item.tier !== undefined && item.tier !== null && item.tier > 0 && ( {item.tier !== undefined && item.tier !== null && item.tier > 0 && (
<div className="item-tooltip-stat"> Tier: {item.tier}</div> <div className="item-tooltip-stat"> {t('stats.tier')}: {item.tier}</div>
)} )}
</div> </div>
</div> </div>
@@ -362,21 +362,21 @@ function LocationView({
className="entity-action-btn pickup" className="entity-action-btn pickup"
onClick={() => onPickup(item.id, 1)} onClick={() => onPickup(item.id, 1)}
> >
Pick Up {t('common.pickUp')}
</button> </button>
) : ( ) : (
<div className="item-pickup-btn-container"> <div className="item-pickup-btn-container">
<button className="entity-action-btn pickup">Pick Up </button> <button className="entity-action-btn pickup">{t('common.pickUp')} </button>
<div className="item-pickup-menu"> <div className="item-pickup-menu">
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>Pick Up 1</button> <button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>{t('common.pickUp')} 1</button>
{item.quantity >= 5 && ( {item.quantity >= 5 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>Pick Up 5</button> <button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>{t('common.pickUp')} 5</button>
)} )}
{item.quantity >= 10 && ( {item.quantity >= 10 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>Pick Up 10</button> <button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>{t('common.pickUp')} 10</button>
)} )}
<button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}> <button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}>
Pick Up All ({item.quantity}) {t('common.pickUpAll')} ({item.quantity})
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import type { Location, Profile, CombatState } from './types' import type { Location, Profile, CombatState } from './types'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getAssetPath } from '../../utils/assetPath' import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils' import { getTranslatedText } from '../../utils/i18nUtils'
@@ -22,6 +23,7 @@ function MovementControls({
onMove, onMove,
onInteract onInteract
}: MovementControlsProps) { }: MovementControlsProps) {
const { t } = useTranslation()
// Force re-render every second to update cooldown timers // Force re-render every second to update cooldown timers
const [, forceUpdate] = useState(0) const [, forceUpdate] = useState(0)
@@ -71,23 +73,24 @@ function MovementControls({
const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false) const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false)
// Build detailed tooltip text // Build detailed tooltip text
const tooltipText = profile?.is_dead ? 'You are dead' : const tooltipText = profile?.is_dead ? t('messages.youAreDead') :
movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
combatState ? 'Cannot travel during combat' : combatState ? t('messages.cannotTravelCombat') :
insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` : insufficientStamina ? t('messages.notEnoughStamina', { need: stamina, have: profile?.stamina ?? 0 }) :
available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` : available ? `${destination}\n${t('game.distance')}: ${distance}m\n${t('game.stamina')}: ${stamina}` :
`Cannot go ${direction}` t('messages.cannotGo', { direction: t('directions.' + direction) })
return ( return (
<button <button
key={direction}
className={`compass-btn ${className} ${disabled ? 'disabled' : ''} ${combatState ? 'in-combat' : ''}`}
onClick={() => onMove(direction)} onClick={() => onMove(direction)}
disabled={disabled} disabled={disabled}
className={`compass-btn ${className} ${disabled ? 'disabled' : ''}`}
title={tooltipText} title={tooltipText}
> >
<span className="compass-arrow">{arrow}</span> <span className="compass-arrow">{arrow}</span>
{available && movementCooldown > 0 ? ( {available && movementCooldown > 0 ? (
<span className="compass-cost">{movementCooldown}s</span> <span className="compass-cost" title={t('messages.waitBeforeMoving', { seconds: movementCooldown })}>{movementCooldown}s</span>
) : available && ( ) : available && (
<span className="compass-cost">{stamina}</span> <span className="compass-cost">{stamina}</span>
)} )}
@@ -98,7 +101,7 @@ function MovementControls({
return ( return (
<> <>
<div className="movement-controls"> <div className="movement-controls">
<h3>🧭 Travel</h3> <h3>{t('game.travel')}</h3>
<div className="compass-grid"> <div className="compass-grid">
{/* Top row */} {/* Top row */}
{renderCompassButton('northwest', '↖️', 'nw')} {renderCompassButton('northwest', '↖️', 'nw')}
@@ -121,7 +124,7 @@ function MovementControls({
{/* Cooldown indicator */} {/* Cooldown indicator */}
{movementCooldown > 0 && ( {movementCooldown > 0 && (
<div className="cooldown-indicator"> <div className="cooldown-indicator">
Wait {movementCooldown}s before moving {t('messages.waitBeforeMovingSimple', { seconds: movementCooldown })}
</div> </div>
)} )}
@@ -132,9 +135,9 @@ function MovementControls({
onClick={() => onMove('up')} onClick={() => onMove('up')}
className="special-btn" className="special-btn"
disabled={!!combatState || movementCooldown > 0} disabled={!!combatState || movementCooldown > 0}
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go up\nStamina: ${getStaminaCost('up')}`} title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.up')}\n${t('game.stamina')}: ${getStaminaCost('up')}`}
> >
Up <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span> {t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('up')}`}</span>
</button> </button>
)} )}
{location.directions.includes('down') && ( {location.directions.includes('down') && (
@@ -142,9 +145,9 @@ function MovementControls({
onClick={() => onMove('down')} onClick={() => onMove('down')}
className="special-btn" className="special-btn"
disabled={!!combatState || movementCooldown > 0} 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 <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span> {t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('down')}`}</span>
</button> </button>
)} )}
{location.directions.includes('enter') && ( {location.directions.includes('enter') && (
@@ -152,9 +155,9 @@ function MovementControls({
onClick={() => onMove('enter')} onClick={() => onMove('enter')}
className="special-btn" className="special-btn"
disabled={!!combatState || movementCooldown > 0} 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 <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('enter')}`}</span> 🚪 {t('directions.enter')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('enter')}`}</span>
</button> </button>
)} )}
{location.directions.includes('inside') && ( {location.directions.includes('inside') && (
@@ -162,9 +165,9 @@ function MovementControls({
onClick={() => onMove('inside')} onClick={() => onMove('inside')}
className="special-btn" className="special-btn"
disabled={!!combatState || movementCooldown > 0} 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 <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('inside')}`}</span> 🚪 {t('directions.inside')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('inside')}`}</span>
</button> </button>
)} )}
{location.directions.includes('exit') && ( {location.directions.includes('exit') && (
@@ -172,9 +175,9 @@ function MovementControls({
onClick={() => onMove('exit')} onClick={() => onMove('exit')}
className="special-btn" className="special-btn"
disabled={!!combatState || movementCooldown > 0} 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')}
</button> </button>
)} )}
{location.directions.includes('outside') && ( {location.directions.includes('outside') && (
@@ -182,9 +185,9 @@ function MovementControls({
onClick={() => onMove('outside')} onClick={() => onMove('outside')}
className="special-btn" className="special-btn"
disabled={!!combatState || movementCooldown > 0} 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 <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('outside')}`}</span> 🚪 {t('directions.outside')} <span className="compass-cost">{movementCooldown > 0 ? `${movementCooldown}s` : `${getStaminaCost('outside')}`}</span>
</button> </button>
)} )}
</div> </div>
@@ -193,7 +196,7 @@ function MovementControls({
{/* Surroundings - outside movement controls */} {/* Surroundings - outside movement controls */}
{location.interactables && location.interactables.length > 0 && ( {location.interactables && location.interactables.length > 0 && (
<div className="interactables-section"> <div className="interactables-section">
<h3>🌿 Surroundings</h3> <h3>{t('game.surroundings')}</h3>
{location.interactables.map((interactable: any) => ( {location.interactables.map((interactable: any) => (
<div key={interactable.instance_id} className="interactable-card"> <div key={interactable.instance_id} className="interactable-card">
{interactable.image_path && ( {interactable.image_path && (

View File

@@ -50,7 +50,7 @@ function Workbench({
onRepair, onRepair,
onUncraft onUncraft
}: WorkbenchProps) { }: WorkbenchProps) {
useTranslation() const { t } = useTranslation()
const [selectedItem, setSelectedItem] = useState<any>(null) const [selectedItem, setSelectedItem] = useState<any>(null)
@@ -116,8 +116,8 @@ function Workbench({
return ( return (
<div className="workbench-empty-state"> <div className="workbench-empty-state">
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🔧</div> <div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🔧</div>
<h3>Select an item to view details</h3> <h3>{t('crafting.selectItem')}</h3>
<p>Choose an item from the list on the left</p> <p>{t('crafting.chooseFromList')}</p>
</div> </div>
) )
} }
@@ -155,13 +155,13 @@ function Workbench({
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}> <div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
{Object.entries(item.base_stats || item.stats).map(([key, value]) => { {Object.entries(item.base_stats || item.stats).map(([key, value]) => {
const icons: Record<string, string> = { const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight', weight_capacity: `⚖️ ${t('game.weight')}`,
volume_capacity: '📦 Volume', volume_capacity: `📦 ${t('game.volume')}`,
armor: '🛡️ Armor', armor: `🛡️ ${t('stats.armor')}`,
hp_max: '❤️ Max HP', hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: '⚡ Max Stamina', stamina_max: `${t('stats.maxStamina')}`,
damage_min: '⚔️ Damage Min', damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: '⚔️ Damage Max' damage_max: `⚔️ ${t('stats.damage')} Max`
} }
const label = icons[key] || key.replace('_', ' ') const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : '' const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
@@ -173,7 +173,7 @@ function Workbench({
})} })}
</div> </div>
<p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}> <p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}>
* Potential base stats. Actual stats may vary. * {t('crafting.potentialBaseStats')}
</p> </p>
</div> </div>
)} )}
@@ -183,13 +183,13 @@ function Workbench({
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}> <div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}>
{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]) => { {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<string, string> = { const icons: Record<string, string> = {
weight_capacity: '⚖️ Weight', weight_capacity: `⚖️ ${t('game.weight')}`,
volume_capacity: '📦 Volume', volume_capacity: `📦 ${t('game.volume')}`,
armor: '🛡️ Armor', armor: `🛡️ ${t('stats.armor')}`,
hp_max: '❤️ Max HP', hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: '⚡ Max Stamina', stamina_max: `${t('stats.maxStamina')}`,
damage_min: '⚔️ Damage Min', damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: '⚔️ Damage Max' damage_max: `⚔️ ${t('stats.damage')} Max`
} }
const label = icons[key] || key.replace('_', ' ') const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : '' const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
@@ -206,30 +206,28 @@ function Workbench({
{workbenchTab === 'craft' && ( {workbenchTab === 'craft' && (
<> <>
<div className="detail-requirements"> <div className="detail-requirements">
<h4>📊 Requirements</h4> <h4>{t('crafting.requirements')}</h4>
{item.craft_level && item.craft_level > 1 && (
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}> <div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
<span>Level {item.craft_level} Required</span> <span>{t('crafting.levelRequired', { level: item.craft_level })}</span>
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span> <span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
</div> </div>
)}
{item.tools && item.tools.length > 0 && ( {item.tools && item.tools.length > 0 && (
<> <>
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5> <h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>{t('crafting.tools')}</h5>
{item.tools.map((tool: any, i: number) => ( {item.tools.map((tool: any, i: number) => (
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}> <div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
<span>{tool.emoji} {getTranslatedText(tool.name)}</span> <span>{tool.emoji} {getTranslatedText(tool.name)}</span>
<span> <span>
{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})`}
</span> </span>
</div> </div>
))} ))}
</> </>
)} )}
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5> <h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>{t('crafting.materials')}</h5>
{item.materials && item.materials.length > 0 ? ( {item.materials && item.materials.length > 0 ? (
item.materials.map((mat: any, i: number) => ( item.materials.map((mat: any, i: number) => (
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}> <div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
@@ -239,7 +237,7 @@ function Workbench({
)) ))
) : ( ) : (
<div className="requirement-item met"> <div className="requirement-item met">
<span>No materials required</span> <span>{t('crafting.noMaterialsRequired')}</span>
</div> </div>
)} )}
</div> </div>
@@ -252,12 +250,12 @@ function Workbench({
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }} style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
> >
<span> <span>
{!item.meets_level ? `Need Level ${item.craft_level}` : {!item.meets_level ? t('crafting.levelRequired', { level: item.craft_level }) :
!item.can_craft ? 'Missing Requirements' : '🔨 Craft Item'} !item.can_craft ? t('crafting.missingRequirements') : t('crafting.craftItem')}
</span> </span>
{item.can_craft && ( {item.can_craft && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}> <span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 5} Stamina {t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}
</span> </span>
)} )}
</button> </button>
@@ -268,10 +266,10 @@ function Workbench({
{workbenchTab === 'repair' && ( {workbenchTab === 'repair' && (
<> <>
<div className="detail-requirements"> <div className="detail-requirements">
<h4>🔧 Repair Status</h4> <h4>🔧 {workbenchTab === 'repair' ? t('game.repair') : t('game.salvage')}</h4>
{!item.needs_repair ? ( {!item.needs_repair ? (
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}> Item is in perfect condition</p> <p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}>{t('crafting.perfectCondition')}</p>
) : ( ) : (
<> <>
<div className="repair-preview-text"> <div className="repair-preview-text">
@@ -333,12 +331,12 @@ function Workbench({
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }} style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
> >
<span> <span>
{!item.needs_repair ? 'Already Full' : {!item.needs_repair ? t('crafting.alreadyFull') :
!item.can_repair ? 'Missing Requirements' : '🛠️ Repair Item'} !item.can_repair ? t('crafting.missingRequirements') : t('crafting.repairItem')}
</span> </span>
{item.needs_repair && item.can_repair && ( {item.needs_repair && item.can_repair && (
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}> <span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 3} Stamina {t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}
</span> </span>
)} )}
</button> </button>
@@ -349,7 +347,7 @@ function Workbench({
{workbenchTab === 'uncraft' && ( {workbenchTab === 'uncraft' && (
<> <>
<div className="detail-requirements"> <div className="detail-requirements">
<h4> Salvage Preview</h4> <h4> {t('game.salvage')}</h4>
{/* Show durability bar if we have durability data */} {/* Show durability bar if we have durability data */}
{(item.unique_item_data || item.durability_percent !== undefined) && ( {(item.unique_item_data || item.durability_percent !== undefined) && (
@@ -382,7 +380,7 @@ function Workbench({
<> <>
{durabilityRatio < 1.0 && ( {durabilityRatio < 1.0 && (
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}> <div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}>
Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage {t('crafting.yieldReduced', { percent: Math.round((1 - durabilityRatio) * 100) })}
</div> </div>
)} )}
@@ -409,15 +407,15 @@ function Workbench({
className="uncraft-btn" className="uncraft-btn"
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)} disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
onClick={() => { 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) 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' }} style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
> >
<span> Salvage Item</span> <span> {t('game.salvage')}</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}> <span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
{item.stamina_cost || 2} Stamina {t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
</span> </span>
</button> </button>
</div> </div>
@@ -429,14 +427,14 @@ function Workbench({
} }
const categories = [ const categories = [
{ id: 'all', label: 'All', icon: '🎒' }, { id: 'all', label: t('categories.all'), icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' }, { id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' }, { id: 'armor', label: t('categories.armor'), icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' }, { id: 'clothing', label: t('categories.clothing'), icon: '👕' },
{ id: 'tool', label: 'Tools', icon: '🛠️' }, { id: 'tool', label: t('categories.tool'), icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' }, { id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' }, { id: 'resource', label: t('categories.resource'), icon: '📦' },
{ id: 'misc', label: 'Misc', icon: '📦' } { id: 'misc', label: t('categories.misc'), icon: '📦' }
] ]
return ( return (
@@ -445,25 +443,25 @@ function Workbench({
}}> }}>
<div className="workbench-menu"> <div className="workbench-menu">
<div className="workbench-header"> <div className="workbench-header">
<h3>🔧 Workbench</h3> <h3>{t('game.workbench')}</h3>
<div className="workbench-tabs"> <div className="workbench-tabs">
<button <button
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`} className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
onClick={() => onSwitchTab('craft')} onClick={() => onSwitchTab('craft')}
> >
🔨 Craft {t('game.craft')}
</button> </button>
<button <button
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`} className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
onClick={() => onSwitchTab('repair')} onClick={() => onSwitchTab('repair')}
> >
🛠 Repair {t('game.repair')}
</button> </button>
<button <button
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`} className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
onClick={() => onSwitchTab('uncraft')} onClick={() => onSwitchTab('uncraft')}
> >
Salvage {t('game.salvage')}
</button> </button>
</div> </div>
<button className="close-btn" onClick={onCloseCrafting}></button> <button className="close-btn" onClick={onCloseCrafting}></button>
@@ -472,7 +470,7 @@ function Workbench({
<div className="workbench-content-grid"> <div className="workbench-content-grid">
{/* Column 1: Categories Sidebar */} {/* Column 1: Categories Sidebar */}
<div className="workbench-sidebar"> <div className="workbench-sidebar">
<h4 className="sidebar-title">Categories</h4> <h4 className="sidebar-title">{t('location.lootableItems').replace(':', '')}</h4>
<div className="category-list"> <div className="category-list">
{categories.map(cat => ( {categories.map(cat => (
<button <button
@@ -492,7 +490,7 @@ function Workbench({
<div className="workbench-filters"> <div className="workbench-filters">
<input <input
type="text" type="text"
placeholder="🔍 Filter items..." placeholder={t('game.searchItems')}
value={workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter} value={workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => { onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (workbenchTab === 'craft') onSetCraftFilter(e.target.value) if (workbenchTab === 'craft') onSetCraftFilter(e.target.value)
@@ -519,9 +517,7 @@ function Workbench({
return matchesSearch && matchesCategory return matchesSearch && matchesCategory
}).length === 0 ? ( }).length === 0 ? (
<div className="empty-state"> <div className="empty-state">
{workbenchTab === 'craft' ? 'No craftable items found.' : {t('game.noItemsFound')}
workbenchTab === 'repair' ? 'No repairable items found.' :
'No salvageable items found.'}
</div> </div>
) : ( ) : (
items items
@@ -573,7 +569,7 @@ function Workbench({
> >
{getTranslatedText(item.name)} {getTranslatedText(item.name)}
</span> </span>
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>Equipped</span>} {item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>{t('game.equipped')}</span>}
</div> </div>
<div className="item-meta-row"> <div className="item-meta-row">

View File

@@ -217,7 +217,7 @@ export function useGameEngine(
}, []) }, [])
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => { const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
setCombatLog((prev: CombatLogEntry[]) => [entry, ...prev]) setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev])
}, []) }, [])
// Fetch functions // Fetch functions
@@ -337,6 +337,7 @@ export function useGameEngine(
pvpRes.data.pvp_combat.defender : pvpRes.data.pvp_combat.defender :
pvpRes.data.pvp_combat.attacker pvpRes.data.pvp_combat.attacker
setCombatLog([{ setCombatLog([{
id: 'pvp-combat-init',
time: timeStr, time: timeStr,
message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`, message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`,
isPlayer: true isPlayer: true
@@ -351,6 +352,7 @@ export function useGameEngine(
const now = new Date() const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{ setCombatLog([{
id: 'combat-in-progress',
time: timeStr, time: timeStr,
message: 'Combat in progress...', message: 'Combat in progress...',
isPlayer: true isPlayer: true
@@ -402,8 +404,9 @@ export function useGameEngine(
const now = new Date() const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{ setCombatLog([{
id: Date.now() + Math.random(),
time: timeStr, time: timeStr,
message: `⚠️ ${encounter.combat.npc_name} ambushes you!`, message: { type: 'combat_start', data: { npc_name: encounter.combat.npc_name } },
isPlayer: false isPlayer: false
}]) }])
@@ -503,10 +506,23 @@ export function useGameEngine(
const now = new Date() const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
const messages = data.message.split('\n').filter((m: string) => m.trim()) 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, time: timeStr,
message: msg, 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]) setCombatLog((prev: any) => [...newEntries, ...prev])
@@ -688,8 +704,9 @@ export function useGameEngine(
const now = new Date() const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{ setCombatLog([{
id: Date.now() + Math.random(),
time: timeStr, 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 isPlayer: true
}]) }])

View File

@@ -56,8 +56,9 @@ export interface Profile {
} }
export interface CombatLogEntry { export interface CombatLogEntry {
id: string | number
time: string time: string
message: string message: string | { type: string; data: any }
isPlayer: boolean isPlayer: boolean
} }

View File

@@ -10,7 +10,16 @@
"no": "No", "no": "No",
"game": "Game", "game": "Game",
"leaderboards": "Leaderboards", "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": { "auth": {
"login": "Login", "login": "Login",
@@ -22,7 +31,23 @@
"forgotPassword": "Forgot Password?", "forgotPassword": "Forgot Password?",
"createAccount": "Create Account", "createAccount": "Create Account",
"alreadyHaveAccount": "Already have an 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": { "game": {
"travel": "🧭 Travel", "travel": "🧭 Travel",
@@ -40,10 +65,41 @@
"use": "Use", "use": "Use",
"equip": "Equip", "equip": "Equip",
"unequip": "Unequip", "unequip": "Unequip",
"attack": "Attack", "attack": "⚔️ Attack",
"flee": "Flee", "flee": "🏃 Flee",
"rest": "Rest", "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": { "stats": {
"hp": "❤️ HP", "hp": "❤️ HP",
@@ -53,8 +109,8 @@
"xp": "⭐ XP", "xp": "⭐ XP",
"level": "Level", "level": "Level",
"unspentPoints": "⭐ Unspent", "unspentPoints": "⭐ Unspent",
"weight": "⚖️ Weight", "weight": "Weight",
"volume": "📦 Volume", "volume": "Volume",
"strength": "💪 STR", "strength": "💪 STR",
"strengthFull": "Strength", "strengthFull": "Strength",
"strengthDesc": "Increases melee damage and carry capacity", "strengthDesc": "Increases melee damage and carry capacity",
@@ -68,10 +124,23 @@
"intellectFull": "Intellect", "intellectFull": "Intellect",
"intellectDesc": "Enhances crafting and resource gathering", "intellectDesc": "Enhances crafting and resource gathering",
"armor": "🛡️ Armor", "armor": "🛡️ Armor",
"damage": "⚔️ Damage", "damage": "Damage",
"durability": "Durability" "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": { "combat": {
"title": "Combat",
"inCombat": "In Combat", "inCombat": "In Combat",
"yourTurn": "Your Turn", "yourTurn": "Your Turn",
"enemyTurn": "Enemy's Turn", "enemyTurn": "Enemy's Turn",
@@ -80,7 +149,20 @@
"youDied": "You Died", "youDied": "You Died",
"respawn": "Respawn", "respawn": "Respawn",
"fleeSuccess": "You escaped!", "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": { "equipment": {
"head": "Head", "head": "Head",
@@ -104,7 +186,16 @@
"staminaCost": "⚡ {{cost}} Stamina", "staminaCost": "⚡ {{cost}} Stamina",
"alreadyFull": "Already Full", "alreadyFull": "Already Full",
"perfectCondition": "✅ Item is in perfect condition", "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": { "categories": {
"all": "All Items", "all": "All Items",
@@ -119,13 +210,38 @@
"misc": "Misc" "misc": "Misc"
}, },
"messages": { "messages": {
"notEnoughStamina": "Not enough stamina", "notEnoughStamina": "Not enough stamina (need {{need}}, have {{have}})",
"inventoryFull": "Inventory full", "inventoryFull": "Inventory full",
"itemDropped": "Item dropped", "itemDropped": "Item dropped",
"itemPickedUp": "Item picked up", "itemPickedUp": "Item picked up",
"waitBeforeMoving": "Wait {{seconds}}s before moving", "waitBeforeMoving": "Wait {{seconds}}s before moving",
"cannotTravelInCombat": "Cannot travel during combat", "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": { "landing": {
"heroTitle": "Echoes of the Ash", "heroTitle": "Echoes of the Ash",

View File

@@ -10,19 +10,44 @@
"no": "No", "no": "No",
"game": "Juego", "game": "Juego",
"leaderboards": "Clasificación", "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": { "auth": {
"login": "Iniciar Sesión", "login": "Iniciar sesión",
"logout": "Cerrar Sesión", "logout": "Cerrar sesión",
"register": "Registrarse", "register": "Registrarse",
"username": "Usuario", "username": "Usuario",
"password": "Contraseña", "password": "Contraseña",
"email": "Correo", "email": "Correo electrónico",
"forgotPassword": "¿Olvidaste tu contraseña?", "forgotPassword": "¿Olvidaste tu contraseña?",
"createAccount": "Crear Cuenta", "createAccount": "Crear cuenta",
"alreadyHaveAccount": "¿Ya tienes una 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": { "game": {
"travel": "🧭 Viajar", "travel": "🧭 Viajar",
@@ -33,17 +58,48 @@
"workbench": "🔧 Banco de Trabajo", "workbench": "🔧 Banco de Trabajo",
"craft": "🔨 Fabricar", "craft": "🔨 Fabricar",
"repair": "🛠️ Reparar", "repair": "🛠️ Reparar",
"salvage": "♻️ Desmontar", "salvage": "♻️ Desguazar",
"pickUp": "Recoger", "pickUp": "Recoger",
"drop": "Soltar", "drop": "Soltar",
"dropAll": "Todo", "dropAll": "Todo",
"use": "Usar", "use": "Usar",
"equip": "Equipar", "equip": "Equipar",
"unequip": "Desequipar", "unequip": "Desequipar",
"attack": "Atacar", "attack": "⚔️ Atacar",
"flee": "Huir", "flee": "🏃 Huir",
"rest": "Descansar", "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": { "stats": {
"hp": "❤️ Vida", "hp": "❤️ Vida",
@@ -53,34 +109,60 @@
"xp": "⭐ XP", "xp": "⭐ XP",
"level": "Nivel", "level": "Nivel",
"unspentPoints": "⭐ Sin gastar", "unspentPoints": "⭐ Sin gastar",
"weight": "⚖️ Peso", "weight": "Peso",
"volume": "📦 Volumen", "volume": "Volumen",
"strength": "💪 FUE", "strength": "💪 FUE",
"strengthFull": "Fuerza", "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", "agility": "🏃 AGI",
"agilityFull": "Agilidad", "agilityFull": "Agilidad",
"agilityDesc": "Mejora la esquiva y golpes críticos", "agilityDesc": "Mejora la probabilidad de esquivar y los golpes críticos",
"endurance": "🛡️ RES", "endurance": "🛡️ RES",
"enduranceFull": "Resistencia", "enduranceFull": "Resistencia",
"enduranceDesc": "Aumenta la vida y energía", "enduranceDesc": "Aumenta la vida y el aguante",
"intellect": "🧠 INT", "intellect": "🧠 INT",
"intellectFull": "Intelecto", "intellectFull": "Intelecto",
"intellectDesc": "Mejora la fabricación y recolección", "intellectDesc": "Mejora la fabricación y recolección de recursos",
"armor": "🛡️ Armadura", "armor": "🛡️ Armadura",
"damage": "⚔️ Daño", "damage": "Daño",
"durability": "Durabilidad" "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": { "combat": {
"title": "Combate",
"inCombat": "En Combate", "inCombat": "En Combate",
"yourTurn": "Tu Turno", "yourTurn": "Tu Turno",
"enemyTurn": "Turno del Enemigo", "enemyTurn": "Turno del Enemigo",
"victory": "¡Victoria!", "victory": "¡Victoria!",
"defeat": "Derrota", "defeat": "Derrota",
"youDied": "Has Muerto", "youDied": "Has Muerto",
"respawn": "Revivir", "respawn": "Reaparecer",
"fleeSuccess": Escapaste!", "fleeSuccess": Has escapado!",
"fleeFailed": "¡No pudiste escapar!" "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": { "equipment": {
"head": "Cabeza", "head": "Cabeza",
@@ -96,20 +178,29 @@
"requirements": "📊 Requisitos", "requirements": "📊 Requisitos",
"materials": "Materiales", "materials": "Materiales",
"tools": "Herramientas", "tools": "Herramientas",
"levelRequired": "Nivel {{level}} Requerido", "levelRequired": "Requiere Nivel {{level}}",
"missingRequirements": "Faltan Requisitos", "missingRequirements": "Faltan Requisitos",
"craftItem": "🔨 Fabricar", "craftItem": "🔨 Fabricar",
"repairItem": "🛠️ Reparar", "repairItem": "🛠️ Reparar",
"salvageItem": "♻️ Desmontar", "salvageItem": "♻️ Desguazar",
"staminaCost": "⚡ {{cost}} Energía", "staminaCost": "⚡ {{cost}} Aguante",
"alreadyFull": "Ya está Completo", "alreadyFull": "Ya está completo",
"perfectCondition": "✅ El objeto está en perfecto estado", "perfectCondition": "✅ El objeto está en perfectas condiciones",
"yieldReduced": "⚠️ Rendimiento reducido {{percent}}% por daño" "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": { "categories": {
"all": "Todos", "all": "Todos los Objetos",
"weapon": "Armas", "weapon": "Armas",
"armor": "Armadura", "armor": "Armaduras",
"clothing": "Ropa", "clothing": "Ropa",
"backpack": "Mochilas", "backpack": "Mochilas",
"tool": "Herramientas", "tool": "Herramientas",
@@ -119,16 +210,41 @@
"misc": "Varios" "misc": "Varios"
}, },
"messages": { "messages": {
"notEnoughStamina": "No tienes suficiente energía", "notEnoughStamina": "No tienes suficiente aguante (necesitas {{need}}, tienes {{have}})",
"inventoryFull": "Inventario lleno", "inventoryFull": "Inventario lleno",
"itemDropped": "Objeto soltado", "itemDropped": "Objeto soltado",
"itemPickedUp": "Objeto recogido", "itemPickedUp": "Objeto recogido",
"waitBeforeMoving": "Espera {{seconds}}s antes de moverte", "waitBeforeMoving": "Espera {{seconds}}s antes de moverte",
"cannotTravelInCombat": "No puedes viajar en combate", "cannotTravelInCombat": "No puedes viajar durante el combate",
"cannotInteractInCombat": "No puedes interactuar en 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": { "landing": {
"heroTitle": "Ecos de la Ceniza", "heroTitle": "Ecos de las Cenizas",
"heroSubtitle": "Un RPG de supervivencia post-apocalíptico", "heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
"playNow": "Jugar Ahora", "playNow": "Jugar Ahora",
"features": "Características" "features": "Características"

View File

@@ -1,5 +1,5 @@
:root { :root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: 'Saira Condensed', system-ui, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;

View File

@@ -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 // Add token to requests if it exists
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
if (token) { if (token) {

View File

@@ -4,11 +4,12 @@ import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './', // Use relative paths for Electron file:// protocol base: '/', // Changed from ./ to / for better PWA absolute path resolution
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
injectRegister: 'auto',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'], includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: { manifest: {
name: 'Echoes of the Ash', name: 'Echoes of the Ash',
@@ -40,6 +41,9 @@ export default defineConfig({
] ]
}, },
workbox: { workbox: {
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
runtimeCaching: [ runtimeCaching: [
{ {