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:
|
if manager:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
npc_def = NPCS.get(npc_id)
|
npc_def = NPCS.get(npc_id)
|
||||||
npc_name = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
|
npc_name_obj = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
|
||||||
|
# Handle localized name for the fallback message
|
||||||
|
if isinstance(npc_name_obj, dict):
|
||||||
|
npc_name_en = npc_name_obj.get('en', str(npc_name_obj))
|
||||||
|
else:
|
||||||
|
npc_name_en = str(npc_name_obj)
|
||||||
|
|
||||||
await manager.send_to_location(
|
await manager.send_to_location(
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"A {npc_name} appeared!",
|
"message": f"A {npc_name_en} appeared!",
|
||||||
"action": "enemy_spawned",
|
"action": "enemy_spawned",
|
||||||
"npc_data": {
|
"npc_data": {
|
||||||
"id": enemy_data['id'],
|
"id": enemy_data['id'],
|
||||||
"npc_id": npc_id,
|
"npc_id": npc_id,
|
||||||
"name": npc_name,
|
"name": npc_name_obj,
|
||||||
"type": "enemy",
|
"type": "enemy",
|
||||||
"is_wandering": True,
|
"is_wandering": True,
|
||||||
"image_path": npc_def.image_path if npc_def else None
|
"image_path": npc_def.image_path if npc_def else None
|
||||||
@@ -209,7 +215,8 @@ async def decay_dropped_items(manager=None):
|
|||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"{count} dropped item(s) decayed",
|
"message": f"{count} dropped item(s) decayed",
|
||||||
"action": "items_decayed"
|
"action": "items_decayed",
|
||||||
|
"count": count
|
||||||
},
|
},
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
}
|
}
|
||||||
@@ -472,7 +479,8 @@ async def decay_corpses(manager=None):
|
|||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"{total} {corpse_type} decayed",
|
"message": f"{total} {corpse_type} decayed",
|
||||||
"action": "corpses_decayed"
|
"action": "corpses_decayed",
|
||||||
|
"count": total
|
||||||
},
|
},
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
|
|||||||
detail="No character selected. Please select a character first."
|
detail="No character selected. Please select a character first."
|
||||||
)
|
)
|
||||||
|
|
||||||
player = await db.get_player_by_id(character_id)
|
player = await db.get_character_by_id(character_id)
|
||||||
if player is None:
|
if player is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -358,15 +358,7 @@ async def init_db():
|
|||||||
await conn.execute(text(index_sql))
|
await conn.execute(text(index_sql))
|
||||||
|
|
||||||
|
|
||||||
# Player operations
|
|
||||||
async def get_player_by_id(player_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get player by internal ID"""
|
|
||||||
async with DatabaseSession() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(players).where(players.c.id == player_id)
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
return dict(row._mapping) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_player_by_username(username: str) -> Optional[Dict[str, Any]]:
|
async def get_player_by_username(username: str) -> Optional[Dict[str, Any]]:
|
||||||
@@ -421,13 +413,7 @@ async def create_player(
|
|||||||
return dict(row._mapping) if row else None
|
return dict(row._mapping) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def update_player(player_id: int, **kwargs) -> bool:
|
|
||||||
"""Update player fields - OLD FUNCTION, use update_character instead"""
|
|
||||||
async with DatabaseSession() as session:
|
|
||||||
stmt = update(characters).where(characters.c.id == player_id).values(**kwargs)
|
|
||||||
await session.execute(stmt)
|
|
||||||
await session.commit()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def update_player_location(player_id: int, location_id: str) -> bool:
|
async def update_player_location(player_id: int, location_id: str) -> bool:
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import random
|
|||||||
import time
|
import time
|
||||||
from typing import Dict, Any, Tuple, Optional, List
|
from typing import Dict, Any, Tuple, Optional, List
|
||||||
from . import database as db
|
from . import database as db
|
||||||
|
from .services.helpers import get_locale_string, translate_travel_message, create_combat_message
|
||||||
|
|
||||||
|
|
||||||
async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[bool, str, Optional[str], int, int]:
|
async def move_player(player_id: int, direction: str, locations: Dict, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]:
|
||||||
"""
|
"""
|
||||||
Move player in a direction.
|
Move player in a direction.
|
||||||
Returns: (success, message, new_location_id, stamina_cost, distance_meters)
|
Returns: (success, message, new_location_id, stamina_cost, distance_meters)
|
||||||
"""
|
"""
|
||||||
player = await db.get_player_by_id(player_id)
|
player = await db.get_character_by_id(player_id)
|
||||||
if not player:
|
if not player:
|
||||||
return False, "Player not found", None, 0, 0
|
return False, "Player not found", None, 0, 0
|
||||||
|
|
||||||
@@ -69,13 +70,15 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[
|
|||||||
return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0
|
return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0
|
||||||
|
|
||||||
# Update player location and stamina
|
# Update player location and stamina
|
||||||
await db.update_player(
|
await db.update_character(
|
||||||
player_id,
|
player_id,
|
||||||
location_id=new_location_id,
|
location_id=new_location_id,
|
||||||
stamina=max(0, player['stamina'] - stamina_cost)
|
stamina=max(0, player['stamina'] - stamina_cost)
|
||||||
)
|
)
|
||||||
|
|
||||||
return True, f"You travel {direction} to {new_location.name}.", new_location_id, stamina_cost, distance
|
translated_location = get_locale_string(new_location.name, locale)
|
||||||
|
travel_message = translate_travel_message(direction, translated_location, locale)
|
||||||
|
return True, travel_message, new_location_id, stamina_cost, distance
|
||||||
|
|
||||||
|
|
||||||
async def inspect_area(player_id: int, location, interactables_data: Dict) -> str:
|
async def inspect_area(player_id: int, location, interactables_data: Dict) -> str:
|
||||||
@@ -216,7 +219,7 @@ async def interact_with_object(
|
|||||||
if not item:
|
if not item:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
item_name = item.name if item else item_id
|
item_name = get_locale_string(item.name) if item else item_id
|
||||||
emoji = item.emoji if item and hasattr(item, 'emoji') else ''
|
emoji = item.emoji if item and hasattr(item, 'emoji') else ''
|
||||||
|
|
||||||
# Check if item has durability (unique item)
|
# Check if item has durability (unique item)
|
||||||
@@ -237,7 +240,7 @@ async def interact_with_object(
|
|||||||
max_durability=item.durability,
|
max_durability=item.durability,
|
||||||
tier=getattr(item, 'tier', None)
|
tier=getattr(item, 'tier', None)
|
||||||
)
|
)
|
||||||
items_found.append(f"{emoji} {item_name}")
|
items_found.append(f"{emoji} {get_locale_string(item_name)}")
|
||||||
current_weight += item.weight
|
current_weight += item.weight
|
||||||
current_volume += item.volume
|
current_volume += item.volume
|
||||||
else:
|
else:
|
||||||
@@ -252,7 +255,7 @@ async def interact_with_object(
|
|||||||
unique_stats=base_stats
|
unique_stats=base_stats
|
||||||
)
|
)
|
||||||
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
|
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
|
||||||
items_dropped.append(f"{emoji} {item_name}")
|
items_dropped.append(f"{emoji} {get_locale_string(item_name)}")
|
||||||
else:
|
else:
|
||||||
# Stackable items - process as before
|
# Stackable items - process as before
|
||||||
item_weight = item.weight * quantity
|
item_weight = item.weight * quantity
|
||||||
@@ -262,13 +265,13 @@ async def interact_with_object(
|
|||||||
current_volume + item_volume <= max_volume):
|
current_volume + item_volume <= max_volume):
|
||||||
# Add to inventory
|
# Add to inventory
|
||||||
await db.add_item_to_inventory(player_id, item_id, quantity)
|
await db.add_item_to_inventory(player_id, item_id, quantity)
|
||||||
items_found.append(f"{emoji} {item_name} x{quantity}")
|
items_found.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
|
||||||
current_weight += item_weight
|
current_weight += item_weight
|
||||||
current_volume += item_volume
|
current_volume += item_volume
|
||||||
else:
|
else:
|
||||||
# Drop to ground
|
# Drop to ground
|
||||||
await db.drop_item_to_world(item_id, quantity, player['location_id'])
|
await db.drop_item_to_world(item_id, quantity, player['location_id'])
|
||||||
items_dropped.append(f"{emoji} {item_name} x{quantity}")
|
items_dropped.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
|
||||||
|
|
||||||
# Apply damage
|
# Apply damage
|
||||||
if damage_taken > 0:
|
if damage_taken > 0:
|
||||||
@@ -283,7 +286,7 @@ async def interact_with_object(
|
|||||||
await db.set_interactable_cooldown(interactable_id, action_id, 60)
|
await db.set_interactable_cooldown(interactable_id, action_id, 60)
|
||||||
|
|
||||||
# Build message
|
# Build message
|
||||||
final_message = outcome.text
|
final_message = get_locale_string(outcome.text)
|
||||||
if items_dropped:
|
if items_dropped:
|
||||||
final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(items_dropped)}"
|
final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(items_dropped)}"
|
||||||
|
|
||||||
@@ -565,7 +568,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
|||||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||||
message = f"{npc_def.name} defends and recovers {heal_amount} HP!"
|
message = f"{get_locale_string(npc_def.name)} defends and recovers {heal_amount} HP!"
|
||||||
|
|
||||||
elif intent_type == 'special':
|
elif intent_type == 'special':
|
||||||
# Strong attack (1.5x damage)
|
# Strong attack (1.5x damage)
|
||||||
@@ -574,7 +577,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
|||||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||||
|
|
||||||
message = f"{npc_def.name} uses a SPECIAL ATTACK for {npc_damage} damage!"
|
message = f"{get_locale_string(npc_def.name)} uses a SPECIAL ATTACK for {npc_damage} damage!"
|
||||||
if armor_absorbed > 0:
|
if armor_absorbed > 0:
|
||||||
message += f" (Armor absorbed {armor_absorbed})"
|
message += f" (Armor absorbed {armor_absorbed})"
|
||||||
|
|
||||||
@@ -589,7 +592,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
|||||||
# Enrage bonus if NPC is below 30% HP
|
# Enrage bonus if NPC is below 30% HP
|
||||||
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
|
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
|
||||||
npc_damage = int(npc_damage * 1.5)
|
npc_damage = int(npc_damage * 1.5)
|
||||||
message = f"{npc_def.name} is ENRAGED! "
|
message = f"{get_locale_string(npc_def.name)} is ENRAGED! "
|
||||||
else:
|
else:
|
||||||
message = ""
|
message = ""
|
||||||
|
|
||||||
@@ -597,7 +600,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
|||||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||||
|
|
||||||
message += f"{npc_def.name} attacks for {npc_damage} damage!"
|
message += create_combat_message("enemy_attack", npc_name=npc_def.name, damage=npc_damage, armor_absorbed=armor_absorbed)
|
||||||
if armor_absorbed > 0:
|
if armor_absorbed > 0:
|
||||||
message += f" (Armor absorbed {armor_absorbed})"
|
message += f" (Armor absorbed {armor_absorbed})"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import logging
|
|||||||
|
|
||||||
from ..core.security import get_current_user, security, verify_internal_key
|
from ..core.security import get_current_user, security, verify_internal_key
|
||||||
from ..services.models import *
|
from ..services.models import *
|
||||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message
|
||||||
from .. import database as db
|
from .. import database as db
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
from .. import game_logic
|
from .. import game_logic
|
||||||
@@ -147,7 +147,7 @@ async def initiate_combat(
|
|||||||
await manager.send_personal_message(current_user['id'], {
|
await manager.send_personal_message(current_user['id'], {
|
||||||
"type": "combat_started",
|
"type": "combat_started",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"Combat started with {npc_def.name}!",
|
"message": create_combat_message("combat_start", npc_name=npc_def.name),
|
||||||
"combat": {
|
"combat": {
|
||||||
"npc_id": enemy.npc_id,
|
"npc_id": enemy.npc_id,
|
||||||
"npc_name": npc_def.name,
|
"npc_name": npc_def.name,
|
||||||
@@ -167,7 +167,7 @@ async def initiate_combat(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"{player['name']} entered combat with {npc_def.name}",
|
"message": f"{player['name']} entered combat with {get_locale_string(npc_def.name)}",
|
||||||
"action": "combat_started",
|
"action": "combat_started",
|
||||||
"player_id": player['id']
|
"player_id": player['id']
|
||||||
},
|
},
|
||||||
@@ -178,7 +178,7 @@ async def initiate_combat(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Combat started with {npc_def.name}!",
|
"message": create_combat_message("combat_start", npc_name=npc_def.name),
|
||||||
"combat": {
|
"combat": {
|
||||||
"npc_id": enemy.npc_id,
|
"npc_id": enemy.npc_id,
|
||||||
"npc_name": npc_def.name,
|
"npc_name": npc_def.name,
|
||||||
@@ -304,7 +304,7 @@ async def combat_action(
|
|||||||
|
|
||||||
if new_npc_hp <= 0:
|
if new_npc_hp <= 0:
|
||||||
# NPC defeated
|
# NPC defeated
|
||||||
result_message += f"{npc_def.name} has been defeated!"
|
result_message += create_combat_message("victory", npc_name=npc_def.name)
|
||||||
combat_over = True
|
combat_over = True
|
||||||
player_won = True
|
player_won = True
|
||||||
|
|
||||||
@@ -435,7 +435,7 @@ async def combat_action(
|
|||||||
# Failed to flee, NPC attacks
|
# Failed to flee, NPC attacks
|
||||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||||
new_player_hp = max(0, player['hp'] - npc_damage)
|
new_player_hp = max(0, player['hp'] - npc_damage)
|
||||||
result_message = f"Failed to flee! {npc_def.name} attacks for {npc_damage} damage!"
|
result_message = create_combat_message("flee_fail", npc_name=npc_def.name, damage=npc_damage)
|
||||||
|
|
||||||
if new_player_hp <= 0:
|
if new_player_hp <= 0:
|
||||||
result_message += "\nYou have been defeated!"
|
result_message += "\nYou have been defeated!"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import logging
|
|||||||
|
|
||||||
from ..core.security import get_current_user, security, verify_internal_key
|
from ..core.security import get_current_user, security, verify_internal_key
|
||||||
from ..services.models import *
|
from ..services.models import *
|
||||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
|
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_locale_string
|
||||||
from .. import database as db
|
from .. import database as db
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
from .. import game_logic
|
from .. import game_logic
|
||||||
@@ -156,7 +156,7 @@ async def get_craftable_items(current_user: dict = Depends(get_current_user)):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Sort: craftable items first, then by tier, then by name
|
# Sort: craftable items first, then by tier, then by name
|
||||||
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name']))
|
craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], get_locale_string(x['name'])))
|
||||||
|
|
||||||
return {'craftable_items': craftable_items}
|
return {'craftable_items': craftable_items}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Game Routes router.
|
Game Routes router.
|
||||||
Auto-generated from main.py migration.
|
Auto-generated from main.py migration.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Depends, status
|
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -391,8 +391,11 @@ async def spend_stat_point(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/game/location")
|
@router.get("/api/game/location")
|
||||||
async def get_current_location(current_user: dict = Depends(get_current_user)):
|
async def get_current_location(request: Request, current_user: dict = Depends(get_current_user)):
|
||||||
"""Get current location information"""
|
"""Get current location information"""
|
||||||
|
# Extract locale from Accept-Language header
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
location_id = current_user['location_id']
|
location_id = current_user['location_id']
|
||||||
location = LOCATIONS.get(location_id)
|
location = LOCATIONS.get(location_id)
|
||||||
|
|
||||||
@@ -682,7 +685,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
|||||||
corpses_data.append({
|
corpses_data.append({
|
||||||
"id": f"npc_{corpse['id']}",
|
"id": f"npc_{corpse['id']}",
|
||||||
"type": "npc",
|
"type": "npc",
|
||||||
"name": f"{npc_def.name if npc_def else corpse['npc_id']} Corpse",
|
"name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse",
|
||||||
"emoji": "💀",
|
"emoji": "💀",
|
||||||
"loot_count": len(loot),
|
"loot_count": len(loot),
|
||||||
"timestamp": corpse['death_timestamp']
|
"timestamp": corpse['death_timestamp']
|
||||||
@@ -719,6 +722,7 @@ async def get_current_location(current_user: dict = Depends(get_current_user)):
|
|||||||
@router.post("/api/game/move")
|
@router.post("/api/game/move")
|
||||||
async def move(
|
async def move(
|
||||||
move_req: MoveRequest,
|
move_req: MoveRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Move player in a direction"""
|
"""Move player in a direction"""
|
||||||
@@ -756,10 +760,14 @@ async def move(
|
|||||||
detail=f"You must wait {int(cooldown_remaining)} seconds before moving again."
|
detail=f"You must wait {int(cooldown_remaining)} seconds before moving again."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract locale from Accept-Language header
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
|
success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
|
||||||
current_user['id'],
|
current_user['id'],
|
||||||
move_req.direction,
|
move_req.direction,
|
||||||
LOCATIONS
|
LOCATIONS,
|
||||||
|
locale
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
@@ -951,9 +959,13 @@ async def inspect(current_user: dict = Depends(get_current_user)):
|
|||||||
@router.post("/api/game/interact")
|
@router.post("/api/game/interact")
|
||||||
async def interact(
|
async def interact(
|
||||||
interact_req: InteractRequest,
|
interact_req: InteractRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Interact with an object"""
|
"""Interact with an object in the game world"""
|
||||||
|
# Extract locale from Accept-Language header
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Check if player is in combat
|
# Check if player is in combat
|
||||||
combat = await db.get_active_combat(current_user['id'])
|
combat = await db.get_active_combat(current_user['id'])
|
||||||
if combat:
|
if combat:
|
||||||
@@ -1026,7 +1038,7 @@ async def interact(
|
|||||||
"instance_id": interact_req.interactable_id,
|
"instance_id": interact_req.interactable_id,
|
||||||
"action_id": interact_req.action_id,
|
"action_id": interact_req.action_id,
|
||||||
"cooldown_remaining": cooldown_remaining,
|
"cooldown_remaining": cooldown_remaining,
|
||||||
"message": f"{current_user['name']} used {action_display} on {interactable_name}"
|
"message": f"{current_user['name']} used {get_locale_string(action_display, locale)} on {get_locale_string(interactable_name, locale)}"
|
||||||
},
|
},
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
}
|
}
|
||||||
@@ -1035,6 +1047,8 @@ async def interact(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/game/use_item")
|
@router.post("/api/game/use_item")
|
||||||
async def use_item(
|
async def use_item(
|
||||||
use_req: UseItemRequest,
|
use_req: UseItemRequest,
|
||||||
@@ -1159,15 +1173,19 @@ async def use_item(
|
|||||||
@router.post("/api/game/pickup")
|
@router.post("/api/game/pickup")
|
||||||
async def pickup(
|
async def pickup(
|
||||||
pickup_req: PickupItemRequest,
|
pickup_req: PickupItemRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Pick up an item from the ground"""
|
"""Pick up an item from the ground"""
|
||||||
|
# Extract locale from Accept-Language header
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Get item details for broadcast BEFORE picking it up (it will be removed from DB)
|
# Get item details for broadcast BEFORE picking it up (it will be removed from DB)
|
||||||
# pickup_req.item_id is the dropped_item database ID, not the item_id string
|
# pickup_req.item_id is the dropped_item database ID, not the item_id string
|
||||||
dropped_item = await db.get_dropped_item(pickup_req.item_id)
|
dropped_item = await db.get_dropped_item(pickup_req.item_id)
|
||||||
if dropped_item:
|
if dropped_item:
|
||||||
item_def = ITEMS_MANAGER.get_item(dropped_item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(dropped_item['item_id'])
|
||||||
item_name = item_def.name if item_def else dropped_item['item_id']
|
item_name = get_locale_string(item_def.name, locale) if item_def else dropped_item['item_id']
|
||||||
else:
|
else:
|
||||||
item_name = "item"
|
item_name = "item"
|
||||||
|
|
||||||
@@ -1392,5 +1410,5 @@ async def drop_item(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Dropped {item_def.emoji} {item_def.name} x{quantity}"
|
"message": f"Dropped {item_def.emoji} {get_locale_string(item_def.name)} x{quantity}"
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import logging
|
|||||||
|
|
||||||
from ..core.security import get_current_user, security, verify_internal_key
|
from ..core.security import get_current_user, security, verify_internal_key
|
||||||
from ..services.models import *
|
from ..services.models import *
|
||||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
|
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string
|
||||||
from .. import database as db
|
from .. import database as db
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
from .. import game_logic
|
from .. import game_logic
|
||||||
@@ -310,13 +310,13 @@ async def loot_corpse(
|
|||||||
message_parts = []
|
message_parts = []
|
||||||
for item in looted_items:
|
for item in looted_items:
|
||||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||||
item_name = item_def.name if item_def else item['item_id']
|
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
|
||||||
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||||
|
|
||||||
dropped_parts = []
|
dropped_parts = []
|
||||||
for item in dropped_items:
|
for item in dropped_items:
|
||||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||||
item_name = item_def.name if item_def else item['item_id']
|
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
|
||||||
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||||
|
|
||||||
message = ""
|
message = ""
|
||||||
@@ -438,13 +438,13 @@ async def loot_corpse(
|
|||||||
message_parts = []
|
message_parts = []
|
||||||
for item in looted_items:
|
for item in looted_items:
|
||||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||||
item_name = item_def.name if item_def else item['item_id']
|
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
|
||||||
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||||
|
|
||||||
dropped_parts = []
|
dropped_parts = []
|
||||||
for item in dropped_items:
|
for item in dropped_items:
|
||||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||||
item_name = item_def.name if item_def else item['item_id']
|
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
|
||||||
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||||
|
|
||||||
message = ""
|
message = ""
|
||||||
|
|||||||
@@ -15,6 +15,45 @@ def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> st
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
# Translation maps for backend messages
|
||||||
|
DIRECTION_TRANSLATIONS = {
|
||||||
|
'north': {'en': 'north', 'es': 'norte'},
|
||||||
|
'south': {'en': 'south', 'es': 'sur'},
|
||||||
|
'east': {'en': 'east', 'es': 'este'},
|
||||||
|
'west': {'en': 'west', 'es': 'oeste'},
|
||||||
|
'northeast': {'en': 'northeast', 'es': 'noreste'},
|
||||||
|
'northwest': {'en': 'northwest', 'es': 'noroeste'},
|
||||||
|
'southeast': {'en': 'southeast', 'es': 'sureste'},
|
||||||
|
'southwest': {'en': 'southwest', 'es': 'suroeste'},
|
||||||
|
}
|
||||||
|
|
||||||
|
def translate_travel_message(direction: str, location_name: str, lang: str = 'en') -> str:
|
||||||
|
"""Translate a travel message to the user's language."""
|
||||||
|
dir_translated = DIRECTION_TRANSLATIONS.get(direction, {}).get(lang, direction)
|
||||||
|
|
||||||
|
if lang == 'es':
|
||||||
|
return f"Viajas al {dir_translated} hacia {location_name}."
|
||||||
|
else:
|
||||||
|
return f"You travel {dir_translated} to {location_name}."
|
||||||
|
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
def create_combat_message(message_type: str, **data) -> str:
|
||||||
|
"""Create a structured combat message with type and data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_type: Type of combat message (combat_start, player_attack, etc.)
|
||||||
|
**data: Dynamic data for the message (damage, npc_name, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with 'type' and 'data' fields
|
||||||
|
"""
|
||||||
|
return json.dumps({
|
||||||
|
"type": message_type,
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
|
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
|
||||||
"""
|
"""
|
||||||
Calculate distance between two points using Euclidean distance.
|
Calculate distance between two points using Euclidean distance.
|
||||||
|
|||||||
@@ -54,11 +54,11 @@
|
|||||||
"npc_id": "raider_scout",
|
"npc_id": "raider_scout",
|
||||||
"name": {
|
"name": {
|
||||||
"en": "Raider Scout",
|
"en": "Raider Scout",
|
||||||
"es": ""
|
"es": "Explorador"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"en": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
|
"en": "A lone raider wearing makeshift armor. They eye you with hostile intent.",
|
||||||
"es": ""
|
"es": "Un explorador solitario con ropa improvisada. Te mira con intención hostil."
|
||||||
},
|
},
|
||||||
"emoji": "🏴☠️",
|
"emoji": "🏴☠️",
|
||||||
"hp_min": 30,
|
"hp_min": 30,
|
||||||
@@ -116,11 +116,11 @@
|
|||||||
"npc_id": "mutant_rat",
|
"npc_id": "mutant_rat",
|
||||||
"name": {
|
"name": {
|
||||||
"en": "Mutant Rat",
|
"en": "Mutant Rat",
|
||||||
"es": ""
|
"es": "Rata mutante"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
|
"en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.",
|
||||||
"es": ""
|
"es": "Una rata grotescamente grande, su pelaje es desgarrado y sus ojos brillan con luz unnatural."
|
||||||
},
|
},
|
||||||
"emoji": "🐀",
|
"emoji": "🐀",
|
||||||
"hp_min": 10,
|
"hp_min": 10,
|
||||||
@@ -160,11 +160,11 @@
|
|||||||
"npc_id": "infected_human",
|
"npc_id": "infected_human",
|
||||||
"name": {
|
"name": {
|
||||||
"en": "Infected Human",
|
"en": "Infected Human",
|
||||||
"es": ""
|
"es": "Humano infectado"
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
|
"en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.",
|
||||||
"es": ""
|
"es": "Una vez humano, ahora algo más. Sus movimientos son torpes y su piel muestra signos de infección avanzada."
|
||||||
},
|
},
|
||||||
"emoji": "🧟",
|
"emoji": "🧟",
|
||||||
"hp_min": 35,
|
"hp_min": 35,
|
||||||
|
|||||||
10
nginx.conf
10
nginx.conf
@@ -34,14 +34,18 @@ server {
|
|||||||
add_header Expires "0";
|
add_header Expires "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Manifest should be cached for a short time
|
# Manifest should never be cached
|
||||||
location /manifest.webmanifest {
|
location /manifest.webmanifest {
|
||||||
add_header Cache-Control "max-age=3600";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA fallback - all other requests go to index.html
|
# SPA fallback - all other requests go to index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#1a1a1a" />
|
<meta name="theme-color" content="#1a1a1a" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
<meta name="description" content="A post-apocalyptic survival RPG" />
|
<meta name="description" content="A post-apocalyptic survival RPG" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Echoes of the Ash</title>
|
<title>Echoes of the Ash</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Game.tsx - Main game orchestrator (refactored from 3,315 lines to ~350 lines)
|
// Game.tsx - Main game orchestrator (refactored from 3,315 lines to ~350 lines)
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { useGameEngine } from './game/hooks/useGameEngine'
|
import { useGameEngine } from './game/hooks/useGameEngine'
|
||||||
import Combat from './game/Combat'
|
import Combat from './game/Combat'
|
||||||
@@ -9,6 +10,7 @@ import PlayerSidebar from './game/PlayerSidebar'
|
|||||||
import './Game.css'
|
import './Game.css'
|
||||||
|
|
||||||
function Game() {
|
function Game() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
const [token] = useState(() => localStorage.getItem('token'))
|
const [token] = useState(() => localStorage.getItem('token'))
|
||||||
|
|
||||||
// Handle WebSocket messages
|
// Handle WebSocket messages
|
||||||
@@ -23,11 +25,29 @@ function Game() {
|
|||||||
case 'location_update':
|
case 'location_update':
|
||||||
// General location updates - update state directly from message data when possible
|
// General location updates - update state directly from message data when possible
|
||||||
console.log('🗺️ Location update:', message.data?.action, message.data?.message)
|
console.log('🗺️ Location update:', message.data?.action, message.data?.message)
|
||||||
if (message.data?.message) {
|
|
||||||
actions.addLocationMessage(message.data.message)
|
let displayMessage = message.data?.message
|
||||||
|
const action = message.data?.action
|
||||||
|
|
||||||
|
// Handle translations for specific actions
|
||||||
|
if (action === 'enemy_spawned' && message.data.npc_data) {
|
||||||
|
const npcData = message.data.npc_data
|
||||||
|
let npcName = npcData.name
|
||||||
|
if (typeof npcName === 'object' && npcName !== null) {
|
||||||
|
npcName = npcName[i18n.language] || npcName['en'] || npcName['es']
|
||||||
|
}
|
||||||
|
displayMessage = t('messages.enemyAppeared', { name: npcName })
|
||||||
|
} else if (action === 'enemy_despawned') {
|
||||||
|
displayMessage = t('messages.enemyDespawned')
|
||||||
|
} else if (action === 'corpses_decayed' && message.data.count) {
|
||||||
|
displayMessage = t('messages.corpsesDecayed', { count: message.data.count })
|
||||||
|
} else if (action === 'items_decayed' && message.data.count) {
|
||||||
|
displayMessage = t('messages.itemsDecayed', { count: message.data.count })
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = message.data?.action
|
if (displayMessage) {
|
||||||
|
actions.addLocationMessage(displayMessage)
|
||||||
|
}
|
||||||
if (action === 'player_arrived' && message.data.player_id) {
|
if (action === 'player_arrived' && message.data.player_id) {
|
||||||
// Add player to location directly without API call
|
// Add player to location directly without API call
|
||||||
actions.addPlayerToLocation({
|
actions.addPlayerToLocation({
|
||||||
@@ -326,6 +346,7 @@ function Game() {
|
|||||||
{/* Location view (when not in combat) */}
|
{/* Location view (when not in combat) */}
|
||||||
{!state.combatState && state.location && state.playerState && (
|
{!state.combatState && state.location && state.playerState && (
|
||||||
<LocationView
|
<LocationView
|
||||||
|
key={state.location.id}
|
||||||
location={state.location}
|
location={state.location}
|
||||||
playerState={state.playerState}
|
playerState={state.playerState}
|
||||||
combatState={state.combatState || null}
|
combatState={state.combatState || null}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ function LanguageSelector() {
|
|||||||
|
|
||||||
const changeLanguage = (langCode: string) => {
|
const changeLanguage = (langCode: string) => {
|
||||||
i18n.changeLanguage(langCode)
|
i18n.changeLanguage(langCode)
|
||||||
|
// Reload page to ensure all components refresh with new language
|
||||||
|
// This is necessary because some data comes from API and won't update without refetch
|
||||||
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentLang = languages.find(l => l.code === i18n.language) || languages[0]
|
const currentLang = languages.find(l => l.code === i18n.language) || languages[0]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import CombatView from './CombatView'
|
import CombatView from './CombatView'
|
||||||
import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
|
import { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
|
||||||
import api from '../../services/api'
|
import api from '../../services/api'
|
||||||
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
import './CombatEffects.css'
|
import './CombatEffects.css'
|
||||||
|
|
||||||
interface CombatProps {
|
interface CombatProps {
|
||||||
@@ -46,6 +47,35 @@ const Combat = ({
|
|||||||
|
|
||||||
// Turn timer state for PvE combat
|
// Turn timer state for PvE combat
|
||||||
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
|
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
|
||||||
|
const isMounted = useRef(true)
|
||||||
|
|
||||||
|
// Floating text ID counter to ensure unique IDs
|
||||||
|
const floatingTextIdCounter = useRef(0)
|
||||||
|
|
||||||
|
// Track all timeout IDs for cleanup
|
||||||
|
const floatingTextTimeouts = useRef<Set<NodeJS.Timeout>>(new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false
|
||||||
|
// Cancel all pending floating text timeouts to prevent DOM manipulation errors
|
||||||
|
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
|
||||||
|
floatingTextTimeouts.current.clear()
|
||||||
|
// Clear all floating texts on unmount to prevent DOM manipulation errors
|
||||||
|
setFloatingTexts([])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Clean up floating texts when combat ends
|
||||||
|
useEffect(() => {
|
||||||
|
if (combatState.combat_over) {
|
||||||
|
// Cancel all pending timeouts immediately when combat ends
|
||||||
|
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
|
||||||
|
floatingTextTimeouts.current.clear()
|
||||||
|
// Clear all floating texts
|
||||||
|
setFloatingTexts([])
|
||||||
|
}
|
||||||
|
}, [combatState.combat_over])
|
||||||
|
|
||||||
// PvP Timer Effect
|
// PvP Timer Effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,11 +140,17 @@ const Combat = ({
|
|||||||
}, [turnTimeRemaining, combatState, updateCombatState])
|
}, [turnTimeRemaining, combatState, updateCombatState])
|
||||||
|
|
||||||
const addFloatingText = (text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal') => {
|
const addFloatingText = (text: string, x: number, y: number, type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal') => {
|
||||||
const id = Date.now() + Math.random()
|
const id = ++floatingTextIdCounter.current
|
||||||
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
|
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
|
||||||
setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
if (isMounted.current) {
|
||||||
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
|
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
|
||||||
|
// Remove this timeout from the tracking set
|
||||||
|
floatingTextTimeouts.current.delete(timeout)
|
||||||
|
}
|
||||||
}, 2500)
|
}, 2500)
|
||||||
|
// Track this timeout for cleanup
|
||||||
|
floatingTextTimeouts.current.add(timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePvEAction = async (action: string) => {
|
const handlePvEAction = async (action: string) => {
|
||||||
@@ -130,38 +166,73 @@ const Combat = ({
|
|||||||
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
||||||
|
|
||||||
// Handle failed flee special case - split combined message
|
// Handle failed flee special case - split combined message
|
||||||
const processedMessages: string[] = []
|
const processedMessages: any[] = []
|
||||||
messages.forEach((msg: string) => {
|
messages.forEach((msg: string) => {
|
||||||
|
// Try to parse as JSON first (for structured messages)
|
||||||
|
try {
|
||||||
|
// Check if it looks like a JSON object before trying to parse
|
||||||
|
if (msg.trim().startsWith('{')) {
|
||||||
|
const parsed = JSON.parse(msg)
|
||||||
|
if (parsed.type && parsed.data) {
|
||||||
|
processedMessages.push(parsed) // Push object directly
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not valid JSON, treat as string
|
||||||
|
}
|
||||||
|
|
||||||
// Check if message contains both flee failure and enemy attack
|
// Check if message contains both flee failure and enemy attack
|
||||||
const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/)
|
const fleeFailMatch = msg.match(/^(Failed to flee!)\s+(.+)$/)
|
||||||
if (fleeFailMatch) {
|
if (fleeFailMatch) {
|
||||||
processedMessages.push(fleeFailMatch[1]) // "Failed to flee!"
|
processedMessages.push(fleeFailMatch[1]) // "Failed to flee!"
|
||||||
processedMessages.push(fleeFailMatch[2]) // Enemy attack message
|
|
||||||
|
// The second part might be a JSON string too
|
||||||
|
const secondPart = fleeFailMatch[2]
|
||||||
|
try {
|
||||||
|
if (secondPart.trim().startsWith('{')) {
|
||||||
|
const parsed = JSON.parse(secondPart)
|
||||||
|
if (parsed.type && parsed.data) {
|
||||||
|
processedMessages.push(parsed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
processedMessages.push(secondPart) // Enemy attack message (string fallback)
|
||||||
} else {
|
} else {
|
||||||
processedMessages.push(msg)
|
processedMessages.push(msg)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const playerMessages = processedMessages.filter((msg: string) =>
|
const playerMessages = processedMessages.filter((msg: any) => {
|
||||||
msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!'
|
if (typeof msg === 'object') {
|
||||||
)
|
return msg.type === 'player_attack' || msg.type === 'victory' || msg.type === 'combat_start'
|
||||||
const enemyMessages = processedMessages.filter((msg: string) =>
|
}
|
||||||
msg !== 'Failed to flee!' &&
|
return msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!'
|
||||||
|
})
|
||||||
|
|
||||||
|
const enemyMessages = processedMessages.filter((msg: any) => {
|
||||||
|
if (typeof msg === 'object') {
|
||||||
|
return msg.type === 'enemy_attack' || msg.type === 'flee_fail'
|
||||||
|
}
|
||||||
|
return msg !== 'Failed to flee!' &&
|
||||||
(msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
|
(msg.includes('attacks') || msg.includes('hits') || msg.includes('misses') || msg.includes('The '))
|
||||||
)
|
})
|
||||||
|
|
||||||
// Check if this is a failed flee attempt
|
// Check if this is a failed flee attempt
|
||||||
const isFailedFlee = playerMessages.some(msg => msg === 'Failed to flee!')
|
const isFailedFlee = playerMessages.some(msg => msg === 'Failed to flee!')
|
||||||
|
|
||||||
// 1. Immediate Player Feedback
|
// 1. Immediate Player Feedback
|
||||||
playerMessages.forEach((msg: string) => {
|
playerMessages.forEach((msg: any) => {
|
||||||
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true })
|
const logId = `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
|
||||||
|
|
||||||
// Only show attack animations for actual attacks, not flee failures
|
// Only show attack animations for actual attacks, not flee failures
|
||||||
if (msg !== 'Failed to flee!') {
|
if (msg !== 'Failed to flee!' && (typeof msg !== 'object' || msg.type === 'player_attack')) {
|
||||||
const damageMatch = msg.match(/(\d+) damage/)
|
const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1]
|
||||||
if (damageMatch) {
|
if (damage) {
|
||||||
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt') // White text on enemy
|
addFloatingText(damage.toString(), 50, 30, 'damage-player-dealt') // White text on enemy
|
||||||
setFlash(true)
|
setFlash(true)
|
||||||
setTimeout(() => setFlash(false), 300)
|
setTimeout(() => setFlash(false), 300)
|
||||||
}
|
}
|
||||||
@@ -193,12 +264,13 @@ const Combat = ({
|
|||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
|
|
||||||
enemyMessages.forEach((msg: string) => {
|
enemyMessages.forEach((msg: any) => {
|
||||||
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: false })
|
const logId = `enemy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: false })
|
||||||
|
|
||||||
const damageMatch = msg.match(/(\d+) damage/)
|
const damage = typeof msg === 'object' ? msg.data.damage : (msg.match(/(\d+) damage/) || [])[1]
|
||||||
if (damageMatch) {
|
if (damage) {
|
||||||
addFloatingText(damageMatch[1], 50, 50, 'damage-player') // Red text over player position
|
addFloatingText(damage.toString(), 50, 50, 'damage-player') // Red text over player position
|
||||||
setShake(true)
|
setShake(true)
|
||||||
setTimeout(() => setShake(false), 500)
|
setTimeout(() => setShake(false), 500)
|
||||||
}
|
}
|
||||||
@@ -293,7 +365,8 @@ const Combat = ({
|
|||||||
// Parse message for damage
|
// Parse message for damage
|
||||||
// Example: "You attacked X for 10 damage!"
|
// Example: "You attacked X for 10 damage!"
|
||||||
const msg = data.message || ''
|
const msg = data.message || ''
|
||||||
addCombatLogEntry({ time: timeStr, message: msg, isPlayer: true })
|
const logId = `pvp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
|
||||||
|
|
||||||
const damageMatch = msg.match(/(\d+) damage/)
|
const damageMatch = msg.match(/(\d+) damage/)
|
||||||
if (damageMatch) {
|
if (damageMatch) {
|
||||||
@@ -324,7 +397,7 @@ const Combat = ({
|
|||||||
health: tempPlayerHP
|
health: tempPlayerHP
|
||||||
} : playerState}
|
} : playerState}
|
||||||
equipment={equipment}
|
equipment={equipment}
|
||||||
enemyName={combatState.combat?.npc_name || 'Enemy'}
|
enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'}
|
||||||
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
|
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
|
||||||
enemyTurnMessage={localEnemyTurnMessage}
|
enemyTurnMessage={localEnemyTurnMessage}
|
||||||
pvpTimeRemaining={pvpTimer}
|
pvpTimeRemaining={pvpTimer}
|
||||||
|
|||||||
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,
|
onUnequipItem,
|
||||||
onDropItem
|
onDropItem
|
||||||
}: InventoryModalProps) {
|
}: InventoryModalProps) {
|
||||||
useTranslation()
|
const { t } = useTranslation()
|
||||||
// Categories for the sidebar
|
// Categories for the sidebar
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'all', label: 'All Items', icon: '🎒' },
|
{ id: 'all', label: t('categories.all'), icon: '🎒' },
|
||||||
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
|
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
|
||||||
{ id: 'armor', label: 'Armor', icon: '🛡️' },
|
{ id: 'armor', label: t('categories.armor'), icon: '🛡️' },
|
||||||
{ id: 'clothing', label: 'Clothing', icon: '👕' },
|
{ id: 'clothing', label: t('categories.clothing'), icon: '👕' },
|
||||||
{ id: 'backpack', label: 'Backpacks', icon: '🎒' },
|
{ id: 'backpack', label: t('categories.backpack'), icon: '🎒' },
|
||||||
{ id: 'tool', label: 'Tools', icon: '🛠️' },
|
{ id: 'tool', label: t('categories.tool'), icon: '🛠️' },
|
||||||
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
|
{ id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
|
||||||
{ id: 'resource', label: 'Resources', icon: '📦' },
|
{ id: 'resource', label: t('categories.resource'), icon: '📦' },
|
||||||
{ id: 'quest', label: 'Quest', icon: '📜' },
|
{ id: 'quest', label: t('categories.quest'), icon: '📜' },
|
||||||
{ id: 'misc', label: 'Misc', icon: '📦' }
|
{ id: 'misc', label: t('categories.misc'), icon: '📦' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Use inventory directly as it now includes equipped items
|
// Use inventory directly as it now includes equipped items
|
||||||
@@ -100,7 +100,7 @@ function InventoryModal({
|
|||||||
<div className="item-header-compact">
|
<div className="item-header-compact">
|
||||||
<span className="item-emoji-inline">{item.emoji}</span>
|
<span className="item-emoji-inline">{item.emoji}</span>
|
||||||
<h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{getTranslatedText(item.name)}</h4>
|
<h4 className={`item-name-compact text-tier-${item.tier || 0}`}>{getTranslatedText(item.name)}</h4>
|
||||||
{item.is_equipped && <span className="item-card-equipped">Equipped</span>}
|
{item.is_equipped && <span className="item-card-equipped">{t('game.equipped')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="item-stats-row">
|
<div className="item-stats-row">
|
||||||
@@ -149,17 +149,17 @@ function InventoryModal({
|
|||||||
)}
|
)}
|
||||||
{(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && (
|
{(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && (
|
||||||
<span className="stat-badge penetration">
|
<span className="stat-badge penetration">
|
||||||
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} Pen
|
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
|
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
|
||||||
<span className="stat-badge crit">
|
<span className="stat-badge crit">
|
||||||
🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% Crit
|
🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
|
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
|
||||||
<span className="stat-badge accuracy">
|
<span className="stat-badge accuracy">
|
||||||
👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% Acc
|
👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
|
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
|
||||||
@@ -169,34 +169,34 @@ function InventoryModal({
|
|||||||
)}
|
)}
|
||||||
{(item.unique_stats?.lifesteal || item.stats?.lifesteal) && (
|
{(item.unique_stats?.lifesteal || item.stats?.lifesteal) && (
|
||||||
<span className="stat-badge lifesteal">
|
<span className="stat-badge lifesteal">
|
||||||
🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% Life
|
🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attributes */}
|
{/* Attributes */}
|
||||||
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
|
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
|
||||||
<span className="stat-badge strength">
|
<span className="stat-badge strength">
|
||||||
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} STR
|
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
|
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
|
||||||
<span className="stat-badge agility">
|
<span className="stat-badge agility">
|
||||||
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} AGI
|
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
|
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
|
||||||
<span className="stat-badge endurance">
|
<span className="stat-badge endurance">
|
||||||
🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} END
|
🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
|
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
|
||||||
<span className="stat-badge health">
|
<span className="stat-badge health">
|
||||||
❤️ +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} HP max
|
❤️ +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} {t('stats.hpMax')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
|
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
|
||||||
<span className="stat-badge stamina">
|
<span className="stat-badge stamina">
|
||||||
⚡ +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} Stm max
|
⚡ +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ function InventoryModal({
|
|||||||
{hasDurability && (
|
{hasDurability && (
|
||||||
<div className="durability-container">
|
<div className="durability-container">
|
||||||
<div className="durability-header">
|
<div className="durability-header">
|
||||||
<span>Durability</span>
|
<span>{t('game.durability')}</span>
|
||||||
<span className={
|
<span className={
|
||||||
currentDurability < maxDurability * 0.2
|
currentDurability < maxDurability * 0.2
|
||||||
? "durability-text-low"
|
? "durability-text-low"
|
||||||
@@ -246,18 +246,18 @@ function InventoryModal({
|
|||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
<div className="item-actions-section">
|
<div className="item-actions-section">
|
||||||
{item.consumable && (
|
{item.consumable && (
|
||||||
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>Use</button>
|
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>{t('game.use')}</button>
|
||||||
)}
|
)}
|
||||||
{item.equippable && !item.is_equipped && (
|
{item.equippable && !item.is_equipped && (
|
||||||
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>Equip</button>
|
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>{t('game.equip')}</button>
|
||||||
)}
|
)}
|
||||||
{item.is_equipped && (
|
{item.is_equipped && (
|
||||||
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>Unequip</button>
|
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>{t('game.unequip')}</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="drop-actions-group">
|
<div className="drop-actions-group">
|
||||||
{item.quantity > 1 && (
|
{item.quantity > 1 && (
|
||||||
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 1)}>x1</button>
|
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, item.quantity)}>{t('game.dropAll')}</button>
|
||||||
)}
|
)}
|
||||||
{item.quantity >= 5 && (
|
{item.quantity >= 5 && (
|
||||||
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
|
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
|
||||||
@@ -266,7 +266,7 @@ function InventoryModal({
|
|||||||
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button>
|
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button>
|
||||||
)}
|
)}
|
||||||
<button className={`action-btn drop ${item.quantity === 1 ? 'single' : ''}`} onClick={() => onDropItem(item.item_id, item.id, item.quantity === 1 ? 1 : item.quantity)}>
|
<button className={`action-btn drop ${item.quantity === 1 ? 'single' : ''}`} onClick={() => onDropItem(item.item_id, item.id, item.quantity === 1 ? 1 : item.quantity)}>
|
||||||
{item.quantity === 1 ? 'Drop' : 'All'}
|
{item.quantity === 1 ? t('game.drop') : t('game.dropAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,7 +288,7 @@ function InventoryModal({
|
|||||||
<span className="metric-icon">⚖️</span>
|
<span className="metric-icon">⚖️</span>
|
||||||
<div className="metric-bar-container">
|
<div className="metric-bar-container">
|
||||||
<div className="metric-text">
|
<div className="metric-text">
|
||||||
Weight: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
|
{t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
|
||||||
</div>
|
</div>
|
||||||
<div className="metric-bar">
|
<div className="metric-bar">
|
||||||
<div
|
<div
|
||||||
@@ -303,7 +303,7 @@ function InventoryModal({
|
|||||||
<span className="metric-icon">📦</span>
|
<span className="metric-icon">📦</span>
|
||||||
<div className="metric-bar-container">
|
<div className="metric-bar-container">
|
||||||
<div className="metric-text">
|
<div className="metric-text">
|
||||||
Volume: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
|
{t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
|
||||||
</div>
|
</div>
|
||||||
<div className="metric-bar">
|
<div className="metric-bar">
|
||||||
<div
|
<div
|
||||||
@@ -328,7 +328,7 @@ function InventoryModal({
|
|||||||
) : (
|
) : (
|
||||||
<div className="backpack-status inactive">
|
<div className="backpack-status inactive">
|
||||||
<span className="backpack-icon">🚫</span>
|
<span className="backpack-icon">🚫</span>
|
||||||
<span>No Backpack Equipped</span>
|
<span>{t('game.noBackpack')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button className="close-btn" onClick={onClose}>✕</button>
|
<button className="close-btn" onClick={onClose}>✕</button>
|
||||||
@@ -356,7 +356,7 @@ function InventoryModal({
|
|||||||
<span className="search-icon">🔍</span>
|
<span className="search-icon">🔍</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search items..."
|
placeholder={t('game.searchItems')}
|
||||||
value={inventoryFilter}
|
value={inventoryFilter}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -366,32 +366,29 @@ function InventoryModal({
|
|||||||
{filteredItems.length === 0 ? (
|
{filteredItems.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<span className="empty-icon">📦</span>
|
<span className="empty-icon">📦</span>
|
||||||
<p>No items found in this category</p>
|
<p>{t('game.noItemsFound')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
inventoryCategoryFilter === 'all' ? (
|
inventoryCategoryFilter === 'all' ? (
|
||||||
<>
|
<>
|
||||||
{/* Equipped */}
|
{/* Equipped */}
|
||||||
{filteredItems.some((i: any) => i.is_equipped) && (
|
{filteredItems.some((item: any) => item.is_equipped) && (
|
||||||
<>
|
<>
|
||||||
<div className="category-header">⚔️ Equipped</div>
|
<div className="category-header">⚔️ {t('game.equipped')}</div>
|
||||||
{filteredItems.filter((i: any) => i.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
|
{filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Backpack */}
|
||||||
{categories.filter(c => c.id !== 'all').map(cat => {
|
{filteredItems.some((item: any) => !item.is_equipped) && (
|
||||||
const categoryItems = filteredItems.filter((i: any) => !i.is_equipped && i.type === cat.id);
|
<>
|
||||||
if (categoryItems.length === 0) return null;
|
<div className="category-header">🎒 {t('game.backpack')}</div>
|
||||||
return (
|
{filteredItems.filter((item: any) => !item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
|
||||||
<div key={cat.id}>
|
</>
|
||||||
<div className="category-header">{cat.icon} {cat.label}</div>
|
)}
|
||||||
{categoryItems.map((item: any, i: number) => renderItemCard(item, i))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
/* Single category */
|
||||||
filteredItems.map((item: any, i: number) => renderItemCard(item, i))
|
filteredItems.map((item: any, i: number) => renderItemCard(item, i))
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function LocationView({
|
|||||||
onRepair,
|
onRepair,
|
||||||
onUncraft
|
onUncraft
|
||||||
}: LocationViewProps) {
|
}: LocationViewProps) {
|
||||||
useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<div className="location-view">
|
<div className="location-view">
|
||||||
<div className="location-info">
|
<div className="location-info">
|
||||||
@@ -115,15 +115,15 @@ function LocationView({
|
|||||||
onClick={isClickable ? handleClick : undefined}
|
onClick={isClickable ? handleClick : undefined}
|
||||||
style={isClickable ? { cursor: 'pointer' } : undefined}
|
style={isClickable ? { cursor: 'pointer' } : undefined}
|
||||||
>
|
>
|
||||||
{tag === 'workbench' && '🔧 Workbench'}
|
{tag === 'workbench' && t('tags.workbench')}
|
||||||
{tag === 'repair_station' && '🛠️ Repair Station'}
|
{tag === 'repair_station' && t('tags.repairStation')}
|
||||||
{tag === 'safe_zone' && '🛡️ Safe Zone'}
|
{tag === 'safe_zone' && t('tags.safeZone')}
|
||||||
{tag === 'shop' && '🏪 Shop'}
|
{tag === 'shop' && t('tags.shop')}
|
||||||
{tag === 'shelter' && '🏠 Shelter'}
|
{tag === 'shelter' && t('tags.shelter')}
|
||||||
{tag === 'medical' && '⚕️ Medical'}
|
{tag === 'medical' && t('tags.medical')}
|
||||||
{tag === 'storage' && '📦 Storage'}
|
{tag === 'storage' && t('tags.storage')}
|
||||||
{tag === 'water_source' && '💧 Water'}
|
{tag === 'water_source' && t('tags.water')}
|
||||||
{tag === 'food_source' && '🍎 Food'}
|
{tag === 'food_source' && t('tags.food')}
|
||||||
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
|
{tag !== 'workbench' && tag !== 'repair_station' && tag !== 'safe_zone' && tag !== 'shop' && tag !== 'shelter' && tag !== 'medical' && tag !== 'storage' && tag !== 'water_source' && tag !== 'food_source' && `🏷️ ${tag}`}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -157,7 +157,7 @@ function LocationView({
|
|||||||
|
|
||||||
{locationMessages.length > 0 && (
|
{locationMessages.length > 0 && (
|
||||||
<div className="location-messages-log">
|
<div className="location-messages-log">
|
||||||
<h4>📜 Recent Activity</h4>
|
<h4>{t('location.recentActivity')}</h4>
|
||||||
<div className="messages-scroll">
|
<div className="messages-scroll">
|
||||||
{locationMessages.slice(-10).reverse().map((msg, idx) => (
|
{locationMessages.slice(-10).reverse().map((msg, idx) => (
|
||||||
<div key={idx} className="location-message-item">
|
<div key={idx} className="location-message-item">
|
||||||
@@ -173,7 +173,7 @@ function LocationView({
|
|||||||
{/* Enemies */}
|
{/* Enemies */}
|
||||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
|
{location.npcs.filter((npc: any) => npc.type === 'enemy').length > 0 && (
|
||||||
<div className="entity-section enemies-section">
|
<div className="entity-section enemies-section">
|
||||||
<h3>⚔️ Enemies</h3>
|
<h3>{t('location.enemies')}</h3>
|
||||||
<div className="entity-list">
|
<div className="entity-list">
|
||||||
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
|
{location.npcs.filter((npc: any) => npc.type === 'enemy').map((enemy: any, i: number) => (
|
||||||
<div key={i} className="entity-card enemy-card">
|
<div key={i} className="entity-card enemy-card">
|
||||||
@@ -188,13 +188,13 @@ function LocationView({
|
|||||||
)}
|
)}
|
||||||
<div className="entity-info">
|
<div className="entity-info">
|
||||||
<div className="entity-name enemy-name">{getTranslatedText(enemy.name)}</div>
|
<div className="entity-name enemy-name">{getTranslatedText(enemy.name)}</div>
|
||||||
{enemy.level && <div className="entity-level">Lv. {enemy.level}</div>}
|
{enemy.level && <div className="entity-level">{t('location.level')} {enemy.level}</div>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="entity-action-btn combat-btn"
|
className="entity-action-btn combat-btn"
|
||||||
onClick={() => onInitiateCombat(enemy.id)}
|
onClick={() => onInitiateCombat(enemy.id)}
|
||||||
>
|
>
|
||||||
⚔️ Fight
|
{t('common.fight')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -205,28 +205,28 @@ function LocationView({
|
|||||||
{/* Corpses */}
|
{/* Corpses */}
|
||||||
{location.corpses && location.corpses.length > 0 && (
|
{location.corpses && location.corpses.length > 0 && (
|
||||||
<div className="entity-section corpses-section">
|
<div className="entity-section corpses-section">
|
||||||
<h3>💀 Corpses</h3>
|
<h3>{t('location.corpses')}</h3>
|
||||||
<div className="entity-list">
|
<div className="entity-list">
|
||||||
{location.corpses.map((corpse: any) => (
|
{location.corpses.map((corpse: any) => (
|
||||||
<div key={corpse.id} className="corpse-container">
|
<div key={corpse.id} className="corpse-container">
|
||||||
<div className="entity-card corpse-card">
|
<div className="entity-card corpse-card">
|
||||||
<div className="entity-info">
|
<div className="entity-info">
|
||||||
<div className="entity-name">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
|
<div className="entity-name">{corpse.emoji} {getTranslatedText(corpse.name)}</div>
|
||||||
<div className="corpse-loot-count">{corpse.loot_count} item(s)</div>
|
<div className="corpse-loot-count">{corpse.loot_count} {t('location.items')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="entity-action-btn loot-btn"
|
className="entity-action-btn loot-btn"
|
||||||
onClick={() => onLootCorpse(String(corpse.id))}
|
onClick={() => onLootCorpse(String(corpse.id))}
|
||||||
disabled={corpse.loot_count === 0}
|
disabled={corpse.loot_count === 0}
|
||||||
>
|
>
|
||||||
🔍 Examine
|
🔍 {t('common.examine')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
|
{expandedCorpse === String(corpse.id) && corpseDetails && corpseDetails.loot_items && (
|
||||||
<div className="corpse-details">
|
<div className="corpse-details">
|
||||||
<div className="corpse-details-header">
|
<div className="corpse-details-header">
|
||||||
<h4>Lootable Items:</h4>
|
<h4>{t('location.lootableItems')}</h4>
|
||||||
<button
|
<button
|
||||||
className="close-btn"
|
className="close-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -244,7 +244,7 @@ function LocationView({
|
|||||||
{item.emoji} {getTranslatedText(item.item_name)}
|
{item.emoji} {getTranslatedText(item.item_name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="corpse-item-qty">
|
<div className="corpse-item-qty">
|
||||||
Qty: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
|
{t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
|
||||||
</div>
|
</div>
|
||||||
{item.required_tool && (
|
{item.required_tool && (
|
||||||
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
|
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
|
||||||
@@ -258,7 +258,7 @@ function LocationView({
|
|||||||
disabled={!item.can_loot}
|
disabled={!item.can_loot}
|
||||||
title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}
|
title={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}
|
||||||
>
|
>
|
||||||
{item.can_loot ? '📦 Loot' : '🔒'}
|
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -267,7 +267,7 @@ function LocationView({
|
|||||||
className="loot-all-btn"
|
className="loot-all-btn"
|
||||||
onClick={() => onLootCorpseItem(String(corpse.id), null)}
|
onClick={() => onLootCorpseItem(String(corpse.id), null)}
|
||||||
>
|
>
|
||||||
📦 Loot All Available
|
📦 {t('common.lootAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -280,16 +280,16 @@ function LocationView({
|
|||||||
{/* Friendly NPCs */}
|
{/* Friendly NPCs */}
|
||||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
|
{location.npcs.filter((npc: any) => npc.type !== 'enemy').length > 0 && (
|
||||||
<div className="entity-section npcs-section">
|
<div className="entity-section npcs-section">
|
||||||
<h3>👥 NPCs</h3>
|
<h3>{t('location.npcs')}</h3>
|
||||||
<div className="entity-list">
|
<div className="entity-list">
|
||||||
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
{location.npcs.filter((npc: any) => npc.type !== 'enemy').map((npc: any, i: number) => (
|
||||||
<div key={i} className="entity-card npc-card">
|
<div key={i} className="entity-card npc-card">
|
||||||
<span className="entity-icon">🧑</span>
|
<span className="entity-icon">🧑</span>
|
||||||
<div className="entity-info">
|
<div className="entity-info">
|
||||||
<div className="entity-name">{getTranslatedText(npc.name)}</div>
|
<div className="entity-name">{getTranslatedText(npc.name)}</div>
|
||||||
{npc.level && <div className="entity-level">Lv. {npc.level}</div>}
|
{npc.level && <div className="entity-level">{t('location.level')} {npc.level}</div>}
|
||||||
</div>
|
</div>
|
||||||
<button className="entity-action-btn">Talk</button>
|
<button className="entity-action-btn">{t('common.talk')}</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +299,7 @@ function LocationView({
|
|||||||
{/* Items on Ground */}
|
{/* Items on Ground */}
|
||||||
{location.items.length > 0 && (
|
{location.items.length > 0 && (
|
||||||
<div className="entity-section items-section">
|
<div className="entity-section items-section">
|
||||||
<h3>📦 Items on Ground</h3>
|
<h3>{t('location.itemsOnGround')}</h3>
|
||||||
<div className="entity-list">
|
<div className="entity-list">
|
||||||
{location.items.map((item: any, i: number) => (
|
{location.items.map((item: any, i: number) => (
|
||||||
<div key={i} className="entity-card item-card">
|
<div key={i} className="entity-card item-card">
|
||||||
@@ -323,37 +323,37 @@ function LocationView({
|
|||||||
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
|
{item.quantity > 1 && <div className="entity-quantity">×{item.quantity}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="item-info-btn-container">
|
<div className="item-info-btn-container">
|
||||||
<button className="entity-action-btn info" title="Item Info">Info</button>
|
<button className="entity-action-btn info" title="Item Info">{t('common.info')}</button>
|
||||||
<div className="item-info-tooltip">
|
<div className="item-info-tooltip">
|
||||||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
||||||
{item.weight !== undefined && item.weight > 0 && (
|
{item.weight !== undefined && item.weight > 0 && (
|
||||||
<div className="item-tooltip-stat">
|
<div className="item-tooltip-stat">
|
||||||
⚖️ Weight: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
|
⚖️ {t('stats.weight')}: {item.weight}kg {item.quantity > 1 && `(Total: ${(item.weight * item.quantity).toFixed(2)}kg)`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.volume !== undefined && item.volume > 0 && (
|
{item.volume !== undefined && item.volume > 0 && (
|
||||||
<div className="item-tooltip-stat">
|
<div className="item-tooltip-stat">
|
||||||
📦 Volume: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
|
📦 {t('stats.volume')}: {item.volume}L {item.quantity > 1 && `(Total: ${(item.volume * item.quantity).toFixed(2)}L)`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.hp_restore && item.hp_restore > 0 && (
|
{item.hp_restore && item.hp_restore > 0 && (
|
||||||
<div className="item-tooltip-stat">❤️ HP Restore: +{item.hp_restore}</div>
|
<div className="item-tooltip-stat">❤️ {t('stats.hpRestore')}: +{item.hp_restore}</div>
|
||||||
)}
|
)}
|
||||||
{item.stamina_restore && item.stamina_restore > 0 && (
|
{item.stamina_restore && item.stamina_restore > 0 && (
|
||||||
<div className="item-tooltip-stat">⚡ Stamina Restore: +{item.stamina_restore}</div>
|
<div className="item-tooltip-stat">⚡ {t('stats.staminaRestore')}: +{item.stamina_restore}</div>
|
||||||
)}
|
)}
|
||||||
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
|
{item.damage_min !== undefined && item.damage_max !== undefined && (item.damage_min > 0 || item.damage_max > 0) && (
|
||||||
<div className="item-tooltip-stat">
|
<div className="item-tooltip-stat">
|
||||||
⚔️ Damage: {item.damage_min}-{item.damage_max}
|
⚔️ {t('stats.damage')}: {item.damage_min}-{item.damage_max}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
|
{item.durability !== undefined && item.durability !== null && item.max_durability !== undefined && item.max_durability !== null && (
|
||||||
<div className="item-tooltip-stat">
|
<div className="item-tooltip-stat">
|
||||||
🔧 Durability: {item.durability}/{item.max_durability}
|
🔧 {t('stats.durability')}: {item.durability}/{item.max_durability}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||||||
<div className="item-tooltip-stat">⭐ Tier: {item.tier}</div>
|
<div className="item-tooltip-stat">⭐ {t('stats.tier')}: {item.tier}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,21 +362,21 @@ function LocationView({
|
|||||||
className="entity-action-btn pickup"
|
className="entity-action-btn pickup"
|
||||||
onClick={() => onPickup(item.id, 1)}
|
onClick={() => onPickup(item.id, 1)}
|
||||||
>
|
>
|
||||||
Pick Up
|
{t('common.pickUp')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="item-pickup-btn-container">
|
<div className="item-pickup-btn-container">
|
||||||
<button className="entity-action-btn pickup">Pick Up ▼</button>
|
<button className="entity-action-btn pickup">{t('common.pickUp')} ▼</button>
|
||||||
<div className="item-pickup-menu">
|
<div className="item-pickup-menu">
|
||||||
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>Pick Up 1</button>
|
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>{t('common.pickUp')} 1</button>
|
||||||
{item.quantity >= 5 && (
|
{item.quantity >= 5 && (
|
||||||
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>Pick Up 5</button>
|
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>{t('common.pickUp')} 5</button>
|
||||||
)}
|
)}
|
||||||
{item.quantity >= 10 && (
|
{item.quantity >= 10 && (
|
||||||
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>Pick Up 10</button>
|
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>{t('common.pickUp')} 10</button>
|
||||||
)}
|
)}
|
||||||
<button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}>
|
<button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}>
|
||||||
Pick Up All ({item.quantity})
|
{t('common.pickUpAll')} ({item.quantity})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Location, Profile, CombatState } from './types'
|
import type { Location, Profile, CombatState } from './types'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ function MovementControls({
|
|||||||
onMove,
|
onMove,
|
||||||
onInteract
|
onInteract
|
||||||
}: MovementControlsProps) {
|
}: MovementControlsProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
// Force re-render every second to update cooldown timers
|
// Force re-render every second to update cooldown timers
|
||||||
const [, forceUpdate] = useState(0)
|
const [, forceUpdate] = useState(0)
|
||||||
|
|
||||||
@@ -71,23 +73,24 @@ function MovementControls({
|
|||||||
const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false)
|
const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false)
|
||||||
|
|
||||||
// Build detailed tooltip text
|
// Build detailed tooltip text
|
||||||
const tooltipText = profile?.is_dead ? 'You are dead' :
|
const tooltipText = profile?.is_dead ? t('messages.youAreDead') :
|
||||||
movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` :
|
movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
|
||||||
combatState ? 'Cannot travel during combat' :
|
combatState ? t('messages.cannotTravelCombat') :
|
||||||
insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` :
|
insufficientStamina ? t('messages.notEnoughStamina', { need: stamina, have: profile?.stamina ?? 0 }) :
|
||||||
available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` :
|
available ? `${destination}\n${t('game.distance')}: ${distance}m\n${t('game.stamina')}: ${stamina}` :
|
||||||
`Cannot go ${direction}`
|
t('messages.cannotGo', { direction: t('directions.' + direction) })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
key={direction}
|
||||||
|
className={`compass-btn ${className} ${disabled ? 'disabled' : ''} ${combatState ? 'in-combat' : ''}`}
|
||||||
onClick={() => onMove(direction)}
|
onClick={() => onMove(direction)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`compass-btn ${className} ${disabled ? 'disabled' : ''}`}
|
|
||||||
title={tooltipText}
|
title={tooltipText}
|
||||||
>
|
>
|
||||||
<span className="compass-arrow">{arrow}</span>
|
<span className="compass-arrow">{arrow}</span>
|
||||||
{available && movementCooldown > 0 ? (
|
{available && movementCooldown > 0 ? (
|
||||||
<span className="compass-cost">⏳{movementCooldown}s</span>
|
<span className="compass-cost" title={t('messages.waitBeforeMoving', { seconds: movementCooldown })}>⏳{movementCooldown}s</span>
|
||||||
) : available && (
|
) : available && (
|
||||||
<span className="compass-cost">⚡{stamina}</span>
|
<span className="compass-cost">⚡{stamina}</span>
|
||||||
)}
|
)}
|
||||||
@@ -98,7 +101,7 @@ function MovementControls({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="movement-controls">
|
<div className="movement-controls">
|
||||||
<h3>🧭 Travel</h3>
|
<h3>{t('game.travel')}</h3>
|
||||||
<div className="compass-grid">
|
<div className="compass-grid">
|
||||||
{/* Top row */}
|
{/* Top row */}
|
||||||
{renderCompassButton('northwest', '↖️', 'nw')}
|
{renderCompassButton('northwest', '↖️', 'nw')}
|
||||||
@@ -121,7 +124,7 @@ function MovementControls({
|
|||||||
{/* Cooldown indicator */}
|
{/* Cooldown indicator */}
|
||||||
{movementCooldown > 0 && (
|
{movementCooldown > 0 && (
|
||||||
<div className="cooldown-indicator">
|
<div className="cooldown-indicator">
|
||||||
⏳ Wait {movementCooldown}s before moving
|
⏳ {t('messages.waitBeforeMovingSimple', { seconds: movementCooldown })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -132,9 +135,9 @@ function MovementControls({
|
|||||||
onClick={() => onMove('up')}
|
onClick={() => onMove('up')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go up\nStamina: ${getStaminaCost('up')}`}
|
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.up')}\n${t('game.stamina')}: ${getStaminaCost('up')}`}
|
||||||
>
|
>
|
||||||
⬆️ Up <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('up')}`}</span>
|
⬆️ {t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('up')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('down') && (
|
{location.directions.includes('down') && (
|
||||||
@@ -142,9 +145,9 @@ function MovementControls({
|
|||||||
onClick={() => onMove('down')}
|
onClick={() => onMove('down')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go down\nStamina: ${getStaminaCost('down')}`}
|
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.down')}\n${t('game.stamina')}: ${getStaminaCost('down')}`}
|
||||||
>
|
>
|
||||||
⬇️ Down <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`}</span>
|
⬇️ {t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('enter') && (
|
{location.directions.includes('enter') && (
|
||||||
@@ -152,9 +155,9 @@ function MovementControls({
|
|||||||
onClick={() => onMove('enter')}
|
onClick={() => onMove('enter')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Enter\nStamina: ${getStaminaCost('enter')}`}
|
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.enter')}\n${t('game.stamina')}: ${getStaminaCost('enter')}`}
|
||||||
>
|
>
|
||||||
🚪 Enter <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('enter')}`}</span>
|
🚪 {t('directions.enter')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('enter')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('inside') && (
|
{location.directions.includes('inside') && (
|
||||||
@@ -162,9 +165,9 @@ function MovementControls({
|
|||||||
onClick={() => onMove('inside')}
|
onClick={() => onMove('inside')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go inside\nStamina: ${getStaminaCost('inside')}`}
|
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.inside')}\n${t('game.stamina')}: ${getStaminaCost('inside')}`}
|
||||||
>
|
>
|
||||||
🚪 Inside <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('inside')}`}</span>
|
🚪 {t('directions.inside')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('inside')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('exit') && (
|
{location.directions.includes('exit') && (
|
||||||
@@ -172,9 +175,9 @@ function MovementControls({
|
|||||||
onClick={() => onMove('exit')}
|
onClick={() => onMove('exit')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : 'Exit'}
|
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : t('directions.exit')}
|
||||||
>
|
>
|
||||||
🚪 Exit
|
🚪 {t('directions.exit')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{location.directions.includes('outside') && (
|
{location.directions.includes('outside') && (
|
||||||
@@ -182,9 +185,9 @@ function MovementControls({
|
|||||||
onClick={() => onMove('outside')}
|
onClick={() => onMove('outside')}
|
||||||
className="special-btn"
|
className="special-btn"
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
disabled={!!combatState || movementCooldown > 0}
|
||||||
title={movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : combatState ? 'Cannot travel during combat' : `Go outside\nStamina: ${getStaminaCost('outside')}`}
|
title={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : `${t('directions.outside')}\n${t('game.stamina')}: ${getStaminaCost('outside')}`}
|
||||||
>
|
>
|
||||||
🚪 Outside <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('outside')}`}</span>
|
🚪 {t('directions.outside')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('outside')}`}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +196,7 @@ function MovementControls({
|
|||||||
{/* Surroundings - outside movement controls */}
|
{/* Surroundings - outside movement controls */}
|
||||||
{location.interactables && location.interactables.length > 0 && (
|
{location.interactables && location.interactables.length > 0 && (
|
||||||
<div className="interactables-section">
|
<div className="interactables-section">
|
||||||
<h3>🌿 Surroundings</h3>
|
<h3>{t('game.surroundings')}</h3>
|
||||||
{location.interactables.map((interactable: any) => (
|
{location.interactables.map((interactable: any) => (
|
||||||
<div key={interactable.instance_id} className="interactable-card">
|
<div key={interactable.instance_id} className="interactable-card">
|
||||||
{interactable.image_path && (
|
{interactable.image_path && (
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function Workbench({
|
|||||||
onRepair,
|
onRepair,
|
||||||
onUncraft
|
onUncraft
|
||||||
}: WorkbenchProps) {
|
}: WorkbenchProps) {
|
||||||
useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [selectedItem, setSelectedItem] = useState<any>(null)
|
const [selectedItem, setSelectedItem] = useState<any>(null)
|
||||||
|
|
||||||
@@ -116,8 +116,8 @@ function Workbench({
|
|||||||
return (
|
return (
|
||||||
<div className="workbench-empty-state">
|
<div className="workbench-empty-state">
|
||||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🔧</div>
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🔧</div>
|
||||||
<h3>Select an item to view details</h3>
|
<h3>{t('crafting.selectItem')}</h3>
|
||||||
<p>Choose an item from the list on the left</p>
|
<p>{t('crafting.chooseFromList')}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -155,13 +155,13 @@ function Workbench({
|
|||||||
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
|
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
{Object.entries(item.base_stats || item.stats).map(([key, value]) => {
|
{Object.entries(item.base_stats || item.stats).map(([key, value]) => {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
weight_capacity: '⚖️ Weight',
|
weight_capacity: `⚖️ ${t('game.weight')}`,
|
||||||
volume_capacity: '📦 Volume',
|
volume_capacity: `📦 ${t('game.volume')}`,
|
||||||
armor: '🛡️ Armor',
|
armor: `🛡️ ${t('stats.armor')}`,
|
||||||
hp_max: '❤️ Max HP',
|
hp_max: `❤️ ${t('stats.maxHp')}`,
|
||||||
stamina_max: '⚡ Max Stamina',
|
stamina_max: `⚡ ${t('stats.maxStamina')}`,
|
||||||
damage_min: '⚔️ Damage Min',
|
damage_min: `⚔️ ${t('stats.damage')} Min`,
|
||||||
damage_max: '⚔️ Damage Max'
|
damage_max: `⚔️ ${t('stats.damage')} Max`
|
||||||
}
|
}
|
||||||
const label = icons[key] || key.replace('_', ' ')
|
const label = icons[key] || key.replace('_', ' ')
|
||||||
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
|
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
|
||||||
@@ -173,7 +173,7 @@ function Workbench({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}>
|
<p style={{ fontSize: '0.8rem', color: '#888', fontStyle: 'italic', marginTop: '0.5rem', textAlign: 'center' }}>
|
||||||
* Potential base stats. Actual stats may vary.
|
* {t('crafting.potentialBaseStats')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,13 +183,13 @@ function Workbench({
|
|||||||
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}>
|
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1rem', flexWrap: 'wrap' }}>
|
||||||
{Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
|
{Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
weight_capacity: '⚖️ Weight',
|
weight_capacity: `⚖️ ${t('game.weight')}`,
|
||||||
volume_capacity: '📦 Volume',
|
volume_capacity: `📦 ${t('game.volume')}`,
|
||||||
armor: '🛡️ Armor',
|
armor: `🛡️ ${t('stats.armor')}`,
|
||||||
hp_max: '❤️ Max HP',
|
hp_max: `❤️ ${t('stats.maxHp')}`,
|
||||||
stamina_max: '⚡ Max Stamina',
|
stamina_max: `⚡ ${t('stats.maxStamina')}`,
|
||||||
damage_min: '⚔️ Damage Min',
|
damage_min: `⚔️ ${t('stats.damage')} Min`,
|
||||||
damage_max: '⚔️ Damage Max'
|
damage_max: `⚔️ ${t('stats.damage')} Max`
|
||||||
}
|
}
|
||||||
const label = icons[key] || key.replace('_', ' ')
|
const label = icons[key] || key.replace('_', ' ')
|
||||||
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
|
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
|
||||||
@@ -206,30 +206,28 @@ function Workbench({
|
|||||||
{workbenchTab === 'craft' && (
|
{workbenchTab === 'craft' && (
|
||||||
<>
|
<>
|
||||||
<div className="detail-requirements">
|
<div className="detail-requirements">
|
||||||
<h4>📊 Requirements</h4>
|
<h4>{t('crafting.requirements')}</h4>
|
||||||
|
|
||||||
{item.craft_level && item.craft_level > 1 && (
|
|
||||||
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
|
<div className={`requirement-item ${item.meets_level ? 'met' : 'missing'}`}>
|
||||||
<span>Level {item.craft_level} Required</span>
|
<span>{t('crafting.levelRequired', { level: item.craft_level })}</span>
|
||||||
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
|
<span>{item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{item.tools && item.tools.length > 0 && (
|
{item.tools && item.tools.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Tools</h5>
|
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>{t('crafting.tools')}</h5>
|
||||||
{item.tools.map((tool: any, i: number) => (
|
{item.tools.map((tool: any, i: number) => (
|
||||||
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
|
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
|
||||||
<span>{tool.emoji} {getTranslatedText(tool.name)}</span>
|
<span>{tool.emoji} {getTranslatedText(tool.name)}</span>
|
||||||
<span>
|
<span>
|
||||||
{tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
|
{tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (${t('crafting.cost')}: ${tool.durability_cost})` : `❌ ${t('crafting.missing')} (${t('crafting.cost')}: ${tool.durability_cost})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>Materials</h5>
|
<h5 style={{ marginTop: '1rem', marginBottom: '0.5rem', color: '#aaa' }}>{t('crafting.materials')}</h5>
|
||||||
{item.materials && item.materials.length > 0 ? (
|
{item.materials && item.materials.length > 0 ? (
|
||||||
item.materials.map((mat: any, i: number) => (
|
item.materials.map((mat: any, i: number) => (
|
||||||
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
|
<div key={i} className={`requirement-item ${mat.has_enough ? 'met' : 'missing'}`}>
|
||||||
@@ -239,7 +237,7 @@ function Workbench({
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="requirement-item met">
|
<div className="requirement-item met">
|
||||||
<span>No materials required</span>
|
<span>{t('crafting.noMaterialsRequired')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -252,12 +250,12 @@ function Workbench({
|
|||||||
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{!item.meets_level ? `Need Level ${item.craft_level}` :
|
{!item.meets_level ? t('crafting.levelRequired', { level: item.craft_level }) :
|
||||||
!item.can_craft ? 'Missing Requirements' : '🔨 Craft Item'}
|
!item.can_craft ? t('crafting.missingRequirements') : t('crafting.craftItem')}
|
||||||
</span>
|
</span>
|
||||||
{item.can_craft && (
|
{item.can_craft && (
|
||||||
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
||||||
⚡ {item.stamina_cost || 5} Stamina
|
{t('crafting.staminaCost', { cost: item.stamina_cost || 5 })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -268,10 +266,10 @@ function Workbench({
|
|||||||
{workbenchTab === 'repair' && (
|
{workbenchTab === 'repair' && (
|
||||||
<>
|
<>
|
||||||
<div className="detail-requirements">
|
<div className="detail-requirements">
|
||||||
<h4>🔧 Repair Status</h4>
|
<h4>🔧 {workbenchTab === 'repair' ? t('game.repair') : t('game.salvage')}</h4>
|
||||||
|
|
||||||
{!item.needs_repair ? (
|
{!item.needs_repair ? (
|
||||||
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}>✅ Item is in perfect condition</p>
|
<p className="repair-info full-durability" style={{ textAlign: 'center', marginBottom: '1rem' }}>{t('crafting.perfectCondition')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="repair-preview-text">
|
<div className="repair-preview-text">
|
||||||
@@ -333,12 +331,12 @@ function Workbench({
|
|||||||
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{!item.needs_repair ? 'Already Full' :
|
{!item.needs_repair ? t('crafting.alreadyFull') :
|
||||||
!item.can_repair ? 'Missing Requirements' : '🛠️ Repair Item'}
|
!item.can_repair ? t('crafting.missingRequirements') : t('crafting.repairItem')}
|
||||||
</span>
|
</span>
|
||||||
{item.needs_repair && item.can_repair && (
|
{item.needs_repair && item.can_repair && (
|
||||||
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
||||||
⚡ {item.stamina_cost || 3} Stamina
|
{t('crafting.staminaCost', { cost: item.stamina_cost || 3 })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -349,7 +347,7 @@ function Workbench({
|
|||||||
{workbenchTab === 'uncraft' && (
|
{workbenchTab === 'uncraft' && (
|
||||||
<>
|
<>
|
||||||
<div className="detail-requirements">
|
<div className="detail-requirements">
|
||||||
<h4>♻️ Salvage Preview</h4>
|
<h4>♻️ {t('game.salvage')}</h4>
|
||||||
|
|
||||||
{/* Show durability bar if we have durability data */}
|
{/* Show durability bar if we have durability data */}
|
||||||
{(item.unique_item_data || item.durability_percent !== undefined) && (
|
{(item.unique_item_data || item.durability_percent !== undefined) && (
|
||||||
@@ -382,7 +380,7 @@ function Workbench({
|
|||||||
<>
|
<>
|
||||||
{durabilityRatio < 1.0 && (
|
{durabilityRatio < 1.0 && (
|
||||||
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}>
|
<div className="uncraft-warning" style={{ marginBottom: '1rem', color: '#ffc107' }}>
|
||||||
⚠️ Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage
|
{t('crafting.yieldReduced', { percent: Math.round((1 - durabilityRatio) * 100) })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -409,15 +407,15 @@ function Workbench({
|
|||||||
className="uncraft-btn"
|
className="uncraft-btn"
|
||||||
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
|
disabled={(profile?.stamina || 0) < (item.stamina_cost || 1)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`Are you sure you want to salvage ${getTranslatedText(item.name)}? This cannot be undone.`)) {
|
if (window.confirm(t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }))) {
|
||||||
onUncraft(item.unique_item_id, item.inventory_id)
|
onUncraft(item.unique_item_id, item.inventory_id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
style={{ width: '100%', padding: '1rem', fontSize: '1.2rem', backgroundColor: '#d32f2f', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||||
>
|
>
|
||||||
<span>♻️ Salvage Item</span>
|
<span>♻️ {t('game.salvage')}</span>
|
||||||
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>
|
||||||
⚡ {item.stamina_cost || 2} Stamina
|
{t('crafting.staminaCost', { cost: item.stamina_cost || 2 })}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,14 +427,14 @@ function Workbench({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'all', label: 'All', icon: '🎒' },
|
{ id: 'all', label: t('categories.all'), icon: '🎒' },
|
||||||
{ id: 'weapon', label: 'Weapons', icon: '⚔️' },
|
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
|
||||||
{ id: 'armor', label: 'Armor', icon: '🛡️' },
|
{ id: 'armor', label: t('categories.armor'), icon: '🛡️' },
|
||||||
{ id: 'clothing', label: 'Clothing', icon: '👕' },
|
{ id: 'clothing', label: t('categories.clothing'), icon: '👕' },
|
||||||
{ id: 'tool', label: 'Tools', icon: '🛠️' },
|
{ id: 'tool', label: t('categories.tool'), icon: '🛠️' },
|
||||||
{ id: 'consumable', label: 'Consumables', icon: '🍖' },
|
{ id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
|
||||||
{ id: 'resource', label: 'Resources', icon: '📦' },
|
{ id: 'resource', label: t('categories.resource'), icon: '📦' },
|
||||||
{ id: 'misc', label: 'Misc', icon: '📦' }
|
{ id: 'misc', label: t('categories.misc'), icon: '📦' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -445,25 +443,25 @@ function Workbench({
|
|||||||
}}>
|
}}>
|
||||||
<div className="workbench-menu">
|
<div className="workbench-menu">
|
||||||
<div className="workbench-header">
|
<div className="workbench-header">
|
||||||
<h3>🔧 Workbench</h3>
|
<h3>{t('game.workbench')}</h3>
|
||||||
<div className="workbench-tabs">
|
<div className="workbench-tabs">
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
|
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
|
||||||
onClick={() => onSwitchTab('craft')}
|
onClick={() => onSwitchTab('craft')}
|
||||||
>
|
>
|
||||||
🔨 Craft
|
{t('game.craft')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
|
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
|
||||||
onClick={() => onSwitchTab('repair')}
|
onClick={() => onSwitchTab('repair')}
|
||||||
>
|
>
|
||||||
🛠️ Repair
|
{t('game.repair')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
|
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
|
||||||
onClick={() => onSwitchTab('uncraft')}
|
onClick={() => onSwitchTab('uncraft')}
|
||||||
>
|
>
|
||||||
♻️ Salvage
|
{t('game.salvage')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="close-btn" onClick={onCloseCrafting}>✕</button>
|
<button className="close-btn" onClick={onCloseCrafting}>✕</button>
|
||||||
@@ -472,7 +470,7 @@ function Workbench({
|
|||||||
<div className="workbench-content-grid">
|
<div className="workbench-content-grid">
|
||||||
{/* Column 1: Categories Sidebar */}
|
{/* Column 1: Categories Sidebar */}
|
||||||
<div className="workbench-sidebar">
|
<div className="workbench-sidebar">
|
||||||
<h4 className="sidebar-title">Categories</h4>
|
<h4 className="sidebar-title">{t('location.lootableItems').replace(':', '')}</h4>
|
||||||
<div className="category-list">
|
<div className="category-list">
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<button
|
<button
|
||||||
@@ -492,7 +490,7 @@ function Workbench({
|
|||||||
<div className="workbench-filters">
|
<div className="workbench-filters">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="🔍 Filter items..."
|
placeholder={t('game.searchItems')}
|
||||||
value={workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter}
|
value={workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (workbenchTab === 'craft') onSetCraftFilter(e.target.value)
|
if (workbenchTab === 'craft') onSetCraftFilter(e.target.value)
|
||||||
@@ -519,9 +517,7 @@ function Workbench({
|
|||||||
return matchesSearch && matchesCategory
|
return matchesSearch && matchesCategory
|
||||||
}).length === 0 ? (
|
}).length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
{workbenchTab === 'craft' ? 'No craftable items found.' :
|
{t('game.noItemsFound')}
|
||||||
workbenchTab === 'repair' ? 'No repairable items found.' :
|
|
||||||
'No salvageable items found.'}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
items
|
items
|
||||||
@@ -573,7 +569,7 @@ function Workbench({
|
|||||||
>
|
>
|
||||||
{getTranslatedText(item.name)}
|
{getTranslatedText(item.name)}
|
||||||
</span>
|
</span>
|
||||||
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>Equipped</span>}
|
{item.location === 'equipped' && <span className="item-card-equipped" style={{ marginLeft: '0.5rem' }}>{t('game.equipped')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="item-meta-row">
|
<div className="item-meta-row">
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export function useGameEngine(
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
|
const addCombatLogEntry = useCallback((entry: CombatLogEntry) => {
|
||||||
setCombatLog((prev: CombatLogEntry[]) => [entry, ...prev])
|
setCombatLog((prev: CombatLogEntry[]) => [{ ...entry, id: entry.id || Date.now() + Math.random() }, ...prev])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Fetch functions
|
// Fetch functions
|
||||||
@@ -337,6 +337,7 @@ export function useGameEngine(
|
|||||||
pvpRes.data.pvp_combat.defender :
|
pvpRes.data.pvp_combat.defender :
|
||||||
pvpRes.data.pvp_combat.attacker
|
pvpRes.data.pvp_combat.attacker
|
||||||
setCombatLog([{
|
setCombatLog([{
|
||||||
|
id: 'pvp-combat-init',
|
||||||
time: timeStr,
|
time: timeStr,
|
||||||
message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`,
|
message: `PvP combat with ${opponent.username} (Lv. ${opponent.level})!`,
|
||||||
isPlayer: true
|
isPlayer: true
|
||||||
@@ -351,6 +352,7 @@ export function useGameEngine(
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||||
setCombatLog([{
|
setCombatLog([{
|
||||||
|
id: 'combat-in-progress',
|
||||||
time: timeStr,
|
time: timeStr,
|
||||||
message: 'Combat in progress...',
|
message: 'Combat in progress...',
|
||||||
isPlayer: true
|
isPlayer: true
|
||||||
@@ -402,8 +404,9 @@ export function useGameEngine(
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||||
setCombatLog([{
|
setCombatLog([{
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
time: timeStr,
|
time: timeStr,
|
||||||
message: `⚠️ ${encounter.combat.npc_name} ambushes you!`,
|
message: { type: 'combat_start', data: { npc_name: encounter.combat.npc_name } },
|
||||||
isPlayer: false
|
isPlayer: false
|
||||||
}])
|
}])
|
||||||
|
|
||||||
@@ -503,10 +506,23 @@ export function useGameEngine(
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||||
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
||||||
const newEntries = messages.map((msg: string) => ({
|
const parsedMessages = messages.map((msg: string) => {
|
||||||
|
try {
|
||||||
|
if (msg.trim().startsWith('{')) {
|
||||||
|
const parsed = JSON.parse(msg)
|
||||||
|
if (parsed.type && parsed.data) return parsed
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
return msg
|
||||||
|
})
|
||||||
|
|
||||||
|
const newEntries = parsedMessages.map((msg: any) => ({
|
||||||
|
id: `item-use-${Date.now()}-${Math.random()}`,
|
||||||
time: timeStr,
|
time: timeStr,
|
||||||
message: msg,
|
message: msg,
|
||||||
isPlayer: !msg.includes('attacks')
|
isPlayer: typeof msg === 'object'
|
||||||
|
? msg.type !== 'enemy_attack' && msg.type !== 'flee_fail'
|
||||||
|
: !msg.includes('attacks') && !msg.includes('hits')
|
||||||
}))
|
}))
|
||||||
setCombatLog((prev: any) => [...newEntries, ...prev])
|
setCombatLog((prev: any) => [...newEntries, ...prev])
|
||||||
|
|
||||||
@@ -688,8 +704,9 @@ export function useGameEngine(
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||||
setCombatLog([{
|
setCombatLog([{
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
time: timeStr,
|
time: timeStr,
|
||||||
message: `Combat started with ${response.data.combat.npc_name}!`,
|
message: { type: 'combat_start', data: { npc_name: response.data.combat.npc_name } },
|
||||||
isPlayer: true
|
isPlayer: true
|
||||||
}])
|
}])
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ export interface Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatLogEntry {
|
export interface CombatLogEntry {
|
||||||
|
id: string | number
|
||||||
time: string
|
time: string
|
||||||
message: string
|
message: string | { type: string; data: any }
|
||||||
isPlayer: boolean
|
isPlayer: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,16 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"game": "Game",
|
"game": "Game",
|
||||||
"leaderboards": "Leaderboards",
|
"leaderboards": "Leaderboards",
|
||||||
"account": "Account"
|
"account": "Account",
|
||||||
|
"info": "Info",
|
||||||
|
"talk": "Talk",
|
||||||
|
"loot": "Loot",
|
||||||
|
"lootAll": "Loot All Available",
|
||||||
|
"examine": "Examine",
|
||||||
|
"fight": "Fight",
|
||||||
|
"pickUp": "Pick Up",
|
||||||
|
"pickUpAll": "Pick Up All",
|
||||||
|
"qty": "Qty"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@@ -22,7 +31,23 @@
|
|||||||
"forgotPassword": "Forgot Password?",
|
"forgotPassword": "Forgot Password?",
|
||||||
"createAccount": "Create Account",
|
"createAccount": "Create Account",
|
||||||
"alreadyHaveAccount": "Already have an account?",
|
"alreadyHaveAccount": "Already have an account?",
|
||||||
"dontHaveAccount": "Don't have an account?"
|
"dontHaveAccount": "Don't have an account?",
|
||||||
|
"rememberMe": "Remember me",
|
||||||
|
"loginTitle": "Welcome Back",
|
||||||
|
"registerTitle": "Create Account",
|
||||||
|
"loginSubtitle": "Sign in to continue your journey",
|
||||||
|
"registerSubtitle": "Join the survivors"
|
||||||
|
},
|
||||||
|
"characters": {
|
||||||
|
"title": "Select Character",
|
||||||
|
"createNew": "Create New Character",
|
||||||
|
"play": "Play",
|
||||||
|
"delete": "Delete",
|
||||||
|
"noCharacters": "No characters yet",
|
||||||
|
"createFirst": "Create your first character to begin",
|
||||||
|
"name": "Character Name",
|
||||||
|
"class": "Class",
|
||||||
|
"level": "Level"
|
||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"travel": "🧭 Travel",
|
"travel": "🧭 Travel",
|
||||||
@@ -40,10 +65,41 @@
|
|||||||
"use": "Use",
|
"use": "Use",
|
||||||
"equip": "Equip",
|
"equip": "Equip",
|
||||||
"unequip": "Unequip",
|
"unequip": "Unequip",
|
||||||
"attack": "Attack",
|
"attack": "⚔️ Attack",
|
||||||
"flee": "Flee",
|
"flee": "🏃 Flee",
|
||||||
"rest": "Rest",
|
"rest": "Rest",
|
||||||
"onlineCount": "{{count}} Online"
|
"onlineCount": "{{count}} Online",
|
||||||
|
"searchItems": "Search items...",
|
||||||
|
"equipped": "Equipped",
|
||||||
|
"backpack": "Backpack",
|
||||||
|
"noBackpack": "No Backpack Equipped",
|
||||||
|
"distance": "Distance",
|
||||||
|
"stamina": "Stamina",
|
||||||
|
"weight": "Weight",
|
||||||
|
"volume": "Volume",
|
||||||
|
"durability": "Durability",
|
||||||
|
"noItemsFound": "No items found in this category"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"recentActivity": "📜 Recent Activity",
|
||||||
|
"enemies": "⚔️ Enemies",
|
||||||
|
"corpses": "💀 Corpses",
|
||||||
|
"npcs": "👥 NPCs",
|
||||||
|
"itemsOnGround": "📦 Items on Ground",
|
||||||
|
"lootableItems": "Lootable Items:",
|
||||||
|
"items": "item(s)",
|
||||||
|
"level": "Lv."
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"workbench": "🔧 Workbench",
|
||||||
|
"repairStation": "🛠️ Repair Station",
|
||||||
|
"safeZone": "🛡️ Safe Zone",
|
||||||
|
"shop": "🏪 Shop",
|
||||||
|
"shelter": "🏠 Shelter",
|
||||||
|
"medical": "⚕️ Medical",
|
||||||
|
"storage": "📦 Storage",
|
||||||
|
"water": "💧 Water",
|
||||||
|
"food": "🍎 Food"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"hp": "❤️ HP",
|
"hp": "❤️ HP",
|
||||||
@@ -53,8 +109,8 @@
|
|||||||
"xp": "⭐ XP",
|
"xp": "⭐ XP",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"unspentPoints": "⭐ Unspent",
|
"unspentPoints": "⭐ Unspent",
|
||||||
"weight": "⚖️ Weight",
|
"weight": "Weight",
|
||||||
"volume": "📦 Volume",
|
"volume": "Volume",
|
||||||
"strength": "💪 STR",
|
"strength": "💪 STR",
|
||||||
"strengthFull": "Strength",
|
"strengthFull": "Strength",
|
||||||
"strengthDesc": "Increases melee damage and carry capacity",
|
"strengthDesc": "Increases melee damage and carry capacity",
|
||||||
@@ -68,10 +124,23 @@
|
|||||||
"intellectFull": "Intellect",
|
"intellectFull": "Intellect",
|
||||||
"intellectDesc": "Enhances crafting and resource gathering",
|
"intellectDesc": "Enhances crafting and resource gathering",
|
||||||
"armor": "🛡️ Armor",
|
"armor": "🛡️ Armor",
|
||||||
"damage": "⚔️ Damage",
|
"damage": "Damage",
|
||||||
"durability": "Durability"
|
"durability": "Durability",
|
||||||
|
"tier": "Tier",
|
||||||
|
"hpRestore": "HP Restore",
|
||||||
|
"staminaRestore": "Stamina Restore",
|
||||||
|
"pen": "Pen",
|
||||||
|
"crit": "Crit",
|
||||||
|
"acc": "Acc",
|
||||||
|
"life": "Life",
|
||||||
|
"str": "STR",
|
||||||
|
"agi": "AGI",
|
||||||
|
"end": "END",
|
||||||
|
"hpMax": "HP max",
|
||||||
|
"stmMax": "Stm max"
|
||||||
},
|
},
|
||||||
"combat": {
|
"combat": {
|
||||||
|
"title": "Combat",
|
||||||
"inCombat": "In Combat",
|
"inCombat": "In Combat",
|
||||||
"yourTurn": "Your Turn",
|
"yourTurn": "Your Turn",
|
||||||
"enemyTurn": "Enemy's Turn",
|
"enemyTurn": "Enemy's Turn",
|
||||||
@@ -80,7 +149,20 @@
|
|||||||
"youDied": "You Died",
|
"youDied": "You Died",
|
||||||
"respawn": "Respawn",
|
"respawn": "Respawn",
|
||||||
"fleeSuccess": "You escaped!",
|
"fleeSuccess": "You escaped!",
|
||||||
"fleeFailed": "Failed to escape!"
|
"fleeFailed": "Failed to escape!",
|
||||||
|
"enemyHp": "Enemy HP",
|
||||||
|
"playerHp": "Your HP",
|
||||||
|
"combatLog": "Combat Log",
|
||||||
|
"attacking": "Attacking",
|
||||||
|
"defending": "Defending",
|
||||||
|
"messages": {
|
||||||
|
"combat_start": "Combat started with {{enemy}}!",
|
||||||
|
"player_attack": "You attack for {{damage}} damage!",
|
||||||
|
"enemy_attack": "{{enemy}} attacks for {{damage}} damage!",
|
||||||
|
"victory": "Victory! Defeated {{enemy}}",
|
||||||
|
"flee_fail": "Failed to flee! {{enemy}} attacks for {{damage}} damage!"
|
||||||
|
},
|
||||||
|
"turnTimer": "Turn Timer"
|
||||||
},
|
},
|
||||||
"equipment": {
|
"equipment": {
|
||||||
"head": "Head",
|
"head": "Head",
|
||||||
@@ -104,7 +186,16 @@
|
|||||||
"staminaCost": "⚡ {{cost}} Stamina",
|
"staminaCost": "⚡ {{cost}} Stamina",
|
||||||
"alreadyFull": "Already Full",
|
"alreadyFull": "Already Full",
|
||||||
"perfectCondition": "✅ Item is in perfect condition",
|
"perfectCondition": "✅ Item is in perfect condition",
|
||||||
"yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage"
|
"yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage",
|
||||||
|
"selectItem": "Select an item to view details",
|
||||||
|
"chooseFromList": "Choose an item from the list on the left",
|
||||||
|
"yield": "Yield",
|
||||||
|
"repairCost": "Repair Cost",
|
||||||
|
"noMaterialsRequired": "No materials required",
|
||||||
|
"missing": "Missing",
|
||||||
|
"cost": "Cost",
|
||||||
|
"potentialBaseStats": "Potential base stats. Actual stats may vary.",
|
||||||
|
"confirmSalvage": "Are you sure you want to salvage {{name}}? This cannot be undone."
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"all": "All Items",
|
"all": "All Items",
|
||||||
@@ -119,13 +210,38 @@
|
|||||||
"misc": "Misc"
|
"misc": "Misc"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"notEnoughStamina": "Not enough stamina",
|
"notEnoughStamina": "Not enough stamina (need {{need}}, have {{have}})",
|
||||||
"inventoryFull": "Inventory full",
|
"inventoryFull": "Inventory full",
|
||||||
"itemDropped": "Item dropped",
|
"itemDropped": "Item dropped",
|
||||||
"itemPickedUp": "Item picked up",
|
"itemPickedUp": "Item picked up",
|
||||||
"waitBeforeMoving": "Wait {{seconds}}s before moving",
|
"waitBeforeMoving": "Wait {{seconds}}s before moving",
|
||||||
"cannotTravelInCombat": "Cannot travel during combat",
|
"cannotTravelInCombat": "Cannot travel during combat",
|
||||||
"cannotInteractInCombat": "Cannot interact during combat"
|
"cannotInteractInCombat": "Cannot interact during combat",
|
||||||
|
"interactionCooldown": "Wait {{seconds}}s before interacting again",
|
||||||
|
"youAreDead": "You are dead",
|
||||||
|
"cannotTravelCombat": "Cannot travel during combat",
|
||||||
|
"cannotGo": "Cannot go {{direction}}",
|
||||||
|
"enemyAppeared": "A {{name}} has appeared!",
|
||||||
|
"enemyDespawned": "A wandering enemy has left the area",
|
||||||
|
"corpsesDecayed": "{{count}} corpses have decayed",
|
||||||
|
"itemsDecayed": "{{count}} dropped items have decayed",
|
||||||
|
"waitBeforeMovingSimple": "Wait {{seconds}}s before moving"
|
||||||
|
},
|
||||||
|
"directions": {
|
||||||
|
"north": "North",
|
||||||
|
"south": "South",
|
||||||
|
"east": "East",
|
||||||
|
"west": "West",
|
||||||
|
"northeast": "Northeast",
|
||||||
|
"northwest": "Northwest",
|
||||||
|
"southeast": "Southeast",
|
||||||
|
"southwest": "Southwest",
|
||||||
|
"up": "Up",
|
||||||
|
"down": "Down",
|
||||||
|
"inside": "Inside",
|
||||||
|
"outside": "Outside",
|
||||||
|
"enter": "Enter",
|
||||||
|
"exit": "Exit"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"heroTitle": "Echoes of the Ash",
|
"heroTitle": "Echoes of the Ash",
|
||||||
|
|||||||
@@ -10,19 +10,44 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"game": "Juego",
|
"game": "Juego",
|
||||||
"leaderboards": "Clasificación",
|
"leaderboards": "Clasificación",
|
||||||
"account": "Cuenta"
|
"account": "Cuenta",
|
||||||
|
"info": "Info",
|
||||||
|
"talk": "Hablar",
|
||||||
|
"loot": "Saquear",
|
||||||
|
"lootAll": "Saquear Todo",
|
||||||
|
"examine": "Examinar",
|
||||||
|
"fight": "Luchar",
|
||||||
|
"pickUp": "Recoger",
|
||||||
|
"pickUpAll": "Recoger Todo",
|
||||||
|
"qty": "Cant"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Iniciar Sesión",
|
"login": "Iniciar sesión",
|
||||||
"logout": "Cerrar Sesión",
|
"logout": "Cerrar sesión",
|
||||||
"register": "Registrarse",
|
"register": "Registrarse",
|
||||||
"username": "Usuario",
|
"username": "Usuario",
|
||||||
"password": "Contraseña",
|
"password": "Contraseña",
|
||||||
"email": "Correo",
|
"email": "Correo electrónico",
|
||||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||||
"createAccount": "Crear Cuenta",
|
"createAccount": "Crear cuenta",
|
||||||
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
||||||
"dontHaveAccount": "¿No tienes cuenta?"
|
"dontHaveAccount": "¿No tienes una cuenta?",
|
||||||
|
"rememberMe": "Recordarme",
|
||||||
|
"loginTitle": "Bienvenido de nuevo",
|
||||||
|
"registerTitle": "Crear cuenta",
|
||||||
|
"loginSubtitle": "Inicia sesión para continuar tu viaje",
|
||||||
|
"registerSubtitle": "Únete a los supervivientes"
|
||||||
|
},
|
||||||
|
"characters": {
|
||||||
|
"title": "Seleccionar Personaje",
|
||||||
|
"createNew": "Crear Nuevo Personaje",
|
||||||
|
"play": "Jugar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"noCharacters": "Aún no hay personajes",
|
||||||
|
"createFirst": "Crea tu primer personaje para comenzar",
|
||||||
|
"name": "Nombre del Personaje",
|
||||||
|
"class": "Clase",
|
||||||
|
"level": "Nivel"
|
||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"travel": "🧭 Viajar",
|
"travel": "🧭 Viajar",
|
||||||
@@ -33,17 +58,48 @@
|
|||||||
"workbench": "🔧 Banco de Trabajo",
|
"workbench": "🔧 Banco de Trabajo",
|
||||||
"craft": "🔨 Fabricar",
|
"craft": "🔨 Fabricar",
|
||||||
"repair": "🛠️ Reparar",
|
"repair": "🛠️ Reparar",
|
||||||
"salvage": "♻️ Desmontar",
|
"salvage": "♻️ Desguazar",
|
||||||
"pickUp": "Recoger",
|
"pickUp": "Recoger",
|
||||||
"drop": "Soltar",
|
"drop": "Soltar",
|
||||||
"dropAll": "Todo",
|
"dropAll": "Todo",
|
||||||
"use": "Usar",
|
"use": "Usar",
|
||||||
"equip": "Equipar",
|
"equip": "Equipar",
|
||||||
"unequip": "Desequipar",
|
"unequip": "Desequipar",
|
||||||
"attack": "Atacar",
|
"attack": "⚔️ Atacar",
|
||||||
"flee": "Huir",
|
"flee": "🏃 Huir",
|
||||||
"rest": "Descansar",
|
"rest": "Descansar",
|
||||||
"onlineCount": "{{count}} En línea"
|
"onlineCount": "{{count}} En línea",
|
||||||
|
"searchItems": "Buscar objetos...",
|
||||||
|
"equipped": "Equipado",
|
||||||
|
"backpack": "Mochila",
|
||||||
|
"noBackpack": "Sin mochila equipada",
|
||||||
|
"distance": "Distancia",
|
||||||
|
"stamina": "Aguante",
|
||||||
|
"weight": "Peso",
|
||||||
|
"volume": "Volumen",
|
||||||
|
"durability": "Durabilidad",
|
||||||
|
"noItemsFound": "No se encontraron objetos en esta categoría"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"recentActivity": "📜 Actividad Reciente",
|
||||||
|
"enemies": "⚔️ Enemigos",
|
||||||
|
"corpses": "💀 Cadáveres",
|
||||||
|
"npcs": "👥 NPCs",
|
||||||
|
"itemsOnGround": "📦 Objetos en el Suelo",
|
||||||
|
"lootableItems": "Objetos Saqueables:",
|
||||||
|
"items": "objeto(s)",
|
||||||
|
"level": "Nv."
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"workbench": "🔧 Banco de Trabajo",
|
||||||
|
"repairStation": "🛠️ Estación de Reparación",
|
||||||
|
"safeZone": "🛡️ Zona Segura",
|
||||||
|
"shop": "🏪 Tienda",
|
||||||
|
"shelter": "🏠 Refugio",
|
||||||
|
"medical": "⚕️ Médico",
|
||||||
|
"storage": "📦 Almacén",
|
||||||
|
"water": "💧 Agua",
|
||||||
|
"food": "🍎 Comida"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"hp": "❤️ Vida",
|
"hp": "❤️ Vida",
|
||||||
@@ -53,34 +109,60 @@
|
|||||||
"xp": "⭐ XP",
|
"xp": "⭐ XP",
|
||||||
"level": "Nivel",
|
"level": "Nivel",
|
||||||
"unspentPoints": "⭐ Sin gastar",
|
"unspentPoints": "⭐ Sin gastar",
|
||||||
"weight": "⚖️ Peso",
|
"weight": "Peso",
|
||||||
"volume": "📦 Volumen",
|
"volume": "Volumen",
|
||||||
"strength": "💪 FUE",
|
"strength": "💪 FUE",
|
||||||
"strengthFull": "Fuerza",
|
"strengthFull": "Fuerza",
|
||||||
"strengthDesc": "Aumenta el daño cuerpo a cuerpo y capacidad de carga",
|
"strengthDesc": "Aumenta el daño cuerpo a cuerpo y la capacidad de carga",
|
||||||
"agility": "🏃 AGI",
|
"agility": "🏃 AGI",
|
||||||
"agilityFull": "Agilidad",
|
"agilityFull": "Agilidad",
|
||||||
"agilityDesc": "Mejora la esquiva y golpes críticos",
|
"agilityDesc": "Mejora la probabilidad de esquivar y los golpes críticos",
|
||||||
"endurance": "🛡️ RES",
|
"endurance": "🛡️ RES",
|
||||||
"enduranceFull": "Resistencia",
|
"enduranceFull": "Resistencia",
|
||||||
"enduranceDesc": "Aumenta la vida y energía",
|
"enduranceDesc": "Aumenta la vida y el aguante",
|
||||||
"intellect": "🧠 INT",
|
"intellect": "🧠 INT",
|
||||||
"intellectFull": "Intelecto",
|
"intellectFull": "Intelecto",
|
||||||
"intellectDesc": "Mejora la fabricación y recolección",
|
"intellectDesc": "Mejora la fabricación y recolección de recursos",
|
||||||
"armor": "🛡️ Armadura",
|
"armor": "🛡️ Armadura",
|
||||||
"damage": "⚔️ Daño",
|
"damage": "Daño",
|
||||||
"durability": "Durabilidad"
|
"durability": "Durabilidad",
|
||||||
|
"tier": "Nivel",
|
||||||
|
"hpRestore": "Restaura Vida",
|
||||||
|
"staminaRestore": "Restaura Aguante",
|
||||||
|
"pen": "Pen",
|
||||||
|
"crit": "Crit",
|
||||||
|
"acc": "Prec",
|
||||||
|
"life": "Vida",
|
||||||
|
"str": "FUE",
|
||||||
|
"agi": "AGI",
|
||||||
|
"end": "RES",
|
||||||
|
"hpMax": "Vida máx",
|
||||||
|
"stmMax": "Agua. máx"
|
||||||
},
|
},
|
||||||
"combat": {
|
"combat": {
|
||||||
|
"title": "Combate",
|
||||||
"inCombat": "En Combate",
|
"inCombat": "En Combate",
|
||||||
"yourTurn": "Tu Turno",
|
"yourTurn": "Tu Turno",
|
||||||
"enemyTurn": "Turno del Enemigo",
|
"enemyTurn": "Turno del Enemigo",
|
||||||
"victory": "¡Victoria!",
|
"victory": "¡Victoria!",
|
||||||
"defeat": "Derrota",
|
"defeat": "Derrota",
|
||||||
"youDied": "Has Muerto",
|
"youDied": "Has Muerto",
|
||||||
"respawn": "Revivir",
|
"respawn": "Reaparecer",
|
||||||
"fleeSuccess": "¡Escapaste!",
|
"fleeSuccess": "¡Has escapado!",
|
||||||
"fleeFailed": "¡No pudiste escapar!"
|
"fleeFailed": "¡No has podido escapar!",
|
||||||
|
"enemyHp": "Vida del Enemigo",
|
||||||
|
"playerHp": "Tu Vida",
|
||||||
|
"combatLog": "Registro de Combate",
|
||||||
|
"turnTimer": "Temporizador de Turno",
|
||||||
|
"attacking": "Atacando",
|
||||||
|
"defending": "Defendiendo",
|
||||||
|
"messages": {
|
||||||
|
"combat_start": "¡Combate iniciado con {{enemy}}!",
|
||||||
|
"player_attack": "¡Atacas por {{damage}} de daño!",
|
||||||
|
"enemy_attack": "{{enemy}} ataca por {{damage}} de daño!",
|
||||||
|
"victory": "¡Victoria! Derrotaste a {{enemy}}",
|
||||||
|
"flee_fail": "¡Fallaste al huir! {{enemy}} ataca por {{damage}} de daño!"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"equipment": {
|
"equipment": {
|
||||||
"head": "Cabeza",
|
"head": "Cabeza",
|
||||||
@@ -96,20 +178,29 @@
|
|||||||
"requirements": "📊 Requisitos",
|
"requirements": "📊 Requisitos",
|
||||||
"materials": "Materiales",
|
"materials": "Materiales",
|
||||||
"tools": "Herramientas",
|
"tools": "Herramientas",
|
||||||
"levelRequired": "Nivel {{level}} Requerido",
|
"levelRequired": "Requiere Nivel {{level}}",
|
||||||
"missingRequirements": "Faltan Requisitos",
|
"missingRequirements": "Faltan Requisitos",
|
||||||
"craftItem": "🔨 Fabricar",
|
"craftItem": "🔨 Fabricar",
|
||||||
"repairItem": "🛠️ Reparar",
|
"repairItem": "🛠️ Reparar",
|
||||||
"salvageItem": "♻️ Desmontar",
|
"salvageItem": "♻️ Desguazar",
|
||||||
"staminaCost": "⚡ {{cost}} Energía",
|
"staminaCost": "⚡ {{cost}} Aguante",
|
||||||
"alreadyFull": "Ya está Completo",
|
"alreadyFull": "Ya está completo",
|
||||||
"perfectCondition": "✅ El objeto está en perfecto estado",
|
"perfectCondition": "✅ El objeto está en perfectas condiciones",
|
||||||
"yieldReduced": "⚠️ Rendimiento reducido {{percent}}% por daño"
|
"yieldReduced": "⚠️ Rendimiento reducido un {{percent}}% por daño",
|
||||||
|
"selectItem": "Selecciona un objeto para ver detalles",
|
||||||
|
"chooseFromList": "Elige un objeto de la lista de la izquierda",
|
||||||
|
"yield": "Rendimiento",
|
||||||
|
"repairCost": "Coste de Reparación",
|
||||||
|
"noMaterialsRequired": "No requiere materiales",
|
||||||
|
"missing": "Falta",
|
||||||
|
"cost": "Coste",
|
||||||
|
"potentialBaseStats": "Estadísticas base potenciales. Las estadísticas reales pueden variar.",
|
||||||
|
"confirmSalvage": "¿Estás seguro de que quieres desguazar {{name}}? Esto no se puede deshacer."
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"all": "Todos",
|
"all": "Todos los Objetos",
|
||||||
"weapon": "Armas",
|
"weapon": "Armas",
|
||||||
"armor": "Armadura",
|
"armor": "Armaduras",
|
||||||
"clothing": "Ropa",
|
"clothing": "Ropa",
|
||||||
"backpack": "Mochilas",
|
"backpack": "Mochilas",
|
||||||
"tool": "Herramientas",
|
"tool": "Herramientas",
|
||||||
@@ -119,16 +210,41 @@
|
|||||||
"misc": "Varios"
|
"misc": "Varios"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"notEnoughStamina": "No tienes suficiente energía",
|
"notEnoughStamina": "No tienes suficiente aguante (necesitas {{need}}, tienes {{have}})",
|
||||||
"inventoryFull": "Inventario lleno",
|
"inventoryFull": "Inventario lleno",
|
||||||
"itemDropped": "Objeto soltado",
|
"itemDropped": "Objeto soltado",
|
||||||
"itemPickedUp": "Objeto recogido",
|
"itemPickedUp": "Objeto recogido",
|
||||||
"waitBeforeMoving": "Espera {{seconds}}s antes de moverte",
|
"waitBeforeMoving": "Espera {{seconds}}s antes de moverte",
|
||||||
"cannotTravelInCombat": "No puedes viajar en combate",
|
"cannotTravelInCombat": "No puedes viajar durante el combate",
|
||||||
"cannotInteractInCombat": "No puedes interactuar en combate"
|
"cannotInteractInCombat": "No puedes interactuar durante el combate",
|
||||||
|
"interactionCooldown": "Espera {{seconds}}s antes de interactuar de nuevo",
|
||||||
|
"youAreDead": "Estás muerto",
|
||||||
|
"cannotTravelCombat": "No puedes viajar durante el combate",
|
||||||
|
"cannotGo": "No puedes ir al {{direction}}",
|
||||||
|
"enemyAppeared": "¡Un {{name}} ha aparecido!",
|
||||||
|
"enemyDespawned": "Un enemigo errante ha abandonado el área",
|
||||||
|
"corpsesDecayed": "{{count}} cadáveres se han descompuesto",
|
||||||
|
"itemsDecayed": "{{count}} objetos caídos se han descompuesto",
|
||||||
|
"waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte"
|
||||||
|
},
|
||||||
|
"directions": {
|
||||||
|
"north": "Norte",
|
||||||
|
"south": "Sur",
|
||||||
|
"east": "Este",
|
||||||
|
"west": "Oeste",
|
||||||
|
"northeast": "Noreste",
|
||||||
|
"northwest": "Noroeste",
|
||||||
|
"southeast": "Sureste",
|
||||||
|
"southwest": "Suroeste",
|
||||||
|
"up": "Arriba",
|
||||||
|
"down": "Abajo",
|
||||||
|
"inside": "Adentro",
|
||||||
|
"outside": "Afuera",
|
||||||
|
"enter": "Entrar",
|
||||||
|
"exit": "Salir"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"heroTitle": "Ecos de la Ceniza",
|
"heroTitle": "Ecos de las Cenizas",
|
||||||
"heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
|
"heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
|
||||||
"playNow": "Jugar Ahora",
|
"playNow": "Jugar Ahora",
|
||||||
"features": "Características"
|
"features": "Características"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: 'Saira Condensed', system-ui, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add request interceptor to include language preference
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const language = localStorage.getItem('i18nextLng') || 'en'
|
||||||
|
config.headers['Accept-Language'] = language
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
// Add token to requests if it exists
|
// Add token to requests if it exists
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { VitePWA } from 'vite-plugin-pwa'
|
|||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './', // Use relative paths for Electron file:// protocol
|
base: '/', // Changed from ./ to / for better PWA absolute path resolution
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: 'auto',
|
||||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Echoes of the Ash',
|
name: 'Echoes of the Ash',
|
||||||
@@ -40,6 +41,9 @@ export default defineConfig({
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user