diff --git a/api/database.py b/api/database.py index 2c3f0ba..5cc4a61 100644 --- a/api/database.py +++ b/api/database.py @@ -329,6 +329,18 @@ character_quests = Table( UniqueConstraint("character_id", "quest_id", name="uix_char_quest") ) +# Quests: Character History +character_quest_history = Table( + "character_quest_history", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False), + Column("quest_id", String, nullable=False), + Column("started_at", Float, nullable=False), + Column("completed_at", Float, default=lambda: time.time()), + Column("rewards", JSON, default={}), +) + # Quests: Global progress global_quests = Table( "global_quests", @@ -405,6 +417,8 @@ async def init_db(): # Quests "CREATE INDEX IF NOT EXISTS idx_character_quests_char ON character_quests(character_id);", "CREATE INDEX IF NOT EXISTS idx_character_quests_status ON character_quests(status);", + "CREATE INDEX IF NOT EXISTS idx_character_quest_history_char ON character_quest_history(character_id);", + "CREATE INDEX IF NOT EXISTS idx_character_quest_history_completed ON character_quest_history(completed_at);", # Merchant Stock "CREATE INDEX IF NOT EXISTS idx_merchant_stock_npc ON merchant_stock(npc_id);", @@ -751,7 +765,9 @@ async def get_character_quests(character_id: int) -> List[Dict[str, Any]]: """Get all quests for a character""" async with DatabaseSession() as session: result = await session.execute( - select(character_quests).where(character_quests.c.character_id == character_id) + select(character_quests) + .where(character_quests.c.character_id == character_id) + .order_by(character_quests.c.started_at.desc()) ) rows = result.fetchall() return [dict(row._mapping) for row in rows] @@ -799,61 +815,139 @@ async def accept_quest(character_id: int, quest_id: str) -> Dict[str, Any]: started_at=time.time(), times_completed=0 ).returning(character_quests) - + result = await session.execute(stmt) row = result.first() await session.commit() return dict(row._mapping) if row else None -async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: Optional[str] = None) -> bool: +async def delete_character_quest(character_id: int, quest_id: str) -> bool: + """Delete a character quest (used when completing or abandoning)""" + async with DatabaseSession() as session: + stmt = delete(character_quests).where( + and_( + character_quests.c.character_id == character_id, + character_quests.c.quest_id == quest_id + ) + ) + await session.execute(stmt) + await session.commit() + return True + + +async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: str = "active") -> bool: """Update quest progress""" - values = {"progress": progress} - if status: - values["status"] = status + async with DatabaseSession() as session: + # Check if we need to update timestamp + values = { + "progress": progress, + "status": status + } + if status == "completed": values["completed_at"] = time.time() values["last_completed_at"] = time.time() # Increment times_completed - # We need to read first or use a raw update expression, - # simplest is to just increment in python for now or assume caller logic handles it - # But let's do it right: - # We can't easily do col + 1 in a simple update call without pulling in Table object to the values - # So we'll rely on a fetch-update pattern or standard SQL - pass - - async with DatabaseSession() as session: - # If completing, increment counter - if status == "completed": - # We can use the column expression for atomic increment - values["times_completed"] = character_quests.c.times_completed + 1 + # We need to do this carefully atomically or just fetch-update + # Doing fetch-update for simplicity as we are inside transaction block if we used one, + # but DatabaseSession is per-call here. + + # Using specific update to increment + stmt = update(character_quests).where( + and_( + character_quests.c.character_id == character_id, + character_quests.c.quest_id == quest_id + ) + ).values(**values) + + # Also increment times_completed separately to avoid overwrite race with simple values + stmt2 = update(character_quests).where( + and_( + character_quests.c.character_id == character_id, + character_quests.c.quest_id == quest_id + ) + ).values(times_completed=character_quests.c.times_completed + 1) + + await session.execute(stmt) + await session.execute(stmt2) + else: + stmt = update(character_quests).where( + and_( + character_quests.c.character_id == character_id, + character_quests.c.quest_id == quest_id + ) + ).values(**values) + await session.execute(stmt) - stmt = update(character_quests).where( - and_( - character_quests.c.character_id == character_id, - character_quests.c.quest_id == quest_id - ) - ).values(**values) - await session.execute(stmt) await session.commit() return True -async def set_quest_cooldown(character_id: int, quest_id: str, cooldown_expires_at: float) -> bool: - """Set cooldown for a quest""" +async def set_quest_cooldown(character_id: int, quest_id: str, expires_at: float) -> bool: + """Set cooldown for a repeatable quest""" async with DatabaseSession() as session: stmt = update(character_quests).where( and_( character_quests.c.character_id == character_id, character_quests.c.quest_id == quest_id ) - ).values(cooldown_expires_at=cooldown_expires_at) + ).values(cooldown_expires_at=expires_at) await session.execute(stmt) await session.commit() return True -# Global Quests +async def log_quest_completion(character_id: int, quest_id: str, started_at: float, rewards: Dict) -> bool: + """Log a quest completion to history""" + async with DatabaseSession() as session: + stmt = insert(character_quest_history).values( + character_id=character_id, + quest_id=quest_id, + started_at=started_at, + completed_at=time.time(), + rewards=rewards + ) + await session.execute(stmt) + await session.commit() + return True + + +async def get_quest_history(character_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]: + """Get quest history with pagination""" + offset = (page - 1) * page_size + + async with DatabaseSession() as session: + # Get total count + count_stmt = select(character_quest_history.c.id).where( + character_quest_history.c.character_id == character_id + ) + count_result = await session.execute(count_stmt) + total_count = len(count_result.fetchall()) + + # Get paged results + stmt = select(character_quest_history).where( + character_quest_history.c.character_id == character_id + ).order_by( + character_quest_history.c.completed_at.desc() + ).offset(offset).limit(page_size) + + result = await session.execute(stmt) + rows = result.fetchall() + data = [dict(row._mapping) for row in rows] + + return { + "data": data, + "total": total_count, + "page": page, + "pages": (total_count + page_size - 1) // page_size + } + + +# ======================================================================== +# GLOBAL QUEST OPERATIONS +# ======================================================================== + async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]: """Get global quest progress""" async with DatabaseSession() as session: @@ -864,34 +958,63 @@ async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]: return dict(row._mapping) if row else None -async def update_global_quest(quest_id: str, progress: Dict, is_completed: bool = False) -> bool: - """Update or create global quest progress""" - # Upsert logic - existing = await get_global_quest(quest_id) - +async def update_global_quest(quest_id: str, progress: Dict) -> bool: + """Update global quest progress""" async with DatabaseSession() as session: - if existing: - stmt = update(global_quests).where( - global_quests.c.quest_id == quest_id - ).values( - global_progress=progress, - is_completed=is_completed, - updated_at=time.time() - ) + # Upsert + existing = await session.execute( + select(global_quests).where(global_quests.c.quest_id == quest_id) + ) + if existing.first(): + stmt = update(global_quests).where( + global_quests.c.quest_id == quest_id + ).values( + global_progress=progress, + updated_at=time.time() + ) else: - stmt = insert(global_quests).values( - quest_id=quest_id, - global_progress=progress, - is_completed=is_completed, - updated_at=time.time() - ) - + stmt = insert(global_quests).values( + quest_id=quest_id, + global_progress=progress, + updated_at=time.time() + ) + await session.execute(stmt) await session.commit() return True -# ======================================================================== +async def get_completed_global_quests() -> List[str]: + """Get list of IDs of all completed global quests""" + async with DatabaseSession() as session: + result = await session.execute( + select(global_quests.c.quest_id).where(global_quests.c.is_completed == True) + ) + return [row[0] for row in result.fetchall()] + + +async def mark_global_quest_completed(quest_id: str) -> bool: + """Mark a global quest as completed""" + async with DatabaseSession() as session: + stmt = update(global_quests).where( + global_quests.c.quest_id == quest_id + ).values( + is_completed=True, + updated_at=time.time() + ) + await session.execute(stmt) + await session.commit() + return True + + +async def get_all_quest_participants(quest_id: str) -> List[Dict[str, Any]]: + """Get all characters who have this quest active or completed""" + async with DatabaseSession() as session: + result = await session.execute( + select(character_quests).where(character_quests.c.quest_id == quest_id) + ) + return [dict(row._mapping) for row in result.fetchall()] + # MERCHANT OPERATIONS # ======================================================================== diff --git a/api/main.py b/api/main.py index 0086871..c593774 100644 --- a/api/main.py +++ b/api/main.py @@ -88,6 +88,26 @@ async def lifespan(app: FastAPI): print(f"✅ Started {len(tasks)} background tasks in this worker") else: print("⏭️ Background tasks running in another worker") + + # APPLY GLOBAL QUEST UNLOCKS + print("🔓 Applying global quest unlocks...") + try: + completed_quests = await db.get_completed_global_quests() + print(f" - Found {len(completed_quests)} completed global quests") + for quest_id in completed_quests: + # Unlock locations + for loc in LOCATIONS.values(): + if loc.unlocked_by == quest_id: + loc.locked = False + print(f" - Unlocked location: {loc.id}") + + # Unlock interactables + for inter in loc.interactables: + if inter.unlocked_by == quest_id: + inter.locked = False + print(f" - Unlocked interactable: {inter.id} in {loc.id}") + except Exception as e: + print(f"❌ Failed to apply global quest unlocks: {e}") yield @@ -150,6 +170,16 @@ try: NPCS_DATA = n_data.get("static_npcs", {}) print(f"✅ Loaded {len(NPCS_DATA)} static NPCs") + # Load Enemies / Other NPCs + enemies_path = Path("./gamedata/npcs.json") + if enemies_path.exists(): + with open(enemies_path, "r") as f: + e_data = json.load(f) + enemies = e_data.get("npcs", {}) + # Merge into NPCS_DATA + NPCS_DATA.update(enemies) + print(f"✅ Loaded {len(enemies)} enemies/NPCs") + except Exception as e: print(f"❌ Error loading game data: {e}") @@ -161,7 +191,7 @@ crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD) admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR) -quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA) +quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS) trade.init_router_dependencies(ITEMS_MANAGER, NPCS_DATA) npcs.init_router_dependencies() diff --git a/api/routers/auth.py b/api/routers/auth.py index eb46a5d..7c285a5 100644 --- a/api/routers/auth.py +++ b/api/routers/auth.py @@ -10,6 +10,8 @@ from ..services.helpers import get_game_message from ..core.security import create_access_token, hash_password, verify_password, get_current_user from ..services.models import UserRegister, UserLogin from .. import database as db +from ..items import items_manager +from ..services.helpers import calculate_player_capacity, enrich_character_data router = APIRouter(prefix="/api/auth", tags=["authentication"]) @@ -110,23 +112,7 @@ async def login(user: UserLogin): "is_premium": account.get("premium_expires_at") is not None, }, "characters": [ - { - "id": char["id"], - "name": char["name"], - "level": char["level"], - "xp": char["xp"], - "hp": char["hp"], - "max_hp": char["max_hp"], - "stamina": char["stamina"], - "max_stamina": char["max_stamina"], - "strength": char["strength"], - "agility": char["agility"], - "endurance": char["endurance"], - "intellect": char["intellect"], - "avatar_data": char.get("avatar_data"), - "last_played_at": char.get("last_played_at"), - "location_id": char["location_id"], - } + await enrich_character_data(char, items_manager) for char in characters ], "needs_character_creation": len(characters) == 0 @@ -188,17 +174,7 @@ async def get_account(current_user: Dict[str, Any] = Depends(get_current_user)): "last_login_at": account.get("last_login_at"), }, "characters": [ - { - "id": char["id"], - "name": char["name"], - "level": char["level"], - "xp": char["xp"], - "hp": char["hp"], - "max_hp": char["max_hp"], - "location_id": char["location_id"], - "avatar_data": char.get("avatar_data"), - "last_played_at": char.get("last_played_at"), - } + await enrich_character_data(char, items_manager) for char in characters ] } @@ -373,17 +349,7 @@ async def steam_login(steam_data: Dict[str, Any]): "last_login_at": account.get("last_login_at") }, "characters": [ - { - "id": char["id"], - "name": char["name"], - "level": char["level"], - "xp": char["xp"], - "hp": char["hp"], - "max_hp": char["max_hp"], - "location_id": char["location_id"], - "avatar_data": char.get("avatar_data"), - "last_played_at": char.get("last_played_at") - } + await enrich_character_data(char, items_manager) for char in characters ], "needs_character_creation": len(characters) == 0 diff --git a/api/routers/characters.py b/api/routers/characters.py index 6510531..79d3434 100644 --- a/api/routers/characters.py +++ b/api/routers/characters.py @@ -5,7 +5,8 @@ Handles character creation, selection, and deletion. from fastapi import APIRouter, HTTPException, Depends, status, Request from fastapi.security import HTTPAuthorizationCredentials -from ..services.helpers import get_game_message +from ..items import items_manager +from ..services.helpers import enrich_character_data, get_game_message from ..core.security import decode_token, create_access_token, security from ..services.models import CharacterCreate, CharacterSelect @@ -13,7 +14,6 @@ from .. import database as db router = APIRouter(prefix="/api/characters", tags=["characters"]) - @router.get("") async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)): """List all characters for the logged-in account""" @@ -31,20 +31,7 @@ async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(se return { "characters": [ - { - "id": char["id"], - "name": char["name"], - "level": char["level"], - "xp": char["xp"], - "hp": char["hp"], - "max_hp": char["max_hp"], - "stamina": char["stamina"], - "max_stamina": char["max_stamina"], - "avatar_data": char.get("avatar_data"), - "location_id": char["location_id"], - "created_at": char["created_at"], - "last_played_at": char.get("last_played_at"), - } + await enrich_character_data(char, items_manager) for char in characters ] } diff --git a/api/routers/combat.py b/api/routers/combat.py index debd58c..857ab09 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -464,9 +464,12 @@ async def combat_action( new_progress[obj['target']] = current_count + 1 progres_changed = True + if progres_changed: # Check completion all_done = True + progress_str = "" + for obj in objectives: target = obj['target'] req_count = obj['count'] @@ -476,8 +479,10 @@ async def combat_action( if obj['type'] == 'kill_count': if curr < req_count: all_done = False + # Capture progress string for the notification (if this was the target updated) + if target == combat['npc_id']: + progress_str = f" ({curr}/{req_count})" elif obj['type'] == 'item_delivery': - # For mixed quests, we can't complete purely on kills. pass await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active') @@ -486,7 +491,7 @@ async def combat_action( messages.append(create_combat_message( "quest_update", origin="system", - message=f"Quest updated: {get_locale_string(q_def['title'], locale)}" + message=f"{get_locale_string(q_def['title'], locale)}{progress_str}" )) quest_updated = True @@ -501,62 +506,6 @@ async def combat_action( except Exception as e: logger.error(f"Failed to update quest progress: {e}") # ----------------------------- - - for q_record in active_quests: - if q_record['status'] != 'active': - continue - - q_def = all_quests_def.get(q_record['quest_id']) - if not q_def: continue - - objectives = q_def.get('objectives', []) - current_progress = q_record.get('progress') or {} - new_progress = current_progress.copy() - progres_changed = False - - for obj in objectives: - if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']: - current_count = current_progress.get(obj['target'], 0) - if current_count < obj['count']: - new_progress[obj['target']] = current_count + 1 - progres_changed = True - - if progres_changed: - # Check completion - all_done = True - for obj in objectives: - target = obj['target'] - req_count = obj['count'] - curr = new_progress.get(target, 0) - - # Simple check for now (ignoring items for kill quests) - if obj['type'] == 'kill_count': - if curr < req_count: - all_done = False - elif obj['type'] == 'item_delivery': - # Items are checked at hand-in usually - # But we need to know if we should mark as completed "ready to turn in" or just "objectives met" - # For mixed quests, we can't complete purely on kills. - # Let's just update progress. - pass - - # We generally don't auto-complete quests, user has to hand in. - # But we can update the progress in DB. - new_status = 'active' - # If we wanted to support auto-complete, we'd do it here. Use 'active'. - - await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, new_status) - - # Notify user - messages.append(create_combat_message( - "quest_update", - origin="system", - message=f"Quest updated: {get_locale_string(q_def['title'], locale)}" - )) - quest_updated = True - - except Exception as e: - logger.error(f"Failed to update quest progress: {e}") # ----------------------------- diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index 5eb0440..c498dcd 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -502,6 +502,10 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge # Format interactables for response with cooldown info interactables_data = [] for interactable in location.interactables: + # Check if locked + if getattr(interactable, 'locked', False): + continue + actions_data = [] for action in interactable.actions: # Check cooldown status for this specific action @@ -556,6 +560,10 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge destination_id = location.exits[direction] destination_loc = LOCATIONS.get(destination_id) + # Check if destination is locked + if destination_loc and getattr(destination_loc, 'locked', False): + continue + if destination_loc: # Calculate real distance using coordinates distance = calculate_distance( diff --git a/api/routers/quests.py b/api/routers/quests.py index 1dad9e2..e3f5cb0 100644 --- a/api/routers/quests.py +++ b/api/routers/quests.py @@ -3,10 +3,12 @@ from typing import Dict, List, Any, Optional import time import json import logging +from ..core.websockets import manager from ..core.security import get_current_user from .. import database as db from .. import game_logic from ..items import ItemsManager +from ..services.helpers import get_locale_string router = APIRouter( prefix="/api/quests", @@ -14,19 +16,110 @@ router = APIRouter( responses={404: {"description": "Not found"}}, ) +# Request Models +class HistoryParams: + page: int = 1 + page_size: int = 20 + logger = logging.getLogger(__name__) # Dependencies QUESTS_DATA = {} NPCS_DATA = {} +LOCATIONS_DATA = {} -def init_router_dependencies(items_manager: ItemsManager, quests_data=None, npcs_data=None): - global ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA +def init_router_dependencies(items_manager: ItemsManager, quests_data=None, npcs_data=None, locations_data=None): + global ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS_DATA ITEMS_MANAGER = items_manager if quests_data: QUESTS_DATA = quests_data if npcs_data: NPCS_DATA = npcs_data + if locations_data: + LOCATIONS_DATA = locations_data + +@router.get("/history") +async def get_quest_history_endpoint( + page: int = 1, + limit: int = 20, + current_user: dict = Depends(get_current_user) +): + """Get completed quest history with pagination""" + character_id = current_user['id'] + history = await db.get_quest_history(character_id, page=page, page_size=limit) + + # Enrich with quest definitions + enriched_data = [] + for entry in history['data']: + quest_def = QUESTS_DATA.get(entry['quest_id']) + if quest_def: + # Merge entry data with quest def + item = dict(entry) + item['title'] = quest_def.get('title') + item['description'] = quest_def.get('description') + item['type'] = quest_def.get('type') + item['objectives'] = quest_def.get('objectives') # Fix: Copy objectives + + # Enrich with giver info + if quest_def.get('giver_id'): + giver = NPCS_DATA.get(quest_def['giver_id']) + if giver: + item['giver_name'] = giver.get('name') + item['giver_image'] = giver.get('image') + + # Get Location Name + if giver.get('location_id'): + loc = LOCATIONS_DATA.get(giver['location_id']) + if loc: + item['giver_location_name'] = loc.name + else: + item['giver_location_name'] = giver['location_id'] + + enriched_data.append(item) + else: + # Fallback if quest def removed? + enriched_data.append(entry) + + # 2nd pass: Enrich objectives and rewards for all items in enriched_data + final_data = [] + for q_data in enriched_data: + # ENRICH OBJECTIVES WITH NAMES + if 'objectives' in q_data: + enriched_objs = [] + for obj in q_data['objectives']: + new_obj = dict(obj) + target = obj.get('target') + if obj.get('type') == 'kill_count': + npc = NPCS_DATA.get(target) + if npc: + new_obj['target_name'] = npc.get('name') + else: + logger.warning(f"NPC not found for target: {target}") + elif obj.get('type') == 'item_delivery': + item = ITEMS_MANAGER.get_item(target) + if item: + new_obj['target_name'] = item.name + else: + logger.warning(f"Item not found for target: {target}") + enriched_objs.append(new_obj) + q_data['objectives'] = enriched_objs + + # ENRICH REWARDS WITH NAMES + # For history, rewards might be stored in 'rewards' json. + if 'rewards' in q_data and 'items' in q_data['rewards']: + enriched_items = {} + for item_id, qty in q_data['rewards']['items'].items(): + item = ITEMS_MANAGER.get_item(item_id) + name = item.name if item else item_id + enriched_items[item_id] = {'qty': qty, 'name': name} + + q_data['reward_items_details'] = enriched_items + + final_data.append(q_data) + + history['data'] = final_data + return history + @router.get("/active") async def get_active_quests(current_user: dict = Depends(get_current_user)): @@ -34,18 +127,8 @@ async def get_active_quests(current_user: dict = Depends(get_current_user)): character_id = current_user['id'] quests = await db.get_character_quests(character_id) - # Filter for active or completed but not yet turned in? - # Usually "active" means in progress. - # We want to return detailed info merged with static data - result = [] for q in quests: - # If it's a repeatable quest that is on cooldown, maybe don't show it as active? - # But we want to show history? - # Let's filter by status="active" or "completed" (ready to turn in?) - # Wait, if status is "completed", it means it's done. - # For repeatable quests, "completed" means it's in cooldown. - quest_def = QUESTS_DATA.get(q['quest_id']) if not quest_def: continue @@ -63,6 +146,58 @@ async def get_active_quests(current_user: dict = Depends(get_current_user)): else: q_data['on_cooldown'] = False + # Global Quest Progress + if quest_def.get('type') == 'global': + g_quest = await db.get_global_quest(q['quest_id']) + if g_quest: + q_data['global_progress'] = g_quest.get('global_progress', {}) + q_data['global_is_completed'] = g_quest.get('is_completed', False) + + # Enrich with giver info + if quest_def.get('giver_id'): + giver = NPCS_DATA.get(quest_def['giver_id']) + if giver: + q_data['giver_name'] = giver.get('name') + q_data['giver_image'] = giver.get('image') + + # Get Location Name + if giver.get('location_id'): + loc = LOCATIONS_DATA.get(giver['location_id']) + if loc: + q_data['giver_location_name'] = loc.name + else: + q_data['giver_location_name'] = giver['location_id'] + + # ENRICH OBJECTIVES WITH NAMES + if 'objectives' in q_data: + enriched_objs = [] + for obj in q_data['objectives']: + new_obj = dict(obj) + target = obj.get('target') + if obj.get('type') == 'kill_count': + npc = NPCS_DATA.get(target) + if npc: + new_obj['target_name'] = npc.get('name') + elif obj.get('type') == 'item_delivery': + item = ITEMS_MANAGER.get_item(target) + if item: + new_obj['target_name'] = item.name + enriched_objs.append(new_obj) + q_data['objectives'] = enriched_objs + + # ENRICH REWARDS WITH NAMES + if 'rewards' in q_data and 'items' in q_data['rewards']: + enriched_items = {} + for item_id, qty in q_data['rewards']['items'].items(): + item = ITEMS_MANAGER.get_item(item_id) + name = item.name if item else item_id + enriched_items[item_id] = {'qty': qty, 'name': name} + + # Store back in a way frontend can use, or just replace items dict? + # Frontend currently iterates entries of items. + # Let's add a new field 'reward_items_details' + q_data['reward_items_details'] = enriched_items + result.append(q_data) return result @@ -194,7 +329,19 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_ if inv_item: available = inv_item['quantity'] - needed = required_count - current_count + needed = required_count - current_count # Personal needed (to match max count) + + # GLOBAL CAP CHECK + is_global = quest_def.get('type') == 'global' + if is_global: + global_quest = await db.get_global_quest(quest_id) + global_prog = global_quest.get('global_progress', {}) if global_quest else {} + global_current_val = global_prog.get(target, 0) + global_remaining = max(0, required_count - global_current_val) + + # Cap needed by global remaining + needed = min(needed, global_remaining) + to_take = min(available, needed) if to_take > 0: @@ -207,13 +354,50 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_ items_deducted.append(f"{target} x{to_take}") # Global Quest Logic - if quest_def.get('type') == 'global': - # Update global counters - global_quest = await db.get_global_quest(quest_id) - global_prog = global_quest['global_progress'] if global_quest else {} - global_current = global_prog.get(target, 0) - global_prog[target] = global_current + to_take + if is_global: + # Re-fetch or use existing? We need to be careful with race conditions slightly, + # but safe enough for now to just update. + # We already fetched 'global_prog' above. + + # Add contribution + new_global = global_current_val + to_take + global_prog[target] = new_global await db.update_global_quest(quest_id, global_prog) + + # Check for global completion + is_global_complete = True + for obj in objectives: + t = obj['target'] + req = obj['count'] + # Check cached updated prog + if global_prog.get(t, 0) < req: + is_global_complete = False + break + + if is_global_complete: + # Finish global quest! + await finish_global_quest(quest_id, quest_def) + + # RETURN IMMEDIATELY to prevent double rewards/deletion logic + # We construct a success response here. + return { + "success": True, + "message": "Global Quest Completed!", + "is_completed": True, + "items_deducted": items_deducted, + "rewards": ["See Global Rewards"], # Placeholders, real rewards via finish_global_quest/websocket + "completion_text": quest_def.get("completion_text", "Global Quest Finished!"), + "quest_update": { + **quest_def, + "quest_id": quest_id, + "status": "completed", + "progress": updated_progress, + "on_cooldown": quest_def.get('repeatable'), + } + } + else: + # Prevent individual completion if global is not done + all_completed = False if new_count < required_count: all_completed = False @@ -227,23 +411,38 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_ if current_count < required_count: all_completed = False - # Save progress - status = "active" - if all_completed: - status = "completed" - - await db.update_quest_progress(character_id, quest_id, updated_progress, status) - - # If completed, giving rewards + # WEIGHT CHECK FOR REWARDS rewards_msg = [] if all_completed: rewards = quest_def.get('rewards', {}) + reward_weight = 0.0 + if 'items' in rewards: + for item_id, qty in rewards['items'].items(): + item_def = ITEMS_MANAGER.get_item(item_id) + if item_def: + reward_weight += item_def.weight * qty + + # Calculate current weight + # Calculate current weight and capacity + from ..services.helpers import calculate_player_capacity + inventory = await db.get_inventory(character_id) + current_weight, capacity, _, _ = await calculate_player_capacity(inventory, ITEMS_MANAGER) + + if current_weight + reward_weight > capacity: + # Rollback? The items for delivery were already removed above! + # Ideally we should check weight BEFORE deducting delivery items. + # converting this to a "check before action" logic is hard because delivery logic is stateful. + # However, delivery items REDUCE weight. So we are likely safe unless rewards are heavier than delivered items. + # BUT, if we error here, we technically leave the quest in "partially delivered" state, which is fine. + # The user can just clear inventory and try again. + + raise HTTPException(status_code=400, detail=f"Not enough inventory space for rewards! (Overweight by {current_weight + reward_weight - capacity:.1f})") + + # Give Rewards # XP if 'xp' in rewards: xp_gained = rewards['xp'] - # We use current_user['xp'] but optimally we should fetch fresh player data if we want to be safe - # For simplicity and performance, assuming current_user is fresh enough (it's from dependency) new_xp = current_user['xp'] + xp_gained await db.update_player(character_id, xp=new_xp) rewards_msg.append(f"{xp_gained} XP") @@ -262,7 +461,10 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_ if 'items' in rewards: for item_id, qty in rewards['items'].items(): await db.add_item_to_inventory(character_id, item_id, qty) - rewards_msg.append(f"{item_id} x{qty}") # Should assume name resolution on frontend or here + # Resolve name + idev = ITEMS_MANAGER.get_item(item_id) + name = idev.name if idev else item_id + rewards_msg.append(f"{name} x{qty}") # Set cooldown if repeatable if quest_def.get('repeatable'): @@ -270,6 +472,44 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_ expires = time.time() + (cooldown_hours * 3600) await db.set_quest_cooldown(character_id, quest_id, expires) + # LOG HISTORY + await db.log_quest_completion( + character_id=character_id, + quest_id=quest_id, + started_at=quest_record.get('started_at') or time.time(), + rewards=quest_def.get('rewards', {}) + ) + + # REMOVE FROM ACTIVE QUESTS (DELETE) + await db.delete_character_quest(character_id, quest_id) + + status = "completed" + + else: + # Not completed, just update progress + status = "active" + await db.update_quest_progress(character_id, quest_id, updated_progress, status) + + # ENRICH OBJECTIVES FOR RESPONSE + enriched_objs = [] + for obj in objectives: + new_obj = dict(obj) + target = obj.get('target') + + # Add current progress + new_obj['current'] = updated_progress.get(target, 0) + + # Add names + if obj.get('type') == 'kill_count': + npc = NPCS_DATA.get(target) + if npc: + new_obj['target_name'] = npc.get('name') + elif obj.get('type') == 'item_delivery': + item = ITEMS_MANAGER.get_item(target) + if item: + new_obj['target_name'] = item.name + enriched_objs.append(new_obj) + response = { "success": True, "progress": updated_progress, @@ -281,6 +521,7 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_ "quest_id": quest_id, "status": status, "progress": updated_progress, + "objectives": enriched_objs, "on_cooldown": all_completed and quest_def.get('repeatable'), # other fields as needed } @@ -300,3 +541,78 @@ async def get_global_quest_progress(quest_id: str): if not quest: return {"progress": {}} return quest + + +async def finish_global_quest(quest_id: str, quest_def: Dict): + """ + Handle global quest completion: + 1. Mark global quest as completed + 2. Unlock content (In-Memory) + 3. Distribute rewards to all participants + 4. Broadcast completion + """ + logger.info(f"🌍 Finishing Global Quest: {quest_id}") + + # 1. Mark as completed in DB + await db.mark_global_quest_completed(quest_id) + + # 2. Unlock content (In-Memory) + unlocks = [] + + # Unlock Locations + for loc in LOCATIONS_DATA.values(): + if loc.unlocked_by == quest_id: + loc.locked = False + unlocks.append({"type": "location", "name": get_locale_string(loc.name, 'en'), "id": loc.id}) + + # Unlock interactables + for inter in loc.interactables: + if inter.unlocked_by == quest_id: + inter.locked = False + unlocks.append({"type": "interactable", "name": get_locale_string(inter.name, 'en'), "location": loc.id}) + + # 3. Distribute Rewards to participants + participants = await db.get_all_quest_participants(quest_id) + total_xp_pool = quest_def.get('rewards', {}).get('xp', 0) + + total_required = 0 + for obj in quest_def.get('objectives', []): + total_required += obj.get('count', 0) + + for p in participants: + # Calculate user contribution + user_progress = p.get('progress', {}) + user_contribution = 0 + for obj in quest_def.get('objectives', []): + target = obj['target'] + user_contribution += user_progress.get(target, 0) + + if user_contribution > 0 and total_required > 0: + percentage = user_contribution / total_required + xp_reward = int(total_xp_pool * percentage) + + if xp_reward > 0: + # Give XP + char = await db.get_player_by_id(p['character_id']) + if char: + new_xp = char['xp'] + xp_reward + await db.update_player(p['character_id'], xp=new_xp) + + # Mark as completed (delete from active) and log history + await db.delete_character_quest(p['character_id'], quest_id) + await db.log_quest_completion( + character_id=p['character_id'], + quest_id=quest_id, + started_at=p['started_at'], + rewards={"xp": xp_reward, "note": f"Contribution: {percentage*100:.1f}%"} + ) + + # 4. Broadcast + await manager.broadcast({ + "type": "global_quest_completed", + "quest_id": quest_id, + "title": get_locale_string(quest_def.get('title', 'Global Quest'), 'en'), + "outcome": { + "unlocks": unlocks + } + }) diff --git a/api/services/helpers.py b/api/services/helpers.py index 4fcaa7c..ba6bf22 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -391,3 +391,36 @@ async def consume_tool_durability(user_id: int, tools: list, inventory: list, it await db.decrease_unique_item_durability(tool['unique_item_id'], durability_cost) return True, "", consumed_tools + + +async def enrich_character_data(char: Dict[str, Any], items_manager: ItemsManager) -> Dict[str, Any]: + """ + Add calculated stats (weight, volume) to character data. + """ + # Calculate weight and volume + inventory = await db.get_inventory(char['id']) + current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, items_manager) + + return { + "id": char["id"], + "name": char["name"], + "level": char["level"], + "xp": char["xp"], + "hp": char["hp"], + "max_hp": char["max_hp"], + "stamina": char["stamina"], + "max_stamina": char["max_stamina"], + "strength": char["strength"], + "agility": char["agility"], + "endurance": char["endurance"], + "intellect": char["intellect"], + "location_id": char["location_id"], + "avatar_data": char.get("avatar_data"), + "last_played_at": char.get("last_played_at"), + "created_at": char.get("created_at"), + # Add calculated capacity + "weight": round(current_weight, 1), + "max_weight": round(max_weight, 1), + "volume": round(current_volume, 1), + "max_volume": round(max_volume, 1), + } diff --git a/api/world_loader.py b/api/world_loader.py index c252853..9e755ab 100644 --- a/api/world_loader.py +++ b/api/world_loader.py @@ -35,6 +35,8 @@ class Interactable: name: Union[str, Dict[str, str]] image_path: str = "" actions: List[Action] = field(default_factory=list) + unlocked_by: str = "" + locked: bool = False def add_action(self, action: Action): self.actions.append(action) @@ -63,6 +65,8 @@ class Location: x: float = 0.0 # X coordinate for distance calculations y: float = 0.0 # Y coordinate for distance calculations danger_level: int = 0 # Danger level (0-5) + unlocked_by: str = "" + locked: bool = False def add_exit(self, direction: str, destination: str, stamina_cost: int = 5): self.exits[direction] = destination @@ -114,9 +118,14 @@ class WorldLoader: interactable = Interactable( id=template_id, name=template_data.get('name', 'Unknown'), - image_path=template_data.get('image_path', '') + image_path=template_data.get('image_path', ''), + unlocked_by=instance_data.get('unlocked_by', template_data.get('unlocked_by', '')), ) + # Set locked status if unlocked_by is present + if interactable.unlocked_by: + interactable.locked = True + # Get actions from template template_actions = template_data.get('actions', {}) @@ -211,9 +220,14 @@ class WorldLoader: y=float(loc_data.get('y', 0.0)), danger_level=danger_level, tags=loc_data.get('tags', []), - npcs=loc_data.get('npcs', []) + npcs=loc_data.get('npcs', []), + unlocked_by=loc_data.get('unlocked_by', '') ) + # Set locked status if unlocked_by is present + if location.unlocked_by: + location.locked = True + # Add exits for direction, destination in loc_data.get('exits', {}).items(): location.add_exit(direction, destination) diff --git a/docs/visual_style_guide.md b/docs/visual_style_guide.md deleted file mode 100644 index a87154f..0000000 --- a/docs/visual_style_guide.md +++ /dev/null @@ -1,139 +0,0 @@ -# Visual Style Guide - Echoes of the Ash - -This document defines the unified visual system for the application, ensuring a consistent, mature "videogame" aesthetic. - -## 1. Core Design Philosophy -- **Aesthetic**: **Mature Post-Apocalyptic**. Think "High-Tech Scavenger". - - **Dark & Gritty**: Deep blacks (`#0a0a0a`) mixed with industrial dark greys (`#1a1a20`). - - **Sharp & Distinct**: Avoid overly rounded "Web 2.0" corners. Use chamfered corners (clip-path) or tight radii (2px-4px) for a militaristic/industrial feel. - - **Glassmorphism**: Use semi-transparent backgrounds with blur (`backdrop-filter`) to keep the player connected to the world. - - **Cinematic**: High contrast text, subtle glows on active elements, and distinct borders. -- **Interaction**: **Instant Feedback**. - - **Custom Tooltips**: **MANDATORY**. Do NOT use the HTML `title` attribute. All information must appear instantly in a custom game-styled tooltip anchor. - - **Micro-animations**: Subtle pulses, border glows on hover. - -## 2. CSS Variables (Design Tokens) -Defined in `:root`. - -### Colors -```css -:root { - /* Backgrounds */ - --game-bg-app: #050505; /* Deepest black */ - --game-bg-panel: rgba(18, 18, 24, 0.96); /* Almost solid panels */ - --game-bg-glass: rgba(10, 10, 15, 0.85); /* Overlays */ - --game-bg-slot: rgba(0, 0, 0, 0.5); /* Item slots - darker than panels */ - --game-bg-slot-hover: rgba(255, 255, 255, 0.08); - - /* Borders / Separators */ - --game-border-color: rgba(255, 255, 255, 0.12); - --game-border-active: rgba(255, 255, 255, 0.4); - --game-border-highlight: #ff6b6b; /* Red accent border */ - - /* Corner Radius - Tighter for mature look */ - --game-radius-xs: 2px; - --game-radius-sm: 4px; - --game-radius-md: 6px; - - /* Typography */ - --game-font-main: 'Saira Condensed', system-ui, sans-serif; - --game-text-primary: #e0e0e0; /* Off-white is better for eyes than pure white */ - --game-text-secondary: #94a3b8; /* Cool grey */ - --game-text-highlight: #fbbf24; /* Amber/Gold */ - --game-text-danger: #ef4444; - - /* Semantic Colors (Desaturated/Industrial) */ - --game-color-primary: #e11d48; /* Blood Red - Action/Health */ - --game-color-stamina: #d97706; /* Amber - Stamina */ - --game-color-magic: #3b82f6; /* Blue - Mana/Tech */ - --game-color-success: #10b981; /* Emerald - Durability High */ - --game-color-warning: #f59e0b; /* Amber - Warning */ - - /* Rarity Colors */ - --rarity-common: #9ca3af; - --rarity-uncommon: #ffffff; - --rarity-rare: #34d399; - --rarity-epic: #60a5fa; - --rarity-legendary: #fbbf24; - - /* Effects */ - --game-shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.8); - --game-shadow-glow: 0 0 15px rgba(225, 29, 72, 0.3); /* Subtle red glow */ -} -``` - -## 3. Tooltip System (CRITICAL) -**Goal**: Replicate a native game HUD tooltip. -**Rule**: NEVER use `title="Description"`. - -### The Component: `` -Every interactive element with info must be wrapped or associated with this component. -- **Behavior**: Appears instantly on hover (0ms delay). Follows cursor OR anchored to element. -- **Visuals**: - - Background: Solid dark (`#0f0f12`) with high opacity (98%). - - Border: Thin accent border (`1px solid --game-border-color`). - - Shadow: Strong drop shadow to separate from UI. - - Content: Supports HTML (rich text, stats, icons). - -```tsx -// Usage Example -}> - - -``` - -## 4. Reusable Component Classes - -### Panels & Containers -**`.game-panel`** -- **Look**: Industrial, solid. -- **CSS**: - ```css - background: var(--game-bg-panel); - border: 1px solid var(--game-border-color); - box-shadow: var(--game-shadow-panel); - backdrop-filter: blur(8px); - border-radius: var(--game-radius-sm); - ``` - -### Buttons -**`.game-btn`** -- **Look**: Rectangular, tactile. -- **States**: - - Normal: Dark grey background, light border. - - Hover: Glow effect, border brightens. - - Active: Presses down (transform). -- **CSS**: - ```css - text-transform: uppercase; - font-family: var(--game-font-main); - letter-spacing: 0.5px; - clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px); /* Mecha-cut corners */ - /* OR simple tight radius */ - border-radius: 2px; - ``` - -### Items & Slots -**`.game-slot`** -- **Background**: `var(--game-bg-slot)` (Darker than panel). -- **Border**: `1px solid var(--game-border-color)`. -- **Interaction**: On hover, background lightens slightly, border acts as highlight. - -## 5. Implementation Plan - -### Phase 1: Tooltip System (Priority) -1. Create `src/components/common/GameTooltip.tsx`. -2. Implement global tooltip context/store if needed for performance, or use a lightweight library like generic CSS hover + React portal. -3. **Audit**: Grep for `title=` in all files and replace with ``. - -### Phase 2: Design Token Migration -1. Define all variables in [index.css](file:///opt/dockers/echoes_of_the_ashes/pwa/src/index.css). -2. Update `src/styles/components.css` (or new file) with utility classes. - -### Phase 3: Component Reskinning -1. **InventoryModal**: Apply `.game-panel` and standard slots. -2. **PlayerSidebar**: Update bars and equipment slots. -3. **HUD/Sidebar**: Ensure borders are strict and consistent. - diff --git a/gamedata/interactables.json b/gamedata/interactables.json index 00abba6..0a33148 100644 --- a/gamedata/interactables.json +++ b/gamedata/interactables.json @@ -172,7 +172,7 @@ "en": "A broken vending machine, glass shattered.", "es": "Una máquina expendedora rota, el vidrio está roto." }, - "image_path": "images/interactables/vending.webp", + "image_path": "images/interactables/vending_machine.webp", "actions": { "break": { "id": "break", diff --git a/gamedata/quests.json b/gamedata/quests.json index e2448f9..779e4ca 100644 --- a/gamedata/quests.json +++ b/gamedata/quests.json @@ -17,7 +17,7 @@ "objectives": [ { "type": "item_delivery", - "target": "wood_plank", + "target": "wood_planks", "count": 1000 } ], diff --git a/images-source/interactables/dumpster.png b/images-source/interactables/dumpster.png deleted file mode 100644 index 447bb6a..0000000 Binary files a/images-source/interactables/dumpster.png and /dev/null differ diff --git a/images-source/interactables/house.png b/images-source/interactables/house.png deleted file mode 100644 index 9dfd39e..0000000 Binary files a/images-source/interactables/house.png and /dev/null differ diff --git a/images-source/interactables/medkit.png b/images-source/interactables/medkit.png deleted file mode 100644 index 58470ad..0000000 Binary files a/images-source/interactables/medkit.png and /dev/null differ diff --git a/images-source/interactables/rubble.png b/images-source/interactables/rubble.png deleted file mode 100644 index 9ffcd97..0000000 Binary files a/images-source/interactables/rubble.png and /dev/null differ diff --git a/images-source/interactables/sedan.png b/images-source/interactables/sedan.png deleted file mode 100644 index 1c1b85e..0000000 Binary files a/images-source/interactables/sedan.png and /dev/null differ diff --git a/images-source/interactables/storage_box.png b/images-source/interactables/storage_box.png deleted file mode 100644 index 8e66f62..0000000 Binary files a/images-source/interactables/storage_box.png and /dev/null differ diff --git a/images-source/interactables/toolshed.png b/images-source/interactables/toolshed.png deleted file mode 100644 index d8b9246..0000000 Binary files a/images-source/interactables/toolshed.png and /dev/null differ diff --git a/images-source/interactables/vending.png b/images-source/interactables/vending.png deleted file mode 100644 index e68023d..0000000 Binary files a/images-source/interactables/vending.png and /dev/null differ diff --git a/images-source/locations/clinic.png b/images-source/locations/clinic.png deleted file mode 100644 index e0f0ac4..0000000 Binary files a/images-source/locations/clinic.png and /dev/null differ diff --git a/images-source/locations/downtown.png b/images-source/locations/downtown.png deleted file mode 100644 index becef35..0000000 Binary files a/images-source/locations/downtown.png and /dev/null differ diff --git a/images-source/locations/gas_station.png b/images-source/locations/gas_station.png index 97cca4c..f60bde7 100644 Binary files a/images-source/locations/gas_station.png and b/images-source/locations/gas_station.png differ diff --git a/images-source/locations/office_building.png b/images-source/locations/office_building.png deleted file mode 100644 index e33b5e3..0000000 Binary files a/images-source/locations/office_building.png and /dev/null differ diff --git a/images-source/locations/office_interior.png b/images-source/locations/office_interior.png deleted file mode 100644 index b5ee47e..0000000 Binary files a/images-source/locations/office_interior.png and /dev/null differ diff --git a/images-source/locations/overpass.png b/images-source/locations/overpass.png deleted file mode 100644 index 97bd0e0..0000000 Binary files a/images-source/locations/overpass.png and /dev/null differ diff --git a/images-source/locations/park.png b/images-source/locations/park.png deleted file mode 100644 index 46bfb5f..0000000 Binary files a/images-source/locations/park.png and /dev/null differ diff --git a/images-source/locations/plaza.png b/images-source/locations/plaza.png index 3ff8525..b55a240 100644 Binary files a/images-source/locations/plaza.png and b/images-source/locations/plaza.png differ diff --git a/images-source/locations/residential.png b/images-source/locations/residential.png deleted file mode 100644 index cff9580..0000000 Binary files a/images-source/locations/residential.png and /dev/null differ diff --git a/images-source/locations/subway.png b/images-source/locations/subway.png index 3ef3b54..159351c 100644 Binary files a/images-source/locations/subway.png and b/images-source/locations/subway.png differ diff --git a/images-source/locations/subway_section_a.jpg b/images-source/locations/subway_section_a.jpg deleted file mode 100644 index 13ff486..0000000 Binary files a/images-source/locations/subway_section_a.jpg and /dev/null differ diff --git a/images-source/locations/subway_tunnels.png b/images-source/locations/subway_tunnels.png deleted file mode 100644 index f66287f..0000000 Binary files a/images-source/locations/subway_tunnels.png and /dev/null differ diff --git a/images-source/locations/warehouse.png b/images-source/locations/warehouse.png deleted file mode 100644 index 1166ecb..0000000 Binary files a/images-source/locations/warehouse.png and /dev/null differ diff --git a/images-source/locations/warehouse_interior.png b/images-source/locations/warehouse_interior.png deleted file mode 100644 index a97216e..0000000 Binary files a/images-source/locations/warehouse_interior.png and /dev/null differ diff --git a/images/interactables/dumpster.webp b/images/interactables/dumpster.webp index 8bb9fbd..f7d3c38 100644 Binary files a/images/interactables/dumpster.webp and b/images/interactables/dumpster.webp differ diff --git a/images/interactables/house.webp b/images/interactables/house.webp index c0ce140..5fd9383 100644 Binary files a/images/interactables/house.webp and b/images/interactables/house.webp differ diff --git a/images/interactables/medkit.webp b/images/interactables/medkit.webp index 4239f19..e2e432b 100644 Binary files a/images/interactables/medkit.webp and b/images/interactables/medkit.webp differ diff --git a/images/interactables/rubble.webp b/images/interactables/rubble.webp index 923234c..4a3370a 100644 Binary files a/images/interactables/rubble.webp and b/images/interactables/rubble.webp differ diff --git a/images/interactables/sedan.webp b/images/interactables/sedan.webp index 6d5e8f0..94905f5 100644 Binary files a/images/interactables/sedan.webp and b/images/interactables/sedan.webp differ diff --git a/images/interactables/storage_box.webp b/images/interactables/storage_box.webp index f15e4eb..9d6fafb 100644 Binary files a/images/interactables/storage_box.webp and b/images/interactables/storage_box.webp differ diff --git a/images/interactables/toolshed.webp b/images/interactables/toolshed.webp index d1a8b6e..b64fab4 100644 Binary files a/images/interactables/toolshed.webp and b/images/interactables/toolshed.webp differ diff --git a/images/interactables/vending.webp b/images/interactables/vending.webp deleted file mode 100644 index 1cc0c1a..0000000 Binary files a/images/interactables/vending.webp and /dev/null differ diff --git a/images/locations/clinic.webp b/images/locations/clinic.webp index 8d17d40..ca57ebf 100644 Binary files a/images/locations/clinic.webp and b/images/locations/clinic.webp differ diff --git a/images/locations/downtown.webp b/images/locations/downtown.webp index 7bd0ce5..ff13a02 100644 Binary files a/images/locations/downtown.webp and b/images/locations/downtown.webp differ diff --git a/images/locations/gas_station.webp b/images/locations/gas_station.webp index d8e33d5..0da2c56 100644 Binary files a/images/locations/gas_station.webp and b/images/locations/gas_station.webp differ diff --git a/images/locations/office_building.webp b/images/locations/office_building.webp index 5b2b318..1b8b421 100644 Binary files a/images/locations/office_building.webp and b/images/locations/office_building.webp differ diff --git a/images/locations/office_interior.webp b/images/locations/office_interior.webp index 1184a2f..9a7b17c 100644 Binary files a/images/locations/office_interior.webp and b/images/locations/office_interior.webp differ diff --git a/images/locations/overpass.webp b/images/locations/overpass.webp index c701504..8caf339 100644 Binary files a/images/locations/overpass.webp and b/images/locations/overpass.webp differ diff --git a/images/locations/park.webp b/images/locations/park.webp index b20779f..61e6e91 100644 Binary files a/images/locations/park.webp and b/images/locations/park.webp differ diff --git a/images/locations/plaza.webp b/images/locations/plaza.webp index 3e5c70a..aad3d65 100644 Binary files a/images/locations/plaza.webp and b/images/locations/plaza.webp differ diff --git a/images/locations/residential.webp b/images/locations/residential.webp index e26c641..b48b365 100644 Binary files a/images/locations/residential.webp and b/images/locations/residential.webp differ diff --git a/images/locations/subway.webp b/images/locations/subway.webp index 7c9ad85..b6101ad 100644 Binary files a/images/locations/subway.webp and b/images/locations/subway.webp differ diff --git a/images/locations/subway_section_a.webp b/images/locations/subway_section_a.webp index cd353ce..ffe1571 100644 Binary files a/images/locations/subway_section_a.webp and b/images/locations/subway_section_a.webp differ diff --git a/images/locations/subway_tunnels.webp b/images/locations/subway_tunnels.webp index e22cc59..874f5ad 100644 Binary files a/images/locations/subway_tunnels.webp and b/images/locations/subway_tunnels.webp differ diff --git a/images/locations/warehouse.webp b/images/locations/warehouse.webp index 748aaa1..e4e89c2 100644 Binary files a/images/locations/warehouse.webp and b/images/locations/warehouse.webp differ diff --git a/images/locations/warehouse_interior.webp b/images/locations/warehouse_interior.webp index 139fb76..c5278f9 100644 Binary files a/images/locations/warehouse_interior.webp and b/images/locations/warehouse_interior.webp differ diff --git a/pwa/src/App.css b/pwa/src/App.css index 69a7a76..fa28e18 100644 --- a/pwa/src/App.css +++ b/pwa/src/App.css @@ -83,16 +83,6 @@ textarea:focus { margin-top: 0.5rem; } -@media (max-width: 768px) { - .container { - padding: 0.5rem; - } - - .card { - padding: 1rem; - } -} - /* Status Effects */ .status-effects-container { display: flex; diff --git a/pwa/src/App.tsx b/pwa/src/App.tsx index e2f2ec6..6e4ecbe 100644 --- a/pwa/src/App.tsx +++ b/pwa/src/App.tsx @@ -1,4 +1,5 @@ -import { BrowserRouter, HashRouter, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom' +import { useEffect } from 'react' import { AuthProvider } from './contexts/AuthContext' import { useAuth } from './hooks/useAuth' import { AudioProvider } from './contexts/AudioContext' @@ -13,7 +14,13 @@ import Profile from './components/Profile' import Leaderboards from './components/Leaderboards' import GameLayout from './components/GameLayout' import AccountPage from './components/AccountPage' +import PublicLayout from './components/PublicLayout' +import AuthenticatedLayout from './components/AuthenticatedLayout' +import PrivacyPolicy from './components/PrivacyPolicy' +import TermsOfService from './components/TermsOfService' import './App.css' +import { NotificationProvider } from './contexts/NotificationContext' +import { NotificationContainer } from './components/common/NotificationContainer' // Use HashRouter for Electron (file:// protocol), BrowserRouter for web const isElectron = window.location.protocol === 'file:' @@ -47,79 +54,125 @@ function CharacterRoute({ children }: { children: React.ReactNode }) { return <>{children} } +function ZoomManager() { + const location = useLocation(); + + useEffect(() => { + const updateZoom = () => { + const isGamePage = location.pathname.includes('/game'); + window.scrollTo(0, 0); + + if (isGamePage) { + const scale = window.innerWidth / 1920; + (document.documentElement.style as any).zoom = `${scale}`; + document.documentElement.style.setProperty('--zoom-factor', `${scale}`); + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + } else { + (document.documentElement.style as any).zoom = '1'; + document.documentElement.style.setProperty('--zoom-factor', '1'); + document.body.style.overflow = 'auto'; + document.documentElement.style.overflow = 'auto'; + document.body.style.height = 'auto'; + document.documentElement.style.height = 'auto'; + } + }; + + updateZoom(); + window.addEventListener('resize', updateZoom); + + return () => { + window.removeEventListener('resize', updateZoom); + }; + }, [location.pathname]); + + return null; +} + function App() { return ( - - - - -
- - } /> - } /> - } /> + + + + + + + +
+ + }> + } /> + } /> + } /> + } /> + } /> + - - - - } - /> + }> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> + - }> - - - - } - /> + }> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> - - -
-
-
-
+ + + + } + /> + +
+
+
+
+
+ ) } export default App + diff --git a/pwa/src/components/AccountPage.css b/pwa/src/components/AccountPage.css index 6aeea0d..1c03718 100644 --- a/pwa/src/components/AccountPage.css +++ b/pwa/src/components/AccountPage.css @@ -3,331 +3,358 @@ max-width: 1200px; margin: 0 auto; color: #fff; + min-height: calc(100vh - 80px); + /* Account for header */ } +/* Ensure the main container inherits the global border radius/clip-path correctly without duplicating it */ .account-container { - background: rgba(0, 0, 0, 0.8); - border-radius: 8px; - padding: 2rem; - backdrop-filter: blur(10px); + max-width: 1000px; + width: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.account-panel-override { + border-radius: 0; + /* Let the game-panel class handle the shape */ +} + +/* Clip paths for inner containers */ +.game-panel.inner { + border-radius: 0; + clip-path: var(--game-clip-path); + background: rgba(0, 0, 0, 0.4) !important; +} + +.account-header-top { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem; + background: rgba(0, 0, 0, 0.4); + border-bottom: 1px solid var(--game-border-color); +} + +.account-top-actions { + display: flex; + gap: 1rem; + align-items: center; } .account-title { - font-size: 2.5rem; - margin-bottom: 2rem; - text-align: center; - color: #e0e0e0; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + font-size: 2rem; + text-transform: uppercase; + letter-spacing: 2px; + margin: 0; + color: var(--game-color-primary); + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); +} + +.account-layout { + display: flex; + flex-direction: column; +} + +@media (min-width: 768px) { + .account-layout { + flex-direction: row; + min-height: 550px; + /* Force minimum height to prevent jumping */ + } +} + +/* Tabs Navigation */ +.account-tabs { + display: flex; + flex-direction: row; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid var(--game-border-color); + overflow-x: auto; +} + +@media (min-width: 768px) { + .account-tabs { + flex-direction: column; + width: 250px; + min-width: 250px; + border-bottom: none; + border-right: 1px solid var(--game-border-color); + } +} + +.account-tab { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 1.2rem 1.5rem; + background: transparent; + border: none; + color: var(--game-text-secondary); + font-size: 1.1rem; + text-transform: uppercase; + letter-spacing: 1px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + text-align: left; + border-bottom: 2px solid transparent; + /* default for mobile */ +} + +@media (min-width: 768px) { + .account-tab { + border-bottom: none; + border-left: 3px solid transparent; + } +} + +.account-tab:hover { + background: rgba(255, 255, 255, 0.05); + color: #fff; +} + +.account-tab.active { + color: var(--game-color-primary); + background: rgba(var(--game-color-primary-rgb), 0.1); +} + +@media (max-width: 767px) { + .account-tab.active { + border-bottom: 2px solid var(--game-color-primary); + } +} + +@media (min-width: 768px) { + .account-tab.active { + border-left: 3px solid var(--game-color-primary); + } +} + +.tab-icon { + font-size: 1.2rem; + opacity: 0.8; +} + +.account-tab.active .tab-icon { + opacity: 1; +} + +/* Content Area */ +.account-content { + flex: 1; + padding: 2rem; + background: rgba(0, 0, 0, 0.2); } .account-section { - margin-bottom: 3rem; - padding-bottom: 2rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; + gap: 1.5rem; } -.account-section:last-child { - border-bottom: none; +/* Make sure container heights don't jump on tab swaps */ +.fixed-height-section { + min-height: 400px; } .section-title { font-size: 1.5rem; - margin-bottom: 1.5rem; - color: #bbb; - border-left: 4px solid #4a9eff; - padding-left: 1rem; + text-transform: uppercase; + letter-spacing: 1px; + color: #fff; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 0.5rem; + margin-bottom: 0.5rem; } +.subsection-title { + font-size: 1.2rem; + color: var(--game-text-secondary); + margin: 1.5rem 0 1rem; +} + +/* General Tab Adjustments */ .info-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; + border: 1px solid var(--game-border-color); + /* Kept borders around clipping */ + padding: 1.5rem; } .info-item { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.4rem; } .info-label { - color: #888; - font-size: 0.9rem; + font-size: 0.85rem; + text-transform: uppercase; + color: var(--game-text-secondary); + letter-spacing: 0.5px; } .info-value { font-size: 1.1rem; font-weight: 500; + color: #fff; } .info-value.premium { - color: #ffd700; - text-shadow: 0 0 10px rgba(255, 215, 0, 0.3); + color: var(--game-color-success); + font-weight: 700; + text-shadow: 0 0 8px rgba(var(--game-color-success-rgb), 0.4); } -/* Characters Grid */ -.characters-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1.5rem; +.character-actions-area { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Shared Settings Components */ +.setting-item-ui { + border: 1px solid var(--game-border-color); + padding: 1.5rem; margin-bottom: 1.5rem; } -.character-card { - background: rgba(255, 255, 255, 0.05); - border-radius: 6px; - padding: 1.5rem; - transition: transform 0.2s, background 0.2s; +.setting-item-ui:last-child { + margin-bottom: 0; } -.character-card:hover { - transform: translateY(-2px); - background: rgba(255, 255, 255, 0.1); -} - -.character-header { +.setting-header-ui { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); } -.character-header h3 { - margin: 0; - color: #fff; -} - -.character-level { - background: #4a9eff; - padding: 0.2rem 0.5rem; - border-radius: 4px; - font-size: 0.8rem; - font-weight: bold; -} - -.character-stats { - display: flex; - gap: 1rem; - margin-bottom: 1rem; -} - -.stat { - display: flex; - gap: 0.5rem; - color: #aba; -} - -.character-attributes { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.5rem; - font-size: 0.9rem; - color: #888; - margin-bottom: 1rem; -} - -.no-characters { - text-align: center; - color: #888; - padding: 2rem; - background: rgba(0, 0, 0, 0.2); - border-radius: 6px; - margin-bottom: 1.5rem; -} - -/* Settings */ -.setting-item { - background: rgba(255, 255, 255, 0.05); - padding: 1.5rem; - border-radius: 6px; - margin-bottom: 1.5rem; -} - -.setting-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.setting-header h3 { +.setting-header-ui h3 { margin: 0; font-size: 1.2rem; -} - -.setting-form { - background: rgba(0, 0, 0, 0.2); - padding: 1.5rem; - border-radius: 4px; - margin-top: 1rem; -} - -.form-group { - margin-bottom: 1rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - color: #bbb; -} - -.form-group input { - width: 100%; - padding: 0.8rem; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; color: #fff; } -.form-group input:focus { - border-color: #4a9eff; - outline: none; -} - -/* Audio Settings */ -.audio-settings { +.setting-form-ui { display: flex; flex-direction: column; - gap: 1rem; + gap: 1.2rem; + max-width: 400px; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); } -.mute-toggle { +.form-group-ui { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group-ui label { + font-size: 0.9rem; + color: var(--game-text-secondary); +} + +/* Remove border-radius rounded corners on inputs explicitly */ +.squared-input { + border-radius: 0 !important; + background: rgba(0, 0, 0, 0.6) !important; +} + +.audio-settings { + border: 1px solid var(--game-border-color); + padding: 1.5rem; +} + +.volume-sliders-ui { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 1.5rem; + /* Limit max width specifically to avoid slider overflow on desktop */ + max-width: 90%; +} + +.slider-group-ui { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + /* Ensure group spans correctly */ +} + +/* The wrapper contains the slider input to avoid bleeding over its container */ +.slider-wrapper { + width: 100%; + padding: 0 10px; + /* Prevent thumb from bleeding outside at 100% width edge */ +} + +.slider-group-ui label { + font-size: 0.95rem; + color: #fff; + font-family: var(--game-font-primary); +} + +.game-slider { + width: 100%; + box-sizing: border-box; + /* Prevent the thumb width from pushing it out */ + margin: 0; +} + +.mute-toggle-ui { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none; + color: var(--game-text-secondary); } -.mute-toggle input { +.mute-toggle-ui input { + cursor: pointer; width: 1.2rem; height: 1.2rem; - cursor: pointer; -} - -.volume-sliders { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 2rem; -} - -.slider-group { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.slider-group label { - font-size: 0.9rem; - color: #bbb; -} - -.slider-group input[type="range"] { - width: 100%; - cursor: pointer; - height: 6px; - background: rgba(255, 255, 255, 0.1); - border-radius: 3px; - appearance: none; -} - -.slider-group input[type="range"]::-webkit-slider-thumb { - appearance: none; - width: 16px; - height: 16px; - background: #4a9eff; - border-radius: 50%; - cursor: pointer; - transition: transform 0.1s; -} - -.slider-group input[type="range"]::-webkit-slider-thumb:hover { - transform: scale(1.2); -} - -/* Actions */ -.account-actions { - display: flex; - justify-content: space-between; - margin-top: 2rem; -} - -/* Buttons */ -.button-primary, -.button-secondary, -.button-danger, -.button-link { - padding: 0.8rem 1.5rem; - border-radius: 4px; - border: none; - cursor: pointer; - font-weight: 500; - transition: all 0.2s; -} - -.button-primary { - background: #4a9eff; - color: #fff; -} - -.button-primary:hover { - background: #3a8eef; -} - -.button-primary:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.button-secondary { - background: rgba(255, 255, 255, 0.1); - color: #fff; -} - -.button-secondary:hover { - background: rgba(255, 255, 255, 0.2); -} - -.button-danger { - background: rgba(220, 53, 69, 0.2); - color: #ff6b6b; - border: 1px solid rgba(220, 53, 69, 0.3); -} - -.button-danger:hover { - background: rgba(220, 53, 69, 0.3); -} - -.button-link { - background: none; - color: #4a9eff; - padding: 0; - text-decoration: underline; -} - -.button-link:hover { - text-decoration: none; } /* Notifications */ -.error { - background: rgba(220, 53, 69, 0.1); - color: #ff6b6b; - padding: 1rem; - border-radius: 4px; - margin-bottom: 1rem; - text-align: center; +.error-message-ui { + background: rgba(var(--game-color-danger-rgb), 0.1); + color: var(--game-color-danger); + padding: 0.8rem; + border-left: 3px solid var(--game-color-danger); + font-size: 0.9rem; } -.message-success { - background: rgba(40, 167, 69, 0.1); - color: #5ddc6c; - padding: 1rem; - border-radius: 4px; - margin-bottom: 1rem; - text-align: center; +.message-success-ui { + background: rgba(var(--game-color-success-rgb), 0.1); + color: var(--game-color-success); + padding: 0.8rem; + border-left: 3px solid var(--game-color-success); + font-size: 0.9rem; +} + +.animate-fade-in { + animation: fadeIn 0.3s ease-out forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(5px); + } + + to { + opacity: 1; + transform: translateY(0); + } } \ No newline at end of file diff --git a/pwa/src/components/AccountPage.tsx b/pwa/src/components/AccountPage.tsx index 7ebcd30..d0822d8 100644 --- a/pwa/src/components/AccountPage.tsx +++ b/pwa/src/components/AccountPage.tsx @@ -1,18 +1,23 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { useAuth } from '../hooks/useAuth' import { useAudio } from '../contexts/AudioContext' -import { authApi, Account, Character } from '../services/api' +import { authApi, Account } from '../services/api' +import { GameButton } from './common/GameButton' import './AccountPage.css' function AccountPage() { + const { t } = useTranslation() const navigate = useNavigate() - const { logout } = useAuth() + const { currentCharacter } = useAuth() const [account, setAccount] = useState(null) - const [characters, setCharacters] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + // Tab State + const [activeTab, setActiveTab] = useState<'general' | 'audio' | 'security'>('general') + // Email change state const [showEmailChange, setShowEmailChange] = useState(false) const [newEmail, setNewEmail] = useState('') @@ -47,10 +52,10 @@ function AccountPage() { setLoading(true) const data = await authApi.getAccount() setAccount(data.account) - setCharacters(data.characters) + // characters are returned as data.characters but we don't display the list here anymore setError('') } catch (err: any) { - setError(err.response?.data?.detail || 'Failed to load account data') + setError(err.response?.data?.detail || t('common.error')) } finally { setLoading(false) } @@ -62,7 +67,7 @@ function AccountPage() { setEmailSuccess('') if (!newEmail || !emailPassword) { - setEmailError('Please fill in all fields') + setEmailError(t('common.error')) return } @@ -76,7 +81,7 @@ function AccountPage() { // Refresh account data await fetchAccountData() } catch (err: any) { - setEmailError(err.response?.data?.detail || 'Failed to change email') + setEmailError(err.response?.data?.detail || t('common.error')) } finally { setEmailLoading(false) } @@ -88,17 +93,17 @@ function AccountPage() { setPasswordSuccess('') if (!currentPassword || !newPassword || !confirmNewPassword) { - setPasswordError('Please fill in all fields') + setPasswordError(t('common.error')) return } if (newPassword !== confirmNewPassword) { - setPasswordError('New passwords do not match') + setPasswordError(t('auth.errors.passwordMatch')) return } if (newPassword.length < 6) { - setPasswordError('New password must be at least 6 characters') + setPasswordError(t('auth.errors.passwordLength')) return } @@ -111,7 +116,7 @@ function AccountPage() { setConfirmNewPassword('') setShowPasswordChange(false) } catch (err: any) { - setPasswordError(err.response?.data?.detail || 'Failed to change password') + setPasswordError(err.response?.data?.detail || t('common.error')) } finally { setPasswordLoading(false) } @@ -135,7 +140,7 @@ function AccountPage() { if (loading) { return (
-
Loading account...
+
{t('common.loading')}
) } @@ -143,10 +148,12 @@ function AccountPage() { if (error || !account) { return (
-
-

Error

-

{error || 'Account not found'}

- +
+

{t('common.error')}

+

{error || t('common.error')}

+ navigate(currentCharacter ? '/game' : '/characters')}> + {t('common.back')} +
) @@ -154,273 +161,268 @@ function AccountPage() { return (
-
-

Account Management

- - {/* Account Information Section */} -
-

Account Information

-
-
- Email: - {account.email} -
-
- Account Type: - {getAccountTypeDisplay(account.account_type)} -
-
- Premium Status: - - {account.premium_expires_at && Number(account.premium_expires_at) > Date.now() / 1000 - ? '✓ Premium Active' - : 'Free Account'} - -
-
- Created: - {formatDate(account.created_at)} -
-
- Last Login: - {formatDate(account.last_login_at)} -
+
+
+

{t('common.accountSettings')}

+
+ navigate(currentCharacter ? '/game' : '/characters')}> + {currentCharacter ? t('game.dialog.back') : t('common.back')} + + {/* Logout removed from here, user wants it only in header */}
-
+
- {/* Characters Section */} -
-

Your Characters

- {characters.length === 0 ? ( -

No characters yet. Create one to start playing!

- ) : ( -
- {characters.map((char) => ( -
-
-

{char.name}

- Level {char.level} +
+ {/* Tabs Navigation */} +
+ + + +
+ + {/* Tab Content Areas */} +
+ {/* GENERAL TAB */} + {activeTab === 'general' && ( +
+

{t('auth.accountInfo')}

+
+
+ {t('auth.email')} + {account.email}
-
-
- HP: - {char.hp}/{char.max_hp} +
+ {t('auth.accountType')} + {getAccountTypeDisplay(account.account_type)} +
+
+ {t('auth.premiumStatus')} + + {account.premium_expires_at && Number(account.premium_expires_at) > Date.now() / 1000 + ? t('auth.premiumActive') + : t('auth.freeAccount')} + +
+
+ {t('auth.created')} + {formatDate(account.created_at)} +
+
+ {t('auth.lastLogin')} + {formatDate(account.last_login_at)} +
+
+ +
+

{t('auth.gameActions')}

+ navigate('/characters')}> + {t('auth.switchCharacter')} + +
+
+ )} + + {/* AUDIO TAB */} + {activeTab === 'audio' && ( +
+

{t('auth.audioSettings')}

+
+
+

{t('auth.volumeControls')}

+ +
+
+
+ +
+ setMasterVolume(parseFloat(e.target.value))} + disabled={isMuted} + /> +
-
- Stamina: - {char.stamina}/{char.max_stamina} +
+ +
+ setMusicVolume(parseFloat(e.target.value))} + disabled={isMuted} + /> +
+
+
+ +
+ setSfxVolume(parseFloat(e.target.value))} + disabled={isMuted} + /> +
-
- STR: {char.strength} - AGI: {char.agility} - END: {char.endurance} - INT: {char.intellect} +
+
+ )} + + {/* SECURITY TAB */} + {activeTab === 'security' && ( +
+

{t('auth.securitySettings')}

+ + {/* Email Change */} +
+
+

{t('auth.changeEmail')}

+ setShowEmailChange(!showEmailChange)} + > + {showEmailChange ? t('auth.cancel') : t('auth.change')} +
- + {showEmailChange && ( +
+
+ + setNewEmail(e.target.value)} + placeholder={t('auth.emailPlaceholder')} + required + disabled={emailLoading} + /> +
+
+ + setEmailPassword(e.target.value)} + placeholder={t('auth.verifyIdentity')} + required + disabled={emailLoading} + /> +
+ {emailError &&
{emailError}
} + {emailSuccess &&
{emailSuccess}
} + { }}> + {emailLoading ? t('auth.updating') : t('auth.updateEmail')} + +
+ )}
- ))} -
- )} - -
- {/* Settings Section */} -
-

Audio Settings

-
-
-
-

Volume Controls

- -
-
-
- - setMasterVolume(parseFloat(e.target.value))} - disabled={isMuted} - /> + {/* Password Change */} +
+
+

{t('auth.changePassword')}

+ setShowPasswordChange(!showPasswordChange)} + > + {showPasswordChange ? t('auth.cancel') : t('auth.change')} + +
+ {showPasswordChange && ( +
+
+ + setCurrentPassword(e.target.value)} + placeholder={t('auth.passwordPlaceholderLogin')} + required + disabled={passwordLoading} + /> +
+
+ + setNewPassword(e.target.value)} + placeholder={t('auth.passwordPlaceholder')} + required + disabled={passwordLoading} + /> +
+
+ + setConfirmNewPassword(e.target.value)} + placeholder={t('auth.confirmPasswordPlaceholder')} + required + disabled={passwordLoading} + /> +
+ {passwordError &&
{passwordError}
} + {passwordSuccess &&
{passwordSuccess}
} + { }}> + {passwordLoading ? t('auth.updating') : t('auth.updatePassword')} + +
+ )}
-
- - setMusicVolume(parseFloat(e.target.value))} - disabled={isMuted} - /> -
-
- - setSfxVolume(parseFloat(e.target.value))} - disabled={isMuted} - /> -
-
-
-
-
- -
-

Account Settings

- - {/* Email Change */} -
-
-

Change Email

- -
- {showEmailChange && ( -
-
- - setNewEmail(e.target.value)} - placeholder="new.email@example.com" - required - disabled={emailLoading} - /> -
-
- - setEmailPassword(e.target.value)} - placeholder="Verify your identity" - required - disabled={emailLoading} - /> -
- {emailError &&
{emailError}
} - {emailSuccess &&
{emailSuccess}
} - -
+
)}
- - {/* Password Change */} -
-
-

Change Password

- -
- {showPasswordChange && ( -
-
- - setCurrentPassword(e.target.value)} - placeholder="Your current password" - required - disabled={passwordLoading} - /> -
-
- - setNewPassword(e.target.value)} - placeholder="At least 6 characters" - required - disabled={passwordLoading} - /> -
-
- - setConfirmNewPassword(e.target.value)} - placeholder="Re-enter new password" - required - disabled={passwordLoading} - /> -
- {passwordError &&
{passwordError}
} - {passwordSuccess &&
{passwordSuccess}
} - -
- )} -
- - - {/* Actions Section */} -
- - -
+
) diff --git a/pwa/src/components/CharacterCreation.css b/pwa/src/components/CharacterCreation.css index 184989b..a007ba4 100644 --- a/pwa/src/components/CharacterCreation.css +++ b/pwa/src/components/CharacterCreation.css @@ -1,203 +1,156 @@ -.character-creation-container { +.char-creation-page { + min-height: 100vh; + padding: 3rem 1rem; + background: radial-gradient(circle at center, rgba(225, 29, 72, 0.08) 0%, var(--game-bg-app) 100%); + font-family: var(--game-font-main); display: flex; justify-content: center; align-items: center; - min-height: 100vh; - padding: 2rem; - background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); } -.character-creation-card { - background-color: #2a2a2a; - border-radius: 12px; - padding: 2rem; +.char-creation-container { max-width: 700px; width: 100%; - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); } -.character-creation-card h1 { - font-size: 2rem; - color: #646cff; +.char-creation-card { + padding: 3rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.creation-title { + font-size: 3rem; + text-transform: uppercase; + letter-spacing: 2px; text-align: center; - margin-bottom: 0.5rem; + margin: 0; + color: var(--game-text-primary); + text-shadow: 0 0 10px rgba(225, 29, 72, 0.2); } -.subtitle { +.creation-subtitle { text-align: center; - color: #888; - margin-bottom: 2rem; -} - -.form-section { - margin-bottom: 2rem; -} - -.form-section h2 { - font-size: 1.3rem; - color: #fff; - margin-bottom: 1rem; -} - -.input-hint { - font-size: 0.85rem; - color: #888; - margin-top: 0.25rem; -} - -.points-remaining { - text-align: center; - font-size: 1.2rem; - font-weight: bold; - padding: 1rem; - background-color: #1a1a1a; - border-radius: 8px; + color: var(--game-text-secondary); + text-transform: uppercase; + letter-spacing: 1px; margin-bottom: 1.5rem; } -.points-complete { - color: #51cf66; -} - -.points-over { - color: #ff6b6b; -} - -.stats-grid { - display: grid; - gap: 1rem; -} - -.stat-input { - background-color: #1a1a1a; - border-radius: 8px; - padding: 1rem; -} - -.stat-header { +.creation-form { display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.75rem; + flex-direction: column; + gap: 2rem; } -.stat-icon { - font-size: 1.5rem; -} - -.stat-header label { - font-size: 1.1rem; - font-weight: 600; - color: #fff; -} - -.stat-control { +.form-group-creation { display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 0.5rem; + flex-direction: column; + gap: 0.75rem; } -.stat-control input { - flex: 1; - text-align: center; - font-size: 1.2rem; - font-weight: bold; - padding: 0.5rem; - margin: 0; +.form-group-creation label { + text-transform: uppercase; + letter-spacing: 1px; + color: var(--game-text-secondary); + font-size: 1rem; } -.stat-button { - width: 40px; - height: 40px; - border-radius: 8px; - border: none; - background-color: #646cff; - color: white; - font-size: 1.5rem; - cursor: pointer; - transition: background-color 0.25s; +.attributes-section { display: flex; - align-items: center; - justify-content: center; + flex-direction: column; + gap: 1.5rem; } -.stat-button:hover:not(:disabled) { - background-color: #535bf2; -} - -.stat-button:disabled { - opacity: 0.3; - cursor: not-allowed; -} - -.stat-description { - font-size: 0.85rem; - color: #888; - margin: 0; -} - -.character-preview { - background: linear-gradient(135deg, rgba(100, 108, 255, 0.1) 0%, rgba(83, 91, 242, 0.1) 100%); - border: 1px solid rgba(100, 108, 255, 0.3); - border-radius: 8px; - padding: 1.5rem; -} - -.preview-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1rem; -} - -.preview-stat { +.attributes-header { display: flex; justify-content: space-between; align-items: center; - background-color: rgba(0, 0, 0, 0.3); - padding: 0.75rem 1rem; - border-radius: 8px; + border-bottom: 1px solid var(--game-border-color); + padding-bottom: 0.5rem; } -.preview-label { - font-weight: 600; - color: #aaa; +.attributes-header h3 { + text-transform: uppercase; + letter-spacing: 1px; + margin: 0; + color: var(--game-color-primary); } -.preview-value { - font-size: 1.2rem; - font-weight: bold; - color: #646cff; +.points-remaining { + font-weight: 700; + color: var(--game-text-primary); } -.form-actions { - display: flex; +.points-remaining.zero { + color: var(--game-color-success); +} + +.attributes-grid-creation { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; - margin-top: 2rem; } -.form-actions button { +.attribute-control { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; + background: var(--game-bg-glass) !important; +} + +.attr-info { + display: flex; + justify-content: space-between; + align-items: center; +} + +.attr-name { + text-transform: uppercase; + letter-spacing: 1px; + color: var(--game-text-secondary); +} + +.attr-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--game-text-primary); +} + +.attr-buttons { + display: flex; + gap: 0.5rem; +} + +.attr-buttons button { flex: 1; } -@media (max-width: 768px) { - .character-creation-container { - padding: 1rem; - } - - .character-creation-card { +.error-message-creation { + color: var(--game-text-danger); + background: rgba(239, 68, 68, 0.1); + padding: 1rem; + border-left: 3px solid var(--game-text-danger); + font-size: 0.95rem; +} + +.creation-actions { + display: flex; + gap: 1.5rem; +} + +.create-submit-btn { + flex: 2; +} + +@media (max-width: 600px) { + .char-creation-card { padding: 1.5rem; } - - .character-creation-card h1 { - font-size: 1.5rem; + + .creation-actions { + flex-direction: column-reverse; } - - .stat-control input { - font-size: 1rem; - } - - .preview-stats { - grid-template-columns: 1fr; - } -} +} \ No newline at end of file diff --git a/pwa/src/components/CharacterCreation.tsx b/pwa/src/components/CharacterCreation.tsx index 4b53363..6cabc6c 100644 --- a/pwa/src/components/CharacterCreation.tsx +++ b/pwa/src/components/CharacterCreation.tsx @@ -1,89 +1,50 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useAuth } from '../hooks/useAuth' +import { GameButton } from './common/GameButton' import './CharacterCreation.css' function CharacterCreation() { const { createCharacter } = useAuth() - const navigate = useNavigate() - const [name, setName] = useState('') - const [strength, setStrength] = useState(0) - const [agility, setAgility] = useState(0) - const [endurance, setEndurance] = useState(0) - const [intellect, setIntellect] = useState(0) - const [error, setError] = useState('') + const [stats, setStats] = useState({ + strength: 10, + agility: 10, + endurance: 10, + intellect: 10 + }) const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const navigate = useNavigate() - const TOTAL_POINTS = 20 - const usedPoints = strength + agility + endurance + intellect - const remainingPoints = TOTAL_POINTS - usedPoints + const totalPoints = 40 + const pointsUsed = stats.strength + stats.agility + stats.endurance + stats.intellect + const pointsRemaining = totalPoints - pointsUsed - const calculateHP = (str: number) => 30 + (str * 2) - const calculateStamina = (end: number) => 20 + (end * 1) + const handleStatChange = (stat: keyof typeof stats, delta: number) => { + const newValue = stats[stat] + delta + if (newValue < 5 || newValue > 20) return + if (delta > 0 && pointsRemaining <= 0) return - const handleStatChange = ( - stat: 'strength' | 'agility' | 'endurance' | 'intellect', - value: number - ) => { - // Prevent negative values - if (value < 0) return - - const currentTotal = strength + agility + endurance + intellect - const otherStats = currentTotal - (stat === 'strength' ? strength : stat === 'agility' ? agility : stat === 'endurance' ? endurance : intellect) - - // Prevent exceeding total points - if (otherStats + value > TOTAL_POINTS) { - value = TOTAL_POINTS - otherStats - } - - switch (stat) { - case 'strength': - setStrength(value) - break - case 'agility': - setAgility(value) - break - case 'endurance': - setEndurance(value) - break - case 'intellect': - setIntellect(value) - break - } + setStats({ + ...stats, + [stat]: newValue + }) } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - setError('') - - // Validation - if (name.length < 3 || name.length > 20) { - setError('Name must be between 3 and 20 characters') - return - } - - if (usedPoints !== TOTAL_POINTS) { - setError(`You must allocate exactly ${TOTAL_POINTS} stat points (currently: ${usedPoints})`) - return - } - - if (strength < 0 || agility < 0 || endurance < 0 || intellect < 0) { - setError('Stats cannot be negative') + if (pointsRemaining !== 0) { + setError('You must use all attribute points') return } setLoading(true) + setError('') try { - await createCharacter({ - name, - strength, - agility, - endurance, - intellect, - }) - navigate('/characters') + await createCharacter({ ...stats, name }) + navigate('/game') } catch (err: any) { setError(err.response?.data?.detail || 'Failed to create character') } finally { @@ -91,175 +52,98 @@ function CharacterCreation() { } } - const handleCancel = () => { - navigate('/characters') - } - return ( -
-
-

Create Your Character

-

Choose your name and distribute your stat points

+
+
+
+

Character Creation

+

Forge your survivor for the wasteland

-
- {/* Name Input */} -
- - setName(e.target.value)} - placeholder="Enter character name" - minLength={3} - maxLength={20} - required - disabled={loading} - /> -

3-20 characters, must be unique

-
- - {/* Stat Allocation */} -
-

Stat Allocation

-
- - Points Remaining: {remainingPoints} / {TOTAL_POINTS} - -
- -
- handleStatChange('strength', v)} - description="Increases melee damage and carry capacity" - disabled={loading} - /> - - handleStatChange('agility', v)} - description="Improves dodge chance and critical hits" - disabled={loading} - /> - - handleStatChange('endurance', v)} - description="Increases HP and stamina" - disabled={loading} - /> - - handleStatChange('intellect', v)} - description="Enhances crafting and resource gathering" + +
+ + setName(e.target.value)} + placeholder="Enter survivor name..." + required disabled={loading} + maxLength={20} />
-
- {/* Character Preview */} -
-

Character Preview

-
-
- HP: - {calculateHP(strength)} +
+
+

Attributes

+
+ Points Remaining: {pointsRemaining} +
-
- Stamina: - {calculateStamina(endurance)} -
-
- Level: - 1 + +
+ {(Object.keys(stats) as Array).map((stat) => ( +
+
+ {stat} + {stats[stat]} +
+
+ { + e.preventDefault() + handleStatChange(stat, -1) + }} + disabled={loading || stats[stat] <= 5} + > + - + + { + e.preventDefault() + handleStatChange(stat, 1) + }} + disabled={loading || stats[stat] >= 20 || pointsRemaining <= 0} + > + + + +
+
+ ))}
-
- {error &&
{error}
} + {error &&
{error}
} -
- - -
- +
+ navigate('/characters')} + disabled={loading} + > + Cancel + + { }} // Handled by form submit + > + {loading ? 'Forging...' : 'Create Survivor'} + +
+ +
) } -function StatInput({ - label, - icon, - value, - onChange, - description, - disabled, -}: { - label: string - icon: string - value: number - onChange: (value: number) => void - description: string - disabled: boolean -}) { - return ( -
-
- {icon} - -
-
- - onChange(parseInt(e.target.value) || 0)} - min="0" - disabled={disabled} - /> - -
-

{description}

-
- ) -} - export default CharacterCreation diff --git a/pwa/src/components/CharacterSelection.css b/pwa/src/components/CharacterSelection.css index afd3c5b..6eeb53b 100644 --- a/pwa/src/components/CharacterSelection.css +++ b/pwa/src/components/CharacterSelection.css @@ -1,239 +1,277 @@ -.character-selection-container { +/* Character Selection Page Styles */ + +/* Base container */ +.char-selection-page { min-height: 100vh; - padding: 2rem; - background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); + padding: 3rem 2rem; + background: radial-gradient(circle at center, rgba(225, 29, 72, 0.08) 0%, var(--game-bg-app) 100%); + font-family: var(--game-font-main); + display: flex; + justify-content: center; } -.character-selection-header { +.char-selection-container { + max-width: 1200px; + width: 100%; + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +/* Header */ +.char-selection-header { + padding: 2.5rem; text-align: center; - margin-bottom: 3rem; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + border-bottom: 2px solid var(--game-color-primary-low); +} + +.title-main { + font-size: 3.5rem; + margin: 0; + text-transform: uppercase; + letter-spacing: 3px; + color: var(--game-text-primary); + text-shadow: 0 0 15px rgba(225, 29, 72, 0.3); +} + +.subtitle-sub { + font-size: 1.25rem; + color: var(--game-text-secondary); + text-transform: uppercase; + letter-spacing: 4px; + margin: 0; +} + +/* Cards Grid */ +.char-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 2rem; +} + +/* Character Card */ +.char-card-ui { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--game-bg-glass) !important; + transition: all 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.08); + /* Default border */ + height: 100%; + /* Ensure uniform height */ +} + +.char-card-ui:hover { + border-color: var(--game-color-primary); + transform: translateY(-5px); + box-shadow: var(--game-shadow-glow); +} + +/* Avatar Section */ +.char-avatar-box { + width: 100%; + aspect-ratio: 16/9; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid var(--game-border-color); + overflow: hidden; position: relative; } -.character-selection-header h1 { - font-size: 2.5rem; - color: #646cff; - margin-bottom: 0.5rem; -} - -.character-selection-header .subtitle { - color: #888; - font-size: 1rem; -} - -.logout-button { - position: absolute; - top: 0; - right: 0; -} - -.characters-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1.5rem; - max-width: 1200px; - margin: 0 auto; -} - -.character-card { - background-color: #2a2a2a; - border-radius: 12px; - padding: 1.5rem; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); - transition: transform 0.2s, box-shadow 0.2s; - display: flex; - flex-direction: column; - gap: 1rem; -} - -.character-card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); -} - -.character-avatar { - width: 100px; - height: 100px; - margin: 0 auto; - border-radius: 50%; - overflow: hidden; - background: linear-gradient(135deg, #646cff 0%, #535bf2 100%); - display: flex; - align-items: center; - justify-content: center; -} - -.character-avatar img { +.char-avatar-box img { width: 100%; height: 100%; object-fit: cover; + opacity: 0.8; + transition: all 0.3s ease; } -.avatar-placeholder { - font-size: 2rem; - font-weight: bold; - color: white; +.char-card-ui:hover .char-avatar-box img { + opacity: 1; + transform: scale(1.05); } -.character-info { - text-align: center; -} - -.character-info h3 { - font-size: 1.5rem; - margin-bottom: 0.5rem; - color: #fff; -} - -.character-stats { - display: flex; - justify-content: center; - gap: 1rem; - margin-bottom: 0.5rem; - color: #aaa; - font-size: 0.9rem; -} - -.character-attributes { - display: flex; - justify-content: center; - gap: 1rem; - font-size: 1rem; - margin: 0.5rem 0; -} - -.character-attributes span { - padding: 0.25rem 0.5rem; - background-color: #1a1a1a; - border-radius: 4px; -} - -.character-meta { - color: #666; - font-size: 0.85rem; - margin-top: 0.5rem; -} - -.character-actions { - display: flex; - gap: 0.5rem; -} - -.character-actions button { - flex: 1; -} - -.button-danger { - background-color: #dc3545; - color: white; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 8px; - font-size: 1rem; - cursor: pointer; - transition: background-color 0.25s; -} - -.button-danger:hover { - background-color: #c82333; -} - -.button-danger:disabled { +.avatar-placeholder-ui { + font-size: 4rem; + font-weight: 700; + color: var(--game-border-color); opacity: 0.5; - cursor: not-allowed; } -.create-character-card { - cursor: pointer; - border: 2px dashed #646cff; - background-color: rgba(100, 108, 255, 0.1); +/* Info Section */ +.char-info-box { + padding: 1.5rem; + flex: 1; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.char-meta-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.char-meta-header h3 { + font-size: 1.6rem; + margin: 0; + color: var(--game-text-primary); + text-transform: uppercase; +} + +.level-badge { + background: var(--game-color-primary); + color: #fff; + padding: 0.15rem 0.6rem; + font-weight: 700; + font-size: 0.9rem; + clip-path: var(--game-clip-path-sm); +} + +/* Stats Preview */ +.char-stats-preview { + display: flex; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--game-border-color); + clip-path: var(--game-clip-path-sm); +} + +.stat-preview { + flex: 1; + padding: 0.6rem; display: flex; flex-direction: column; align-items: center; + gap: 0.2rem; +} + +.stat-preview .label { + font-size: 0.75rem; + text-transform: uppercase; + color: var(--game-text-secondary); +} + +.stat-preview .value { + font-size: 1.1rem; + font-weight: 700; +} + +/* Attributes Grid */ +.char-attr-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.attr-item { + background: rgba(255, 255, 255, 0.03); + padding: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + clip-path: var(--game-clip-path-sm); +} + +.last-played { + font-size: 0.85rem; + color: var(--game-text-secondary); +} + +/* Actions Section */ +.char-card-actions { + padding: 1rem; + display: flex; + gap: 1rem; + width: 100%; justify-content: center; - min-height: 250px; + border-top: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.2); } -.create-character-card:hover { - background-color: rgba(100, 108, 255, 0.2); - border-color: #535bf2; +/* Create Card Styles */ +.create-card { + align-items: center; + justify-content: center; + min-height: 400px; + /* Approximate height of other cards */ + cursor: pointer; + border: 2px dashed rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.2) !important; } -.create-character-icon { - font-size: 4rem; - color: #646cff; - margin-bottom: 1rem; +.create-card:hover { + border-color: var(--game-color-primary); + background: rgba(225, 29, 72, 0.05) !important; } -.create-character-card h3 { - color: #646cff; - margin-bottom: 0.5rem; -} - -.create-character-subtitle { - color: #888; - font-size: 0.9rem; -} - -.premium-banner { - background: linear-gradient(135deg, #646cff 0%, #535bf2 100%); - border-radius: 12px; - padding: 2rem; - text-align: center; - max-width: 600px; - margin: 2rem auto 0; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); -} - -.premium-banner h3 { - font-size: 1.5rem; - margin-bottom: 0.5rem; -} - -.premium-banner p { +.create-icon-wrapper { + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; margin-bottom: 1.5rem; + transition: all 0.3s ease; +} + +.create-card:hover .create-icon-wrapper { + background: var(--game-color-primary); + transform: scale(1.1); + box-shadow: 0 0 15px rgba(225, 29, 72, 0.4); +} + +.create-icon { + font-size: 3rem; + font-weight: 300; + color: var(--game-text-primary); + line-height: 1; +} + +.create-card h3 { + font-size: 1.5rem; + color: var(--game-text-primary); + text-transform: uppercase; + margin-bottom: 0.5rem; +} + +.create-subtitle { + color: var(--game-text-secondary); font-size: 1rem; } -.premium-banner button { - background-color: white; - color: #646cff; - font-weight: bold; -} - -.premium-banner button:hover { - background-color: #f0f0f0; -} - -.no-characters { +/* Premium Banner */ +.premium-banner-ui { + grid-column: 1 / -1; text-align: center; - color: #888; - padding: 3rem; - max-width: 500px; - margin: 0 auto; + padding: 2rem; + margin-top: 2rem; + background: linear-gradient(135deg, rgba(37, 99, 235, 0.1) 0%, rgba(37, 99, 235, 0.05) 100%); + border: 1px solid rgba(37, 99, 235, 0.3); } -.no-characters p { - margin-bottom: 1rem; - font-size: 1.1rem; +.premium-banner-ui h3 { + color: #60a5fa; + margin-bottom: 0.5rem; } -@media (max-width: 768px) { - .character-selection-container { - padding: 1rem; - } - - .character-selection-header h1 { - font-size: 1.8rem; - } - - .logout-button { - position: static; - margin-top: 1rem; - } - - .characters-grid { - grid-template-columns: 1fr; - gap: 1rem; - } -} +/* No Characters Empty State */ +.no-chars-box { + grid-column: 1 / -1; + text-align: center; + padding: 4rem; + color: var(--game-text-secondary); +} \ No newline at end of file diff --git a/pwa/src/components/CharacterSelection.tsx b/pwa/src/components/CharacterSelection.tsx index cedde47..2730e6b 100644 --- a/pwa/src/components/CharacterSelection.tsx +++ b/pwa/src/components/CharacterSelection.tsx @@ -1,16 +1,21 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { useAuth } from '../hooks/useAuth' import { Character } from '../services/api' -import './CharacterSelection.css' +import { GameButton } from './common/GameButton' import { GameTooltip } from './common/GameTooltip' +import { GameModal } from './game/GameModal' +import './CharacterSelection.css' function CharacterSelection() { - const { characters, account, selectCharacter, deleteCharacter, logout } = useAuth() + const { characters, account, selectCharacter, deleteCharacter } = useAuth() const [loading, setLoading] = useState(false) const [error, setError] = useState('') - const [deletingId, setDeletingId] = useState(null) + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [characterToDelete, setCharacterToDelete] = useState(null) const navigate = useNavigate() + const { t } = useTranslation() const handleSelectCharacter = async (characterId: number) => { setLoading(true) @@ -20,26 +25,31 @@ function CharacterSelection() { await selectCharacter(characterId) navigate('/game') } catch (err: any) { - setError(err.response?.data?.detail || 'Failed to select character') + setError(err.response?.data?.detail || t('common.error')) } finally { setLoading(false) } } - const handleDeleteCharacter = async (characterId: number) => { - if (!window.confirm('Are you sure you want to delete this character? This action cannot be undone.')) { - return - } + const confirmDelete = (characterId: number) => { + setCharacterToDelete(characterId) + setShowDeleteModal(true) + } - setDeletingId(characterId) + const handleDeleteCharacter = async () => { + if (!characterToDelete) return + + setLoading(true) setError('') try { - await deleteCharacter(characterId) + await deleteCharacter(characterToDelete) + setShowDeleteModal(false) + setCharacterToDelete(null) } catch (err: any) { - setError(err.response?.data?.detail || 'Failed to delete character') + setError(err.response?.data?.detail || t('common.error')) } finally { - setDeletingId(null) + setLoading(false) } } @@ -52,53 +62,73 @@ function CharacterSelection() { const canCreateCharacter = characters.length < maxCharacters return ( -
-
-

Select Your Character

-

Echoes of the Ash

- -
+
+
+
+

{t('characters.title')}

+

Echoes of the Ash

+
- {error &&
{error}
} + {error &&
{error}
} -
- {characters.map((character) => ( - handleSelectCharacter(character.id)} - onDelete={() => handleDeleteCharacter(character.id)} - loading={loading || deletingId === character.id} - /> - ))} +
+ {characters.map((character) => ( + handleSelectCharacter(character.id)} + onDelete={() => confirmDelete(character.id)} + loading={loading} + /> + ))} - {canCreateCharacter && ( -
-
+
-

Create New Character

-

- {characters.length} / {maxCharacters} slots used -

+ {canCreateCharacter && ( +
+
+ + +
+

{t('characters.create.title', 'Create New')}

+

+ {characters.length} / {maxCharacters} {t('characters.create.slots', 'slots used')} +

+
+ )} +
+ + {!canCreateCharacter && !isPremium && ( +
+

{t('characters.premium.title', 'Character Limit Reached')}

+

{t('characters.premium.description', 'Upgrade to Premium to create up to 10 characters!')}

+ { }}>{t('characters.premium.upgrade', 'Upgrade to Premium')}
)} + + {characters.length === 0 && ( +
+

{t('characters.noCharacters')}

+

{t('characters.createFirst')}

+
+ )} + + {showDeleteModal && ( + setShowDeleteModal(false)} + footer={ +
+ setShowDeleteModal(false)}> + {t('common.cancel')} + + + {t('common.confirm')} + +
+ } + > +

{t('characters.deleteModal.confirm', 'Are you sure you want to delete this character? This action cannot be undone.')}

+
+ )}
- - {!canCreateCharacter && !isPremium && ( -
-

Character Limit Reached

-

Upgrade to Premium to create up to 10 characters!

- -
- )} - - {characters.length === 0 && ( -
-

You don't have any characters yet.

-

Click the "Create New Character" button to get started!

-
- )}
) } @@ -114,64 +144,97 @@ function CharacterCard({ onDelete: () => void loading: boolean }) { + const { t } = useTranslation() + const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString() + try { + if (!dateString) return 'Never' + // Timestamp from API is float seconds, convert to ms + const timestamp = typeof dateString === 'number' ? dateString * 1000 : dateString + const date = new Date(timestamp) + // Check if date is valid (1970 usually means 0 timestamp/invalid in some contexts, but let's just check validity) + if (isNaN(date.getTime()) || date.getFullYear() === 1970) return 'Never' + return date.toLocaleDateString() + } catch (e) { + return 'Invalid Date' + } } return ( -
-
+
+
{character.avatar_data?.image ? ( {character.name} ) : ( -
+
{character.name.substring(0, 2).toUpperCase()}
)}
-
-

{character.name}

-
- Level {character.level} - HP: {character.hp}/{character.max_hp} +
+
+

{character.name}

+ + {t('stats.level')} {character.level} +
+
+
+ {t('stats.hp')} + {character.hp}/{character.max_hp} +
+
+ {t('stats.stamina')} + {character.stamina}/{character.max_stamina} +
+
+ {t('stats.weight')} + {character.weight}/{character.max_weight} +
+
+ {t('stats.volume')} + {character.volume}/{character.max_volume} +
+
-
- - 💪 {character.strength} +
+ +
💪 {character.strength}
- - ⚡ {character.agility} + +
⚡ {character.agility}
- - 🛡️ {character.endurance} + +
🛡️ {character.endurance}
- - 🧠 {character.intellect} + +
🧠 {character.intellect}
-

- Last played: {formatDate(character.last_played_at)} + +

+ {t('characters.lastActive')}: {formatDate(character.last_played_at)}

-
- - + {t('characters.delete')} +
) diff --git a/pwa/src/components/Game.css b/pwa/src/components/Game.css index 317280e..07b0a7f 100644 --- a/pwa/src/components/Game.css +++ b/pwa/src/components/Game.css @@ -317,7 +317,7 @@ html { .game-main { flex: 1; - padding: 1.5rem; + padding: var(--game-padding-md); overflow: hidden; min-height: 0; } @@ -332,7 +332,7 @@ html { transition: all 0.2s ease; height: 40px; position: relative; - clip-path: var(--game-clip-path-sm); + clip-path: var(--game-clip-path); } .game-search-container:focus-within { @@ -406,17 +406,6 @@ html { } /* Mobile fallback */ -@media (max-width: 1200px) { - .explore-tab-desktop { - grid-template-columns: 1fr; - gap: 1rem; - } - - .left-sidebar, - .right-sidebar { - padding: 0; - } -} .location-info { background: var(--game-bg-panel); @@ -699,7 +688,7 @@ html { margin: 0 auto; aspect-ratio: 16 / 9; overflow: hidden; - border: 2px solid rgba(255, 107, 107, 0.3); + border: 1px solid rgba(255, 107, 107, 0.3); clip-path: var(--game-clip-path); flex-shrink: 0; } @@ -1213,6 +1202,7 @@ body.no-scroll { transition: all 0.3s; min-width: 320px; clip-path: var(--game-clip-path); + animation: fadeIn 0.2s ease-out; } .entity-card:hover { @@ -1379,10 +1369,12 @@ body.no-scroll { @keyframes fadeIn { from { opacity: 0; + transform: translateY(10px); } to { opacity: 1; + transform: translateY(0); } } @@ -2031,8 +2023,8 @@ body.no-scroll { /* Changed from center to space-between */ gap: 0.25rem; /* Fixed dimensions for consistent sizing */ - height: 100px; - width: 100%; + height: 90px; + width: 90px; box-sizing: border-box; transition: all 0.2s; cursor: pointer; @@ -2072,14 +2064,17 @@ body.no-scroll { } .equipment-slot.filled { - border-color: rgba(255, 107, 107, 0.5); + padding: 0; + border: none; + background: transparent; + clip-path: none; } .equipment-slot.filled:hover { - border-color: #ff6b6b; - background: rgba(255, 107, 107, 0.1); - transform: scale(1.02); - box-shadow: 0 0 15px rgba(255, 107, 107, 0.3); + transform: none; + box-shadow: none; + background: transparent; + border: none; z-index: 10001; } @@ -2096,36 +2091,6 @@ body.no-scroll { /* Space out elements */ } -/* New unequip button in top-right corner */ -.equipment-unequip-btn { - position: absolute; - top: 4px; - right: 4px; - width: 24px; - height: 24px; - background: rgba(244, 67, 54, 0.9); - border: 1px solid rgba(255, 255, 255, 0.3); - /* clip-path removed to ensure square box */ - aspect-ratio: 1; - color: #fff; - font-size: 0.7rem; - font-weight: bold; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; - padding: 0; - z-index: 10; - line-height: 1; -} - -.equipment-unequip-btn:hover { - background: rgba(244, 67, 54, 1); - transform: scale(1.1); - box-shadow: 0 2px 8px rgba(244, 67, 54, 0.6); -} - /* Equipment tooltip - shows on slot hover */ .equipment-tooltip { display: none; @@ -3623,33 +3588,6 @@ body.no-scroll { } /* Responsive Combat View */ -@media (max-width: 768px) { - .combat-view { - padding: 1rem; - } - - .combat-header-inline h2 { - font-size: 1.5rem; - } - - .combat-enemy-info-inline h3 { - font-size: 1.3rem; - } - - .combat-actions-inline { - grid-template-columns: 1fr 1fr; - /* Side by side on mobile */ - gap: 0.75rem; - } - - .combat-log-container { - padding: 0.75rem; - } - - .combat-log-messages { - max-height: 200px; - } -} /* Centered headings for consistency */ .centered-heading { @@ -3835,9 +3773,7 @@ body.no-scroll { margin-bottom: 1rem; } -/* ============= MOBILE SLIDING MENUS ============= */ - -/* Hide mobile menu buttons on desktop */ +/* Hide mobile menu elements (desktop-only game) */ .mobile-menu-buttons { display: none; } @@ -3846,477 +3782,14 @@ body.no-scroll { display: none; } -/* Mobile menu overlay (darkens background when menu is open) */ .mobile-menu-overlay { display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - z-index: 998; - backdrop-filter: blur(2px); } -/* Mobile header toggle button */ .mobile-header-toggle { display: none; } -/* Mobile Styles */ -@media (max-width: 768px) { - - /* Tab-style navigation bar at bottom */ - .mobile-menu-buttons { - display: flex; - position: fixed; - bottom: 0; - left: 0; - right: 0; - background: rgba(20, 20, 20, 1) !important; - /* Fully opaque */ - border-top: 2px solid rgba(255, 107, 107, 0.5); - z-index: 1000; - /* Always on top */ - padding: 0.5rem 0; - box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.8); - justify-content: space-around; - gap: 0; - height: 65px; - } - - .mobile-menu-btn { - flex: 1; - height: 55px; - border: none; - border-radius: 0; - background: transparent; - color: rgba(255, 255, 255, 0.6); - font-size: 1.5rem; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.2rem; - position: relative; - } - - .mobile-menu-btn::after { - content: ''; - position: absolute; - bottom: 0; - left: 10%; - right: 10%; - height: 3px; - background: transparent; - border-radius: 3px 3px 0 0; - transition: all 0.2s ease; - } - - .mobile-menu-btn:active { - background: rgba(255, 255, 255, 0.1); - } - - .mobile-menu-btn.left-btn::after { - background: rgba(255, 107, 107, 0.8); - } - - .mobile-menu-btn.bottom-btn::after { - background: rgba(255, 193, 7, 0.8); - } - - .mobile-menu-btn.right-btn::after { - background: rgba(107, 147, 255, 0.8); - } - - /* Active tab styles */ - .mobile-menu-btn.left-btn.active { - color: rgb(255, 107, 107); - background: rgba(255, 107, 107, 0.1); - } - - .mobile-menu-btn.bottom-btn.active { - color: rgb(255, 193, 7); - background: rgba(255, 193, 7, 0.1); - } - - .mobile-menu-btn.right-btn.active { - color: rgb(107, 147, 255); - background: rgba(107, 147, 255, 0.1); - } - - .mobile-menu-btn.active::after { - opacity: 1; - } - - .mobile-menu-btn:not(.active)::after { - opacity: 0; - } - - /* Disable bottom-btn during combat */ - .mobile-menu-btn.bottom-btn:disabled { - opacity: 0.3; - cursor: not-allowed; - pointer-events: none; - } - - /* Show overlay when any menu is open */ - .mobile-menu-overlay { - display: block; - } - - /* Hide desktop 3-column layout on mobile */ - .explore-tab-desktop { - display: block !important; - position: relative; - grid-template-columns: 1fr !important; - } - - /* Mobile panels - hidden by default, slide in when open */ - .mobile-menu-panel { - position: fixed; - top: 0; - bottom: 65px; - /* Stop 65px from bottom (above tab bar) */ - width: 85vw; - max-width: 400px; - background: linear-gradient(135deg, rgba(20, 20, 20, 0.98), rgba(40, 40, 40, 0.98)); - z-index: 999; - /* Below tab bar */ - overflow-y: auto; - transition: transform 0.3s ease; - padding: 1rem; - padding-bottom: 1rem; - /* No extra padding needed */ - box-shadow: 0 0 30px rgba(0, 0, 0, 0.8); - } - - /* Left sidebar - slides from left */ - .left-sidebar.mobile-menu-panel { - left: 0; - transform: translateX(-100%); - border-right: 3px solid rgba(255, 107, 107, 0.5); - } - - .left-sidebar.mobile-menu-panel.open { - transform: translateX(0); - } - - /* Right sidebar - slides from right */ - .right-sidebar.mobile-menu-panel { - right: 0; - transform: translateX(100%); - border-left: 3px solid rgba(107, 147, 255, 0.5); - } - - .right-sidebar.mobile-menu-panel.open { - transform: translateX(0); - } - - /* Bottom panel (ground entities) - slides from bottom */ - .ground-entities.mobile-menu-panel.bottom { - top: auto; - bottom: 65px; - /* Start 65px from bottom (above tab bar) */ - left: 0; - right: 0; - width: 100%; - max-width: 100%; - height: calc(70vh - 65px); - /* Height minus tab bar */ - transform: translateY(calc(100% + 65px)); - /* Hide below screen */ - border-top: 3px solid rgba(255, 193, 7, 0.5); - border-radius: 20px 20px 0 0; - padding-bottom: 1rem; - } - - .ground-entities.mobile-menu-panel.bottom.open { - transform: translateY(0); - /* Slide up to bottom: 65px position */ - } - - /* Keep center content always visible on mobile */ - .center-content { - display: block !important; - padding: 0; - } - - /* Hide sidebars and ground entities by default on mobile (until menu opened) */ - .left-sidebar:not(.open), - .right-sidebar:not(.open), - .ground-entities:not(.open) { - display: none; - } - - /* When panel is open, show it */ - .mobile-menu-panel.open { - display: block !important; - } - - /* Adjust center content to be full width on mobile */ - .location-info, - .message-box { - margin: 0.5rem; - } - - /* Make compass slightly smaller on mobile when in panel */ - .mobile-menu-panel .compass-grid { - grid-template-columns: repeat(3, 70px); - gap: 0.5rem; - } - - .mobile-menu-panel .compass-btn { - width: 70px; - height: 70px; - } - - .mobile-menu-panel .compass-center { - width: 70px; - height: 70px; - } - - /* Always show item action buttons on mobile (no hover needed) */ - .inventory-item-row-hover .item-actions-hover { - display: flex !important; - position: static; - margin-top: 0.5rem; - justify-content: flex-end; - } - - .inventory-item-row-hover { - flex-direction: column; - align-items: stretch; - } - - .item-action-btn { - min-width: 70px; - padding: 0.4rem 0.6rem; - font-size: 0.8rem; - } - - /* Ensure right sidebar has proper background */ - .right-sidebar { - background: linear-gradient(135deg, rgba(20, 20, 20, 0.98), rgba(40, 40, 40, 0.98)); - padding: 1rem; - } - - /* Make combat view always visible and prominent on mobile */ - .combat-view { - position: relative; - z-index: 1; - } - - /* Combat mode - maintain tab bar */ - .game-main:has(.combat-view) .mobile-menu-buttons { - opacity: 0.9; - } - - /* Fix item tooltips on mobile - allow overflow and reposition */ - .inventory-items-scrollable { - overflow: visible !important; - } - - .inventory-panel { - overflow: visible !important; - } - - .right-sidebar.mobile-menu-panel { - overflow-y: auto !important; - overflow-x: visible !important; - } - - .item-info-btn-container .item-info-tooltip { - right: auto; - left: 50%; - transform: translateX(-50%); - max-width: 90vw; - z-index: 10001; - } - - /* Make sure tooltips show on touch */ - .item-info-btn-container:active .item-info-tooltip, - .item-info-btn-container.show-tooltip .item-info-tooltip { - display: block; - } - - /* Hide header on mobile, show toggle button */ - .game-container { - position: relative; - height: 100vh; - display: flex; - flex-direction: column; - overflow: hidden; - /* Prevent header from going outside viewport */ - } - - .game-header { - position: fixed; - top: 0; - left: -100%; - width: 80%; - max-width: 300px; - height: 100%; - z-index: 999; - background: rgba(20, 20, 20, 0.98) !important; - border-right: 2px solid rgba(255, 107, 107, 0.5); - border-bottom: none; - transform: none; - transition: left 0.3s ease; - overflow-y: auto; - box-shadow: 4px 0 20px rgba(0, 0, 0, 0.8); - padding: 1.5rem !important; - padding-top: 4rem !important; - /* Space for X button */ - padding-bottom: calc(65px + 1.5rem) !important; - /* Space for tab bar + padding */ - flex-direction: column; - align-items: flex-start; - gap: 1.5rem; - } - - .game-header.open { - left: 0; - } - - .game-header h1 { - font-size: 1.3rem !important; - width: 100%; - text-align: center; - padding-bottom: 1rem; - border-bottom: 1px solid rgba(255, 107, 107, 0.3); - } - - .game-header .nav-links { - display: flex; - flex-direction: column; - gap: 0.5rem; - width: 100%; - } - - .game-header .user-info { - display: flex; - flex-direction: column; - gap: 0.5rem; - width: 100%; - padding-top: 1rem; - border-top: 1px solid rgba(255, 107, 107, 0.3); - } - - .nav-link, - .username-link { - padding: 0.75rem 1rem !important; - font-size: 0.95rem !important; - width: 100%; - text-align: left; - justify-content: flex-start; - } - - .button-secondary { - width: 100%; - } - - .mobile-header-toggle { - display: block; - position: fixed; - top: 10px; - left: 10px; - width: 45px; - height: 45px; - border-radius: 8px; - background: linear-gradient(135deg, rgba(40, 40, 40, 0.95), rgba(60, 60, 60, 0.95)); - border: 2px solid rgba(255, 107, 107, 0.5); - color: #fff; - font-size: 1.3rem; - cursor: pointer; - z-index: 1001; - /* Above sidebar */ - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); - } - - .mobile-header-toggle:active { - transform: scale(0.95); - } - - /* Make game-main fill space and account for tab bar */ - .game-main { - flex: 1; - overflow-y: auto; - margin-bottom: 65px; - /* Space for tab bar */ - padding-bottom: 0 !important; - } - - /* Compact location titles on mobile */ - .location-info h2 { - font-size: 1.2rem !important; - line-height: 1.3 !important; - margin-bottom: 0.3rem !important; - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - } - - .location-badge { - font-size: 0.7rem !important; - padding: 0.2rem 0.4rem !important; - white-space: nowrap; - } - - /* Toast notification for messages */ - .message-box { - position: fixed !important; - top: 60px; - left: 50%; - transform: translateX(-50%); - width: 90%; - max-width: 400px; - z-index: 9999 !important; - margin: 0 !important; - animation: slideDown 0.3s ease; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.6); - cursor: pointer; - background: rgba(40, 40, 40, 0.98) !important; - /* Opaque background */ - backdrop-filter: blur(10px); - } - - @keyframes slideDown { - from { - opacity: 0; - transform: translateX(-50%) translateY(-20px); - } - - to { - opacity: 1; - transform: translateX(-50%) translateY(0); - } - } - - .message-box.fade-out { - animation: fadeOut 0.3s ease forwards; - } - - @keyframes fadeOut { - from { - opacity: 1; - transform: translateX(-50%) translateY(0); - } - - to { - opacity: 0; - transform: translateX(-50%) translateY(-20px); - } - } -} - /* Utility classes */ .text-danger { color: #ff4444 !important; @@ -4573,6 +4046,120 @@ body.no-scroll { border-color: #ff4444 !important; box-shadow: 0 0 10px rgba(255, 68, 68, 0.5) !important; display: flex !important; - /* Ensure it stays flex */ transform-origin: center; +} + +/* GLOBAL GAME ITEM CARD */ +.game-item-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--game-bg-card); + border: 1px solid var(--game-border-color); + clip-path: var(--game-clip-path); + padding: 0.5rem; + aspect-ratio: 1; + position: relative; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: var(--game-shadow-sm); + width: 90px; + height: 90px; + box-sizing: border-box; + flex-shrink: 0; +} + +.game-item-card:hover, +.game-item-card.active { + border-color: #63b3ed; + background: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 10; +} + +.game-item-card.equipped { + border-color: #63b3ed; + background: rgba(66, 153, 225, 0.1); +} + +.game-item-image-wrapper { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.game-item-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); +} + +.game-item-emoji.hidden { + display: none; +} + +/* Tier Border Colors matching LocationView/TradeModal */ +.game-item-card.text-tier-0 { + border-color: #a0aec0; +} + +.game-item-card.text-tier-1 { + border-color: #ffffff; +} + +.game-item-card.text-tier-2 { + border-color: #68d391; +} + +.game-item-card.text-tier-3 { + border-color: #63b3ed; +} + +.game-item-card.text-tier-4 { + border-color: #9f7aea; +} + +.game-item-card.text-tier-5 { + border-color: #ed8936; +} + +/* Global Item Quantity / Stack Badge */ +.item-quantity-badge { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 0.75rem; + padding: 2px 5px; + clip-path: var(--game-clip-path-sm); + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.2); + z-index: 20; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + line-height: 1; +} + +.game-item-value-badge { + position: absolute; + bottom: 4px; + left: 4px; + background: rgba(0, 0, 0, 0.85); + color: #ffd700; + font-size: 0.75rem; + padding: 2px 5px; + clip-path: var(--game-clip-path-sm); + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.2); + z-index: 20; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + line-height: 1; } \ No newline at end of file diff --git a/pwa/src/components/Game.tsx b/pwa/src/components/Game.tsx index 9fee843..cec22a7 100644 --- a/pwa/src/components/Game.tsx +++ b/pwa/src/components/Game.tsx @@ -287,6 +287,26 @@ function Game() { // Handled by GameHeader, ignore here break + case 'global_quest_completed': + console.log('🌍 Global Quest Completed', message.data) + actions.addLocationMessage(`🎉 GLOBAL QUEST COMPLETED: ${message.data.title}`) + + // Show unlocks if any + if (message.data.outcome?.unlocks && message.data.outcome.unlocks.length > 0) { + const unlocks = message.data.outcome.unlocks; + // @ts-ignore + const locationUnlocks = unlocks.filter((u: any) => u.type === 'location').map((u: any) => u.name).join(', '); + // @ts-ignore + const interactableUnlocks = unlocks.filter((u: any) => u.type === 'interactable').map((u: any) => u.name).join(', '); + + if (locationUnlocks) actions.addLocationMessage(`🔓 New Locations Unlocked: ${locationUnlocks}`); + if (interactableUnlocks) actions.addLocationMessage(`🔓 New Content Unlocked: ${interactableUnlocks}`); + } + + // Refresh everything to reflect unlocks and rewards + actions.fetchGameData() + break + default: console.log('Unknown WebSocket message type:', message.type) } diff --git a/pwa/src/components/GameHeader.css b/pwa/src/components/GameHeader.css index 047278e..8927b2f 100644 --- a/pwa/src/components/GameHeader.css +++ b/pwa/src/components/GameHeader.css @@ -324,63 +324,4 @@ height: 24px; background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.2), transparent); margin: 0 2px; -} - -/* Responsive */ -@media (max-width: 768px) { - .game-header { - padding: 0 0.5rem; - height: 56px; - } - - .header-left h1 { - display: none; - /* Hide full text on mobile */ - } - - .header-title-container::after { - content: 'EotA'; - /* Short title */ - color: #fff; - font-weight: 800; - } - - .nav-links { - margin-left: 0.5rem; - gap: 4px; - flex: 1; - } - - .nav-link { - padding: 0 0.6rem; - font-size: 0.7rem; - clip-path: none; - /* Simplify for mobile touch */ - border-radius: 4px; - } - - .user-info { - gap: 0.4rem; - } - - .player-count-badge { - padding: 0 6px; - } - - .count-text { - display: none; - } - - /* Dot only */ - - .username-link { - padding: 0 0.5rem; - max-width: 80px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - display: block; - /* block for ellipsis */ - line-height: 34px; - } } \ No newline at end of file diff --git a/pwa/src/components/LandingPage.css b/pwa/src/components/LandingPage.css index f250096..c354810 100644 --- a/pwa/src/components/LandingPage.css +++ b/pwa/src/components/LandingPage.css @@ -1,52 +1,47 @@ +/* LandingPage.css */ + .landing-page { min-height: 100vh; - background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%); - color: #fff; + background-color: #050508; + color: #e2e8f0; + font-family: 'Inter', system-ui, sans-serif; + overflow-x: hidden; } -/* Hero Section */ +/* --- Hero Section --- */ .hero-section { position: relative; - min-height: 100vh; + height: 100vh; display: flex; align-items: center; justify-content: center; - padding: 2rem; - overflow: hidden; + text-align: center; + padding: 0 1rem; + background-image: url('/landing-bg.webp'); + background-size: cover; + background-position: center; + background-attachment: fixed; } +/* Gradient Overlay for better text readability */ .hero-gradient { position: absolute; top: 0; left: 0; right: 0; bottom: 0; - background: radial-gradient(ellipse at center, rgba(100, 108, 255, 0.15) 0%, transparent 70%); - pointer-events: none; - animation: pulse 8s ease-in-out infinite; -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 0.5; - } - - 50% { - opacity: 1; - } + background: radial-gradient(circle at center, rgba(5, 5, 8, 0.4) 0%, rgba(5, 5, 8, 0.95) 90%); + z-index: 1; } .hero-content { position: relative; - z-index: 1; + z-index: 2; max-width: 800px; - text-align: center; - animation: fadeInUp 1s ease-out; + animation: fadeUp 1s ease-out; } -@keyframes fadeInUp { +@keyframes fadeUp { from { opacity: 0; transform: translateY(30px); @@ -59,40 +54,39 @@ } .hero-title { + font-family: 'Orbitron', sans-serif; font-size: 4rem; - font-weight: 700; + font-weight: 900; margin-bottom: 1rem; - background: linear-gradient(135deg, #646cff 0%, #8b5cf6 100%); + color: #fff; + text-transform: uppercase; + letter-spacing: 2px; + text-shadow: 0 0 20px rgba(225, 29, 72, 0.6); + background: linear-gradient(to right, #fff 20%, #fda4af 50%, #fff 80%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; - background-clip: text; - animation: glow 3s ease-in-out infinite; + background-size: 200% auto; + animation: shine 5s linear infinite; } -@keyframes glow { - - 0%, - 100% { - filter: drop-shadow(0 0 20px rgba(100, 108, 255, 0.5)); - } - - 50% { - filter: drop-shadow(0 0 30px rgba(100, 108, 255, 0.8)); +@keyframes shine { + to { + background-position: 200% center; } } .hero-subtitle { font-size: 1.5rem; - color: #ccc; - margin-bottom: 1.5rem; + color: #cbd5e1; + margin-bottom: 2rem; font-weight: 300; } .hero-description { font-size: 1.1rem; - color: #999; - line-height: 1.8; - margin-bottom: 2.5rem; + color: #94a3b8; + margin-bottom: 3rem; + line-height: 1.6; max-width: 600px; margin-left: auto; margin-right: auto; @@ -100,146 +94,205 @@ .hero-buttons { display: flex; - gap: 1rem; + gap: 1.5rem; justify-content: center; - flex-wrap: wrap; } .hero-button { - padding: 1rem 2.5rem; - font-size: 1.1rem; min-width: 180px; - transition: all 0.3s ease; + font-family: 'Saira Condensed', sans-serif; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + font-size: 1.2rem !important; } -.hero-button:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(100, 108, 255, 0.4); -} - -/* Features Section */ +/* --- Features Section --- */ .features-section { padding: 6rem 2rem; - background: linear-gradient(180deg, transparent 0%, rgba(100, 108, 255, 0.05) 100%); + background-color: #050508; + position: relative; + overflow: hidden; +} + +/* Tech grid background for features */ +.features-section::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(rgba(225, 29, 72, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(225, 29, 72, 0.03) 1px, transparent 1px); + background-size: 40px 40px; + opacity: 0.5; + pointer-events: none; } .section-title { text-align: center; + font-family: 'Orbitron', sans-serif; font-size: 2.5rem; - margin-bottom: 3rem; - color: #646cff; - font-weight: 600; + margin-bottom: 4rem; + color: #fff; + text-transform: uppercase; + letter-spacing: 2px; + position: relative; + z-index: 2; +} + +.section-title::after { + content: ''; + display: block; + width: 60px; + height: 4px; + background: #e11d48; + margin: 1rem auto 0; + box-shadow: 0 0 10px #e11d48; } .features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 3rem; max-width: 1200px; margin: 0 auto; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 2rem; + position: relative; + z-index: 2; } .feature-card { - background: rgba(42, 42, 42, 0.6); - backdrop-filter: blur(10px); - border: 1px solid rgba(100, 108, 255, 0.2); - border-radius: 16px; - padding: 2rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 2.5rem; transition: all 0.3s ease; - animation: fadeIn 0.6s ease-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } + clip-path: polygon(20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%, 0 20px); + position: relative; + overflow: hidden; } .feature-card:hover { - transform: translateY(-8px); - border-color: rgba(100, 108, 255, 0.5); - box-shadow: 0 12px 30px rgba(100, 108, 255, 0.2); + transform: translateY(-10px); + background: rgba(255, 255, 255, 0.06); + border-color: rgba(225, 29, 72, 0.4); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); +} + +.feature-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, transparent, #e11d48, transparent); + opacity: 0; + transition: opacity 0.3s; +} + +.feature-card:hover::before { + opacity: 1; } .feature-icon { font-size: 3rem; - margin-bottom: 1rem; - filter: drop-shadow(0 0 10px rgba(100, 108, 255, 0.5)); + margin-bottom: 1.5rem; + background: rgba(225, 29, 72, 0.1); + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%); + color: #e11d48; } .feature-card h3 { - font-size: 1.5rem; + font-family: 'Orbitron', sans-serif; + font-size: 1.4rem; margin-bottom: 1rem; color: #fff; + letter-spacing: 1px; } .feature-card p { - color: #aaa; + color: #94a3b8; line-height: 1.6; - margin-bottom: 1rem; + margin-bottom: 1.5rem; } .feature-screenshot { width: 100%; - border-radius: 8px; - margin-top: 1rem; - border: 1px solid rgba(100, 108, 255, 0.3); - transition: all 0.3s ease; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: transform 0.3s; } -.feature-screenshot:hover { +.feature-card:hover .feature-screenshot { transform: scale(1.02); - box-shadow: 0 8px 20px rgba(100, 108, 255, 0.3); + border-color: rgba(255, 255, 255, 0.3); } -/* About Section */ +/* --- About Section --- */ .about-section { padding: 6rem 2rem; - background: rgba(26, 26, 26, 0.8); + background: linear-gradient(to bottom, #050508, #0f0f13); + text-align: center; } .about-content { max-width: 800px; margin: 0 auto; - text-align: center; } .about-content p { - font-size: 1.1rem; + font-size: 1.2rem; + color: #cbd5e1; line-height: 1.8; - color: #bbb; - margin-bottom: 1.5rem; } -/* Footer */ +/* --- Footer --- */ .landing-footer { - padding: 2rem; - text-align: center; - background: #0a0a0a; - border-top: 1px solid rgba(100, 108, 255, 0.2); + padding: 3rem 2rem; + background-color: #020203; + border-top: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; } .landing-footer p { - color: #666; + color: #64748b; font-size: 0.9rem; } -/* Responsive Design */ +.footer-links { + display: flex; + gap: 2rem; +} + +.footer-links span { + color: #94a3b8; + cursor: pointer; + transition: color 0.2s; + font-size: 0.9rem; +} + +.footer-links span:hover { + color: #e11d48; +} + +/* --- Responsive Adjustments --- */ @media (max-width: 768px) { .hero-title { font-size: 2.5rem; } .hero-subtitle { - font-size: 1.2rem; - } - - .hero-description { - font-size: 1rem; + font-size: 1.1rem; } .section-title { @@ -250,19 +303,9 @@ grid-template-columns: 1fr; } - .features-section, - .about-section { - padding: 4rem 1rem; - } -} - -@media (max-width: 480px) { - .hero-title { - font-size: 2rem; - } - .hero-buttons { flex-direction: column; + gap: 1rem; } .hero-button { diff --git a/pwa/src/components/LandingPage.tsx b/pwa/src/components/LandingPage.tsx index 8f188cc..df1ce5a 100644 --- a/pwa/src/components/LandingPage.tsx +++ b/pwa/src/components/LandingPage.tsx @@ -1,11 +1,14 @@ import { useNavigate } from 'react-router-dom' import { useAuth } from '../hooks/useAuth' import { useEffect } from 'react' +import { GameButton } from './common/GameButton' +import { useTranslation } from 'react-i18next' import './LandingPage.css' function LandingPage() { const navigate = useNavigate() const { isAuthenticated } = useAuth() + const { t } = useTranslation() // Redirect authenticated users to characters page useEffect(() => { @@ -19,26 +22,28 @@ function LandingPage() { {/* Hero Section */}
-

Echoes of the Ash

-

Survive the Wasteland. Forge Your Legend.

+

{t('landing.heroTitle')}

+

{t('landing.heroSubtitle')}

- A post-apocalyptic survival RPG where every decision matters. - Explore desolate wastelands, battle mutated creatures, craft essential gear, - and compete with other survivors in a world consumed by ash. + {t('landing.about.description')}

- - + {t('landing.login')} +
@@ -46,73 +51,46 @@ function LandingPage() { {/* Features Section */}
-

Game Features

+

{t('landing.features')}

⚔️
-

Tactical Combat

-

Engage in turn-based battles against mutated creatures and hostile survivors. Choose your actions wisely!

- Combat gameplay +

{t('landing.featureCards.combat.title')}

+

{t('landing.featureCards.combat.description')}

🎒
-

Deep Inventory System

-

Manage your equipment, craft items, and optimize your loadout for survival in the harsh wasteland.

- Inventory system -
- -
-
🗺️
-

Explore the Wasteland

-

Navigate through dangerous locations, discover hidden treasures, and encounter other players in real-time.

- Exploration gameplay +

{t('landing.featureCards.survival.title')}

+

{t('landing.featureCards.survival.description')}

🔧
-

Crafting & Salvage

-

Scavenge materials, repair equipment, and craft powerful items to gain an edge in the wasteland.

-
- -
-
📊
-

Character Progression

-

Level up your character, allocate stat points, and customize your build to match your playstyle.

-
- -
-
👥
-

Multiplayer Interactions

-

Trade with other players, engage in PvP combat, or cooperate to survive in the harsh world.

+

{t('landing.featureCards.crafting.title')}

+

{t('landing.featureCards.crafting.description')}

{/* About Section */}
-

About the Game

+

{t('landing.about.title')}

- In the aftermath of a catastrophic event that covered the world in ash, - humanity struggles to survive. Resources are scarce, dangers lurk around - every corner, and only the strongest and smartest will endure. -

-

- Create your character, explore the wasteland, battle mutated creatures, - and compete with other survivors. Will you become a legendary scavenger, - a feared warrior, or a cunning trader? The choice is yours. -

-

- Join thousands of players in this persistent online world where your - actions have consequences and your reputation matters. + {t('landing.about.description')}

{/* Footer */}
-

© 2025 Echoes of the Ash. All rights reserved.

+

{t('landing.footer.copyright', { year: 2026 })}

+
+ navigate('/privacy')}>{t('landing.footer.links.privacy')} + navigate('/terms')}>{t('landing.footer.links.terms')} + window.open('https://discord.gg/8QWK9QcNqm', '_blank')}>{t('landing.footer.links.discord')} +
) diff --git a/pwa/src/components/LanguageSelector.css b/pwa/src/components/LanguageSelector.css index fecd07a..dd6ac88 100644 --- a/pwa/src/components/LanguageSelector.css +++ b/pwa/src/components/LanguageSelector.css @@ -10,7 +10,6 @@ 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; diff --git a/pwa/src/components/Leaderboards.css b/pwa/src/components/Leaderboards.css index ddbf0cc..ca025f3 100644 --- a/pwa/src/components/Leaderboards.css +++ b/pwa/src/components/Leaderboards.css @@ -101,7 +101,9 @@ color: #fff; } -.leaderboard-loading, .leaderboard-error, .leaderboard-empty { +.leaderboard-loading, +.leaderboard-error, +.leaderboard-empty { text-align: center; padding: 4rem 2rem; } @@ -117,7 +119,9 @@ } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .leaderboard-error button { @@ -305,293 +309,4 @@ color: rgba(255, 255, 255, 0.8); font-size: 1rem; font-weight: 600; -} - -/* Mobile responsive */ -@media (max-width: 1024px) { - .game-main .leaderboards-container { - grid-template-columns: 1fr; - } - - .stat-selector { - position: static; - } - - .stat-options { - display: grid; - grid-template-columns: repeat(2, 1fr); - } -} - -@media (max-width: 768px) { - /* Remove tab bar spacing for leaderboards page */ - .game-main { - margin-bottom: 0 !important; - } - - .game-main .leaderboards-container { - padding: 0.75rem; - padding-top: 4rem; /* Space for hamburger button */ - display: flex; - flex-direction: column; - gap: 1rem; - width: 100%; - max-width: 100vw; - overflow-x: hidden; - box-sizing: border-box; - } - - /* Hide desktop stat selector on mobile */ - .stat-selector { - display: none; - } - - .stat-selector h3 { - display: none; - } - - /* Dropdown-style selector on mobile */ - .stat-options { - position: relative; - display: block; - cursor: pointer; - background: rgba(0, 0, 0, 0.6); - border: 2px solid rgba(107, 185, 240, 0.3); - border-radius: 8px; - width: 90%; - max-width: 350px; - margin: 0 auto; - } - - .stat-option { - width: 100%; - border: none; - border-radius: 0; - margin: 0; - padding: 1rem; - background: transparent; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - cursor: pointer; - transition: background 0.2s; - } - - .stat-option:hover { - background: rgba(255, 255, 255, 0.05); - } - - .stat-option:first-child { - border-radius: 6px 6px 0 0; - } - - .stat-option:last-child { - border-bottom: none; - border-radius: 0 0 6px 6px; - } - - /* Show only active by default */ - .stat-option:not(.active) { - display: none; - } - - .stat-option.active { - background: rgba(107, 185, 240, 0.15); - border-radius: 6px; - position: relative; - } - - /* Add dropdown arrow to active option */ - .stat-option.active::after { - content: '▼'; - position: absolute; - right: 1rem; - opacity: 0.7; - font-size: 0.8rem; - pointer-events: none; - } - - /* Show all options when expanded */ - .stat-options.expanded .stat-option:not(.active) { - display: flex; - } - - .stat-options.expanded .stat-option.active { - border-radius: 6px 6px 0 0; - } - - .stat-options.expanded .stat-option.active::after { - content: '▲'; - } - - .stat-options.expanded { - background: rgba(0, 0, 0, 0.98); - border-radius: 6px; - border-color: rgba(107, 185, 240, 0.6); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - z-index: 100; - } - - .leaderboard-content { - padding: 0.75rem; - width: 100%; - box-sizing: border-box; - overflow-x: hidden; - } - - .leaderboard-title { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - padding: 0.75rem; - margin-bottom: 1rem; - position: relative; - } - - .leaderboard-title.dropdown-open { - z-index: 100; - } - - .title-left { - width: 100%; - } - - .clickable-title { - cursor: pointer; - user-select: none; - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem; - margin: -0.5rem; - border-radius: 8px; - transition: background 0.2s; - } - - .clickable-title:active { - background: rgba(255, 255, 255, 0.05); - } - - .dropdown-arrow { - margin-left: auto; - font-size: 0.9rem; - opacity: 0.7; - } - - .title-dropdown { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: rgba(0, 0, 0, 0.98); - border: 2px solid rgba(107, 185, 240, 0.6); - border-top: none; - border-radius: 0 0 12px 12px; - margin-top: -0.75rem; - padding-top: 0.75rem; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); - z-index: 101; - max-height: 400px; - overflow-y: auto; - } - - .title-dropdown-option { - width: 100%; - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1rem; - background: transparent; - border: none; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - color: #fff; - cursor: pointer; - transition: background 0.2s; - text-align: left; - } - - .title-dropdown-option:last-child { - border-bottom: none; - border-radius: 0 0 10px 10px; - } - - .title-dropdown-option:hover, - .title-dropdown-option:active { - background: rgba(255, 255, 255, 0.1); - } - - .title-icon { - font-size: 1.5rem; - } - - .leaderboard-title h2 { - font-size: 1.3rem; - } - - .pagination-top, - .pagination-bottom { - width: 100%; - justify-content: center; - } - - .pagination-bottom { - margin-top: 1rem; - } - - .pagination-btn { - min-width: 44px !important; - width: 44px !important; - height: 44px !important; - padding: 0.5rem !important; - font-size: 1.2rem !important; - border-radius: 8px !important; - } - - .pagination-info { - min-width: 100px; - text-align: center; - font-size: 0.95rem; - } - - .table-header { - display: none; /* Hide header on mobile */ - } - - .table-row { - grid-template-columns: 50px 1fr 70px; - gap: 0.75rem; - padding: 0.75rem; - } - - .col-level { - order: 3; - } - - .col-value { - order: 2; - grid-column: 2 / 3; - text-align: right; - margin-top: 0.25rem; - } - - .player-name { - font-size: 1rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .player-username { - font-size: 0.85rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .level-badge { - padding: 0.4rem 0.8rem; - font-size: 0.85rem; - } - - .col-value .stat-value { - font-size: 1.1rem; - } -} +} \ No newline at end of file diff --git a/pwa/src/components/Login.css b/pwa/src/components/Login.css index 1d52911..ea95a77 100644 --- a/pwa/src/components/Login.css +++ b/pwa/src/components/Login.css @@ -3,85 +3,98 @@ justify-content: center; align-items: center; min-height: 100vh; - padding: 1rem; - background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); + padding: 2rem; + background: radial-gradient(circle at center, rgba(225, 29, 72, 0.1) 0%, var(--game-bg-app) 100%); + font-family: var(--game-font-main); } .login-card { - background-color: #2a2a2a; - border-radius: 12px; - padding: 2rem; - max-width: 400px; + max-width: 450px; width: 100%; - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); + padding: 3rem; + display: flex; + flex-direction: column; + gap: 1rem; } -.login-card h1 { - font-size: 2rem; +.auth-title { + font-size: 3rem; margin-bottom: 0.5rem; text-align: center; - color: #646cff; + color: var(--game-text-primary); + text-transform: uppercase; + letter-spacing: 2px; } .login-subtitle { text-align: center; - color: #888; + color: var(--game-text-secondary); margin-bottom: 2rem; - font-size: 0.9rem; + font-size: 1.1rem; + text-transform: uppercase; + letter-spacing: 1px; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1.5rem; } .form-group { - margin-bottom: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; } .form-group label { - display: block; - margin-bottom: 0.5rem; - color: #ccc; - font-size: 0.9rem; + color: var(--game-text-secondary); + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 1px; } -.form-group input { - margin-bottom: 0; +.game-input { + background: rgba(0, 0, 0, 0.5); + border: 1px solid var(--game-border-color); + color: var(--game-text-primary); + padding: 0.8rem 1rem; + font-family: var(--game-font-main); + font-size: 1.1rem; + outline: none; + transition: all 0.2s ease; + clip-path: var(--game-clip-path-sm); +} + +.game-input:focus { + border-color: var(--game-color-primary); + background: rgba(0, 0, 0, 0.7); + box-shadow: 0 0 10px rgba(225, 29, 72, 0.2); +} + +.auth-submit { + margin-top: 1rem; + width: 100%; +} + +.error-message { + color: var(--game-text-danger); + background: rgba(239, 68, 68, 0.1); + padding: 0.75rem; + border-left: 3px solid var(--game-text-danger); + font-size: 0.9rem; + margin-top: 0.5rem; } .login-toggle { margin-top: 1.5rem; - text-align: center; -} - -.button-link { - background: none; - border: none; - color: #646cff; - cursor: pointer; - font-size: 0.9rem; - padding: 0.5rem; - text-decoration: underline; -} - -.button-link:hover { - color: #535bf2; - border: none; -} - -.button-link:disabled { - opacity: 0.5; - cursor: not-allowed; + display: flex; + justify-content: center; } .password-strength { - margin-top: 0.5rem; - font-size: 0.85rem; + margin-top: 0.25rem; + font-size: 0.9rem; font-weight: 600; -} - -@media (max-width: 480px) { - .login-card { - padding: 1.5rem; - } - - .login-card h1 { - font-size: 1.5rem; - } + text-transform: uppercase; } \ No newline at end of file diff --git a/pwa/src/components/Login.tsx b/pwa/src/components/Login.tsx index 19cc638..bc51f69 100644 --- a/pwa/src/components/Login.tsx +++ b/pwa/src/components/Login.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { useAuth } from '../hooks/useAuth' +import { GameButton } from './common/GameButton' import './Login.css' function Login() { @@ -10,6 +12,7 @@ function Login() { const [loading, setLoading] = useState(false) const { login } = useAuth() const navigate = useNavigate() + const { t } = useTranslation() const validateEmail = (email: string) => { const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ @@ -22,12 +25,12 @@ function Login() { // Validation if (!validateEmail(email)) { - setError('Please enter a valid email address') + setError(t('auth.errors.invalidEmail')) return } if (password.length < 6) { - setError('Password must be at least 6 characters') + setError(t('auth.errors.passwordLength')) return } @@ -38,7 +41,7 @@ function Login() { // Navigate to character selection after successful login navigate('/characters') } catch (err: any) { - setError(err.response?.data?.detail || 'Login failed') + setError(err.response?.data?.detail || t('auth.errors.loginFailed')) } finally { setLoading(false) } @@ -46,19 +49,20 @@ function Login() { return (
-
-

Welcome Back

-

Login to continue your journey

+
+

{t('auth.loginTitle')}

+

{t('auth.loginSubtitle')}

-
+
- + setEmail(e.target.value)} - placeholder="your.email@example.com" + placeholder={t('auth.emailPlaceholder')} required disabled={loading} autoComplete="email" @@ -66,47 +70,45 @@ function Login() {
- + setPassword(e.target.value)} - placeholder="Your password" + placeholder={t('auth.passwordPlaceholderLogin')} required disabled={loading} autoComplete="current-password" />
- {error &&
{error}
} + {error &&
{error}
} - + { }} // Form will handle it via submit + > + {loading ? t('auth.loggingIn') : t('auth.login')} +
- + {t('auth.registerLink')} +
-
- -
+
) diff --git a/pwa/src/components/Profile.css b/pwa/src/components/Profile.css index e06fb56..f8912cb 100644 --- a/pwa/src/components/Profile.css +++ b/pwa/src/components/Profile.css @@ -181,30 +181,4 @@ .stat-value.highlight-stamina { color: #ffd93d; -} - -/* Mobile responsive */ -@media (max-width: 768px) { - - /* Remove tab bar spacing for profile page */ - .game-main { - margin-bottom: 0 !important; - } - - .game-main .profile-container { - grid-template-columns: 1fr; - padding: 1rem; - padding-top: 4rem; - /* Space for hamburger button */ - max-width: 100vw; - overflow-x: hidden; - } - - .profile-info-card { - position: static; - } - - .profile-stats-grid { - grid-template-columns: 1fr; - } } \ No newline at end of file diff --git a/pwa/src/components/Register.tsx b/pwa/src/components/Register.tsx index 3487571..c8d3eb5 100644 --- a/pwa/src/components/Register.tsx +++ b/pwa/src/components/Register.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { useAuth } from '../hooks/useAuth' +import { GameButton } from './common/GameButton' import './Login.css' function Register() { @@ -11,6 +13,7 @@ function Register() { const [loading, setLoading] = useState(false) const { register } = useAuth() const navigate = useNavigate() + const { t } = useTranslation() const validateEmail = (email: string) => { const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ @@ -19,9 +22,9 @@ function Register() { const getPasswordStrength = (password: string): { strength: string; color: string } => { if (password.length === 0) return { strength: '', color: '' } - if (password.length < 6) return { strength: 'Weak', color: '#ff6b6b' } - if (password.length < 10) return { strength: 'Medium', color: '#ffd93d' } - return { strength: 'Strong', color: '#51cf66' } + if (password.length < 6) return { strength: t('auth.strength.weak'), color: '#ff6b6b' } + if (password.length < 10) return { strength: t('auth.strength.medium'), color: '#ffd93d' } + return { strength: t('auth.strength.strong'), color: '#51cf66' } } const passwordStrength = getPasswordStrength(password) @@ -32,17 +35,17 @@ function Register() { // Validation if (!validateEmail(email)) { - setError('Please enter a valid email address') + setError(t('auth.errors.invalidEmail')) return } if (password.length < 6) { - setError('Password must be at least 6 characters') + setError(t('auth.errors.passwordLength')) return } if (password !== confirmPassword) { - setError('Passwords do not match') + setError(t('auth.errors.passwordMatch')) return } @@ -53,7 +56,7 @@ function Register() { // Navigate to character selection after successful registration navigate('/characters') } catch (err: any) { - setError(err.response?.data?.detail || 'Registration failed') + setError(err.response?.data?.detail || t('auth.errors.registrationFailed')) } finally { setLoading(false) } @@ -61,19 +64,20 @@ function Register() { return (
-
-

Create Account

-

Join the survivors in the wasteland

+
+

{t('auth.registerTitle')}

+

{t('auth.registerSubtitle')}

-
+
- + setEmail(e.target.value)} - placeholder="your.email@example.com" + placeholder={t('auth.emailPlaceholder')} required disabled={loading} autoComplete="email" @@ -81,13 +85,14 @@ function Register() {
- + setPassword(e.target.value)} - placeholder="At least 6 characters" + placeholder={t('auth.passwordPlaceholder')} required disabled={loading} autoComplete="new-password" @@ -102,50 +107,49 @@ function Register() {
- + setConfirmPassword(e.target.value)} - placeholder="Re-enter your password" + placeholder={t('auth.confirmPasswordPlaceholder')} required disabled={loading} autoComplete="new-password" />
- {error &&
{error}
} + {error &&
{error}
} - + { }} // Form will handle it + > + {loading ? t('auth.submitting') : t('auth.submit')} +
- + {t('auth.loginLink')} +
-
- -
+
) } + export default Register diff --git a/pwa/src/components/common/GameButton.tsx b/pwa/src/components/common/GameButton.tsx index 70a06dd..b2882bd 100644 --- a/pwa/src/components/common/GameButton.tsx +++ b/pwa/src/components/common/GameButton.tsx @@ -9,6 +9,7 @@ interface GameButtonProps { disabled?: boolean; className?: string; style?: React.CSSProperties; + title?: string; } export const GameButton: React.FC = ({ @@ -18,7 +19,8 @@ export const GameButton: React.FC = ({ size = 'md', disabled = false, className = '', - style + style, + title }) => { const handleClick = (e: React.MouseEvent) => { if (disabled) return; @@ -31,6 +33,7 @@ export const GameButton: React.FC = ({ onClick={handleClick} disabled={disabled} style={style} + title={title} > {children} diff --git a/pwa/src/components/common/GameDropdown.tsx b/pwa/src/components/common/GameDropdown.tsx index 8a64835..f5e1337 100644 --- a/pwa/src/components/common/GameDropdown.tsx +++ b/pwa/src/components/common/GameDropdown.tsx @@ -71,15 +71,25 @@ export const GameDropdown: React.FC = ({ // Use passed position (if updated dynamically) or fall back to the captured sticky position const target = position || capturedPos; - const targetX = target.x; - const targetY = target.y; - let x = targetX - 10; + // Get zoom factor to adjust coordinates + const zoom = typeof window !== 'undefined' + ? (parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--zoom-factor')) || 1) + : 1; + + const targetX = target.x / zoom; + const targetY = target.y / zoom; + + // Offset from cursor + const offsetX = 5; + const offsetY = 5; + + let x = targetX - offsetX; // Determine flip direction first using raw position let flipUp = false; if (typeof window !== 'undefined') { - const viewportHeight = window.innerHeight; + const viewportHeight = window.innerHeight / zoom; const estimatedHeight = 200; // Guess for now if (targetY + estimatedHeight > viewportHeight) { @@ -87,7 +97,7 @@ export const GameDropdown: React.FC = ({ } // Adjust width constrained by viewport - const viewportWidth = window.innerWidth; + const viewportWidth = window.innerWidth / zoom; const estimatedWidth = parseInt(width) || 200; if (x + estimatedWidth > viewportWidth) { x = viewportWidth - estimatedWidth - 10; @@ -97,7 +107,7 @@ export const GameDropdown: React.FC = ({ // Apply offset based on direction // If flipping up, we want the bottom to be slightly below the mouse (y + 10) // If flipping down, we want the top to be slightly above the mouse (y - 10) - const y = flipUp ? targetY + 10 : targetY - 10; + const y = flipUp ? targetY + offsetY : targetY - offsetY; return createPortal(
= ({ className={`game-dropdown-menu ${className}`} style={{ top: flipUp ? 'auto' : y, - bottom: flipUp ? (window.innerHeight - y) : 'auto', + bottom: flipUp ? ((window.innerHeight / zoom) - y) : 'auto', left: x, width: width }} diff --git a/pwa/src/components/common/GameTooltip.tsx b/pwa/src/components/common/GameTooltip.tsx index e11e1c5..89e6691 100644 --- a/pwa/src/components/common/GameTooltip.tsx +++ b/pwa/src/components/common/GameTooltip.tsx @@ -21,21 +21,26 @@ export const GameTooltip: React.FC = ({ content, children, cla const updatePosition = (e: React.MouseEvent) => { // Offset from cursor - const offsetX = 15; - const offsetY = 15; + const offsetX = 5; + const offsetY = 5; - // Check viewport boundaries to prevent overflow - let x = e.clientX + offsetX; - let y = e.clientY + offsetY; + // Get zoom factor (CSS zoom on shifts coordinate space) + const zoom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--zoom-factor')) || 1; + + // clientX/Y are in physical viewport coords, but position:fixed uses CSS coords (divided by zoom) + let x = e.clientX / zoom - offsetX; + let y = e.clientY / zoom - offsetY; // Simple boundary check (can be expanded if needed) if (tooltipRef.current) { const rect = tooltipRef.current.getBoundingClientRect(); - if (x + rect.width > window.innerWidth) { - x = e.clientX - rect.width - 5; + const viewW = window.innerWidth / zoom; + const viewH = window.innerHeight / zoom; + if (x + rect.width / zoom > viewW) { + x = e.clientX / zoom - rect.width / zoom + offsetX; } - if (y + rect.height > window.innerHeight) { - y = e.clientY - rect.height - 5; + if (y + rect.height / zoom > viewH) { + y = e.clientY / zoom - rect.height / zoom + offsetY; } } @@ -55,6 +60,11 @@ export const GameTooltip: React.FC = ({ content, children, cla setIsVisible(false); }; + const handleClick = () => { + // Hide tooltip on click so it doesn't interfere with dropdowns/menus + setIsVisible(false); + }; + // Render the tooltip portal const tooltip = isVisible && content ? ( createPortal( @@ -94,6 +104,7 @@ export const GameTooltip: React.FC = ({ content, children, cla onMouseEnter={handleMouseEnter} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} + onClick={handleClick} style={{ display: 'contents' }} // Use contents so the wrapper doesn't affect layout > {children} diff --git a/pwa/src/components/common/ItemTooltipContent.tsx b/pwa/src/components/common/ItemTooltipContent.tsx index c769d47..738b731 100644 --- a/pwa/src/components/common/ItemTooltipContent.tsx +++ b/pwa/src/components/common/ItemTooltipContent.tsx @@ -1,11 +1,15 @@ import { useTranslation } from 'react-i18next'; import { getTranslatedText } from '../../utils/i18nUtils'; -import { EffectBadge } from '../game/EffectBadge'; +import { ItemStatBadges } from './ItemStatBadges'; +import { GameProgressBar } from './GameProgressBar'; interface ItemTooltipContentProps { item: any; showValue?: boolean; // Show item value (for trading) + valueDisplayType?: 'unit' | 'total'; + tradeMarkup?: number; // Multiplier for displayed value showDurability?: boolean; // Show durability bar (default: true if available) + actionHint?: string; } /** @@ -15,13 +19,13 @@ interface ItemTooltipContentProps { export const ItemTooltipContent = ({ item, showValue = false, - showDurability = true + valueDisplayType = 'total', + tradeMarkup, + showDurability = true, + actionHint }: ItemTooltipContentProps) => { const { t } = useTranslation(); - const stats = item.unique_stats || item.stats || {}; - const effects = item.effects || {}; - const maxDurability = item.max_durability; const currentDurability = item.durability; const hasDurability = showDurability && maxDurability && maxDurability > 0; @@ -45,136 +49,40 @@ export const ItemTooltipContent = ({
{/* Value (for trading) */} - {showValue && item.value !== undefined && ( -
- 💰 {t('game.value')}: {item.value * (item.quantity || 1)} coins -
- )} + {showValue && item.value !== undefined && (() => { + const qty = item.is_infinite ? 1 : (item._displayQuantity !== undefined ? item._displayQuantity : item.quantity) || 1; + const multiplier = valueDisplayType === 'total' ? qty : 1; + return ( +
+ 💰 {t('game.value')}: {Math.round(item.value * (tradeMarkup || 1) * multiplier)} +
+ ); + })()} {/* Stat Badges */} -
- {/* Capacity */} - {(stats.weight_capacity) && ( - - ⚖️ +{stats.weight_capacity}kg - - )} - {(stats.volume_capacity) && ( - - 📦 +{stats.volume_capacity}L - - )} - - {/* Combat */} - {(stats.damage_min) && ( - - ⚔️ {stats.damage_min}-{stats.damage_max} - - )} - {(stats.armor) && ( - - 🛡️ +{stats.armor} - - )} - {(stats.armor_penetration) && ( - - 💔 +{stats.armor_penetration} {t('stats.pen')} - - )} - {(stats.crit_chance) && ( - - 🎯 +{Math.round(stats.crit_chance * 100)}% {t('stats.crit')} - - )} - {(stats.accuracy) && ( - - 👁️ +{Math.round(stats.accuracy * 100)}% {t('stats.acc')} - - )} - {(stats.dodge_chance) && ( - - 💨 +{Math.round(stats.dodge_chance * 100)}% Dodge - - )} - {(stats.lifesteal) && ( - - 🧛 +{Math.round(stats.lifesteal * 100)}% {t('stats.life')} - - )} - - {/* Attributes */} - {(stats.strength_bonus) && ( - - 💪 +{stats.strength_bonus} {t('stats.str')} - - )} - {(stats.agility_bonus) && ( - - 🏃 +{stats.agility_bonus} {t('stats.agi')} - - )} - {(stats.endurance_bonus) && ( - - 🏋️ +{stats.endurance_bonus} {t('stats.end')} - - )} - {(stats.hp_bonus) && ( - - ❤️ +{stats.hp_bonus} {t('stats.hpMax')} - - )} - {(stats.stamina_bonus) && ( - - ⚡ +{stats.stamina_bonus} {t('stats.stmMax')} - - )} - - {/* Consumables */} - {(item.hp_restore || effects.hp_restore) && ( - - ❤️ +{item.hp_restore || effects.hp_restore} HP - - )} - {(item.stamina_restore || effects.stamina_restore) && ( - - ⚡ +{item.stamina_restore || effects.stamina_restore} Stm - - )} - - {/* Status Effects */} - {effects.status_effect && ( - - )} - - {effects.cures && effects.cures.length > 0 && ( - - 💊 {t('game.cures')}: {effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')} - - )} -
+ {/* Durability Bar */} {hasDurability && ( -
-
+
+
{t('game.durability')} - - {currentDurability} / {maxDurability} - -
-
-
+ {currentDurability} / {maxDurability}
+ +
+ )} + + {/* Action Hint */} + {actionHint && ( +
+ {actionHint}
)}
diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx index 5aafd09..bff4474 100644 --- a/pwa/src/components/game/Combat.tsx +++ b/pwa/src/components/game/Combat.tsx @@ -26,6 +26,8 @@ interface CombatProps { onClose?: () => void; } +import { useNotification } from '../../contexts/NotificationContext'; + export const Combat: React.FC = ({ combatState: initialCombatData, combatLog: _combatLog, @@ -44,6 +46,7 @@ export const Combat: React.FC = ({ }) => { const { currentCharacter: _currentCharacter, refreshCharacters } = useAuth(); const { t, i18n } = useTranslation(); + const { addNotification } = useNotification(); const isPvP = initialCombatData?.is_pvp || false; @@ -488,6 +491,10 @@ export const Combat: React.FC = ({ setCombatResult('fled'); }, 500); break; + + case 'quest_update': + addNotification(data.message || 'Quest Progress', 'quest'); + break; } }, [t]); diff --git a/pwa/src/components/game/CombatInventoryModal.tsx b/pwa/src/components/game/CombatInventoryModal.tsx index 0378c5c..6a21243 100644 --- a/pwa/src/components/game/CombatInventoryModal.tsx +++ b/pwa/src/components/game/CombatInventoryModal.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { getAssetPath } from '../../utils/assetPath'; import { getTranslatedText } from '../../utils/i18nUtils'; import './CombatInventoryModal.css'; -import { EffectBadge } from './EffectBadge'; +import { ItemStatBadges } from '../common/ItemStatBadges'; import { GameButton } from '../common/GameButton'; interface CombatInventoryModalProps { @@ -107,33 +107,10 @@ export const CombatInventoryModal: React.FC = ({ )}
- {/* Logic adapted from InventoryModal to show all relevant stats */} + {/* Shared stat badges */} + - {/* Consumables (Priority for combat) */} - {(item.effects?.hp_restore || item.hp_restore) && ( - - ❤️ +{item.effects?.hp_restore || item.hp_restore} HP - - )} - {(item.effects?.stamina_restore || item.stamina_restore) && ( - - ⚡ +{item.effects?.stamina_restore || item.stamina_restore} Stm - - )} - - - {/* Status Effects & Cures */} - {item.effects?.status_effect && ( - - )} - - {item.effects?.cures && item.effects.cures.length > 0 && ( - - 💊 {t('game.cures')}: {item.effects.cures.map((c: string) => getTranslatedText(c)).join(', ')} - - )} - - {/* Combat Effects (Throwables, etc) */} + {/* Combat-specific Effects (Throwables, etc) */} {item.combat_effects?.damage_min && ( 💥 {item.combat_effects.damage_min}-{item.combat_effects.damage_max} Dmg @@ -144,60 +121,6 @@ export const CombatInventoryModal: React.FC = ({ ☠️ {t(`effects.${item.combat_effects.status.name}`, item.combat_effects.status.name) as string} )} - - {/* Stats & Unique Stats (If applicable) */} - {(item.unique_stats?.damage_min || item.stats?.damage_min) && ( - - ⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} - - )} - {(item.unique_stats?.armor || item.stats?.armor) && ( - - 🛡️ +{item.unique_stats?.armor || item.stats?.armor} - - )} - {(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && ( - - 💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen') as string} - - )} - {(item.unique_stats?.crit_chance || item.stats?.crit_chance) && ( - - 🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit') as string} - - )} - {(item.unique_stats?.accuracy || item.stats?.accuracy) && ( - - 👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc') as string} - - )} - {(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && ( - - 💨 +{Math.round((item.unique_stats?.dodge_chance || item.stats?.dodge_chance) * 100)}% Dodge - - )} - {(item.unique_stats?.lifesteal || item.stats?.lifesteal) && ( - - 🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life') as string} - - )} - - {/* Attributes */} - {(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && ( - - 💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str') as string} - - )} - {(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && ( - - 🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi') as string} - - )} - {(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && ( - - 🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end') as string} - - )}
diff --git a/pwa/src/components/game/DialogModal.css b/pwa/src/components/game/DialogModal.css index abfef5f..b964c94 100644 --- a/pwa/src/components/game/DialogModal.css +++ b/pwa/src/components/game/DialogModal.css @@ -67,23 +67,29 @@ /* Renamed from .options-container to match JSX */ .options-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); + display: flex; + flex-wrap: wrap; gap: 10px; margin-top: auto; } /* Make the last item span full width if it's the only one in the row (odd number of items) */ -.options-grid>*:last-child:nth-child(odd) { +/*.options-grid>*:last-child:nth-child(odd) { grid-column: span 2; -} +}*/ .option-btn { /* Base styles handled by GameButton, but ensure consistent height */ - width: 100%; + flex: 1 1 45%; + min-width: 120px; margin: 0; } +.full-width { + flex: 1 1 100%; + width: 100%; +} + .option-button { /* Legacy style - keeping just in case */ background: rgba(255, 255, 255, 0.05); diff --git a/pwa/src/components/game/DialogModal.tsx b/pwa/src/components/game/DialogModal.tsx index d7a974e..dd76162 100644 --- a/pwa/src/components/game/DialogModal.tsx +++ b/pwa/src/components/game/DialogModal.tsx @@ -1,5 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useGame } from '../../contexts/GameContext'; +import { useNotification } from '../../contexts/NotificationContext'; +import { useTranslation } from 'react-i18next'; import { GAME_API_URL } from '../../config'; import { GameModal } from './GameModal'; import { GameButton } from '../common/GameButton'; @@ -32,7 +34,9 @@ interface Quest { } export const DialogModal: React.FC = ({ npcId, npcData, onClose, onTrade }) => { - const { token, locale, actions } = useGame(); + const { t } = useTranslation(); + const { token, locale, actions, inventory } = useGame(); + const { addNotification } = useNotification(); const [dialogData, setDialogData] = useState(null); const [currentText, setCurrentText] = useState(""); const [quests, setQuests] = useState([]); @@ -115,7 +119,7 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos const desc = getLocalized(quest.description); if (quest.status === 'active') { - setCurrentText(desc + "\n\n(Quest in progress...)"); + setCurrentText(desc + "\n\n" + t('game.dialog.questInProgress')); } else { setCurrentText(desc); } @@ -132,7 +136,8 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos if (res.ok) { const data = await res.json(); // Refresh or update state - setCurrentText("Quest accepted! Good luck."); + setCurrentText(t('game.dialog.questAccepted')); + addNotification(t('messages.questAccepted'), "success"); if (data.quest) { actions.handleQuestUpdate(data.quest); @@ -147,13 +152,36 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos }, 1500); } else { const err = await res.json(); - alert(err.detail); + addNotification(err.detail, "error"); } } catch (e) { console.error(e); + addNotification(t('messages.failedToAcceptQuest'), "error"); } }; + // Check if player has any relevant items for the quest + const hasRequiredItems = () => { + if (!selectedQuest || !selectedQuest.objectives) return false; + + // If it has kill objectives, we can always "hand in" (check progress/complete) + // unless it's ONLY item delivery. + const hasKillObjective = selectedQuest.objectives.some((o: any) => o.type === 'kill_count'); + if (hasKillObjective) return true; + + // Check item delivery objectives + const itemObjectives = selectedQuest.objectives.filter((o: any) => o.type === 'item_delivery'); + if (itemObjectives.length === 0) return true; // No delivery needed? Should allow. + + // Check if we have ANY of the required items in inventory + // @ts-ignore + return itemObjectives.some((o: any) => { + // @ts-ignore + const invItem = inventory.find((i: any) => i.item_id === o.target); + return invItem && invItem.quantity > 0; + }); + }; + const handInQuest = async () => { if (!selectedQuest) return; try { @@ -166,26 +194,44 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos if (res.ok) { if (result.quest_update) { actions.handleQuestUpdate(result.quest_update); + // Update local state to reflect new progress + setQuests(prev => prev.map(q => q.quest_id === result.quest_update.quest_id ? result.quest_update : q)); + // Also update selectedQuest so the UI reflects changes immediately if we stay on this screen + setSelectedQuest(result.quest_update); } // Refresh game data to update inventory/stats actions.fetchGameData(); if (result.is_completed) { + addNotification(t('messages.questCompleted'), "quest"); let msg = getLocalized(result.completion_text) || "Thank you!"; if (result.rewards && result.rewards.length > 0) { - msg += "\n\nRewards:\n" + result.rewards.join('\n'); + msg += "\n\n" + t('game.dialog.rewards') + ":\n" + result.rewards.join('\n'); } setCurrentText(msg); - // Remove from list - setQuests(prev => prev.filter(q => q.quest_id !== selectedQuest.quest_id)); + // Remove from list or mark as completed in local list + setQuests(prev => prev.map(q => q.quest_id === selectedQuest.quest_id ? { ...q, status: 'completed' } : q)); } else { - setCurrentText(`Progress updated.\n${result.items_deducted?.join('\n')}`); + addNotification(t('messages.questProgressUpdated'), "info"); + + let feedback = t('game.dialog.progressUpdated'); + if (result.items_deducted && result.items_deducted.length > 0) { + feedback += `\n\n${result.items_deducted.join('\n')}`; + } + // Append objective status + if (result.quest_update && result.quest_update.objectives) { + const objText = result.quest_update.objectives.map((o: any) => { + const targetName = o.target_name || o.target; + return `- ${targetName}: ${o.current}/${o.count}`; + }).join('\n'); + feedback += `\n\n${objText}`; + } + + setCurrentText(feedback); } - setTimeout(() => { - resetToGreeting(); - }, 2000); + // Removed setTimeout to keep user in the dialog } else { - alert(result.detail); + addNotification(result.detail, "error"); } } catch (e) { console.error(e); @@ -225,13 +271,6 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos
- {/* BACK BUTTON */} - {(viewState === 'topic' || viewState === 'quest_preview') && ( - - ← Back - - )} - {/* NPC TOPICS */} {viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => ( handleTopicClick(topic)}> @@ -246,34 +285,35 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos className="option-btn quest-btn" size="sm" onClick={() => handleQuestClick(q)} - variant={q.status === 'active' ? 'warning' : 'info'} + variant={q.status === 'active' ? 'warning' : q.status === 'completed' ? 'success' : 'info'} > - {q.status === 'available' ? '❗' : '❓'} {getLocalized(q.title)} + {q.status === 'available' ? '❗' : q.status === 'completed' ? '✅' : '❓'} {getLocalized(q.title)} ))} {/* CONFIRM QUEST ACTION */} {viewState === 'quest_preview' && selectedQuest?.status === 'available' && ( -
+
- Accept Quest + {t('game.dialog.acceptQuest')}
)} {viewState === 'quest_preview' && selectedQuest?.status === 'active' && ( -
+
{/* If it's pure kill quest, 'Complete' makes more sense than 'Hand In' */} {selectedQuest.objectives?.some((o: any) => o.type === 'kill_count') && !selectedQuest.objectives?.some((o: any) => o.type === 'item_delivery') - ? "Complete Quest" - : "Hand In Items"} + ? t('game.dialog.completeQuest') + : t('game.dialog.handInItems')}
)} @@ -281,14 +321,21 @@ export const DialogModal: React.FC = ({ npcId, npcData, onClos {/* TRADE - Only show in greeting */} {viewState === 'greeting' && npcData.trade?.enabled && ( - 💰 Trade + 💰 {t('game.dialog.trade')} )} {/* EXIT - Span full width */} {viewState === 'greeting' && ( - - Goodbye + + {t('game.dialog.goodbye')} + + )} + + {/* BACK BUTTON - Moved to bottom */} + {(viewState === 'topic' || viewState === 'quest_preview') && ( + + ← {t('game.dialog.back')} )}
diff --git a/pwa/src/components/game/InventoryModal.css b/pwa/src/components/game/InventoryModal.css index a0e49bc..2b8200c 100644 --- a/pwa/src/components/game/InventoryModal.css +++ b/pwa/src/components/game/InventoryModal.css @@ -38,10 +38,11 @@ /* --- Redesigned Inventory Modal --- */ /* --- Redesigned Inventory Modal --- */ -.inventory-modal-redesign { +.game-modal-container.inventory-modal-redesign { display: flex; flex-direction: column; - height: 85vh; + height: 90%; + max-height: 90%; width: 95vw; max-width: 1400px; background: var(--game-bg-modal); @@ -53,6 +54,15 @@ clip-path: var(--game-clip-path); } +.game-modal-container.inventory-modal-redesign .game-modal-content { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + /* Ensure it fills the container */ +} + /* Top Bar */ .inventory-top-bar { display: flex; @@ -233,31 +243,8 @@ } .game-search-container { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - background: var(--game-bg-input, rgba(0, 0, 0, 0.3)); - border: 1px solid var(--game-border-color); margin-bottom: 1.5rem; - color: var(--game-text-primary); - width: 100%; box-sizing: border-box; - clip-path: var(--game-clip-path-sm); -} - -.game-search-icon { - font-size: 1rem; - opacity: 0.7; -} - -.game-search-input { - background: transparent; - border: none; - color: #fff; - font-size: 1rem; - flex: 1; - outline: none; } /* View Toggle Button */ @@ -308,9 +295,10 @@ /* Grid View Layout */ .items-container.grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + grid-template-columns: repeat(auto-fill, 90px); + justify-content: center; grid-auto-rows: max-content; - gap: 1rem; + gap: 0.5rem; align-content: start; } @@ -365,6 +353,9 @@ cursor: pointer; transition: all 0.2s ease; box-shadow: var(--game-shadow-sm); + width: 90px; + height: 90px; + box-sizing: border-box; } .inventory-item-card.grid:hover, @@ -424,38 +415,23 @@ display: none; } -.item-quantity-badge { - position: absolute; - bottom: -5px; - right: -5px; - background: var(--game-bg-panel); - border: 1px solid var(--game-border-color); - color: var(--game-text-primary); - font-size: 0.75rem; - padding: 2px 6px; - clip-path: var(--game-clip-path-sm); - font-weight: bold; - box-shadow: var(--game-shadow-sm); -} -/* Position adjustment for grid view badge */ -.inventory-item-card.grid .item-quantity-badge { - bottom: 2px; - right: 2px; - font-size: 0.7rem; - padding: 1px 4px; -} .item-equipped-indicator { position: absolute; top: 2px; - right: 2px; + left: 2px; + /* moved to left to free up space for clip path */ background: #4299e1; color: #fff; - font-size: 0.65rem; + font-size: 0.7rem; font-weight: bold; - padding: 1px 4px; - border-radius: 2px; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + clip-path: var(--game-clip-path-sm); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } diff --git a/pwa/src/components/game/InventoryModal.tsx b/pwa/src/components/game/InventoryModal.tsx index b901e80..237e7d0 100644 --- a/pwa/src/components/game/InventoryModal.tsx +++ b/pwa/src/components/game/InventoryModal.tsx @@ -5,10 +5,13 @@ import { PlayerState, Profile, Equipment } from './types' import { getAssetPath } from '../../utils/assetPath' import { getTranslatedText } from '../../utils/i18nUtils' import './InventoryModal.css' -import { EffectBadge } from './EffectBadge' import { GameTooltip } from '../common/GameTooltip' import { GameDropdown } from '../common/GameDropdown' import { GameButton } from '../common/GameButton' +import { GameModal } from './GameModal' +import { ItemStatBadges } from '../common/ItemStatBadges' +import { GameProgressBar } from '../common/GameProgressBar' +import { GameItemCard } from '../common/GameItemCard' import '../common/GameDropdown.css' interface InventoryModalProps { @@ -173,112 +176,12 @@ function InventoryModal({
{item.description &&

{getTranslatedText(item.description)}

} - {/* Stats Row - Button-like Badges */} -
- {/* Capacity */} - {(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && ( - - ⚖️ +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg - - )} - {(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && ( - - 📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L - - )} - - {/* Combat */} - {(item.unique_stats?.damage_min || item.stats?.damage_min) && ( - - ⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} - - )} - {(item.unique_stats?.armor || item.stats?.armor) && ( - - 🛡️ +{item.unique_stats?.armor || item.stats?.armor} - - )} - {(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && ( - - 💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen')} - - )} - {(item.unique_stats?.crit_chance || item.stats?.crit_chance) && ( - - 🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit')} - - )} - {(item.unique_stats?.accuracy || item.stats?.accuracy) && ( - - 👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc')} - - )} - {(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && ( - - 💨 +{Math.round((item.unique_stats?.dodge_chance || item.stats?.dodge_chance) * 100)}% Dodge - - )} - {(item.unique_stats?.lifesteal || item.stats?.lifesteal) && ( - - 🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life')} - - )} - - {/* Attributes */} - {(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && ( - - 💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str')} - - )} - {(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && ( - - 🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi')} - - )} - {(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && ( - - 🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end')} - - )} - {(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && ( - - ❤️ +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} {t('stats.hpMax')} - - )} - {(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && ( - - ⚡ +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')} - - )} - - {/* Consumables */} - {item.hp_restore && ( - - ❤️ +{item.hp_restore} HP - - )} - {item.stamina_restore && ( - - ⚡ +{item.stamina_restore} Stm - - )} - - {/* Status Effects */} - {item.effects?.status_effect && ( - - )} - - {item.effects?.cures && item.effects.cures.length > 0 && ( - - 💊 {t('game.cures')}: {item.effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')} - - )} - -
+ {/* Stats Row - Reusable Badges */} + {/* Durability Bar */} {hasDurability && ( -
+
{t('game.durability')}
-
-
-
+
)}
@@ -390,189 +287,15 @@ function InventoryModal({ return effectName === itemName; }); - const maxDurability = item.max_durability; - const currentDurability = item.durability; - const hasDurability = maxDurability && maxDurability > 0; - - const tooltipContent = ( -
-
- {item.emoji} {getTranslatedText(item.name)} -
- {item.description &&
{getTranslatedText(item.description)}
} - -
-
⚖️ {item.weight}kg {item.quantity > 1 && `(x${item.quantity})`}
-
📦 {item.volume}L {item.quantity > 1 && `(x${item.quantity})`}
-
- - {/* Stats Row - Button-like Badges */} -
- {/* Capacity */} - {(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && ( - - ⚖️ +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg - - )} - {(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && ( - - 📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L - - )} - - {/* Combat */} - {(item.unique_stats?.damage_min || item.stats?.damage_min) && ( - - ⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} - - )} - {(item.unique_stats?.armor || item.stats?.armor) && ( - - 🛡️ +{item.unique_stats?.armor || item.stats?.armor} - - )} - {(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && ( - - 💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen')} - - )} - {(item.unique_stats?.crit_chance || item.stats?.crit_chance) && ( - - 🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit')} - - )} - {(item.unique_stats?.accuracy || item.stats?.accuracy) && ( - - 👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc')} - - )} - {(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && ( - - 💨 +{Math.round((item.unique_stats?.dodge_chance || item.stats?.dodge_chance) * 100)}% Dodge - - )} - {(item.unique_stats?.lifesteal || item.stats?.lifesteal) && ( - - 🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life')} - - )} - - {/* Attributes */} - {(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && ( - - 💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str')} - - )} - {(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && ( - - 🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi')} - - )} - {(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && ( - - 🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end')} - - )} - {(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && ( - - ❤️ +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} {t('stats.hpMax')} - - )} - {(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && ( - - ⚡ +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')} - - )} - - {/* Consumables */} - {item.hp_restore && ( - - ❤️ +{item.hp_restore} HP - - )} - {item.stamina_restore && ( - - ⚡ +{item.stamina_restore} Stm - - )} - - {/* Status Effects */} - {item.effects?.status_effect && ( - - )} - - {item.effects?.cures && item.effects.cures.length > 0 && ( - - 💊 {t('game.cures')}: {item.effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')} - - )} - -
- - {/* Durability Bar */} - {hasDurability && ( -
-
- {t('game.durability')} - - {currentDurability} / {maxDurability} - -
-
-
-
-
- )} -
- ); - return (
- -
handleItemClick(e, item)} - > - {/* Image/Icon */} -
- {item.image_path ? ( - {getTranslatedText(item.name)} { - (e.target as HTMLImageElement).style.display = 'none'; - const icon = (e.target as HTMLImageElement).nextElementSibling; - if (icon) icon.classList.remove('hidden'); - }} - /> - ) : null} -
- {item.emoji || '📦'} -
-
- - {/* Quantity Badge */} - {item.quantity > 1 &&
x{item.quantity}
} - - {/* Equipped Indicator */} - {item.is_equipped &&
E
} -
-
+ handleItemClick(e, item)} + isActive={activeDropdown === item.id} + showEquipped={true} + showQuantity={true} + /> {/* Dropdown Menu */} {activeDropdown === item.id && ( @@ -707,172 +430,171 @@ function InventoryModal({ }; return ( -
) => { - if (e.target === e.currentTarget) handleClose() - }}> -
- {/* Top Bar: Capacity & Backpack Info */} -
-
-
- ⚖️ -
- - {t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg - - -
-
- -
- 📦 -
- - {t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L - - -
+ + {/* Top Bar: Capacity & Backpack Info */} +
+
+
+ ⚖️ +
+ + {t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg + +
-
- {equipment?.backpack ? ( -
- 🎒 - {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) - +
+ 📦 +
+ + {t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L + + +
+
+
+ +
+ {equipment?.backpack ? ( +
+ 🎒 + {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) + +
+ ) : ( +
+ 🚫 + {t('game.noBackpack')} +
+ )} + +
+
+ +
+ {/* Left Sidebar: Categories */} + +
+ {categories.map(cat => ( + + ))} +
+ + {/* Right Content: Search & List */} +
+
+ 🔍 + ) => onSetInventoryFilter(e.target.value)} + /> + + {/* View Mode Toggle */} +
+ +
+
+ +
+ {filteredItems.length === 0 ? ( +
+ 📦 +

{t('game.noItemsFound')}

) : ( -
- 🚫 - {t('game.noBackpack')} -
- )} - -
-
+ inventoryCategoryFilter === 'all' ? ( + <> + {/* Equipped */} + {filteredItems.some((item: any) => item.is_equipped) && ( + <> +
⚔️ {t('game.equipped')}
+
+ {filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) => + viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i) + )} +
+ + )} -
- {/* Left Sidebar: Categories */} - -
- {categories.map(cat => ( - - ))} -
- - {/* Right Content: Search & List */} -
-
- 🔍 - ) => onSetInventoryFilter(e.target.value)} - /> - - {/* View Mode Toggle */} -
- -
-
- -
- {filteredItems.length === 0 ? ( -
- 📦 -

{t('game.noItemsFound')}

-
- ) : ( - inventoryCategoryFilter === 'all' ? ( - <> - {/* Equipped */} - {filteredItems.some((item: any) => item.is_equipped) && ( - <> -
⚔️ {t('game.equipped')}
-
- {filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) => - viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i) - )} -
- - )} - - {/* Backpack - grouped by categories */} - {filteredItems.some((item: any) => !item.is_equipped) && ( - <> - {/* Group backpack items by category */} - {categories - .filter(cat => cat.id !== 'all') // Exclude 'all' from subcategories - .map(cat => { - const categoryItems = filteredItems.filter( - (item: any) => !item.is_equipped && item.type === cat.id - ); - if (categoryItems.length === 0) return null; - return ( -
-
- {cat.icon} - {cat.label} - ({categoryItems.length}) -
-
- {categoryItems.map((item: any, i: number) => - viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i) - )} -
+ {/* Backpack - grouped by categories */} + {filteredItems.some((item: any) => !item.is_equipped) && ( + <> + {/* Group backpack items by category */} + {categories + .filter(cat => cat.id !== 'all') // Exclude 'all' from subcategories + .map(cat => { + const categoryItems = filteredItems.filter( + (item: any) => !item.is_equipped && item.type === cat.id + ); + if (categoryItems.length === 0) return null; + return ( +
+
+ {cat.icon} + {cat.label} + ({categoryItems.length})
- ); - })} - - )} - - ) : ( - /* Single category */ -
- {filteredItems.map((item: any, i: number) => - viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i) - )} -
- ) - )} -
+
+ {categoryItems.map((item: any, i: number) => + viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i) + )} +
+
+ ); + })} + + )} + + ) : ( + /* Single category */ +
+ {filteredItems.map((item: any, i: number) => + viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i) + )} +
+ ) + )}
-
-
+
+ ) } export default InventoryModal -import { GameProgressBar } from '../common/GameProgressBar' diff --git a/pwa/src/components/game/LocationView.css b/pwa/src/components/game/LocationView.css index e77de83..6a327e3 100644 --- a/pwa/src/components/game/LocationView.css +++ b/pwa/src/components/game/LocationView.css @@ -176,7 +176,7 @@ @keyframes fadeIn { from { opacity: 0; - transform: translateY(10px); + transform: translateY(5px); } to { @@ -186,6 +186,7 @@ } .entity-card.grid-card { + animation: fadeIn 0.2s ease-out; display: flex; flex-direction: column; align-items: center; @@ -238,17 +239,7 @@ /* Overlay for text or stats on hover could be improved, but for now we keep the tooltip */ -.grid-card .grid-quantity { - position: absolute; - bottom: 2px; - right: 2px; - background: rgba(0, 0, 0, 0.8); - color: #fff; - font-size: 0.75rem; - padding: 2px 6px; - font-weight: bold; - border: 1px solid rgba(255, 255, 255, 0.2); -} + .grid-overlay { position: absolute; diff --git a/pwa/src/components/game/LocationView.tsx b/pwa/src/components/game/LocationView.tsx index c1f284a..f10e347 100644 --- a/pwa/src/components/game/LocationView.tsx +++ b/pwa/src/components/game/LocationView.tsx @@ -10,7 +10,7 @@ import { getAssetPath } from '../../utils/assetPath' import { getTranslatedText } from '../../utils/i18nUtils' import { DialogModal } from './DialogModal' import { TradeModal } from './TradeModal' -import { ItemTooltipContent } from '../common/ItemTooltipContent' +import { GameItemCard } from '../common/GameItemCard' import { GameModal } from './GameModal' import './LocationView.css' @@ -33,7 +33,7 @@ interface LocationViewProps { playerState: PlayerState | null combatState: CombatState | null message: string - locationMessages: Array<{ time: string; message: string; location_name?: string }> + locationMessages: Array<{ time: string; message: string; location_name?: string | { [key: string]: string } }> expandedCorpse: string | null corpseDetails: any mobileMenuOpen: string @@ -223,6 +223,36 @@ function LocationView({ setActiveDialogNpc(npc.id); }; + const renderItemPickupOptions = (item: any, isModal: boolean = false) => { + const options = []; + options.push({ label: `${t('common.pickUp')}${item.quantity > 1 ? ' (x1)' : ''}`, qty: 1 }); + if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 }); + if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 }); + if (item.quantity > 1) options.push({ label: `${t('common.pickUpAll')}`, qty: item.quantity }); + + return ( +
+ {options.map((opt) => ( + { + e.stopPropagation(); + playSfx('/audio/sfx/pickup.wav'); + onPickup(Number(item.id), opt.qty); + setActiveDropdown(null); + if (isModal) setEntityModal(null); + }} + style={{ width: '100%', justifyContent: 'center' }} + > + {opt.label} + + ))} +
+ ); + }; + const renderIndicator = (npcId: string) => { const type = questIndicators[npcId]; if (!type) return null; @@ -320,7 +350,7 @@ function LocationView({ {msg.time} {getTranslatedText(msg.message)} {msg.location_name && ( - [{msg.location_name}] + [{getTranslatedText(msg.location_name)}] )}
))} @@ -555,65 +585,16 @@ function LocationView({ const isShaking = failedActionItemId == item.id; const itemId = `item-${item.id}-${i}`; - const renderPickupOptions = () => { - const options = []; - options.push({ label: `${t('common.pickUp')} (x1)`, qty: 1 }); - if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 }); - if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 }); - if (item.quantity > 1) options.push({ label: t('common.pickUpAll'), qty: item.quantity }); - - return ( -
- {options.map((opt) => ( - { - e.stopPropagation(); - playSfx('/audio/sfx/pickup.wav'); - onPickup(Number(item.id), opt.qty); - setActiveDropdown(null); - }} - style={{ width: '100%', justifyContent: 'center' }} - > - {opt.label} - - ))} -
- ); - }; - return ( -
handleDropdownClick(e, itemId)} - > - - -
Click to Interact
- - }> -
- {item.image_path ? ( - {getTranslatedText(item.name)} { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); - }} - /> - ) : null} -
- {item.emoji} -
- {item.quantity > 1 && ( -
x{item.quantity}
- )} -
-
+
+ handleDropdownClick(e, itemId)} + isActive={activeDropdown === itemId} + showQuantity={true} + showDurability={true} + className="entity-card item-card grid-card" + /> {activeDropdown === itemId && (
{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}
- - { - playSfx('/audio/sfx/pickup.wav'); - onPickup(Number(item.id), 1); - setActiveDropdown(null); - }} - style={{ width: '100%', justifyContent: 'center', marginBottom: '8px' }} - > - ✋ {t('common.pickUp')} - - - {item.quantity > 1 && ( - <> -
- {renderPickupOptions()} - - )} + {renderItemPickupOptions(item)} )}
@@ -697,7 +658,7 @@ function LocationView({
🧍
-
+
Lv.{player.level}
@@ -765,72 +726,63 @@ function LocationView({ {/* Corpse Loot Overlay Modal */} { expandedCorpse && corpseDetails && corpseDetails.loot_items && ( -
onSetExpandedCorpse(null)}> -
e.stopPropagation()}> -
-

{t('location.lootableItems')}

- -
-
- {corpseDetails.loot_items.map((item: any) => ( -
- {/* Item Image */} -
- {item.image_path ? ( - {item.item_name} { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - ) : {item.emoji || '📦'}} -
- -
-
- {getTranslatedText(item.item_name)} -
- {item.description &&
{getTranslatedText(item.description)}
} -
- {t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''} -
- {item.required_tool && ( -
- 🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'} -
- )} -
- - - - + onSetExpandedCorpse(null)} + className="corpse-loot-modal-wrapper" + > +
+ {corpseDetails.loot_items.map((item: any) => ( +
+ {/* Item Image */} +
+ {item.image_path ? ( + {item.item_name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : {item.emoji || '📦'}}
- ))} -
- + +
+
+ {getTranslatedText(item.item_name)} +
+ {item.description &&
{getTranslatedText(item.description)}
} +
+ {t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''} +
+ {item.required_tool && ( +
+ 🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'} +
+ )} +
+ + + + +
+ ))}
-
+ + ) } @@ -885,158 +837,152 @@ function LocationView({ ) } {/* Entity "Show All" Modal */} - {entityModal && ( - setEntityModal(null)} - className="entity-show-all-modal" - > -
- {entityModal.type === 'enemies' && location.npcs - .filter((npc: any) => npc.type === 'enemy') - .map((enemy: any) => { - const id = `modal-enemy-${enemy.id}`; - return ( -
handleDropdownClick(e, id)} - > - {enemy.id && ( -
- {getTranslatedText(enemy.name)} { e.currentTarget.style.display = 'none' }} - /> -
- )} - -
{getTranslatedText(enemy.name)}
-
{t('location.level')} {enemy.level}
-
- }> -
- - {activeDropdown === id && ( - setActiveDropdown(null)} width="160px"> -
{getTranslatedText(enemy.name)}
- { onInitiateCombat(enemy.id); setActiveDropdown(null); setEntityModal(null); }} - style={{ width: '100%', justifyContent: 'flex-start' }} - > - ⚔️ {t('common.fight')} - -
- )} -
- ); - })} - - {entityModal.type === 'corpses' && (location.corpses || []).map((corpse: any) => ( -
handleDropdownClick(e, `modal-corpse-${corpse.id}`)} - > -
- {corpse.image_path ? ( - {getTranslatedText(corpse.name)} { (e.target as HTMLImageElement).style.display = 'none'; }} /> - ) :
{corpse.emoji}
} -
{corpse.loot_count} items
-
- {activeDropdown === `modal-corpse-${corpse.id}` && ( - setActiveDropdown(null)} width="160px"> -
{getTranslatedText(corpse.name)}
- { playSfx('/audio/sfx/interact.wav'); onLootCorpse(String(corpse.id)); setActiveDropdown(null); setEntityModal(null); }} - disabled={corpse.loot_count === 0} - style={{ width: '100%', justifyContent: 'flex-start' }} + { + entityModal && ( + setEntityModal(null)} + className="entity-show-all-modal" + > +
+ {entityModal.type === 'enemies' && location.npcs + .filter((npc: any) => npc.type === 'enemy') + .map((enemy: any) => { + const id = `modal-enemy-${enemy.id}`; + return ( +
handleDropdownClick(e, id)} > - 🔍 {t('common.examine')} - - - )} -
- ))} + {enemy.id && ( +
+ {getTranslatedText(enemy.name)} { e.currentTarget.style.display = 'none' }} + /> +
+ )} + +
{getTranslatedText(enemy.name)}
+
{t('location.level')} {enemy.level}
+
+ }> +
+ + {activeDropdown === id && ( + setActiveDropdown(null)} width="160px"> +
{getTranslatedText(enemy.name)}
+ { onInitiateCombat(enemy.id); setActiveDropdown(null); setEntityModal(null); }} + style={{ width: '100%', justifyContent: 'flex-start' }} + > + ⚔️ {t('common.fight')} + +
+ )} +
+ ); + })} - {entityModal.type === 'npcs' && location.npcs - .filter((npc: any) => npc.type !== 'enemy') - .map((npc: any, i: number) => ( -
{ handleNpcClick(npc); setEntityModal(null); }} - style={{ cursor: 'pointer' }} - > - {npc.image_path ? ( - {getTranslatedText(npc.name)} - ) : 🧑} -
-
- ))} - - {entityModal.type === 'items' && [...location.items] - .sort((a: any, b: any) => (a.id || 0) - (b.id || 0)) - .map((item: any, i: number) => { - const itemId = `modal-item-${item.id}-${i}`; - return ( -
handleDropdownClick(e, itemId)} - > - }> -
- {item.image_path ? ( - {getTranslatedText(item.name)} { (e.target as HTMLImageElement).style.display = 'none'; }} /> - ) :
{item.emoji}
} - {item.quantity > 1 &&
x{item.quantity}
} -
-
- {activeDropdown === itemId && ( - setActiveDropdown(null)} width="200px"> -
{getTranslatedText(item.name)}
- { playSfx('/audio/sfx/pickup.wav'); onPickup(Number(item.id), 1); setActiveDropdown(null); setEntityModal(null); }} - style={{ width: '100%', justifyContent: 'center' }} - > - ✋ {t('common.pickUp')} - -
- )} -
- ); - })} - - {entityModal.type === 'players' && (location.other_players || []).map((player: any, i: number) => { - const playerId = `modal-player-${player.id}-${i}`; - return ( -
handleDropdownClick(e, playerId)} + {entityModal.type === 'corpses' && (location.corpses || []).map((corpse: any) => ( +
handleDropdownClick(e, `modal-corpse-${corpse.id}`)} >
-
🧍
-
- Lv.{player.level} -
+ {corpse.image_path ? ( + {getTranslatedText(corpse.name)} { (e.target as HTMLImageElement).style.display = 'none'; }} /> + ) :
{corpse.emoji}
} +
{corpse.loot_count} items
- {activeDropdown === playerId && ( - setActiveDropdown(null)} width="180px"> -
{player.name || player.username}
- {player.can_pvp ? ( - { onInitiatePvP(player.id); setActiveDropdown(null); setEntityModal(null); }} - style={{ width: '100%', justifyContent: 'flex-start' }} - > - ⚔️ {t('game.attack')} - - ) : ( -
PvP Unavailable
- )} + {activeDropdown === `modal-corpse-${corpse.id}` && ( + setActiveDropdown(null)} width="160px"> +
{getTranslatedText(corpse.name)}
+ { playSfx('/audio/sfx/interact.wav'); onLootCorpse(String(corpse.id)); setActiveDropdown(null); setEntityModal(null); }} + disabled={corpse.loot_count === 0} + style={{ width: '100%', justifyContent: 'flex-start' }} + > + 🔍 {t('common.examine')} +
)}
- ); - })} -
-
- )} + ))} + + {entityModal.type === 'npcs' && location.npcs + .filter((npc: any) => npc.type !== 'enemy') + .map((npc: any, i: number) => ( +
{ handleNpcClick(npc); setEntityModal(null); }} + style={{ cursor: 'pointer' }} + > + {npc.image_path ? ( + {getTranslatedText(npc.name)} + ) : 🧑} +
+
+ ))} + + {entityModal.type === 'items' && [...location.items] + .sort((a: any, b: any) => (a.id || 0) - (b.id || 0)) + .map((item: any, i: number) => { + const itemId = `modal-item-${item.id}-${i}`; + return ( +
+ handleDropdownClick(e, itemId)} + isActive={activeDropdown === itemId} + showQuantity={true} + showDurability={true} + className="entity-card item-card" + /> + {activeDropdown === itemId && ( + setActiveDropdown(null)} width="200px"> +
{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}
+ {renderItemPickupOptions(item, true)} +
+ )} +
+ ); + })} + + {entityModal.type === 'players' && (location.other_players || []).map((player: any, i: number) => { + const playerId = `modal-player-${player.id}-${i}`; + return ( +
handleDropdownClick(e, playerId)} + > +
+
🧍
+
+ Lv.{player.level} +
+
+ {activeDropdown === playerId && ( + setActiveDropdown(null)} width="180px"> +
{player.name || player.username}
+ {player.can_pvp ? ( + { onInitiatePvP(player.id); setActiveDropdown(null); setEntityModal(null); }} + style={{ width: '100%', justifyContent: 'flex-start' }} + > + ⚔️ {t('game.attack')} + + ) : ( +
PvP Unavailable
+ )} +
+ )} +
+ ); + })} +
+ + ) + }
) } diff --git a/pwa/src/components/game/MovementControls.tsx b/pwa/src/components/game/MovementControls.tsx index 77ea7ae..5ac5c89 100644 --- a/pwa/src/components/game/MovementControls.tsx +++ b/pwa/src/components/game/MovementControls.tsx @@ -112,9 +112,11 @@ function MovementControls({ const outsideDir = location.directions.includes('outside') ? 'outside' : null; const enterDir = location.directions.includes('enter') ? 'enter' : null; const exitDir = location.directions.includes('exit') ? 'exit' : null; + const upDir = location.directions.includes('up') ? 'up' : null; + const downDir = location.directions.includes('down') ? 'down' : null; // Priority: Inside/Outside (usually mutually exclusive) > Enter/Exit - const centerDirection = insideDir || outsideDir || enterDir || exitDir; + const centerDirection = insideDir || outsideDir || enterDir || exitDir || upDir || downDir; if (!centerDirection) { // Default Compass Icon @@ -136,6 +138,8 @@ function MovementControls({ let icon = '🚪'; if (centerDirection === 'inside') icon = '🏠'; if (centerDirection === 'outside') icon = '🌳'; + if (centerDirection === 'up') icon = '⬆️'; + if (centerDirection === 'down') icon = '⬇️'; const tooltipText = profile?.is_dead ? t('messages.youAreDead') : movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : @@ -191,44 +195,6 @@ function MovementControls({ {renderCompassButton('southeast', '↘️', 'se')}
- {(location.directions.includes('up') || location.directions.includes('down')) && ( -
- {location.directions.includes('up') && ( - 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : ( -
-
{t('directions.up')}
-
⚡ {t('game.stamina')}: {getStaminaCost('up')}
-
- )}> - -
- )} - {location.directions.includes('down') && ( - 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : ( -
-
{t('directions.down')}
-
⚡ {t('game.stamina')}: {getStaminaCost('down')}
-
- )}> - -
- )} -
- )}
{/* Surroundings - outside movement controls */} diff --git a/pwa/src/components/game/PlayerSidebar.tsx b/pwa/src/components/game/PlayerSidebar.tsx index 41c865d..3c29d14 100644 --- a/pwa/src/components/game/PlayerSidebar.tsx +++ b/pwa/src/components/game/PlayerSidebar.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useGame } from '../../contexts/GameContext' import { useTranslation } from 'react-i18next' import type { PlayerState, Profile, Equipment } from './types' import { getAssetPath } from '../../utils/assetPath' @@ -7,6 +8,8 @@ import InventoryModal from './InventoryModal' import { GameProgressBar } from '../common/GameProgressBar' import { GameTooltip } from '../common/GameTooltip' import { GameButton } from '../common/GameButton' +import { GameItemCard } from '../common/GameItemCard' +import { GameDropdown } from '../common/GameDropdown' import { useAudio } from '../../contexts/AudioContext' interface PlayerSidebarProps { @@ -43,107 +46,63 @@ function PlayerSidebar({ onOpenQuestJournal }: PlayerSidebarProps) { const [showInventory, setShowInventory] = useState(false) + const [activeSlot, setActiveSlot] = useState(null) const { t } = useTranslation() const { playSfx } = useAudio() + const { state } = useGame() // Use global state to check quests + + // Check if any quest is ready to turn in + const hasReadyQuests = state.quests.active?.some((q: any) => { + // Check if all objectives met + if (!q.objectives) return false; + return q.objectives.every((obj: any) => { + const current = q.progress?.[obj.target] || 0; + return current >= obj.count; + }); + }); const renderEquipmentSlot = (slot: string, item: any, label: string) => { - // Construct the tooltip content if item exists - const tooltipContent = item ? ( -
-
- {getTranslatedText(item.name)} -
- {item.tier !== undefined && item.tier !== null && item.tier > 0 && ( -
- ⭐ Tier: {item.tier} -
- )} - {item.description &&
{getTranslatedText(item.description)}
} - {(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && ( -
- {(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) && ( -
- {t('stats.hp')}: +{item.unique_stats?.hp_max || item.stats?.hp_max} -
- )} - {(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) && ( -
- {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) && ( -
- {t('stats.weight')}: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg -
- )} - {(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && ( -
- {t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L -
- )} -
- )} - {item.durability !== undefined && item.durability !== null && ( -
-
- {t('stats.durability')}: - {item.durability}/{item.max_durability} -
- -
- )} -
- ) : label; // Show label if no item + // Merge with full inventory data to ensure tooltips have weight/volume + const fullItemInfo = playerState.inventory?.find((i: any) => i.is_equipped && i.equipment_slot === slot) || item; return (
{item ? ( <> - - - - -
- {item.image_path ? ( - {getTranslatedText(item.name)} - ) : ( - {item.emoji} - )} - {item.durability !== undefined && item.durability !== null && ( -
- -
- )} -
-
+ { + e.preventDefault(); + e.stopPropagation(); + // Toggle active slot + setActiveSlot(activeSlot === slot ? null : slot); + }} + isActive={activeSlot === slot} + className="equipment-item-content" + style={{ width: '100%', height: '100%' }} + /> + {activeSlot === slot && ( + setActiveSlot(null)} width="160px"> +
+ {getTranslatedText(item.name)} +
+ { + e.stopPropagation(); + setActiveSlot(null); + onUnequipItem(slot); + playSfx('/audio/sfx/unequip.wav'); + }} + style={{ width: '100%', justifyContent: 'flex-start' }} + > + {t('game.unequip')} + +
+ )} ) : ( @@ -305,13 +264,13 @@ function PlayerSidebar({ - 📜 {t('common.quests')} + {hasReadyQuests ? '❗ ' : '📜 '}{t('common.quests')}
diff --git a/pwa/src/components/game/QuestJournal.css b/pwa/src/components/game/QuestJournal.css index d9b9c40..783595d 100644 --- a/pwa/src/components/game/QuestJournal.css +++ b/pwa/src/components/game/QuestJournal.css @@ -1,66 +1,37 @@ -.quest-journal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.8); - z-index: 1000; +.quest-journal-modal { + width: 90vw; + max-width: 1200px; + height: 95%; +} + +.quest-journal-modal .game-modal-content { display: flex; - align-items: center; - justify-content: center; -} - -.journal-container { - background: rgba(20, 20, 20, 0.95); - border: 1px solid #444; - border-radius: 8px; - padding: 20px; - max-width: 800px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - color: #e0e0e0; - position: relative; -} - -.journal-title { - color: #ff9800; - border-bottom: 2px solid #555; - padding-bottom: 10px; - margin-top: 0; -} - -.journal-close-btn { - position: absolute; - top: 15px; - right: 15px; - background: none; - border: none; - color: #888; - font-size: 1.5rem; - cursor: pointer; -} - -.journal-close-btn:hover { - color: #fff; + flex-direction: column; + height: 100%; + padding: 0; + overflow: hidden; + /* Manage scroll internally */ } +/* Tabs - matching Workbench style but full width split */ .tab-container { display: flex; - margin-bottom: 20px; - border-bottom: 1px solid #444; + gap: 10px; } .journal-tab { + flex: 1; background: transparent; - border: none; - border-bottom: 3px solid transparent; - color: #aaa; - padding: 10px 20px; + border: 1px solid transparent; + color: #a0aec0; + padding: 10px; cursor: pointer; - font-size: 1rem; + font-weight: 600; transition: all 0.2s; + clip-path: var(--game-clip-path); + text-align: center; + border-bottom: none; + /* Override old */ } .journal-tab:hover { @@ -69,78 +40,389 @@ } .journal-tab.active { - background: rgba(255, 152, 0, 0.2); - border-bottom: 3px solid #ff9800; - color: #ff9800; + background: #3182ce; + color: #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + border-color: transparent; + /* Override old */ } -.quest-list { +/* Main Layout */ +.journal-layout { + display: flex; + flex-direction: row; + height: 100%; + overflow: hidden; +} + +/* Quest List Column */ +.quest-list-column { + width: 40%; + min-width: 300px; + border-right: 1px solid var(--game-border-color); display: flex; flex-direction: column; - gap: 15px; + background: rgba(0, 0, 0, 0.2); + position: relative; } -.quest-card { - background: rgba(0, 0, 0, 0.3); - border: 1px solid #555; - border-radius: 5px; +/* Search Bar (Game Style) - Removed (using Game.css global) */ +.game-search-container { + margin: 10px; +} + +/* Quest List Area */ +.quest-list-scroll { + flex: 1; + overflow-y: auto; + padding: 10px; + padding-bottom: 60px; +} + +/* Quest List Cards */ +.quest-list-item { padding: 15px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + margin-bottom: 10px; + cursor: pointer; + transition: all 0.2s; + position: relative; + clip-path: var(--game-clip-path); + display: flex; + flex-direction: column; + gap: 5px; } -.quest-card.completed { - border-color: #4caf50; +.quest-list-item:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); } -.quest-card h3 { - margin: 0 0 5px 0; - color: #ddd; +.quest-list-item.selected { + border-color: var(--game-color-primary); + box-shadow: 0 0 0 1px var(--game-color-primary); +} + +.quest-list-item h4 { + margin: 0; + font-size: 1.1rem; + color: #eee; + font-weight: 600; +} + +.quest-card-type { + align-self: flex-start; + font-size: 0.75rem; + padding: 2px 8px; + clip-path: var(--game-clip-path); + background: rgba(0, 0, 0, 0.3); + color: #aaa; + border: 1px solid rgba(255, 255, 255, 0.1); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.quest-list-item.selected h4 { + color: var(--game-color-primary); +} + +.quest-status-indicator { + position: absolute; + top: 10px; + right: 10px; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-active { + background-color: #2196f3; + box-shadow: 0 0 5px #2196f3; +} + +.status-completed { + background-color: #4caf50; + box-shadow: 0 0 5px #4caf50; +} + +/* Pagination - Sticky at Bottom */ +.pagination-controls { display: flex; justify-content: space-between; + align-items: center; + padding: 10px 15px; + border-top: 1px solid var(--game-border-color); + background: rgba(18, 18, 18, 0.95); + position: sticky; + bottom: 0; + z-index: 10; + margin-top: auto; } -.quest-card.completed h3 { - color: #4caf50; +/* Right Column: Quest Details */ +.quest-details-column { + flex: 1; + padding: 30px; + overflow-y: auto; + background: rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; } -.quest-desc { +.quest-details-header { + margin-bottom: 25px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 20px; +} + +.quest-details-header h2 { + margin: 0; + font-size: 2rem; + color: var(--game-color-primary); + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.quest-giver-info { + margin-top: 15px; + display: flex; + gap: 15px; + align-items: flex-start; + background: rgba(0, 0, 0, 0.2); + padding: 10px; + clip-path: var(--game-clip-path); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.quest-giver-image { + width: 100px; + height: 100px; + clip-path: var(--game-clip-path); + object-fit: cover; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + background: var(--game-bg-app) +} + +.quest-giver-details { + display: flex; + flex-direction: column; + gap: 4px; font-size: 0.9rem; - color: #ccc; - margin-bottom: 10px; - font-style: italic; } -.objective-list { +.label { + color: #888; + margin-right: 5px; +} + +.value { + color: #ddd; + font-weight: 500; +} + +.quest-description { + font-size: 1.05rem; + line-height: 1.7; + color: #e0e0e0; + margin-bottom: 30px; + white-space: pre-wrap; + background: rgba(0, 0, 0, 0.1); + padding: 15px; + clip-path: var(--game-clip-path); + border-left: 2px solid var(--game-color-primary); +} + +.quest-section-title { + font-size: 0.9rem; + color: #aaa; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 15px; + margin-top: 25px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 5px; + display: inline-block; +} + +.objective-list, +.rewards-list { list-style: none; padding: 0; - margin: 10px 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; } .objective-item { - color: #aaa; - margin-bottom: 4px; + padding: 12px 15px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + color: #ccc; + clip-path: var(--game-clip-path); display: flex; align-items: center; } -.objective-item.met { - color: #8bc34a; -} - -.objective-item:before { - content: '○'; - margin-right: 8px; +.objective-item::before { + content: "○"; + margin-right: 10px; + color: #aaa; font-weight: bold; - color: #777; } -.objective-item.met:before { - content: '✓'; - color: #8bc34a; +.objective-item.met { + background: rgba(76, 175, 80, 0.1); + border-color: rgba(76, 175, 80, 0.3); + color: #a5d6a7; + text-decoration: none; } -.empty-message { - text-align: center; - padding: 40px; - color: #777; +.objective-item.met::before { + content: "✓"; + color: #4caf50; +} + +.rewards-list li { + padding: 10px; + color: #ffd700; + background: rgba(255, 215, 0, 0.05); + border: 1px solid rgba(255, 215, 0, 0.2); + clip-path: var(--game-clip-path); + display: flex; + align-items: center; +} + +.rewards-list li::before { + content: "🎁"; + margin-right: 10px; +} + +.history-dates { + margin-top: auto; + padding-top: 30px; + font-size: 0.85rem; + color: #666; + border-top: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + gap: 5px; +} + +.completion-text { font-style: italic; + color: #b0bec5; + padding: 15px; + background: rgba(255, 255, 255, 0.05); + border-left: 2px solid #b0bec5; + clip-path: var(--game-clip-path); + margin-bottom: 20px; +} + +.empty-selection { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #666; + font-size: 1.2rem; + font-style: italic; + background: repeating-linear-gradient(45deg, + rgba(0, 0, 0, 0.1), + rgba(0, 0, 0, 0.1) 10px, + rgba(0, 0, 0, 0.15) 10px, + rgba(0, 0, 0, 0.15) 20px); +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Global Quest Styles */ +.objective-item.global-objective { + flex-direction: column; + align-items: stretch; + gap: 8px; + background: rgba(33, 150, 243, 0.1); + border-color: rgba(33, 150, 243, 0.3); + padding-bottom: 12px; +} + +.objective-item.global-objective::before { + content: none; +} + +.objective-label { + display: flex; + justify-content: space-between; + align-items: center; + color: #fff; + margin-bottom: 4px; +} + +.completed-badge { + font-size: 0.8rem; + color: #4caf50; + background: rgba(76, 175, 80, 0.1); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid rgba(76, 175, 80, 0.3); +} + +.global-progress-container { + background: rgba(0, 0, 0, 0.3); + padding: 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.progress-label { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + color: #aaa; + margin-bottom: 6px; +} + +.progress-bar-bg { + height: 8px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; +} + +.progress-bar-fill.global { + height: 100%; + background: linear-gradient(90deg, #2196f3, #64b5f6); + box-shadow: 0 0 10px rgba(33, 150, 243, 0.5); + transition: width 0.5s ease-out; +} + +.personal-contribution { + font-size: 0.85rem; + color: #ccc; + text-align: right; + margin-top: 4px; +} + +.contribution-value { + color: #ffd700; + font-weight: bold; } \ No newline at end of file diff --git a/pwa/src/components/game/QuestJournal.tsx b/pwa/src/components/game/QuestJournal.tsx index 570da1d..4f161ba 100644 --- a/pwa/src/components/game/QuestJournal.tsx +++ b/pwa/src/components/game/QuestJournal.tsx @@ -1,7 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useGame } from '../../contexts/GameContext'; import { GameModal } from './GameModal'; +import { GameButton } from '../common/GameButton'; +import { useNotification } from '../../contexts/NotificationContext'; import './QuestJournal.css'; +import axios from 'axios'; interface Quest { quest_id: string; @@ -14,18 +17,76 @@ interface Quest { type: string; completion_text?: { [key: string]: string } | string; completed_at?: number; + started_at?: number; + giver_name?: { [key: string]: string } | string; + giver_location_id?: string; + giver_location_name?: { [key: string]: string } | string; + giver_image?: string; } interface QuestJournalProps { onClose: () => void; } +import { GAME_API_URL } from '../../config'; +import { useTranslation } from 'react-i18next'; + export const QuestJournal: React.FC = ({ onClose }) => { - const { locale, state } = useGame(); // Use global state + const { t } = useTranslation(); + const { locale, state } = useGame(); + const { addNotification } = useNotification(); + + // Tabs const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active'); - // Derived from global state - const quests = (state.quests.active || []) as Quest[]; + // Selection - now using a composite key + const [selectedQuestKey, setSelectedQuestKey] = useState(null); + + // Search + const [searchQuery, setSearchQuery] = useState(''); + + // Data + const [activeQuests, setActiveQuests] = useState([]); + const [historyQuests, setHistoryQuests] = useState([]); + + // Pagination for History + const [historyPage, setHistoryPage] = useState(1); + const [historyTotalPages, setHistoryTotalPages] = useState(1); + const [loadingHistory, setLoadingHistory] = useState(false); + + // Initial Load of Active Quests from Game State + useEffect(() => { + if (state.quests && state.quests.active) { + setActiveQuests(state.quests.active as Quest[]); + } + }, [state.quests]); + + // Fetch History when tab changes to completed or page changes + useEffect(() => { + if (activeTab === 'completed') { + fetchHistory(historyPage); + } + }, [activeTab, historyPage]); + + const fetchHistory = async (page: number) => { + setLoadingHistory(true); + try { + const token = localStorage.getItem('token'); + const response = await axios.get(`${GAME_API_URL}/quests/history`, { + params: { page, limit: 10 }, // 10 per page for better UI fit + headers: { Authorization: `Bearer ${token}` } + }); + + setHistoryQuests(response.data.data); + setHistoryTotalPages(response.data.pages); + setHistoryPage(response.data.page); + } catch (error) { + console.error("Failed to fetch quest history", error); + addNotification(t('common.error'), "error"); + } finally { + setLoadingHistory(false); + } + }; const getLocalizedText = (textObj: any) => { if (typeof textObj === 'string') return textObj; @@ -33,25 +94,111 @@ export const QuestJournal: React.FC = ({ onClose }) => { return textObj[locale] || textObj['en'] || Object.values(textObj)[0] || ''; }; - const filteredQuests = quests.filter((q: Quest) => { - if (activeTab === 'active') { - return q.status === 'active'; + // Filter Logic + const getFilteredQuests = () => { + const source = activeTab === 'active' ? activeQuests : historyQuests; + if (!searchQuery) return source; + return source.filter(q => { + const title = getLocalizedText(q.title).toLowerCase(); + return title.includes(searchQuery.toLowerCase()); + }); + }; + + const getQuestKey = (quest: Quest) => { + return quest.quest_id + (quest.completed_at ? `_${quest.completed_at}` : `_${quest.started_at || ''}`); + }; + + const filteredQuests = getFilteredQuests(); + const selectedQuest = filteredQuests.find(q => getQuestKey(q) === selectedQuestKey) || (filteredQuests.length > 0 ? filteredQuests[0] : null); + + // Automatically select first if selection is invalid/null or tab changes + useEffect(() => { + if (filteredQuests.length > 0) { + const currentKeyValid = selectedQuestKey && filteredQuests.some(q => getQuestKey(q) === selectedQuestKey); + if (!currentKeyValid) { + setSelectedQuestKey(getQuestKey(filteredQuests[0])); + } } else { - return q.status === 'completed'; + setSelectedQuestKey(null); } - }); + }, [filteredQuests, activeTab]); // Re-run when list changes or tab changes + + // Renderers const renderObjectives = (quest: Quest) => { - return quest.objectives.map((obj, idx) => { - const current = quest.progress[obj.target] || 0; - const required = obj.count; - const met = current >= required; - let label = obj.target; + if (!quest.objectives) return null; - if (obj.type === 'kill_count') { - label = `Kill ${obj.target}`; - } else if (obj.type === 'item_delivery') { - label = `Deliver ${obj.target}`; + // GLOBAL QUEST RENDERING + if (quest.type === 'global') { + return quest.objectives.map((obj, idx) => { + const required = obj.count; + // Personal Progress + const personalCurrent = quest.progress?.[obj.target] || 0; + + // Global Progress + // @ts-ignore - dynamic field + const globalProgressMap = quest.global_progress || {}; + const globalCurrent = globalProgressMap[obj.target] || 0; + const isGlobalComplete = (quest as any).global_is_completed || globalCurrent >= required; + + let label = obj.target; + if (obj.target_name) { + const targetName = getLocalizedText(obj.target_name); + label = targetName; // usually simplified for global counters? e.g. "Wood" + } + + return ( +
  • +
    + {label} + {isGlobalComplete ? ✅ {t('common.completed')} : null} +
    + + {/* Global Progress Bar */} +
    +
    + {t('journal.communityProgress')} + {globalCurrent} / {required} +
    +
    +
    +
    +
    + + {/* Personal Contribution */} +
    + {t('journal.yourContribution')}: + {personalCurrent} +
    +
  • + ); + }); + } + + // STANDARD QUEST RENDERING + return quest.objectives.map((obj, idx) => { + const required = obj.count; + // Force completed count for history items to avoid confusing 0/X display + const isCompleted = quest.status === 'completed' || activeTab === 'completed'; + const current = isCompleted ? required : (quest.progress?.[obj.target] || 0); + const met = current >= required || isCompleted; + + let label = obj.target; + // Improved translation logic + if (obj.target_name) { + // If we have an enriched name, use it. + // But we still want the prefix "Fight" or "Pick Up" if applicable + const targetName = getLocalizedText(obj.target_name); + if (obj.type === 'kill_count') label = `${t('game.kill')} ${targetName}`; + else if (obj.type === 'item_delivery') label = `${t('game.pickUp')} ${targetName}`; + else label = targetName; + } else { + // Fallback to basic translation if no enriched name + if (obj.type === 'kill_count') label = `${t('game.kill')} ${obj.target}`; + else if (obj.type === 'item_delivery') label = `${t('game.pickUp')} ${obj.target}`; } return ( @@ -62,59 +209,196 @@ export const QuestJournal: React.FC = ({ onClose }) => { }); }; + const renderDate = (timestamp?: number) => { + if (!timestamp) return 'N/A'; + return new Date(timestamp * 1000).toLocaleString(locale === 'en' ? 'en-US' : locale); + }; + return ( + > +
    + {/* Header / Tabs */} +
    - } - > -
    -
    - {filteredQuests.length === 0 ? ( -
    No quests found in this category.
    - ) : ( - filteredQuests.map((quest: Quest) => ( -
    -

    - {getLocalizedText(quest.title)} - {quest.type === 'global' && GLOBAL} -

    -
    {getLocalizedText(quest.description)}
    - {quest.status === 'active' && ( -
      - {renderObjectives(quest)} -
    - )} + {/* Main Content Area */} +
    + {/* LEFT COLUMN: LIST */} +
    +
    + 🔍 + setSearchQuery(e.target.value)} + /> +
    - {quest.status === 'completed' && quest.completion_text && ( -
    - "{getLocalizedText(quest.completion_text)}" -
    - )} +
    + {filteredQuests.length === 0 ? ( +
    + {loadingHistory ? t('common.loading') : t('journal.noQuests')} +
    + ) : ( + filteredQuests.map(quest => { + const key = getQuestKey(quest); + return ( +
    setSelectedQuestKey(key)} + > +

    {getLocalizedText(quest.title)}

    + + {quest.type === 'global' ? t('journal.global') : (quest.type === 'story' ? t('journal.story') : t('journal.side'))} + +
    +
    + ); + }) + )} +
    + + {/* Pagination Controls for History */} + {activeTab === 'completed' && ( +
    + setHistoryPage(p => p - 1)} + > + « {t('common.prev')} + + + {loadingHistory ? '...' : `${historyPage} / ${historyTotalPages}`} + + = historyTotalPages || loadingHistory} + onClick={() => setHistoryPage(p => p + 1)} + > + {t('common.next')} » +
    - )) - )} + )} +
    + + {/* RIGHT COLUMN: DETAILS */} +
    + {selectedQuest ? ( + <> +
    +
    +

    {getLocalizedText(selectedQuest.title)}

    + {activeTab === 'active' && selectedQuest.status === 'completed' && ( + {t('journal.ready')} + )} +
    + + {/* Subtitle removed to avoid redundancy as requested */} + + {/* Giver Info */} + {selectedQuest.giver_name && ( +
    + {selectedQuest.giver_image && ( + Giver + )} +
    +
    + {t('journal.giver')}: + {getLocalizedText(selectedQuest.giver_name)} +
    + {(selectedQuest.giver_location_name || selectedQuest.giver_location_id) && ( +
    + {t('journal.location')}: + + {selectedQuest.giver_location_name + ? getLocalizedText(selectedQuest.giver_location_name) + : selectedQuest.giver_location_id + } + +
    + )} +
    +
    + )} +
    + +
    + {getLocalizedText(selectedQuest.description)} +
    + + {/* Objectives - Show for both active and completed */} +
    {t('journal.objectives')}
    +
      + {renderObjectives(selectedQuest)} +
    + + {selectedQuest.status === 'completed' && selectedQuest.completion_text && ( + <> +
    {t('journal.completionMessage')}
    +
    + "{getLocalizedText(selectedQuest.completion_text)}" +
    + + )} + + {selectedQuest.rewards && ( + <> +
    {t('journal.rewards')}
    +
      + {selectedQuest.rewards.xp &&
    • {selectedQuest.rewards.xp} {t('stats.xp')}
    • } + {(selectedQuest as any).reward_items_details ? + Object.values((selectedQuest as any).reward_items_details).map((item: any, idx) => ( +
    • {getLocalizedText(item.name)} x{item.qty}
    • + )) + : + selectedQuest.rewards.items && Object.entries(selectedQuest.rewards.items).map(([id, qty]) => ( +
    • {id} x{qty as any}
    • + )) + } +
    + + )} + +
    + {/* Show accepted date for both active and completed quests if available */} + {selectedQuest.started_at &&
    {t('journal.accepted')}: {renderDate(selectedQuest.started_at)}
    } + {activeTab === 'completed' && selectedQuest.completed_at &&
    {t('journal.completed')}: {renderDate(selectedQuest.completed_at)}
    } +
    + + ) : ( +
    {t('journal.selectQuest')}
    + )} +
    - + ); }; diff --git a/pwa/src/components/game/TradeModal.css b/pwa/src/components/game/TradeModal.css index 38a9328..c11979f 100644 --- a/pwa/src/components/game/TradeModal.css +++ b/pwa/src/components/game/TradeModal.css @@ -3,7 +3,8 @@ .game-modal-container.trade-modal { max-width: 1400px; width: 95vw; - height: 90vh; + max-height: 90%; + height: 90%; } .trade-modal .game-modal-content { @@ -50,30 +51,33 @@ } .search-bar { - margin-bottom: 0.5rem; - padding: 0.75rem 1rem; - background: rgba(0, 0, 0, 0.5); - border: 1px solid #555; - color: white; width: 100%; + margin-bottom: 0.5rem; + padding: 0.8rem 1rem 0.8rem 2.5rem; + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--game-border-color); + color: var(--game-text-primary); + font-family: var(--game-font-main); + font-size: 0.95rem; + clip-path: var(--game-clip-path-sm); + transition: all 0.2s ease; box-sizing: border-box; - /* Fixes cut-off issue */ - clip-path: var(--game-clip-path-sm, polygon(0 0, - 100% 0, - 100% calc(100% - 5px), - calc(100% - 5px) 100%, - 0 100%)); } -.inventory-grid { +.search-bar:focus { + outline: none; + border-color: #6bb9f0; + background: rgba(0, 0, 0, 0.6); + box-shadow: 0 0 10px rgba(107, 185, 240, 0.2); +} + +.trade-inventory-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); grid-auto-rows: max-content; /* Ensure rows don't stretch */ + grid-template-columns: repeat(auto-fill, 90px); + justify-content: center; gap: 0.5rem; - overflow-y: auto; - grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); - gap: 0.75rem; padding: 0.5rem; overflow-y: auto; } @@ -91,6 +95,10 @@ justify-content: center; padding: 0.5rem; box-shadow: var(--game-shadow-sm); + width: 90px; + height: 90px; + box-sizing: border-box; + flex-shrink: 0; } .trade-item-card:hover { @@ -139,30 +147,10 @@ max-height: 100%; object-fit: contain; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); + z-index: 1; } -/* Exact match for quantity badge from InventoryModal.css */ -.trade-item-qty { - position: absolute; - bottom: 2px; - right: 2px; - background: var(--game-bg-panel); - /* Match source */ - border: 1px solid var(--game-border-color); - /* Match source */ - color: var(--game-text-primary); - /* Match source */ - font-size: 0.7rem; - /* Match source grid adjustment */ - padding: 1px 4px; - /* Match source grid adjustment */ - clip-path: var(--game-clip-path-sm); - /* Match source */ - font-weight: bold; - box-shadow: var(--game-shadow-sm); - /* Match source */ - pointer-events: none; -} + .trade-item-value { position: absolute; @@ -210,9 +198,10 @@ .cart-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); + grid-template-columns: repeat(auto-fill, 90px); + justify-content: center; grid-auto-rows: max-content; - gap: 8px; + gap: 0.5rem; overflow-y: auto; padding-right: 5px; margin-top: 10px; @@ -313,4 +302,5 @@ background: #1a202c; border: 1px solid #4a5568; color: white; + border-radius: 0; } \ No newline at end of file diff --git a/pwa/src/components/game/TradeModal.tsx b/pwa/src/components/game/TradeModal.tsx index dec53e9..84c58d7 100644 --- a/pwa/src/components/game/TradeModal.tsx +++ b/pwa/src/components/game/TradeModal.tsx @@ -3,9 +3,9 @@ import { useGame } from '../../contexts/GameContext'; import { GAME_API_URL } from '../../config'; import { GameModal } from './GameModal'; import { GameButton } from '../common/GameButton'; -import { GameTooltip } from '../common/GameTooltip'; -import { getAssetPath } from '../../utils/assetPath'; +import { GameItemCard } from '../common/GameItemCard'; import { getTranslatedText } from '../../utils/i18nUtils'; +import { useTranslation } from 'react-i18next'; import './TradeModal.css'; interface TradeItem { @@ -51,6 +51,7 @@ interface TradeModalProps { export const TradeModal: React.FC = ({ npcId, onClose }) => { const { token, inventory: playerInv } = useGame(); + const { t } = useTranslation(); const [npcStock, setNpcStock] = useState([]); const [playerItems, setPlayerItems] = useState([]); @@ -64,6 +65,19 @@ export const TradeModal: React.FC = ({ npcId, onClose }) => { const [npcSearch, setNpcSearch] = useState(''); const [playerSearch, setPlayerSearch] = useState(''); + const categories = [ + { id: 'all', label: t('categories.all'), icon: '🎒' }, + { id: 'weapon', label: t('categories.weapon'), icon: '⚔️' }, + { id: 'armor', label: t('categories.armor'), icon: '🛡️' }, + { id: 'clothing', label: t('categories.clothing'), icon: '👕' }, + { id: 'backpack', label: t('categories.backpack'), icon: '🎒' }, + { id: 'tool', label: t('categories.tool'), icon: '🛠️' }, + { id: 'consumable', label: t('categories.consumable'), icon: '🍖' }, + { id: 'resource', label: t('categories.resource'), icon: '📦' }, + { id: 'quest', label: t('categories.quest'), icon: '📜' }, + { id: 'misc', label: t('categories.misc'), icon: '📦' } + ]; + // Selection logic const [selectedItem, setSelectedItem] = useState(null); const [showQtyModal, setShowQtyModal] = useState(false); @@ -138,6 +152,10 @@ export const TradeModal: React.FC = ({ npcId, onClose }) => { // Filter by search const n = getTranslatedText(item.name).toLowerCase(); return n.includes(npcSearch.toLowerCase()); + }).sort((a: any, b: any) => { + // High tier first, then name + if ((a.tier || 0) !== (b.tier || 0)) return (b.tier || 0) - (a.tier || 0); + return (getTranslatedText(a.name) || '').localeCompare(getTranslatedText(b.name) || ''); }); }, [npcStock, npcSearch, buying]); @@ -156,6 +174,10 @@ export const TradeModal: React.FC = ({ npcId, onClose }) => { if (item._displayQuantity <= 0) return false; if (item.is_equipped) return false; // Usually can't sell equipped items directly return true; + }).sort((a: any, b: any) => { + // High tier first, then name + if ((a.tier || 0) !== (b.tier || 0)) return (b.tier || 0) - (a.tier || 0); + return (getTranslatedText(a.name) || '').localeCompare(getTranslatedText(b.name) || ''); }); }, [playerItems, playerSearch, selling]); @@ -253,14 +275,46 @@ export const TradeModal: React.FC = ({ npcId, onClose }) => { } }; - // Tooltip Renderer (Reusable) - REMOVED as we use inline now to match InventoryModal structure better + // Drag and Drop Logic + const [dragOverZone, setDragOverZone] = useState<'buy' | 'sell' | null>(null); + const handleDragStart = (e: React.DragEvent, item: TradeItem, source: 'npc' | 'player') => { + e.dataTransfer.setData('application/json', JSON.stringify({ item, source })); + e.dataTransfer.effectAllowed = 'copy'; + }; + + const handleDragOver = (e: React.DragEvent, zone: 'buy' | 'sell') => { + e.preventDefault(); + if (dragOverZone !== zone) setDragOverZone(zone); + }; + + const handleDragLeave = () => { + setDragOverZone(null); + }; + + const handleDrop = (e: React.DragEvent, zone: 'buy' | 'sell') => { + e.preventDefault(); + setDragOverZone(null); + + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')); + const { item, source } = data; + + if (zone === 'buy' && source === 'npc') { + handleItemClick(item, 'npc'); + } else if (zone === 'sell' && source === 'player') { + handleItemClick(item, 'player'); + } + } catch (err) { + console.error('Failed to parse drag data', err); + } + }; if (!npcStock || !tradeConfig) return
    Loading trade data...
    ; return ( @@ -268,85 +322,40 @@ export const TradeModal: React.FC = ({ npcId, onClose }) => {
    {/* LEFT: NPC STOCK */}
    -

    Merchant Stock {tradeConfig.buy_markup && (x{tradeConfig.buy_markup})}

    - setNpcSearch(e.target.value)} - /> -
    - {availableNpcStock.map((item, idx) => { - // Prepare tooltip content matching InventoryModal - const tooltipContent = ( -
    -
    - {item.emoji} {getTranslatedText(item.name)} -
    - {item.description &&
    {getTranslatedText(item.description)}
    } - -
    -
    💰 {Math.round(item.value * (tradeConfig.buy_markup || 1))}
    - {item.weight !== undefined &&
    ⚖️ {item.weight}kg
    } - {item.volume !== undefined &&
    📦 {item.volume}L
    } -
    - -
    - {/* Capacity */} - {(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && ( - - ⚖️ +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg - - )} - {(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && ( - - 📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L - - )} - {/* Combat Stats */} - {(item.unique_stats?.damage_min || item.stats?.damage_min) && ( - - ⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} - - )} - {(item.unique_stats?.armor || item.stats?.armor) && ( - - 🛡️ +{item.unique_stats?.armor || item.stats?.armor} - - )} - {/* Consumables */} - {item.hp_restore && ( - - ❤️ +{item.hp_restore} HP - - )} - {item.stamina_restore && ( - - ⚡ +{item.stamina_restore} Stm - - )} -
    -
    - ); - +

    {t('trade.merchantStock')} {tradeConfig.buy_markup && (x{tradeConfig.buy_markup})}

    +
    + 🔍 + setNpcSearch(e.target.value)} + /> +
    +
    + {categories.filter(cat => cat.id !== 'all').map(cat => { + const categoryItems = availableNpcStock.filter((item: any) => item.item_type === cat.id); + if (categoryItems.length === 0) return null; return ( - -
    handleItemClick(item, 'npc')}> -
    - {item.image_path ? ( - {getTranslatedText(item.name)} - ) : ( -
    {item.emoji || '📦'}
    - )} -
    - - {(item.is_infinite || (item as any)._displayQuantity > 1) && ( -
    {item.is_infinite ? '∞' : `x${(item as any)._displayQuantity}`}
    - )} -
    {Math.round(item.value * (tradeConfig.buy_markup || 1))}
    + +
    + {cat.icon} + {cat.label}
    - + {categoryItems.map((item, idx) => ( + handleItemClick(item, 'npc')} + draggable={true} + onDragStart={(e) => handleDragStart(e, item, 'npc')} + showValue={true} + valueDisplayType="unit" + tradeMarkup={tradeConfig.buy_markup || 1} + /> + ))} +
    ); })}
    @@ -354,52 +363,68 @@ export const TradeModal: React.FC = ({ npcId, onClose }) => { {/* CENTER: CART */}
    -
    +
    handleDragOver(e, 'buy')} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, 'buy')} + >
    - Buying + {t('trade.buying')} {Math.round(buyTotal)}
    - {buying.length === 0 &&
    Empty
    } + {buying.length === 0 &&
    {t('trade.empty')}
    } {buying.map((b, i) => ( - {getTranslatedText(b.name)}
    x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}
    }> -
    { + { const n = [...buying]; n.splice(i, 1); setBuying(n); - }}> - {b.image_path ? ( - {getTranslatedText(b.name)} - ) : ( -
    {b.emoji || '📦'}
    - )} -
    x{b.quantity}
    -
    {Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}
    -
    - + }} + showValue={true} + valueDisplayType="total" + tradeMarkup={tradeConfig.buy_markup || 1} + actionHint={t('trade.clickToRemove')} + /> ))}
    -
    + {/* BALANCE INDICATOR MOVED TO CENTER DIVIDER */} +
    +
    + {t('trade.balance')}: + = buyTotal ? 'text-green' : 'text-red'}`}> + {Math.round(sellTotal - buyTotal)} {sellTotal >= buyTotal ? '▲' : '▼'} + +
    +
    + +
    handleDragOver(e, 'sell')} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, 'sell')} + >
    - Selling + {t('trade.selling')} {Math.round(sellTotal)}
    - {selling.length === 0 &&
    Empty
    } + {selling.length === 0 &&
    {t('trade.empty')}
    } {selling.map((b, i) => ( - {getTranslatedText(b.name)}
    x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}
    }> -
    { + { const n = [...selling]; n.splice(i, 1); setSelling(n); - }}> - {b.image_path ? ( - {getTranslatedText(b.name)} - ) : ( -
    {b.emoji || '📦'}
    - )} -
    x{b.quantity}
    -
    {Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}
    -
    - + }} + showValue={true} + valueDisplayType="total" + tradeMarkup={tradeConfig.sell_markdown || 1} + actionHint={t('trade.clickToRemove')} + /> ))}
    @@ -407,51 +432,40 @@ export const TradeModal: React.FC = ({ npcId, onClose }) => { {/* RIGHT: PLAYER INVENTORY */}
    -

    Inventory {tradeConfig.sell_markdown && (x{tradeConfig.sell_markdown})}

    - setPlayerSearch(e.target.value)} - /> -
    - {availablePlayerInv.map((item, idx) => { - const tooltipContent = ( -
    -
    - {item.emoji} {getTranslatedText(item.name)} -
    - {item.description &&
    {getTranslatedText(item.description)}
    } - -
    -
    💰 {Math.round(item.value * (tradeConfig.sell_markdown || 1))}
    -
    -
    - {/* Same badges logic could be extracted but duplicating for speed/safety */} - {(item.unique_stats?.damage_min || item.stats?.damage_min) && ( - - ⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max} - - )} - {item.hp_restore && ❤️ +{item.hp_restore} HP} -
    -
    - ); +

    {t('trade.inventory')} {tradeConfig.sell_markdown && (x{tradeConfig.sell_markdown})}

    +
    + 🔍 + setPlayerSearch(e.target.value)} + /> +
    +
    + {categories.filter(cat => cat.id !== 'all').map(cat => { + const categoryItems = availablePlayerInv.filter((item: any) => item.item_type === cat.id); + if (categoryItems.length === 0) return null; return ( - -
    handleItemClick(item, 'player')}> -
    - {item.image_path ? ( - {getTranslatedText(item.name)} - ) : ( -
    {item.emoji || '📦'}
    - )} -
    - {(item as any)._displayQuantity > 1 &&
    x{(item as any)._displayQuantity}
    } -
    {Math.round(item.value * (tradeConfig.sell_markdown || 1))}
    + +
    + {cat.icon} + {cat.label}
    - + {categoryItems.map((item, idx) => ( + handleItemClick(item, 'player')} + draggable={true} + onDragStart={(e) => handleDragStart(e, item, 'player')} + showValue={true} + valueDisplayType="unit" + tradeMarkup={tradeConfig.sell_markdown || 1} + /> + ))} +
    ); })}
    @@ -459,46 +473,49 @@ export const TradeModal: React.FC = ({ npcId, onClose }) => {
    -
    - Balance - = buyTotal ? 'text-green' : 'text-red'}`}> - {Math.round(sellTotal - buyTotal)} - -
    - -
    {/* Spacer */}
    - {showQtyModal && selectedItem && ( -
    -
    -

    How many {getTranslatedText(selectedItem.name)}?

    -
    - setQtyInput(Math.max(1, qtyInput - 1))}>- - setQtyInput(parseInt(e.target.value) || 1)} - min="1" - /> - setQtyInput(qtyInput + 1)}>+ - { - const max = (selectedItem as any)._displayQuantity || 1; - setQtyInput(max); - }}>Max -
    -
    - Confirm - setShowQtyModal(false)}>Cancel + {showQtyModal && selectedItem && (() => { + const maxAvailable = (selectedItem as any)._displayQuantity || 1; + return ( +
    +
    +

    {t('trade.howMany', { item: getTranslatedText(selectedItem.name) })}

    +
    + +
    +
    + setQtyInput(Math.max(1, qtyInput - 1))} disabled={qtyInput <= 1}>- + { + const val = parseInt(e.target.value); + if (isNaN(val)) { + setQtyInput(1); + } else { + setQtyInput(Math.min(Math.max(1, val), maxAvailable)); + } + }} + min="1" + max={maxAvailable} + /> + setQtyInput(Math.min(maxAvailable, qtyInput + 1))} disabled={qtyInput >= maxAvailable}>+ + setQtyInput(maxAvailable)}>Max +
    +
    + Confirm + setShowQtyModal(false)}>Cancel +
    -
    - )} + ); + })()}
    ); diff --git a/pwa/src/components/game/Workbench.css b/pwa/src/components/game/Workbench.css index 1ba51b0..0bbe863 100644 --- a/pwa/src/components/game/Workbench.css +++ b/pwa/src/components/game/Workbench.css @@ -13,19 +13,39 @@ backdrop-filter: blur(4px); } -.workbench-menu { +/* Specific Override for GameModal container when used as Workbench */ +.game-modal-container.workbench-modal { width: 95vw; max-width: 1400px; - height: 85vh; + height: 90%; + max-height: 90%; background: var(--game-bg-modal); border: 1px solid var(--game-border-color); - display: flex; - flex-direction: column; box-shadow: var(--game-shadow-modal); - overflow: hidden; color: var(--game-text-primary); font-family: var(--game-font-main); - clip-path: var(--game-clip-path); +} + +.game-modal-container.workbench-modal .game-modal-content { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.workbench-menu { + /* Legacy class support or internal structure if needed, but GameModal is the container */ + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.workbench-menu-content { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; } .workbench-header { @@ -46,6 +66,15 @@ gap: 0.5rem; } +.workbench-header-tabs { + display: flex; + gap: 0.5rem; + padding: 1rem; + background: var(--game-bg-panel); + border-bottom: 1px solid var(--game-border-color); + flex-shrink: 0; +} + .workbench-tabs { display: flex; gap: 0.5rem; diff --git a/pwa/src/components/game/Workbench.tsx b/pwa/src/components/game/Workbench.tsx index 9209e72..c613571 100644 --- a/pwa/src/components/game/Workbench.tsx +++ b/pwa/src/components/game/Workbench.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, type MouseEvent, type ChangeEvent } from 'react' +import { useState, useEffect, 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 { GameModal } from './GameModal' import { GameButton } from '../common/GameButton' import './Workbench.css' @@ -476,33 +477,31 @@ function Workbench({ ] return ( -
    ) => { - if (e.target === e.currentTarget) onCloseCrafting() - }}> -
    -
    -

    {t('game.workbench')}

    -
    - - - -
    - + +
    +
    + + +
    @@ -678,7 +677,7 @@ function Workbench({
    -
    + ) } diff --git a/pwa/src/components/game/hooks/useGameEngine.ts b/pwa/src/components/game/hooks/useGameEngine.ts index bd24669..e2c3474 100644 --- a/pwa/src/components/game/hooks/useGameEngine.ts +++ b/pwa/src/components/game/hooks/useGameEngine.ts @@ -234,7 +234,7 @@ export function useGameEngine( const addLocationMessage = useCallback((msg: string) => { const now = new Date() const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - const locationName = location?.name ? (typeof location.name === 'string' ? location.name : location.name.en || Object.values(location.name)[0]) : '' + const locationName = location?.name || '' setLocationMessages((prev: LocationMessage[]) => { const newMessages = [...prev, { time: timeStr, message: msg, location_name: locationName }] diff --git a/pwa/src/components/game/types.ts b/pwa/src/components/game/types.ts index d22cd1c..711d90e 100644 --- a/pwa/src/components/game/types.ts +++ b/pwa/src/components/game/types.ts @@ -77,7 +77,7 @@ export interface CombatLogEntry { export interface LocationMessage { time: string message: string - location_name?: string + location_name?: string | { [key: string]: string } } export interface Equipment { diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json index 3d4101c..f643e4b 100644 --- a/pwa/src/i18n/locales/en.json +++ b/pwa/src/i18n/locales/en.json @@ -11,6 +11,10 @@ "game": "Game", "leaderboards": "Leaderboards", "account": "Account", + "accountSettings": "Account Settings", + "general": "General", + "audio": "Audio", + "security": "Security", "info": "Info", "talk": "Talk", "loot": "Loot", @@ -23,7 +27,10 @@ "enemy": "Enemy", "you": "You", "quests": "Quests", - "all": "All" + "all": "All", + "prev": "Prev", + "next": "Next", + "back": "Back" }, "auth": { "login": "Login", @@ -40,25 +47,88 @@ "loginTitle": "Welcome Back", "registerTitle": "Create Account", "loginSubtitle": "Sign in to continue your journey", - "registerSubtitle": "Join the survivors" + "registerSubtitle": "Join the survivors", + "confirmPassword": "Confirm Password", + "emailPlaceholder": "your.email@example.com", + "passwordPlaceholder": "At least 6 characters", + "passwordPlaceholderLogin": "Your password", + "confirmPasswordPlaceholder": "Re-enter your password", + "submit": "Create Account", + "submitting": "Creating Account...", + "loggingIn": "Logging in...", + "loginLink": "Already have an account? Login", + "registerLink": "Don't have an account? Register", + "errors": { + "invalidEmail": "Please enter a valid email address", + "passwordLength": "Password must be at least 6 characters", + "passwordMatch": "Passwords do not match", + "registrationFailed": "Registration failed", + "loginFailed": "Login failed" + }, + "strength": { + "weak": "Weak", + "medium": "Medium", + "strong": "Strong" + }, + "accountInfo": "Account Information", + "accountType": "Account Type", + "premiumStatus": "Premium Status", + "premiumActive": "✓ Premium Active", + "freeAccount": "Free Account", + "created": "Created", + "lastLogin": "Last Login", + "gameActions": "Game Actions", + "switchCharacter": "Switch Character", + "audioSettings": "Audio Settings", + "volumeControls": "Volume Controls", + "muteAll": "Mute All", + "masterVolume": "Master Volume", + "musicVolume": "Music Volume", + "sfxVolume": "SFX Volume", + "securitySettings": "Security Settings", + "changeEmail": "Change Email", + "changePassword": "Change Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "verifyIdentity": "Verify your identity", + "updateEmail": "Update Email", + "updatePassword": "Update Password", + "updating": "Updating...", + "change": "Change", + "cancel": "Cancel" }, "characters": { "title": "Select Character", "createNew": "Create New Character", "play": "Play", "delete": "Delete", + "deleteModal": { + "title": "Delete Character", + "confirm": "Are you sure you want to delete this character? This action cannot be undone." + }, "noCharacters": "No characters yet", "createFirst": "Create your first character to begin", "name": "Character Name", "class": "Class", - "level": "Level" + "level": "Level", + "lastActive": "Last active", + "create": { + "title": "Create New", + "slots": "slots used" + }, + "premium": { + "title": "Character Limit Reached", + "description": "Upgrade to Premium to create up to 10 characters!", + "upgrade": "Upgrade to Premium - $4.99" + } }, "game": { "travel": "🧭 Travel", "surroundings": "🌿 Surroundings", "character": "👤 Character", "equipment": "⚔️ Equipment", - "inventory": "🎒 Open Inventory", + "inventory": "🎒 Inventory", "workbench": "🔧 Workbench", "craft": "🔨 Craft", "repair": "🛠️ Repair", @@ -69,6 +139,7 @@ "equip": "Equip", "unequip": "Unequip", "attack": "⚔️ Attack", + "kill": "Kill", "flee": "🏃 Flee", "rest": "Rest", "onlineCount": "{{count}} Online", @@ -92,7 +163,33 @@ "poisoned": "Poisoned" }, "effectAlreadyActive": "Effect already active", - "found": "Found" + "found": "Found", + "value": "Value", + "dialog": { + "back": "Back", + "goodbye": "Goodbye", + "trade": "Trade", + "acceptQuest": "Accept Quest", + "completeQuest": "Complete Quest", + "handInItems": "Hand In Items", + "questInProgress": "(Quest in progress...)", + "questAccepted": "Quest accepted! Good luck.", + "progressUpdated": "Progress updated.", + "rewards": "Rewards" + } + }, + "trade": { + "title": "Trade", + "buying": "Buying", + "selling": "Selling", + "merchantStock": "Merchant Stock", + "inventory": "Inventory", + "invalidOffer": "INVALID OFFER", + "confirmTrade": "CONFIRM TRADE", + "balance": "Balance", + "empty": "Empty", + "clickToRemove": "Click to remove", + "howMany": "How many {{item}}?" }, "location": { "recentActivity": "📜 Recent Activity", @@ -315,7 +412,11 @@ "staminaRegenerated": "Stamina regenerated", "combatTimeout": "⏱️ Turn skipped due to timeout!", "interactableReady": "{{action}} is ready on {{name}}", - "waitBeforeMovingSimple": "Wait {{seconds}}s before moving" + "waitBeforeMovingSimple": "Wait {{seconds}}s before moving", + "questAccepted": "Quest Accepted!", + "questCompleted": "Quest Completed!", + "questProgressUpdated": "Quest Progress Updated", + "failedToAcceptQuest": "Failed to accept quest" }, "directions": { "north": "North", @@ -337,6 +438,114 @@ "heroTitle": "Echoes of the Ash", "heroSubtitle": "A post-apocalyptic survival RPG", "playNow": "Play Now", - "features": "Features" + "login": "Login", + "features": "Game Features", + "about": { + "title": "About the World", + "description": "In a world ravaged by nuclear fire, survival is the only law. Scavenge for resources, craft distinct weapons, and fight for your life against mutants and other survivors. Will you rebuild civilization or rule over the ashes?" + }, + "featureCards": { + "survival": { + "title": "Hardcore Survival", + "description": "Survive in a harsh environment filled with radiation and deadly enemies. Manage your inventory and resources wisely." + }, + "combat": { + "title": "Tactical Combat", + "description": "Engage in turn-based battles where every decision counts. Use skills, items, and strategy." + }, + "crafting": { + "title": "Deep Crafting", + "description": "Scavenge materials to create weapons, armor, and tools essential for your survival." + } + }, + "footer": { + "copyright": "© {{year}} Echoes of the Ash. All rights reserved.", + "links": { + "privacy": "Privacy Policy", + "terms": "Terms of Service", + "discord": "Join Discord" + } + } + }, + "journal": { + "title": "Quest Journal", + "activeQuests": "Active Quests", + "history": "History", + "searchPlaceholder": "Search quests...", + "noQuests": "No quests found.", + "objectives": "Objectives", + "completionMessage": "Completion Message", + "rewards": "Rewards", + "accepted": "Accepted", + "completed": "Completed", + "type": "Type", + "status": "Status", + "ready": "READY", + "giver": "Giver", + "location": "Location", + "inProgress": "In Progress", + "story": "Story", + "side": "Side", + "daily": "Daily", + "global": "Global", + "selectQuest": "Select a quest to view details", + "communityProgress": "Global progress", + "yourContribution": "Your contribution" + }, + "legal": { + "privacy": { + "title": "Privacy Policy", + "lastUpdated": "Last updated: February 16, 2026", + "sections": { + "1": { + "title": "1. Information We Collect", + "content": "We collect your email address and game-related data (character progress, inventory, etc.) to provide the service." + }, + "2": { + "title": "2. How We Use Information", + "content": "We use your information to manage your account, save your game progress, and improve the game experience. We do not sell your data to third parties." + }, + "3": { + "title": "3. Data Security", + "content": "We implement security measures to maintain the safety of your personal information. However, no method of transmission over the Internet is 100% secure." + }, + "4": { + "title": "4. Cookies", + "content": "We use local storage to maintain your login session and game settings." + }, + "5": { + "title": "5. Contact", + "content": "If you have questions about this privacy policy, please contact us via our Discord community." + } + }, + "back": "Return to Home" + }, + "terms": { + "title": "Terms of Service", + "lastUpdated": "Last updated: February 16, 2026", + "sections": { + "1": { + "title": "1. Acceptance of Terms", + "content": "By accessing and using Echoes of the Ash, you accept and agree to be bound by the terms and provision of this agreement." + }, + "2": { + "title": "2. Game Rules", + "content": "Users agree not to exploit bugs, use automation software (bots), or engage in harassment of other players. We reserve the right to ban accounts that violate these rules." + }, + "3": { + "title": "3. User Accounts", + "content": "You are responsible for maintaining the confidentiality of your account and password. You agree to accept responsibility for all activities that occur under your account." + }, + "4": { + "title": "4. Virtual Items", + "content": "Virtual items and currency in Echoes of the Ash have no real-world value and cannot be exchanged for real currency." + }, + "5": { + "title": "5. Disclaimer", + "content": "The game is provided \"as is\" without warranties of any kind. We are not responsible for any data loss or service interruptions." + } + }, + "back": "Return to Home" + } } } \ No newline at end of file diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json index ec59456..46a0bdf 100644 --- a/pwa/src/i18n/locales/es.json +++ b/pwa/src/i18n/locales/es.json @@ -11,6 +11,10 @@ "game": "Juego", "leaderboards": "Clasificación", "account": "Cuenta", + "accountSettings": "Ajustes de Cuenta", + "general": "General", + "audio": "Audio", + "security": "Seguridad", "info": "Info", "talk": "Hablar", "loot": "Saquear", @@ -21,7 +25,10 @@ "pickUpAll": "Recoger Todo", "qty": "Cant", "quests": "Misiones", - "all": "Todo" + "all": "Todo", + "prev": "Ant", + "next": "Sig", + "back": "Volver" }, "auth": { "login": "Iniciar sesión", @@ -38,25 +45,88 @@ "loginTitle": "Bienvenido de nuevo", "registerTitle": "Crear cuenta", "loginSubtitle": "Inicia sesión para continuar tu viaje", - "registerSubtitle": "Únete a los supervivientes" + "registerSubtitle": "Únete a los supervivientes", + "confirmPassword": "Confirmar Contraseña", + "emailPlaceholder": "tu.email@ejemplo.com", + "passwordPlaceholder": "Al menos 6 caracteres", + "passwordPlaceholderLogin": "Tu contraseña", + "confirmPasswordPlaceholder": "Reingresa tu contraseña", + "submit": "Crear Cuenta", + "submitting": "Creando Cuenta...", + "loggingIn": "Iniciando sesión...", + "loginLink": "¿Ya tienes una cuenta? Inicia sesión", + "registerLink": "¿No tienes una cuenta? Regístrate", + "errors": { + "invalidEmail": "Por favor ingresa un correo electrónico válido", + "passwordLength": "La contraseña debe tener al menos 6 caracteres", + "passwordMatch": "Las contraseñas no coinciden", + "registrationFailed": "Error en el registro", + "loginFailed": "Error al iniciar sesión" + }, + "strength": { + "weak": "Débil", + "medium": "Media", + "strong": "Fuerte" + }, + "accountInfo": "Información de la Cuenta", + "accountType": "Tipo de Cuenta", + "premiumStatus": "Estado Premium", + "premiumActive": "✓ Premium Activo", + "freeAccount": "Cuenta Gratuita", + "created": "Creado", + "lastLogin": "Último Acceso", + "gameActions": "Acciones de Juego", + "switchCharacter": "Cambiar Personaje", + "audioSettings": "Ajustes de Audio", + "volumeControls": "Controles de Volumen", + "muteAll": "Silenciar Todo", + "masterVolume": "Volumen Principal", + "musicVolume": "Música", + "sfxVolume": "Efectos", + "securitySettings": "Ajustes de Seguridad", + "changeEmail": "Cambiar Correo", + "changePassword": "Cambiar Contraseña", + "currentPassword": "Contraseña Actual", + "newPassword": "Nueva Contraseña", + "confirmNewPassword": "Confirmar Nueva Contraseña", + "verifyIdentity": "Verifica tu identidad", + "updateEmail": "Actualizar Correo", + "updatePassword": "Actualizar Contraseña", + "updating": "Actualizando...", + "change": "Cambiar", + "cancel": "Cancelar" }, "characters": { "title": "Seleccionar Personaje", "createNew": "Crear Nuevo Personaje", "play": "Jugar", "delete": "Eliminar", + "deleteModal": { + "title": "Eliminar Personaje", + "confirm": "¿Estás seguro de que quieres eliminar este personaje? Esta acción no se puede deshacer." + }, "noCharacters": "Aún no hay personajes", "createFirst": "Crea tu primer personaje para comenzar", "name": "Nombre del Personaje", "class": "Clase", - "level": "Nivel" + "level": "Nivel", + "lastActive": "Última actividad", + "create": { + "title": "Crear Nuevo", + "slots": "ranuras usadas" + }, + "premium": { + "title": "Límite de Personajes", + "description": "¡Mejora a Premium para crear hasta 10 personajes!", + "upgrade": "Mejorar a Premium - $4.99" + } }, "game": { "travel": "🧭 Viajar", "surroundings": "🌿 Alrededores", "character": "👤 Personaje", "equipment": "⚔️ Equipamiento", - "inventory": "🎒 Abrir Inventario", + "inventory": "🎒 Inventario", "workbench": "🔧 Banco de Trabajo", "craft": "🔨 Fabricar", "repair": "🛠️ Reparar", @@ -67,6 +137,7 @@ "equip": "Equipar", "unequip": "Desequipar", "attack": "⚔️ Atacar", + "kill": "Mata", "flee": "🏃 Huir", "rest": "Descansar", "onlineCount": "{{count}} En línea", @@ -90,7 +161,33 @@ "poisoned": "Envenenamiento" }, "effectAlreadyActive": "Efecto ya activo", - "found": "Encontrado" + "found": "Encontrado", + "value": "Valor", + "dialog": { + "back": "Atrás", + "goodbye": "Adiós", + "trade": "Comerciar", + "acceptQuest": "Aceptar Misión", + "completeQuest": "Completar Misión", + "handInItems": "Entregar Objetos", + "questInProgress": "(Misión en progreso...)", + "questAccepted": "¡Misión aceptada! Buena suerte.", + "progressUpdated": "Progreso actualizado.", + "rewards": "Recompensas" + } + }, + "trade": { + "title": "Comercio", + "buying": "Comprando", + "selling": "Vendiendo", + "merchantStock": "Inventario del Mercader", + "inventory": "Inventario", + "invalidOffer": "OFERTA INVÁLIDA", + "confirmTrade": "CONFIRMAR COMERCIO", + "balance": "Balance", + "empty": "Vacío", + "clickToRemove": "Haz clic para remover", + "howMany": "¿Cuántos {{item}}?" }, "location": { "recentActivity": "📜 Actividad Reciente", @@ -313,7 +410,11 @@ "staminaRegenerated": "Estamina regenerada", "combatTimeout": "⏱️ ¡Turno saltado por tiempo agotado!", "interactableReady": "{{action}} está listo en {{name}}", - "waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte" + "waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte", + "questAccepted": "¡Misión Aceptada!", + "questCompleted": "¡Misión Completada!", + "questProgressUpdated": "Progreso de Misión Actualizado", + "failedToAcceptQuest": "Error al aceptar la misión" }, "directions": { "north": "Norte", @@ -332,9 +433,117 @@ "exit": "Salir" }, "landing": { - "heroTitle": "Ecos de las Cenizas", + "heroTitle": "Echoes of the Ash", "heroSubtitle": "Un RPG de supervivencia post-apocalíptico", "playNow": "Jugar Ahora", - "features": "Características" + "login": "Iniciar Sesión", + "features": "Características del Juego", + "about": { + "title": "Sobre el Mundo", + "description": "En un mundo devastado por el fuego nuclear, la supervivencia es la única ley. Busca recursos, fabrica armas distintas y lucha por tu vida contra mutantes y otros supervivientes. ¿Reconstruirás la civilización o reinarás sobre las cenizas?" + }, + "featureCards": { + "survival": { + "title": "Supervivencia Extrema", + "description": "Sobrevive en un entorno hostil lleno de radiación y enemigos mortales. Gestiona tu inventario y recursos sabiamente." + }, + "combat": { + "title": "Combate Táctico", + "description": "Participa en batallas por turnos donde cada decisión cuenta. Usa habilidades, objetos y estrategia." + }, + "crafting": { + "title": "Fabricación Profunda", + "description": "Busca materiales para crear armas, armaduras y herramientas esenciales para tu supervivencia." + } + }, + "footer": { + "copyright": "© {{year}} Echoes of the Ash. Todos los derechos reservados.", + "links": { + "privacy": "Política de Privacidad", + "terms": "Términos de Servicio", + "discord": "Unirse a Discord" + } + } + }, + "journal": { + "title": "Diario de Misiones", + "activeQuests": "Misiones Activas", + "history": "Historial", + "searchPlaceholder": "Buscar misiones...", + "noQuests": "No se encontraron misiones.", + "objectives": "Objetivos", + "completionMessage": "Mensaje de Completado", + "rewards": "Recompensas", + "accepted": "Aceptado", + "completed": "Completado", + "type": "Tipo", + "status": "Estado", + "ready": "LISTO", + "giver": "Dador", + "location": "Ubicación", + "inProgress": "En Progreso", + "story": "Historia", + "side": "Secundaria", + "daily": "Diaria", + "global": "Global", + "selectQuest": "Selecciona una misión para ver detalles", + "communityProgress": "Progreso global", + "yourContribution": "Tu contribución" + }, + "legal": { + "privacy": { + "title": "Política de Privacidad", + "lastUpdated": "Última actualización: 16 de Febrero de 2026", + "sections": { + "1": { + "title": "1. Información que Recopilamos", + "content": "Recopilamos tu dirección de correo electrónico y datos relacionados con el juego (progreso del personaje, inventario, etc.) para proporcionar el servicio." + }, + "2": { + "title": "2. Cómo Usamos la Información", + "content": "Usamos tu información para administrar tu cuenta, guardar tu progreso y mejorar la experiencia de juego. No vendemos tus datos a terceros." + }, + "3": { + "title": "3. Seguridad de Datos", + "content": "Implementamos medidas de seguridad para mantener a salvo tu información personal. Sin embargo, ningún método de transmisión por Internet es 100% seguro." + }, + "4": { + "title": "4. Cookies", + "content": "Usamos almacenamiento local para mantener tu sesión de inicio y configuraciones del juego." + }, + "5": { + "title": "5. Contacto", + "content": "Si tienes preguntas sobre esta política de privacidad, contáctanos a través de nuestra comunidad de Discord." + } + }, + "back": "Volver al Inicio" + }, + "terms": { + "title": "Términos de Servicio", + "lastUpdated": "Última actualización: 16 de Febrero de 2026", + "sections": { + "1": { + "title": "1. Aceptación de Términos", + "content": "Al acceder y usar Echoes of the Ash, aceptas y acuerdas estar sujeto a los términos y disposiciones de este acuerdo." + }, + "2": { + "title": "2. Reglas del Juego", + "content": "Los usuarios acuerdan no explotar errores, usar software de automatización (bots) o participar en el acoso de otros jugadores. Nos reservamos el derecho de prohibir cuentas que violen estas reglas." + }, + "3": { + "title": "3. Cuentas de Usuario", + "content": "Eres responsable de mantener la confidencialidad de tu cuenta y contraseña. Aceptas la responsabilidad de todas las actividades que ocurran bajo tu cuenta." + }, + "4": { + "title": "4. Objetos Virtuales", + "content": "Los objetos virtuales y la moneda en Echoes of the Ash no tienen valor en el mundo real y no pueden intercambiarse por moneda real." + }, + "5": { + "title": "5. Descargo de Responsabilidad", + "content": "El juego se proporciona \"tal cual\" sin garantías de ningún tipo. No somos responsables de ninguna pérdida de datos o interrupciones del servicio." + } + }, + "back": "Volver al Inicio" + } } } \ No newline at end of file diff --git a/pwa/src/index.css b/pwa/src/index.css index 213a05f..cc09a98 100644 --- a/pwa/src/index.css +++ b/pwa/src/index.css @@ -87,20 +87,6 @@ /* Default (1080p and below) */ } -@media (min-width: 2200px) { - :root { - --location-content-width: 1000px; - /* 1440p */ - } -} - -@media (min-width: 3400px) { - :root { - --location-content-width: 1400px; - /* 4K */ - } -} - /* --- Reusable Game Classes --- */ /* Panels */ diff --git a/pwa/src/services/api.ts b/pwa/src/services/api.ts index 2c83243..8133649 100644 --- a/pwa/src/services/api.ts +++ b/pwa/src/services/api.ts @@ -56,6 +56,10 @@ export interface Character { is_dead: boolean created_at: string last_played_at: string + weight: number + max_weight: number + volume: number + max_volume: number } export interface LoginResponse { diff --git a/pwa/vite.config.ts b/pwa/vite.config.ts index 0e1017a..c8d2662 100644 --- a/pwa/vite.config.ts +++ b/pwa/vite.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ theme_color: '#1a1a1a', background_color: '#1a1a1a', display: 'standalone', - orientation: 'portrait', + orientation: 'landscape', scope: '/', start_url: '/', icons: [