WIP: Current state before PVP combat investigation
This commit is contained in:
125
api/database.py
125
api/database.py
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
88
count_sloc.py
Normal 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
BIN
pwa/public/audio/bgm.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/attack_default.wav
Normal file
BIN
pwa/public/audio/sfx/attack_default.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/attack_enemy_default.wav
Normal file
BIN
pwa/public/audio/sfx/attack_enemy_default.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/drop.wav
Normal file
BIN
pwa/public/audio/sfx/drop.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/hit.wav
Normal file
BIN
pwa/public/audio/sfx/hit.wav
Normal file
Binary file not shown.
BIN
pwa/public/audio/sfx/pickup.wav
Normal file
BIN
pwa/public/audio/sfx/pickup.wav
Normal file
Binary file not shown.
107
pwa/src/App.tsx
107
pwa/src/App.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
119
pwa/src/components/BackgroundMusic.tsx
Normal file
119
pwa/src/components/BackgroundMusic.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
115
pwa/src/contexts/AudioContext.tsx
Normal file
115
pwa/src/contexts/AudioContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user