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:
from datetime import datetime
npc_def = NPCS.get(npc_id)
npc_name = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
npc_name_obj = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
# Handle localized name for the fallback message
if isinstance(npc_name_obj, dict):
npc_name_en = npc_name_obj.get('en', str(npc_name_obj))
else:
npc_name_en = str(npc_name_obj)
await manager.send_to_location(
location_id=location_id,
message={
"type": "location_update",
"data": {
"message": f"A {npc_name} appeared!",
"message": f"A {npc_name_en} appeared!",
"action": "enemy_spawned",
"npc_data": {
"id": enemy_data['id'],
"npc_id": npc_id,
"name": npc_name,
"name": npc_name_obj,
"type": "enemy",
"is_wandering": True,
"image_path": npc_def.image_path if npc_def else None
@@ -209,7 +215,8 @@ async def decay_dropped_items(manager=None):
"type": "location_update",
"data": {
"message": f"{count} dropped item(s) decayed",
"action": "items_decayed"
"action": "items_decayed",
"count": count
},
"timestamp": datetime.utcnow().isoformat()
}
@@ -472,7 +479,8 @@ async def decay_corpses(manager=None):
"type": "location_update",
"data": {
"message": f"{total} {corpse_type} decayed",
"action": "corpses_decayed"
"action": "corpses_decayed",
"count": total
},
"timestamp": datetime.utcnow().isoformat()
}

View File

@@ -70,7 +70,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
detail="No character selected. Please select a character first."
)
player = await db.get_player_by_id(character_id)
player = await db.get_character_by_id(character_id)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,

View File

@@ -358,15 +358,7 @@ async def init_db():
await conn.execute(text(index_sql))
# Player operations
async def get_player_by_id(player_id: int) -> Optional[Dict[str, Any]]:
"""Get player by internal ID"""
async with DatabaseSession() as session:
result = await session.execute(
select(players).where(players.c.id == player_id)
)
row = result.first()
return dict(row._mapping) if row else None
async def get_player_by_username(username: str) -> Optional[Dict[str, Any]]:
@@ -421,13 +413,7 @@ async def create_player(
return dict(row._mapping) if row else None
async def update_player(player_id: int, **kwargs) -> bool:
"""Update player fields - OLD FUNCTION, use update_character instead"""
async with DatabaseSession() as session:
stmt = update(characters).where(characters.c.id == player_id).values(**kwargs)
await session.execute(stmt)
await session.commit()
return True
async def update_player_location(player_id: int, location_id: str) -> bool:

View File

@@ -6,14 +6,15 @@ import random
import time
from typing import Dict, Any, Tuple, Optional, List
from . import database as db
from .services.helpers import get_locale_string, translate_travel_message, create_combat_message
async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[bool, str, Optional[str], int, int]:
async def move_player(player_id: int, direction: str, locations: Dict, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]:
"""
Move player in a direction.
Returns: (success, message, new_location_id, stamina_cost, distance_meters)
"""
player = await db.get_player_by_id(player_id)
player = await db.get_character_by_id(player_id)
if not player:
return False, "Player not found", None, 0, 0
@@ -69,13 +70,15 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[
return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0
# Update player location and stamina
await db.update_player(
await db.update_character(
player_id,
location_id=new_location_id,
stamina=max(0, player['stamina'] - stamina_cost)
)
return True, f"You travel {direction} to {new_location.name}.", new_location_id, stamina_cost, distance
translated_location = get_locale_string(new_location.name, locale)
travel_message = translate_travel_message(direction, translated_location, locale)
return True, travel_message, new_location_id, stamina_cost, distance
async def inspect_area(player_id: int, location, interactables_data: Dict) -> str:
@@ -216,7 +219,7 @@ async def interact_with_object(
if not item:
continue
item_name = item.name if item else item_id
item_name = get_locale_string(item.name) if item else item_id
emoji = item.emoji if item and hasattr(item, 'emoji') else ''
# Check if item has durability (unique item)
@@ -237,7 +240,7 @@ async def interact_with_object(
max_durability=item.durability,
tier=getattr(item, 'tier', None)
)
items_found.append(f"{emoji} {item_name}")
items_found.append(f"{emoji} {get_locale_string(item_name)}")
current_weight += item.weight
current_volume += item.volume
else:
@@ -252,7 +255,7 @@ async def interact_with_object(
unique_stats=base_stats
)
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
items_dropped.append(f"{emoji} {item_name}")
items_dropped.append(f"{emoji} {get_locale_string(item_name)}")
else:
# Stackable items - process as before
item_weight = item.weight * quantity
@@ -262,13 +265,13 @@ async def interact_with_object(
current_volume + item_volume <= max_volume):
# Add to inventory
await db.add_item_to_inventory(player_id, item_id, quantity)
items_found.append(f"{emoji} {item_name} x{quantity}")
items_found.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
current_weight += item_weight
current_volume += item_volume
else:
# Drop to ground
await db.drop_item_to_world(item_id, quantity, player['location_id'])
items_dropped.append(f"{emoji} {item_name} x{quantity}")
items_dropped.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
# Apply damage
if damage_taken > 0:
@@ -283,7 +286,7 @@ async def interact_with_object(
await db.set_interactable_cooldown(interactable_id, action_id, 60)
# Build message
final_message = outcome.text
final_message = get_locale_string(outcome.text)
if items_dropped:
final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(items_dropped)}"
@@ -565,7 +568,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
heal_amount = int(combat['npc_max_hp'] * 0.05)
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
message = f"{npc_def.name} defends and recovers {heal_amount} HP!"
message = f"{get_locale_string(npc_def.name)} defends and recovers {heal_amount} HP!"
elif intent_type == 'special':
# Strong attack (1.5x damage)
@@ -574,7 +577,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
message = f"{npc_def.name} uses a SPECIAL ATTACK for {npc_damage} damage!"
message = f"{get_locale_string(npc_def.name)} uses a SPECIAL ATTACK for {npc_damage} damage!"
if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})"
@@ -589,7 +592,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
# Enrage bonus if NPC is below 30% HP
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
npc_damage = int(npc_damage * 1.5)
message = f"{npc_def.name} is ENRAGED! "
message = f"{get_locale_string(npc_def.name)} is ENRAGED! "
else:
message = ""
@@ -597,7 +600,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
message += f"{npc_def.name} attacks for {npc_damage} damage!"
message += create_combat_message("enemy_attack", npc_name=npc_def.name, damage=npc_damage, armor_absorbed=armor_absorbed)
if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})"

View File

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

View File

@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_locale_string
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
@@ -156,7 +156,7 @@ async def get_craftable_items(current_user: dict = Depends(get_current_user)):
})
# Sort: craftable items first, then by tier, then by name
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name']))
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], get_locale_string(x['name'])))
return {'craftable_items': craftable_items}

View File

@@ -2,7 +2,7 @@
Game Routes router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi import APIRouter, HTTPException, Depends, status, Request
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
@@ -391,8 +391,11 @@ async def spend_stat_point(
@router.get("/api/game/location")
async def get_current_location(current_user: dict = Depends(get_current_user)):
async def get_current_location(request: Request, current_user: dict = Depends(get_current_user)):
"""Get current location information"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
location_id = current_user['location_id']
location = LOCATIONS.get(location_id)
@@ -682,7 +685,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
corpses_data.append({
"id": f"npc_{corpse['id']}",
"type": "npc",
"name": f"{npc_def.name if npc_def else corpse['npc_id']} Corpse",
"name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse",
"emoji": "💀",
"loot_count": len(loot),
"timestamp": corpse['death_timestamp']
@@ -719,6 +722,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
@router.post("/api/game/move")
async def move(
move_req: MoveRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Move player in a direction"""
@@ -756,10 +760,14 @@ async def move(
detail=f"You must wait {int(cooldown_remaining)} seconds before moving again."
)
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
current_user['id'],
move_req.direction,
LOCATIONS
LOCATIONS,
locale
)
if not success:
@@ -951,9 +959,13 @@ async def inspect(current_user: dict = Depends(get_current_user)):
@router.post("/api/game/interact")
async def interact(
interact_req: InteractRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Interact with an object"""
"""Interact with an object in the game world"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Check if player is in combat
combat = await db.get_active_combat(current_user['id'])
if combat:
@@ -1026,7 +1038,7 @@ async def interact(
"instance_id": interact_req.interactable_id,
"action_id": interact_req.action_id,
"cooldown_remaining": cooldown_remaining,
"message": f"{current_user['name']} used {action_display} on {interactable_name}"
"message": f"{current_user['name']} used {get_locale_string(action_display, locale)} on {get_locale_string(interactable_name, locale)}"
},
"timestamp": datetime.utcnow().isoformat()
}
@@ -1035,6 +1047,8 @@ async def interact(
return result
@router.post("/api/game/use_item")
async def use_item(
use_req: UseItemRequest,
@@ -1159,15 +1173,19 @@ async def use_item(
@router.post("/api/game/pickup")
async def pickup(
pickup_req: PickupItemRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Pick up an item from the ground"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Get item details for broadcast BEFORE picking it up (it will be removed from DB)
# pickup_req.item_id is the dropped_item database ID, not the item_id string
dropped_item = await db.get_dropped_item(pickup_req.item_id)
if dropped_item:
item_def = ITEMS_MANAGER.get_item(dropped_item['item_id'])
item_name = item_def.name if item_def else dropped_item['item_id']
item_name = get_locale_string(item_def.name, locale) if item_def else dropped_item['item_id']
else:
item_name = "item"
@@ -1392,5 +1410,5 @@ async def drop_item(
return {
"success": True,
"message": f"Dropped {item_def.emoji} {item_def.name} x{quantity}"
"message": f"Dropped {item_def.emoji} {get_locale_string(item_def.name)} x{quantity}"
}

View File

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

View File

@@ -15,6 +15,45 @@ def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> st
return str(value)
# Translation maps for backend messages
DIRECTION_TRANSLATIONS = {
'north': {'en': 'north', 'es': 'norte'},
'south': {'en': 'south', 'es': 'sur'},
'east': {'en': 'east', 'es': 'este'},
'west': {'en': 'west', 'es': 'oeste'},
'northeast': {'en': 'northeast', 'es': 'noreste'},
'northwest': {'en': 'northwest', 'es': 'noroeste'},
'southeast': {'en': 'southeast', 'es': 'sureste'},
'southwest': {'en': 'southwest', 'es': 'suroeste'},
}
def translate_travel_message(direction: str, location_name: str, lang: str = 'en') -> str:
"""Translate a travel message to the user's language."""
dir_translated = DIRECTION_TRANSLATIONS.get(direction, {}).get(lang, direction)
if lang == 'es':
return f"Viajas al {dir_translated} hacia {location_name}."
else:
return f"You travel {dir_translated} to {location_name}."
import json
def create_combat_message(message_type: str, **data) -> str:
"""Create a structured combat message with type and data.
Args:
message_type: Type of combat message (combat_start, player_attack, etc.)
**data: Dynamic data for the message (damage, npc_name, etc.)
Returns:
Dictionary with 'type' and 'data' fields
"""
return json.dumps({
"type": message_type,
"data": data
})
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
"""
Calculate distance between two points using Euclidean distance.

View File

@@ -54,11 +54,11 @@
"npc_id": "raider_scout",
"name": {
"en": "Raider Scout",
"es": ""
"es": "Explorador"
},
"description": {
"en": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
"es": ""
"es": "Un explorador solitario con ropa improvisada. Te mira con intención hostil."
},
"emoji": "🏴‍☠️",
"hp_min": 30,
@@ -116,11 +116,11 @@
"npc_id": "mutant_rat",
"name": {
"en": "Mutant Rat",
"es": ""
"es": "Rata mutante"
},
"description": {
"en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
"es": ""
"es": "Una rata grotescamente grande, su pelaje es desgarrado y sus ojos brillan con luz unnatural."
},
"emoji": "🐀",
"hp_min": 10,
@@ -160,11 +160,11 @@
"npc_id": "infected_human",
"name": {
"en": "Infected Human",
"es": ""
"es": "Humano infectado"
},
"description": {
"en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
"es": ""
"es": "Una vez humano, ahora algo más. Sus movimientos son torpes y su piel muestra signos de infección avanzada."
},
"emoji": "🧟",
"hp_min": 35,

View File

@@ -34,14 +34,18 @@ server {
add_header Expires "0";
}
# Manifest should be cached for a short time
# Manifest should never be cached
location /manifest.webmanifest {
add_header Cache-Control "max-age=3600";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# SPA fallback - all other requests go to index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}

View File

@@ -1,17 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a1a" />
<meta name="description" content="A post-apocalyptic survival RPG" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Echoes of the Ash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Echoes of the Ash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
// Game.tsx - Main game orchestrator (refactored from 3,315 lines to ~350 lines)
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { useGameEngine } from './game/hooks/useGameEngine'
import Combat from './game/Combat'
@@ -9,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
import './Game.css'
function Game() {
const { t, i18n } = useTranslation()
const [token] = useState(() => localStorage.getItem('token'))
// Handle WebSocket messages
@@ -23,11 +25,29 @@ function Game() {
case 'location_update':
// General location updates - update state directly from message data when possible
console.log('🗺️ Location update:', message.data?.action, message.data?.message)
if (message.data?.message) {
actions.addLocationMessage(message.data.message)
let displayMessage = message.data?.message
const action = message.data?.action
// Handle translations for specific actions
if (action === 'enemy_spawned' && message.data.npc_data) {
const npcData = message.data.npc_data
let npcName = npcData.name
if (typeof npcName === 'object' && npcName !== null) {
npcName = npcName[i18n.language] || npcName['en'] || npcName['es']
}
displayMessage = t('messages.enemyAppeared', { name: npcName })
} else if (action === 'enemy_despawned') {
displayMessage = t('messages.enemyDespawned')
} else if (action === 'corpses_decayed' && message.data.count) {
displayMessage = t('messages.corpsesDecayed', { count: message.data.count })
} else if (action === 'items_decayed' && message.data.count) {
displayMessage = t('messages.itemsDecayed', { count: message.data.count })
}
const action = message.data?.action
if (displayMessage) {
actions.addLocationMessage(displayMessage)
}
if (action === 'player_arrived' && message.data.player_id) {
// Add player to location directly without API call
actions.addPlayerToLocation({
@@ -326,6 +346,7 @@ function Game() {
{/* Location view (when not in combat) */}
{!state.combatState && state.location && state.playerState && (
<LocationView
key={state.location.id}
location={state.location}
playerState={state.playerState}
combatState={state.combatState || null}

View File

@@ -11,6 +11,9 @@ function LanguageSelector() {
const changeLanguage = (langCode: string) => {
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]

View File

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

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,
onDropItem
}: InventoryModalProps) {
useTranslation()
const { t } = useTranslation()
// Categories for the sidebar
const categories = [
{ id: 'all', label: 'All Items', icon: '🎒' },
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
{ id: 'armor', label: 'Armor', icon: '🛡️' },
{ id: 'clothing', label: 'Clothing', icon: '👕' },
{ id: 'backpack', label: 'Backpacks', icon: '🎒' },
{ id: 'tool', label: 'Tools', icon: '🛠️' },
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
{ id: 'resource', label: 'Resources', icon: '📦' },
{ id: 'quest', label: 'Quest', icon: '📜' },
{ id: 'misc', label: 'Misc', icon: '📦' }
{ id: 'all', label: t('categories.all'), icon: '🎒' },
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
{ id: 'armor', label: t('categories.armor'), icon: '🛡️' },
{ id: 'clothing', label: t('categories.clothing'), icon: '👕' },
{ id: 'backpack', label: t('categories.backpack'), icon: '🎒' },
{ id: 'tool', label: t('categories.tool'), icon: '🛠️' },
{ id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
{ id: 'resource', label: t('categories.resource'), icon: '📦' },
{ id: 'quest', label: t('categories.quest'), icon: '📜' },
{ id: 'misc', label: t('categories.misc'), icon: '📦' }
]
// Use inventory directly as it now includes equipped items
@@ -100,7 +100,7 @@ function InventoryModal({
<div className="item-header-compact">
<span className="item-emoji-inline">{item.emoji}</span>
<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 className="item-stats-row">
@@ -149,17 +149,17 @@ function InventoryModal({
)}
{(item.unique_stats?.armor_penetration || item.stats?.armor_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>
)}
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
<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>
)}
{(item.unique_stats?.accuracy || item.stats?.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>
)}
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
@@ -169,34 +169,34 @@ function InventoryModal({
)}
{(item.unique_stats?.lifesteal || item.stats?.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>
)}
{/* Attributes */}
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
<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>
)}
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
<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>
)}
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
<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>
)}
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
<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>
)}
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
<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>
)}
@@ -217,7 +217,7 @@ function InventoryModal({
{hasDurability && (
<div className="durability-container">
<div className="durability-header">
<span>Durability</span>
<span>{t('game.durability')}</span>
<span className={
currentDurability < maxDurability * 0.2
? "durability-text-low"
@@ -246,18 +246,18 @@ function InventoryModal({
{/* Right: Actions */}
<div className="item-actions-section">
{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 && (
<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 && (
<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">
{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 && (
<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 ${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>
</div>
</div>
@@ -288,7 +288,7 @@ function InventoryModal({
<span className="metric-icon"></span>
<div className="metric-bar-container">
<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 className="metric-bar">
<div
@@ -303,7 +303,7 @@ function InventoryModal({
<span className="metric-icon">📦</span>
<div className="metric-bar-container">
<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 className="metric-bar">
<div
@@ -328,7 +328,7 @@ function InventoryModal({
) : (
<div className="backpack-status inactive">
<span className="backpack-icon">🚫</span>
<span>No Backpack Equipped</span>
<span>{t('game.noBackpack')}</span>
</div>
)}
<button className="close-btn" onClick={onClose}></button>
@@ -356,7 +356,7 @@ function InventoryModal({
<span className="search-icon">🔍</span>
<input
type="text"
placeholder="Search items..."
placeholder={t('game.searchItems')}
value={inventoryFilter}
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
/>
@@ -366,32 +366,29 @@ function InventoryModal({
{filteredItems.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">📦</span>
<p>No items found in this category</p>
<p>{t('game.noItemsFound')}</p>
</div>
) : (
inventoryCategoryFilter === 'all' ? (
<>
{/* Equipped */}
{filteredItems.some((i: any) => i.is_equipped) && (
{filteredItems.some((item: any) => item.is_equipped) && (
<>
<div className="category-header"> Equipped</div>
{filteredItems.filter((i: any) => i.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
<div className="category-header"> {t('game.equipped')}</div>
{filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
</>
)}
{/* Categories */}
{categories.filter(c => c.id !== 'all').map(cat => {
const categoryItems = filteredItems.filter((i: any) => !i.is_equipped && i.type === cat.id);
if (categoryItems.length === 0) return null;
return (
<div key={cat.id}>
<div className="category-header">{cat.icon} {cat.label}</div>
{categoryItems.map((item: any, i: number) => renderItemCard(item, i))}
</div>
);
})}
{/* Backpack */}
{filteredItems.some((item: any) => !item.is_equipped) && (
<>
<div className="category-header">🎒 {t('game.backpack')}</div>
{filteredItems.filter((item: any) => !item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
</>
)}
</>
) : (
/* Single category */
filteredItems.map((item: any, i: number) => renderItemCard(item, i))
)
)}

View File

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

View File

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

View File

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

View File

@@ -217,7 +217,7 @@ export function useGameEngine(
}, [])
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
setCombatLog((prev: CombatLogEntry[]) => [entry, ...prev])
setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev])
}, [])
// Fetch functions
@@ -337,6 +337,7 @@ export function useGameEngine(
pvpRes.data.pvp_combat.defender :
pvpRes.data.pvp_combat.attacker
setCombatLog([{
id: 'pvp-combat-init',
time: timeStr,
message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`,
isPlayer: true
@@ -351,6 +352,7 @@ export function useGameEngine(
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
id: 'combat-in-progress',
time: timeStr,
message: 'Combat in progress...',
isPlayer: true
@@ -402,8 +404,9 @@ export function useGameEngine(
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
id: Date.now() + Math.random(),
time: timeStr,
message: `⚠️ ${encounter.combat.npc_name} ambushes you!`,
message: { type: 'combat_start', data: { npc_name: encounter.combat.npc_name } },
isPlayer: false
}])
@@ -503,10 +506,23 @@ export function useGameEngine(
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
const messages = data.message.split('\n').filter((m: string) => m.trim())
const newEntries = messages.map((msg: string) => ({
const parsedMessages = messages.map((msg: string) => {
try {
if (msg.trim().startsWith('{')) {
const parsed = JSON.parse(msg)
if (parsed.type && parsed.data) return parsed
}
} catch (e) { }
return msg
})
const newEntries = parsedMessages.map((msg: any) => ({
id: `item-use-${Date.now()}-${Math.random()}`,
time: timeStr,
message: msg,
isPlayer: !msg.includes('attacks')
isPlayer: typeof msg === 'object'
? msg.type !== 'enemy_attack' && msg.type !== 'flee_fail'
: !msg.includes('attacks') && !msg.includes('hits')
}))
setCombatLog((prev: any) => [...newEntries, ...prev])
@@ -688,8 +704,9 @@ export function useGameEngine(
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
setCombatLog([{
id: Date.now() + Math.random(),
time: timeStr,
message: `Combat started with ${response.data.combat.npc_name}!`,
message: { type: 'combat_start', data: { npc_name: response.data.combat.npc_name } },
isPlayer: true
}])

View File

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

View File

@@ -10,7 +10,16 @@
"no": "No",
"game": "Game",
"leaderboards": "Leaderboards",
"account": "Account"
"account": "Account",
"info": "Info",
"talk": "Talk",
"loot": "Loot",
"lootAll": "Loot All Available",
"examine": "Examine",
"fight": "Fight",
"pickUp": "Pick Up",
"pickUpAll": "Pick Up All",
"qty": "Qty"
},
"auth": {
"login": "Login",
@@ -22,7 +31,23 @@
"forgotPassword": "Forgot Password?",
"createAccount": "Create Account",
"alreadyHaveAccount": "Already have an account?",
"dontHaveAccount": "Don't have an account?"
"dontHaveAccount": "Don't have an account?",
"rememberMe": "Remember me",
"loginTitle": "Welcome Back",
"registerTitle": "Create Account",
"loginSubtitle": "Sign in to continue your journey",
"registerSubtitle": "Join the survivors"
},
"characters": {
"title": "Select Character",
"createNew": "Create New Character",
"play": "Play",
"delete": "Delete",
"noCharacters": "No characters yet",
"createFirst": "Create your first character to begin",
"name": "Character Name",
"class": "Class",
"level": "Level"
},
"game": {
"travel": "🧭 Travel",
@@ -40,10 +65,41 @@
"use": "Use",
"equip": "Equip",
"unequip": "Unequip",
"attack": "Attack",
"flee": "Flee",
"attack": "⚔️ Attack",
"flee": "🏃 Flee",
"rest": "Rest",
"onlineCount": "{{count}} Online"
"onlineCount": "{{count}} Online",
"searchItems": "Search items...",
"equipped": "Equipped",
"backpack": "Backpack",
"noBackpack": "No Backpack Equipped",
"distance": "Distance",
"stamina": "Stamina",
"weight": "Weight",
"volume": "Volume",
"durability": "Durability",
"noItemsFound": "No items found in this category"
},
"location": {
"recentActivity": "📜 Recent Activity",
"enemies": "⚔️ Enemies",
"corpses": "💀 Corpses",
"npcs": "👥 NPCs",
"itemsOnGround": "📦 Items on Ground",
"lootableItems": "Lootable Items:",
"items": "item(s)",
"level": "Lv."
},
"tags": {
"workbench": "🔧 Workbench",
"repairStation": "🛠️ Repair Station",
"safeZone": "🛡️ Safe Zone",
"shop": "🏪 Shop",
"shelter": "🏠 Shelter",
"medical": "⚕️ Medical",
"storage": "📦 Storage",
"water": "💧 Water",
"food": "🍎 Food"
},
"stats": {
"hp": "❤️ HP",
@@ -53,8 +109,8 @@
"xp": "⭐ XP",
"level": "Level",
"unspentPoints": "⭐ Unspent",
"weight": "⚖️ Weight",
"volume": "📦 Volume",
"weight": "Weight",
"volume": "Volume",
"strength": "💪 STR",
"strengthFull": "Strength",
"strengthDesc": "Increases melee damage and carry capacity",
@@ -68,10 +124,23 @@
"intellectFull": "Intellect",
"intellectDesc": "Enhances crafting and resource gathering",
"armor": "🛡️ Armor",
"damage": "⚔️ Damage",
"durability": "Durability"
"damage": "Damage",
"durability": "Durability",
"tier": "Tier",
"hpRestore": "HP Restore",
"staminaRestore": "Stamina Restore",
"pen": "Pen",
"crit": "Crit",
"acc": "Acc",
"life": "Life",
"str": "STR",
"agi": "AGI",
"end": "END",
"hpMax": "HP max",
"stmMax": "Stm max"
},
"combat": {
"title": "Combat",
"inCombat": "In Combat",
"yourTurn": "Your Turn",
"enemyTurn": "Enemy's Turn",
@@ -80,7 +149,20 @@
"youDied": "You Died",
"respawn": "Respawn",
"fleeSuccess": "You escaped!",
"fleeFailed": "Failed to escape!"
"fleeFailed": "Failed to escape!",
"enemyHp": "Enemy HP",
"playerHp": "Your HP",
"combatLog": "Combat Log",
"attacking": "Attacking",
"defending": "Defending",
"messages": {
"combat_start": "Combat started with {{enemy}}!",
"player_attack": "You attack for {{damage}} damage!",
"enemy_attack": "{{enemy}} attacks for {{damage}} damage!",
"victory": "Victory! Defeated {{enemy}}",
"flee_fail": "Failed to flee! {{enemy}} attacks for {{damage}} damage!"
},
"turnTimer": "Turn Timer"
},
"equipment": {
"head": "Head",
@@ -104,7 +186,16 @@
"staminaCost": "⚡ {{cost}} Stamina",
"alreadyFull": "Already Full",
"perfectCondition": "✅ Item is in perfect condition",
"yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage"
"yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage",
"selectItem": "Select an item to view details",
"chooseFromList": "Choose an item from the list on the left",
"yield": "Yield",
"repairCost": "Repair Cost",
"noMaterialsRequired": "No materials required",
"missing": "Missing",
"cost": "Cost",
"potentialBaseStats": "Potential base stats. Actual stats may vary.",
"confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone."
},
"categories": {
"all": "All Items",
@@ -119,13 +210,38 @@
"misc": "Misc"
},
"messages": {
"notEnoughStamina": "Not enough stamina",
"notEnoughStamina": "Not enough stamina (need {{need}}, have {{have}})",
"inventoryFull": "Inventory full",
"itemDropped": "Item dropped",
"itemPickedUp": "Item picked up",
"waitBeforeMoving": "Wait {{seconds}}s before moving",
"cannotTravelInCombat": "Cannot travel during combat",
"cannotInteractInCombat": "Cannot interact during combat"
"cannotInteractInCombat": "Cannot interact during combat",
"interactionCooldown": "Wait {{seconds}}s before interacting again",
"youAreDead": "You are dead",
"cannotTravelCombat": "Cannot travel during combat",
"cannotGo": "Cannot go {{direction}}",
"enemyAppeared": "A {{name}} has appeared!",
"enemyDespawned": "A wandering enemy has left the area",
"corpsesDecayed": "{{count}} corpses have decayed",
"itemsDecayed": "{{count}} dropped items have decayed",
"waitBeforeMovingSimple": "Wait {{seconds}}s before moving"
},
"directions": {
"north": "North",
"south": "South",
"east": "East",
"west": "West",
"northeast": "Northeast",
"northwest": "Northwest",
"southeast": "Southeast",
"southwest": "Southwest",
"up": "Up",
"down": "Down",
"inside": "Inside",
"outside": "Outside",
"enter": "Enter",
"exit": "Exit"
},
"landing": {
"heroTitle": "Echoes of the Ash",

View File

@@ -10,19 +10,44 @@
"no": "No",
"game": "Juego",
"leaderboards": "Clasificación",
"account": "Cuenta"
"account": "Cuenta",
"info": "Info",
"talk": "Hablar",
"loot": "Saquear",
"lootAll": "Saquear Todo",
"examine": "Examinar",
"fight": "Luchar",
"pickUp": "Recoger",
"pickUpAll": "Recoger Todo",
"qty": "Cant"
},
"auth": {
"login": "Iniciar Sesión",
"logout": "Cerrar Sesión",
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"register": "Registrarse",
"username": "Usuario",
"password": "Contraseña",
"email": "Correo",
"email": "Correo electrónico",
"forgotPassword": "¿Olvidaste tu contraseña?",
"createAccount": "Crear Cuenta",
"createAccount": "Crear cuenta",
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"dontHaveAccount": "¿No tienes cuenta?"
"dontHaveAccount": "¿No tienes una cuenta?",
"rememberMe": "Recordarme",
"loginTitle": "Bienvenido de nuevo",
"registerTitle": "Crear cuenta",
"loginSubtitle": "Inicia sesión para continuar tu viaje",
"registerSubtitle": "Únete a los supervivientes"
},
"characters": {
"title": "Seleccionar Personaje",
"createNew": "Crear Nuevo Personaje",
"play": "Jugar",
"delete": "Eliminar",
"noCharacters": "Aún no hay personajes",
"createFirst": "Crea tu primer personaje para comenzar",
"name": "Nombre del Personaje",
"class": "Clase",
"level": "Nivel"
},
"game": {
"travel": "🧭 Viajar",
@@ -33,17 +58,48 @@
"workbench": "🔧 Banco de Trabajo",
"craft": "🔨 Fabricar",
"repair": "🛠️ Reparar",
"salvage": "♻️ Desmontar",
"salvage": "♻️ Desguazar",
"pickUp": "Recoger",
"drop": "Soltar",
"dropAll": "Todo",
"use": "Usar",
"equip": "Equipar",
"unequip": "Desequipar",
"attack": "Atacar",
"flee": "Huir",
"attack": "⚔️ Atacar",
"flee": "🏃 Huir",
"rest": "Descansar",
"onlineCount": "{{count}} En línea"
"onlineCount": "{{count}} En línea",
"searchItems": "Buscar objetos...",
"equipped": "Equipado",
"backpack": "Mochila",
"noBackpack": "Sin mochila equipada",
"distance": "Distancia",
"stamina": "Aguante",
"weight": "Peso",
"volume": "Volumen",
"durability": "Durabilidad",
"noItemsFound": "No se encontraron objetos en esta categoría"
},
"location": {
"recentActivity": "📜 Actividad Reciente",
"enemies": "⚔️ Enemigos",
"corpses": "💀 Cadáveres",
"npcs": "👥 NPCs",
"itemsOnGround": "📦 Objetos en el Suelo",
"lootableItems": "Objetos Saqueables:",
"items": "objeto(s)",
"level": "Nv."
},
"tags": {
"workbench": "🔧 Banco de Trabajo",
"repairStation": "🛠️ Estación de Reparación",
"safeZone": "🛡️ Zona Segura",
"shop": "🏪 Tienda",
"shelter": "🏠 Refugio",
"medical": "⚕️ Médico",
"storage": "📦 Almacén",
"water": "💧 Agua",
"food": "🍎 Comida"
},
"stats": {
"hp": "❤️ Vida",
@@ -53,34 +109,60 @@
"xp": "⭐ XP",
"level": "Nivel",
"unspentPoints": "⭐ Sin gastar",
"weight": "⚖️ Peso",
"volume": "📦 Volumen",
"weight": "Peso",
"volume": "Volumen",
"strength": "💪 FUE",
"strengthFull": "Fuerza",
"strengthDesc": "Aumenta el daño cuerpo a cuerpo y capacidad de carga",
"strengthDesc": "Aumenta el daño cuerpo a cuerpo y la capacidad de carga",
"agility": "🏃 AGI",
"agilityFull": "Agilidad",
"agilityDesc": "Mejora la esquiva y golpes críticos",
"agilityDesc": "Mejora la probabilidad de esquivar y los golpes críticos",
"endurance": "🛡️ RES",
"enduranceFull": "Resistencia",
"enduranceDesc": "Aumenta la vida y energía",
"enduranceDesc": "Aumenta la vida y el aguante",
"intellect": "🧠 INT",
"intellectFull": "Intelecto",
"intellectDesc": "Mejora la fabricación y recolección",
"intellectDesc": "Mejora la fabricación y recolección de recursos",
"armor": "🛡️ Armadura",
"damage": "⚔️ Daño",
"durability": "Durabilidad"
"damage": "Daño",
"durability": "Durabilidad",
"tier": "Nivel",
"hpRestore": "Restaura Vida",
"staminaRestore": "Restaura Aguante",
"pen": "Pen",
"crit": "Crit",
"acc": "Prec",
"life": "Vida",
"str": "FUE",
"agi": "AGI",
"end": "RES",
"hpMax": "Vida máx",
"stmMax": "Agua. máx"
},
"combat": {
"title": "Combate",
"inCombat": "En Combate",
"yourTurn": "Tu Turno",
"enemyTurn": "Turno del Enemigo",
"victory": "¡Victoria!",
"defeat": "Derrota",
"youDied": "Has Muerto",
"respawn": "Revivir",
"fleeSuccess": Escapaste!",
"fleeFailed": "¡No pudiste escapar!"
"respawn": "Reaparecer",
"fleeSuccess": Has escapado!",
"fleeFailed": "¡No has podido escapar!",
"enemyHp": "Vida del Enemigo",
"playerHp": "Tu Vida",
"combatLog": "Registro de Combate",
"turnTimer": "Temporizador de Turno",
"attacking": "Atacando",
"defending": "Defendiendo",
"messages": {
"combat_start": "¡Combate iniciado con {{enemy}}!",
"player_attack": "¡Atacas por {{damage}} de daño!",
"enemy_attack": "{{enemy}} ataca por {{damage}} de daño!",
"victory": "¡Victoria! Derrotaste a {{enemy}}",
"flee_fail": "¡Fallaste al huir! {{enemy}} ataca por {{damage}} de daño!"
}
},
"equipment": {
"head": "Cabeza",
@@ -96,20 +178,29 @@
"requirements": "📊 Requisitos",
"materials": "Materiales",
"tools": "Herramientas",
"levelRequired": "Nivel {{level}} Requerido",
"levelRequired": "Requiere Nivel {{level}}",
"missingRequirements": "Faltan Requisitos",
"craftItem": "🔨 Fabricar",
"repairItem": "🛠️ Reparar",
"salvageItem": "♻️ Desmontar",
"staminaCost": "⚡ {{cost}} Energía",
"alreadyFull": "Ya está Completo",
"perfectCondition": "✅ El objeto está en perfecto estado",
"yieldReduced": "⚠️ Rendimiento reducido {{percent}}% por daño"
"salvageItem": "♻️ Desguazar",
"staminaCost": "⚡ {{cost}} Aguante",
"alreadyFull": "Ya está completo",
"perfectCondition": "✅ El objeto está en perfectas condiciones",
"yieldReduced": "⚠️ Rendimiento reducido un {{percent}}% por daño",
"selectItem": "Selecciona un objeto para ver detalles",
"chooseFromList": "Elige un objeto de la lista de la izquierda",
"yield": "Rendimiento",
"repairCost": "Coste de Reparación",
"noMaterialsRequired": "No requiere materiales",
"missing": "Falta",
"cost": "Coste",
"potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.",
"confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer."
},
"categories": {
"all": "Todos",
"all": "Todos los Objetos",
"weapon": "Armas",
"armor": "Armadura",
"armor": "Armaduras",
"clothing": "Ropa",
"backpack": "Mochilas",
"tool": "Herramientas",
@@ -119,16 +210,41 @@
"misc": "Varios"
},
"messages": {
"notEnoughStamina": "No tienes suficiente energía",
"notEnoughStamina": "No tienes suficiente aguante (necesitas {{need}}, tienes {{have}})",
"inventoryFull": "Inventario lleno",
"itemDropped": "Objeto soltado",
"itemPickedUp": "Objeto recogido",
"waitBeforeMoving": "Espera {{seconds}}s antes de moverte",
"cannotTravelInCombat": "No puedes viajar en combate",
"cannotInteractInCombat": "No puedes interactuar en combate"
"cannotTravelInCombat": "No puedes viajar durante el combate",
"cannotInteractInCombat": "No puedes interactuar durante el combate",
"interactionCooldown": "Espera {{seconds}}s antes de interactuar de nuevo",
"youAreDead": "Estás muerto",
"cannotTravelCombat": "No puedes viajar durante el combate",
"cannotGo": "No puedes ir al {{direction}}",
"enemyAppeared": "¡Un {{name}} ha aparecido!",
"enemyDespawned": "Un enemigo errante ha abandonado el área",
"corpsesDecayed": "{{count}} cadáveres se han descompuesto",
"itemsDecayed": "{{count}} objetos caídos se han descompuesto",
"waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte"
},
"directions": {
"north": "Norte",
"south": "Sur",
"east": "Este",
"west": "Oeste",
"northeast": "Noreste",
"northwest": "Noroeste",
"southeast": "Sureste",
"southwest": "Suroeste",
"up": "Arriba",
"down": "Abajo",
"inside": "Adentro",
"outside": "Afuera",
"enter": "Entrar",
"exit": "Salir"
},
"landing": {
"heroTitle": "Ecos de la Ceniza",
"heroTitle": "Ecos de las Cenizas",
"heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
"playNow": "Jugar Ahora",
"features": "Características"

View File

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

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
const token = localStorage.getItem('token')
if (token) {

View File

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