WIP: Current state before PVP combat investigation

This commit is contained in:
Joan
2026-02-03 12:19:28 +01:00
parent 7f42fd6b7f
commit 0b0a23f500
36 changed files with 2423 additions and 1472 deletions

View File

@@ -2190,3 +2190,128 @@ async def remove_expired_dropped_items(timestamp_limit: float) -> int:
result = await session.execute(stmt)
await session.commit()
return result.rowcount
# ============================================================================
# PVP COMBAT FUNCTIONS
# ============================================================================
async def get_pvp_combat_by_id(combat_id: int) -> Optional[Dict[str, Any]]:
"""Get PVP combat by ID."""
async with DatabaseSession() as session:
stmt = select(pvp_combats).where(pvp_combats.c.id == combat_id)
result = await session.execute(stmt)
row = result.first()
return dict(row._mapping) if row else None
async def get_pvp_combat_by_player(character_id: int) -> Optional[Dict[str, Any]]:
"""Get active PVP combat for a player (either as attacker or defender)."""
async with DatabaseSession() as session:
stmt = select(pvp_combats).where(
and_(
or_(
pvp_combats.c.attacker_character_id == character_id,
pvp_combats.c.defender_character_id == character_id
),
# If acknowledged by both, it's effectively over for query purposes
# But here we want the active one.
# Logic: If I am attacker, and I haven't acknowledged => active
# If I am defender, and I haven't acknowledged => active
# Simplified: Just return the record, caller handles logic.
)
)
result = await session.execute(stmt)
# There should only be one active combat at a time per player
row = result.first()
return dict(row._mapping) if row else None
async def create_pvp_combat(
attacker_id: int,
defender_id: int,
location_id: str,
turn_timeout: int = 300
) -> Dict[str, Any]:
"""Create a new PVP combat encounter."""
import time
async with DatabaseSession() as session:
current_time = time.time()
# Get names for denormalization
attacker_res = await session.execute(select(characters.c.name).where(characters.c.id == attacker_id))
defender_res = await session.execute(select(characters.c.name).where(characters.c.id == defender_id))
attacker_name = attacker_res.scalar() or "Unknown"
defender_name = defender_res.scalar() or "Unknown"
stmt = insert(pvp_combats).values(
attacker_character_id=attacker_id,
defender_character_id=defender_id,
attacker_name=attacker_name,
defender_name=defender_name,
location_id=location_id,
started_at=current_time,
updated_at=current_time,
turn='defender', # Defender goes first usually, or random? 'initiator pays price?'
# Requirement says: "You have initiated combat... They get the first turn."
turn_started_at=current_time,
turn_timeout_seconds=turn_timeout,
attacker_acknowledged=False,
defender_acknowledged=False
).returning(pvp_combats)
result = await session.execute(stmt)
await session.commit()
return dict(result.fetchone()._mapping)
async def update_pvp_combat(combat_id: int, updates: Dict[str, Any]) -> bool:
"""Update PVP combat state."""
import time
updates['updated_at'] = time.time()
async with DatabaseSession() as session:
stmt = update(pvp_combats).where(
pvp_combats.c.id == combat_id
).values(**updates)
await session.execute(stmt)
await session.commit()
return True
async def acknowledge_pvp_combat(combat_id: int, player_id: int) -> bool:
"""Player acknowledges combat end."""
async with DatabaseSession() as session:
# First check who this player is
stmt = select(pvp_combats).where(pvp_combats.c.id == combat_id)
result = await session.execute(stmt)
combat = result.first()
if not combat:
return False
updates = {}
if combat.attacker_character_id == player_id:
updates['attacker_acknowledged'] = True
elif combat.defender_character_id == player_id:
updates['defender_acknowledged'] = True
else:
return False
stmt = update(pvp_combats).where(
pvp_combats.c.id == combat_id
).values(**updates)
await session.execute(stmt)
# Check if both acknowledged, then delete?
# Or just keep it. We have acknowledge flags.
# If both acknowledged, maybe delete to clean up?
# Let's check updated flags
if (updates.get('attacker_acknowledged') or combat.attacker_acknowledged) and \
(updates.get('defender_acknowledged') or combat.defender_acknowledged):
delete_stmt = delete(pvp_combats).where(pvp_combats.c.id == combat_id)
await session.execute(delete_stmt)
await session.commit()
return True

View File

@@ -6,7 +6,7 @@ import random
import time
from typing import Dict, Any, Tuple, Optional, List
from . import database as db
from .services.helpers import get_locale_string, translate_travel_message, create_combat_message
from .services.helpers import get_locale_string, translate_travel_message, create_combat_message, get_game_message
async def move_player(player_id: int, direction: str, locations: Dict, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]:
@@ -67,7 +67,7 @@ async def move_player(player_id: int, direction: str, locations: Dict, locale: s
# Check stamina
if player['stamina'] < stamina_cost:
return False, "You're too exhausted to move. Wait for your stamina to regenerate.", None, 0, 0
return False, get_game_message('exhausted_move', locale), None, 0, 0
# Update player location and stamina
await db.update_character(
@@ -81,7 +81,7 @@ async def move_player(player_id: int, direction: str, locations: Dict, locale: s
return True, travel_message, new_location_id, stamina_cost, distance
async def inspect_area(player_id: int, location, interactables_data: Dict) -> str:
async def inspect_area(player_id: int, location, interactables_data: Dict, locale: str = 'en') -> str:
"""
Inspect the current area and return detailed information.
Returns formatted text with interactables and their actions.
@@ -92,18 +92,18 @@ async def inspect_area(player_id: int, location, interactables_data: Dict) -> st
# Check if player has enough stamina
if player['stamina'] < 1:
return "You're too exhausted to inspect the area thoroughly. Wait for your stamina to regenerate."
return get_game_message('exhausted_inspect', locale)
# Deduct stamina
await db.update_player_stamina(player_id, player['stamina'] - 1)
# Build inspection message
lines = [f"🔍 **Inspecting {location.name}**\n"]
lines = [get_game_message('inspecting_title', locale, name=location.name)]
lines.append(location.description)
lines.append("")
if location.interactables:
lines.append("**Interactables:**")
lines.append(get_game_message('interactables_title', locale))
for interactable in location.interactables:
lines.append(f"• **{interactable.name}**")
if interactable.actions:
@@ -112,13 +112,13 @@ async def inspect_area(player_id: int, location, interactables_data: Dict) -> st
lines.append("")
if location.npcs:
lines.append(f"**NPCs:** {', '.join(location.npcs)}")
lines.append(f"{get_game_message('npcs_title', locale)} {', '.join(location.npcs)}")
lines.append("")
# Check for dropped items
dropped_items = await db.get_dropped_items(location.id)
if dropped_items:
lines.append("**Items on ground:**")
lines.append(get_game_message('items_ground_title', locale))
for item in dropped_items:
lines.append(f"{item['item_id']} x{item['quantity']}")
@@ -130,7 +130,8 @@ async def interact_with_object(
interactable_id: str,
action_id: str,
location,
items_manager
items_manager,
locale: str = 'en'
) -> Dict[str, Any]:
"""
Interact with an object using a specific action.
@@ -148,7 +149,7 @@ async def interact_with_object(
break
if not interactable:
return {"success": False, "message": "Object not found"}
return {"success": False, "message": get_game_message('object_not_found', locale)}
# Find the action
action = None
@@ -158,13 +159,13 @@ async def interact_with_object(
break
if not action:
return {"success": False, "message": "Action not found"}
return {"success": False, "message": get_game_message('action_not_found', locale)}
# Check stamina
if player['stamina'] < action.stamina_cost:
return {
"success": False,
"message": f"Not enough stamina. Need {action.stamina_cost}, have {player['stamina']}."
"message": get_game_message('not_enough_stamina', locale, cost=action.stamina_cost, current=player['stamina'])
}
# Check cooldown for this specific action
@@ -173,7 +174,7 @@ async def interact_with_object(
remaining = int(cooldown_expiry - time.time())
return {
"success": False,
"message": f"This action is still on cooldown. Wait {remaining} seconds."
"message": get_game_message('cooldown_wait', locale, seconds=remaining)
}
# Deduct stamina
@@ -199,7 +200,7 @@ async def interact_with_object(
if not outcome:
return {
"success": False,
"message": "Action has no defined outcomes"
"message": get_game_message('action_no_outcomes', locale)
}
# Process outcome
@@ -219,7 +220,7 @@ async def interact_with_object(
if not item:
continue
item_name = get_locale_string(item.name) if item else item_id
item_name = get_locale_string(item.name, locale) if item else item_id
emoji = item.emoji if item and hasattr(item, 'emoji') else ''
# Check if item has durability (unique item)
@@ -240,7 +241,7 @@ async def interact_with_object(
max_durability=item.durability,
tier=getattr(item, 'tier', None)
)
items_found.append(f"{emoji} {get_locale_string(item_name)}")
items_found.append(f"{emoji} {item_name}")
current_weight += item.weight
current_volume += item.volume
else:
@@ -255,7 +256,7 @@ async def interact_with_object(
unique_stats=base_stats
)
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
items_dropped.append(f"{emoji} {get_locale_string(item_name)}")
items_dropped.append(f"{emoji} {item_name}")
else:
# Stackable items - process as before
item_weight = item.weight * quantity
@@ -265,13 +266,13 @@ async def interact_with_object(
current_volume + item_volume <= max_volume):
# Add to inventory
await db.add_item_to_inventory(player_id, item_id, quantity)
items_found.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
items_found.append(f"{emoji} {item_name} x{quantity}")
current_weight += item_weight
current_volume += item_volume
else:
# Drop to ground
await db.drop_item_to_world(item_id, quantity, player['location_id'])
items_dropped.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
items_dropped.append(f"{emoji} {item_name} x{quantity}")
# Apply damage
if damage_taken > 0:
@@ -286,9 +287,9 @@ async def interact_with_object(
await db.set_interactable_cooldown(interactable_id, action_id, 60)
# Build message
final_message = get_locale_string(outcome.text)
final_message = get_locale_string(outcome.text, locale)
if items_dropped:
final_message += f"\n⚠️ Inventory full! Dropped to ground: {', '.join(items_dropped)}"
final_message += f"\n⚠️ {get_game_message('inventory_full', locale)}! {get_game_message('dropped_to_ground', locale)}: {', '.join(items_dropped)}"
return {
"success": True,
@@ -302,7 +303,7 @@ async def interact_with_object(
}
async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any]:
async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'en') -> Dict[str, Any]:
"""
Use an item from inventory.
Returns: {success, message, effects}
@@ -320,7 +321,7 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
break
if not item_entry:
return {"success": False, "message": "You don't have this item"}
return {"success": False, "message": get_game_message('no_item', locale)}
# Get item data
item = items_manager.get_item(item_id)
@@ -328,7 +329,7 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
return {"success": False, "message": "Item not found in game data"}
if not item.consumable:
return {"success": False, "message": "This item cannot be used"}
return {"success": False, "message": get_game_message('cannot_use', locale)}
# Apply item effects
effects = {}
@@ -366,7 +367,7 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
await db.update_player_statistics(player_id, **stat_updates)
# Build message
msg = f"Used {item.name}"
msg = f"{get_game_message('item_used', locale, name=get_locale_string(item.name, locale))}"
if effects_msg:
msg += f" ({', '.join(effects_msg)})"
@@ -377,7 +378,7 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
}
async def pickup_item(player_id: int, item_id: int, location_id: str, quantity: int = None, items_manager=None) -> Dict[str, Any]:
async def pickup_item(player_id: int, item_id: int, location_id: str, quantity: int = None, items_manager=None, locale: str = 'en') -> Dict[str, Any]:
"""
Pick up an item from the ground.
item_id is the dropped_item id, not the item_id field.
@@ -389,7 +390,7 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
dropped_item = await db.get_dropped_item(item_id)
if not dropped_item:
return {"success": False, "message": "Item not found on ground"}
return {"success": False, "message": get_game_message('item_not_found_ground', locale)}
# Get item definition
item_def = items_manager.get_item(dropped_item['item_id']) if items_manager else None
@@ -402,7 +403,7 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
pickup_qty = available_qty
else:
if quantity < 1:
return {"success": False, "message": "Invalid quantity"}
return {"success": False, "message": get_game_message('invalid_quantity', locale)}
pickup_qty = quantity
# Get player and calculate capacity
@@ -423,13 +424,13 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
if new_weight > max_weight:
return {
"success": False,
"message": f"⚠️ Item too heavy! {item_def.emoji} {item_def.name} x{pickup_qty} ({item_weight:.1f}kg) would exceed capacity. Current: {current_weight:.1f}/{max_weight:.1f}kg"
"message": get_game_message('item_too_heavy', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=pickup_qty, weight=item_weight, current=current_weight, max=max_weight)
}
if new_volume > max_volume:
return {
"success": False,
"message": f"⚠️ Item too large! {item_def.emoji} {item_def.name} x{pickup_qty} ({item_volume:.1f}L) would exceed capacity. Current: {current_volume:.1f}/{max_volume:.1f}L"
"message": get_game_message('item_too_large', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=pickup_qty, volume=item_volume, current=current_volume, max=max_volume)
}
# Items fit - update dropped item quantity or remove it
@@ -449,7 +450,7 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
return {
"success": True,
"message": f"Picked up {item_def.emoji} {item_def.name} x{pickup_qty}"
"message": f"{get_game_message('picked_up', locale)} {item_def.emoji} {get_locale_string(item_def.name, locale)} x{pickup_qty}"
}
@@ -538,19 +539,16 @@ def generate_npc_intent(npc_def, combat_state: dict) -> dict:
return intent
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[str, bool]:
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[List[dict], bool]:
"""
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
Returns: (messages_list, player_defeated)
"""
player = await db.get_player_by_id(player_id)
if not player:
return "Player not found", True
return [], True
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
# For now, let's assume simple string "attack", "defend", "special" stored in npc_intent
# If we want more complex data, we should use JSON, but the migration added VARCHAR.
# Let's stick to simple string for the column, but we can store "type:value" if needed.
current_intent_str = combat.get('npc_intent', 'attack')
# Handle legacy/null
if not current_intent_str:
@@ -558,17 +556,22 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
intent_type = current_intent_str
message = ""
messages = []
actual_damage = 0
# EXECUTE INTENT
if intent_type == 'defend':
# NPC defends - maybe heals or takes less damage next turn?
# For simplicity: Heals 5% HP
# NPC defends - heals 5% HP
heal_amount = int(combat['npc_max_hp'] * 0.05)
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
message = f"{get_locale_string(npc_def.name)} defends and recovers {heal_amount} HP!"
messages.append(create_combat_message(
"enemy_defend",
origin="enemy",
npc_name=npc_def.name,
heal=heal_amount
))
elif intent_type == 'special':
# Strong attack (1.5x damage)
@@ -577,54 +580,78 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
message = f"{get_locale_string(npc_def.name)} uses a SPECIAL ATTACK for {npc_damage} damage!"
if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})"
messages.append(create_combat_message(
"enemy_special",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage,
armor_absorbed=armor_absorbed
))
if broken_armor:
for armor in broken_armor:
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
messages.append(create_combat_message(
"item_broken",
origin="player",
item_name=armor['name'],
emoji=armor['emoji']
))
await db.update_player(player_id, hp=new_player_hp)
else: # Default 'attack'
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
# Enrage bonus if NPC is below 30% HP
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
if is_enraged:
npc_damage = int(npc_damage * 1.5)
message = f"{get_locale_string(npc_def.name)} is ENRAGED! "
else:
message = ""
messages.append(create_combat_message(
"enemy_enraged",
origin="enemy",
npc_name=npc_def.name
))
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
message += f"{get_locale_string(npc_def.name)} attacks for {npc_damage} damage!"
if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})"
messages.append(create_combat_message(
"enemy_attack",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage,
armor_absorbed=armor_absorbed
))
if broken_armor:
for armor in broken_armor:
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
messages.append(create_combat_message(
"item_broken",
origin="player",
item_name=armor['name'],
emoji=armor['emoji']
))
await db.update_player(player_id, hp=new_player_hp)
# GENERATE NEXT INTENT
# We need to update the combat state with the new HP values first to make good decisions
# But we can just use the values we calculated.
# Check if player defeated
player_defeated = False
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
# Re-fetch to be sure or just trust calculation
if new_player_hp <= 0:
message += "\nYou have been defeated!"
messages.append(create_combat_message(
"player_defeated",
origin="neutral",
npc_name=npc_def.name
))
player_defeated = True
await db.update_player(player_id, hp=0, is_dead=True)
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
await db.end_combat(player_id)
return message, player_defeated
return messages, player_defeated
if not player_defeated:
if actual_damage > 0:
@@ -648,4 +675,4 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
'npc_intent': next_intent['type']
})
return message, player_defeated
return messages, player_defeated

View File

@@ -2,9 +2,11 @@
Authentication router.
Handles user registration, login, and profile retrieval.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi import APIRouter, HTTPException, Depends, status, Request
from typing import Dict, Any
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
@@ -205,10 +207,12 @@ async def get_account(current_user: Dict[str, Any] = Depends(get_current_user)):
@router.post("/change-email")
async def change_email(
request: "ChangeEmailRequest",
req: Request,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Change account email address"""
from ..services.models import ChangeEmailRequest
locale = req.headers.get('Accept-Language', 'en')
# Get account
account_id = current_user.get("account_id")
@@ -250,7 +254,7 @@ async def change_email(
# Update email
try:
await db.update_account_email(account_id, request.new_email)
return {"message": "Email updated successfully", "new_email": request.new_email}
return {"message": get_game_message('email_updated', locale), "new_email": request.new_email}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -261,10 +265,12 @@ async def change_email(
@router.post("/change-password")
async def change_password(
request: "ChangePasswordRequest",
req: Request,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Change account password"""
from ..services.models import ChangePasswordRequest
locale = req.headers.get('Accept-Language', 'en')
# Get account
account_id = current_user.get("account_id")
@@ -305,7 +311,7 @@ async def change_password(
new_password_hash = hash_password(request.new_password)
await db.update_account_password(account_id, new_password_hash)
return {"message": "Password updated successfully"}
return {"message": get_game_message('password_updated', locale)}
@router.post("/steam-login")

View File

@@ -2,9 +2,11 @@
Character management router.
Handles character creation, selection, and deletion.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi import APIRouter, HTTPException, Depends, status, Request
from fastapi.security import HTTPAuthorizationCredentials
from ..services.helpers import get_game_message
from ..core.security import decode_token, create_access_token, security
from ..services.models import CharacterCreate, CharacterSelect
from .. import database as db
@@ -51,10 +53,12 @@ async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(se
@router.post("")
async def create_character_endpoint(
character: CharacterCreate,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security)
):
"""Create a new character"""
token = credentials.credentials
locale = request.headers.get('Accept-Language', 'en')
payload = decode_token(token)
account_id = payload.get("account_id")
@@ -120,7 +124,7 @@ async def create_character_endpoint(
)
return {
"message": "Character created successfully",
"message": get_game_message('character_created', locale),
"character": {
"id": new_character["id"],
"name": new_character["name"],
@@ -203,10 +207,12 @@ async def select_character(
@router.delete("/{character_id}")
async def delete_character_endpoint(
character_id: int,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security)
):
"""Delete a character"""
token = credentials.credentials
locale = request.headers.get('Accept-Language', 'en')
payload = decode_token(token)
account_id = payload.get("account_id")
@@ -234,5 +240,5 @@ async def delete_character_endpoint(
await db.delete_character(character_id)
return {
"message": f"Character '{character['name']}' deleted successfully"
"message": get_game_message('character_deleted', locale, name=character['name'])
}

View File

@@ -2,7 +2,7 @@
Combat router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi import APIRouter, HTTPException, Depends, status, Request
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message, get_game_message
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
@@ -80,6 +80,7 @@ async def get_combat_status(current_user: dict = Depends(get_current_user)):
@router.post("/api/game/combat/initiate")
async def initiate_combat(
req: InitiateCombatRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Start combat with a wandering enemy"""
@@ -88,6 +89,9 @@ async def initiate_combat(
sys.path.insert(0, '/app')
from data.npcs import NPCS
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Check if already in combat
existing_combat = await db.get_active_combat(current_user['id'])
if existing_combat:
@@ -147,7 +151,7 @@ async def initiate_combat(
await manager.send_personal_message(current_user['id'], {
"type": "combat_started",
"data": {
"message": create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name),
"messages": [create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name)],
"combat": {
"npc_id": enemy.npc_id,
"npc_name": npc_def.name,
@@ -167,7 +171,7 @@ async def initiate_combat(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} entered combat with {get_locale_string(npc_def.name)}",
"message": get_game_message('player_entered_combat', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)),
"action": "combat_started",
"player_id": player['id']
},
@@ -178,7 +182,7 @@ async def initiate_combat(
return {
"success": True,
"message": create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name),
"messages": [create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name)],
"combat": {
"npc_id": enemy.npc_id,
"npc_name": npc_def.name,
@@ -194,6 +198,7 @@ async def initiate_combat(
@router.post("/api/game/combat/action")
async def combat_action(
req: CombatActionRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Perform a combat action"""
@@ -202,6 +207,9 @@ async def combat_action(
sys.path.insert(0, '/app')
from data.npcs import NPCS
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Get active combat
combat = await db.get_active_combat(current_user['id'])
if not combat:
@@ -238,7 +246,7 @@ async def combat_action(
player = current_user # current_user is already the character dict
npc_def = NPCS.get(combat['npc_id'])
result_message = ""
messages = []
combat_over = False
player_won = False
@@ -278,12 +286,20 @@ async def combat_action(
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
if attack_failed:
result_message = f"Your attack misses due to heavy encumbrance! "
messages.append(create_combat_message(
"player_miss",
origin="player",
reason="encumbrance"
))
new_npc_hp = combat['npc_hp']
else:
# Apply damage to NPC
new_npc_hp = max(0, combat['npc_hp'] - damage)
result_message = f"You attack for {damage} damage! "
messages.append(create_combat_message(
"player_attack",
origin="player",
damage=damage
))
# Apply weapon effects
if weapon_effects and 'bleeding' in weapon_effects:
@@ -292,26 +308,42 @@ async def combat_action(
# Apply bleeding effect (would need combat effects table, for now just bonus damage)
bleed_damage = bleeding.get('damage', 0)
new_npc_hp = max(0, new_npc_hp - bleed_damage)
result_message += f"💉 Bleeding effect! +{bleed_damage} damage! "
messages.append(create_combat_message(
"effect_bleeding",
origin="player",
damage=bleed_damage
))
# Decrease weapon durability (from unique_item)
if weapon_inv_id and inv_item.get('unique_item_id'):
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
if new_durability is None:
# Weapon broke (unique_item was deleted, cascades to inventory)
result_message += "\n⚠️ Your weapon broke! "
messages.append(create_combat_message(
"weapon_broke",
origin="player",
item_name=weapon_def.name
))
await db.unequip_item(player['id'], 'weapon')
if new_npc_hp <= 0:
# NPC defeated
result_message += f"Victory! Defeated {get_locale_string(npc_def.name)}"
messages.append(create_combat_message(
"victory",
origin="neutral",
npc_name=npc_def.name
))
combat_over = True
player_won = True
# Award XP
xp_gained = npc_def.xp_reward
new_xp = player['xp'] + xp_gained
result_message += f"\n+{xp_gained} XP"
messages.append(create_combat_message(
"xp_gain",
origin="player",
amount=xp_gained
))
await db.update_player(player['id'], xp=new_xp)
@@ -321,8 +353,12 @@ async def combat_action(
# Check for level up
level_up_result = await game_logic.check_and_apply_level_up(player['id'])
if level_up_result['leveled_up']:
result_message += f"\n🎉 Level Up! You are now level {level_up_result['new_level']}!"
result_message += f"\n+{level_up_result['levels_gained']} stat point(s) to spend!"
messages.append(create_combat_message(
"level_up",
origin="player",
level=level_up_result['new_level'],
stat_points=level_up_result['levels_gained']
))
# Create corpse with loot
import json
@@ -361,7 +397,7 @@ async def combat_action(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} defeated {npc_def.name}",
"message": get_game_message('player_defeated_enemy_broadcast', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)),
"action": "combat_ended",
"player_id": player['id'],
"corpse_created": True
@@ -373,13 +409,13 @@ async def combat_action(
else:
# NPC's turn - use shared logic
npc_attack_message, player_defeated = await game_logic.npc_attack(
npc_attack_messages, player_defeated = await game_logic.npc_attack(
player['id'],
{'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']},
npc_def,
reduce_armor_durability
)
result_message += f"\n{npc_attack_message}"
messages.extend(npc_attack_messages)
if player_defeated:
combat_over = True
@@ -392,7 +428,10 @@ async def combat_action(
elif req.action == 'flee':
# 50% chance to flee
if random.random() < 0.5:
result_message = "You successfully fled from combat!"
messages.append(create_combat_message(
"flee_success",
origin="player"
))
combat_over = True
player_won = False # Fled, not won
@@ -423,7 +462,7 @@ async def combat_action(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} fled from combat",
"message": get_game_message('player_fled_broadcast', locale, player_name=player['name']),
"action": "combat_fled",
"player_id": player['id']
},
@@ -435,10 +474,20 @@ async def combat_action(
# Failed to flee, NPC attacks
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
new_player_hp = max(0, player['hp'] - npc_damage)
result_message = f"Failed to flee! {get_locale_string(npc_def.name)} attacks for {npc_damage} damage!"
messages.append(create_combat_message(
"flee_fail",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage
))
if new_player_hp <= 0:
result_message += "\nYou have been defeated!"
messages.append(create_combat_message(
"player_defeated",
origin="neutral",
npc_name=npc_def.name
))
combat_over = True
await db.update_player(player['id'], hp=0, is_dead=True)
await db.update_player_statistics(player['id'], deaths=1, failed_flees=1, damage_taken=npc_damage, increment=True)
@@ -509,7 +558,7 @@ async def combat_action(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} was defeated in combat",
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
"action": "player_died",
"player_id": player['id'],
"corpse": corpse_data
@@ -558,7 +607,7 @@ async def combat_action(
return {
"success": True,
"message": result_message,
"messages": messages,
"combat_over": combat_over,
"player_won": player_won if combat_over else None,
"combat": updated_combat if updated_combat else None,
@@ -574,6 +623,7 @@ async def combat_action(
@router.post("/api/game/pvp/initiate")
async def initiate_pvp_combat(
req: PvPCombatInitiateRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Initiate PvP combat with another player"""
@@ -582,6 +632,9 @@ async def initiate_pvp_combat(
if not attacker:
raise HTTPException(status_code=404, detail="Player not found")
# Extract locale
locale = request.headers.get('Accept-Language', 'en')
# Check if attacker is already in combat
existing_combat = await db.get_active_combat(attacker['id'])
if existing_combat:
@@ -637,7 +690,7 @@ async def initiate_pvp_combat(
await manager.send_personal_message(attacker['id'], {
"type": "combat_started",
"data": {
"message": f"You have initiated combat with {defender['name']}! They get the first turn.",
"message": get_game_message('pvp_initiated_attacker', locale, defender=defender['name']),
"pvp_combat": pvp_combat
},
"timestamp": datetime.utcnow().isoformat()
@@ -646,7 +699,7 @@ async def initiate_pvp_combat(
await manager.send_personal_message(defender['id'], {
"type": "combat_started",
"data": {
"message": f"{attacker['name']} has challenged you to PvP combat! It's your turn.",
"message": get_game_message('pvp_challenged_defender', locale, attacker=attacker['name']),
"pvp_combat": pvp_combat
},
"timestamp": datetime.utcnow().isoformat()
@@ -654,7 +707,7 @@ async def initiate_pvp_combat(
return {
"success": True,
"message": f"You have initiated combat with {defender['name']}! They get the first turn.",
"message": get_game_message('pvp_initiated_attacker', locale, defender=defender['name']),
"pvp_combat": pvp_combat
}
@@ -737,11 +790,15 @@ class PvPAcknowledgeRequest(BaseModel):
@router.post("/api/game/pvp/acknowledge")
async def acknowledge_pvp_combat(
req: PvPAcknowledgeRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Acknowledge PvP combat end"""
await db.acknowledge_pvp_combat(req.combat_id, current_user['id'])
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Broadcast to location that player has returned
player = current_user # current_user is already the character dict
if player:
@@ -752,7 +809,7 @@ async def acknowledge_pvp_combat(
"data": {
"player_id": player['id'],
"username": player['name'],
"message": f"{player['name']} has returned from PvP combat."
"message": get_game_message('player_returned_pvp', locale, player_name=player['name'])
},
"timestamp": datetime.utcnow().isoformat()
},
@@ -770,12 +827,16 @@ class PvPCombatActionRequest(BaseModel):
@router.post("/api/game/pvp/action")
async def pvp_combat_action(
req: PvPCombatActionRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Perform a PvP combat action"""
import random
import time
# Extract locale
locale = request.headers.get('Accept-Language', 'en')
# Get PvP combat
pvp_combat = await db.get_pvp_combat_by_player(current_user['id'])
if not pvp_combat:
@@ -795,10 +856,13 @@ async def pvp_combat_action(
current_player = attacker if is_attacker else defender
opponent = defender if is_attacker else attacker
result_message = ""
messages = []
combat_over = False
winner_id = None
# Track the last action string for DB history
last_action_text = ""
if req.action == 'attack':
# Calculate damage (similar to PvE)
base_damage = 5
@@ -822,7 +886,11 @@ async def pvp_combat_action(
if inv_item.get('unique_item_id'):
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
if new_durability is None:
result_message += "⚠️ Your weapon broke! "
messages.append(create_combat_message(
"weapon_broke",
origin="player",
item_name=weapon_def.name
))
await db.unequip_item(current_player['id'], 'weapon')
variance = random.randint(-2, 2)
@@ -832,24 +900,42 @@ async def pvp_combat_action(
armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage)
actual_damage = max(1, damage - armor_absorbed)
# Structure the attack message
messages.append(create_combat_message(
"player_attack",
origin="player",
damage=damage,
armor_absorbed=armor_absorbed
))
# Update opponent HP (use actual player HP, not pvp_combat fields)
new_opponent_hp = max(0, opponent['hp'] - actual_damage)
# Update opponent's HP in database
await db.update_player(opponent['id'], hp=new_opponent_hp)
# Store message with attacker's username so both players can see it correctly
stored_message = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!"
# Construct summary string for DB history/passive player
last_action_text = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!"
if armor_absorbed > 0:
stored_message += f" (Armor absorbed {armor_absorbed})"
last_action_text += f" (Armor absorbed {armor_absorbed})"
for broken in broken_armor:
stored_message += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
messages.append(create_combat_message(
"item_broken",
origin="enemy", # Belongs to opponent
item_name=broken['name'],
emoji=broken['emoji']
))
last_action_text += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
# Check if opponent defeated
if new_opponent_hp <= 0:
stored_message += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
result_message = "Combat victory!" # Simple message, details in stored_message
last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
messages.append(create_combat_message(
"victory",
origin="neutral",
npc_name=opponent['name']
))
combat_over = True
winner_id = current_player['id']
@@ -921,7 +1007,7 @@ async def pvp_combat_action(
message={
"type": "location_update",
"data": {
"message": f"{opponent['name']} was defeated by {current_player['name']} in PvP combat",
"message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']),
"action": "player_died",
"player_id": opponent['id'],
"corpse": corpse_data
@@ -933,8 +1019,7 @@ async def pvp_combat_action(
# End PvP combat
await db.end_pvp_combat(pvp_combat['id'])
else:
# Combat continues - don't return detailed message, it's in stored_message
result_message = "" # Empty message, frontend will show stored_message from polling
# Combat continues
# Update PvP statistics for attack
await db.update_player_statistics(current_player['id'],
@@ -953,7 +1038,7 @@ async def pvp_combat_action(
updates = {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{stored_message}|{time.time()}" # Add timestamp for uniqueness
'last_action': f"{last_action_text}|{time.time()}" # Add timestamp for uniqueness
}
# No need to update HP in pvp_combat - we use player HP directly
@@ -963,14 +1048,19 @@ async def pvp_combat_action(
elif req.action == 'flee':
# 50% chance to flee from PvP
if random.random() < 0.5:
result_message = f"You successfully fled from {opponent['name']}!"
last_action_text = f"{current_player['name']} fled from combat!"
messages.append(create_combat_message(
"flee_success",
origin="player"
))
combat_over = True
# Mark as fled, store last action with timestamp, and end combat
flee_field = 'attacker_fled' if is_attacker else 'defender_fled'
await db.update_pvp_combat(pvp_combat['id'], {
flee_field: True,
'last_action': f"{current_player['name']} fled from combat!|{time.time()}"
'last_action': f"{last_action_text}|{time.time()}"
})
await db.end_pvp_combat(pvp_combat['id'])
await db.update_player_statistics(current_player['id'],
@@ -979,11 +1069,17 @@ async def pvp_combat_action(
)
else:
# Failed to flee, skip turn
result_message = f"Failed to flee from {opponent['name']}!"
last_action_text = f"{current_player['name']} tried to flee but failed!"
messages.append(create_combat_message(
"flee_fail",
origin="player",
reason="chance"
))
await db.update_pvp_combat(pvp_combat['id'], {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{current_player['name']} tried to flee but failed!|{time.time()}"
'last_action': f"{last_action_text}|{time.time()}"
})
await db.update_player_statistics(current_player['id'],
pvp_failed_flees=1,
@@ -1041,20 +1137,22 @@ async def pvp_combat_action(
await manager.send_personal_message(player_id, {
"type": "combat_update",
"data": {
"message": result_message if player_id == current_user['id'] else "",
"log_entry": result_message if player_id == current_user['id'] else "", # Append to combat log
"message": last_action_text if player_id == current_user['id'] else "",
"log_entry": last_action_text if player_id == current_user['id'] else "", # Append to combat log
"pvp_combat": enriched_pvp,
"combat_over": combat_over,
"winner_id": winner_id,
"attacker_hp": fresh_attacker['hp'],
"defender_hp": fresh_defender['hp']
"defender_hp": fresh_defender['hp'],
"messages": messages if player_id == current_user['id'] else []
},
"timestamp": datetime.utcnow().isoformat()
})
return {
"success": True,
"message": result_message,
"messages": messages,
"combat_over": combat_over,
"winner_id": winner_id
"winner_id": winner_id,
"pvp_combat": updated_pvp
}

View File

@@ -2,7 +2,7 @@
Equipment router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi import APIRouter, HTTPException, Depends, status, Request
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_game_message, get_locale_string
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
@@ -41,10 +41,12 @@ router = APIRouter(tags=["equipment"])
@router.post("/api/game/equip")
async def equip_item(
equip_req: EquipItemRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Equip an item from inventory"""
player_id = current_user['id']
locale = request.headers.get('Accept-Language', 'en')
# Get the inventory item
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
@@ -107,9 +109,9 @@ async def equip_item(
# Build message
if unequipped_item_name:
message = f"Unequipped {unequipped_item_name}, equipped {item_def.name}"
message = get_game_message('unequip_equip', locale, old=unequipped_item_name, new=get_locale_string(item_def.name, locale))
else:
message = f"Equipped {item_def.name}"
message = get_game_message('equipped', locale, item=get_locale_string(item_def.name, locale))
return {
"success": True,
@@ -122,10 +124,12 @@ async def equip_item(
@router.post("/api/game/unequip")
async def unequip_item(
unequip_req: UnequipItemRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Unequip an item from equipment slot"""
player_id = current_user['id']
locale = request.headers.get('Accept-Language', 'en')
# Check if slot is valid
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
@@ -190,7 +194,7 @@ async def unequip_item(
return {
"success": True,
"message": f"Unequipped {item_def.name} (dropped to ground - inventory full)",
"message": get_game_message('unequip_dropped', locale, item=get_locale_string(item_def.name, locale)),
"dropped": True
}
@@ -200,7 +204,7 @@ async def unequip_item(
return {
"success": True,
"message": f"Unequipped {item_def.name}",
"message": get_game_message('unequipped', locale, item=get_locale_string(item_def.name, locale)),
"dropped": False
}
@@ -241,10 +245,12 @@ async def get_equipment(current_user: dict = Depends(get_current_user)):
@router.post("/api/game/repair_item")
async def repair_item(
repair_req: RepairItemRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Repair an item using materials at a workbench location"""
player_id = current_user['id']
locale = request.headers.get('Accept-Language', 'en')
# Get player's location
player = await db.get_player_by_id(player_id)
@@ -358,7 +364,7 @@ async def repair_item(
return {
"success": True,
"message": f"Repaired {item_def.name}! Restored {repair_amount} durability.",
"message": get_game_message('repaired_success', locale, item=get_locale_string(item_def.name, locale), amount=repair_amount),
"item_name": item_def.name,
"old_durability": current_durability,
"new_durability": new_durability,

View File

@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, get_game_message
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
@@ -757,7 +757,7 @@ async def move(
if cooldown_remaining > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"You must wait {int(cooldown_remaining)} seconds before moving again."
detail=get_game_message('move_cooldown', locale, seconds=int(cooldown_remaining))
)
# Extract locale from Accept-Language header
@@ -870,7 +870,7 @@ async def move(
response["encounter"] = {
"triggered": True,
"enemy_id": enemy_id,
"message": f"⚠️ An enemy ambushes you upon arrival!",
"message": get_game_message('enemy_ambush', locale),
"combat": combat_data
}
@@ -881,7 +881,7 @@ async def move(
{
"type": "location_update",
"data": {
"message": f"{player['name']} left the area",
"message": get_game_message('player_left', locale, player_name=player['name']),
"action": "player_left",
"player_id": current_user['id'],
"player_name": player['name']
@@ -897,7 +897,7 @@ async def move(
{
"type": "location_update",
"data": {
"message": f"{player['name']} arrived",
"message": get_game_message('player_arrived', locale, player_name=player['name']),
"action": "player_arrived",
"player_id": current_user['id'],
"player_name": player['name'],
@@ -930,8 +930,11 @@ async def move(
@router.post("/api/game/inspect")
async def inspect(current_user: dict = Depends(get_current_user)):
async def inspect(request: Request, current_user: dict = Depends(get_current_user)):
"""Inspect the current area"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
location_id = current_user['location_id']
location = LOCATIONS.get(location_id)
@@ -947,7 +950,8 @@ async def inspect(current_user: dict = Depends(get_current_user)):
message = await game_logic.inspect_area(
current_user['id'],
location,
{} # interactables_data - not needed with new structure
{}, # interactables_data - not needed with new structure
locale
)
return {
@@ -971,7 +975,7 @@ async def interact(
if combat:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot interact with objects while in combat"
detail=get_game_message('interact_in_combat', locale)
)
location_id = current_user['location_id']
@@ -988,7 +992,8 @@ async def interact(
interact_req.interactable_id,
interact_req.action_id,
location,
ITEMS_MANAGER
ITEMS_MANAGER,
locale
)
if not result['success']:
@@ -1052,6 +1057,7 @@ async def interact(
@router.post("/api/game/use_item")
async def use_item(
use_req: UseItemRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Use an item from inventory"""
@@ -1064,10 +1070,14 @@ async def use_item(
combat = await db.get_active_combat(current_user['id'])
in_combat = combat is not None
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
result = await game_logic.use_item(
current_user['id'],
use_req.item_id,
ITEMS_MANAGER
ITEMS_MANAGER,
locale
)
if not result['success']:
@@ -1087,10 +1097,10 @@ async def use_item(
npc_damage = int(npc_damage * 1.5)
new_player_hp = max(0, player['hp'] - npc_damage)
combat_message = f"\n{npc_def.name} attacks for {npc_damage} damage!"
combat_message = get_game_message('combat_enemy_attack', locale, name=npc_def.name, damage=npc_damage)
if new_player_hp <= 0:
combat_message += "\nYou have been defeated!"
combat_message += get_game_message('combat_defeated', locale)
await db.update_player(current_user['id'], hp=0, is_dead=True)
await db.end_combat(current_user['id'])
result['combat_over'] = True
@@ -1149,7 +1159,7 @@ async def use_item(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} was defeated in combat",
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
"action": "player_died",
"player_id": player['id'],
"corpse": corpse_data # Send full corpse data
@@ -1194,7 +1204,8 @@ async def pickup(
pickup_req.item_id,
current_user['location_id'],
pickup_req.quantity,
ITEMS_MANAGER
ITEMS_MANAGER,
locale
)
if not result['success']:
@@ -1214,7 +1225,7 @@ async def pickup(
{
"type": "location_update",
"data": {
"message": f"{player['name']} picked up {quantity}x {item_name}",
"message": f"{player['name']} {get_game_message('picked_up', locale).lower()} {quantity}x {item_name}",
"action": "item_picked_up"
},
"timestamp": datetime.utcnow().isoformat()
@@ -1336,6 +1347,7 @@ async def get_inventory(current_user: dict = Depends(get_current_user)):
@router.post("/api/game/item/drop")
async def drop_item(
drop_req: dict,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Drop an item from inventory"""
@@ -1343,6 +1355,9 @@ async def drop_item(
item_id = drop_req.get('item_id') # This is the item_id string like "energy_bar"
quantity = drop_req.get('quantity', 1)
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Get player to know their location
player = await db.get_player_by_id(player_id)
if not player:
@@ -1400,7 +1415,7 @@ async def drop_item(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} dropped {item_def.emoji} {item_def.name} x{quantity}",
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity).replace('You', player['name']).replace('Has tirado', f"{player['name']} ha tirado"),
"action": "item_dropped"
},
"timestamp": datetime.utcnow().isoformat()
@@ -1410,5 +1425,5 @@ async def drop_item(
return {
"success": True,
"message": f"Dropped {item_def.emoji} {get_locale_string(item_def.name)} x{quantity}"
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity)
}

View File

@@ -2,7 +2,7 @@
Loot router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi import APIRouter, HTTPException, Depends, status, Request
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
@@ -12,7 +12,7 @@ import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, get_game_message
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
@@ -42,6 +42,7 @@ router = APIRouter(tags=["loot"])
@router.get("/api/game/corpse/{corpse_id}")
async def get_corpse_details(
corpse_id: str,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Get detailed information about a corpse's lootable items"""
@@ -50,6 +51,9 @@ async def get_corpse_details(
sys.path.insert(0, '/app')
from data.npcs import NPCS
# Extract locale
locale = request.headers.get('Accept-Language', 'en')
# Parse corpse ID
corpse_type, corpse_db_id = corpse_id.split('_', 1)
corpse_db_id = int(corpse_db_id)
@@ -99,7 +103,7 @@ async def get_corpse_details(
return {
'corpse_id': corpse_id,
'type': 'npc',
'name': f"{npc_def.name if npc_def else corpse['npc_id']} Corpse",
'name': get_game_message('corpse_name_npc', locale, name=get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']),
'loot_items': loot_items,
'total_items': len(loot_items)
}
@@ -137,7 +141,7 @@ async def get_corpse_details(
return {
'corpse_id': corpse_id,
'type': 'player',
'name': f"{corpse['player_name']}'s Corpse",
'name': get_game_message('corpse_name_player', locale, name=corpse['player_name']),
'loot_items': loot_items,
'total_items': len(loot_items)
}
@@ -149,6 +153,7 @@ async def get_corpse_details(
@router.post("/api/game/loot_corpse")
async def loot_corpse(
req: LootCorpseRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Loot a corpse (NPC or player) - can loot specific item by index or all items"""
@@ -158,6 +163,9 @@ async def loot_corpse(
sys.path.insert(0, '/app')
from data.npcs import NPCS
# Extract locale
locale = request.headers.get('Accept-Language', 'en')
# Parse corpse ID
corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
corpse_db_id = int(corpse_db_id)
@@ -310,26 +318,26 @@ async def loot_corpse(
message_parts = []
for item in looted_items:
item_def = ITEMS_MANAGER.get_item(item['item_id'])
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
item_name = get_locale_string(item_def.name, locale) if item_def else item['item_id']
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
dropped_parts = []
for item in dropped_items:
item_def = ITEMS_MANAGER.get_item(item['item_id'])
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
item_name = get_locale_string(item_def.name, locale) if item_def else item['item_id']
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
message = ""
if message_parts:
message = "Looted: " + ", ".join(message_parts)
message = get_game_message('looted_items_start', locale) + ", ".join(message_parts)
if dropped_parts:
if message:
message += "\n"
message += "⚠️ Backpack full! Dropped on ground: " + ", ".join(dropped_parts)
message += get_game_message('backpack_full_drop', locale) + ", ".join(dropped_parts)
if not message_parts and not dropped_parts:
message = "Nothing could be looted"
message = get_game_message('nothing_looted', locale)
if remaining_loot and req.item_index is None:
message += f"\n{len(remaining_loot)} item(s) require tools to extract"
message += "\n" + get_game_message('items_require_tools', locale, count=len(remaining_loot))
# Broadcast to location about corpse looting
if len(remaining_loot) == 0:
@@ -339,7 +347,7 @@ async def loot_corpse(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} fully looted an NPC corpse",
"message": get_game_message('full_loot_broadcast', locale, player_name=player['name']),
"action": "corpse_looted"
},
"timestamp": datetime.utcnow().isoformat()
@@ -438,24 +446,24 @@ async def loot_corpse(
message_parts = []
for item in looted_items:
item_def = ITEMS_MANAGER.get_item(item['item_id'])
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
item_name = get_locale_string(item_def.name, locale) if item_def else item['item_id']
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
dropped_parts = []
for item in dropped_items:
item_def = ITEMS_MANAGER.get_item(item['item_id'])
item_name = get_locale_string(item_def.name) if item_def else item['item_id']
item_name = get_locale_string(item_def.name, locale) if item_def else item['item_id']
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
message = ""
if message_parts:
message = "Looted: " + ", ".join(message_parts)
message = get_game_message('looted_items_start', locale) + ", ".join(message_parts)
if dropped_parts:
if message:
message += "\n"
message += "⚠️ Backpack full! Dropped on ground: " + ", ".join(dropped_parts)
message += get_game_message('backpack_full_drop', locale) + ", ".join(dropped_parts)
if not message_parts and not dropped_parts:
message = "Nothing could be looted"
message = get_game_message('nothing_looted', locale)
# Broadcast to location about corpse looting
if len(remaining_items) == 0:
@@ -465,7 +473,7 @@ async def loot_corpse(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} fully looted {corpse['player_name']}'s corpse",
"message": get_game_message('player_corpse_emptied_broadcast', locale, player_name=player['name'], corpse_name=corpse['player_name']),
"action": "player_corpse_emptied",
"corpse_id": req.corpse_id
},
@@ -480,7 +488,7 @@ async def loot_corpse(
message={
"type": "location_update",
"data": {
"message": f"{player['name']} looted from {corpse['player_name']}'s corpse",
"message": get_game_message('player_corpse_looted_broadcast', locale, player_name=player['name'], corpse_name=corpse['player_name']),
"action": "player_corpse_looted",
"corpse_id": req.corpse_id,
"remaining_items": remaining_items,

View File

@@ -15,7 +15,97 @@ def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> st
return str(value)
# Translation maps for backend messages
GAME_MESSAGES = {
# Pickup
'picked_up': {'en': 'Picked up', 'es': 'Has cogido'},
'inventory_full': {'en': 'Inventory full', 'es': 'Inventario lleno'},
'dropped_to_ground': {'en': 'Dropped to ground', 'es': 'Tirado al suelo'},
'item_too_heavy': {
'en': "⚠️ Item too heavy! {emoji} {name} x{qty} ({weight:.1f}kg) would exceed capacity. Current: {current:.1f}/{max:.1f}kg",
'es': "⚠️ ¡Objeto muy pesado! {emoji} {name} x{qty} ({weight:.1f}kg) excedería la capacidad. Actual: {current:.1f}/{max:.1f}kg"
},
'item_too_large': {
'en': "⚠️ Item too large! {emoji} {name} x{qty} ({volume:.1f}L) would exceed capacity. Current: {current:.1f}/{max:.1f}L",
'es': "⚠️ ¡Objeto muy grande! {emoji} {name} x{qty} ({volume:.1f}L) excedería la capacidad. Actual: {current:.1f}/{max:.1f}L"
},
'item_not_found_ground': {'en': "Item not found on ground", 'es': "Objeto no encontrado en el suelo"},
'invalid_quantity': {'en': "Invalid quantity", 'es': "Cantidad inválida"},
'dropped_item_success': {'en': 'Dropped {emoji} {name} x{qty}', 'es': 'Has tirado {emoji} {name} x{qty}'},
# Movement
'cannot_go_direction': {'en': "You cannot go {direction} from here.", 'es': "No puedes ir al {direction} desde aquí."},
'exhausted_move': {'en': "You're too exhausted to move. Wait for your stamina to regenerate.", 'es': "Estás demasiado exhausto para moverte. Espera a recuperar stamina."},
'move_cooldown': {'en': 'You must wait {seconds} seconds before moving again.', 'es': 'Debes esperar {seconds} segundos antes de moverte de nuevo.'},
'enemy_ambush': {'en': '⚠️ An enemy ambushes you upon arrival!', 'es': '⚠️ ¡Un enemigo te tiende una emboscada al llegar!'},
'player_left': {'en': '{player_name} left the area', 'es': '{player_name} abandonó el área'},
'player_arrived': {'en': '{player_name} arrived', 'es': '{player_name} ha llegado'},
'player_defeated_broadcast': {'en': '{player_name} was defeated in combat', 'es': '{player_name} fue derrotado en combate'},
'player_defeated_enemy_broadcast': {'en': '{player_name} defeated {npc_name}', 'es': '{player_name} derrotó a {npc_name}'},
'player_fled_broadcast': {'en': '{player_name} fled from combat', 'es': '{player_name} huyó del combate'},
'player_entered_combat': {'en': '{player_name} entered combat with {npc_name}', 'es': '{player_name} entró en combate con {npc_name}'},
'player_returned_pvp': {'en': '{player_name} has returned from PvP combat.', 'es': '{player_name} ha regresado del combate PvP.'},
'player_entered_combat': {'en': '{player_name} entered combat with {npc_name}', 'es': '{player_name} entró en combate con {npc_name}'},
'player_returned_pvp': {'en': '{player_name} has returned from PvP combat.', 'es': '{player_name} ha regresado del combate PvP.'},
'pvp_defeat_broadcast': {'en': '{opponent} was defeated by {winner} in PvP combat', 'es': '{opponent} fue derrotado por {winner} en combate PvP'},
'pvp_initiated_attacker': {'en': "You have initiated combat with {defender}! They get the first turn.", 'es': "¡Has iniciado combate con {defender}! Tiene el primer turno."},
'pvp_challenged_defender': {'en': "{attacker} has challenged you to PvP combat! It's your turn.", 'es': "¡{attacker} te ha desafiado a combate PvP! Es tu turno."},
# Loot
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
'corpse_name_player': {'en': "{name}'s Corpse", 'es': "Cadáver de {name}"},
'looted_items_start': {'en': "Looted: ", 'es': "Saqueado: "},
'backpack_full_drop': {'en': "⚠️ Backpack full! Dropped on ground: ", 'es': "⚠️ ¡Mochila llena! Tirado al suelo: "},
'nothing_looted': {'en': "Nothing could be looted", 'es': "No se pudo saquear nada"},
'items_require_tools': {'en': "{count} item(s) require tools to extract", 'es': "{count} objeto(s) requieren herramientas"},
'full_loot_broadcast': {'en': "{player_name} fully looted an NPC corpse", 'es': "{player_name} saqueó completamente un cadáver de NPC"},
'player_corpse_emptied_broadcast': {'en': "{player_name} fully looted {corpse_name}'s corpse", 'es': "{player_name} vació el cadáver de {corpse_name}"},
'player_corpse_looted_broadcast': {'en': "{player_name} looted from {corpse_name}'s corpse", 'es': "{player_name} saqueó del cadáver de {corpse_name}"},
# Equipment
'unequip_equip': {'en': "Unequipped {old}, equipped {new}", 'es': "Desequipado {old}, equipado {new}"},
'equipped': {'en': "Equipped {item}", 'es': "Equipado {item}"},
'unequipped': {'en': "Unequipped {item}", 'es': "Desequipado {item}"},
'unequip_dropped': {'en': "Unequipped {item} (dropped to ground - inventory full)", 'es': "Desequipado {item} (tirado al suelo - inventario lleno)"},
'repaired_success': {'en': "Repaired {item}! Restored {amount} durability.", 'es': "¡Reparado {item}! Restaurados {amount} puntos de durabilidad."},
# Characters/Auth
'character_created': {'en': "Character created successfully", 'es': "Personaje creado con éxito"},
'character_deleted': {'en': "Character '{name}' deleted successfully", 'es': "Personaje '{name}' eliminado con éxito"},
'email_updated': {'en': "Email updated successfully", 'es': "Email actualizado con éxito"},
'password_updated': {'en': "Password updated successfully", 'es': "Contraseña actualizada con éxito"},
# Inspection
'exhausted_inspect': {'en': "You're too exhausted to inspect the area thoroughly. Wait for your stamina to regenerate.", 'es': "Estás demasiado exhausto para inspeccionar. Espera a recuperar stamina."},
'inspecting_title': {'en': "🔍 **Inspecting {name}**\n", 'es': "🔍 **Inspeccionando {name}**\n"},
'interactables_title': {'en': "**Interactables:**", 'es': "**Objetos interactuables:**"},
'npcs_title': {'en': "**NPCs:**", 'es': "**NPCs:**"},
'items_ground_title': {'en': "**Items on ground:**", 'es': "**Objetos en el suelo:**"},
# Interaction
'not_enough_stamina': {'en': "Not enough stamina. Need {cost}, have {current}.", 'es': "No tienes suficiente stamina. Necesitas {cost}, tienes {current}."},
'cooldown_wait': {'en': "This action is still on cooldown. Wait {seconds} seconds.", 'es': "Esta acción está en enfriamiento. Espera {seconds} segundos."},
'object_not_found': {'en': "Object not found", 'es': "Objeto no encontrado"},
'action_not_found': {'en': "Action not found", 'es': "Acción no encontrada"},
'action_no_outcomes': {'en': "Action has no defined outcomes", 'es': "La acción no tiene resultados definidos"},
# Item Usage
'item_used': {'en': "Used {name}", 'es': "Usado {name}"},
'no_item': {'en': "You don't have this item", 'es': "No tienes este objeto"},
'cannot_use': {'en': "This item cannot be used", 'es': "Este objeto no se puede usar"}
}
def get_game_message(key: str, lang: str = 'en', **kwargs) -> str:
"""Get and format a localized game message."""
messages = GAME_MESSAGES.get(key, {})
template = messages.get(lang) or messages.get('en') or key
try:
return template.format(**kwargs)
except KeyError:
return template
DIRECTION_TRANSLATIONS = {
'north': {'en': 'north', 'es': 'norte'},
'south': {'en': 'south', 'es': 'sur'},
@@ -39,8 +129,8 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
import json
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> str:
"""Create a structured combat message with type, origin, and data.
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict:
"""Create a structured combat message object.
Args:
message_type: Type of combat message (combat_start, player_attack, etc.)
@@ -48,13 +138,13 @@ def create_combat_message(message_type: str, origin: str = "neutral", **data) ->
**data: Dynamic data for the message (damage, npc_name, etc.)
Returns:
JSON string with 'type', 'origin', and 'data' fields
Dictionary with 'type', 'origin', and 'data' fields
"""
return json.dumps({
return {
"type": message_type,
"origin": origin,
"data": data
})
}
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
"""

88
count_sloc.py Normal file
View File

@@ -0,0 +1,88 @@
import os
import subprocess
def count_lines():
try:
# Get list of tracked files
result = subprocess.run(['git', 'ls-files'], capture_output=True, text=True, check=True)
files = result.stdout.splitlines()
except subprocess.CalledProcessError:
print("Not a git repository or git error.")
return
stats = {}
total_effective = 0
total_files = 0
comments = {
'.py': '#',
'.js': '//',
'.jsx': '//',
'.ts': '//',
'.tsx': '//',
'.css': '/*', # Simple check, not perfect for block comments across lines or inline
'.html': '<!--',
'.json': None, # JSON doesn't standardized comments, but we count lines
'.yml': '#',
'.yaml': '#',
'.sh': '#',
'.md': None
}
ignored_dirs = ['old', 'migrations', 'images', 'claude_sonnet_logs', 'data', 'gamedata/items.json'] # items.json can be huge
for file_path in files:
if any(part in file_path.split('/') for part in ignored_dirs):
continue
# Determine extension
_, ext = os.path.splitext(file_path)
if ext not in comments and ext not in ['.json', '.md']:
# Skip unknown extensions or binary files if not handled
# But let's verify if text
continue
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
except Exception:
continue
effective_lines = 0
file_total = 0
comment_char = comments.get(ext)
for line in lines:
line_strip = line.strip()
if not line_strip:
continue
file_total += 1
if comment_char:
if line_strip.startswith(comment_char):
continue
# Special handling for CSS/HTML block comments would be needed for perfect accuracy
# keeping it simple: if it starts with comment char, ignore.
effective_lines += 1
if ext not in stats:
stats[ext] = {'files': 0, 'lines': 0}
stats[ext]['files'] += 1
stats[ext]['lines'] += effective_lines
total_effective += effective_lines
total_files += 1
print(f"{'Language':<15} {'Files':<10} {'Effective Lines':<15}")
print("-" * 40)
for ext, data in sorted(stats.items(), key=lambda x: x[1]['lines'], reverse=True):
lang = ext if ext else "No Ext"
print(f"{lang:<15} {data['files']:<10} {data['lines']:<15}")
print("-" * 40)
print(f"{'Total':<15} {total_files:<10} {total_effective:<15}")
if __name__ == "__main__":
count_lines()

BIN
pwa/public/audio/bgm.wav Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,8 @@
import { BrowserRouter, HashRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { useAuth } from './hooks/useAuth'
import { AudioProvider } from './contexts/AudioContext'
import BackgroundMusic from './components/BackgroundMusic'
import LandingPage from './components/LandingPage'
import Login from './components/Login'
import Register from './components/Register'
@@ -48,71 +50,74 @@ function CharacterRoute({ children }: { children: React.ReactNode }) {
function App() {
return (
<AuthProvider>
<Router>
<div className="app">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/characters"
element={
<PrivateRoute>
<CharacterSelection />
</PrivateRoute>
}
/>
<Route
path="/create-character"
element={
<PrivateRoute>
<CharacterCreation />
</PrivateRoute>
}
/>
<Route
path="/account"
element={
<PrivateRoute>
<AccountPage />
</PrivateRoute>
}
/>
<Route element={<GameLayout />}>
<Route
path="/game"
element={
<CharacterRoute>
<Game />
</CharacterRoute>
}
/>
<AudioProvider>
<Router>
<BackgroundMusic />
<div className="app">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/profile/:playerId"
path="/characters"
element={
<PrivateRoute>
<Profile />
<CharacterSelection />
</PrivateRoute>
}
/>
<Route
path="/leaderboards"
path="/create-character"
element={
<PrivateRoute>
<Leaderboards />
<CharacterCreation />
</PrivateRoute>
}
/>
</Route>
</Routes>
</div>
</Router>
<Route
path="/account"
element={
<PrivateRoute>
<AccountPage />
</PrivateRoute>
}
/>
<Route element={<GameLayout />}>
<Route
path="/game"
element={
<CharacterRoute>
<Game />
</CharacterRoute>
}
/>
<Route
path="/profile/:playerId"
element={
<PrivateRoute>
<Profile />
</PrivateRoute>
}
/>
<Route
path="/leaderboards"
element={
<PrivateRoute>
<Leaderboards />
</PrivateRoute>
}
/>
</Route>
</Routes>
</div>
</Router>
</AudioProvider>
</AuthProvider>
)
}

View File

@@ -1,56 +1,47 @@
.account-page {
min-height: 100vh;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
color: #fff;
}
.account-container {
max-width: 1000px;
margin: 0 auto;
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
padding: 2rem;
backdrop-filter: blur(10px);
}
.account-title {
font-size: 2.5rem;
color: #646cff;
margin-bottom: 2rem;
text-align: center;
color: #e0e0e0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.account-loading,
.account-error {
text-align: center;
padding: 3rem;
color: #fff;
}
.account-error h2 {
color: #ff6b6b;
margin-bottom: 1rem;
}
/* Account Sections */
.account-section {
background: rgba(42, 42, 42, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(100, 108, 255, 0.2);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.account-section:last-child {
border-bottom: none;
}
.section-title {
font-size: 1.5rem;
color: #646cff;
margin-bottom: 1.5rem;
border-bottom: 1px solid rgba(100, 108, 255, 0.2);
padding-bottom: 0.5rem;
color: #bbb;
border-left: 4px solid #4a9eff;
padding-left: 1rem;
}
/* Account Information Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
gap: 1.5rem;
}
.info-item {
@@ -60,41 +51,38 @@
}
.info-label {
font-size: 0.9rem;
color: #888;
font-weight: 600;
font-size: 0.9rem;
}
.info-value {
font-size: 1.1rem;
color: #fff;
font-weight: 500;
}
.info-value.premium {
color: #ffd93d;
font-weight: 600;
color: #ffd700;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
/* Characters Grid */
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.character-card {
background: rgba(26, 26, 26, 0.8);
border: 1px solid rgba(100, 108, 255, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 1.5rem;
transition: all 0.3s ease;
transition: transform 0.2s, background 0.2s;
}
.character-card:hover {
transform: translateY(-4px);
border-color: rgba(100, 108, 255, 0.6);
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.2);
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.1);
}
.character-header {
@@ -102,21 +90,21 @@
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 {
font-size: 1.3rem;
color: #fff;
margin: 0;
color: #fff;
}
.character-level {
background: linear-gradient(135deg, #646cff 0%, #8b5cf6 100%);
color: #fff;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
background: #4a9eff;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
}
.character-stats {
@@ -127,135 +115,219 @@
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-label {
font-size: 0.8rem;
color: #888;
}
.stat-value {
font-size: 1rem;
color: #fff;
font-weight: 600;
gap: 0.5rem;
color: #aba;
}
.character-attributes {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
font-size: 0.9rem;
color: #aaa;
color: #888;
margin-bottom: 1rem;
}
.no-characters {
color: #888;
text-align: center;
color: #888;
padding: 2rem;
font-style: italic;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 1.5rem;
}
/* Settings */
.setting-item {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(100, 108, 255, 0.1);
}
.setting-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
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: 1rem;
margin-bottom: 1.5rem;
}
.setting-header h3 {
font-size: 1.2rem;
color: #fff;
margin: 0;
font-size: 1.2rem;
}
.setting-form {
background: rgba(26, 26, 26, 0.6);
border: 1px solid rgba(100, 108, 255, 0.2);
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
padding: 1.5rem;
border-radius: 4px;
margin-top: 1rem;
}
.setting-form .form-group {
.form-group {
margin-bottom: 1rem;
}
.setting-form .form-group:last-of-type {
margin-bottom: 1.5rem;
.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 {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mute-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.mute-toggle input {
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;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 2rem;
}
.button-danger {
background-color: #ff6b6b;
color: white;
/* Buttons */
.button-primary,
.button-secondary,
.button-danger,
.button-link {
padding: 0.8rem 1.5rem;
border-radius: 4px;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.25s;
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-color: #ff5252;
background: rgba(220, 53, 69, 0.3);
}
/* Responsive Design */
@media (max-width: 768px) {
.account-page {
padding: 1rem;
}
.account-title {
font-size: 2rem;
}
.account-section {
padding: 1.5rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.characters-grid {
grid-template-columns: 1fr;
}
.setting-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.account-actions {
flex-direction: column;
}
.account-actions button {
width: 100%;
}
.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;
}
.success {
background: rgba(40, 167, 69, 0.1);
color: #5ddc6c;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useAudio } from '../contexts/AudioContext'
import { authApi, Account, Character } from '../services/api'
import './AccountPage.css'
@@ -29,6 +30,14 @@ function AccountPage() {
const [passwordError, setPasswordError] = useState('')
const [passwordSuccess, setPasswordSuccess] = useState('')
// Audio state
const {
masterVolume, setMasterVolume,
musicVolume, setMusicVolume,
sfxVolume, setSfxVolume,
isMuted, setIsMuted
} = useAudio()
useEffect(() => {
fetchAccountData()
}, [])
@@ -227,6 +236,63 @@ function AccountPage() {
</section>
{/* Settings Section */}
<section className="account-section">
<h2 className="section-title">Audio Settings</h2>
<div className="audio-settings">
<div className="setting-item">
<div className="setting-header">
<h3>Volume Controls</h3>
<label className="mute-toggle">
<input
type="checkbox"
checked={isMuted}
onChange={(e) => setIsMuted(e.target.checked)}
/>
<span>Mute All</span>
</label>
</div>
<div className="volume-sliders">
<div className="slider-group">
<label>Master Volume: {Math.round(masterVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={masterVolume}
onChange={(e) => setMasterVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
<div className="slider-group">
<label>Music Volume: {Math.round(musicVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={musicVolume}
onChange={(e) => setMusicVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
<div className="slider-group">
<label>SFX Volume: {Math.round(sfxVolume * 100)}%</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={sfxVolume}
onChange={(e) => setSfxVolume(parseFloat(e.target.value))}
disabled={isMuted}
/>
</div>
</div>
</div>
</div>
</section>
<section className="account-section">
<h2 className="section-title">Account Settings</h2>

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useAudio } from '../contexts/AudioContext';
import { isElectronApp } from '../utils/assetPath';
export default function BackgroundMusic() {
const { pathname } = useLocation();
const { masterVolume, musicVolume, isMuted } = useAudio();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playbackError, setPlaybackError] = useState(false);
// Routes where music should play
const shouldPlayMusic = () => {
// Game main view
if (pathname === '/game') return true;
// Leaderboards
if (pathname === '/leaderboards') return true;
// Account management
if (pathname === '/account') return true;
// Profile views
if (pathname.startsWith('/profile/')) return true;
return false;
};
// Calculate effective volume
const effectiveVolume = isMuted ? 0 : masterVolume * musicVolume;
useEffect(() => {
if (!audioRef.current) {
// For static assets in public folder:
// Browser: use absolute path from root
// Electron: use relative path
const src = isElectronApp() ? './audio/bgm.wav' : '/audio/bgm.wav';
audioRef.current = new Audio(src);
audioRef.current.loop = true;
}
const audio = audioRef.current;
// Update volume in real-time
audio.volume = effectiveVolume;
const handlePlay = async () => {
try {
if (shouldPlayMusic()) {
if (audio.paused) {
await audio.play();
setPlaybackError(false);
}
} else {
if (!audio.paused) {
audio.pause();
audio.currentTime = 0; // Reset track when stopping
}
}
} catch (err) {
console.log('Audio playback failed:', err);
setPlaybackError(true);
}
};
handlePlay();
// Attempts to resume audio if the user interacts with the page
const retryPlay = () => {
if (shouldPlayMusic() && audio.paused) {
handlePlay();
}
};
if (playbackError) {
document.addEventListener('click', retryPlay, { once: true });
}
return () => {
document.removeEventListener('click', retryPlay);
};
}, [pathname, effectiveVolume, playbackError]);
// Handle volume changes specifically if they happen while playing
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = effectiveVolume;
}
}, [effectiveVolume]);
// Render a small overlay if autoplay is blocked
if (!playbackError || !shouldPlayMusic()) return null;
return (
<div
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9999,
background: 'rgba(74, 158, 255, 0.9)',
color: 'white',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
boxShadow: '0 4px 6px rgba(0,0,0,0.3)',
fontWeight: 'bold',
animation: 'pulse 2s infinite'
}}
onClick={() => {
if (audioRef.current) {
audioRef.current.play()
.then(() => setPlaybackError(false))
.catch(e => console.error(e));
}
}}
>
🎵 Click to Enable Audio
</div>
);
}

View File

@@ -3074,12 +3074,7 @@ body.no-scroll {
color: #f44336;
}
.combat-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 1.5rem;
}
.combat-action-btn {
padding: 1rem 2rem;

View File

@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { useGameEngine } from './game/hooks/useGameEngine'
import Combat from './game/Combat'
import { Combat } from './game/Combat'
import LocationView from './game/LocationView'
import MovementControls from './game/MovementControls'
import PlayerSidebar from './game/PlayerSidebar'

View File

@@ -1,372 +1,433 @@
import { useState, useEffect, useRef } from 'react'
import CombatView from './CombatView'
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import type { FloatingText, CombatMessage } from './CombatTypes'
import api from '../../services/api'
import { getTranslatedText } from '../../utils/i18nUtils'
import './CombatEffects.css'
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
// import { useGame } from '../../contexts/GameContext'; // Removed invalid import
import { CombatView } from './CombatView';
import { CombatState, CombatMessage, FloatingText, AnimationState, CombatActionResponse } from './CombatTypes';
import { useTranslation } from 'react-i18next';
// Updated props interface to match Game.tsx
interface CombatProps {
combatState: CombatState
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
onCombatAction: (action: string) => Promise<any>
onExitCombat: () => void
onPvPAction: (action: string) => Promise<any>
onExitPvPCombat: () => void
combatLog: CombatLogEntry[]
addCombatLogEntry: (entry: CombatLogEntry) => void
updatePlayerState: (state: PlayerState) => void
updateCombatState: (state: CombatState) => void
combatState: any; // Using any for now to be flexible with backend response
combatLog: any[];
profile: any;
playerState: any;
equipment: any;
onCombatAction: (action: string) => Promise<any>;
onPvPAction: (action: string, targetId: number) => Promise<void>;
onExitCombat: () => void;
onExitPvPCombat: () => Promise<void>;
addCombatLogEntry: (entry: any) => void;
updatePlayerState: (data: any) => void;
updateCombatState: (data: any) => void;
// Kept for compatibility if passed
onClose?: () => void;
}
const Combat = ({
combatState,
export const Combat: React.FC<CombatProps> = ({
combatState: initialCombatData,
combatLog: _combatLog,
profile,
playerState,
equipment,
equipment: _equipment,
onCombatAction,
onExitCombat,
onPvPAction,
onExitCombat,
onExitPvPCombat,
combatLog,
addCombatLogEntry,
addCombatLogEntry: _addCombatLogEntry,
updatePlayerState,
updateCombatState
}: CombatProps) => {
// Visual effects state
const [shake, setShake] = useState(false)
const [flash, setFlash] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([])
const [processing, setProcessing] = useState(false)
updateCombatState: _updateCombatState,
onClose
}) => {
const { currentCharacter: _currentCharacter, refreshCharacters } = useAuth();
const { t, i18n } = useTranslation();
// Timer state
const [pvpTimer, setPvpTimer] = useState<number | null>(null)
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
const isPvP = initialCombatData?.is_pvp || false;
// Enemy thinking indicator
const [enemyThinking, setEnemyThinking] = useState(false)
// Helper to resolve localized names safely
const resolveName = useCallback((name: any) => {
if (!name) return '';
if (typeof name === 'string') return name;
if (typeof name === 'object') {
return name[i18n.language] || name['en'] || name['es'] || 'Unknown';
}
return 'Unknown';
}, [i18n.language]);
// Temporary HP to delay updates during enemy turn
const [tempPlayerHP, setTempPlayerHP] = useState<number | null>(null)
// Helper to determine initial combat message
const getInitialLogMessage = (): CombatMessage[] => {
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
// Refs for cleanup
const isMounted = useRef(true)
const floatingTextIdCounter = useRef(0)
const floatingTextTimeouts = useRef<Set<NodeJS.Timeout>>(new Set())
if (isPvP) {
const isAttacker = initialCombatData?.pvp_combat?.is_attacker;
return [{
type: 'text',
origin: 'system',
timestamp,
data: { text: isAttacker ? t('combat.log.pvp_attack') : t('combat.log.pvp_defense') }
}];
}
// ============================================================================
// Cleanup Effects
// ============================================================================
// PvE Logic
// If it's round 1 and enemy turn, likely an ambush or high initiative enemy
// We don't have explicit 'ambush' flag, but we can infer or just use generic start
// User requested 'getting ambushed when travelling', usually implies enemy starts
const isAmbush = initialCombatData?.combat?.turn === 'enemy' && initialCombatData?.combat?.round === 1;
if (isAmbush) {
return [{
type: 'text',
origin: 'system',
timestamp,
data: { text: t('combat.log.ambush') }
}];
}
return [{
type: 'combat_start',
origin: 'system',
timestamp,
data: { message: t('combat.log.combat_start') } // Fallback if 'combat_start' type isn't fully handled text-wise in View
}];
};
// --- State Management ---
// We synchronize local state with props, but manage animations locally
const [localCombatState, setLocalCombatState] = useState<CombatState>({
inCombat: true,
turn: initialCombatData?.turn || 'player',
npcId: initialCombatData?.combat?.npc_id || initialCombatData?.pvp_combat?.defender?.id,
npcName: resolveName(initialCombatData?.combat?.npc_name) ||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username),
npcHp: initialCombatData?.combat?.npc_hp ||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.hp : initialCombatData?.pvp_combat?.attacker?.hp) || 100,
npcMaxHp: initialCombatData?.combat?.npc_max_hp ||
(initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.max_hp : initialCombatData?.pvp_combat?.attacker?.max_hp) || 100,
npcImage: initialCombatData?.combat?.npc_image,
playerHp: playerState?.health || profile?.hp || 100,
playerMaxHp: playerState?.max_health || profile?.max_hp || 100,
messages: getInitialLogMessage(),
round: initialCombatData?.combat?.round || 1,
isPvP: isPvP,
opponentName: isPvP
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
: undefined,
turnTimeRemaining: initialCombatData?.turn_time_remaining
});
const [animState, setAnimState] = useState<AnimationState>({
shaking: false, // Deprecated, but kept for safe removal if needed
flashing: false, // Deprecated
enemyAttacking: false,
playerAttacking: false,
playerHit: false,
npcHit: false
});
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([]);
// const [isThinking, setIsThinking] = useState(false); // Unused for now
const [messageQueue, setMessageQueue] = useState<CombatMessage[]>([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const [combatResult, setCombatResult] = useState<'victory' | 'defeat' | 'fled' | null>(null);
// --- Refs ---
const processingRef = useRef(false);
const queueRef = useRef<CombatMessage[]>([]);
// Store server player HP to apply when damage floating text appears
const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null);
// Store server player XP to apply when XP floating text appears
const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null);
// Update queueRef
useEffect(() => {
queueRef.current = messageQueue;
}, [messageQueue]);
// Update local state when props change (especially for PvP live updates)
// IMPORTANT: We preserve existing messages to avoid wiping the initial log
// NOTE: HP values are NOT synced here - they are managed through processMessage for proper animation timing
useEffect(() => {
if (initialCombatData) {
setLocalCombatState(prev => ({
...prev,
turn: initialCombatData.turn || initialCombatData.combat?.turn || prev.turn,
round: initialCombatData?.combat?.round ?? prev.round,
turnTimeRemaining: initialCombatData?.turn_time_remaining
// Do NOT overwrite messages or HP here - HP is managed by processMessage
}));
}
}, [initialCombatData]);
// --- Handlers ---
// Move ref to component scope
const cleanupIntervalRef = useRef<NodeJS.Timeout | null>(null);
const addFloatingText = (text: string, type: FloatingText['type'], origin: 'player' | 'enemy') => {
const id = Math.random().toString(36).substr(2, 9);
// Fixed position at center - no random offset for turn-based combat
const x = 50;
const y = 50;
setFloatingTexts(prev => [...prev, { id, text, type, x, y, origin, timestamp: Date.now() }]);
};
// Clean up floats
useEffect(() => {
cleanupIntervalRef.current = setInterval(() => {
// Only clean up if we are NOT in a result state (victory/defeat) to prevent race conditions
setFloatingTexts(prev => {
if (prev.length === 0) return prev;
return prev.filter(ft => Date.now() - ft.timestamp < 5000);
});
}, 500);
return () => {
isMounted.current = false
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
floatingTextTimeouts.current.clear()
setFloatingTexts([])
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
};
}, []);
const triggerAnim = (anim: keyof AnimationState, duration: number = 500) => {
setAnimState(prev => ({ ...prev, [anim]: true }));
setTimeout(() => {
setAnimState(prev => ({ ...prev, [anim]: false }));
}, duration);
};
// --- Message Processing ---
const processMessage = useCallback((msg: CombatMessage) => {
const { type, origin, data } = msg;
// Force NPC HP to 0 on victory to ensure bar is empty, as backend might report pre-death HP
if (type === 'victory') {
setLocalCombatState(prev => ({
...prev,
npcHp: 0
}));
}
}, [])
useEffect(() => {
if (combatState.combat_over) {
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
floatingTextTimeouts.current.clear()
setFloatingTexts([])
}
}, [combatState.combat_over])
const msgWithTimestamp = {
...msg,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
};
// ============================================================================
// Timer Effects
// ============================================================================
setLocalCombatState(prev => ({
...prev,
messages: [...prev.messages, msgWithTimestamp]
}));
// PvP Timer
useEffect(() => {
if (combatState.is_pvp && combatState.pvp_combat) {
setPvpTimer(combatState.pvp_combat.time_remaining)
switch (type) {
case 'combat_start':
break;
const interval = setInterval(() => {
setPvpTimer(prev => (prev && prev > 0 ? prev - 1 : 0))
}, 1000)
return () => clearInterval(interval)
} else {
setPvpTimer(null)
}
}, [combatState.is_pvp, combatState.pvp_combat])
// PvE Timer - Update from server
useEffect(() => {
if (!combatState.is_pvp && combatState.combat?.turn === 'player' && combatState.combat?.turn_time_remaining !== undefined) {
setTurnTimeRemaining(combatState.combat.turn_time_remaining)
} else {
setTurnTimeRemaining(null)
}
}, [combatState.is_pvp, combatState.combat?.turn, combatState.combat?.turn_time_remaining])
// PvE Timer - Countdown
useEffect(() => {
if (turnTimeRemaining !== null && turnTimeRemaining > 0) {
const interval = setInterval(() => {
setTurnTimeRemaining(prev => prev !== null && prev > 0 ? prev - 1 : 0)
}, 1000)
return () => clearInterval(interval)
}
}, [turnTimeRemaining])
// PvE Polling when timeout is imminent
useEffect(() => {
if (!combatState.is_pvp && turnTimeRemaining !== null && turnTimeRemaining < 30 && turnTimeRemaining >= 0) {
const pollInterval = setInterval(async () => {
try {
const response = await api.get('/api/game/combat')
if (response.data.in_combat && response.data.combat) {
if (response.data.combat.turn !== combatState.combat?.turn) {
updateCombatState({
...combatState,
combat: response.data.combat
})
}
}
} catch (error) {
console.error('Failed to poll combat state:', error)
case 'player_attack':
triggerAnim('playerAttacking');
triggerAnim('npcHit', 300); // Enemy takes damage
if (data.damage) {
addFloatingText(`-${data.damage}`, 'damage', 'enemy');
// HP is updated via server value in handlePvEAction
}
}, 10000)
break;
return () => clearInterval(pollInterval)
case 'player_miss':
addFloatingText(t('combat.miss'), 'miss', 'enemy');
break;
case 'enemy_attack':
case 'monster_attack':
triggerAnim('enemyAttacking');
triggerAnim('playerHit', 300); // Player takes damage
if (data.damage) {
addFloatingText(`-${data.damage}`, 'damage', 'player');
// Apply server player HP when floating text appears
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({
...prev,
playerHp: hp,
playerMaxHp: max_hp
}));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
}
break;
case 'enemy_miss':
addFloatingText(t('combat.miss'), 'miss', 'player');
break;
case 'enemy_defend':
addFloatingText(`+${data.heal}`, 'heal', 'enemy');
break;
case 'enemy_special':
triggerAnim('enemyAttacking');
triggerAnim('flashing', 500);
triggerAnim('shaking', 500);
if (data.damage) {
addFloatingText(`-${data.damage}!`, 'crit', 'player');
}
break;
case 'effect_bleeding':
addFloatingText(`-${data.damage}`, 'damage', origin === 'player' ? 'enemy' : 'player');
break;
case 'xp_gain':
addFloatingText(`+${data.amount} XP`, 'info', 'player');
// Sync XP bar with floating text using server value
if (pendingPlayerXpRef.current) {
updatePlayerState({ xp: pendingPlayerXpRef.current.xp, level: pendingPlayerXpRef.current.level });
pendingPlayerXpRef.current = null;
} else if (data.xp !== undefined) {
// Fallback to message data if ref is missing (shouldn't happen usually)
updatePlayerState({ xp: data.xp, level: data.level });
}
break;
case 'victory':
// Stop cleanup interval to freeze the DOM state regarding floating texts
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
// Delay victory state to allow final animations (floating text) to persist
setTimeout(() => {
setCombatResult('victory');
}, 1000);
break;
case 'player_defeated':
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
setTimeout(() => {
setCombatResult('defeat');
}, 2000);
break;
case 'flee_success':
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
setTimeout(() => {
setCombatResult('fled');
}, 500);
break;
}
}, [turnTimeRemaining, combatState, updateCombatState])
}, [t]);
// ============================================================================
// Helper Functions
// ============================================================================
const processQueue = useCallback(async () => {
if (processingRef.current || queueRef.current.length === 0) return;
const addFloatingText = (text: string, x: number, y: number, type: FloatingText['type']) => {
const id = ++floatingTextIdCounter.current
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
processingRef.current = true;
setIsProcessingQueue(true);
const timeout = setTimeout(() => {
if (isMounted.current) {
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
floatingTextTimeouts.current.delete(timeout)
}
}, 2500)
const msg = queueRef.current[0];
floatingTextTimeouts.current.add(timeout)
}
processMessage(msg);
const parseMessage = (msg: any): CombatMessage | null => {
if (typeof msg === 'string') {
try {
return JSON.parse(msg) as CombatMessage
} catch {
// Not a JSON message, return null to use as plain text
return null
}
// Determine delay based on message type
let delay = 600;
if (msg.type === 'enemy_attack' || msg.type === 'enemy_special') delay = 1200;
// Increase victory delay in queue processing so the UI doesn't rush
if (msg.type === 'victory') delay = 2000;
if (msg.origin === 'enemy' && msg.type !== 'flee_fail') delay = 1000;
await new Promise(resolve => setTimeout(resolve, delay));
setMessageQueue(prev => prev.slice(1));
processingRef.current = false;
}, [processMessage]);
useEffect(() => {
if (messageQueue.length > 0 && !processingRef.current) {
processQueue();
} else if (messageQueue.length === 0 && isProcessingQueue) {
// Queue just finished processing
setIsProcessingQueue(false);
}
return msg as CombatMessage
}
}, [messageQueue, processQueue, isProcessingQueue]);
// ============================================================================
// PvE Combat Actions
// ============================================================================
const handlePvEAction = async (action: string) => {
if (processing) return
setProcessing(true)
if (isProcessingQueue) return;
try {
const data = await onCombatAction(action)
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
if (localCombatState.turn !== 'player') return;
// Parse message into structured parts
const messages = data.message.split('\n').filter((m: string) => m.trim())
// Use the prop function instead of direct fetch
const data: CombatActionResponse = await onCombatAction(action);
const playerMessages: any[] = []
const enemyMessages: any[] = []
if (data && data.success && data.messages) {
setMessageQueue(data.messages);
messages.forEach((msg: string) => {
const parsed = parseMessage(msg)
if (parsed) {
// Structured message - use origin field
if (parsed.origin === 'player') {
playerMessages.push(parsed)
} else if (parsed.origin === 'enemy') {
enemyMessages.push(parsed)
} else {
// Neutral messages (victory, combat start) go to player
playerMessages.push(parsed)
}
} else {
// Legacy string message - fallback to text parsing
if (msg.includes('You ') || msg.includes('Your ') || msg === 'Failed to flee!') {
playerMessages.push(msg)
} else if (msg.includes('attacks') || msg.includes('hits') || msg.includes('misses')) {
enemyMessages.push(msg)
}
}
})
// 1. Process player messages immediately
playerMessages.forEach((msg: any) => {
const logId = `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
// Extract damage if present
const parsed = parseMessage(msg)
if (parsed && parsed.type === 'player_attack' && parsed.data.damage) {
addFloatingText(parsed.data.damage.toString(), 50, 30, 'damage-player-dealt')
setFlash(true)
setTimeout(() => setFlash(false), 300)
}
})
// Update enemy HP immediately
if (data.combat && !data.combat_over) {
updateCombatState({
...combatState,
combat: {
...combatState.combat,
npc_hp: data.combat.npc_hp,
// Apply server HP values IMMEDIATELY
if (data.combat) {
setLocalCombatState(prev => ({
...prev,
npcHp: data.combat.npc_hp,
npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn,
turn_time_remaining: data.combat.turn_time_remaining,
round: data.combat.round,
npc_intent: data.combat.npc_intent
}
})
npcName: resolveName(data.combat.npc_name) || prev.npcName
}));
} else if (data.combat_over && data.player_won) {
// Combat ended with victory but data.combat is null - set enemy HP to 0
setLocalCombatState(prev => ({
...prev,
npcHp: 0
}));
}
// Store current player HP
if (playerState) {
setTempPlayerHP(playerState.health)
if (data.player) {
// Store player HP to apply when enemy_attack message is processed
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
// Store player XP to apply when xp_gain message is processed
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
refreshCharacters();
}
}
// 2. Enemy turn with delay
if (enemyMessages.length > 0 && !data.combat_over) {
setEnemyThinking(true)
await new Promise(resolve => setTimeout(resolve, 2000))
setEnemyThinking(false)
enemyMessages.forEach((msg: any) => {
const logId = `enemy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: false })
// Extract damage if present
const parsed = parseMessage(msg)
if (parsed && (parsed.type === 'enemy_attack' || parsed.type === 'flee_fail') && parsed.data.damage) {
addFloatingText(parsed.data.damage.toString(), 50, 50, 'damage-player')
setShake(true)
setTimeout(() => setShake(false), 500)
}
})
// Update player HP after delay
if (data.player && playerState) {
setTempPlayerHP(null)
updatePlayerState({
...playerState,
health: data.player.hp,
max_health: data.player.max_hp ?? playerState.max_health
})
}
} else if (data.combat_over) {
// Combat ended
const playerFled = data.message.toLowerCase().includes('fled') || data.message.toLowerCase().includes('escape')
updateCombatState({
...combatState,
combat_over: true,
player_won: data.player_won || false,
player_fled: playerFled,
combat: {
...combatState.combat,
npc_hp: data.player_won ? 0 : (data.combat?.npc_hp ?? combatState.combat.npc_hp)
}
})
setTempPlayerHP(null)
if (data.player && playerState) {
updatePlayerState({
...playerState,
health: data.player.hp,
max_health: data.player.max_hp ?? playerState.max_health
})
}
}
} catch (error) {
console.error('Combat action failed:', error)
} finally {
setProcessing(false)
} catch (err) {
console.error(err);
}
}
};
// ============================================================================
// PvP Combat Actions
// ============================================================================
const handlePvPActionWrapper = async (action: string) => {
if (isProcessingQueue) return;
// Clean up targetId - standard action doesn't need it usually, or use 0
await onPvPAction(action, 0);
};
const handlePvPActionLocal = async (action: string) => {
if (processing) return
setProcessing(true)
const [isClosing, setIsClosing] = useState(false);
try {
const data = await onPvPAction(action)
const handleCloseWrapper = () => {
if (isClosing) return;
setIsClosing(true);
if (data) {
const now = new Date()
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
// Clear all dynamic elements to allow React to reconcile locally before unmounting
setFloatingTexts([]);
setAnimState({ shaking: false, flashing: false, enemyAttacking: false, playerAttacking: false });
setMessageQueue([]);
const msg = data.message || ''
const logId = `pvp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
addCombatLogEntry({ id: logId, time: timeStr, message: msg, isPlayer: true })
// Extract damage
const damageMatch = msg.match(/(\\d+) damage/)
if (damageMatch) {
addFloatingText(damageMatch[1], 50, 30, 'damage-player-dealt')
setFlash(true)
setTimeout(() => setFlash(false), 300)
}
// Small delay to ensure the DOM is clear of floating texts before unmounting the component
setTimeout(() => {
if (isPvP) {
onExitPvPCombat();
} else {
onExitCombat();
}
if (onClose) onClose();
}, 50);
};
} catch (error) {
console.error('PvP action failed:', error)
} finally {
setProcessing(false)
}
}
return (
<div className={`combat-container ${shake ? 'shake-effect' : ''}`}>
<CombatView
combatState={combatState}
combatLog={combatLog}
profile={profile}
playerState={tempPlayerHP !== null && playerState ? {
...playerState,
health: tempPlayerHP
} : playerState}
equipment={equipment}
enemyName={getTranslatedText(combatState.combat?.npc_name) || 'Enemy'}
enemyImage={combatState.combat?.npc_image || combatState.combat_image || ''}
enemyTurnMessage={enemyThinking ? '🗡️ Enemy is thinking...' : ''}
pvpTimeRemaining={pvpTimer}
turnTimeRemaining={turnTimeRemaining}
onCombatAction={handlePvEAction}
onFlee={async () => handlePvEAction('flee')}
onPvPAction={handlePvPActionLocal}
onExitCombat={onExitCombat}
onExitPvPCombat={onExitPvPCombat}
flashEnemy={flash}
buttonsDisabled={processing || enemyThinking}
<CombatView
state={localCombatState}
animState={animState}
floatingTexts={floatingTexts}
/>
</div>
)
}
export default Combat
onAction={isPvP ? handlePvPActionWrapper : handlePvEAction}
onClose={handleCloseWrapper}
isProcessing={isProcessingQueue}
combatResult={combatResult}
equipment={_equipment}
/>
);
};

View File

@@ -1,328 +1,471 @@
/* Combat Visual Effects */
/* Screen Shake */
@keyframes shake {
0% {
transform: translate(1px, 1px) rotate(0deg);
}
10% {
transform: translate(-1px, -2px) rotate(-1deg);
}
20% {
transform: translate(-3px, 0px) rotate(1deg);
}
30% {
transform: translate(3px, 2px) rotate(0deg);
}
40% {
transform: translate(1px, -1px) rotate(1deg);
}
50% {
transform: translate(-1px, 2px) rotate(-1deg);
}
60% {
transform: translate(-3px, 1px) rotate(0deg);
}
70% {
transform: translate(3px, 1px) rotate(-1deg);
}
80% {
transform: translate(-1px, -1px) rotate(1deg);
}
90% {
transform: translate(1px, 2px) rotate(0deg);
}
100% {
transform: translate(1px, -2px) rotate(-1deg);
}
/* Combat Layout */
.combat-container {
display: flex;
flex-direction: column;
width: 100%;
margin: 0 auto;
/* More transparent/themed background */
background: rgba(20, 20, 20, 0.6);
backdrop-filter: blur(5px);
border-radius: 12px;
padding: 1rem;
color: white;
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.shake-effect {
animation: shake 0.5s;
animation-iteration-count: 1;
.glow-effect {
box-shadow: 0 0 10px #ff4444, 0 0 20px #ff4444;
transition: box-shadow 0.3s ease-in-out;
}
/* Hit Flash */
@keyframes flash-red {
0% {
filter: brightness(1) sepia(0) hue-rotate(0deg) saturate(1);
}
50% {
filter: brightness(0.5) sepia(1) hue-rotate(-50deg) saturate(5);
}
/* Red tint */
100% {
filter: brightness(1) sepia(0) hue-rotate(0deg) saturate(1);
}
}
.flash-hit {
animation: flash-red 0.3s ease-out;
}
/* Dead Enemy Grayscale */
.enemy-dead {
.dead .location-image {
filter: grayscale(100%);
transition: filter 0.5s ease-out;
transition: filter 1s ease;
}
/* Fled Enemy Blueish Tint */
.enemy-fled {
filter: sepia(1) saturate(3) hue-rotate(180deg) brightness(0.8);
transition: filter 0.5s ease-out;
/* Enemy avatar now uses shared .location-image styles from Game.css */
/* ... existing code ... */
/* Action Buttons Center */
.combat-actions {
display: flex;
flex-direction: column;
align-items: center;
/* Center horizontally */
padding: 1rem 0;
width: 100%;
}
/* Floating Damage Numbers */
@keyframes float-up {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
50% {
opacity: 1;
transform: translateY(-30px) scale(1.3);
}
100% {
opacity: 0;
transform: translateY(-60px) scale(1.5);
}
.combat-header {
display: flex;
justify-content: center;
align-items: center;
margin: 0 0 1rem 0;
padding: 0;
border: none;
background: transparent;
}
.floating-text-container {
.battle-arena {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem 1rem;
position: relative;
min-height: 250px;
}
/* Combatants */
.combatant {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.combatant.enemy {
color: #ffaaaa;
}
.combatant.player {
color: #aaddff;
}
.combatant.dead .enemy-avatar {
filter: grayscale(100%) brightness(0.5);
transition: filter 1s ease-out;
}
.avatar-container {
position: relative;
width: 100px;
height: 100px;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Cleaned up old styles */
.player-placeholder {
font-size: 5rem;
color: #ff4444;
}
.player-placeholder {
color: #4488ff;
}
.vs-divider {
font-size: 1.5rem;
font-weight: bold;
color: #666;
margin: 0 1rem;
}
/* Health Bars */
.stats-container {
width: 100%;
max-width: 200px;
text-align: center;
}
.health-bar-container {
width: 100%;
height: 16px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
margin-top: 5px;
position: relative;
overflow: hidden;
}
.health-bar-fill {
height: 100%;
transition: width 0.3s ease-out;
}
.enemy-fill {
background: linear-gradient(90deg, #ff4444, #cc0000);
}
.player-fill {
background: linear-gradient(90deg, #4488ff, #0044cc);
}
.health-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.7rem;
font-weight: bold;
text-shadow: 1px 1px 1px black;
z-index: 2;
}
/* Action Buttons */
.combat-actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 0;
width: 100%;
/* Ensure buttons stack properly */
gap: 0.5rem;
}
.combat-actions-group {
display: flex;
gap: 1rem;
width: 100%;
justify-content: center;
max-width: 400px;
}
.combat-actions-group {
max-width: 400px;
/* Limit width of attack/flee buttons */
}
.btn.full-width {
width: 100%;
max-width: 300px;
/* Don't let close button get too wide */
}
.btn-attack {
background: #ff4444;
color: white;
}
.btn-flee {
background: #666;
color: white;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Combat Log */
.combat-log-container {
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
padding: 0.5rem;
margin-top: 1rem;
height: 150px;
overflow-y: auto;
font-size: 0.9rem;
}
.log-message {
padding: 2px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.log-player_attack {
color: #aaddff;
}
.log-enemy_attack {
color: #ffaaaa;
}
.log-victory {
color: #44ff44;
font-weight: bold;
}
.log-defeat {
color: #ff4444;
font-weight: bold;
}
/* Animations & Floats */
.floating-text-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 100;
z-index: 10;
}
.floating-text {
position: absolute;
font-weight: bold;
font-size: 2.5rem;
text-shadow: 3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000;
animation: float-up 2.5s ease-out forwards;
white-space: nowrap;
font-size: 1.5rem;
animation: float-up 5s forwards;
pointer-events: none;
z-index: 1000;
text-shadow: 2px 2px 0 #000;
}
.floating-text.damage-player {
.type-damage {
color: #ff4444;
}
.floating-text.damage-enemy {
color: #ff4444;
.type-crit {
color: #ffaa00;
font-size: 2rem;
}
.floating-text.damage-player-dealt {
color: #ffffff;
}
.floating-text.heal {
.type-heal {
color: #44ff44;
}
/* Intent Bubble */
.intent-bubble {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
border: 2px solid #fff;
border-radius: 20px;
padding: 5px 15px;
color: #fff;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
z-index: 10;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
animation: pop-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.type-miss {
color: #aaa;
}
@keyframes pop-in {
.type-info {
color: #ffff44;
}
@keyframes float-up {
0% {
transform: translateX(-50%) scale(0);
transform: translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateX(-50%) scale(1);
transform: translateY(-50px) scale(1.2);
opacity: 0;
}
}
.intent-icon {
font-size: 1.2em;
.type-xp {
color: #ffd700;
font-size: 1.2rem;
/* User wants it lower, so we can adjust top via inline style in TSX or here */
/* text-shadow: 1px 1px 0 #000; */
}
.intent-desc {
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
.shake-effect {
animation: shake 0.5s cubic-bezier(.36, .07, .19, .97) both;
}
/* Intent Types */
.intent-attack {
border-color: #ff4444;
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
.intent-defend {
border-color: #4488ff;
.flash-hit {
animation: flash 0.3s;
}
.intent-special {
border-color: #ffaa00;
@keyframes flash {
0% {
filter: brightness(1);
}
50% {
filter: brightness(2) sepia(1) hue-rotate(-50deg) saturate(5);
}
/* Red flash */
100% {
filter: brightness(1);
}
}
/* Container relative positioning for absolute children */
.combat-enemy-display-inline {
position: relative;
.turn-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
padding: 1rem 2rem;
border-radius: 20px;
font-size: 1.5rem;
animation: pulse 1s infinite;
z-index: 20;
}
.combat-enemy-image-large {
position: relative;
display: inline-block;
max-width: 100%;
@keyframes pulse {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
.combat-enemy-image-large img {
max-width: 100%;
height: auto;
display: block;
/* Attacking Animation */
.attacking {
animation: lunge 0.3s;
}
.combat-view {
position: relative;
/* For screen shake scope if applied here */
@keyframes lunge {
0% {
transform: translateX(0);
}
50% {
transform: translateX(20px);
}
/* Assuming LTR, for enemy use -20px via modifier if needed */
100% {
transform: translateX(0);
}
}
/* Combat Container */
.combat-container {
position: relative;
width: 100%;
.enemy.attacking {
animation: lunge-left 0.3s;
}
/* Combat Content Wrapper - Groups enemy display, turn indicator, and combat log */
.combat-content-wrapper {
display: inline-flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
max-width: 800px;
margin: 0 auto;
@keyframes lunge-left {
0% {
transform: translateX(0);
}
50% {
transform: translateX(-20px);
}
100% {
transform: translateX(0);
}
}
/* Turn Indicator - Match Enemy Image Width */
.combat-turn-indicator-inline {
width: 100%;
/* Combat Stats Layout - Staggered HP Bars */
.combat-stats-container {
display: flex;
justify-content: center;
}
/* Combat Log Styles */
.combat-log-wrapper {
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
width: 100%;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.combat-log-title {
margin: 0 0 10px 0;
font-size: 1.1em;
color: #aaa;
text-align: left;
}
.combat-log-inline {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
.stat-block {
background: rgba(0, 0, 0, 0.4);
padding: 0.5rem 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
position: relative;
}
.log-entries {
max-height: 200px;
overflow-y: auto;
.stat-block.enemy {
width: 60%;
align-self: flex-start;
border-left: 3px solid #dc3545;
}
.stat-block.player {
width: 60%;
align-self: flex-end;
border-right: 3px solid #4caf50;
}
.stat-block .stat-header {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
justify-content: space-between;
margin-bottom: 0.25rem;
color: #fff;
}
/* Custom scrollbar for combat log */
.log-entries::-webkit-scrollbar {
width: 8px;
.stat-block .stat-label {
font-weight: 600;
}
.log-entries::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
.stat-block .stat-numbers {
color: #ddd;
}
.log-entries::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.log-entries::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.log-entry {
font-size: 0.9em;
padding: 6px 8px;
line-height: 1.5;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
border-left: 3px solid transparent;
transition: background 0.2s ease;
.stat-block.player .progress-bar {
display: flex;
align-items: flex-start;
gap: 8px;
justify-content: flex-end;
}
/* Ensure progress bars look like GameHeader */
.progress-bar {
width: 100%;
height: 12px;
/* Slightly thinner than header */
background: rgba(0, 0, 0, 0.6);
border-radius: 6px;
overflow: hidden;
position: relative;
}
.log-entry:hover {
background: rgba(0, 0, 0, 0.35);
}
.log-time {
color: #888;
font-size: 0.85em;
font-family: monospace;
flex-shrink: 0;
white-space: nowrap;
}
.log-message {
flex: 1;
word-wrap: break-word;
}
.player-log {
color: #aaddff;
border-left-color: #4488ff;
}
.enemy-log {
color: #ffaaaa;
border-left-color: #ff4444;
.progress-fill {
height: 100%;
border-radius: 6px;
transition: width 0.3s ease-out;
}

View File

@@ -1,154 +1,58 @@
/**
* Combat Types
* TypeScript type definitions for the combat system
*/
// ============================================================================
// Combat Message Types
// ============================================================================
/**
* Structured combat message from the server
*/
export interface CombatMessage {
type: 'combat_start' | 'player_attack' | 'enemy_attack' | 'victory' | 'flee_fail' | string
origin: 'player' | 'enemy' | 'neutral'
data: {
damage?: number
npc_name?: string | { en: string; es: string }
armor_absorbed?: number
[key: string]: any
}
type: string;
origin: 'player' | 'enemy' | 'neutral' | 'system';
data: Record<string, any>;
timestamp?: string;
}
/**
* Combat log entry displayed in the UI
*/
export interface CombatLogEntry {
id: string
time: string
message: string | CombatMessage
isPlayer: boolean
}
// ============================================================================
// Animation Types
// ============================================================================
/**
* Floating damage text animation
*/
export interface FloatingText {
id: number
text: string
x: number
y: number
type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal'
id: string;
text: string;
type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp';
x: number; // Percentage 0-100
y: number; // Percentage 0-100
origin: 'player' | 'enemy';
timestamp: number;
}
/**
* Animation state for combat effects
*/
export interface CombatAnimationState {
shake: boolean
flash: boolean
enemyThinking: boolean
}
// ============================================================================
// Combat State Types
// ============================================================================
/**
* PvE Combat Data
*/
export interface PvECombat {
npc_id: string
npc_name: string | { en: string; es: string }
npc_hp: number
npc_max_hp: number
npc_image: string
turn: 'player' | 'enemy'
round: number
turn_time_remaining?: number
npc_intent?: 'attack' | 'defend' | 'special'
}
/**
* PvP Combat Player Info
*/
export interface PvPCombatPlayer {
id: number
username: string
level: number
hp: number
max_hp: number
}
/**
* PvP Combat Data
*/
export interface PvPCombat {
id: number
attacker: PvPCombatPlayer
defender: PvPCombatPlayer
is_attacker: boolean
your_turn: boolean
current_turn: 'attacker' | 'defender'
time_remaining: number
location_id: string
last_action?: string
combat_over: boolean
attacker_fled: boolean
defender_fled: boolean
}
/**
* Main Combat State
*/
export interface CombatState {
// Common fields
in_combat: boolean
combat_over: boolean
player_won?: boolean
player_fled?: boolean
// PvE fields
is_pvp: boolean
combat?: PvECombat
combat_image?: string
// PvP fields
in_pvp_combat?: boolean
pvp_combat?: PvPCombat
inCombat: boolean;
turn: 'player' | 'enemy';
npcId?: string;
npcName?: string;
npcHp: number;
npcMaxHp: number;
npcImage?: string;
playerHp: number;
playerMaxHp: number;
messages: CombatMessage[]; // History of messages
turnTimeRemaining?: number;
round: number;
isPvP?: boolean;
opponentName?: string;
}
// ============================================================================
// Combat Action Types
// ============================================================================
/**
* Combat action response from server
*/
export interface CombatActionResponse {
success: boolean
message: string
combat_over: boolean
player_won?: boolean
combat?: PvECombat
success: boolean;
messages: CombatMessage[]; // The structured messages from this action
combat_over: boolean;
player_won?: boolean;
combat?: any; // Updated combat state from API
pvp_combat?: any; // Updated PvP combat state from API
player?: {
hp: number
max_hp: number
xp: number
level: number
}
hp: number;
max_hp: number;
xp: number;
level: number;
};
winner_id?: string;
}
/**
* PvP Combat action response from server
*/
export interface PvPCombatActionResponse {
success: boolean
message: string
combat?: PvPCombat
export interface AnimationState {
shaking: boolean;
flashing: boolean;
enemyAttacking: boolean;
playerAttacking: boolean;
playerHit?: boolean; // New: Player taking damage
npcHit?: boolean; // New: NPC taking damage
}

View File

@@ -1,420 +1,274 @@
import { useTranslation } from 'react-i18next'
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
import type { FloatingText, CombatMessage } from './CombatTypes'
import { getTranslatedText } from '../../utils/i18nUtils'
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAudio } from '../../contexts/AudioContext';
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
import { Equipment } from './types';
import './CombatEffects.css';
interface CombatViewProps {
combatState: CombatState
combatLog: CombatLogEntry[]
profile: Profile | null
playerState: PlayerState | null
equipment: Equipment
enemyName: string
enemyImage: string
enemyTurnMessage: string
pvpTimeRemaining: number | null
turnTimeRemaining: number | null
onCombatAction: (action: string) => void
onFlee: () => void
onPvPAction: (action: string) => void
onExitCombat: () => void
onExitPvPCombat: () => void
flashEnemy?: boolean
buttonsDisabled?: boolean
floatingTexts?: FloatingText[]
state: CombatState;
animState: AnimationState;
floatingTexts: FloatingText[];
onAction: (action: string) => void;
onClose: () => void;
isProcessing: boolean;
combatResult: 'victory' | 'defeat' | 'fled' | null;
equipment?: Equipment | any;
}
function CombatView({
combatState,
combatLog,
profile: _profile,
playerState,
enemyName,
enemyImage,
enemyTurnMessage,
pvpTimeRemaining,
turnTimeRemaining,
onCombatAction,
onPvPAction,
onExitCombat,
onExitPvPCombat,
flashEnemy,
buttonsDisabled,
floatingTexts = []
}: CombatViewProps) {
const { t } = useTranslation()
const displayEnemyName = typeof enemyName === 'object' ? getTranslatedText(enemyName) : (enemyName || 'Enemy')
export const CombatView: React.FC<CombatViewProps> = ({
state,
animState,
floatingTexts,
onAction,
onClose,
isProcessing,
combatResult,
equipment
}) => {
const { t } = useTranslation();
const { playSfx } = useAudio();
// ============================================================================
// Message Rendering
// ============================================================================
// SFX Logic triggered by state or anim states
useEffect(() => {
// Check for combat completion sounds
if (combatResult === 'victory') {
playSfx('/audio/sfx/victory.wav');
} else if (combatResult === 'defeat') {
playSfx('/audio/sfx/defeat.wav');
} else if (combatResult === 'fled') {
playSfx('/audio/sfx/flee.wav');
}
}, [combatResult, playSfx]);
const renderCombatMessage = (msg: any) => {
// Handle string messages
if (typeof msg === 'string') {
return msg
// Track animation states to trigger attack/hit sounds
useEffect(() => {
// Player Attack Sound
if (animState.playerAttacking) {
if (equipment && equipment.main_hand) {
// Try to derive weapon type from name or properties
// This is a naive check; ideally the backend sends weapon type.
// We'll check for common keywords in the icon or name.
let weaponType = 'default';
const weaponName = (typeof equipment.main_hand.name === 'string'
? equipment.main_hand.name
: (equipment.main_hand.name?.en || '')
).toLowerCase();
if (weaponName.includes('sword') || weaponName.includes('blade')) weaponType = 'sword';
else if (weaponName.includes('axe')) weaponType = 'axe';
else if (weaponName.includes('bow')) weaponType = 'bow';
else if (weaponName.includes('hammer') || weaponName.includes('mace')) weaponType = 'blunt';
else if (weaponName.includes('dagger')) weaponType = 'dagger';
else if (weaponName.includes('fist') || !equipment.main_hand) weaponType = 'punch';
playSfx(`/audio/sfx/attack_${weaponType}.wav`, '/audio/sfx/attack_default.wav');
} else {
// Unarmed
playSfx('/audio/sfx/attack_punch.wav', '/audio/sfx/attack_default.wav');
}
}
// Handle legacy formatted messages
if (!msg || !msg.type) {
return String(msg)
// Enemy Attack Sound
if (animState.enemyAttacking) {
// We can use state.npcId to get specific enemy sounds
if (state.npcId) {
playSfx(`/audio/sfx/attack_enemy_${state.npcId}.wav`, '/audio/sfx/attack_enemy_default.wav');
} else {
playSfx('/audio/sfx/attack_enemy_default.wav', '/audio/sfx/attack_default.wav');
}
}
const message = msg as CombatMessage
const { type, data } = message
switch (type) {
case 'combat_start':
return t('combat.messages.combat_start', { enemy: getTranslatedText(data.npc_name) })
case 'player_attack':
return t('combat.messages.player_attack', { damage: data.damage })
case 'enemy_attack':
return t('combat.messages.enemy_attack', {
enemy: getTranslatedText(data.npc_name),
damage: data.damage
})
case 'victory':
return t('combat.messages.victory', { enemy: getTranslatedText(data.npc_name) })
case 'flee_fail':
return t('combat.messages.flee_fail', {
enemy: getTranslatedText(data.npc_name),
damage: data.damage
})
default:
// Fallback to JSON string for unknown types
return JSON.stringify(msg)
// Hit reaction (when shaking) - distinguishing origin would be better
// but animState.shaking is general.
// However, we know who is attacking from the other flags, so the *other* valid one is getting hit.
// Simpler: just play a generic hit sound when someone gets hit.
if (animState.shaking && !animState.playerAttacking && !animState.enemyAttacking) {
// This case might not happen often if shaking is coupled with attacking in parent.
// Actually Combat.tsx triggers 'shaking' ON attack for impact effect.
// So we might play 'hit' sound alongside attack sound?
// Or let's trigger hit sound specifically when damage numbers appear.
// Since we can't easily hook into floating text creation here without prop drill or context,
// we'll rely on the visual 'flashing' which usually implies taking damage.
}
}
// ============================================================================
// Format Timer Display
// ============================================================================
if (animState.flashing) {
// Someone took damage
playSfx('/audio/sfx/hit.wav');
}
const formatTimer = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${String(secs).padStart(2, '0')}`
}
}, [animState.playerAttacking, animState.enemyAttacking, animState.flashing, equipment, state.npcId, playSfx]);
// Auto-scroll log is less critical for table but good to keep if we can target the container
// For the table, we might just rely on normal scroll or add ref to the container
// Auto-scroll log to top on new entry
useEffect(() => {
const container = document.querySelector('.combat-log-container');
if (container) {
container.scrollTop = 0;
}
}, [state.messages]);
const getHealthPercent = (current: number, max: number) => {
return Math.max(0, Math.min(100, (current / max) * 100));
};
return (
<div className="combat-view">
<div className="combat-header-inline">
<h2 style={{ background: 'linear-gradient(90deg, #4CAF50, #2196F3)', padding: '0.5rem', borderRadius: '8px' }}>
🆕 NEW COMBAT - {combatState.is_pvp ? `⚔️ ${t('combat.title')} - PvP` : `⚔️ ${t('combat.title')} - ${displayEnemyName}`}
<div className="combat-container">
{/* Header (Location View Style) */}
<div className="combat-header">
<h2 className="centered-heading">
{state.isPvP ? t('combat.pvp_title') : t('combat.title')}
<span style={{ margin: '0 0.5rem', color: '#aaa', fontSize: '0.9em' }}>vs</span>
{state.npcName || t('combat.unknown_enemy')}
{state.turnTimeRemaining !== undefined && (
<span className="danger-badge danger-2" style={{ fontSize: '0.8rem', marginLeft: '0.5rem' }}>
{state.turnTimeRemaining}s
</span>
)}
</h2>
</div>
{combatState.is_pvp ? (
/* ================================================================ */
/* PvP Combat UI */
/* ================================================================ */
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Opponent Display */}
<div className="combat-enemy-image-large">
<div className="floating-texts-container">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
</div>
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
{/* Main Content Vertical Stack */}
<div className="combat-main-content">
if (!opponent) return <div className="pvp-opponent-avatar"></div>
return (
<div className="pvp-opponent-avatar" style={{ fontSize: '4rem', textAlign: 'center' }}>
👤
<div style={{ fontSize: '1rem', marginTop: '0.5rem' }}>
{opponent.username} (Lv. {opponent.level})
</div>
</div>
)
})()}
</div>
<div className="combat-enemy-info-inline">
{/* Opponent HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const opponent = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.defender :
combatState.pvp_combat.attacker
if (!opponent) return null
return (
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{opponent.username}: {opponent.hp} / {opponent.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(opponent.hp / opponent.max_hp) * 100}%`,
transition: 'width 0.5s ease-in-out'
}}
/>
</div>
</div>
)
})()}
{/* Player HP Bar */}
{(() => {
if (!combatState.pvp_combat) return null
const you = combatState.pvp_combat.is_attacker ?
combatState.pvp_combat.attacker :
combatState.pvp_combat.defender
if (!you) return null
return (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
{t('combat.playerHp')}: {you.hp} / {you.max_hp}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(you.hp / you.max_hp) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)',
transition: 'width 0.5s ease-in-out'
}}
/>
</div>
</div>
)
})()}
</div>
</div>
<div className="combat-turn-indicator-inline">
{combatState.pvp_combat.combat_over ? (
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
</span>
) : combatState.pvp_combat.your_turn ? (
<span className="your-turn">
{t('combat.yourTurn')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)})
</span>
{/* 1. Enemy Avatar (Location Image Style) */}
{/* Shake on npcHit, Attack on enemyAttacking, Dead on victory */}
<div className={`enemy-display ${animState.enemyAttacking ? 'attacking' : ''} ${animState.npcHit ? 'shake-effect flash-hit' : ''} ${combatResult === 'victory' ? 'dead' : ''}`}>
<div className="location-image-container">
{state.npcImage ? (
<img src={state.npcImage} alt={state.npcName} className="location-image" />
) : (
<span className="enemy-turn">
{t('combat.waiting')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)})
</span>
<div className="enemy-placeholder">💀</div>
)}
</div>
</div>
<div className="combat-actions-inline">
{!combatState.pvp_combat.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onPvPAction('attack')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('combat.actions.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onPvPAction('flee')}
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
>
{t('combat.actions.flee')}
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitPvPCombat}
>
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? '✅ Continue' : '💀 Return'}
</button>
)}
</div>
{/* 2. HP Bars (Character Sheet Style) - Staggered Lines */}
<div className="combat-stats-container">
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
<div className="combat-log-inline">
<div className="log-entries">
<div className="log-list">
{combatLog.length > 0 ? (
combatLog.map((entry: any) => (
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{renderCombatMessage(entry.message)}</span>
</div>
))
) : (
<div className="log-entry"><span className="log-message">PvP Combat started...</span></div>
)}
{/* Enemy HP (Left) */}
{/* Also shake the stat block on npcHit if desired, or just avatar. User said "both image and health bar should shake" */}
<div className={`stat-block enemy ${animState.npcHit ? 'shake-effect' : ''}`}>
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
{floatingTexts.filter(ft => ft.origin === 'enemy').map(ft => (
<div key={ft.id} className={`floating-text type-${ft.type}`} style={{ left: `${ft.x}%`, top: `${ft.y - 50}%` }}>
{ft.text}
</div>
</div>
))}
</div>
<div className="stat-header">
<span className="stat-label">{t('common.enemy')}</span>
<span className="stat-numbers">{state.npcHp} / {state.npcMaxHp}</span>
</div>
<div className="progress-bar">
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.npcHp, state.npcMaxHp)}%`, background: 'linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%)' }}></div>
</div>
</div>
{/* Player HP (Right) */}
<div className={`stat-block player ${animState.playerAttacking ? 'attacking' : ''} ${animState.playerHit ? 'shake-effect flash-hit' : ''}`}>
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
{floatingTexts.filter(ft => ft.origin === 'player').map(ft => (
<div key={ft.id} className={`floating-text type-${ft.type}`} style={{ left: `${ft.x}%`, top: `${ft.y - 50}%` }}>
{ft.text}
</div>
))}
</div>
<div className="stat-header">
<span className="stat-label">{t('common.you')}</span>
<span className="stat-numbers">{state.playerHp} / {state.playerMaxHp}</span>
</div>
<div className="progress-bar">
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.playerHp, state.playerMaxHp)}%`, background: 'linear-gradient(90deg, #4caf50 0%, #8bc34a 100%)' }}></div>
</div>
</div>
</div>
) : (
/* ================================================================ */
/* PvE Combat UI */
/* ================================================================ */
<>
<div className="combat-content-wrapper">
<div className="combat-enemy-display-inline">
{/* Enemy Intent Bubble */}
{combatState.combat?.npc_intent && !combatState.combat_over && (
<div className={`intent-bubble intent-${combatState.combat.npc_intent}`}>
<span className="intent-icon">
{combatState.combat.npc_intent === 'attack' ? '⚔️' :
combatState.combat.npc_intent === 'defend' ? '🛡️' :
combatState.combat.npc_intent === 'special' ? ' 🔥' : '❓'}
</span>
<span className="intent-desc">{combatState.combat.npc_intent}</span>
</div>
)}
<div className="combat-enemy-image-large">
<div className="floating-texts-container">
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`floating-text ${ft.type}`}
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
{ft.text}
</div>
))}
</div>
<img
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
className={`${flashEnemy ? 'flash-hit' : ''}
${combatState.combat_over && combatState.player_won ? 'enemy-dead' : ''}
${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : ''}`}
/>
</div>
{/* 3. Actions */}
<div className="combat-actions">
<button
className="btn btn-primary full-width glow-effect"
onClick={onClose}
style={{ display: combatResult ? 'block' : 'none', margin: '0 auto' }}
>
{t('common.close')}
</button>
<div className="combat-enemy-info-inline">
<div className="combat-hp-bar-container-inline enemy-hp-bar">
<div className="combat-hp-bar-inline">
<div className="combat-stat-label-inline">
{t('combat.enemyHp')}: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`,
transition: 'width 0.5s ease-in-out'
}}
/>
</div>
</div>
{playerState && (
<div className="combat-hp-bar-container-inline player-hp-bar" style={{ marginTop: '0.75rem' }}>
<div className="combat-hp-bar-inline" style={{ background: 'rgba(255, 107, 107, 0.2)' }}>
<div className="combat-stat-label-inline" style={{ color: '#ff6b6b' }}>
{t('combat.playerHp')}: {playerState.health} / {playerState.max_health}
</div>
<div
className="combat-hp-fill-inline"
style={{
width: `${(playerState.health / playerState.max_health) * 100}%`,
background: 'linear-gradient(90deg, #f44336, #ff6b6b)',
transition: 'width 0.5s ease-in-out'
}}
/>
</div>
</div>
)}
</div>
</div>
<div className="combat-actions-group" style={{ display: !combatResult ? 'flex' : 'none', gap: '1rem', width: '100%', justifyContent: 'center' }}>
<button
className="btn btn-attack"
onClick={() => onAction('attack')}
disabled={isProcessing || state.turn !== 'player'}
>
👊 {t('combat.actions.attack')}
</button>
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
{!combatState.combat_over ? (
enemyTurnMessage ? (
<span className="enemy-turn">{t('combat.thinking')}</span>
) : combatState.combat?.turn === 'player' ? (
<>
<span className="your-turn"> {t('combat.yourTurn')}</span>
{turnTimeRemaining !== null && (
<span className="turn-timer" style={{ marginLeft: '1rem', fontSize: '0.9em', opacity: 0.8 }}>
{formatTimer(turnTimeRemaining)}
</span>
)}
</>
) : (
<span className="enemy-turn"> {t('combat.enemyTurn')}</span>
)
) : (
<span className={combatState.player_won ? "your-turn" : combatState.player_fled ? "your-turn" : "enemy-turn"}>
{combatState.player_won ? `${t('combat.victory')}` : combatState.player_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
</span>
)}
</div>
{/* PvE Combat Actions */}
<div className="combat-actions-inline">
{!combatState.combat_over ? (
<>
<button
className="combat-action-btn attack-btn"
onClick={() => onCombatAction('attack')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('combat.actions.attack')}
</button>
<button
className="combat-action-btn flee-btn"
onClick={() => onCombatAction('flee')}
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
>
{t('combat.actions.flee')}
</button>
</>
) : (
<button
className="combat-action-btn exit-btn"
onClick={onExitCombat}
>
{combatState.player_won ? '✅ Continue' : combatState.player_fled ? '✅ Continue' : '💀 Return'}
</button>
)}
</div>
{/* Combat Log */}
<div className="combat-log-wrapper">
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
<div className="combat-log-inline">
<div className="log-entries">
<div className="log-list">
{combatLog.length > 0 ? (
combatLog.map((entry: any) => (
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
<span className="log-time">[{entry.time}]</span>
<span className="log-message">{renderCombatMessage(entry.message)}</span>
</div>
))
) : (
<div className="log-entry"><span className="log-message">Combat started...</span></div>
)}
</div>
</div>
</div>
</div>
<button
className="btn btn-flee"
onClick={() => onAction('flee')}
disabled={isProcessing || state.turn !== 'player'}
>
🏃 {t('combat.actions.flee')}
</button>
</div>
</>
</div>
{/* 4. Log (Table) */}
<div className="combat-log-container">
<table className="combat-log-table">
<tbody>
{[...state.messages].reverse().map((msg, index) => {
let text = "";
let className = `log-row log-${msg.type}`;
if (msg.data && msg.data.message) {
text = msg.data.message;
} else {
switch (msg.type) {
case 'combat_start': text = t('combat.start'); break;
case 'player_attack': text = t('combat.log.player_attack', { damage: msg.data?.damage || 0 }); break;
case 'enemy_attack':
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
className += " text-danger";
break;
case 'player_miss': text = t('combat.log.player_miss'); break;
case 'enemy_miss': text = t('combat.log.enemy_miss'); break;
case 'victory': text = t('combat.victory'); className += " text-success bold"; break;
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
case 'flee_success': text = t('combat.flee.success'); break;
case 'flee_fail': text = t('combat.flee.fail'); break;
case 'item_broken': text = t('combat.item_broken', { item: msg.data?.item_name }); break;
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
case 'text': text = msg.data?.text || ""; break;
default: text = msg.type;
}
}
const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return (
<tr key={index} className={className}>
<td className="log-time">[{time}]</td>
<td className="log-event">{text}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Overlay for Enemy Turn / Processing */}
{/* Overlay for Enemy Turn / Processing */}
{isProcessing && !combatResult && state.turn === 'enemy' && (
<div className="turn-overlay">
{t('combat.enemy_turn')}
</div>
)}
</div>
)
}
export default CombatView
);
};

View File

@@ -757,3 +757,37 @@
gap: 0.5rem 1rem;
align-items: center;
}
/* Backpack Category Sections */
.backpack-category-section {
margin-bottom: 0.5rem;
}
.subcategory-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid #4299e1;
margin: 0.5rem 0;
border-radius: 0 4px 4px 0;
}
.subcat-icon {
font-size: 1rem;
}
.subcat-label {
font-size: 0.8rem;
font-weight: 500;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.subcat-count {
font-size: 0.75rem;
color: #718096;
margin-left: auto;
}

View File

@@ -1,5 +1,6 @@
import { MouseEvent, ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useAudio } from '../../contexts/AudioContext'
import { PlayerState, Profile, Equipment } from './types'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
@@ -35,6 +36,7 @@ function InventoryModal({
onDropItem
}: InventoryModalProps) {
const { t } = useTranslation()
const { playSfx } = useAudio()
// Categories for the sidebar
const categories = [
{ id: 'all', label: t('categories.all'), icon: '🎒' },
@@ -246,28 +248,49 @@ function InventoryModal({
{/* Right: Actions */}
<div className="item-actions-section">
{item.consumable && (
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>{t('game.use')}</button>
<button className="action-btn use" onClick={() => {
playSfx('/audio/sfx/use.wav')
onUseItem(item.item_id, item.id)
}}>{t('game.use')}</button>
)}
{item.equippable && !item.is_equipped && (
<button className="action-btn equip" onClick={() => onEquipItem(item.id)}>{t('game.equip')}</button>
<button className="action-btn equip" onClick={() => {
playSfx('/audio/sfx/equip.wav')
onEquipItem(item.id)
}}>{t('game.equip')}</button>
)}
{item.is_equipped && (
<button className="action-btn unequip" onClick={() => onUnequipItem(item.slot)}>{t('game.unequip')}</button>
<button className="action-btn unequip" onClick={() => {
playSfx('/audio/sfx/unequip.wav')
onUnequipItem(item.slot)
}}>{t('game.unequip')}</button>
)}
<div className="drop-actions-group">
{item.quantity > 1 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, item.quantity)}>{t('game.dropAll')}</button>
)}
<button className={`action-btn drop single`} onClick={() => {
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, 1)
}}>
{item.quantity === 1 ? t('game.drop') : 'x1' }
</button>
{item.quantity >= 5 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
<button className="action-btn drop" onClick={() => {
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, 5)
}}>x5</button>
)}
{item.quantity >= 10 && (
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 10)}>x10</button>
<button className="action-btn drop" onClick={() => {
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, 10)
}}>x10</button>
)}
{item.quantity > 1 && (
<button className="action-btn drop" onClick={() => {
playSfx('/audio/sfx/drop.wav')
onDropItem(item.item_id, item.id, item.quantity)
}}>{t('game.dropAll')}</button>
)}
<button className={`action-btn drop ${item.quantity === 1 ? 'single' : ''}`} onClick={() => onDropItem(item.item_id, item.id, item.quantity === 1 ? 1 : item.quantity)}>
{item.quantity === 1 ? t('game.drop') : t('game.dropAll')}
</button>
</div>
</div>
</div>
@@ -379,11 +402,29 @@ function InventoryModal({
</>
)}
{/* Backpack */}
{/* Backpack - grouped by categories */}
{filteredItems.some((item: any) => !item.is_equipped) && (
<>
<div className="category-header">🎒 {t('game.backpack')}</div>
{filteredItems.filter((item: any) => !item.is_equipped).map((item: any, i: number) => renderItemCard(item, i))}
{/* 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 (
<div key={cat.id} className="backpack-category-section">
<div className="subcategory-header">
<span className="subcat-icon">{cat.icon}</span>
<span className="subcat-label">{cat.label}</span>
<span className="subcat-count">({categoryItems.length})</span>
</div>
{categoryItems.map((item: any, i: number) => renderItemCard(item, i))}
</div>
);
})}
</>
)}
</>

View File

@@ -1,6 +1,7 @@
import type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types'
import { useTranslation } from 'react-i18next'
import { useAudio } from '../../contexts/AudioContext'
import Workbench from './Workbench'
import { getAssetPath } from '../../utils/assetPath'
import { getTranslatedText } from '../../utils/i18nUtils'
@@ -83,6 +84,7 @@ function LocationView({
onUncraft
}: LocationViewProps) {
const { t } = useTranslation()
const { playSfx } = useAudio()
return (
<div className="location-view">
<div className="location-info">
@@ -216,7 +218,10 @@ function LocationView({
</div>
<button
className="entity-action-btn loot-btn"
onClick={() => onLootCorpse(String(corpse.id))}
onClick={() => {
playSfx('/audio/sfx/interact.wav')
onLootCorpse(String(corpse.id))
}}
disabled={corpse.loot_count === 0}
>
🔍 {t('common.examine')}
@@ -360,7 +365,10 @@ function LocationView({
{item.quantity === 1 ? (
<button
className="entity-action-btn pickup"
onClick={() => onPickup(item.id, 1)}
onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, 1)
}}
>
{t('common.pickUp')}
</button>
@@ -368,14 +376,26 @@ function LocationView({
<div className="item-pickup-btn-container">
<button className="entity-action-btn pickup">{t('common.pickUp')} </button>
<div className="item-pickup-menu">
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>{t('common.pickUp')} 1</button>
<button className="item-pickup-option" onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, 1)
}}>{t('common.pickUp')} 1</button>
{item.quantity >= 5 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 5)}>{t('common.pickUp')} 5</button>
<button className="item-pickup-option" onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, 5)
}}>{t('common.pickUp')} 5</button>
)}
{item.quantity >= 10 && (
<button className="item-pickup-option" onClick={() => onPickup(item.id, 10)}>{t('common.pickUp')} 10</button>
<button className="item-pickup-option" onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, 10)
}}>{t('common.pickUp')} 10</button>
)}
<button className="item-pickup-option" onClick={() => onPickup(item.id, item.quantity)}>
<button className="item-pickup-option" onClick={() => {
playSfx('/audio/sfx/pickup.wav')
onPickup(item.id, item.quantity)
}}>
{t('common.pickUpAll')} ({item.quantity})
</button>
</div>
@@ -410,14 +430,14 @@ function LocationView({
onClick={() => onInitiatePvP(player.id)}
title={`Attack ${player.name || player.username}`}
>
Attack
{t('game.attack')}
</button>
)}
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
<div className="pvp-disabled-reason">Level difference too high</div>
<div className="pvp-disabled-reason">{t('game.levelDifferenceTooHigh')}</div>
)}
{!player.can_pvp && location.danger_level !== undefined && location.danger_level < 3 && (
<div className="pvp-disabled-reason">Area too safe for PvP</div>
<div className="pvp-disabled-reason">{t('game.areaTooSafeForPvP')}</div>
)}
</div>
))}

View File

@@ -224,24 +224,30 @@ function MovementControls({
const cooldownRemaining = cooldownExpiry && cooldownExpiry > now
? Math.ceil(cooldownExpiry - now)
: 0
const staminaCost = action.stamina_cost || 1
const insufficientStamina = profile ? profile.stamina < staminaCost : false
return (
<button
key={action.id}
className="interact-btn"
disabled={!!combatState || cooldownRemaining > 0}
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
title={
combatState
? 'Cannot interact during combat'
: cooldownRemaining > 0
? `Wait ${cooldownRemaining}s`
: getTranslatedText(action.description)
profile?.is_dead
? t('messages.youAreDead')
: combatState
? t('messages.cannotInteractInCombat')
: insufficientStamina
? t('messages.notEnoughStamina', { need: staminaCost, have: profile?.stamina ?? 0 })
: cooldownRemaining > 0
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
: getTranslatedText(action.description)
}
>
{getTranslatedText(action.name)}
<span className="stamina-cost">
{cooldownRemaining > 0 ? `${cooldownRemaining}s` : `${action.stamina_cost}`}
{cooldownRemaining > 0 ? `${cooldownRemaining}s` : `${staminaCost}`}
</span>
</button>
)

View File

@@ -910,8 +910,8 @@ export function useGameEngine(
// Map API field names to playerState field names
const mappedData: any = {}
// Skip HP updates if in combat (Combat.tsx handles HP timing)
if (playerData.hp !== undefined && !combatState) {
// HP updates are now controlled by Combat.tsx - it calls updatePlayerState at the right time
if (playerData.hp !== undefined) {
mappedData.health = playerData.hp
}
if (playerData.max_hp !== undefined) {
@@ -929,8 +929,8 @@ export function useGameEngine(
setPlayerState((prev: any) => prev ? { ...prev, ...mappedData } : null)
}
// Also update profile for consistency (skip HP if in combat)
if (playerData.hp !== undefined && profile && !combatState) {
// Also update profile for consistency
if (playerData.hp !== undefined && profile) {
setProfile((prev: any) => prev ? { ...prev, hp: playerData.hp } : null)
}
if (playerData.xp !== undefined && profile) {

View File

@@ -0,0 +1,115 @@
import React, { createContext, useContext, useState } from 'react';
import { isElectronApp } from '../utils/assetPath';
interface AudioContextType {
masterVolume: number;
musicVolume: number;
sfxVolume: number;
isMuted: boolean;
setMasterVolume: (val: number) => void;
setMusicVolume: (val: number) => void;
setSfxVolume: (val: number) => void;
setIsMuted: (val: boolean) => void;
playSfx: (path: string, fallbackPath?: string) => void;
}
const AudioContext = createContext<AudioContextType | undefined>(undefined);
export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Initialize state from localStorage or defaults
const [masterVolume, setMasterVolumeState] = useState(() => {
const saved = localStorage.getItem('audio_masterVolume');
return saved ? parseFloat(saved) : 1.0;
});
const [musicVolume, setMusicVolumeState] = useState(() => {
const saved = localStorage.getItem('audio_musicVolume');
return saved ? parseFloat(saved) : 0.5;
});
const [sfxVolume, setSfxVolumeState] = useState(() => {
const saved = localStorage.getItem('audio_sfxVolume');
return saved ? parseFloat(saved) : 0.8;
});
const [isMuted, setIsMutedState] = useState(() => {
const saved = localStorage.getItem('audio_isMuted');
return saved ? JSON.parse(saved) : false;
});
// Persistence wrappers
const setMasterVolume = (val: number) => {
setMasterVolumeState(val);
localStorage.setItem('audio_masterVolume', val.toString());
};
const setMusicVolume = (val: number) => {
setMusicVolumeState(val);
localStorage.setItem('audio_musicVolume', val.toString());
};
const setSfxVolume = (val: number) => {
setSfxVolumeState(val);
localStorage.setItem('audio_sfxVolume', val.toString());
};
const setIsMuted = (val: boolean) => {
setIsMutedState(val);
localStorage.setItem('audio_isMuted', JSON.stringify(val));
};
const playSfx = (path: string, fallbackPath?: string) => {
if (isMuted) return;
// Calculate effective volume
const effectiveVolume = masterVolume * sfxVolume;
if (effectiveVolume <= 0) return;
// Handle path correction for Electron vs Browser
const resolvePath = (p: string) => {
if (p.startsWith('http') || p.startsWith('file')) return p;
// Ensure leading slash for browser, dot slash for electron relative
const cleanPath = p.startsWith('/') ? p.slice(1) : p;
return isElectronApp() ? `./${cleanPath}` : `/${cleanPath}`;
};
const primarySrc = resolvePath(path);
const audio = new Audio(primarySrc);
audio.volume = effectiveVolume;
const playPromise = audio.play();
playPromise.catch((error) => {
// If primary fails (e.g. 404 or format issue), try fallback
console.warn(`SFX failed: ${path}`, error);
if (fallbackPath) {
const fallbackSrc = resolvePath(fallbackPath);
console.log(`Trying fallback SFX: ${fallbackPath}`);
const fallbackAudio = new Audio(fallbackSrc);
fallbackAudio.volume = effectiveVolume;
fallbackAudio.play().catch(e => console.error(`Fallback SFX failed: ${fallbackPath}`, e));
}
});
};
return (
<AudioContext.Provider value={{
masterVolume,
musicVolume,
sfxVolume,
isMuted,
setMasterVolume,
setMusicVolume,
setSfxVolume,
setIsMuted,
playSfx
}}>
{children}
</AudioContext.Provider>
);
};
export const useAudio = () => {
const context = useContext(AudioContext);
if (context === undefined) {
throw new Error('useAudio must be used within an AudioProvider');
}
return context;
};

View File

@@ -1,6 +1,11 @@
import { createContext, useState, useEffect, ReactNode } from 'react'
import { createContext, useState, useEffect, ReactNode, useContext } from 'react'
import api, { authApi, characterApi, Account, Character } from '../services/api'
// ... (interface remains same) ...
export const useAuth = () => useContext(AuthContext)
interface AuthContextType {
isAuthenticated: boolean
loading: boolean

View File

@@ -19,7 +19,9 @@
"fight": "Fight",
"pickUp": "Pick Up",
"pickUpAll": "Pick Up All",
"qty": "Qty"
"qty": "Qty",
"enemy": "Enemy",
"you": "You"
},
"auth": {
"login": "Login",
@@ -78,7 +80,9 @@
"weight": "Weight",
"volume": "Volume",
"durability": "Durability",
"noItemsFound": "No items found in this category"
"noItemsFound": "No items found in this category",
"levelDifferenceTooHigh": "Level difference too high",
"areaTooSafeForPvP": "Area too safe for PvP"
},
"location": {
"recentActivity": "📜 Recent Activity",
@@ -141,6 +145,9 @@
},
"combat": {
"title": "Combat",
"pvp_title": "Duel",
"unknown_enemy": "Unknown Enemy",
"start": "Combat started!",
"inCombat": "In Combat",
"yourTurn": "Your Turn",
"enemyTurn": "Enemy's Turn",
@@ -184,6 +191,20 @@
"enemyMiss": "Enemy missed!",
"armorAbsorbed": "Armor absorbed {{armor}} damage",
"itemBroke": "{{item}} broke!"
},
"log": {
"combat_start": "Combat started!",
"combat_initiation": "Combat initiated!",
"ambush": "You were ambushed!",
"pvp_attack": "You attacked another player!",
"pvp_defense": "You are under attack by another player!",
"player_attack": "You hit for {{damage}} damage",
"enemy_attack": "Enemy hits for {{damage}} damage",
"player_miss": "You missed!",
"enemy_miss": "Enemy missed!",
"item_broken": "Your {{item}} broke!",
"xp_gain": "You gained {{xp}} XP!",
"flee_success": "You managed to escape!"
}
},
"equipment": {

View File

@@ -78,7 +78,9 @@
"weight": "Peso",
"volume": "Volumen",
"durability": "Durabilidad",
"noItemsFound": "No se encontraron objetos en esta categoría"
"noItemsFound": "No se encontraron objetos en esta categoría",
"levelDifferenceTooHigh": "Nivel demasiado alto",
"areaTooSafeForPvP": "Área demasiado segura para PvP"
},
"location": {
"recentActivity": "📜 Actividad Reciente",
@@ -141,6 +143,9 @@
},
"combat": {
"title": "Combate",
"pvp_title": "Duelo",
"unknown_enemy": "Enemigo Desconocido",
"start": "¡Combate iniciado!",
"inCombat": "En Combate",
"yourTurn": "Tu Turno",
"enemyTurn": "Turno del Enemigo",
@@ -184,6 +189,20 @@
"enemyMiss": "¡El enemigo falló!",
"armorAbsorbed": "La armadura absorbió {{armor}} de daño",
"itemBroke": "¡{{item}} se rompió!"
},
"log": {
"combat_start": "¡Combate iniciado!",
"combat_initiation": "¡Combate iniciado!",
"ambush": "¡Te emboscaron!",
"pvp_attack": "¡Atacaste a otro jugador!",
"pvp_defense": "¡Estás bajo ataque de otro jugador!",
"player_attack": "Golpeas por {{damage}} de daño",
"enemy_attack": "El enemigo golpea por {{damage}} de daño",
"player_miss": "¡Fallaste!",
"enemy_miss": "¡El enemigo falló!",
"item_broken": "¡Tu {{item}} se rompió!",
"flee_success": "¡Lograste escapar!",
"flee_fail": "¡No pudiste escapar!"
}
},
"equipment": {

View File

@@ -44,7 +44,9 @@ export default defineConfig({
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
// Exclude images from precache manifest to avoid 404s on build
globPatterns: ['**/*.{js,css,html,ico,svg,woff,woff2}'],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
runtimeCaching: [
{
urlPattern: /^https:\/\/staging\.echoesoftheash\.com\/api\/.*/i,