Pre-combat-rewrite: Backup current state before comprehensive combat frontend rewrite
This commit is contained in:
56
CLAUDE.md
Normal file
56
CLAUDE.md
Normal 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
627
README.md
Normal 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.
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})"
|
||||
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
nginx.conf
10
nginx.conf
@@ -34,14 +34,18 @@ server {
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Manifest should be cached for a short time
|
||||
# Manifest should never be cached
|
||||
location /manifest.webmanifest {
|
||||
add_header Cache-Control "max-age=3600";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# SPA fallback - all other requests go to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
<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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(() => {
|
||||
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!' &&
|
||||
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}
|
||||
|
||||
393
pwa/src/components/game/CombatView.tsx
Normal file
393
pwa/src/components/game/CombatView.tsx
Normal 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
|
||||
@@ -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))
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>{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">
|
||||
|
||||
@@ -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
|
||||
}])
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user