diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 74c7e35..e0e3a31 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,48 +30,48 @@ build:web: tags: - docker -# Build Linux AppImage and .deb -build:linux: - stage: build-desktop - image: electronuserland/builder:wine - dependencies: - - build:web - script: - - cd pwa - - npm ci - - npm run electron:build:linux - - echo "=== AppImage size ===" - - ls -lh dist-electron/*.AppImage - - du -h dist-electron/*.AppImage - artifacts: - paths: - - pwa/dist-electron/*.AppImage - expire_in: 1 week - name: "linux-appimage-$CI_COMMIT_TAG" - rules: - - if: '$CI_COMMIT_TAG' - tags: - - docker +# # Build Linux AppImage and .deb +# build:linux: +# stage: build-desktop +# image: electronuserland/builder:wine +# dependencies: +# - build:web +# script: +# - cd pwa +# - npm ci +# - npm run electron:build:linux +# - echo "=== AppImage size ===" +# - ls -lh dist-electron/*.AppImage +# - du -h dist-electron/*.AppImage +# artifacts: +# paths: +# - pwa/dist-electron/*.AppImage +# expire_in: 1 week +# name: "linux-appimage-$CI_COMMIT_TAG" +# rules: +# - if: '$CI_COMMIT_TAG' +# tags: +# - docker -# Build Linux .deb (separate job to avoid size limits) -build:linux-deb: - stage: build-desktop - image: electronuserland/builder:wine - dependencies: - - build:web - script: - - cd pwa - - npm ci - - npm run electron:build:linux - artifacts: - paths: - - pwa/dist-electron/*.deb - expire_in: 1 week - name: "linux-deb-$CI_COMMIT_TAG" - rules: - - if: '$CI_COMMIT_TAG' - tags: - - docker +# # Build Linux .deb (separate job to avoid size limits) +# build:linux-deb: +# stage: build-desktop +# image: electronuserland/builder:wine +# dependencies: +# - build:web +# script: +# - cd pwa +# - npm ci +# - npm run electron:build:linux +# artifacts: +# paths: +# - pwa/dist-electron/*.deb +# expire_in: 1 week +# name: "linux-deb-$CI_COMMIT_TAG" +# rules: +# - if: '$CI_COMMIT_TAG' +# tags: +# - docker # Build Windows executable build:windows: diff --git a/api/items.py b/api/items.py index 280c6b5..d40da64 100644 --- a/api/items.py +++ b/api/items.py @@ -4,7 +4,7 @@ Loads and manages game items from JSON without bot dependencies. """ import json from pathlib import Path -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Union from dataclasses import dataclass @@ -12,8 +12,8 @@ from dataclasses import dataclass class Item: """Represents a game item""" id: str - name: str - description: str + name: Union[str, Dict[str, str]] + description: Union[str, Dict[str, str]] type: str image_path: str = "" emoji: str = "📦" diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index f454e4f..8b66f49 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -12,7 +12,7 @@ import logging from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * -from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity +from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string from .. import database as db from ..items import ItemsManager from .. import game_logic @@ -226,6 +226,11 @@ async def get_game_state(current_user: dict = Depends(get_current_user)): "encumbrance": item_def.encumbrance, "weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {} } + else: + logger.error(f"❌ Item definition not found for equipped item: {inv_item['item_id']} (slot: {slot})") + else: + logger.warning(f"⚠️ Inventory item not found for equipped slot: {slot} (ID: {item_data['item_id']})") + if slot not in equipment: equipment[slot] = None diff --git a/api/services/helpers.py b/api/services/helpers.py index 5e5f0b0..a359579 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -3,11 +3,18 @@ Helper utilities for game calculations and common operations. Contains distance calculations, stamina costs, capacity calculations, etc. """ import math -from typing import Tuple, List, Dict, Any +from typing import Tuple, List, Dict, Any, Union from .. import database as db from ..items import ItemsManager +def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str: + """Helper to safely get string from i18n object or string.""" + if isinstance(value, dict): + return value.get(lang) or value.get('en') or str(value) + return str(value) + + def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float: """ Calculate distance between two points using Euclidean distance. @@ -182,7 +189,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int, items_manag # which cascades to the inventory row. broken_armor.append({ - 'name': armor['item_def'].name, + 'name': get_locale_string(armor['item_def'].name), 'emoji': getattr(armor['item_def'], 'emoji', '🛡️') }) @@ -214,7 +221,7 @@ async def consume_tool_durability(user_id: int, tools: list, inventory: list, it 'unique_item_id': inv_item['unique_item_id'], 'item_id': inv_item['item_id'], 'durability': unique_item['durability'], - 'name': item_def.name, + 'name': get_locale_string(item_def.name), 'emoji': getattr(item_def, 'emoji', '🔧') }) diff --git a/api/world_loader.py b/api/world_loader.py index 19539aa..c252853 100644 --- a/api/world_loader.py +++ b/api/world_loader.py @@ -4,14 +4,14 @@ Loads game data from JSON files without bot dependencies. """ import json from pathlib import Path -from typing import Dict, List, Any, Optional +from typing import Dict, List, Any, Optional, Union from dataclasses import dataclass, field @dataclass class Outcome: """Represents an outcome of an action""" - text: str + text: Union[str, Dict[str, str]] items_reward: Dict[str, int] = field(default_factory=dict) damage_taken: int = 0 @@ -20,7 +20,7 @@ class Outcome: class Action: """Represents an action that can be performed on an interactable""" id: str - label: str + label: Union[str, Dict[str, str]] stamina_cost: int = 2 outcomes: Dict[str, Outcome] = field(default_factory=dict) @@ -32,7 +32,7 @@ class Action: class Interactable: """Represents an interactable object""" id: str - name: str + name: Union[str, Dict[str, str]] image_path: str = "" actions: List[Action] = field(default_factory=list) @@ -52,8 +52,8 @@ class Exit: class Location: """Represents a location in the game world""" id: str - name: str - description: str + name: Union[str, Dict[str, str]] + description: Union[str, Dict[str, str]] image_path: str = "" exits: Dict[str, str] = field(default_factory=dict) # direction -> destination_id exit_stamina: Dict[str, int] = field(default_factory=dict) # direction -> stamina_cost diff --git a/gamedata/interactables.json b/gamedata/interactables.json index ead8e1e..00abba6 100644 --- a/gamedata/interactables.json +++ b/gamedata/interactables.json @@ -2,114 +2,192 @@ "interactables": { "rubble": { "id": "rubble", - "name": "🧱 Pile of Rubble", - "description": "A scattered pile of debris and broken concrete.", + "name": { + "en": "🧱 Pile of Rubble", + "es": "🧱 Pila de escombros" + }, + "description": { + "en": "A scattered pile of debris and broken concrete.", + "es": "Una pila de escombros y cemento roto." + }, "image_path": "images/interactables/rubble.webp", "actions": { "search": { "id": "search", - "label": "\ud83d\udd0e Search Rubble", + "label": { + "en": "🔎 Search Rubble", + "es": "🔎 Buscar en los escombros" + }, "stamina_cost": 2 } } }, "dumpster": { "id": "dumpster", - "name": "\ud83d\uddd1\ufe0f Dumpster", - "description": "A rusted metal dumpster, possibly containing scavenged goods.", + "name": { + "en": "🗑️ Dumpster", + "es": "🗑️ Contenedor de basura" + }, + "description": { + "en": "A rusted metal dumpster, possibly containing scavenged goods.", + "es": "Un contenedor de basura de metal oxidado, posiblemente conteniendo bienes robados." + }, "image_path": "images/interactables/dumpster.webp", "actions": { "search_dumpster": { "id": "search_dumpster", - "label": "\ud83d\udd0e Dig Through Trash", + "label": { + "en": "🔎 Dig Through Trash", + "es": "🔎 Buscar en la basura" + }, "stamina_cost": 2 } } }, "sedan": { "id": "sedan", - "name": "\ud83d\ude97 Rusty Sedan", - "description": "An abandoned sedan with rusted doors.", + "name": { + "en": "🚗 Rusty Sedan", + "es": "🚗 Sedán oxidado" + }, + "description": { + "en": "An abandoned sedan with rusted doors.", + "es": "Un sedán abandonado con puertas oxidadas." + }, "image_path": "images/interactables/sedan.webp", "actions": { "search_glovebox": { "id": "search_glovebox", - "label": "\ud83d\udd0e Search Glovebox", + "label": { + "en": "🔎 Search Glovebox", + "es": "🔎 Buscar en la guantera" + }, "stamina_cost": 1 }, "pop_trunk": { "id": "pop_trunk", - "label": "\ud83d\udd27 Pop the Trunk", + "label": { + "en": "🔧 Pop the Trunk", + "es": "🔧 Forzar el maletero" + }, "stamina_cost": 3 } } }, "house": { "id": "house", - "name": "\ud83c\udfda\ufe0f Abandoned House", - "description": "A dilapidated house with boarded windows.", + "name": { + "en": "🏚️ Abandoned House", + "es": "🏚️ Casa abandonada" + }, + "description": { + "en": "A dilapidated house with boarded windows.", + "es": "Una casa abandonada con ventanas tapadas." + }, "image_path": "images/interactables/house.webp", "actions": { "search_house": { "id": "search_house", - "label": "\ud83d\udd0e Search House", + "label": { + "en": "🔎 Search House", + "es": "🔎 Buscar en la casa" + }, "stamina_cost": 3 } } }, "toolshed": { "id": "toolshed", - "name": "\ud83d\udd28 Tool Shed", - "description": "A small wooden shed, door slightly ajar.", + "name": { + "en": "🔨 Tool Shed", + "es": "🔨 Almacén de herramientas" + }, + "description": { + "en": "A small wooden shed, door slightly ajar.", + "es": "Un pequeño almacén de madera, la puerta está ligeramente abierta." + }, "image_path": "images/interactables/toolshed.webp", "actions": { "search_shed": { "id": "search_shed", - "label": "\ud83d\udd0e Search Shed", + "label": { + "en": "🔎 Search Shed", + "es": "🔎 Buscar en el almacén" + }, "stamina_cost": 2 } } }, "medkit": { "id": "medkit", - "name": "\ud83c\udfe5 Medical Supply Cabinet", - "description": "A white metal cabinet with a red cross symbol.", + "name": { + "en": "🏥 Medical Supply Cabinet", + "es": "🏥 Armario de suministros médicos" + }, + "description": { + "en": "A white metal cabinet with a red cross symbol.", + "es": "Un armario de metal blanco con un símbolo de cruz roja." + }, "image_path": "images/interactables/medkit.webp", "actions": { "search_medkit": { "id": "search_medkit", - "label": "\ud83d\udd0e Search Cabinet", + "label": { + "en": "🔎 Search Cabinet", + "es": "🔎 Buscar en el armario" + }, "stamina_cost": 2 } } }, "storage_box": { "id": "storage_box", - "name": "📦 Storage Box", - "description": "A weathered storage container.", + "name": { + "en": "📦 Storage Box", + "es": "📦 Caja de almacenamiento" + }, + "description": { + "en": "A weathered storage container.", + "es": "Un contenedor de almacenamiento desgastado." + }, "image_path": "images/interactables/storage_box.webp", "actions": { "search": { "id": "search", - "label": "\ud83d\udd0e Search Box", + "label": { + "en": "🔎 Search Box", + "es": "🔎 Buscar en la caja" + }, "stamina_cost": 2 } } }, "vending_machine": { "id": "vending_machine", - "name": "\ud83e\uddc3 Vending Machine", - "description": "A broken vending machine, glass shattered.", + "name": { + "en": "🧃 Vending Machine", + "es": "🧃 Máquina expendedora" + }, + "description": { + "en": "A broken vending machine, glass shattered.", + "es": "Una máquina expendedora rota, el vidrio está roto." + }, "image_path": "images/interactables/vending.webp", "actions": { "break": { "id": "break", - "label": "\ud83d\udd28 Break Open", + "label": { + "en": "🔨 Break Open", + "es": "🔨 Forzar la máquina" + }, "stamina_cost": 5 }, "search": { "id": "search", - "label": "\ud83d\udd0e Search Machine", + "label": { + "en": "🔎 Search Machine", + "es": "🔎 Buscar en la máquina" + }, "stamina_cost": 2 } } diff --git a/gamedata/items.json b/gamedata/items.json index 4e53f1a..aedcc40 100644 --- a/gamedata/items.json +++ b/gamedata/items.json @@ -1,48 +1,78 @@ { "items": { "scrap_metal": { - "name": "Scrap Metal", + "name": { + "en": "Scrap Metal", + "es": "Metal desechado" + }, "type": "resource", "weight": 0.5, "volume": 0.2, - "emoji": "\u2699\ufe0f", + "emoji": "⚙️", "image_path": "images/items/scrap_metal.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "rusty_nails": { - "name": "Rusty Nails", + "name": { + "en": "Rusty Nails", + "es": "Clavos oxidados" + }, "weight": 0.2, "volume": 0.1, "type": "resource", - "emoji": "\ud83d\udccc", + "emoji": "📌", "image_path": "images/items/rusty_nails.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "wood_planks": { - "name": "Wood Planks", + "name": { + "en": "Wood Planks", + "es": "Tablillas de madera" + }, "weight": 3.0, "volume": 2.0, "type": "resource", - "emoji": "\ud83e\udeb5", + "emoji": "🪵", "image_path": "images/items/wood_planks.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "cloth_scraps": { - "name": "Cloth Scraps", + "name": { + "en": "Cloth Scraps", + "es": "Ramas de tela" + }, "weight": 0.1, "volume": 0.2, "type": "resource", - "emoji": "\ud83e\uddf5", + "emoji": "🧵", "image_path": "images/items/cloth_scraps.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "cloth": { - "name": "Cloth", + "name": { + "en": "Cloth", + "es": "Tela" + }, "type": "resource", "weight": 0.1, "volume": 0.2, - "emoji": "\ud83e\uddf5", - "description": "A raw material used for crafting and upgrades.", + "emoji": "🧵", + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + }, "image_path": "images/items/cloth.webp", "uncraftable": true, "uncraft_yield": [ @@ -59,187 +89,301 @@ ] }, "plastic_bottles": { - "name": "Plastic Bottles", + "name": { + "en": "Plastic Bottles", + "es": "Botellas de plástico" + }, "weight": 0.05, "volume": 0.3, "type": "resource", - "emoji": "\ud83c\udf76", + "emoji": "🍶", "image_path": "images/items/plastic_bottles.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "bone": { - "name": "Bone", + "name": { + "en": "Bone", + "es": "Hueso" + }, "weight": 0.3, "volume": 0.1, "type": "resource", - "emoji": "\ud83e\uddb4", + "emoji": "🦴", "image_path": "images/items/bone.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "raw_meat": { - "name": "Raw Meat", + "name": { + "en": "Raw Meat", + "es": "Carne cruda" + }, "weight": 0.5, "volume": 0.2, "type": "resource", - "emoji": "\ud83e\udd69", + "emoji": "🥩", "image_path": "images/items/raw_meat.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "animal_hide": { - "name": "Animal Hide", + "name": { + "en": "Animal Hide", + "es": "Piel de animal" + }, "weight": 0.4, "volume": 0.3, "type": "resource", - "emoji": "\ud83e\udde4", + "emoji": "🧤", "image_path": "images/items/animal_hide.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "mutant_tissue": { - "name": "Mutant Tissue", + "name": { + "en": "Mutant Tissue", + "es": "Piel de mutante" + }, "weight": 0.2, "volume": 0.1, "type": "resource", - "emoji": "\ud83e\uddec", + "emoji": "🧬", "image_path": "images/items/mutant_tissue.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "infected_tissue": { - "name": "Infected Tissue", + "name": { + "en": "Infected Tissue", + "es": "Piel infectada" + }, "weight": 0.2, "volume": 0.1, "type": "resource", - "emoji": "\u2623\ufe0f", + "emoji": "☣️", "image_path": "images/items/infected_tissue.webp", - "description": "A raw material used for crafting and upgrades." + "description": { + "en": "A raw material used for crafting and upgrades.", + "es": "Un material bruto utilizado para la fabricación y las mejoras." + } }, "stale_chocolate_bar": { - "name": "Stale Chocolate Bar", + "name": { + "en": "Stale Chocolate Bar", + "es": "Barra de chocolate caducada" + }, "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 10, - "emoji": "\ud83c\udf6b", + "emoji": "🍫", "image_path": "images/items/stale_chocolate_bar.webp", - "description": "Can be consumed to restore health or stamina." + "description": { + "en": "Can be consumed to restore health or stamina.", + "es": "Se puede consumir para restaurar salud o stamina." + } }, "canned_beans": { - "name": "Canned Beans", + "name": { + "en": "Canned Beans", + "es": "Frijoles enlatados" + }, "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 20, "stamina_restore": 5, - "emoji": "\ud83e\udd6b", + "emoji": "🥫", "image_path": "images/items/canned_beans.webp", - "description": "Can be consumed to restore health or stamina." + "description": { + "en": "Can be consumed to restore health or stamina.", + "es": "Se puede consumir para restaurar salud o stamina." + } }, "canned_food": { - "name": "Canned Food", + "name": { + "en": "Canned Food", + "es": "Comida enlatada" + }, "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 25, "stamina_restore": 5, - "emoji": "\ud83e\udd6b", + "emoji": "🥫", "image_path": "images/items/canned_food.webp", - "description": "Can be consumed to restore health or stamina." + "description": { + "en": "Can be consumed to restore health or stamina.", + "es": "Se puede consumir para restaurar salud o stamina." + } }, "bottled_water": { - "name": "Bottled Water", + "name": { + "en": "Bottled Water", + "es": "Agua embotellada" + }, "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, - "emoji": "\ud83d\udca7", + "emoji": "💧", "image_path": "images/items/bottled_water.webp", - "description": "Can be consumed to restore health or stamina." + "description": { + "en": "Can be consumed to restore health or stamina.", + "es": "Se puede consumir para restaurar salud o stamina." + } }, "water_bottle": { - "name": "Water Bottle", + "name": { + "en": "Water Bottle", + "es": "Botella de agua" + }, "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, - "emoji": "\ud83d\udca7", + "emoji": "💧", "image_path": "images/items/water_bottle.webp", - "description": "Can be consumed to restore health or stamina." + "description": { + "en": "Can be consumed to restore health or stamina.", + "es": "Se puede consumir para restaurar salud o stamina." + } }, "energy_bar": { - "name": "Energy Bar", + "name": { + "en": "Energy Bar", + "es": "Barra de energía" + }, "weight": 0.1, "volume": 0.1, "type": "consumable", "stamina_restore": 15, - "emoji": "\ud83c\udf6b", + "emoji": "🍫", "image_path": "images/items/energy_bar.webp", - "description": "Can be consumed to restore health or stamina." + "description": { + "en": "Can be consumed to restore health or stamina.", + "es": "Se puede consumir para restaurar salud o stamina." + } }, "mystery_pills": { - "name": "Mystery Pills", + "name": { + "en": "Mystery Pills", + "es": "Píldoras misteriosas" + }, "weight": 0.05, "volume": 0.05, "type": "consumable", "hp_restore": 30, - "emoji": "\ud83d\udc8a", + "emoji": "💊", "image_path": "images/items/mystery_pills.webp", - "description": "Can be consumed to restore health or stamina." + "description": { + "en": "Can be consumed to restore health or stamina.", + "es": "Se puede consumir para restaurar salud o stamina." + } }, "first_aid_kit": { - "name": "First Aid Kit", - "description": "A professional medical kit with bandages, antiseptic, and pain relievers.", + "name": { + "en": "First Aid Kit", + "es": "Kit de primeros auxilios" + }, + "description": { + "en": "A professional medical kit with bandages, antiseptic, and pain relievers.", + "es": "Un kit médico profesional con vendajes, antisépticos y analgésicos." + }, "weight": 0.8, "volume": 0.5, "type": "consumable", "hp_restore": 50, - "emoji": "\ud83e\ude79", + "emoji": "🩹", "image_path": "images/items/first_aid_kit.webp" }, "bandage": { - "name": "Bandage", - "description": "Clean cloth bandages for treating minor wounds. Can stop bleeding.", + "name": { + "en": "Bandage", + "es": "Vendaje" + }, + "description": { + "en": "Clean cloth bandages for treating minor wounds. Can stop bleeding.", + "es": "Vendajes limpios de tela para tratar heridas menores. Pueden detener la sangrado." + }, "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 15, "treats": "Bleeding", - "emoji": "\ud83e\ude79", + "emoji": "🩹", "image_path": "images/items/bandage.webp" }, "medical_supplies": { - "name": "Medical Supplies", - "description": "Assorted medical supplies scavenged from a clinic.", + "name": { + "en": "Medical Supplies", + "es": "Suministros médicos" + }, + "description": { + "en": "Assorted medical supplies scavenged from a clinic.", + "es": "Suministros médicos diversos robados de una clínica." + }, "weight": 0.6, "volume": 0.4, "type": "consumable", "hp_restore": 40, - "emoji": "\u2695\ufe0f", + "emoji": "⚕️", "image_path": "images/items/medical_supplies.webp" }, "antibiotics": { - "name": "Antibiotics", - "description": "Pills that fight infections. Expired, but better than nothing.", + "name": { + "en": "Antibiotics", + "es": "Antibióticos" + }, + "description": { + "en": "Pills that fight infections. Expired, but better than nothing.", + "es": "Píldoras que combaten las infecciones. Caducadas, pero mejor que nada." + }, "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 20, "treats": "Infected", - "emoji": "\ud83d\udc8a", + "emoji": "💊", "image_path": "images/items/antibiotics.webp" }, "rad_pills": { - "name": "Rad Pills", - "description": "Anti-radiation medication. Helps flush radioactive particles from the body.", + "name": { + "en": "Rad Pills", + "es": "Píldoras de radiación" + }, + "description": { + "en": "Anti-radiation medication. Helps flush radioactive particles from the body.", + "es": "Medicamento antirradiación. Ayuda a eliminar partículas radiactivas del cuerpo." + }, "weight": 0.05, "volume": 0.05, "type": "consumable", "hp_restore": 5, "treats": "Radiation", - "emoji": "\u2622\ufe0f", + "emoji": "☢️", "image_path": "images/items/rad_pills.webp" }, "tire_iron": { - "name": "Tire Iron", - "description": "A heavy metal tool. Makes a decent improvised weapon.", + "name": { + "en": "Tire Iron", + "es": "Herramienta de neumático" + }, + "description": { + "en": "A heavy metal tool. Makes a decent improvised weapon.", + "es": "Un herramienta de metal pesado. Sirve como un buen arma improvisada." + }, "weight": 2.0, "volume": 1.0, "type": "weapon", @@ -252,17 +396,23 @@ "damage_min": 3, "damage_max": 5 }, - "emoji": "\ud83d\udd27", + "emoji": "🔧", "image_path": "images/items/tire_iron.webp" }, "baseball_bat": { - "name": "Baseball Bat", - "description": "Wooden bat with dents and bloodstains. Someone used this before you.", + "name": { + "en": "Baseball Bat", + "es": "Bate de béisbol" + }, + "description": { + "en": "Wooden bat with dents and bloodstains. Someone used this before you.", + "es": "Bate de béisbol con dientes y manchas de sangre. Alguien lo usó antes que tú." + }, "weight": 1.0, "volume": 1.5, "type": "weapon", "slot": "hand", - "emoji": "\u26be", + "emoji": "⚾", "image_path": "images/items/baseball_bat.webp", "stats": { "damage_min": 5, @@ -270,8 +420,14 @@ } }, "rusty_knife": { - "name": "Rusty Knife", - "description": "A dull, rusted blade. Better than your fists.", + "name": { + "en": "Rusty Knife", + "es": "Navaja oxidada" + }, + "description": { + "en": "A dull, rusted blade. Better than your fists.", + "es": "Una navaja desgastada y oxidada. Mejor que tus puños." + }, "weight": 0.3, "volume": 0.2, "type": "weapon", @@ -296,12 +452,18 @@ "damage_min": 2, "damage_max": 5 }, - "emoji": "\ud83d\udd2a", + "emoji": "🔪", "image_path": "images/items/rusty_knife.webp" }, "knife": { - "name": "Knife", - "description": "A sharp survival knife in decent condition.", + "name": { + "en": "Knife", + "es": "" + }, + "description": { + "en": "A sharp survival knife in decent condition.", + "es": "" + }, "weight": 0.3, "volume": 0.2, "type": "weapon", @@ -379,17 +541,23 @@ "duration": 3 } }, - "emoji": "\ud83d\udd2a", + "emoji": "🔪", "image_path": "images/items/knife.webp" }, "rusty_pipe": { - "name": "Rusty Pipe", - "description": "Heavy metal pipe. Crude but effective.", + "name": { + "en": "Rusty Pipe", + "es": "" + }, + "description": { + "en": "Heavy metal pipe. Crude but effective.", + "es": "" + }, "weight": 1.5, "volume": 0.8, "type": "weapon", "slot": "hand", - "emoji": "\ud83d\udd29", + "emoji": "🔩", "image_path": "images/items/rusty_pipe.webp", "stats": { "damage_min": 5, @@ -397,8 +565,14 @@ } }, "tattered_rucksack": { - "name": "Tattered Rucksack", - "description": "An old backpack with torn straps. Still functional.", + "name": { + "en": "Tattered Rucksack", + "es": "" + }, + "description": { + "en": "An old backpack with torn straps. Still functional.", + "es": "" + }, "weight": 1.0, "volume": 0.5, "type": "backpack", @@ -434,12 +608,18 @@ "weight_capacity": 10, "volume_capacity": 10 }, - "emoji": "\ud83c\udf92", + "emoji": "🎒", "image_path": "images/items/tattered_rucksack.webp" }, "hiking_backpack": { - "name": "Hiking Backpack", - "description": "A quality backpack with multiple compartments.", + "name": { + "en": "Hiking Backpack", + "es": "" + }, + "description": { + "en": "A quality backpack with multiple compartments.", + "es": "" + }, "weight": 1.5, "volume": 0.7, "type": "backpack", @@ -464,17 +644,23 @@ "weight_capacity": 20, "volume_capacity": 20 }, - "emoji": "\ud83c\udf92", + "emoji": "🎒", "image_path": "images/items/hiking_backpack.webp" }, "flashlight": { - "name": "Flashlight", - "description": "A battery-powered flashlight. Batteries low but working.", + "name": { + "en": "Flashlight", + "es": "" + }, + "description": { + "en": "A battery-powered flashlight. Batteries low but working.", + "es": "" + }, "weight": 0.3, "volume": 0.2, "type": "tool", "slot": "tool", - "emoji": "\ud83d\udd26", + "emoji": "🔦", "image_path": "images/items/flashlight.webp", "stats": { "damage_min": 5, @@ -482,26 +668,44 @@ } }, "old_photograph": { - "name": "Old Photograph", + "name": { + "en": "Old Photograph", + "es": "" + }, "weight": 0.01, "volume": 0.01, "type": "quest", - "emoji": "\ud83d\udcf7", + "emoji": "📷", "image_path": "images/items/old_photograph.webp", - "description": "A useful old photograph." + "description": { + "en": "A useful old photograph.", + "es": "" + } }, "key_ring": { - "name": "Key Ring", + "name": { + "en": "Key Ring", + "es": "" + }, "weight": 0.1, "volume": 0.05, "type": "quest", - "emoji": "\ud83d\udd11", + "emoji": "🔑", "image_path": "images/items/key_ring.webp", - "description": "A useful key ring." + "description": { + "en": "A useful key ring.", + "es": "" + } }, "makeshift_spear": { - "name": "Makeshift Spear", - "description": "A crude spear made from a sharpened stick and scrap metal.", + "name": { + "en": "Makeshift Spear", + "es": "" + }, + "description": { + "en": "A crude spear made from a sharpened stick and scrap metal.", + "es": "" + }, "weight": 1.2, "volume": 2.0, "type": "weapon", @@ -541,12 +745,18 @@ "damage_min": 4, "damage_max": 7 }, - "emoji": "\u2694\ufe0f", + "emoji": "⚔️", "image_path": "images/items/makeshift_spear.webp" }, "reinforced_bat": { - "name": "Reinforced Bat", - "description": "A wooden bat wrapped with scrap metal and nails. Brutal.", + "name": { + "en": "Reinforced Bat", + "es": "" + }, + "description": { + "en": "A wooden bat wrapped with scrap metal and nails. Brutal.", + "es": "" + }, "weight": 1.8, "volume": 1.5, "type": "weapon", @@ -592,12 +802,18 @@ "duration": 1 } }, - "emoji": "\ud83c\udff8", + "emoji": "🏸", "image_path": "images/items/reinforced_bat.webp" }, "leather_vest": { - "name": "Leather Vest", - "description": "A makeshift vest crafted from leather scraps. Provides basic protection.", + "name": { + "en": "Leather Vest", + "es": "" + }, + "description": { + "en": "A makeshift vest crafted from leather scraps. Provides basic protection.", + "es": "" + }, "weight": 1.5, "volume": 1.0, "type": "armor", @@ -637,12 +853,18 @@ "armor": 3, "hp_bonus": 10 }, - "emoji": "\ud83e\uddba", + "emoji": "🦺", "image_path": "images/items/leather_vest.webp" }, "cloth_bandana": { - "name": "Cloth Bandana", - "description": "A simple cloth head covering. Keeps the sun and dust out.", + "name": { + "en": "Cloth Bandana", + "es": "" + }, + "description": { + "en": "A simple cloth head covering. Keeps the sun and dust out.", + "es": "" + }, "weight": 0.1, "volume": 0.1, "type": "clothing", @@ -669,12 +891,18 @@ "stats": { "armor": 1 }, - "emoji": "\ud83e\udde3", + "emoji": "🧣", "image_path": "images/items/cloth_bandana.webp" }, "sturdy_boots": { - "name": "Sturdy Boots", - "description": "Reinforced boots for traversing the wasteland.", + "name": { + "en": "Sturdy Boots", + "es": "" + }, + "description": { + "en": "Reinforced boots for traversing the wasteland.", + "es": "" + }, "weight": 1.0, "volume": 0.8, "type": "clothing", @@ -714,12 +942,18 @@ "armor": 2, "stamina_bonus": 5 }, - "emoji": "\ud83e\udd7e", + "emoji": "🥾", "image_path": "images/items/sturdy_boots.webp" }, "padded_pants": { - "name": "Padded Pants", - "description": "Pants reinforced with extra padding for protection.", + "name": { + "en": "Padded Pants", + "es": "" + }, + "description": { + "en": "Pants reinforced with extra padding for protection.", + "es": "" + }, "weight": 0.8, "volume": 0.6, "type": "armor", @@ -755,12 +989,18 @@ "armor": 2, "hp_bonus": 5 }, - "emoji": "\ud83d\udc56", + "emoji": "👖", "image_path": "images/items/padded_pants.webp" }, "reinforced_pack": { - "name": "Reinforced Pack", - "description": "A custom-built backpack with metal frame and extra pockets.", + "name": { + "en": "Reinforced Pack", + "es": "" + }, + "description": { + "en": "A custom-built backpack with metal frame and extra pockets.", + "es": "" + }, "weight": 2.0, "volume": 0.9, "type": "backpack", @@ -839,12 +1079,18 @@ "weight_capacity": 30, "volume_capacity": 30 }, - "emoji": "\ud83c\udf92", + "emoji": "🎒", "image_path": "images/items/reinforced_pack.webp" }, "hammer": { - "name": "Hammer", - "description": "A basic tool for crafting and repairs. Essential for any survivor.", + "name": { + "en": "Hammer", + "es": "" + }, + "description": { + "en": "A basic tool for crafting and repairs. Essential for any survivor.", + "es": "" + }, "weight": 0.8, "volume": 0.4, "type": "tool", @@ -872,12 +1118,18 @@ } ], "repair_percentage": 30, - "emoji": "\ud83d\udd28", + "emoji": "🔨", "image_path": "images/items/hammer.webp" }, "screwdriver": { - "name": "Screwdriver", - "description": "A flathead screwdriver. Useful for repairs and scavenging.", + "name": { + "en": "Screwdriver", + "es": "" + }, + "description": { + "en": "A flathead screwdriver. Useful for repairs and scavenging.", + "es": "" + }, "weight": 0.2, "volume": 0.2, "type": "tool", @@ -905,7 +1157,7 @@ } ], "repair_percentage": 25, - "emoji": "\ud83e\ude9b", + "emoji": "🪛", "image_path": "images/items/screwdriver.webp", "stats": { "damage_min": 5, diff --git a/gamedata/locations.json b/gamedata/locations.json index 76862e7..703bb5a 100644 --- a/gamedata/locations.json +++ b/gamedata/locations.json @@ -2,8 +2,14 @@ "locations": [ { "id": "start_point", - "name": "\ud83c\udf06 Ruined Downtown Core", - "description": "The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt. You sense danger, but also opportunity.", + "name": { + "en": "🌆 Ruined Downtown Core", + "es": "🌆 Centro de la ciudad destruido" + }, + "description": { + "en": "The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt. You sense danger, but also opportunity.", + "es": "El viento ruge a través de los esqueléticos rascacielos. El desastre llena el asfalto roto. Sientes el peligro, pero también la oportunidad." + }, "image_path": "images/locations/downtown.webp", "x": 0, "y": 0, @@ -33,10 +39,19 @@ "stamina_cost": 2, "success_rate": 0.5, "text": { - "crit_failure": "You disturb a nest of rats! They bite you! (-8 HP)", + "crit_failure": { + "en": "You disturb a nest of rats! They bite you!", + "es": "Te topas con una colmena de ratones. Te muerden!" + }, "crit_success": "", - "failure": "Just rotting garbage. Nothing useful.", - "success": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps]." + "failure": { + "en": "Just rotting garbage. Nothing useful.", + "es": "Solo escombros rotos. Nada útil." + }, + "success": { + "en": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].", + "es": "A pesar del olor, encuentras algunos [Botellas de plástico] y [Ramas de tela]." + } } } }, @@ -64,8 +79,14 @@ "text": { "crit_failure": "", "crit_success": "", - "failure": "The trunk is rusted shut. You can't get it open.", - "success": "With a great heave, you pry the trunk open and find a [Tire Iron]!" + "failure": { + "en": "The trunk is rusted shut. You can't get it open.", + "es": "El maletero está oxidado. No puedes abrirlo." + }, + "success": { + "en": "With a great heave, you pry the trunk open and find a [Tire Iron]!", + "es": "Con un gran esfuerzo, pruebas el maletero y encuentras una [Herramienta de neumáticos]!" + } } }, "search_glovebox": { @@ -88,8 +109,14 @@ "text": { "crit_failure": "", "crit_success": "", - "failure": "The glovebox is empty except for dust and old receipts.", - "success": "You find a half-eaten [Stale Chocolate Bar]." + "failure": { + "en": "The glovebox is empty except for dust and old receipts.", + "es": "" + }, + "success": { + "en": "You find a half-eaten [Stale Chocolate Bar].", + "es": "" + } } } }, @@ -99,8 +126,14 @@ }, { "id": "gas_station", - "name": "\u26fd\ufe0f Abandoned Gas Station", - "description": "The smell of stale gasoline hangs in the air. A rusty sedan sits by the pumps, its door ajar. Behind the station, you spot a small tool shed with a workbench.", + "name": { + "en": "⛽️ Abandoned Gas Station", + "es": "⛽️ Gasolinera abandonada" + }, + "description": { + "en": "The smell of stale gasoline hangs in the air. A rusty sedan sits by the pumps, its door ajar. Behind the station, you spot a small tool shed with a workbench.", + "es": "El olor a gasolina se suspende en el aire. Un sedán oxidado está en los surtidores, su puerta está abierta. Por detrás de la gasolinera, ves un pequeño almacén de herramientas con una mesa de trabajo." + }, "image_path": "images/locations/gas_station.webp", "x": 0, "y": 2, @@ -141,10 +174,22 @@ ] }, "text": { - "success": "You find some cloth scraps and plastic in the glovebox.", - "failure": "The glovebox is empty except for old papers.", - "crit_success": "You find scrap metal from the dashboard!", - "crit_failure": "The glovebox is jammed shut." + "success": { + "en": "You find some cloth scraps and plastic in the glovebox.", + "es": "" + }, + "failure": { + "en": "The glovebox is empty except for old papers.", + "es": "" + }, + "crit_success": { + "en": "You find scrap metal from the dashboard!", + "es": "" + }, + "crit_failure": { + "en": "The glovebox is jammed shut.", + "es": "" + } } }, "pop_trunk": { @@ -176,10 +221,22 @@ ] }, "text": { - "success": "You force the trunk open and find scrap metal and plastic.", - "failure": "The trunk is rusted shut.", - "crit_success": "The trunk contains tools!", - "crit_failure": "You cut your hand on rusty metal! (-5 HP)" + "success": { + "en": "You force the trunk open and find scrap metal and plastic.", + "es": "" + }, + "failure": { + "en": "The trunk is rusted shut.", + "es": "" + }, + "crit_success": { + "en": "The trunk contains tools!", + "es": "" + }, + "crit_failure": { + "en": "You cut your hand on rusty metal! (-5 HP)", + "es": "" + } } } } @@ -216,10 +273,22 @@ ] }, "text": { - "success": "You find scrap metal and cloth in the storage box.", - "failure": "The storage box is mostly empty.", - "crit_success": "You discover tools inside!", - "crit_failure": "Just oil stains and rust." + "success": { + "en": "You find scrap metal and cloth in the storage box.", + "es": "" + }, + "failure": { + "en": "The storage box is mostly empty.", + "es": "" + }, + "crit_success": { + "en": "You discover tools inside!", + "es": "" + }, + "crit_failure": { + "en": "Just oil stains and rust.", + "es": "" + } } } } @@ -228,8 +297,14 @@ }, { "id": "residential", - "name": "\ud83c\udfd8\ufe0f Residential Street", - "description": "A quiet suburban street lined with abandoned homes. Most are boarded up, but a few doors hang open, creaking in the wind.", + "name": { + "en": "🏘️ Residential Street", + "es": "🏘️ Calle residencial" + }, + "description": { + "en": "A quiet suburban street lined with abandoned homes. Most are boarded up, but a few doors hang open, creaking in the wind.", + "es": "Una tranquila calle suburbana llena de casas abandonadas. La mayoría están tapiadas, pero algunas puertas están abiertas, movidas por el viento." + }, "image_path": "images/locations/residential.webp", "x": 3, "y": 0, @@ -264,10 +339,19 @@ "stamina_cost": 3, "success_rate": 0.5, "text": { - "crit_failure": "The floor collapses beneath you! (-10 HP)", + "crit_failure": { + "en": "The floor collapses beneath you! (-10 HP)", + "es": "" + }, "crit_success": "", - "failure": "The house has already been thoroughly looted. Nothing remains.", - "success": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!" + "failure": { + "en": "The house has already been thoroughly looted. Nothing remains.", + "es": "" + }, + "success": { + "en": "You find some useful supplies: [Canned Beans], [Bottled Water], and [Cloth Scraps]!", + "es": "" + } } } }, @@ -277,8 +361,14 @@ }, { "id": "clinic", - "name": "\ud83c\udfe5 Old Clinic", - "description": "A small medical clinic, its windows shattered. The waiting room is a mess of overturned chairs and scattered papers. The examination rooms might still have supplies.", + "name": { + "en": "🏥 Old Clinic", + "es": "🏥 Clínica abandonada" + }, + "description": { + "en": "A small medical clinic, its windows shattered. The waiting room is a mess of overturned chairs and scattered papers. The examination rooms might still have supplies.", + "es": "Una pequeña clínica médica, sus ventanas están rotas. El salón de espera es un desastre de sillas invertidas y papeles dispersos. Las habitaciones de examen pueden todavía tener suministros." + }, "image_path": "images/locations/clinic.webp", "x": 2, "y": 3, @@ -310,8 +400,14 @@ "text": { "crit_failure": "", "crit_success": "", - "failure": "The cabinet is empty. Someone got here first.", - "success": "Jackpot! You find a [First Aid Kit] and some [Bandages]!" + "failure": { + "en": "The cabinet is empty. Someone got here first.", + "es": "" + }, + "success": { + "en": "Jackpot! You find a [First Aid Kit] and some [Bandages]!", + "es": "" + } } } }, @@ -321,8 +417,14 @@ }, { "id": "plaza", - "name": "\ud83c\udfec Shopping Plaza", - "description": "A strip mall with broken storefronts. Most shops have been thoroughly ransacked, but you might find something if you search carefully.", + "name": { + "en": "🏬 Shopping Plaza", + "es": "🏬 Plaza de comercio" + }, + "description": { + "en": "A strip mall with broken storefronts. Most shops have been thoroughly ransacked, but you might find something if you search carefully.", + "es": "Una plaza de comercio con vitrinas rotas. La mayoría de las tiendas han sido despojadas, pero puedes encontrar algo si buscas con cuidado." + }, "image_path": "images/locations/plaza.webp", "x": -2.5, "y": 0, @@ -359,10 +461,22 @@ ] }, "text": { - "success": "You smash the vending machine and grab bottles and scrap.", - "failure": "The machine is too sturdy to break.", - "crit_success": "Packaged food falls out!", - "crit_failure": "Glass cuts your arm! (-10 HP)" + "success": { + "en": "You smash the vending machine and grab bottles and scrap.", + "es": "" + }, + "failure": { + "en": "The machine is too sturdy to break.", + "es": "" + }, + "crit_success": { + "en": "Packaged food falls out!", + "es": "" + }, + "crit_failure": { + "en": "Glass cuts your arm! (-10 HP)", + "es": "" + } } }, "search": { @@ -389,10 +503,22 @@ ] }, "text": { - "success": "You find a plastic bottle at the bottom.", - "failure": "Nothing left to scavenge.", - "crit_success": "A snack is wedged in the dispenser!", - "crit_failure": "Already picked clean." + "success": { + "en": "You find a plastic bottle at the bottom.", + "es": "" + }, + "failure": { + "en": "Nothing left to scavenge.", + "es": "" + }, + "crit_success": { + "en": "A snack is wedged in the dispenser!", + "es": "" + }, + "crit_failure": { + "en": "Already picked clean.", + "es": "" + } } } } @@ -429,10 +555,22 @@ ] }, "text": { - "success": "You dig through rubble and find scrap metal and cloth.", - "failure": "Just broken concrete and dust.", - "crit_success": "A tool was buried in the debris!", - "crit_failure": "Sharp debris cuts you! (-5 HP)" + "success": { + "en": "You dig through rubble and find scrap metal and cloth.", + "es": "" + }, + "failure": { + "en": "Just broken concrete and dust.", + "es": "" + }, + "crit_success": { + "en": "A tool was buried in the debris!", + "es": "" + }, + "crit_failure": { + "en": "Sharp debris cuts you! (-5 HP)", + "es": "" + } } } } @@ -441,8 +579,14 @@ }, { "id": "park", - "name": "\ud83c\udf33 Suburban Park", - "description": "An overgrown park with rusted playground equipment. Nature is slowly reclaiming this space. A maintenance shed sits at the far end.", + "name": { + "en": "🌳 Suburban Park", + "es": "🌳 Parque suburbano" + }, + "description": { + "en": "An overgrown park with rusted playground equipment. Nature is slowly reclaiming this space. A maintenance shed sits at the far end.", + "es": "Un parque suburbano deshabitado con equipos de juegos oxidados. La naturaleza está reclamando este espacio. Un almacén de mantenimiento se encuentra al final." + }, "image_path": "images/locations/park.webp", "x": -1, "y": -2, @@ -484,8 +628,14 @@ "text": { "crit_failure": "", "crit_success": "", - "failure": "The shed has been picked clean. Only empty shelves remain.", - "success": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!" + "failure": { + "en": "The shed has been picked clean. Only empty shelves remain.", + "es": "" + }, + "success": { + "en": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!", + "es": "" + } } } }, @@ -495,8 +645,14 @@ }, { "id": "overpass", - "name": "\ud83d\udee3\ufe0f Highway Overpass", - "description": "A concrete overpass spanning the cracked highway below. Abandoned vehicles litter the road. This is a good vantage point to survey the area.", + "name": { + "en": "🛣️ Highway Overpass", + "es": "🛣️ Puesto de carretera" + }, + "description": { + "en": "A concrete overpass spanning the cracked highway below. Abandoned vehicles litter the road. This is a good vantage point to survey the area.", + "es": "Un puesto de carretera de cemento que atraviesa la carretera rota por debajo. Vehículos abandonados se desvanecen por la carretera. Este es un buen punto de vista para examinar el área." + }, "x": 1.0, "y": 4.5, "image_path": "images/locations/overpass.webp", @@ -510,8 +666,14 @@ "crit_success_chance": 0.1, "crit_failure_chance": 0.1, "text": { - "success": "You find a half-eaten [Stale Chocolate Bar].", - "failure": "The glovebox is empty except for dust and old receipts.", + "success": { + "en": "You find a half-eaten [Stale Chocolate Bar].", + "es": "" + }, + "failure": { + "en": "The glovebox is empty except for dust and old receipts.", + "es": "" + }, "crit_success": "", "crit_failure": "" }, @@ -534,8 +696,14 @@ "crit_success_chance": 0.1, "crit_failure_chance": 0.1, "text": { - "success": "With a great heave, you pry the trunk open and find a [Tire Iron]!", - "failure": "The trunk is rusted shut. You can't get it open.", + "success": { + "en": "With a great heave, you pry the trunk open and find a [Tire Iron]!", + "es": "" + }, + "failure": { + "en": "The trunk is rusted shut. You can't get it open.", + "es": "" + }, "crit_success": "", "crit_failure": "" }, @@ -563,8 +731,14 @@ "crit_success_chance": 0.1, "crit_failure_chance": 0.1, "text": { - "success": "You find a half-eaten [Stale Chocolate Bar].", - "failure": "The glovebox is empty except for dust and old receipts.", + "success": { + "en": "You find a half-eaten [Stale Chocolate Bar].", + "es": "" + }, + "failure": { + "en": "The glovebox is empty except for dust and old receipts.", + "es": "" + }, "crit_success": "", "crit_failure": "" }, @@ -587,8 +761,14 @@ "crit_success_chance": 0.1, "crit_failure_chance": 0.1, "text": { - "success": "With a great heave, you pry the trunk open and find a [Tire Iron]!", - "failure": "The trunk is rusted shut. You can't get it open.", + "success": { + "en": "With a great heave, you pry the trunk open and find a [Tire Iron]!", + "es": "" + }, + "failure": { + "en": "The trunk is rusted shut. You can't get it open.", + "es": "" + }, "crit_success": "", "crit_failure": "" }, @@ -611,8 +791,14 @@ }, { "id": "warehouse", - "name": "\ud83c\udfed Warehouse District", - "description": "Rows of industrial warehouses stretch before you. Metal doors creak in the wind. The loading docks are littered with debris and abandoned cargo.", + "name": { + "en": "🏭 Warehouse District", + "es": "" + }, + "description": { + "en": "Rows of industrial warehouses stretch before you. Metal doors creak in the wind. The loading docks are littered with debris and abandoned cargo.", + "es": "" + }, "image_path": "images/locations/warehouse.webp", "x": 4, "y": -1.5, @@ -642,10 +828,19 @@ "stamina_cost": 2, "success_rate": 0.5, "text": { - "crit_failure": "You disturb a nest of rats! They bite you! (-8 HP)", + "crit_failure": { + "en": "You disturb a nest of rats! They bite you! (-8 HP)", + "es": "" + }, "crit_success": "", - "failure": "Just rotting garbage. Nothing useful.", - "success": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps]." + "failure": { + "en": "Just rotting garbage. Nothing useful.", + "es": "" + }, + "success": { + "en": "Despite the smell, you find some [Plastic Bottles] and [Cloth Scraps].", + "es": "" + } } } }, @@ -683,8 +878,14 @@ "text": { "crit_failure": "", "crit_success": "", - "failure": "The shed has been picked clean. Only empty shelves remain.", - "success": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!" + "failure": { + "en": "The shed has been picked clean. Only empty shelves remain.", + "es": "" + }, + "success": { + "en": "You find some tools: [Rusty Nails], [Wood Planks], and a [Flashlight]!", + "es": "" + } } } }, @@ -694,8 +895,14 @@ }, { "id": "warehouse_interior", - "name": "\ud83d\udce6 Warehouse Interior", - "description": "Inside the warehouse, towering shelves cast long shadows. Scattered crates and pallets suggest this was once a distribution center. The back office door hangs open.", + "name": { + "en": "📦 Warehouse Interior", + "es": "" + }, + "description": { + "en": "Inside the warehouse, towering shelves cast long shadows. Scattered crates and pallets suggest this was once a distribution center. The back office door hangs open.", + "es": "" + }, "image_path": "images/locations/warehouse_interior.webp", "x": 4.5, "y": -2, @@ -709,8 +916,14 @@ "crit_success_chance": 0, "crit_failure_chance": 0, "text": { - "success": "You successfully \ud83d\udd0e search box.", - "failure": "You failed to \ud83d\udd0e search box.", + "success": { + "en": "You successfully 🔎 search box.", + "es": "" + }, + "failure": { + "en": "You failed to 🔎 search box.", + "es": "" + }, "crit_success": "", "crit_failure": "" }, @@ -738,8 +951,14 @@ }, { "id": "subway", - "name": "\ud83d\ude87 Subway Station Entrance", - "description": "Stairs descend into darkness. The entrance to an abandoned subway station yawns before you. Emergency lighting flickers somewhere below.", + "name": { + "en": "🚇 Subway Station Entrance", + "es": "" + }, + "description": { + "en": "Stairs descend into darkness. The entrance to an abandoned subway station yawns before you. Emergency lighting flickers somewhere below.", + "es": "" + }, "image_path": "images/locations/subway.webp", "x": -4, "y": -0.5, @@ -775,10 +994,22 @@ "stamina_cost": 2, "success_rate": 0.55, "text": { - "crit_failure": "Debris shifts and hits your leg! (-4 HP)", - "crit_success": "You uncover a tool buried deep!", - "failure": "Just concrete chunks.", - "success": "You sift through rubble and find scrap metal." + "crit_failure": { + "en": "Debris shifts and hits your leg! (-4 HP)", + "es": "" + }, + "crit_success": { + "en": "You uncover a tool buried deep!", + "es": "" + }, + "failure": { + "en": "Just concrete chunks.", + "es": "" + }, + "success": { + "en": "You sift through rubble and find scrap metal.", + "es": "" + } } } }, @@ -810,10 +1041,22 @@ "stamina_cost": 5, "success_rate": 0.6, "text": { - "crit_failure": "The machine topples on you! (-12 HP)", - "crit_success": "Food packages tumble out!", - "failure": "The machine won't budge.", - "success": "You bash open the vending machine and grab bottles." + "crit_failure": { + "en": "The machine topples on you! (-12 HP)", + "es": "" + }, + "crit_success": { + "en": "Food packages tumble out!", + "es": "" + }, + "failure": { + "en": "The machine won't budge.", + "es": "" + }, + "success": { + "en": "You bash open the vending machine and grab bottles.", + "es": "" + } } }, "search": { @@ -834,10 +1077,22 @@ "stamina_cost": 2, "success_rate": 0.4, "text": { - "crit_failure": "Nothing here.", - "crit_success": "A bottle still rolls out!", - "failure": "Completely empty.", - "success": "You find a bottle in the machine's slot." + "crit_failure": { + "en": "Nothing here.", + "es": "" + }, + "crit_success": { + "en": "A bottle still rolls out!", + "es": "" + }, + "failure": { + "en": "Completely empty.", + "es": "" + }, + "success": { + "en": "You find a bottle in the machine's slot.", + "es": "" + } } } }, @@ -847,8 +1102,14 @@ }, { "id": "subway_tunnels", - "name": "\ud83d\ude8a Subway Tunnels", - "description": "Dark subway tunnels stretch into blackness. Flickering emergency lights cast eerie shadows. The third rail is dead, but you should still watch your step.", + "name": { + "en": "🚊 Subway Tunnels", + "es": "" + }, + "description": { + "en": "Dark subway tunnels stretch into blackness. Flickering emergency lights cast eerie shadows. The third rail is dead, but you should still watch your step.", + "es": "" + }, "image_path": "images/locations/subway_tunnels.webp", "x": -4.5, "y": -1, @@ -880,10 +1141,22 @@ ] }, "text": { - "success": "You find scrap metal in the tunnel debris.", - "failure": "Just rocks and dirt.", - "crit_success": "A maintenance tool was left behind!", - "crit_failure": "You stumble and hit the wall! (-6 HP)" + "success": { + "en": "You find scrap metal in the tunnel debris.", + "es": "" + }, + "failure": { + "en": "Just rocks and dirt.", + "es": "" + }, + "crit_success": { + "en": "A maintenance tool was left behind!", + "es": "" + }, + "crit_failure": { + "en": "You stumble and hit the wall! (-6 HP)", + "es": "" + } } } } @@ -892,8 +1165,14 @@ }, { "id": "office_building", - "name": "\ud83c\udfe2 Office Building", - "description": "A five-story office building with shattered windows. The lobby is trashed, but the stairs appear intact. You can hear the wind whistling through the upper floors.", + "name": { + "en": "🏢 Office Building", + "es": "" + }, + "description": { + "en": "A five-story office building with shattered windows. The lobby is trashed, but the stairs appear intact. You can hear the wind whistling through the upper floors.", + "es": "" + }, "image_path": "images/locations/office_building.webp", "x": 3.5, "y": 4, @@ -924,10 +1203,22 @@ "crit_items": [] }, "text": { - "success": "You find scrap metal and cloth in the lobby debris.", - "failure": "Just broken furniture and papers.", - "crit_success": "You discover useful materials!", - "crit_failure": "Glass cuts your hand! (-5 HP)" + "success": { + "en": "You find scrap metal and cloth in the lobby debris.", + "es": "" + }, + "failure": { + "en": "Just broken furniture and papers.", + "es": "" + }, + "crit_success": { + "en": "You discover useful materials!", + "es": "" + }, + "crit_failure": { + "en": "Glass cuts your hand! (-5 HP)", + "es": "" + } } } } @@ -936,8 +1227,14 @@ }, { "id": "office_interior", - "name": "\ud83d\udcbc Office Floors", - "description": "Cubicles stretch across the floor. Papers scatter in the breeze from broken windows. Filing cabinets stand open, already ransacked. A corner office looks promising.", + "name": { + "en": "💼 Office Floors", + "es": "" + }, + "description": { + "en": "Cubicles stretch across the floor. Papers scatter in the breeze from broken windows. Filing cabinets stand open, already ransacked. A corner office looks promising.", + "es": "" + }, "image_path": "images/locations/office_interior.webp", "x": 4, "y": 4.5, @@ -974,10 +1271,22 @@ ] }, "text": { - "success": "You find cloth and bottles in desk drawers.", - "failure": "Everything's been picked through already.", - "crit_success": "Someone left food in their desk!", - "crit_failure": "Just old paperwork." + "success": { + "en": "You find cloth and bottles in desk drawers.", + "es": "" + }, + "failure": { + "en": "Everything's been picked through already.", + "es": "" + }, + "crit_success": { + "en": "Someone left food in their desk!", + "es": "" + }, + "crit_failure": { + "en": "Just old paperwork.", + "es": "" + } } } } @@ -986,8 +1295,14 @@ }, { "id": "location_1760791397492", - "name": "Subway Section A", - "description": "A shady dimly lit subway section. All you can see are abandoned train tracks and some garbage lying around. ", + "name": { + "en": "Subway Section A", + "es": "" + }, + "description": { + "en": "A shady dimly lit subway section. All you can see are abandoned train tracks and some garbage lying around. ", + "es": "" + }, "image_path": "images/locations/subway_section_a.jpg", "x": -5, "y": -2, @@ -1019,10 +1334,22 @@ ] }, "text": { - "success": "You dig through the garbage and find scrap metal.", - "failure": "Just rotting trash.", - "crit_success": "A tool was discarded here!", - "crit_failure": "You step on sharp debris! (-5 HP)" + "success": { + "en": "You dig through the garbage and find scrap metal.", + "es": "" + }, + "failure": { + "en": "Just rotting trash.", + "es": "" + }, + "crit_success": { + "en": "A tool was discarded here!", + "es": "" + }, + "crit_failure": { + "en": "You step on sharp debris! (-5 HP)", + "es": "" + } } } } diff --git a/gamedata/npcs.json b/gamedata/npcs.json index f0cb12f..5417f20 100644 --- a/gamedata/npcs.json +++ b/gamedata/npcs.json @@ -2,8 +2,14 @@ "npcs": { "feral_dog": { "npc_id": "feral_dog", - "name": "Feral Dog", - "description": "A wild, mangy dog with desperate hunger in its eyes. Its ribs are visible beneath matted fur.", + "name": { + "en": "Feral Dog", + "es": "Perro feroz" + }, + "description": { + "en": "A wild, mangy dog with desperate hunger in its eyes. Its ribs are visible beneath matted fur.", + "es": "Un perro salvaje, desgarrado, con hambre desesperada en sus ojos. Sus huesos están visibles bajo el pelo despeinado." + }, "emoji": "🐕", "hp_min": 15, "hp_max": 25, @@ -46,8 +52,14 @@ }, "raider_scout": { "npc_id": "raider_scout", - "name": "Raider Scout", - "description": "A lone raider wearing makeshift armor. They eye you with hostile intent.", + "name": { + "en": "Raider Scout", + "es": "" + }, + "description": { + "en": "A lone raider wearing makeshift armor. They eye you with hostile intent.", + "es": "" + }, "emoji": "🏴‍☠️", "hp_min": 30, "hp_max": 45, @@ -102,8 +114,14 @@ }, "mutant_rat": { "npc_id": "mutant_rat", - "name": "Mutant Rat", - "description": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.", + "name": { + "en": "Mutant Rat", + "es": "" + }, + "description": { + "en": "A grotesquely large rat, its fur patchy and eyes glowing with unnatural light.", + "es": "" + }, "emoji": "🐀", "hp_min": 10, "hp_max": 18, @@ -140,8 +158,14 @@ }, "infected_human": { "npc_id": "infected_human", - "name": "Infected Human", - "description": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.", + "name": { + "en": "Infected Human", + "es": "" + }, + "description": { + "en": "Once human, now something else. Their movements are jerky and their skin shows signs of advanced infection.", + "es": "" + }, "emoji": "🧟", "hp_min": 35, "hp_max": 50, @@ -184,8 +208,14 @@ }, "scavenger": { "npc_id": "scavenger", - "name": "Hostile Scavenger", - "description": "Another survivor, but this one sees you as competition. They won't share territory.", + "name": { + "en": "Hostile Scavenger", + "es": "" + }, + "description": { + "en": "Another survivor, but this one sees you as competition. They won't share territory.", + "es": "" + }, "emoji": "💀", "hp_min": 25, "hp_max": 40, @@ -264,23 +294,23 @@ }, "residential": { "danger_level": 1, - "encounter_rate": 0.10, - "wandering_chance": 0.20 + "encounter_rate": 0.1, + "wandering_chance": 0.2 }, "park": { "danger_level": 1, - "encounter_rate": 0.10, - "wandering_chance": 0.20 + "encounter_rate": 0.1, + "wandering_chance": 0.2 }, "clinic": { "danger_level": 2, - "encounter_rate": 0.20, + "encounter_rate": 0.2, "wandering_chance": 0.35 }, "plaza": { "danger_level": 2, "encounter_rate": 0.15, - "wandering_chance": 0.30 + "wandering_chance": 0.3 }, "warehouse": { "danger_level": 2, @@ -290,27 +320,27 @@ "warehouse_interior": { "danger_level": 2, "encounter_rate": 0.22, - "wandering_chance": 0.40 + "wandering_chance": 0.4 }, "overpass": { "danger_level": 3, - "encounter_rate": 0.30, + "encounter_rate": 0.3, "wandering_chance": 0.45 }, "office_building": { "danger_level": 3, "encounter_rate": 0.25, - "wandering_chance": 0.40 + "wandering_chance": 0.4 }, "office_interior": { "danger_level": 3, "encounter_rate": 0.35, - "wandering_chance": 0.50 + "wandering_chance": 0.5 }, "subway": { "danger_level": 4, "encounter_rate": 0.35, - "wandering_chance": 0.50 + "wandering_chance": 0.5 }, "subway_tunnels": { "danger_level": 4, @@ -468,4 +498,4 @@ } ] } -} +} \ No newline at end of file diff --git a/godot_poc/Main.gd b/godot_poc/Main.gd deleted file mode 100644 index 5b2d066..0000000 --- a/godot_poc/Main.gd +++ /dev/null @@ -1,105 +0,0 @@ -extends Control - -@onready var token_input = $VBoxContainer/HBoxContainer/TokenInput -@onready var status_label = $VBoxContainer/ConnectionStatusLabel -@onready var location_name_label = $VBoxContainer/LocationNameLabel -@onready var location_image = $VBoxContainer/LocationImage -@onready var location_desc_label = $VBoxContainer/LocationDescriptionLabel -@onready var log_label = $VBoxContainer/LogLabel - -var socket = WebSocketPeer.new() -var http_request : HTTPRequest -var is_connected_to_host = false - -func _ready(): - log_message("Godot PoC Started") - http_request = HTTPRequest.new() - add_child(http_request) - http_request.request_completed.connect(_on_image_request_completed) - -func _process(delta): - socket.poll() - var state = socket.get_ready_state() - - if state == WebSocketPeer.STATE_OPEN: - if not is_connected_to_host: - is_connected_to_host = true - status_label.text = "Status: Connected" - log_message("WebSocket Connected!") - - while socket.get_available_packet_count(): - var packet = socket.get_packet() - var data = packet.get_string_from_utf8() - var json = JSON.new() - var error = json.parse(data) - if error == OK: - handle_message(json.get_data()) - else: - log_message("Error parsing JSON: " + data) - - elif state == WebSocketPeer.STATE_CLOSED: - if is_connected_to_host: - is_connected_to_host = false - status_label.text = "Status: Disconnected" - log_message("WebSocket Disconnected") - -func _on_connect_button_pressed(): - var token = token_input.text.strip_edges() - if token == "": - log_message("Please enter a token.") - return - - var url = "wss://api-staging.echoesoftheash.com/ws/game/" + token - log_message("Connecting to: " + url) - var err = socket.connect_to_url(url) - if err != OK: - log_message("Error connecting to URL: " + str(err)) - else: - status_label.text = "Status: Connecting..." - -func handle_message(msg): - # log_message("Received: " + str(msg.get("type"))) - - if msg.get("type") == "location_update": - var data = msg.get("data", {}) - var location = data.get("location", {}) - - if location: - update_location_ui(location) - -func update_location_ui(location): - location_name_label.text = location.get("name", "Unknown Location") - location_desc_label.text = location.get("description", "") - - var image_url = location.get("image_url", "") - if image_url != "": - fetch_image(image_url) - -func fetch_image(url): - if url.begins_with("/"): - url = "https://api-staging.echoesoftheash.com" + url - - log_message("Fetching image: " + url) - http_request.cancel_request() - http_request.request(url) - -func _on_image_request_completed(result, response_code, headers, body): - if result == HTTPRequest.RESULT_SUCCESS: - var image = Image.new() - var error = image.load_png_from_buffer(body) - if error != OK: - error = image.load_jpg_from_buffer(body) - if error != OK: - error = image.load_webp_from_buffer(body) - - if error == OK: - var texture = ImageTexture.create_from_image(image) - location_image.texture = texture - else: - log_message("Failed to load image texture") - else: - log_message("Failed to fetch image. Code: " + str(response_code)) - -func log_message(text): - print(text) - log_label.text += text + "\n" diff --git a/godot_poc/Main.tscn b/godot_poc/Main.tscn deleted file mode 100644 index a05a469..0000000 --- a/godot_poc/Main.tscn +++ /dev/null @@ -1,69 +0,0 @@ -[gd_scene load_steps=2 format=3 uid="uid://c8q7y6x5z4w3"] - -[ext_resource type="Script" path="res://Main.gd" id="1_m4i3n"] - -[node name="Main" type="Control"] -layout_mode = 3 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 -script = ExtResource("1_m4i3n") - -[node name="VBoxContainer" type="VBoxContainer" parent="."] -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 - -[node name="HeaderLabel" type="Label" parent="VBoxContainer"] -layout_mode = 2 -text = "Echoes of the Ashes - Godot PoC" -horizontal_alignment = 1 - -[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] -layout_mode = 2 - -[node name="TokenInput" type="LineEdit" parent="VBoxContainer/HBoxContainer"] -layout_mode = 2 -size_flags_horizontal = 3 -placeholder_text = "Enter Auth Token" - -[node name="ConnectButton" type="Button" parent="VBoxContainer/HBoxContainer"] -layout_mode = 2 -text = "Connect via WebSocket" - -[node name="ConnectionStatusLabel" type="Label" parent="VBoxContainer"] -layout_mode = 2 -text = "Status: Disconnected" - -[node name="HSeparator" type="HSeparator" parent="VBoxContainer"] -layout_mode = 2 - -[node name="LocationNameLabel" type="Label" parent="VBoxContainer"] -layout_mode = 2 -horizontal_alignment = 1 - -[node name="LocationImage" type="TextureRect" parent="VBoxContainer"] -custom_minimum_size = Vector2(0, 300) -layout_mode = 2 -expand_mode = 1 -stretch_mode = 5 - -[node name="LocationDescriptionLabel" type="RichTextLabel" parent="VBoxContainer"] -layout_mode = 2 -fit_content = true - -[node name="HSeparator2" type="HSeparator" parent="VBoxContainer"] -layout_mode = 2 - -[node name="LogLabel" type="RichTextLabel" parent="VBoxContainer"] -layout_mode = 2 -size_flags_vertical = 3 -text = "Logs will appear here... -" - -[connection signal="pressed" from="VBoxContainer/HBoxContainer/ConnectButton" to="." method="_on_connect_button_pressed"] diff --git a/godot_poc/icon.svg b/godot_poc/icon.svg deleted file mode 100644 index 319deb5..0000000 --- a/godot_poc/icon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/godot_poc/project.godot b/godot_poc/project.godot deleted file mode 100644 index d1ed220..0000000 --- a/godot_poc/project.godot +++ /dev/null @@ -1,13 +0,0 @@ -config_version=5 - -[application] - -config/name="Echoes of the Ashes PoC" -config/features=PackedStringArray("4.5", "Forward Plus") -run/main_scene="res://Main.tscn" -config/icon="res://icon.svg" - -[display] - -window/size/viewport_width=1280 -window/size/viewport_height=720 diff --git a/images/interactables/original/dumpster.png b/images-source/interactables/dumpster.png similarity index 100% rename from images/interactables/original/dumpster.png rename to images-source/interactables/dumpster.png diff --git a/images/interactables/original/house.png b/images-source/interactables/house.png similarity index 100% rename from images/interactables/original/house.png rename to images-source/interactables/house.png diff --git a/images/interactables/original/medkit.png b/images-source/interactables/medkit.png similarity index 100% rename from images/interactables/original/medkit.png rename to images-source/interactables/medkit.png diff --git a/images/interactables/original/rubble.png b/images-source/interactables/rubble.png similarity index 100% rename from images/interactables/original/rubble.png rename to images-source/interactables/rubble.png diff --git a/images/interactables/original/sedan.png b/images-source/interactables/sedan.png similarity index 100% rename from images/interactables/original/sedan.png rename to images-source/interactables/sedan.png diff --git a/images/interactables/original/storage_box.png b/images-source/interactables/storage_box.png similarity index 100% rename from images/interactables/original/storage_box.png rename to images-source/interactables/storage_box.png diff --git a/images/interactables/original/toolshed.png b/images-source/interactables/toolshed.png similarity index 100% rename from images/interactables/original/toolshed.png rename to images-source/interactables/toolshed.png diff --git a/images/interactables/original/vending.png b/images-source/interactables/vending.png similarity index 100% rename from images/interactables/original/vending.png rename to images-source/interactables/vending.png diff --git a/images/items/original/animal_hide.png b/images-source/items/animal_hide.png similarity index 100% rename from images/items/original/animal_hide.png rename to images-source/items/animal_hide.png diff --git a/images/items/original/antibiotics.png b/images-source/items/antibiotics.png similarity index 100% rename from images/items/original/antibiotics.png rename to images-source/items/antibiotics.png diff --git a/images/items/original/bandage.png b/images-source/items/bandage.png similarity index 100% rename from images/items/original/bandage.png rename to images-source/items/bandage.png diff --git a/images/items/original/baseball_bat.png b/images-source/items/baseball_bat.png similarity index 100% rename from images/items/original/baseball_bat.png rename to images-source/items/baseball_bat.png diff --git a/images/items/original/bone.png b/images-source/items/bone.png similarity index 100% rename from images/items/original/bone.png rename to images-source/items/bone.png diff --git a/images/items/original/bottled_water.png b/images-source/items/bottled_water.png similarity index 100% rename from images/items/original/bottled_water.png rename to images-source/items/bottled_water.png diff --git a/images/items/original/canned_beans.png b/images-source/items/canned_beans.png similarity index 100% rename from images/items/original/canned_beans.png rename to images-source/items/canned_beans.png diff --git a/images/items/original/canned_food.png b/images-source/items/canned_food.png similarity index 100% rename from images/items/original/canned_food.png rename to images-source/items/canned_food.png diff --git a/images/items/original/cloth.png b/images-source/items/cloth.png similarity index 100% rename from images/items/original/cloth.png rename to images-source/items/cloth.png diff --git a/images/items/original/cloth_bandana.png b/images-source/items/cloth_bandana.png similarity index 100% rename from images/items/original/cloth_bandana.png rename to images-source/items/cloth_bandana.png diff --git a/images/items/original/cloth_scraps.png b/images-source/items/cloth_scraps.png similarity index 100% rename from images/items/original/cloth_scraps.png rename to images-source/items/cloth_scraps.png diff --git a/images/items/original/energy_bar.png b/images-source/items/energy_bar.png similarity index 100% rename from images/items/original/energy_bar.png rename to images-source/items/energy_bar.png diff --git a/images/items/original/first_aid_kit.png b/images-source/items/first_aid_kit.png similarity index 100% rename from images/items/original/first_aid_kit.png rename to images-source/items/first_aid_kit.png diff --git a/images/items/original/flashlight.png b/images-source/items/flashlight.png similarity index 100% rename from images/items/original/flashlight.png rename to images-source/items/flashlight.png diff --git a/images/items/original/hammer.png b/images-source/items/hammer.png similarity index 100% rename from images/items/original/hammer.png rename to images-source/items/hammer.png diff --git a/images/items/original/hiking_backpack.png b/images-source/items/hiking_backpack.png similarity index 100% rename from images/items/original/hiking_backpack.png rename to images-source/items/hiking_backpack.png diff --git a/images/items/original/infected_tissue.png b/images-source/items/infected_tissue.png similarity index 100% rename from images/items/original/infected_tissue.png rename to images-source/items/infected_tissue.png diff --git a/images/items/original/key_ring.png b/images-source/items/key_ring.png similarity index 100% rename from images/items/original/key_ring.png rename to images-source/items/key_ring.png diff --git a/images/items/original/knife.png b/images-source/items/knife.png similarity index 100% rename from images/items/original/knife.png rename to images-source/items/knife.png diff --git a/images/items/original/leather_vest.png b/images-source/items/leather_vest.png similarity index 100% rename from images/items/original/leather_vest.png rename to images-source/items/leather_vest.png diff --git a/images/items/original/makeshift_spear.png b/images-source/items/makeshift_spear.png similarity index 100% rename from images/items/original/makeshift_spear.png rename to images-source/items/makeshift_spear.png diff --git a/images/items/original/medical_supplies.png b/images-source/items/medical_supplies.png similarity index 100% rename from images/items/original/medical_supplies.png rename to images-source/items/medical_supplies.png diff --git a/images/items/original/mutant_tissue.png b/images-source/items/mutant_tissue.png similarity index 100% rename from images/items/original/mutant_tissue.png rename to images-source/items/mutant_tissue.png diff --git a/images/items/original/mystery_pills.png b/images-source/items/mystery_pills.png similarity index 100% rename from images/items/original/mystery_pills.png rename to images-source/items/mystery_pills.png diff --git a/images/items/original/old_photograph.png b/images-source/items/old_photograph.png similarity index 100% rename from images/items/original/old_photograph.png rename to images-source/items/old_photograph.png diff --git a/images/items/original/padded_pants.png b/images-source/items/padded_pants.png similarity index 100% rename from images/items/original/padded_pants.png rename to images-source/items/padded_pants.png diff --git a/images/items/original/plastic_bottles.png b/images-source/items/plastic_bottles.png similarity index 100% rename from images/items/original/plastic_bottles.png rename to images-source/items/plastic_bottles.png diff --git a/images/items/original/rad_pills.png b/images-source/items/rad_pills.png similarity index 100% rename from images/items/original/rad_pills.png rename to images-source/items/rad_pills.png diff --git a/images/items/original/raw_meat.png b/images-source/items/raw_meat.png similarity index 100% rename from images/items/original/raw_meat.png rename to images-source/items/raw_meat.png diff --git a/images/items/original/reinforced_bat.png b/images-source/items/reinforced_bat.png similarity index 100% rename from images/items/original/reinforced_bat.png rename to images-source/items/reinforced_bat.png diff --git a/images/items/original/reinforced_pack.png b/images-source/items/reinforced_pack.png similarity index 100% rename from images/items/original/reinforced_pack.png rename to images-source/items/reinforced_pack.png diff --git a/images/items/original/rusty_knife.png b/images-source/items/rusty_knife.png similarity index 100% rename from images/items/original/rusty_knife.png rename to images-source/items/rusty_knife.png diff --git a/images/items/original/rusty_nails.png b/images-source/items/rusty_nails.png similarity index 100% rename from images/items/original/rusty_nails.png rename to images-source/items/rusty_nails.png diff --git a/images/items/original/scrap_metal.png b/images-source/items/scrap_metal.png similarity index 100% rename from images/items/original/scrap_metal.png rename to images-source/items/scrap_metal.png diff --git a/images/items/original/screwdriver.png b/images-source/items/screwdriver.png similarity index 100% rename from images/items/original/screwdriver.png rename to images-source/items/screwdriver.png diff --git a/images/items/original/stale_chocolate_bar.png b/images-source/items/stale_chocolate_bar.png similarity index 100% rename from images/items/original/stale_chocolate_bar.png rename to images-source/items/stale_chocolate_bar.png diff --git a/images/items/original/sturdy_boots.png b/images-source/items/sturdy_boots.png similarity index 100% rename from images/items/original/sturdy_boots.png rename to images-source/items/sturdy_boots.png diff --git a/images/items/original/tattered_rucksack.png b/images-source/items/tattered_rucksack.png similarity index 100% rename from images/items/original/tattered_rucksack.png rename to images-source/items/tattered_rucksack.png diff --git a/images/items/original/tire_iron.png b/images-source/items/tire_iron.png similarity index 100% rename from images/items/original/tire_iron.png rename to images-source/items/tire_iron.png diff --git a/images/items/original/wood_planks.png b/images-source/items/wood_planks.png similarity index 100% rename from images/items/original/wood_planks.png rename to images-source/items/wood_planks.png diff --git a/images/locations/original/clinic.png b/images-source/locations/clinic.png similarity index 100% rename from images/locations/original/clinic.png rename to images-source/locations/clinic.png diff --git a/images/locations/original/downtown.png b/images-source/locations/downtown.png similarity index 100% rename from images/locations/original/downtown.png rename to images-source/locations/downtown.png diff --git a/images/locations/original/gas_station.png b/images-source/locations/gas_station.png similarity index 100% rename from images/locations/original/gas_station.png rename to images-source/locations/gas_station.png diff --git a/images/locations/original/office_building.png b/images-source/locations/office_building.png similarity index 100% rename from images/locations/original/office_building.png rename to images-source/locations/office_building.png diff --git a/images/locations/original/office_interior.png b/images-source/locations/office_interior.png similarity index 100% rename from images/locations/original/office_interior.png rename to images-source/locations/office_interior.png diff --git a/images/locations/original/overpass.png b/images-source/locations/overpass.png similarity index 100% rename from images/locations/original/overpass.png rename to images-source/locations/overpass.png diff --git a/images/locations/original/park.png b/images-source/locations/park.png similarity index 100% rename from images/locations/original/park.png rename to images-source/locations/park.png diff --git a/images/locations/original/plaza.png b/images-source/locations/plaza.png similarity index 100% rename from images/locations/original/plaza.png rename to images-source/locations/plaza.png diff --git a/images/locations/original/residential.png b/images-source/locations/residential.png similarity index 100% rename from images/locations/original/residential.png rename to images-source/locations/residential.png diff --git a/images/locations/original/subway.png b/images-source/locations/subway.png similarity index 100% rename from images/locations/original/subway.png rename to images-source/locations/subway.png diff --git a/images/locations/original/subway_section_a.jpg b/images-source/locations/subway_section_a.jpg similarity index 100% rename from images/locations/original/subway_section_a.jpg rename to images-source/locations/subway_section_a.jpg diff --git a/images/locations/original/subway_tunnels.png b/images-source/locations/subway_tunnels.png similarity index 100% rename from images/locations/original/subway_tunnels.png rename to images-source/locations/subway_tunnels.png diff --git a/images/locations/original/warehouse.png b/images-source/locations/warehouse.png similarity index 100% rename from images/locations/original/warehouse.png rename to images-source/locations/warehouse.png diff --git a/images/locations/original/warehouse_interior.png b/images-source/locations/warehouse_interior.png similarity index 100% rename from images/locations/original/warehouse_interior.png rename to images-source/locations/warehouse_interior.png diff --git a/images-source/make_webp.sh b/images-source/make_webp.sh new file mode 100755 index 0000000..8c2cc96 --- /dev/null +++ b/images-source/make_webp.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -e + +# Script to convert original images to optimized WebP format +# Run this script from the images-source directory +# Source files: ./ (current directory) +# Output files: ../images/ + +SOURCE_DIR="." +OUTPUT_DIR="../images" +ITEM_SIZE="256x256" + +echo "🔄 Starting image conversion..." +echo " Source: $SOURCE_DIR" +echo " Output: $OUTPUT_DIR" +echo "" + +for category in items locations npcs interactables; do + src="$SOURCE_DIR/$category" + out="$OUTPUT_DIR/$category" + + if [[ ! -d "$src" ]]; then + echo "⚠️ Skipping $category (source not found)" + continue + fi + + mkdir -p "$out" + echo "📂 Processing $category..." + + find "$src" -maxdepth 1 -type f \( -iname "*.png" -o -iname "*.jpg" -o -iname "*.jpeg" \) | while read -r img; do + filename="${img##*/}" + base="${filename%.*}" + out_file="$out/$base.webp" + + if [[ -f "$out_file" ]]; then + echo " ✔ Exists: $base.webp" + continue + fi + + if [[ "$category" == "items" ]]; then + # Special processing for items: remove white background and resize + echo " ➜ Converting item: $filename" + tmp="/tmp/${base}_clean.png" + convert "$img" -fuzz 10% -transparent white -resize "$ITEM_SIZE" "$tmp" + cwebp "$tmp" -q 85 -o "$out_file" >/dev/null + rm "$tmp" + else + # Standard conversion for other categories + echo " ➜ Converting: $filename" + cwebp "$img" -q 85 -o "$out_file" >/dev/null + fi + done +done + +echo "" +echo "✨ Done! WebP files generated in $OUTPUT_DIR" diff --git a/images/npcs/original/feral_dog.png b/images-source/npcs/feral_dog.png similarity index 100% rename from images/npcs/original/feral_dog.png rename to images-source/npcs/feral_dog.png diff --git a/images/npcs/original/infected_human.png b/images-source/npcs/infected_human.png similarity index 100% rename from images/npcs/original/infected_human.png rename to images-source/npcs/infected_human.png diff --git a/images/npcs/original/mutant_rat.png b/images-source/npcs/mutant_rat.png similarity index 100% rename from images/npcs/original/mutant_rat.png rename to images-source/npcs/mutant_rat.png diff --git a/images/npcs/original/raider_scout.png b/images-source/npcs/raider_scout.png similarity index 100% rename from images/npcs/original/raider_scout.png rename to images-source/npcs/raider_scout.png diff --git a/images/npcs/original/scavenger.png b/images-source/npcs/scavenger.png similarity index 100% rename from images/npcs/original/scavenger.png rename to images-source/npcs/scavenger.png diff --git a/images/make_webp.sh b/images/make_webp.sh deleted file mode 100755 index 972f8ef..0000000 --- a/images/make_webp.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Set size for item icons (example: 256x256 or 128x128) -ITEM_SIZE="256x256" - -echo "Starting conversion..." - -find . -type d -name "original" | while read -r orig_dir; do - echo "📂 Folder: $orig_dir" - - out_dir="$(dirname "$orig_dir")" - - # Check if this is the items/original folder - is_items_folder=false - if [[ "$orig_dir" == *"/items/original" ]]; then - is_items_folder=true - echo " 🔧 Applying item-specific processing (remove white background, resize)" - fi - - # Process images in original folder - find "$orig_dir" -maxdepth 1 -type f \( -iname "*.png" -o -iname "*.jpg" -o -iname "*.jpeg" \) | \ - while read -r img; do - - filename="$(basename "$img")" - base="${filename%.*}" - out_file="$out_dir/$base.webp" - - if [[ -f "$out_file" ]]; then - echo " ✔ Skipping existing: $out_file" - continue - fi - - # If this is an item icon, preprocess it - if [[ "$is_items_folder" = true ]]; then - echo " ➜ Processing item: $filename" - - tmp_png="/tmp/${base}_clean.png" - - # 1. Remove white background using ImageMagick - convert "$img" -fuzz 10% -transparent white "$tmp_png" - - # 2. Resize to smaller square - convert "$tmp_png" -resize "$ITEM_SIZE" "$tmp_png" - - # 3. Convert to WebP - cwebp "$tmp_png" -q 85 -o "$out_file" >/dev/null - - rm "$tmp_png" - - else - # Standard conversion for other folders - echo " ➜ Converting: $filename → $out_file" - cwebp "$img" -q 85 -o "$out_file" >/dev/null - fi - - done -done - -echo "✨ Done! Missing files created, with special processing for items." diff --git a/new-readme.md b/new-readme.md new file mode 100644 index 0000000..21aef83 --- /dev/null +++ b/new-readme.md @@ -0,0 +1,188 @@ +# Echoes of the Ash + +> A post-apocalyptic survival RPG - Browser-based MUD-style game + +![Status](https://img.shields.io/badge/Status-In%20Development-yellow) +![Platform](https://img.shields.io/badge/Platform-Web%20%7C%20PWA%20%7C%20Electron%20%7C%20Steam-blue) +![License](https://img.shields.io/badge/License-Proprietary-red) + +## 🎮 What is Echoes of the Ash? + +Echoes of the Ash is a **browser-based RPG** set in a dark, post-apocalyptic world. Inspired by classic MUD (Multi-User Dungeon) games, it combines text-driven exploration with visual elements, real-time combat, and survival mechanics. + +--- + +## 🌟 Current Game Features + +### Core Systems + +| Feature | Status | Description | +|---------|--------|-------------| +| **Character System** | ✅ Complete | Create characters with 4 stats (Strength, Agility, Endurance, Intellect) | +| **Health & Stamina** | ✅ Complete | HP/Stamina management with visual progress bars | +| **Leveling & XP** | ✅ Complete | XP-based progression with stat point allocation | +| **Inventory** | ✅ Complete | Weight/volume-based carrying capacity | +| **Equipment** | ✅ Complete | Weapon, armor, and backpack slots | +| **Combat (PvE)** | ✅ Complete | Turn-based combat with visual effects | +| **Combat (PvP)** | ✅ Complete | Player vs Player combat system | +| **Real-time Updates** | ✅ Complete | WebSocket-based live game state | + +### Exploration & Interaction + +| Feature | Status | Description | +|---------|--------|-------------| +| **World Map** | ✅ Complete | Graph-based location system with connections | +| **Movement** | ✅ Complete | Navigate between connected locations | +| **Interactables** | ✅ Complete | Search containers, objects for loot | +| **Enemy Spawning** | ✅ Complete | Static and wandering NPCs | +| **Corpse Looting** | ✅ Complete | Loot fallen enemies and players | +| **Dropped Items** | ✅ Complete | Pick up items on the ground | + +### Crafting & Economy + +| Feature | Status | Description | +|---------|--------|-------------| +| **Workbench** | ✅ Complete | Craft, repair, and salvage items | +| **Crafting System** | ✅ Complete | Create items from materials | +| **Repair System** | ✅ Complete | Restore durability to equipment | +| **Salvage System** | ✅ Complete | Break down items for materials | + +### Social & Multiplayer + +| Feature | Status | Description | +|---------|--------|-------------| +| **Accounts** | ✅ Complete | Registration, login, JWT authentication | +| **Multiple Characters** | ✅ Complete | Create up to 3 characters per account | +| **Leaderboards** | ✅ Complete | Rankings by level, kills, XP | +| **Player Profiles** | ✅ Complete | View player stats and equipment | +| **Online Players** | ✅ Complete | See who's currently online | + +### Platforms + +| Platform | Status | Description | +|----------|--------|-------------| +| **Web Browser** | ✅ Complete | Play at any time via modern browser | +| **PWA (Mobile)** | ✅ Complete | Install as app on mobile devices | +| **Electron Desktop** | ✅ Complete | Standalone Windows/Mac/Linux app | +| **Steam Integration** | 🔧 Setup | Steamworks SDK ready for deployment | + +--- + +## 🎯 What Can Players Do? + +### Getting Started +1. **Create an Account** - Register with username and password +2. **Create a Character** - Name your survivor and choose starting stats +3. **Enter the World** - Spawn at the starting location + +### Gameplay Loop +1. **Explore** - Move between connected locations to discover new areas +2. **Scavenge** - Search containers, corpses, and interactables for supplies +3. **Fight** - Engage hostile NPCs in turn-based combat +4. **Craft** - Use workbenches to create, repair, or salvage items +5. **Level Up** - Gain XP from combat and allocate stat points +6. **Survive** - Manage HP, stamina, and inventory weight + +### Combat +- **Attack** enemies with equipped weapons +- **Use Items** during battle (healing, buffs) +- **Flee** when outmatched (success based on Agility) +- **PvP** - Challenge other players in combat + +### Character Progression +- **4 Core Stats**: Strength, Agility, Endurance, Intellect +- **Equipment**: Weapons, armor, backpacks +- **Stat Points**: Earn 1 per level to customize your build + +--- + +## 🛠️ Technical Stack + +### Frontend (PWA) +- **Framework**: React 18 + TypeScript +- **Build Tool**: Vite +- **State Management**: Zustand +- **Real-time**: WebSocket connections +- **Styling**: Custom CSS with dark theme + +### Backend (API) +- **Framework**: FastAPI (Python) +- **Database**: SQLite (development) / PostgreSQL (production) +- **Cache**: Redis for real-time state +- **Auth**: JWT tokens + +### Desktop (Electron) +- **Framework**: Electron 28 +- **Steam SDK**: steamworks.js integration +- **Builds**: Windows (NSIS, Portable), Linux (AppImage, DEB), macOS + +--- + +## 📊 Asset Summary + +| Category | Count | Size | +|----------|-------|------| +| Location Images | 14 | - | +| Item Images | 40 | - | +| NPC Images | 5 | - | +| Interactable Images | 8 | - | +| Icon Sets | 1 | - | +| **Total Images** | **134 files** | **~79 MB** | +| Sound Effects | 0 | 0 | +| Music | 0 | 0 | + +--- + +## 🗺️ Roadmap + +### In Progress +- [ ] Sound effects and ambient music +- [ ] Quest/mission system +- [ ] NPC dialogue trees + +### Planned Features +- [ ] Crafting recipes expansion +- [ ] Faction/reputation system +- [ ] Player trading +- [ ] Housing/storage +- [ ] Skill tree system +- [ ] Status effects (poison, bleeding, etc.) +- [ ] Weather/day-night cycle +- [ ] Achievements + +--- + +## 🚀 Running the Game + +### Web/PWA (Docker) +```bash +docker compose up echoes_of_the_ashes_pwa echoes_of_the_ashes_api +``` + +### Electron Development +```bash +cd pwa +npm install +npm run electron:dev +``` + +### Build Electron Apps +```bash +npm run electron:build:win # Windows +npm run electron:build:linux # Linux +npm run electron:build:mac # macOS +``` + +--- + +## 📝 Additional Documentation + +- [Game Mechanics](docs/game/MECHANICS.md) - Detailed gameplay systems +- [API Documentation](docs/api/) - Backend endpoints reference +- [Development Guide](docs/development/) - Contributing and architecture +- [Map Editor](web-map/README.md) - World building tools + +--- + +**Version**: 1.0.0-alpha +**Last Updated**: December 2025 diff --git a/pwa/.gitignore b/pwa/.gitignore index da032dd..06cae84 100644 --- a/pwa/.gitignore +++ b/pwa/.gitignore @@ -6,6 +6,10 @@ yarn.lock # Build output dist/ build/ +dist-electron/ + +# Copied assets (generated at build time) +public/images/ # Environment variables .env diff --git a/pwa/electron/afterPack.cjs b/pwa/electron/afterPack.cjs new file mode 100644 index 0000000..e7f3aee --- /dev/null +++ b/pwa/electron/afterPack.cjs @@ -0,0 +1,29 @@ +/** + * electron-builder afterPack hook + * Removes "type": "module" from the packaged package.json + * to ensure CommonJS files work correctly in the Electron app. + */ + +const fs = require('fs') +const path = require('path') + +exports.default = async function (context) { + const appDir = context.appOutDir + const resourcesPath = path.join(appDir, 'resources', 'app') + const packageJsonPath = path.join(resourcesPath, 'package.json') + + console.log('afterPack: Checking for package.json at:', packageJsonPath) + + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) + + // Remove the "type": "module" field to ensure CommonJS compat + if (packageJson.type === 'module') { + delete packageJson.type + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)) + console.log('afterPack: Removed "type": "module" from package.json') + } + } else { + console.log('afterPack: package.json not found, skipping...') + } +} diff --git a/pwa/electron/main.js b/pwa/electron/main.cjs similarity index 98% rename from pwa/electron/main.js rename to pwa/electron/main.cjs index 5864307..7edbc92 100644 --- a/pwa/electron/main.js +++ b/pwa/electron/main.cjs @@ -40,7 +40,7 @@ function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, - preload: path.join(__dirname, 'preload.js') + preload: path.join(__dirname, 'preload.cjs') }, icon: path.join(__dirname, 'icons/icon.png'), title: 'Echoes of the Ash' diff --git a/pwa/electron/preload.js b/pwa/electron/preload.cjs similarity index 100% rename from pwa/electron/preload.js rename to pwa/electron/preload.cjs diff --git a/pwa/package.json b/pwa/package.json index fe33aa4..65b628f 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -9,17 +9,18 @@ }, "homepage": "https://echoesoftheash.com", "type": "module", - "main": "electron/main.js", + "main": "electron/main.cjs", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "copy-assets": "rm -rf ./public/images && cp -r ../images ./public/", "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"", - "electron:build": "npm run build && electron-builder", - "electron:build:win": "npm run build && electron-builder --win", - "electron:build:linux": "npm run build && electron-builder --linux", - "electron:build:mac": "npm run build && electron-builder --mac" + "electron:build": "npm run copy-assets && npm run build && electron-builder", + "electron:build:win": "npm run copy-assets && npm run build && electron-builder --win", + "electron:build:linux": "npm run copy-assets && npm run build && electron-builder --linux", + "electron:build:mac": "npm run copy-assets && npm run build && electron-builder --mac" }, "dependencies": { "react": "^18.2.0", @@ -27,7 +28,10 @@ "react-router-dom": "^6.20.0", "axios": "^1.6.2", "zustand": "^4.4.7", - "twemoji": "^14.0.2" + "twemoji": "^14.0.2", + "i18next": "^23.7.0", + "react-i18next": "^14.0.0", + "i18next-browser-languagedetector": "^7.2.0" }, "devDependencies": { "@types/react": "^18.2.43", @@ -52,6 +56,7 @@ "build": { "appId": "com.echoesoftheash.game", "productName": "Echoes of the Ash", + "afterPack": "./electron/afterPack.cjs", "directories": { "output": "dist-electron" }, diff --git a/pwa/src/components/GameHeader.tsx b/pwa/src/components/GameHeader.tsx index bb75bfb..bf2c1a9 100644 --- a/pwa/src/components/GameHeader.tsx +++ b/pwa/src/components/GameHeader.tsx @@ -3,6 +3,8 @@ import { useNavigate, useLocation } from 'react-router-dom' import { useAuth } from '../hooks/useAuth' import { useGameWebSocket } from '../hooks/useGameWebSocket' import api from '../services/api' +import { useTranslation } from 'react-i18next' +import LanguageSelector from './LanguageSelector' import './Game.css' interface GameHeaderProps { @@ -13,6 +15,7 @@ export default function GameHeader({ className = '' }: GameHeaderProps) { const navigate = useNavigate() const location = useLocation() const { currentCharacter, logout } = useAuth() + const { t } = useTranslation() const [playerCount, setPlayerCount] = useState(0) // Fetch initial player count @@ -63,19 +66,20 @@ export default function GameHeader({ className = '' }: GameHeaderProps) { onClick={() => navigate('/game')} className={`nav-link ${isActive('/game') ? 'active' : ''}`} > - 🎮 Game + 🎮 {t('common.game')}
-
+ +
- {playerCount} Online + {t('game.onlineCount', { count: playerCount })}
- +
) diff --git a/pwa/src/components/LanguageSelector.css b/pwa/src/components/LanguageSelector.css new file mode 100644 index 0000000..fecd07a --- /dev/null +++ b/pwa/src/components/LanguageSelector.css @@ -0,0 +1,86 @@ +.language-selector { + position: relative; + display: inline-block; +} + +.language-btn { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 0.75rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + color: #fff; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.language-btn:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} + +.lang-flag { + font-size: 1.1rem; +} + +.lang-code { + font-weight: 500; + letter-spacing: 0.5px; +} + +.language-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.25rem; + background: #1a1a2e; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + overflow: hidden; + opacity: 0; + visibility: hidden; + transform: translateY(-8px); + transition: all 0.2s ease; + z-index: 1000; + min-width: 140px; +} + +.language-selector:hover .language-dropdown, +.language-selector:focus-within .language-dropdown { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.lang-option { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.65rem 1rem; + border: none; + background: transparent; + color: #ccc; + cursor: pointer; + text-align: left; + font-size: 0.9rem; + transition: all 0.15s; +} + +.lang-option:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.lang-option.active { + background: rgba(100, 100, 255, 0.2); + color: #8bf; +} + +.lang-name { + font-weight: 400; +} diff --git a/pwa/src/components/LanguageSelector.tsx b/pwa/src/components/LanguageSelector.tsx new file mode 100644 index 0000000..32201b3 --- /dev/null +++ b/pwa/src/components/LanguageSelector.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next' +import './LanguageSelector.css' + +const languages = [ + { code: 'en', name: 'English', flag: '🇬🇧' }, + { code: 'es', name: 'Español', flag: '🇪🇸' } +] + +function LanguageSelector() { + const { i18n } = useTranslation() + + const changeLanguage = (langCode: string) => { + i18n.changeLanguage(langCode) + } + + const currentLang = languages.find(l => l.code === i18n.language) || languages[0] + + return ( +
+ +
+ {languages.map(lang => ( + + ))} +
+
+ ) +} + +export default LanguageSelector diff --git a/pwa/src/components/game/InventoryModal.tsx b/pwa/src/components/game/InventoryModal.tsx index 3eeab65..d82b24e 100644 --- a/pwa/src/components/game/InventoryModal.tsx +++ b/pwa/src/components/game/InventoryModal.tsx @@ -1,5 +1,8 @@ import { MouseEvent, ChangeEvent } from 'react' +import { useTranslation } from 'react-i18next' import { PlayerState, Profile, Equipment } from './types' +import { getAssetPath } from '../../utils/assetPath' +import { getTranslatedText } from '../../utils/i18nUtils' import './InventoryModal.css' interface InventoryModalProps { @@ -31,6 +34,7 @@ function InventoryModal({ onUnequipItem, onDropItem }: InventoryModalProps) { + useTranslation() // Categories for the sidebar const categories = [ { id: 'all', label: 'All Items', icon: '🎒' }, @@ -51,7 +55,7 @@ function InventoryModal({ // Filter items based on search and category const filteredItems = allItems .filter((item: any) => { - const itemName = item.name || 'Unknown Item'; + const itemName = getTranslatedText(item.name) || 'Unknown Item'; const matchesSearch = itemName.toLowerCase().includes(inventoryFilter.toLowerCase()) const matchesCategory = inventoryCategoryFilter === 'all' || item.type === inventoryCategoryFilter return matchesSearch && matchesCategory @@ -60,7 +64,7 @@ function InventoryModal({ // Equipped items first if (a.is_equipped && !b.is_equipped) return -1; if (!a.is_equipped && b.is_equipped) return 1; - return (a.name || '').localeCompare(b.name || ''); + return (getTranslatedText(a.name) || '').localeCompare(getTranslatedText(b.name) || ''); }) const renderItemCard = (item: any, i: number) => { @@ -75,8 +79,8 @@ function InventoryModal({
{item.image_path ? ( {item.name} { (e.target as HTMLImageElement).style.display = 'none'; @@ -95,7 +99,7 @@ function InventoryModal({
{item.emoji} -

{item.name}

+

{getTranslatedText(item.name)}

{item.is_equipped && Equipped}
@@ -116,7 +120,7 @@ function InventoryModal({ {/* Stats & Durability */}
- {item.description &&

{item.description}

} + {item.description &&

{getTranslatedText(item.description)}

} {/* Stats Row - Button-like Badges */}
@@ -315,7 +319,7 @@ function InventoryModal({ {equipment?.backpack ? (
🎒 - {equipment.backpack.name} + {getTranslatedText(equipment.backpack.name)} (+{equipment.backpack.unique_stats?.weight_capacity || equipment.backpack.stats?.weight_capacity || 0}kg / +{equipment.backpack.unique_stats?.volume_capacity || equipment.backpack.stats?.volume_capacity || 0}L) diff --git a/pwa/src/components/game/LocationView.tsx b/pwa/src/components/game/LocationView.tsx index 9477e23..826e178 100644 --- a/pwa/src/components/game/LocationView.tsx +++ b/pwa/src/components/game/LocationView.tsx @@ -1,6 +1,9 @@ import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types' +import { useTranslation } from 'react-i18next' import Workbench from './Workbench' +import { getAssetPath } from '../../utils/assetPath' +import { getTranslatedText } from '../../utils/i18nUtils' interface LocationViewProps { location: Location @@ -79,11 +82,12 @@ function LocationView({ onRepair, onUncraft }: LocationViewProps) { + useTranslation() return (

- {location.name} + {getTranslatedText(location.name)} {location.danger_level !== undefined && location.danger_level === 0 && ( ✓ Safe )} @@ -132,8 +136,8 @@ function LocationView({ {location.image_url && (
{location.name} (e.currentTarget.style.display = 'none')} /> @@ -141,7 +145,7 @@ function LocationView({ )}
-

{location.description}

+

{getTranslatedText(location.description)}

@@ -176,7 +180,7 @@ function LocationView({ {enemy.id && (
{enemy.name} { e.currentTarget.style.display = 'none' }} /> @@ -301,7 +305,7 @@ function LocationView({
{item.image_path ? ( {item.name} { diff --git a/pwa/src/components/game/MovementControls.tsx b/pwa/src/components/game/MovementControls.tsx index eec6eb6..50d5cc1 100644 --- a/pwa/src/components/game/MovementControls.tsx +++ b/pwa/src/components/game/MovementControls.tsx @@ -1,5 +1,7 @@ import type { Location, Profile, CombatState } from './types' import { useState, useEffect } from 'react' +import { getAssetPath } from '../../utils/assetPath' +import { getTranslatedText } from '../../utils/i18nUtils' interface MovementControlsProps { location: Location @@ -22,14 +24,14 @@ function MovementControls({ }: MovementControlsProps) { // Force re-render every second to update cooldown timers const [, forceUpdate] = useState(0) - + useEffect(() => { const timer = setInterval(() => { forceUpdate(prev => prev + 1) }, 1000) return () => clearInterval(timer) }, []) - + // Helper function to get direction details const getDirectionDetail = (direction: string) => { if (!location.directions_detailed) return null @@ -45,9 +47,9 @@ function MovementControls({ // Helper function to get destination name for a direction const getDestinationName = (direction: string): string => { const detail = getDirectionDetail(direction) - return detail ? (detail.destination_name || detail.destination) : '' + return detail ? getTranslatedText(detail.destination_name || detail.destination) : '' } - + // Helper function to get distance for a direction const getDistance = (direction: string): number => { const detail = getDirectionDetail(direction) @@ -67,15 +69,15 @@ function MovementControls({ const distance = getDistance(direction) const insufficientStamina = profile ? profile.stamina < stamina : false const disabled = !available || !!combatState || movementCooldown > 0 || insufficientStamina || (profile?.is_dead ?? false) - + // Build detailed tooltip text const tooltipText = profile?.is_dead ? 'You are dead' : movementCooldown > 0 ? `Wait ${movementCooldown}s before moving` : - combatState ? 'Cannot travel during combat' : - insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` : - available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` : - `Cannot go ${direction}` - + combatState ? 'Cannot travel during combat' : + insufficientStamina ? `Not enough stamina (need ${stamina}, have ${profile?.stamina ?? 0})` : + available ? `${destination}\nDistance: ${distance}m\nStamina: ${stamina}` : + `Cannot go ${direction}` + return ( + )} + {location.directions.includes('down') && ( + + )} + {location.directions.includes('enter') && ( + + )} + {location.directions.includes('inside') && ( + + )} + {location.directions.includes('exit') && ( + + )} + {location.directions.includes('outside') && ( + + )}
- )} - - {/* Special movements */} -
- {location.directions.includes('up') && ( - - )} - {location.directions.includes('down') && ( - - )} - {location.directions.includes('enter') && ( - - )} - {location.directions.includes('inside') && ( - - )} - {location.directions.includes('exit') && ( - - )} - {location.directions.includes('outside') && ( - - )}
-
- - {/* Surroundings - outside movement controls */} - {location.interactables && location.interactables.length > 0 && ( -
-

🌿 Surroundings

- {location.interactables.map((interactable: any) => ( -
- {interactable.image_path && ( -
- {interactable.name} { - e.currentTarget.style.display = 'none' - }} - /> -
- )} -
-
- {interactable.name} -
- {interactable.actions && interactable.actions.length > 0 && ( -
- {interactable.actions.map((action: any) => { - const cooldownKey = `${interactable.instance_id}:${action.id}` - const cooldownExpiry = interactableCooldowns[cooldownKey] - const now = Date.now() / 1000 - const cooldownRemaining = cooldownExpiry && cooldownExpiry > now - ? Math.ceil(cooldownExpiry - now) - : 0 - - return ( - - ) - })} + + {/* Surroundings - outside movement controls */} + {location.interactables && location.interactables.length > 0 && ( +
+

🌿 Surroundings

+ {location.interactables.map((interactable: any) => ( +
+ {interactable.image_path && ( +
+ {getTranslatedText(interactable.name)} { + e.currentTarget.style.display = 'none' + }} + />
)} +
+
+ {getTranslatedText(interactable.name)} +
+ {interactable.actions && interactable.actions.length > 0 && ( +
+ {interactable.actions.map((action: any) => { + const cooldownKey = `${interactable.instance_id}:${action.id}` + const cooldownExpiry = interactableCooldowns[cooldownKey] + const now = Date.now() / 1000 + const cooldownRemaining = cooldownExpiry && cooldownExpiry > now + ? Math.ceil(cooldownExpiry - now) + : 0 + + return ( + + ) + })} +
+ )} +
-
- ))} -
- )} - + ))} +
+ )} + ) } diff --git a/pwa/src/components/game/PlayerSidebar.tsx b/pwa/src/components/game/PlayerSidebar.tsx index abaa9cd..756771f 100644 --- a/pwa/src/components/game/PlayerSidebar.tsx +++ b/pwa/src/components/game/PlayerSidebar.tsx @@ -1,5 +1,8 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' import type { PlayerState, Profile, Equipment } from './types' +import { getAssetPath } from '../../utils/assetPath' +import { getTranslatedText } from '../../utils/i18nUtils' import InventoryModal from './InventoryModal' interface PlayerSidebarProps { @@ -37,16 +40,18 @@ function PlayerSidebar({ + const { t } = useTranslation() + const renderEquipmentSlot = (slot: string, item: any, emoji: string, label: string) => (
{item ? ( <> - +
{item.image_path ? ( {item.name} { (e.target as HTMLImageElement).style.display = 'none'; @@ -56,52 +61,52 @@ function PlayerSidebar({ /> ) : null} {item.emoji} - {item.name} + {getTranslatedText(item.name)} {item.durability && item.durability !== null && ( {item.durability}/{item.max_durability} )}
- {item.description &&
{item.description}
} + {item.description &&
{getTranslatedText(item.description)}
} {/* Use unique_stats if available, otherwise fall back to base stats */} {(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && ( <> {(item.unique_stats?.armor || item.stats?.armor) && (
- 🛡️ Armor: +{item.unique_stats?.armor || item.stats?.armor} + {t('stats.armor')}: +{item.unique_stats?.armor || item.stats?.armor}
)} {(item.unique_stats?.hp_max || item.stats?.hp_max) && (
- ❤️ Max HP: +{item.unique_stats?.hp_max || item.stats?.hp_max} + {t('stats.hp')}: +{item.unique_stats?.hp_max || item.stats?.hp_max}
)} {(item.unique_stats?.stamina_max || item.stats?.stamina_max) && (
- ⚡ Max Stamina: +{item.unique_stats?.stamina_max || item.stats?.stamina_max} + {t('stats.stamina')}: +{item.unique_stats?.stamina_max || item.stats?.stamina_max}
)} {(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) && (item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && (
- ⚔️ Damage: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} + {t('stats.damage')}: {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
)} {(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
- ⚖️ Weight: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg + {t('stats.weight')}: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
)} {(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
- 📦 Volume: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L + {t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
)} )} {item.durability !== undefined && item.durability !== null && (
- 🔧 Durability: {item.durability}/{item.max_durability} + {t('stats.durability')}: {item.durability}/{item.max_durability}
)} {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( @@ -126,12 +131,12 @@ function PlayerSidebar({
{/* Profile Stats */}
-

👤 Character

+

{t('game.character')}

- ❤️ HP + {t('stats.hp')} {playerState.health}/{playerState.max_health}
@@ -145,7 +150,7 @@ function PlayerSidebar({
- ⚡ Stamina + {t('stats.stamina')} {playerState.stamina}/{playerState.max_stamina}
@@ -161,13 +166,13 @@ function PlayerSidebar({ {profile && (
- Level: + {t('stats.level')}: {profile.level}
- ⭐ XP + {t('stats.xp')} {profile.xp} / {(profile.level * 100)}
@@ -181,7 +186,7 @@ function PlayerSidebar({ {profile.unspent_points > 0 && (
- ⭐ Unspent: + {t('stats.unspentPoints')}: {profile.unspent_points}
)} @@ -191,28 +196,28 @@ function PlayerSidebar({ {/* Compact 2x2 Stats Grid */}
- 💪 STR: + {t('stats.strength')}: {profile.strength} {profile.unspent_points > 0 && ( )}
- 🏃 AGI: + {t('stats.agility')}: {profile.agility} {profile.unspent_points > 0 && ( )}
- 🛡️ END: + {t('stats.endurance')}: {profile.endurance} {profile.unspent_points > 0 && ( )}
- 🧠 INT: + {t('stats.intellect')}: {profile.intellect} {profile.unspent_points > 0 && ( @@ -225,7 +230,7 @@ function PlayerSidebar({ {/* Inventory Capacity - matching HP/Stamina/XP style */}
- ⚖️ Weight + {t('stats.weight')} {(profile.current_weight || 0).toFixed(1)}/{profile.max_weight || 0}kg
@@ -239,7 +244,7 @@ function PlayerSidebar({
- 📦 Volume + {t('stats.volume')} {(profile.current_volume || 0).toFixed(1)}/{profile.max_volume || 0}L
@@ -271,7 +276,7 @@ function PlayerSidebar({ transition: 'all 0.2s' }} > - 🎒 Open Inventory + {t('game.inventory')}
)} @@ -279,24 +284,24 @@ function PlayerSidebar({ {/* Equipment Display - Proper Grid Layout */}
-

⚔️ Equipment

+

{t('game.equipment')}

{/* Row 1: Head */}
- {renderEquipmentSlot('head', equipment.head, '🪖', 'Head')} + {renderEquipmentSlot('head', equipment.head, '🪖', t('equipment.head'))}
{/* Row 2: Weapon, Torso, Backpack */}
- {renderEquipmentSlot('weapon', equipment.weapon, '⚔️', 'Weapon')} - {renderEquipmentSlot('torso', equipment.torso, '👕', 'Torso')} - {renderEquipmentSlot('backpack', equipment.backpack, '🎒', 'Backpack')} + {renderEquipmentSlot('weapon', equipment.weapon, '⚔️', t('equipment.weapon'))} + {renderEquipmentSlot('torso', equipment.torso, '👕', t('equipment.torso'))} + {renderEquipmentSlot('backpack', equipment.backpack, '🎒', t('equipment.backpack'))}
{/* Row 3: Legs & Feet */}
- {renderEquipmentSlot('legs', equipment.legs, '👖', 'Legs')} - {renderEquipmentSlot('feet', equipment.feet, '👟', 'Feet')} + {renderEquipmentSlot('legs', equipment.legs, '👖', t('equipment.legs'))} + {renderEquipmentSlot('feet', equipment.feet, '👟', t('equipment.feet'))}
diff --git a/pwa/src/components/game/Workbench.tsx b/pwa/src/components/game/Workbench.tsx index 7260430..6759c4c 100644 --- a/pwa/src/components/game/Workbench.tsx +++ b/pwa/src/components/game/Workbench.tsx @@ -1,5 +1,8 @@ import { useState, useEffect, type MouseEvent, type ChangeEvent } from 'react' +import { useTranslation } from 'react-i18next' import type { Profile, WorkbenchTab } from './types' +import { getAssetPath } from '../../utils/assetPath' +import { getTranslatedText } from '../../utils/i18nUtils' import './Workbench.css' interface WorkbenchProps { @@ -47,6 +50,8 @@ function Workbench({ onRepair, onUncraft }: WorkbenchProps) { + useTranslation() + const [selectedItem, setSelectedItem] = useState(null) // Reset selection when tab changes @@ -84,12 +89,12 @@ function Workbench({ switch (workbenchTab) { case 'craft': return craftableItems.filter(item => - item.name.toLowerCase().includes(craftFilter.toLowerCase()) && + getTranslatedText(item.name).toLowerCase().includes(craftFilter.toLowerCase()) && (craftCategoryFilter === 'all' || item.category === craftCategoryFilter) ) case 'repair': return repairableItems - .filter(item => item.name.toLowerCase().includes(repairFilter.toLowerCase())) + .filter(item => getTranslatedText(item.name).toLowerCase().includes(repairFilter.toLowerCase())) .sort((a, b) => { if (a.needs_repair && !b.needs_repair) return -1 if (!a.needs_repair && b.needs_repair) return 1 @@ -97,7 +102,7 @@ function Workbench({ }) case 'uncraft': return uncraftableItems.filter(item => - item.name.toLowerCase().includes(uncraftFilter.toLowerCase()) + getTranslatedText(item.name).toLowerCase().includes(uncraftFilter.toLowerCase()) ) default: return [] @@ -118,7 +123,7 @@ function Workbench({ } const item = selectedItem - const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp` + const imagePath = getAssetPath(item.image_path || `images/items/${item.item_id || item.id}.webp`) return ( <> @@ -127,7 +132,7 @@ function Workbench({ {imagePath ? ( {item.name} { (e.target as HTMLImageElement).style.display = 'none'; @@ -140,14 +145,43 @@ function Workbench({ {item.emoji || '📦'}
-

{item.emoji} {item.name}

- {item.description &&

{item.description}

} +
+

{item.emoji} {getTranslatedText(item.name)}

+ {item.description &&

{getTranslatedText(item.description)}

} - {/* Base Stats Display for Crafting */} - {workbenchTab === 'craft' && (item.base_stats || item.stats) && ( -
-
- {Object.entries(item.base_stats || item.stats).map(([key, value]) => { + {/* Base Stats Display for Crafting */} + {workbenchTab === 'craft' && (item.base_stats || item.stats) && ( +
+
+ {Object.entries(item.base_stats || item.stats).map(([key, value]) => { + const icons: Record = { + weight_capacity: '⚖️ Weight', + volume_capacity: '📦 Volume', + armor: '🛡️ Armor', + hp_max: '❤️ Max HP', + stamina_max: '⚡ Max Stamina', + damage_min: '⚔️ Damage Min', + damage_max: '⚔️ Damage Max' + } + const label = icons[key] || key.replace('_', ' ') + const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : '' + return ( +
+ {label}: +{Math.round(Number(value))}{unit} +
+ ) + })} +
+

+ * Potential base stats. Actual stats may vary. +

+
+ )} + + {/* Stats Display for Repair/Salvage */} + {workbenchTab !== 'craft' && (item.unique_item_data?.unique_stats || item.unique_item_data || item.base_stats || item.stats) && ( +
+ {Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => { const icons: Record = { weight_capacity: '⚖️ Weight', volume_capacity: '📦 Volume', @@ -166,257 +200,230 @@ function Workbench({ ) })}
-

- * Potential base stats. Actual stats may vary. -

-
+ )} +
+ + {workbenchTab === 'craft' && ( + <> +
+

📊 Requirements

+ + {item.craft_level && item.craft_level > 1 && ( +
+ Level {item.craft_level} Required + {item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`} +
+ )} + + {item.tools && item.tools.length > 0 && ( + <> +
Tools
+ {item.tools.map((tool: any, i: number) => ( +
+ {tool.emoji} {getTranslatedText(tool.name)} + + {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`} + +
+ ))} + + )} + +
Materials
+ {item.materials && item.materials.length > 0 ? ( + item.materials.map((mat: any, i: number) => ( +
+ {mat.emoji} {getTranslatedText(mat.name)} + {mat.available} / {mat.required} +
+ )) + ) : ( +
+ No materials required +
+ )} +
+ +
+ +
+ )} - {/* Stats Display for Repair/Salvage */} - {workbenchTab !== 'craft' && (item.unique_item_data?.unique_stats || item.unique_item_data || item.base_stats || item.stats) && ( -
- {Object.entries(item.unique_item_data?.unique_stats ?? item.unique_item_data ?? item.base_stats ?? item.stats ?? {}).filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)).map(([key, value]) => { - const icons: Record = { - weight_capacity: '⚖️ Weight', - volume_capacity: '📦 Volume', - armor: '🛡️ Armor', - hp_max: '❤️ Max HP', - stamina_max: '⚡ Max Stamina', - damage_min: '⚔️ Damage Min', - damage_max: '⚔️ Damage Max' - } - const label = icons[key] || key.replace('_', ' ') - const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : '' - return ( -
- {label}: +{Math.round(Number(value))}{unit} + {workbenchTab === 'repair' && ( + <> +
+

🔧 Repair Status

+ + {!item.needs_repair ? ( +

✅ Item is in perfect condition

+ ) : ( + <> +
+ Current: {item.durability_percent}% + After Repair: {Math.min(100, item.durability_percent + (item.repair_percentage || 0))}% +
+
+
+
+
+
+ {item.current_durability}/{item.max_durability} + +{Math.round((item.max_durability || 0) * ((item.repair_percentage || 0) / 100))} durability +
+ + )} + + {item.needs_repair && ( + <> + {item.tools && item.tools.length > 0 && ( + <> +
Tools
+ {item.tools.map((tool: any, i: number) => ( +
+ {tool.emoji} {getTranslatedText(tool.name)} + + {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`} + +
+ ))} + + )} + +
Materials
+ {item.materials.map((mat: any, i: number) => ( +
+ {mat.emoji} {getTranslatedText(mat.name)} + {mat.available} / {mat.quantity} +
+ ))} + + )} +
+ +
+ +
+ + )} + + {workbenchTab === 'uncraft' && ( + <> +
+

♻️ Salvage Preview

+ + {/* Show durability bar if we have durability data */} + {(item.unique_item_data || item.durability_percent !== undefined) && ( +
+
+
+
+
+ Condition: {item.unique_item_data?.durability_percent || item.durability_percent || 0}% +
- ) - })} -
+ )} + +
+ {(() => { + const durabilityRatio = item.unique_item_data?.durability_percent !== undefined + ? (item.unique_item_data.durability_percent || 0) / 100 + : item.durability_percent !== undefined + ? (item.durability_percent || 0) / 100 + : 1.0 + const adjustedYield = (item.uncraft_yield || item.base_yield || []).map((mat: any) => ({ + ...mat, + adjusted_quantity: Math.round((mat.quantity || 0) * durabilityRatio) + })) + + return ( + <> + {durabilityRatio < 1.0 && ( +
+ ⚠️ Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage +
+ )} + + {item.loss_chance && ( +
+ ⚠️ {Math.round(item.loss_chance * 100)}% chance to lose each material +
+ )} + + {adjustedYield.map((mat: any, i: number) => ( +
+ {mat.emoji} {getTranslatedText(mat.name)} + x{mat.adjusted_quantity} +
+ ))} + + ) + })()} +
+
+ +
+ +
+ )}
- - {workbenchTab === 'craft' && ( - <> -
-

📊 Requirements

- - {item.craft_level && item.craft_level > 1 && ( -
- Level {item.craft_level} Required - {item.meets_level ? '✅' : `❌ (Lv. ${profile?.level || 1})`} -
- )} - - {item.tools && item.tools.length > 0 && ( - <> -
Tools
- {item.tools.map((tool: any, i: number) => ( -
- {tool.emoji} {tool.name} - - {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`} - -
- ))} - - )} - -
Materials
- {item.materials && item.materials.length > 0 ? ( - item.materials.map((mat: any, i: number) => ( -
- {mat.emoji} {mat.name} - {mat.available} / {mat.required} -
- )) - ) : ( -
- No materials required -
- )} -
- -
- -
- - )} - - {workbenchTab === 'repair' && ( - <> -
-

🔧 Repair Status

- - {!item.needs_repair ? ( -

✅ Item is in perfect condition

- ) : ( - <> -
- Current: {item.durability_percent}% - After Repair: {Math.min(100, item.durability_percent + (item.repair_percentage || 0))}% -
-
-
-
-
-
- {item.current_durability}/{item.max_durability} - +{Math.round((item.max_durability || 0) * ((item.repair_percentage || 0) / 100))} durability -
- - )} - - {item.needs_repair && ( - <> - {item.tools && item.tools.length > 0 && ( - <> -
Tools
- {item.tools.map((tool: any, i: number) => ( -
- {tool.emoji} {tool.name} - - {tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`} - -
- ))} - - )} - -
Materials
- {item.materials.map((mat: any, i: number) => ( -
- {mat.emoji} {mat.name} - {mat.available} / {mat.quantity} -
- ))} - - )} -
- -
- -
- - )} - - {workbenchTab === 'uncraft' && ( - <> -
-

♻️ Salvage Preview

- - {/* Show durability bar if we have durability data */} - {(item.unique_item_data || item.durability_percent !== undefined) && ( -
-
-
-
-
- Condition: {item.unique_item_data?.durability_percent || item.durability_percent || 0}% -
-
- )} - -
- {(() => { - const durabilityRatio = item.unique_item_data?.durability_percent !== undefined - ? (item.unique_item_data.durability_percent || 0) / 100 - : item.durability_percent !== undefined - ? (item.durability_percent || 0) / 100 - : 1.0 - const adjustedYield = (item.uncraft_yield || item.base_yield || []).map((mat: any) => ({ - ...mat, - adjusted_quantity: Math.round((mat.quantity || 0) * durabilityRatio) - })) - - return ( - <> - {durabilityRatio < 1.0 && ( -
- ⚠️ Yield reduced by {Math.round((1 - durabilityRatio) * 100)}% due to damage -
- )} - - {item.loss_chance && ( -
- ⚠️ {Math.round(item.loss_chance * 100)}% chance to lose each material -
- )} - - {adjustedYield.map((mat: any, i: number) => ( -
- {mat.emoji} {mat.name} - x{mat.adjusted_quantity} -
- ))} - - ) - })()} -
-
- -
- -
- - )} ) } @@ -500,7 +507,7 @@ function Workbench({ {items.filter(item => { // Text search filter const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter - const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase()) + const matchesSearch = !searchFilter || getTranslatedText(item.name).toLowerCase().includes(searchFilter.toLowerCase()) // Category filter (apply to all tabs) let matchesCategory = true @@ -521,7 +528,7 @@ function Workbench({ .filter(item => { // Text search filter const searchFilter = workbenchTab === 'craft' ? craftFilter : workbenchTab === 'repair' ? repairFilter : uncraftFilter - const matchesSearch = !searchFilter || item.name.toLowerCase().includes(searchFilter.toLowerCase()) + const matchesSearch = !searchFilter || getTranslatedText(item.name).toLowerCase().includes(searchFilter.toLowerCase()) // Category filter (apply to all tabs) let matchesCategory = true @@ -533,7 +540,7 @@ function Workbench({ return matchesSearch && matchesCategory }) .map((item: any, idx: number) => { - const imagePath = item.image_path || `/images/items/${item.item_id || item.id}.webp` + const imagePath = getAssetPath(item.image_path || `images/items/${item.item_id || item.id}.webp`) return (
{ (e.target as HTMLImageElement).style.display = 'none'; @@ -564,7 +571,7 @@ function Workbench({ - {item.name} + {getTranslatedText(item.name)} {item.location === 'equipped' && Equipped}
diff --git a/pwa/src/components/game/types.ts b/pwa/src/components/game/types.ts index d09a67a..a1de803 100644 --- a/pwa/src/components/game/types.ts +++ b/pwa/src/components/game/types.ts @@ -16,13 +16,13 @@ export interface DirectionDetail { stamina_cost: number distance: number destination: string - destination_name?: string + destination_name?: string | { [key: string]: string } } export interface Location { id: string - name: string - description: string + name: string | { [key: string]: string } + description: string | { [key: string]: string } directions: string[] directions_detailed?: DirectionDetail[] danger_level?: number diff --git a/pwa/src/i18n/index.ts b/pwa/src/i18n/index.ts new file mode 100644 index 0000000..266d514 --- /dev/null +++ b/pwa/src/i18n/index.ts @@ -0,0 +1,29 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import LanguageDetector from 'i18next-browser-languagedetector' + +import en from './locales/en.json' +import es from './locales/es.json' + +const resources = { + en: { translation: en }, + es: { translation: es } +} + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + supportedLngs: ['en', 'es'], + interpolation: { + escapeValue: false // React already escapes + }, + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'] + } + }) + +export default i18n diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json new file mode 100644 index 0000000..905b264 --- /dev/null +++ b/pwa/src/i18n/locales/en.json @@ -0,0 +1,136 @@ +{ + "common": { + "loading": "Loading...", + "error": "Error", + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "close": "Close", + "yes": "Yes", + "no": "No", + "game": "Game", + "leaderboards": "Leaderboards", + "account": "Account" + }, + "auth": { + "login": "Login", + "logout": "Logout", + "register": "Register", + "username": "Username", + "password": "Password", + "email": "Email", + "forgotPassword": "Forgot Password?", + "createAccount": "Create Account", + "alreadyHaveAccount": "Already have an account?", + "dontHaveAccount": "Don't have an account?" + }, + "game": { + "travel": "🧭 Travel", + "surroundings": "🌿 Surroundings", + "character": "👤 Character", + "equipment": "⚔️ Equipment", + "inventory": "🎒 Open Inventory", + "workbench": "🔧 Workbench", + "craft": "🔨 Craft", + "repair": "🛠️ Repair", + "salvage": "♻️ Salvage", + "pickUp": "Pick Up", + "drop": "Drop", + "dropAll": "All", + "use": "Use", + "equip": "Equip", + "unequip": "Unequip", + "attack": "Attack", + "flee": "Flee", + "rest": "Rest", + "onlineCount": "{{count}} Online" + }, + "stats": { + "hp": "❤️ HP", + "maxHp": "❤️ Max HP", + "stamina": "⚡ Stamina", + "maxStamina": "⚡ Max Stamina", + "xp": "⭐ XP", + "level": "Level", + "unspentPoints": "⭐ Unspent", + "weight": "⚖️ Weight", + "volume": "📦 Volume", + "strength": "💪 STR", + "strengthFull": "Strength", + "strengthDesc": "Increases melee damage and carry capacity", + "agility": "🏃 AGI", + "agilityFull": "Agility", + "agilityDesc": "Improves dodge chance and critical hits", + "endurance": "🛡️ END", + "enduranceFull": "Endurance", + "enduranceDesc": "Increases HP and stamina", + "intellect": "🧠 INT", + "intellectFull": "Intellect", + "intellectDesc": "Enhances crafting and resource gathering", + "armor": "🛡️ Armor", + "damage": "⚔️ Damage", + "durability": "Durability" + }, + "combat": { + "inCombat": "In Combat", + "yourTurn": "Your Turn", + "enemyTurn": "Enemy's Turn", + "victory": "Victory!", + "defeat": "Defeat", + "youDied": "You Died", + "respawn": "Respawn", + "fleeSuccess": "You escaped!", + "fleeFailed": "Failed to escape!" + }, + "equipment": { + "head": "Head", + "torso": "Torso", + "legs": "Legs", + "feet": "Feet", + "weapon": "Weapon", + "backpack": "Backpack", + "noBackpack": "No Backpack Equipped", + "equipped": "Equipped" + }, + "crafting": { + "requirements": "📊 Requirements", + "materials": "Materials", + "tools": "Tools", + "levelRequired": "Level {{level}} Required", + "missingRequirements": "Missing Requirements", + "craftItem": "🔨 Craft Item", + "repairItem": "🛠️ Repair Item", + "salvageItem": "♻️ Salvage Item", + "staminaCost": "⚡ {{cost}} Stamina", + "alreadyFull": "Already Full", + "perfectCondition": "✅ Item is in perfect condition", + "yieldReduced": "⚠️ Yield reduced by {{percent}}% due to damage" + }, + "categories": { + "all": "All Items", + "weapon": "Weapons", + "armor": "Armor", + "clothing": "Clothing", + "backpack": "Backpacks", + "tool": "Tools", + "consumable": "Consumables", + "resource": "Resources", + "quest": "Quest", + "misc": "Misc" + }, + "messages": { + "notEnoughStamina": "Not enough stamina", + "inventoryFull": "Inventory full", + "itemDropped": "Item dropped", + "itemPickedUp": "Item picked up", + "waitBeforeMoving": "Wait {{seconds}}s before moving", + "cannotTravelInCombat": "Cannot travel during combat", + "cannotInteractInCombat": "Cannot interact during combat" + }, + "landing": { + "heroTitle": "Echoes of the Ash", + "heroSubtitle": "A post-apocalyptic survival RPG", + "playNow": "Play Now", + "features": "Features" + } +} \ No newline at end of file diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json new file mode 100644 index 0000000..e94e764 --- /dev/null +++ b/pwa/src/i18n/locales/es.json @@ -0,0 +1,136 @@ +{ + "common": { + "loading": "Cargando...", + "error": "Error", + "save": "Guardar", + "cancel": "Cancelar", + "confirm": "Confirmar", + "close": "Cerrar", + "yes": "Sí", + "no": "No", + "game": "Juego", + "leaderboards": "Clasificación", + "account": "Cuenta" + }, + "auth": { + "login": "Iniciar Sesión", + "logout": "Cerrar Sesión", + "register": "Registrarse", + "username": "Usuario", + "password": "Contraseña", + "email": "Correo", + "forgotPassword": "¿Olvidaste tu contraseña?", + "createAccount": "Crear Cuenta", + "alreadyHaveAccount": "¿Ya tienes una cuenta?", + "dontHaveAccount": "¿No tienes cuenta?" + }, + "game": { + "travel": "🧭 Viajar", + "surroundings": "🌿 Alrededores", + "character": "👤 Personaje", + "equipment": "⚔️ Equipamiento", + "inventory": "🎒 Abrir Inventario", + "workbench": "🔧 Banco de Trabajo", + "craft": "🔨 Fabricar", + "repair": "🛠️ Reparar", + "salvage": "♻️ Desmontar", + "pickUp": "Recoger", + "drop": "Soltar", + "dropAll": "Todo", + "use": "Usar", + "equip": "Equipar", + "unequip": "Desequipar", + "attack": "Atacar", + "flee": "Huir", + "rest": "Descansar", + "onlineCount": "{{count}} En línea" + }, + "stats": { + "hp": "❤️ Vida", + "maxHp": "❤️ Vida Máx.", + "stamina": "⚡ Aguante", + "maxStamina": "⚡ Aguante Máx.", + "xp": "⭐ XP", + "level": "Nivel", + "unspentPoints": "⭐ Sin gastar", + "weight": "⚖️ Peso", + "volume": "📦 Volumen", + "strength": "💪 FUE", + "strengthFull": "Fuerza", + "strengthDesc": "Aumenta el daño cuerpo a cuerpo y capacidad de carga", + "agility": "🏃 AGI", + "agilityFull": "Agilidad", + "agilityDesc": "Mejora la esquiva y golpes críticos", + "endurance": "🛡️ RES", + "enduranceFull": "Resistencia", + "enduranceDesc": "Aumenta la vida y energía", + "intellect": "🧠 INT", + "intellectFull": "Intelecto", + "intellectDesc": "Mejora la fabricación y recolección", + "armor": "🛡️ Armadura", + "damage": "⚔️ Daño", + "durability": "Durabilidad" + }, + "combat": { + "inCombat": "En Combate", + "yourTurn": "Tu Turno", + "enemyTurn": "Turno del Enemigo", + "victory": "¡Victoria!", + "defeat": "Derrota", + "youDied": "Has Muerto", + "respawn": "Revivir", + "fleeSuccess": "¡Escapaste!", + "fleeFailed": "¡No pudiste escapar!" + }, + "equipment": { + "head": "Cabeza", + "torso": "Torso", + "legs": "Piernas", + "feet": "Pies", + "weapon": "Arma", + "backpack": "Mochila", + "noBackpack": "Sin Mochila Equipada", + "equipped": "Equipado" + }, + "crafting": { + "requirements": "📊 Requisitos", + "materials": "Materiales", + "tools": "Herramientas", + "levelRequired": "Nivel {{level}} Requerido", + "missingRequirements": "Faltan Requisitos", + "craftItem": "🔨 Fabricar", + "repairItem": "🛠️ Reparar", + "salvageItem": "♻️ Desmontar", + "staminaCost": "⚡ {{cost}} Energía", + "alreadyFull": "Ya está Completo", + "perfectCondition": "✅ El objeto está en perfecto estado", + "yieldReduced": "⚠️ Rendimiento reducido {{percent}}% por daño" + }, + "categories": { + "all": "Todos", + "weapon": "Armas", + "armor": "Armadura", + "clothing": "Ropa", + "backpack": "Mochilas", + "tool": "Herramientas", + "consumable": "Consumibles", + "resource": "Recursos", + "quest": "Misión", + "misc": "Varios" + }, + "messages": { + "notEnoughStamina": "No tienes suficiente energía", + "inventoryFull": "Inventario lleno", + "itemDropped": "Objeto soltado", + "itemPickedUp": "Objeto recogido", + "waitBeforeMoving": "Espera {{seconds}}s antes de moverte", + "cannotTravelInCombat": "No puedes viajar en combate", + "cannotInteractInCombat": "No puedes interactuar en combate" + }, + "landing": { + "heroTitle": "Ecos de la Ceniza", + "heroSubtitle": "Un RPG de supervivencia post-apocalíptico", + "playNow": "Jugar Ahora", + "features": "Características" + } +} \ No newline at end of file diff --git a/pwa/src/main.tsx b/pwa/src/main.tsx index e40fd47..70050a5 100644 --- a/pwa/src/main.tsx +++ b/pwa/src/main.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' import './index.css' +import './i18n' // Initialize i18n import { registerSW } from 'virtual:pwa-register' import twemoji from 'twemoji' diff --git a/pwa/src/utils/assetPath.ts b/pwa/src/utils/assetPath.ts new file mode 100644 index 0000000..f3ef615 --- /dev/null +++ b/pwa/src/utils/assetPath.ts @@ -0,0 +1,41 @@ +/** + * Asset Path Utility + * + * Resolves asset paths based on runtime environment: + * - Electron: Returns local path (assets bundled with app) + * - Browser: Returns full server URL + */ + +// Detect if running in Electron +const isElectron = !!(window as any).electronAPI?.isElectron + +// Base URL for remote assets (browser mode) +const ASSET_BASE_URL = import.meta.env.VITE_ASSET_URL || + (import.meta.env.PROD ? 'https://api-staging.echoesoftheash.com' : '') + +/** + * Resolves an asset path for the current environment + * @param path - The asset path (e.g., "images/items/knife.webp" or "/images/items/knife.webp") + * @returns The resolved path for the current environment + */ +export function getAssetPath(path: string): string { + if (!path) return '' + + // Normalize path (ensure leading slash) + const normalizedPath = path.startsWith('/') ? path : `/${path}` + + if (isElectron) { + // In Electron, assets are served relative to the app + return normalizedPath + } + + // In browser, prepend the server URL + return `${ASSET_BASE_URL}${normalizedPath}` +} + +/** + * Check if we're running in Electron + */ +export function isElectronApp(): boolean { + return isElectron +} diff --git a/pwa/src/utils/i18nUtils.ts b/pwa/src/utils/i18nUtils.ts new file mode 100644 index 0000000..5c1f631 --- /dev/null +++ b/pwa/src/utils/i18nUtils.ts @@ -0,0 +1,31 @@ +import i18n from '../i18n' + +export type I18nString = string | { [key: string]: string } + +/** + * Safely extracts the translated string from a value that could be a string or an I18nString object. + * @param value The value to translate (string or object with language keys) + * @returns The translated string for the current language, or fallback to English/first available + */ +export const getTranslatedText = (value: I18nString | undefined | null): string => { + if (!value) return '' + + // If it's already a string, return it + if (typeof value === 'string') return value + + // If it's an object, try to get the current language + const currentLang = i18n.language || 'en' + + // 1. Try current language + if (value[currentLang]) return value[currentLang] + + // 2. Try English fallback + if (value['en']) return value['en'] + + // 3. Return the first available key + const firstKey = Object.keys(value)[0] + if (firstKey) return value[firstKey] + + // 4. Fallback empty + return '' +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..592392f --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,36 @@ +# Release Scripts + +## release.sh + +Automated release script for Echoes of the Ash desktop builds. + +### Usage + +```bash +./scripts/release.sh +``` + +### What it does + +1. Generates `package-lock.json` using Docker (no local npm installation required) +2. Suggests next version automatically (increments patch version) +3. Commits changes and pushes to main +4. Creates and pushes git tag to trigger CI/CD pipeline + +### Example + +``` +Current version: v0.2.6 +Suggested next version: v0.2.7 + +Enter version (or press Enter for v0.2.7): v0.3.0 + +Using version: v0.3.0 +✓ Release v0.3.0 completed! +``` + +The CI/CD pipeline will automatically build: +- Linux AppImage and .deb (compressed in `linux-builds.zip`) +- Windows .exe installer (compressed in `windows-builds.zip`) + +Download artifacts from: http://gitlab.kingstudio.es/patacuack/echoes-of-the-ash/-/pipelines diff --git a/scripts/convert_to_i18n.py b/scripts/convert_to_i18n.py new file mode 100644 index 0000000..9ae0900 --- /dev/null +++ b/scripts/convert_to_i18n.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Convert game data JSON files to i18n-ready format. +Transforms plain text fields to inline translation objects. + +Usage: python convert_to_i18n.py +""" + +import json +from pathlib import Path + +GAMEDATA_DIR = Path(__file__).parent.parent / 'gamedata' + +# Fields that should be translated +TRANSLATABLE_FIELDS = ['name', 'description'] + +# Nested text fields in outcomes +OUTCOME_TEXT_FIELDS = ['success', 'failure', 'crit_success', 'crit_failure'] + + +def convert_text_to_i18n(value: str) -> dict: + """Convert a plain string to i18n format""" + if not value or not isinstance(value, str): + return value + return { + "en": value, + "es": "" # Empty - to be translated + } + + +def convert_item(item_id: str, item: dict) -> dict: + """Convert an item to i18n format""" + result = {} + for key, value in item.items(): + if key in TRANSLATABLE_FIELDS and isinstance(value, str): + result[key] = convert_text_to_i18n(value) + else: + result[key] = value + return result + + +def convert_location(location: dict) -> dict: + """Convert a location to i18n format with nested interactable outcomes""" + result = {} + for key, value in location.items(): + if key in TRANSLATABLE_FIELDS and isinstance(value, str): + result[key] = convert_text_to_i18n(value) + elif key == 'interactables' and isinstance(value, dict): + # Convert interactable outcome texts + result[key] = convert_interactables(value) + else: + result[key] = value + return result + + +def convert_interactables(interactables: dict) -> dict: + """Convert interactable templates and outcomes to i18n format""" + result = {} + for inter_id, inter_data in interactables.items(): + result[inter_id] = {} + for key, value in inter_data.items(): + if key in TRANSLATABLE_FIELDS and isinstance(value, str): + result[inter_id][key] = convert_text_to_i18n(value) + elif key == 'actions' and isinstance(value, dict): + result[inter_id][key] = convert_actions(value) + elif key == 'outcomes' and isinstance(value, dict): + result[inter_id][key] = convert_outcomes(value) + else: + result[inter_id][key] = value + return result + + +def convert_actions(actions: dict) -> dict: + """Convert action labels to i18n format""" + result = {} + for action_id, action_data in actions.items(): + result[action_id] = {} + for key, value in action_data.items(): + if key == 'label' and isinstance(value, str): + result[action_id][key] = convert_text_to_i18n(value) + else: + result[action_id][key] = value + return result + + +def convert_outcomes(outcomes: dict) -> dict: + """Convert outcome text fields to i18n format""" + result = {} + for outcome_id, outcome_data in outcomes.items(): + result[outcome_id] = {} + for key, value in outcome_data.items(): + if key == 'text' and isinstance(value, dict): + result[outcome_id][key] = {} + for text_key, text_value in value.items(): + if text_key in OUTCOME_TEXT_FIELDS and isinstance(text_value, str) and text_value: + result[outcome_id][key][text_key] = convert_text_to_i18n(text_value) + else: + result[outcome_id][key][text_key] = text_value + else: + result[outcome_id][key] = value + return result + + +def convert_npc(npc: dict) -> dict: + """Convert an NPC to i18n format""" + result = {} + for key, value in npc.items(): + if key in TRANSLATABLE_FIELDS and isinstance(value, str): + result[key] = convert_text_to_i18n(value) + else: + result[key] = value + return result + + + +def convert_interactable_template(interactable: dict) -> dict: + """Convert an interactable template to i18n format""" + result = {} + for key, value in interactable.items(): + if key in TRANSLATABLE_FIELDS and isinstance(value, str): + result[key] = convert_text_to_i18n(value) + elif key == 'actions' and isinstance(value, dict): + result[key] = convert_actions(value) + else: + result[key] = value + return result + + +def convert_file(filename: str, converter, key_name: str): + """Convert a JSON file to i18n format""" + filepath = GAMEDATA_DIR / filename + + print(f"Converting {filename}...") + + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + if key_name in data: + if isinstance(data[key_name], dict): + # items.json format: {"items": {"item_id": {...}, ...}} + data[key_name] = { + item_id: converter(item_id, item) if key_name == 'items' else converter(item) + for item_id, item in data[key_name].items() + } + elif isinstance(data[key_name], list): + # locations.json format: {"locations": [{...}, ...]} + data[key_name] = [converter(item) for item in data[key_name]] + + # Write back + output_file = GAMEDATA_DIR / f'{filename.replace(".json", "_i18n.json")}' + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + print(f" -> Saved to {output_file.name}") + + +def main(): + print("Converting game data to i18n format...\n") + + convert_file('items.json', convert_item, 'items') + convert_file('locations.json', convert_location, 'locations') + convert_file('npcs.json', convert_npc, 'npcs') + convert_file('interactables.json', convert_interactable_template, 'interactables') + + print("\nDone! Review *_i18n.json files, then rename to replace originals.") + print("Spanish translations are empty - add them in the web-map editor.") + + +if __name__ == '__main__': + main() diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..59e1012 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +# Echoes of the Ash - Release Script +# Generates package-lock.json, commits changes, and creates a new release tag + +set -e # Exit on error + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} Echoes of the Ash - Release Script${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Get the project root directory +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Step 1: Generate package-lock.json using Docker +echo -e "${YELLOW}[1/5]${NC} Generating package-lock.json with Docker..." +docker run --rm -v "$PROJECT_ROOT/pwa:/app" -w /app node:20-alpine npm install + +# Optional: Remove node_modules to keep local directory clean +echo -e "${YELLOW}[2/5]${NC} Cleaning up node_modules..." +#rm -rf "$PROJECT_ROOT/pwa/node_modules" + +echo -e "${GREEN}✓${NC} package-lock.json generated successfully" +echo "" + +# Step 2: Get current version from git tags +echo -e "${YELLOW}[3/5]${NC} Determining version..." +CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") +echo -e "Current version: ${GREEN}$CURRENT_VERSION${NC}" + +# Parse version and suggest next version +if [[ $CURRENT_VERSION =~ v([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + NEXT_PATCH=$((PATCH + 1)) + SUGGESTED_VERSION="v${MAJOR}.${MINOR}.${NEXT_PATCH}" +else + SUGGESTED_VERSION="v0.1.0" +fi + +echo -e "Suggested next version: ${BLUE}$SUGGESTED_VERSION${NC}" +echo "" +read -p "Enter version (or press Enter for $SUGGESTED_VERSION): " NEW_VERSION + +# Use suggested version if user didn't provide one +if [ -z "$NEW_VERSION" ]; then + NEW_VERSION="$SUGGESTED_VERSION" +fi + +# Ensure version starts with 'v' +if [[ ! $NEW_VERSION =~ ^v ]]; then + NEW_VERSION="v$NEW_VERSION" +fi + +echo -e "Using version: ${GREEN}$NEW_VERSION${NC}" +echo "" + +# Step 3: Git add and commit +echo -e "${YELLOW}[4/5]${NC} Committing changes..." +git add -A + +# Check if there are changes to commit +if git diff --cached --quiet; then + echo -e "${YELLOW}No changes to commit${NC}" +else + git commit -m "Release $NEW_VERSION: Update package-lock.json and CI config" + echo -e "${GREEN}✓${NC} Changes committed" +fi + +# Push to main +echo -e "${YELLOW}Pushing to main branch...${NC}" +git push origin main +echo -e "${GREEN}✓${NC} Pushed to main" +echo "" + +# Step 4: Create and push tag +echo -e "${YELLOW}[5/5]${NC} Creating release tag..." + +# Delete tag if it already exists (locally and remotely) +if git tag -l | grep -q "^$NEW_VERSION$"; then + echo -e "${YELLOW}Tag $NEW_VERSION already exists, deleting...${NC}" + git tag -d "$NEW_VERSION" + git push origin ":refs/tags/$NEW_VERSION" 2>/dev/null || true +fi + +# Create new tag +git tag "$NEW_VERSION" +echo -e "${GREEN}✓${NC} Tag $NEW_VERSION created" + +# Push tag +git push origin "$NEW_VERSION" +echo -e "${GREEN}✓${NC} Tag pushed to remote" +echo "" + +# Step 5: Summary +echo -e "${BLUE}========================================${NC}" +echo -e "${GREEN}✓ Release $NEW_VERSION completed!${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" +echo -e "Pipeline URL:" +echo -e "${BLUE}http://gitlab.kingstudio.es/patacuack/echoes-of-the-ash/-/pipelines${NC}" +echo "" +echo -e "The CI/CD pipeline will now build:" +echo -e " • Linux AppImage and .deb (compressed)" +echo -e " • Windows .exe installer (compressed)" +echo "" +echo -e "Build time: ~10-20 minutes" +echo "" diff --git a/web-map/editor.html b/web-map/editor.html index 7a64dff..04fd87f 100644 --- a/web-map/editor.html +++ b/web-map/editor.html @@ -922,13 +922,17 @@
- - + +
+ + +
- - + + +
diff --git a/web-map/editor_enhanced.js b/web-map/editor_enhanced.js index 2f4f0ad..c5842e9 100644 --- a/web-map/editor_enhanced.js +++ b/web-map/editor_enhanced.js @@ -12,6 +12,14 @@ let liveStats = { players: {}, enemies: {} }; let canvas, ctx; let currentTab = 'locations'; +// Helper for i18n display +function getI18nDisplay(val) { + if (typeof val === 'object' && val !== null) { + return val.en || val.es || ''; + } + return val || ''; +} + // View state let scale = 50; let minScale = 10; @@ -192,7 +200,8 @@ function renderLocationList() { currentLocations.forEach(location => { const item = document.createElement('div'); item.className = 'location-item'; - item.dataset.name = location.name; + const displayName = getI18nDisplay(location.name); + item.dataset.name = displayName; item.dataset.id = location.id; if (location.id === selectedLocationId) { item.classList.add('active'); @@ -202,7 +211,7 @@ function renderLocationList() { const enemyCount = liveStats.enemies[location.id] || 0; item.innerHTML = ` -
${location.name}
+
${displayName}
📍 (${location.x}, ${location.y}) | Danger: ${location.danger_level}
${playerCount > 0 || enemyCount > 0 ? `
👥 ${playerCount} | 👹 ${enemyCount}
` : ''} `; @@ -233,8 +242,26 @@ function populateForm(location) { document.getElementById('propertiesForm').classList.remove('hidden'); document.getElementById('locationId').value = location.id; - document.getElementById('locationName').value = location.name; - document.getElementById('locationDescription').value = location.description; + + // Handle i18n name + const name = location.name || ''; + if (typeof name === 'object') { + document.getElementById('locationName_en').value = name.en || ''; + document.getElementById('locationName_es').value = name.es || ''; + } else { + document.getElementById('locationName_en').value = name; + document.getElementById('locationName_es').value = ''; + } + + // Handle i18n description + const desc = location.description || ''; + if (typeof desc === 'object') { + document.getElementById('locationDescription_en').value = desc.en || ''; + document.getElementById('locationDescription_es').value = desc.es || ''; + } else { + document.getElementById('locationDescription_en').value = desc; + document.getElementById('locationDescription_es').value = ''; + } document.getElementById('locationX').value = location.x; document.getElementById('locationY').value = location.y; document.getElementById('dangerLevel').value = location.danger_level; @@ -521,7 +548,7 @@ function drawLocations(centerX, centerY) { ctx.fillStyle = '#e0e0e0'; ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(location.name, x, y + 30); + ctx.fillText(getI18nDisplay(location.name), x, y + 30); // Draw live stats badges const playerCount = liveStats.players[location.id] || 0; @@ -713,8 +740,14 @@ function createNewLocation() { async function saveLocation() { const locationData = { id: document.getElementById('locationId').value, - name: document.getElementById('locationName').value, - description: document.getElementById('locationDescription').value, + name: { + en: document.getElementById('locationName_en').value, + es: document.getElementById('locationName_es').value + }, + description: { + en: document.getElementById('locationDescription_en').value, + es: document.getElementById('locationDescription_es').value + }, x: parseFloat(document.getElementById('locationX').value), y: parseFloat(document.getElementById('locationY').value), danger_level: parseInt(document.getElementById('dangerLevel').value), @@ -1364,8 +1397,8 @@ function editInteractableInstance(instanceId, templateId) { const editor = document.getElementById('interactableInstanceEditor'); editor.innerHTML = `
-

${template.name}

-

${template.description}

+

${getI18nDisplay(template.name)}

+

${getI18nDisplay(template.description)}

@@ -1448,7 +1481,7 @@ function editInteractableInstance(instanceId, templateId) {
${(outcome.rewards?.items || []).map((reward, idx) => { const selectedItem = availableItems.find(i => i.id === reward.item_id); - const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${selectedItem.name}` : ''; + const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${getI18nDisplay(selectedItem.name)}` : ''; return `
@@ -1484,7 +1517,7 @@ function editInteractableInstance(instanceId, templateId) {
${(outcome.rewards?.crit_items || []).map((reward, idx) => { const selectedItem = availableItems.find(i => i.id === reward.item_id); - const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${selectedItem.name}` : ''; + const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${getI18nDisplay(selectedItem.name)}` : ''; return `
@@ -1590,7 +1623,7 @@ function filterItemDropdown(input) { // Filter items const filteredItems = availableItems.filter(item => { - const itemText = `${item.emoji || ''} ${item.name || ''} ${item.id}`.toLowerCase(); + const itemText = `${item.emoji || ''} ${getI18nDisplay(item.name)} ${item.id}`.toLowerCase(); return itemText.includes(searchText); }); @@ -1612,7 +1645,7 @@ function filterItemDropdown(input) { optionDiv.style.borderBottom = '1px solid #2a2a4a'; optionDiv.innerHTML = ` - ${item.emoji || '📦'} ${item.name || item.id} + ${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id} ${item.id} `; @@ -1628,7 +1661,7 @@ function filterItemDropdown(input) { optionDiv.addEventListener('click', function (e) { e.stopPropagation(); // Set the visible input to show emoji + name - input.value = `${item.emoji || '📦'} ${item.name || item.id}`; + input.value = `${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id}`; // Set the hidden input to store the item_id hiddenInput.value = item.id; dropdown.style.display = 'none'; @@ -1674,7 +1707,7 @@ function filterMaterialDropdown(input, prefix) { // Filter items const filteredItems = availableItems.filter(item => { - const itemText = `${item.emoji || ''} ${item.name || ''} ${item.id}`.toLowerCase(); + const itemText = `${item.emoji || ''} ${getI18nDisplay(item.name)} ${item.id}`.toLowerCase(); return itemText.includes(searchText); }); @@ -1696,7 +1729,7 @@ function filterMaterialDropdown(input, prefix) { optionDiv.style.borderBottom = '1px solid #2a2a4a'; optionDiv.innerHTML = ` - ${item.emoji || '📦'} ${item.name || item.id} + ${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id} ${item.id} `; @@ -1711,7 +1744,7 @@ function filterMaterialDropdown(input, prefix) { // Add click handler optionDiv.addEventListener('click', function (e) { e.stopPropagation(); - input.value = `${item.emoji || '📦'} ${item.name || item.id}`; + input.value = `${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id}`; hiddenInput.value = item.id; dropdown.style.display = 'none'; }); @@ -1734,7 +1767,7 @@ function filterToolDropdown(input, prefix) { // Filter items const filteredItems = availableItems.filter(item => { - const itemText = `${item.emoji || ''} ${item.name || ''} ${item.id}`.toLowerCase(); + const itemText = `${item.emoji || ''} ${getI18nDisplay(item.name)} ${item.id}`.toLowerCase(); return itemText.includes(searchText); }); @@ -1756,7 +1789,7 @@ function filterToolDropdown(input, prefix) { optionDiv.style.borderBottom = '1px solid #2a2a4a'; optionDiv.innerHTML = ` - ${item.emoji || '🔧'} ${item.name || item.id} + ${item.emoji || '🔧'} ${getI18nDisplay(item.name) || item.id} ${item.id} `; @@ -1771,7 +1804,7 @@ function filterToolDropdown(input, prefix) { // Add click handler optionDiv.addEventListener('click', function (e) { e.stopPropagation(); - input.value = `${item.emoji || '🔧'} ${item.name || item.id}`; + input.value = `${item.emoji || '🔧'} ${getI18nDisplay(item.name) || item.id}`; hiddenInput.value = item.id; dropdown.style.display = 'none'; }); @@ -1932,14 +1965,14 @@ function renderInteractablesList(interactables) { // Try to find template in available interactables const template = availableInteractables.find(i => i.id === templateId); if (template) { - displayName = template.name; + displayName = getI18nDisplay(template.name); actionCount = Object.keys(template.actions || {}).length; console.log(`Found template: ${displayName} with ${actionCount} actions`); } else { console.log(`Template ${templateId} not found in availableInteractables`); // For new format instances that have full data if (instance.name) { - displayName = instance.name; + displayName = getI18nDisplay(instance.name); } if (instance.actions) { actionCount = Object.keys(instance.actions).length; @@ -2327,14 +2360,15 @@ function renderNPCManagementList() { availableNPCs.forEach(npc => { const item = document.createElement('div'); item.className = 'management-item'; - item.dataset.name = npc.name; + const displayName = getI18nDisplay(npc.name); + item.dataset.name = displayName; item.dataset.id = npc.id; if (npc.id === selectedNPCId) { item.classList.add('active'); } item.innerHTML = ` -
${npc.emoji} ${npc.name}
+
${npc.emoji} ${displayName}
HP: ${npc.hp_min}-${npc.hp_max} | DMG: ${npc.damage_min}-${npc.damage_max}
@@ -2374,7 +2408,10 @@ function selectNPC(npcId) {
- +
+ + +
@@ -2417,7 +2454,8 @@ function selectNPC(npcId) {
- + +
@@ -2587,7 +2625,10 @@ async function saveCurrentNPC() { const npcData = { id: document.getElementById('npcId').value, - name: document.getElementById('npcName').value, + name: { + en: document.getElementById('npcName_en').value, + es: document.getElementById('npcName_es').value + }, emoji: document.getElementById('npcEmoji').value, hp_min: parseInt(document.getElementById('npcHpMin').value), hp_max: parseInt(document.getElementById('npcHpMax').value), @@ -2595,7 +2636,10 @@ async function saveCurrentNPC() { damage_max: parseInt(document.getElementById('npcDamageMax').value), xp_reward: parseInt(document.getElementById('npcXp').value), defense: parseInt(document.getElementById('npcDefense').value), - description: document.getElementById('npcDescription').value, + description: { + en: document.getElementById('npcDescription_en').value, + es: document.getElementById('npcDescription_es').value + }, image_path: document.getElementById('npcImagePath').value, loot_table: getNPCLootTable(), corpse_loot: getNPCCorpseLoot() @@ -2685,7 +2729,7 @@ function filterNPCLootItemDropdown(index) { const filtered = availableItems.filter(item => item.id.toLowerCase().includes(searchTerm) || - (item.name && item.name.toLowerCase().includes(searchTerm)) || + (getI18nDisplay(item.name).toLowerCase().includes(searchTerm)) || (item.emoji && item.emoji.includes(searchTerm)) ); @@ -2700,7 +2744,7 @@ function filterNPCLootItemDropdown(index) { option.style.padding = '8px'; option.style.cursor = 'pointer'; option.style.borderBottom = '1px solid #2a2a4a'; - option.innerHTML = `${item.emoji || '📦'} ${item.name || item.id} (${item.id})`; + option.innerHTML = `${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id} (${item.id})`; // Add hover effects option.addEventListener('mouseover', function () { @@ -2732,7 +2776,7 @@ function filterNPCCorpseItemDropdown(index) { const filtered = availableItems.filter(item => item.id.toLowerCase().includes(searchTerm) || - (item.name && item.name.toLowerCase().includes(searchTerm)) || + (getI18nDisplay(item.name).toLowerCase().includes(searchTerm)) || (item.emoji && item.emoji.includes(searchTerm)) ); @@ -2747,7 +2791,7 @@ function filterNPCCorpseItemDropdown(index) { option.style.padding = '8px'; option.style.cursor = 'pointer'; option.style.borderBottom = '1px solid #2a2a4a'; - option.innerHTML = `${item.emoji || '📦'} ${item.name || item.id} (${item.id})`; + option.innerHTML = `${item.emoji || '📦'} ${getI18nDisplay(item.name) || item.id} (${item.id})`; // Add hover effects option.addEventListener('mouseover', function () { @@ -2858,7 +2902,8 @@ function renderItemManagementList() { availableItems.forEach(item => { const elem = document.createElement('div'); elem.className = 'management-item'; - elem.dataset.name = item.name; + const displayName = getI18nDisplay(item.name); + elem.dataset.name = displayName; elem.dataset.id = item.id; elem.dataset.type = item.type || ''; if (item.id === selectedItemId) { @@ -2866,7 +2911,7 @@ function renderItemManagementList() { } elem.innerHTML = ` -
${item.emoji ? item.emoji + ' ' : ''}${item.name}
+
${item.emoji ? item.emoji + ' ' : ''}${displayName}
Type: ${item.type} | Weight: ${item.weight}kg
@@ -2913,7 +2958,10 @@ function selectItem(itemId) {
- +
+ + +
@@ -2947,7 +2995,8 @@ function selectItem(itemId) {
- + +
@@ -3204,7 +3253,8 @@ function renderMaterialsList(materials, prefix) { return materials.map((mat, index) => { const selectedItem = availableItems.find(i => i.id === mat.item_id); - const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${selectedItem.name}` : ''; + const displayValue = selectedItem ? `${selectedItem.emoji || '📦'} ${getI18nDisplay(selectedItem.name)}` : ''; + return `
@@ -3233,7 +3283,7 @@ function renderToolsList(tools, prefix) { return tools.map((tool, index) => { const selectedItem = availableItems.find(i => i.id === tool.item_id); - const displayValue = selectedItem ? `${selectedItem.emoji || '🔧'} ${selectedItem.name}` : ''; + const displayValue = selectedItem ? `${selectedItem.emoji || '🔧'} ${getI18nDisplay(selectedItem.name)}` : ''; return `
@@ -3327,12 +3377,18 @@ async function saveCurrentItem_() { // Build complete item data object const itemData = { id: document.getElementById('itemId').value, - name: document.getElementById('itemName').value, + name: { + en: document.getElementById('itemName_en').value, + es: document.getElementById('itemName_es').value + }, emoji: document.getElementById('itemEmoji').value, type: document.getElementById('itemType').value, weight: parseFloat(document.getElementById('itemWeight').value), volume: parseFloat(document.getElementById('itemVolume').value), - description: document.getElementById('itemDescription').value, + description: { + en: document.getElementById('itemDescription_en').value, + es: document.getElementById('itemDescription_es').value + }, image_path: document.getElementById('itemImagePath')?.value || '', stackable: document.getElementById('itemStackable').checked,