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)
|
result = await session.execute(stmt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount
|
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
|
import time
|
||||||
from typing import Dict, Any, Tuple, Optional, List
|
from typing import Dict, Any, Tuple, Optional, List
|
||||||
from . import database as db
|
from . import database as db
|
||||||
from .services.helpers import get_locale_string, translate_travel_message, create_combat_message
|
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]:
|
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
|
# Check stamina
|
||||||
if player['stamina'] < stamina_cost:
|
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
|
# Update player location and stamina
|
||||||
await db.update_character(
|
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
|
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.
|
Inspect the current area and return detailed information.
|
||||||
Returns formatted text with interactables and their actions.
|
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
|
# Check if player has enough stamina
|
||||||
if player['stamina'] < 1:
|
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
|
# Deduct stamina
|
||||||
await db.update_player_stamina(player_id, player['stamina'] - 1)
|
await db.update_player_stamina(player_id, player['stamina'] - 1)
|
||||||
|
|
||||||
# Build inspection message
|
# 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(location.description)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
if location.interactables:
|
if location.interactables:
|
||||||
lines.append("**Interactables:**")
|
lines.append(get_game_message('interactables_title', locale))
|
||||||
for interactable in location.interactables:
|
for interactable in location.interactables:
|
||||||
lines.append(f"• **{interactable.name}**")
|
lines.append(f"• **{interactable.name}**")
|
||||||
if interactable.actions:
|
if interactable.actions:
|
||||||
@@ -112,13 +112,13 @@ async def inspect_area(player_id: int, location, interactables_data: Dict) -> st
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
if location.npcs:
|
if location.npcs:
|
||||||
lines.append(f"**NPCs:** {', '.join(location.npcs)}")
|
lines.append(f"{get_game_message('npcs_title', locale)} {', '.join(location.npcs)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Check for dropped items
|
# Check for dropped items
|
||||||
dropped_items = await db.get_dropped_items(location.id)
|
dropped_items = await db.get_dropped_items(location.id)
|
||||||
if dropped_items:
|
if dropped_items:
|
||||||
lines.append("**Items on ground:**")
|
lines.append(get_game_message('items_ground_title', locale))
|
||||||
for item in dropped_items:
|
for item in dropped_items:
|
||||||
lines.append(f"• {item['item_id']} x{item['quantity']}")
|
lines.append(f"• {item['item_id']} x{item['quantity']}")
|
||||||
|
|
||||||
@@ -130,7 +130,8 @@ async def interact_with_object(
|
|||||||
interactable_id: str,
|
interactable_id: str,
|
||||||
action_id: str,
|
action_id: str,
|
||||||
location,
|
location,
|
||||||
items_manager
|
items_manager,
|
||||||
|
locale: str = 'en'
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Interact with an object using a specific action.
|
Interact with an object using a specific action.
|
||||||
@@ -148,7 +149,7 @@ async def interact_with_object(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not interactable:
|
if not interactable:
|
||||||
return {"success": False, "message": "Object not found"}
|
return {"success": False, "message": get_game_message('object_not_found', locale)}
|
||||||
|
|
||||||
# Find the action
|
# Find the action
|
||||||
action = None
|
action = None
|
||||||
@@ -158,13 +159,13 @@ async def interact_with_object(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not action:
|
if not action:
|
||||||
return {"success": False, "message": "Action not found"}
|
return {"success": False, "message": get_game_message('action_not_found', locale)}
|
||||||
|
|
||||||
# Check stamina
|
# Check stamina
|
||||||
if player['stamina'] < action.stamina_cost:
|
if player['stamina'] < action.stamina_cost:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"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
|
# Check cooldown for this specific action
|
||||||
@@ -173,7 +174,7 @@ async def interact_with_object(
|
|||||||
remaining = int(cooldown_expiry - time.time())
|
remaining = int(cooldown_expiry - time.time())
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": f"This action is still on cooldown. Wait {remaining} seconds."
|
"message": get_game_message('cooldown_wait', locale, seconds=remaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deduct stamina
|
# Deduct stamina
|
||||||
@@ -199,7 +200,7 @@ async def interact_with_object(
|
|||||||
if not outcome:
|
if not outcome:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Action has no defined outcomes"
|
"message": get_game_message('action_no_outcomes', locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Process outcome
|
# Process outcome
|
||||||
@@ -219,7 +220,7 @@ async def interact_with_object(
|
|||||||
if not item:
|
if not item:
|
||||||
continue
|
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 ''
|
emoji = item.emoji if item and hasattr(item, 'emoji') else ''
|
||||||
|
|
||||||
# Check if item has durability (unique item)
|
# Check if item has durability (unique item)
|
||||||
@@ -240,7 +241,7 @@ async def interact_with_object(
|
|||||||
max_durability=item.durability,
|
max_durability=item.durability,
|
||||||
tier=getattr(item, 'tier', None)
|
tier=getattr(item, 'tier', None)
|
||||||
)
|
)
|
||||||
items_found.append(f"{emoji} {get_locale_string(item_name)}")
|
items_found.append(f"{emoji} {item_name}")
|
||||||
current_weight += item.weight
|
current_weight += item.weight
|
||||||
current_volume += item.volume
|
current_volume += item.volume
|
||||||
else:
|
else:
|
||||||
@@ -255,7 +256,7 @@ async def interact_with_object(
|
|||||||
unique_stats=base_stats
|
unique_stats=base_stats
|
||||||
)
|
)
|
||||||
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
|
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
|
||||||
items_dropped.append(f"{emoji} {get_locale_string(item_name)}")
|
items_dropped.append(f"{emoji} {item_name}")
|
||||||
else:
|
else:
|
||||||
# Stackable items - process as before
|
# Stackable items - process as before
|
||||||
item_weight = item.weight * quantity
|
item_weight = item.weight * quantity
|
||||||
@@ -265,13 +266,13 @@ async def interact_with_object(
|
|||||||
current_volume + item_volume <= max_volume):
|
current_volume + item_volume <= max_volume):
|
||||||
# Add to inventory
|
# Add to inventory
|
||||||
await db.add_item_to_inventory(player_id, item_id, quantity)
|
await db.add_item_to_inventory(player_id, item_id, quantity)
|
||||||
items_found.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
|
items_found.append(f"{emoji} {item_name} x{quantity}")
|
||||||
current_weight += item_weight
|
current_weight += item_weight
|
||||||
current_volume += item_volume
|
current_volume += item_volume
|
||||||
else:
|
else:
|
||||||
# Drop to ground
|
# Drop to ground
|
||||||
await db.drop_item_to_world(item_id, quantity, player['location_id'])
|
await db.drop_item_to_world(item_id, quantity, player['location_id'])
|
||||||
items_dropped.append(f"{emoji} {get_locale_string(item_name)} x{quantity}")
|
items_dropped.append(f"{emoji} {item_name} x{quantity}")
|
||||||
|
|
||||||
# Apply damage
|
# Apply damage
|
||||||
if damage_taken > 0:
|
if damage_taken > 0:
|
||||||
@@ -286,9 +287,9 @@ async def interact_with_object(
|
|||||||
await db.set_interactable_cooldown(interactable_id, action_id, 60)
|
await db.set_interactable_cooldown(interactable_id, action_id, 60)
|
||||||
|
|
||||||
# Build message
|
# Build message
|
||||||
final_message = get_locale_string(outcome.text)
|
final_message = get_locale_string(outcome.text, locale)
|
||||||
if items_dropped:
|
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 {
|
return {
|
||||||
"success": True,
|
"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.
|
Use an item from inventory.
|
||||||
Returns: {success, message, effects}
|
Returns: {success, message, effects}
|
||||||
@@ -320,7 +321,7 @@ async def use_item(player_id: int, item_id: str, items_manager) -> Dict[str, Any
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not item_entry:
|
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
|
# Get item data
|
||||||
item = items_manager.get_item(item_id)
|
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"}
|
return {"success": False, "message": "Item not found in game data"}
|
||||||
|
|
||||||
if not item.consumable:
|
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
|
# Apply item effects
|
||||||
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)
|
await db.update_player_statistics(player_id, **stat_updates)
|
||||||
|
|
||||||
# Build message
|
# 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:
|
if effects_msg:
|
||||||
msg += f" ({', '.join(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.
|
Pick up an item from the ground.
|
||||||
item_id is the dropped_item id, not the item_id field.
|
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)
|
dropped_item = await db.get_dropped_item(item_id)
|
||||||
|
|
||||||
if not dropped_item:
|
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
|
# Get item definition
|
||||||
item_def = items_manager.get_item(dropped_item['item_id']) if items_manager else None
|
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
|
pickup_qty = available_qty
|
||||||
else:
|
else:
|
||||||
if quantity < 1:
|
if quantity < 1:
|
||||||
return {"success": False, "message": "Invalid quantity"}
|
return {"success": False, "message": get_game_message('invalid_quantity', locale)}
|
||||||
pickup_qty = quantity
|
pickup_qty = quantity
|
||||||
|
|
||||||
# Get player and calculate capacity
|
# 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:
|
if new_weight > max_weight:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"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:
|
if new_volume > max_volume:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"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
|
# 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 {
|
return {
|
||||||
"success": True,
|
"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
|
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.
|
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)
|
player = await db.get_player_by_id(player_id)
|
||||||
if not player:
|
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)
|
# 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')
|
current_intent_str = combat.get('npc_intent', 'attack')
|
||||||
# Handle legacy/null
|
# Handle legacy/null
|
||||||
if not current_intent_str:
|
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
|
intent_type = current_intent_str
|
||||||
|
|
||||||
message = ""
|
messages = []
|
||||||
actual_damage = 0
|
actual_damage = 0
|
||||||
|
|
||||||
# EXECUTE INTENT
|
# EXECUTE INTENT
|
||||||
if intent_type == 'defend':
|
if intent_type == 'defend':
|
||||||
# NPC defends - maybe heals or takes less damage next turn?
|
# NPC defends - heals 5% HP
|
||||||
# For simplicity: Heals 5% HP
|
|
||||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||||
message = f"{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':
|
elif intent_type == 'special':
|
||||||
# Strong attack (1.5x damage)
|
# 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)
|
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||||
|
|
||||||
message = f"{get_locale_string(npc_def.name)} uses a SPECIAL ATTACK for {npc_damage} damage!"
|
messages.append(create_combat_message(
|
||||||
if armor_absorbed > 0:
|
"enemy_special",
|
||||||
message += f" (Armor absorbed {armor_absorbed})"
|
origin="enemy",
|
||||||
|
npc_name=npc_def.name,
|
||||||
|
damage=npc_damage,
|
||||||
|
armor_absorbed=armor_absorbed
|
||||||
|
))
|
||||||
|
|
||||||
if broken_armor:
|
if broken_armor:
|
||||||
for armor in 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)
|
await db.update_player(player_id, hp=new_player_hp)
|
||||||
|
|
||||||
else: # Default 'attack'
|
else: # Default 'attack'
|
||||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||||
|
|
||||||
# Enrage bonus if NPC is below 30% HP
|
# Enrage bonus if NPC is below 30% HP
|
||||||
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
|
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
|
||||||
|
if is_enraged:
|
||||||
npc_damage = int(npc_damage * 1.5)
|
npc_damage = int(npc_damage * 1.5)
|
||||||
message = f"{get_locale_string(npc_def.name)} is ENRAGED! "
|
messages.append(create_combat_message(
|
||||||
else:
|
"enemy_enraged",
|
||||||
message = ""
|
origin="enemy",
|
||||||
|
npc_name=npc_def.name
|
||||||
|
))
|
||||||
|
|
||||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||||
|
|
||||||
message += f"{get_locale_string(npc_def.name)} attacks for {npc_damage} damage!"
|
messages.append(create_combat_message(
|
||||||
if armor_absorbed > 0:
|
"enemy_attack",
|
||||||
message += f" (Armor absorbed {armor_absorbed})"
|
origin="enemy",
|
||||||
|
npc_name=npc_def.name,
|
||||||
|
damage=npc_damage,
|
||||||
|
armor_absorbed=armor_absorbed
|
||||||
|
))
|
||||||
|
|
||||||
if broken_armor:
|
if broken_armor:
|
||||||
for armor in 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)
|
await db.update_player(player_id, hp=new_player_hp)
|
||||||
|
|
||||||
# GENERATE NEXT INTENT
|
# 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
|
# Check if player defeated
|
||||||
player_defeated = False
|
player_defeated = False
|
||||||
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
|
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
|
||||||
# Re-fetch to be sure or just trust calculation
|
# Re-fetch to be sure or just trust calculation
|
||||||
if new_player_hp <= 0:
|
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
|
player_defeated = True
|
||||||
await db.update_player(player_id, hp=0, is_dead=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.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
|
||||||
await db.end_combat(player_id)
|
await db.end_combat(player_id)
|
||||||
return message, player_defeated
|
return messages, player_defeated
|
||||||
|
|
||||||
if not player_defeated:
|
if not player_defeated:
|
||||||
if actual_damage > 0:
|
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']
|
'npc_intent': next_intent['type']
|
||||||
})
|
})
|
||||||
|
|
||||||
return message, player_defeated
|
return messages, player_defeated
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
Authentication router.
|
Authentication router.
|
||||||
Handles user registration, login, and profile retrieval.
|
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 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 ..core.security import create_access_token, hash_password, verify_password, get_current_user
|
||||||
from ..services.models import UserRegister, UserLogin
|
from ..services.models import UserRegister, UserLogin
|
||||||
from .. import database as db
|
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")
|
@router.post("/change-email")
|
||||||
async def change_email(
|
async def change_email(
|
||||||
request: "ChangeEmailRequest",
|
request: "ChangeEmailRequest",
|
||||||
|
req: Request,
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Change account email address"""
|
"""Change account email address"""
|
||||||
from ..services.models import ChangeEmailRequest
|
from ..services.models import ChangeEmailRequest
|
||||||
|
locale = req.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Get account
|
# Get account
|
||||||
account_id = current_user.get("account_id")
|
account_id = current_user.get("account_id")
|
||||||
@@ -250,7 +254,7 @@ async def change_email(
|
|||||||
# Update email
|
# Update email
|
||||||
try:
|
try:
|
||||||
await db.update_account_email(account_id, request.new_email)
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -261,10 +265,12 @@ async def change_email(
|
|||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(
|
async def change_password(
|
||||||
request: "ChangePasswordRequest",
|
request: "ChangePasswordRequest",
|
||||||
|
req: Request,
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Change account password"""
|
"""Change account password"""
|
||||||
from ..services.models import ChangePasswordRequest
|
from ..services.models import ChangePasswordRequest
|
||||||
|
locale = req.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Get account
|
# Get account
|
||||||
account_id = current_user.get("account_id")
|
account_id = current_user.get("account_id")
|
||||||
@@ -305,7 +311,7 @@ async def change_password(
|
|||||||
new_password_hash = hash_password(request.new_password)
|
new_password_hash = hash_password(request.new_password)
|
||||||
await db.update_account_password(account_id, new_password_hash)
|
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")
|
@router.post("/steam-login")
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
Character management router.
|
Character management router.
|
||||||
Handles character creation, selection, and deletion.
|
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 fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from ..services.helpers import get_game_message
|
||||||
|
|
||||||
from ..core.security import decode_token, create_access_token, security
|
from ..core.security import decode_token, create_access_token, security
|
||||||
from ..services.models import CharacterCreate, CharacterSelect
|
from ..services.models import CharacterCreate, CharacterSelect
|
||||||
from .. import database as db
|
from .. import database as db
|
||||||
@@ -51,10 +53,12 @@ async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(se
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_character_endpoint(
|
async def create_character_endpoint(
|
||||||
character: CharacterCreate,
|
character: CharacterCreate,
|
||||||
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||||
):
|
):
|
||||||
"""Create a new character"""
|
"""Create a new character"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
account_id = payload.get("account_id")
|
account_id = payload.get("account_id")
|
||||||
|
|
||||||
@@ -120,7 +124,7 @@ async def create_character_endpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Character created successfully",
|
"message": get_game_message('character_created', locale),
|
||||||
"character": {
|
"character": {
|
||||||
"id": new_character["id"],
|
"id": new_character["id"],
|
||||||
"name": new_character["name"],
|
"name": new_character["name"],
|
||||||
@@ -203,10 +207,12 @@ async def select_character(
|
|||||||
@router.delete("/{character_id}")
|
@router.delete("/{character_id}")
|
||||||
async def delete_character_endpoint(
|
async def delete_character_endpoint(
|
||||||
character_id: int,
|
character_id: int,
|
||||||
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||||
):
|
):
|
||||||
"""Delete a character"""
|
"""Delete a character"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
account_id = payload.get("account_id")
|
account_id = payload.get("account_id")
|
||||||
|
|
||||||
@@ -234,5 +240,5 @@ async def delete_character_endpoint(
|
|||||||
await db.delete_character(character_id)
|
await db.delete_character(character_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"Character '{character['name']}' deleted successfully"
|
"message": get_game_message('character_deleted', locale, name=character['name'])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Combat router.
|
Combat router.
|
||||||
Auto-generated from main.py migration.
|
Auto-generated from main.py migration.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Depends, status
|
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -12,7 +12,7 @@ import logging
|
|||||||
|
|
||||||
from ..core.security import get_current_user, security, verify_internal_key
|
from ..core.security import get_current_user, security, verify_internal_key
|
||||||
from ..services.models import *
|
from ..services.models import *
|
||||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, 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 .. import database as db
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
from .. import game_logic
|
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")
|
@router.post("/api/game/combat/initiate")
|
||||||
async def initiate_combat(
|
async def initiate_combat(
|
||||||
req: InitiateCombatRequest,
|
req: InitiateCombatRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Start combat with a wandering enemy"""
|
"""Start combat with a wandering enemy"""
|
||||||
@@ -88,6 +89,9 @@ async def initiate_combat(
|
|||||||
sys.path.insert(0, '/app')
|
sys.path.insert(0, '/app')
|
||||||
from data.npcs import NPCS
|
from data.npcs import NPCS
|
||||||
|
|
||||||
|
# Extract locale from Accept-Language header
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Check if already in combat
|
# Check if already in combat
|
||||||
existing_combat = await db.get_active_combat(current_user['id'])
|
existing_combat = await db.get_active_combat(current_user['id'])
|
||||||
if existing_combat:
|
if existing_combat:
|
||||||
@@ -147,7 +151,7 @@ async def initiate_combat(
|
|||||||
await manager.send_personal_message(current_user['id'], {
|
await manager.send_personal_message(current_user['id'], {
|
||||||
"type": "combat_started",
|
"type": "combat_started",
|
||||||
"data": {
|
"data": {
|
||||||
"message": 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": {
|
"combat": {
|
||||||
"npc_id": enemy.npc_id,
|
"npc_id": enemy.npc_id,
|
||||||
"npc_name": npc_def.name,
|
"npc_name": npc_def.name,
|
||||||
@@ -167,7 +171,7 @@ async def initiate_combat(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"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",
|
"action": "combat_started",
|
||||||
"player_id": player['id']
|
"player_id": player['id']
|
||||||
},
|
},
|
||||||
@@ -178,7 +182,7 @@ async def initiate_combat(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"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": {
|
"combat": {
|
||||||
"npc_id": enemy.npc_id,
|
"npc_id": enemy.npc_id,
|
||||||
"npc_name": npc_def.name,
|
"npc_name": npc_def.name,
|
||||||
@@ -194,6 +198,7 @@ async def initiate_combat(
|
|||||||
@router.post("/api/game/combat/action")
|
@router.post("/api/game/combat/action")
|
||||||
async def combat_action(
|
async def combat_action(
|
||||||
req: CombatActionRequest,
|
req: CombatActionRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Perform a combat action"""
|
"""Perform a combat action"""
|
||||||
@@ -202,6 +207,9 @@ async def combat_action(
|
|||||||
sys.path.insert(0, '/app')
|
sys.path.insert(0, '/app')
|
||||||
from data.npcs import NPCS
|
from data.npcs import NPCS
|
||||||
|
|
||||||
|
# Extract locale from Accept-Language header
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Get active combat
|
# Get active combat
|
||||||
combat = await db.get_active_combat(current_user['id'])
|
combat = await db.get_active_combat(current_user['id'])
|
||||||
if not combat:
|
if not combat:
|
||||||
@@ -238,7 +246,7 @@ async def combat_action(
|
|||||||
player = current_user # current_user is already the character dict
|
player = current_user # current_user is already the character dict
|
||||||
npc_def = NPCS.get(combat['npc_id'])
|
npc_def = NPCS.get(combat['npc_id'])
|
||||||
|
|
||||||
result_message = ""
|
messages = []
|
||||||
combat_over = False
|
combat_over = False
|
||||||
player_won = False
|
player_won = False
|
||||||
|
|
||||||
@@ -278,12 +286,20 @@ async def combat_action(
|
|||||||
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
||||||
|
|
||||||
if attack_failed:
|
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']
|
new_npc_hp = combat['npc_hp']
|
||||||
else:
|
else:
|
||||||
# Apply damage to NPC
|
# Apply damage to NPC
|
||||||
new_npc_hp = max(0, combat['npc_hp'] - damage)
|
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
|
# Apply weapon effects
|
||||||
if weapon_effects and 'bleeding' in 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)
|
# Apply bleeding effect (would need combat effects table, for now just bonus damage)
|
||||||
bleed_damage = bleeding.get('damage', 0)
|
bleed_damage = bleeding.get('damage', 0)
|
||||||
new_npc_hp = max(0, new_npc_hp - bleed_damage)
|
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)
|
# Decrease weapon durability (from unique_item)
|
||||||
if weapon_inv_id and inv_item.get('unique_item_id'):
|
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)
|
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
|
||||||
if new_durability is None:
|
if new_durability is None:
|
||||||
# Weapon broke (unique_item was deleted, cascades to inventory)
|
# 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')
|
await db.unequip_item(player['id'], 'weapon')
|
||||||
|
|
||||||
if new_npc_hp <= 0:
|
if new_npc_hp <= 0:
|
||||||
# NPC defeated
|
# 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
|
combat_over = True
|
||||||
player_won = True
|
player_won = True
|
||||||
|
|
||||||
# Award XP
|
# Award XP
|
||||||
xp_gained = npc_def.xp_reward
|
xp_gained = npc_def.xp_reward
|
||||||
new_xp = player['xp'] + xp_gained
|
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)
|
await db.update_player(player['id'], xp=new_xp)
|
||||||
|
|
||||||
@@ -321,8 +353,12 @@ async def combat_action(
|
|||||||
# Check for level up
|
# Check for level up
|
||||||
level_up_result = await game_logic.check_and_apply_level_up(player['id'])
|
level_up_result = await game_logic.check_and_apply_level_up(player['id'])
|
||||||
if level_up_result['leveled_up']:
|
if level_up_result['leveled_up']:
|
||||||
result_message += f"\n🎉 Level Up! You are now level {level_up_result['new_level']}!"
|
messages.append(create_combat_message(
|
||||||
result_message += f"\n+{level_up_result['levels_gained']} stat point(s) to spend!"
|
"level_up",
|
||||||
|
origin="player",
|
||||||
|
level=level_up_result['new_level'],
|
||||||
|
stat_points=level_up_result['levels_gained']
|
||||||
|
))
|
||||||
|
|
||||||
# Create corpse with loot
|
# Create corpse with loot
|
||||||
import json
|
import json
|
||||||
@@ -361,7 +397,7 @@ async def combat_action(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"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",
|
"action": "combat_ended",
|
||||||
"player_id": player['id'],
|
"player_id": player['id'],
|
||||||
"corpse_created": True
|
"corpse_created": True
|
||||||
@@ -373,13 +409,13 @@ async def combat_action(
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# NPC's turn - use shared logic
|
# 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'],
|
player['id'],
|
||||||
{'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']},
|
{'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']},
|
||||||
npc_def,
|
npc_def,
|
||||||
reduce_armor_durability
|
reduce_armor_durability
|
||||||
)
|
)
|
||||||
result_message += f"\n{npc_attack_message}"
|
messages.extend(npc_attack_messages)
|
||||||
|
|
||||||
if player_defeated:
|
if player_defeated:
|
||||||
combat_over = True
|
combat_over = True
|
||||||
@@ -392,7 +428,10 @@ async def combat_action(
|
|||||||
elif req.action == 'flee':
|
elif req.action == 'flee':
|
||||||
# 50% chance to flee
|
# 50% chance to flee
|
||||||
if random.random() < 0.5:
|
if random.random() < 0.5:
|
||||||
result_message = "You successfully fled from combat!"
|
messages.append(create_combat_message(
|
||||||
|
"flee_success",
|
||||||
|
origin="player"
|
||||||
|
))
|
||||||
combat_over = True
|
combat_over = True
|
||||||
player_won = False # Fled, not won
|
player_won = False # Fled, not won
|
||||||
|
|
||||||
@@ -423,7 +462,7 @@ async def combat_action(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"{player['name']} fled from combat",
|
"message": get_game_message('player_fled_broadcast', locale, player_name=player['name']),
|
||||||
"action": "combat_fled",
|
"action": "combat_fled",
|
||||||
"player_id": player['id']
|
"player_id": player['id']
|
||||||
},
|
},
|
||||||
@@ -435,10 +474,20 @@ async def combat_action(
|
|||||||
# Failed to flee, NPC attacks
|
# Failed to flee, NPC attacks
|
||||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||||
new_player_hp = max(0, player['hp'] - npc_damage)
|
new_player_hp = max(0, player['hp'] - npc_damage)
|
||||||
result_message = f"Failed to flee! {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:
|
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
|
combat_over = True
|
||||||
await db.update_player(player['id'], hp=0, is_dead=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)
|
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={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"{player['name']} was defeated in combat",
|
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
|
||||||
"action": "player_died",
|
"action": "player_died",
|
||||||
"player_id": player['id'],
|
"player_id": player['id'],
|
||||||
"corpse": corpse_data
|
"corpse": corpse_data
|
||||||
@@ -558,7 +607,7 @@ async def combat_action(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": result_message,
|
"messages": messages,
|
||||||
"combat_over": combat_over,
|
"combat_over": combat_over,
|
||||||
"player_won": player_won if combat_over else None,
|
"player_won": player_won if combat_over else None,
|
||||||
"combat": updated_combat if updated_combat else None,
|
"combat": updated_combat if updated_combat else None,
|
||||||
@@ -574,6 +623,7 @@ async def combat_action(
|
|||||||
@router.post("/api/game/pvp/initiate")
|
@router.post("/api/game/pvp/initiate")
|
||||||
async def initiate_pvp_combat(
|
async def initiate_pvp_combat(
|
||||||
req: PvPCombatInitiateRequest,
|
req: PvPCombatInitiateRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Initiate PvP combat with another player"""
|
"""Initiate PvP combat with another player"""
|
||||||
@@ -582,6 +632,9 @@ async def initiate_pvp_combat(
|
|||||||
if not attacker:
|
if not attacker:
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
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
|
# Check if attacker is already in combat
|
||||||
existing_combat = await db.get_active_combat(attacker['id'])
|
existing_combat = await db.get_active_combat(attacker['id'])
|
||||||
if existing_combat:
|
if existing_combat:
|
||||||
@@ -637,7 +690,7 @@ async def initiate_pvp_combat(
|
|||||||
await manager.send_personal_message(attacker['id'], {
|
await manager.send_personal_message(attacker['id'], {
|
||||||
"type": "combat_started",
|
"type": "combat_started",
|
||||||
"data": {
|
"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
|
"pvp_combat": pvp_combat
|
||||||
},
|
},
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
@@ -646,7 +699,7 @@ async def initiate_pvp_combat(
|
|||||||
await manager.send_personal_message(defender['id'], {
|
await manager.send_personal_message(defender['id'], {
|
||||||
"type": "combat_started",
|
"type": "combat_started",
|
||||||
"data": {
|
"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
|
"pvp_combat": pvp_combat
|
||||||
},
|
},
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
@@ -654,7 +707,7 @@ async def initiate_pvp_combat(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"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
|
"pvp_combat": pvp_combat
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,11 +790,15 @@ class PvPAcknowledgeRequest(BaseModel):
|
|||||||
@router.post("/api/game/pvp/acknowledge")
|
@router.post("/api/game/pvp/acknowledge")
|
||||||
async def acknowledge_pvp_combat(
|
async def acknowledge_pvp_combat(
|
||||||
req: PvPAcknowledgeRequest,
|
req: PvPAcknowledgeRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Acknowledge PvP combat end"""
|
"""Acknowledge PvP combat end"""
|
||||||
await db.acknowledge_pvp_combat(req.combat_id, current_user['id'])
|
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
|
# Broadcast to location that player has returned
|
||||||
player = current_user # current_user is already the character dict
|
player = current_user # current_user is already the character dict
|
||||||
if player:
|
if player:
|
||||||
@@ -752,7 +809,7 @@ async def acknowledge_pvp_combat(
|
|||||||
"data": {
|
"data": {
|
||||||
"player_id": player['id'],
|
"player_id": player['id'],
|
||||||
"username": player['name'],
|
"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()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
},
|
},
|
||||||
@@ -770,12 +827,16 @@ class PvPCombatActionRequest(BaseModel):
|
|||||||
@router.post("/api/game/pvp/action")
|
@router.post("/api/game/pvp/action")
|
||||||
async def pvp_combat_action(
|
async def pvp_combat_action(
|
||||||
req: PvPCombatActionRequest,
|
req: PvPCombatActionRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Perform a PvP combat action"""
|
"""Perform a PvP combat action"""
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
# Extract locale
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Get PvP combat
|
# Get PvP combat
|
||||||
pvp_combat = await db.get_pvp_combat_by_player(current_user['id'])
|
pvp_combat = await db.get_pvp_combat_by_player(current_user['id'])
|
||||||
if not pvp_combat:
|
if not pvp_combat:
|
||||||
@@ -795,10 +856,13 @@ async def pvp_combat_action(
|
|||||||
current_player = attacker if is_attacker else defender
|
current_player = attacker if is_attacker else defender
|
||||||
opponent = defender if is_attacker else attacker
|
opponent = defender if is_attacker else attacker
|
||||||
|
|
||||||
result_message = ""
|
messages = []
|
||||||
combat_over = False
|
combat_over = False
|
||||||
winner_id = None
|
winner_id = None
|
||||||
|
|
||||||
|
# Track the last action string for DB history
|
||||||
|
last_action_text = ""
|
||||||
|
|
||||||
if req.action == 'attack':
|
if req.action == 'attack':
|
||||||
# Calculate damage (similar to PvE)
|
# Calculate damage (similar to PvE)
|
||||||
base_damage = 5
|
base_damage = 5
|
||||||
@@ -822,7 +886,11 @@ async def pvp_combat_action(
|
|||||||
if inv_item.get('unique_item_id'):
|
if inv_item.get('unique_item_id'):
|
||||||
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
|
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
|
||||||
if new_durability is None:
|
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')
|
await db.unequip_item(current_player['id'], 'weapon')
|
||||||
|
|
||||||
variance = random.randint(-2, 2)
|
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)
|
armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage)
|
||||||
actual_damage = max(1, damage - armor_absorbed)
|
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)
|
# Update opponent HP (use actual player HP, not pvp_combat fields)
|
||||||
new_opponent_hp = max(0, opponent['hp'] - actual_damage)
|
new_opponent_hp = max(0, opponent['hp'] - actual_damage)
|
||||||
|
|
||||||
# Update opponent's HP in database
|
# Update opponent's HP in database
|
||||||
await db.update_player(opponent['id'], hp=new_opponent_hp)
|
await db.update_player(opponent['id'], hp=new_opponent_hp)
|
||||||
|
|
||||||
# Store message with attacker's username so both players can see it correctly
|
# Construct summary string for DB history/passive player
|
||||||
stored_message = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!"
|
last_action_text = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!"
|
||||||
if armor_absorbed > 0:
|
if armor_absorbed > 0:
|
||||||
stored_message += f" (Armor absorbed {armor_absorbed})"
|
last_action_text += f" (Armor absorbed {armor_absorbed})"
|
||||||
|
|
||||||
for broken in broken_armor:
|
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
|
# Check if opponent defeated
|
||||||
if new_opponent_hp <= 0:
|
if new_opponent_hp <= 0:
|
||||||
stored_message += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
|
last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
|
||||||
result_message = "Combat victory!" # Simple message, details in stored_message
|
messages.append(create_combat_message(
|
||||||
|
"victory",
|
||||||
|
origin="neutral",
|
||||||
|
npc_name=opponent['name']
|
||||||
|
))
|
||||||
combat_over = True
|
combat_over = True
|
||||||
winner_id = current_player['id']
|
winner_id = current_player['id']
|
||||||
|
|
||||||
@@ -921,7 +1007,7 @@ async def pvp_combat_action(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"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",
|
"action": "player_died",
|
||||||
"player_id": opponent['id'],
|
"player_id": opponent['id'],
|
||||||
"corpse": corpse_data
|
"corpse": corpse_data
|
||||||
@@ -933,8 +1019,7 @@ async def pvp_combat_action(
|
|||||||
# End PvP combat
|
# End PvP combat
|
||||||
await db.end_pvp_combat(pvp_combat['id'])
|
await db.end_pvp_combat(pvp_combat['id'])
|
||||||
else:
|
else:
|
||||||
# Combat continues - don't return detailed message, it's in stored_message
|
# Combat continues
|
||||||
result_message = "" # Empty message, frontend will show stored_message from polling
|
|
||||||
|
|
||||||
# Update PvP statistics for attack
|
# Update PvP statistics for attack
|
||||||
await db.update_player_statistics(current_player['id'],
|
await db.update_player_statistics(current_player['id'],
|
||||||
@@ -953,7 +1038,7 @@ async def pvp_combat_action(
|
|||||||
updates = {
|
updates = {
|
||||||
'turn': 'defender' if is_attacker else 'attacker',
|
'turn': 'defender' if is_attacker else 'attacker',
|
||||||
'turn_started_at': time.time(),
|
'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
|
# 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':
|
elif req.action == 'flee':
|
||||||
# 50% chance to flee from PvP
|
# 50% chance to flee from PvP
|
||||||
if random.random() < 0.5:
|
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
|
combat_over = True
|
||||||
|
|
||||||
# Mark as fled, store last action with timestamp, and end combat
|
# Mark as fled, store last action with timestamp, and end combat
|
||||||
flee_field = 'attacker_fled' if is_attacker else 'defender_fled'
|
flee_field = 'attacker_fled' if is_attacker else 'defender_fled'
|
||||||
await db.update_pvp_combat(pvp_combat['id'], {
|
await db.update_pvp_combat(pvp_combat['id'], {
|
||||||
flee_field: True,
|
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.end_pvp_combat(pvp_combat['id'])
|
||||||
await db.update_player_statistics(current_player['id'],
|
await db.update_player_statistics(current_player['id'],
|
||||||
@@ -979,11 +1069,17 @@ async def pvp_combat_action(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Failed to flee, skip turn
|
# 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'], {
|
await db.update_pvp_combat(pvp_combat['id'], {
|
||||||
'turn': 'defender' if is_attacker else 'attacker',
|
'turn': 'defender' if is_attacker else 'attacker',
|
||||||
'turn_started_at': time.time(),
|
'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'],
|
await db.update_player_statistics(current_player['id'],
|
||||||
pvp_failed_flees=1,
|
pvp_failed_flees=1,
|
||||||
@@ -1041,20 +1137,22 @@ async def pvp_combat_action(
|
|||||||
await manager.send_personal_message(player_id, {
|
await manager.send_personal_message(player_id, {
|
||||||
"type": "combat_update",
|
"type": "combat_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": result_message if player_id == current_user['id'] else "",
|
"message": last_action_text if player_id == current_user['id'] else "",
|
||||||
"log_entry": result_message if player_id == current_user['id'] else "", # Append to combat log
|
"log_entry": last_action_text if player_id == current_user['id'] else "", # Append to combat log
|
||||||
"pvp_combat": enriched_pvp,
|
"pvp_combat": enriched_pvp,
|
||||||
"combat_over": combat_over,
|
"combat_over": combat_over,
|
||||||
"winner_id": winner_id,
|
"winner_id": winner_id,
|
||||||
"attacker_hp": fresh_attacker['hp'],
|
"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()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": result_message,
|
"messages": messages,
|
||||||
"combat_over": combat_over,
|
"combat_over": combat_over,
|
||||||
"winner_id": winner_id
|
"winner_id": winner_id,
|
||||||
|
"pvp_combat": updated_pvp
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Equipment router.
|
Equipment router.
|
||||||
Auto-generated from main.py migration.
|
Auto-generated from main.py migration.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Depends, status
|
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -12,7 +12,7 @@ import logging
|
|||||||
|
|
||||||
from ..core.security import get_current_user, security, verify_internal_key
|
from ..core.security import get_current_user, security, verify_internal_key
|
||||||
from ..services.models import *
|
from ..services.models import *
|
||||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
|
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost, get_game_message, get_locale_string
|
||||||
from .. import database as db
|
from .. import database as db
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
from .. import game_logic
|
from .. import game_logic
|
||||||
@@ -41,10 +41,12 @@ router = APIRouter(tags=["equipment"])
|
|||||||
@router.post("/api/game/equip")
|
@router.post("/api/game/equip")
|
||||||
async def equip_item(
|
async def equip_item(
|
||||||
equip_req: EquipItemRequest,
|
equip_req: EquipItemRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Equip an item from inventory"""
|
"""Equip an item from inventory"""
|
||||||
player_id = current_user['id']
|
player_id = current_user['id']
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Get the inventory item
|
# Get the inventory item
|
||||||
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
|
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
|
||||||
@@ -107,9 +109,9 @@ async def equip_item(
|
|||||||
|
|
||||||
# Build message
|
# Build message
|
||||||
if unequipped_item_name:
|
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:
|
else:
|
||||||
message = f"Equipped {item_def.name}"
|
message = get_game_message('equipped', locale, item=get_locale_string(item_def.name, locale))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -122,10 +124,12 @@ async def equip_item(
|
|||||||
@router.post("/api/game/unequip")
|
@router.post("/api/game/unequip")
|
||||||
async def unequip_item(
|
async def unequip_item(
|
||||||
unequip_req: UnequipItemRequest,
|
unequip_req: UnequipItemRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Unequip an item from equipment slot"""
|
"""Unequip an item from equipment slot"""
|
||||||
player_id = current_user['id']
|
player_id = current_user['id']
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Check if slot is valid
|
# Check if slot is valid
|
||||||
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
||||||
@@ -190,7 +194,7 @@ async def unequip_item(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"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
|
"dropped": True
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +204,7 @@ async def unequip_item(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Unequipped {item_def.name}",
|
"message": get_game_message('unequipped', locale, item=get_locale_string(item_def.name, locale)),
|
||||||
"dropped": False
|
"dropped": False
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,10 +245,12 @@ async def get_equipment(current_user: dict = Depends(get_current_user)):
|
|||||||
@router.post("/api/game/repair_item")
|
@router.post("/api/game/repair_item")
|
||||||
async def repair_item(
|
async def repair_item(
|
||||||
repair_req: RepairItemRequest,
|
repair_req: RepairItemRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Repair an item using materials at a workbench location"""
|
"""Repair an item using materials at a workbench location"""
|
||||||
player_id = current_user['id']
|
player_id = current_user['id']
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Get player's location
|
# Get player's location
|
||||||
player = await db.get_player_by_id(player_id)
|
player = await db.get_player_by_id(player_id)
|
||||||
@@ -358,7 +364,7 @@ async def repair_item(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"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,
|
"item_name": item_def.name,
|
||||||
"old_durability": current_durability,
|
"old_durability": current_durability,
|
||||||
"new_durability": new_durability,
|
"new_durability": new_durability,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import logging
|
|||||||
|
|
||||||
from ..core.security import get_current_user, security, verify_internal_key
|
from ..core.security import get_current_user, security, verify_internal_key
|
||||||
from ..services.models import *
|
from ..services.models import *
|
||||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, 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 .. import database as db
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
from .. import game_logic
|
from .. import game_logic
|
||||||
@@ -757,7 +757,7 @@ async def move(
|
|||||||
if cooldown_remaining > 0:
|
if cooldown_remaining > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
# Extract locale from Accept-Language header
|
||||||
@@ -870,7 +870,7 @@ async def move(
|
|||||||
response["encounter"] = {
|
response["encounter"] = {
|
||||||
"triggered": True,
|
"triggered": True,
|
||||||
"enemy_id": enemy_id,
|
"enemy_id": enemy_id,
|
||||||
"message": f"⚠️ An enemy ambushes you upon arrival!",
|
"message": get_game_message('enemy_ambush', locale),
|
||||||
"combat": combat_data
|
"combat": combat_data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,7 +881,7 @@ async def move(
|
|||||||
{
|
{
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"{player['name']} left the area",
|
"message": get_game_message('player_left', locale, player_name=player['name']),
|
||||||
"action": "player_left",
|
"action": "player_left",
|
||||||
"player_id": current_user['id'],
|
"player_id": current_user['id'],
|
||||||
"player_name": player['name']
|
"player_name": player['name']
|
||||||
@@ -897,7 +897,7 @@ async def move(
|
|||||||
{
|
{
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"{player['name']} arrived",
|
"message": get_game_message('player_arrived', locale, player_name=player['name']),
|
||||||
"action": "player_arrived",
|
"action": "player_arrived",
|
||||||
"player_id": current_user['id'],
|
"player_id": current_user['id'],
|
||||||
"player_name": player['name'],
|
"player_name": player['name'],
|
||||||
@@ -930,8 +930,11 @@ async def move(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/game/inspect")
|
@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"""
|
"""Inspect the current area"""
|
||||||
|
# Extract locale from Accept-Language header
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
location_id = current_user['location_id']
|
location_id = current_user['location_id']
|
||||||
location = LOCATIONS.get(location_id)
|
location = LOCATIONS.get(location_id)
|
||||||
|
|
||||||
@@ -947,7 +950,8 @@ async def inspect(current_user: dict = Depends(get_current_user)):
|
|||||||
message = await game_logic.inspect_area(
|
message = await game_logic.inspect_area(
|
||||||
current_user['id'],
|
current_user['id'],
|
||||||
location,
|
location,
|
||||||
{} # interactables_data - not needed with new structure
|
{}, # interactables_data - not needed with new structure
|
||||||
|
locale
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -971,7 +975,7 @@ async def interact(
|
|||||||
if combat:
|
if combat:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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']
|
location_id = current_user['location_id']
|
||||||
@@ -988,7 +992,8 @@ async def interact(
|
|||||||
interact_req.interactable_id,
|
interact_req.interactable_id,
|
||||||
interact_req.action_id,
|
interact_req.action_id,
|
||||||
location,
|
location,
|
||||||
ITEMS_MANAGER
|
ITEMS_MANAGER,
|
||||||
|
locale
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result['success']:
|
if not result['success']:
|
||||||
@@ -1052,6 +1057,7 @@ async def interact(
|
|||||||
@router.post("/api/game/use_item")
|
@router.post("/api/game/use_item")
|
||||||
async def use_item(
|
async def use_item(
|
||||||
use_req: UseItemRequest,
|
use_req: UseItemRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Use an item from inventory"""
|
"""Use an item from inventory"""
|
||||||
@@ -1064,10 +1070,14 @@ async def use_item(
|
|||||||
combat = await db.get_active_combat(current_user['id'])
|
combat = await db.get_active_combat(current_user['id'])
|
||||||
in_combat = combat is not None
|
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(
|
result = await game_logic.use_item(
|
||||||
current_user['id'],
|
current_user['id'],
|
||||||
use_req.item_id,
|
use_req.item_id,
|
||||||
ITEMS_MANAGER
|
ITEMS_MANAGER,
|
||||||
|
locale
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result['success']:
|
if not result['success']:
|
||||||
@@ -1087,10 +1097,10 @@ async def use_item(
|
|||||||
npc_damage = int(npc_damage * 1.5)
|
npc_damage = int(npc_damage * 1.5)
|
||||||
|
|
||||||
new_player_hp = max(0, player['hp'] - npc_damage)
|
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:
|
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.update_player(current_user['id'], hp=0, is_dead=True)
|
||||||
await db.end_combat(current_user['id'])
|
await db.end_combat(current_user['id'])
|
||||||
result['combat_over'] = True
|
result['combat_over'] = True
|
||||||
@@ -1149,7 +1159,7 @@ async def use_item(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"data": {
|
||||||
"message": f"{player['name']} was defeated in combat",
|
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
|
||||||
"action": "player_died",
|
"action": "player_died",
|
||||||
"player_id": player['id'],
|
"player_id": player['id'],
|
||||||
"corpse": corpse_data # Send full corpse data
|
"corpse": corpse_data # Send full corpse data
|
||||||
@@ -1194,7 +1204,8 @@ async def pickup(
|
|||||||
pickup_req.item_id,
|
pickup_req.item_id,
|
||||||
current_user['location_id'],
|
current_user['location_id'],
|
||||||
pickup_req.quantity,
|
pickup_req.quantity,
|
||||||
ITEMS_MANAGER
|
ITEMS_MANAGER,
|
||||||
|
locale
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result['success']:
|
if not result['success']:
|
||||||
@@ -1214,7 +1225,7 @@ async def pickup(
|
|||||||
{
|
{
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"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"
|
"action": "item_picked_up"
|
||||||
},
|
},
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"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")
|
@router.post("/api/game/item/drop")
|
||||||
async def drop_item(
|
async def drop_item(
|
||||||
drop_req: dict,
|
drop_req: dict,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Drop an item from inventory"""
|
"""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"
|
item_id = drop_req.get('item_id') # This is the item_id string like "energy_bar"
|
||||||
quantity = drop_req.get('quantity', 1)
|
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
|
# Get player to know their location
|
||||||
player = await db.get_player_by_id(player_id)
|
player = await db.get_player_by_id(player_id)
|
||||||
if not player:
|
if not player:
|
||||||
@@ -1400,7 +1415,7 @@ async def drop_item(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"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"
|
"action": "item_dropped"
|
||||||
},
|
},
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
@@ -1410,5 +1425,5 @@ async def drop_item(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"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.
|
Loot router.
|
||||||
Auto-generated from main.py migration.
|
Auto-generated from main.py migration.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Depends, status
|
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -12,7 +12,7 @@ import logging
|
|||||||
|
|
||||||
from ..core.security import get_current_user, security, verify_internal_key
|
from ..core.security import get_current_user, security, verify_internal_key
|
||||||
from ..services.models import *
|
from ..services.models import *
|
||||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, 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 .. import database as db
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
from .. import game_logic
|
from .. import game_logic
|
||||||
@@ -42,6 +42,7 @@ router = APIRouter(tags=["loot"])
|
|||||||
@router.get("/api/game/corpse/{corpse_id}")
|
@router.get("/api/game/corpse/{corpse_id}")
|
||||||
async def get_corpse_details(
|
async def get_corpse_details(
|
||||||
corpse_id: str,
|
corpse_id: str,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Get detailed information about a corpse's lootable items"""
|
"""Get detailed information about a corpse's lootable items"""
|
||||||
@@ -50,6 +51,9 @@ async def get_corpse_details(
|
|||||||
sys.path.insert(0, '/app')
|
sys.path.insert(0, '/app')
|
||||||
from data.npcs import NPCS
|
from data.npcs import NPCS
|
||||||
|
|
||||||
|
# Extract locale
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Parse corpse ID
|
# Parse corpse ID
|
||||||
corpse_type, corpse_db_id = corpse_id.split('_', 1)
|
corpse_type, corpse_db_id = corpse_id.split('_', 1)
|
||||||
corpse_db_id = int(corpse_db_id)
|
corpse_db_id = int(corpse_db_id)
|
||||||
@@ -99,7 +103,7 @@ async def get_corpse_details(
|
|||||||
return {
|
return {
|
||||||
'corpse_id': corpse_id,
|
'corpse_id': corpse_id,
|
||||||
'type': 'npc',
|
'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,
|
'loot_items': loot_items,
|
||||||
'total_items': len(loot_items)
|
'total_items': len(loot_items)
|
||||||
}
|
}
|
||||||
@@ -137,7 +141,7 @@ async def get_corpse_details(
|
|||||||
return {
|
return {
|
||||||
'corpse_id': corpse_id,
|
'corpse_id': corpse_id,
|
||||||
'type': 'player',
|
'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,
|
'loot_items': loot_items,
|
||||||
'total_items': len(loot_items)
|
'total_items': len(loot_items)
|
||||||
}
|
}
|
||||||
@@ -149,6 +153,7 @@ async def get_corpse_details(
|
|||||||
@router.post("/api/game/loot_corpse")
|
@router.post("/api/game/loot_corpse")
|
||||||
async def loot_corpse(
|
async def loot_corpse(
|
||||||
req: LootCorpseRequest,
|
req: LootCorpseRequest,
|
||||||
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Loot a corpse (NPC or player) - can loot specific item by index or all items"""
|
"""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')
|
sys.path.insert(0, '/app')
|
||||||
from data.npcs import NPCS
|
from data.npcs import NPCS
|
||||||
|
|
||||||
|
# Extract locale
|
||||||
|
locale = request.headers.get('Accept-Language', 'en')
|
||||||
|
|
||||||
# Parse corpse ID
|
# Parse corpse ID
|
||||||
corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
|
corpse_type, corpse_db_id = req.corpse_id.split('_', 1)
|
||||||
corpse_db_id = int(corpse_db_id)
|
corpse_db_id = int(corpse_db_id)
|
||||||
@@ -310,26 +318,26 @@ async def loot_corpse(
|
|||||||
message_parts = []
|
message_parts = []
|
||||||
for item in looted_items:
|
for item in looted_items:
|
||||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||||
item_name = 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']}")
|
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||||
|
|
||||||
dropped_parts = []
|
dropped_parts = []
|
||||||
for item in dropped_items:
|
for item in dropped_items:
|
||||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||||
item_name = 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']}")
|
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||||
|
|
||||||
message = ""
|
message = ""
|
||||||
if message_parts:
|
if message_parts:
|
||||||
message = "Looted: " + ", ".join(message_parts)
|
message = get_game_message('looted_items_start', locale) + ", ".join(message_parts)
|
||||||
if dropped_parts:
|
if dropped_parts:
|
||||||
if message:
|
if message:
|
||||||
message += "\n"
|
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:
|
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:
|
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
|
# Broadcast to location about corpse looting
|
||||||
if len(remaining_loot) == 0:
|
if len(remaining_loot) == 0:
|
||||||
@@ -339,7 +347,7 @@ async def loot_corpse(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"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"
|
"action": "corpse_looted"
|
||||||
},
|
},
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
@@ -438,24 +446,24 @@ async def loot_corpse(
|
|||||||
message_parts = []
|
message_parts = []
|
||||||
for item in looted_items:
|
for item in looted_items:
|
||||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||||
item_name = 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']}")
|
message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}")
|
||||||
|
|
||||||
dropped_parts = []
|
dropped_parts = []
|
||||||
for item in dropped_items:
|
for item in dropped_items:
|
||||||
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
item_def = ITEMS_MANAGER.get_item(item['item_id'])
|
||||||
item_name = 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']}")
|
dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}")
|
||||||
|
|
||||||
message = ""
|
message = ""
|
||||||
if message_parts:
|
if message_parts:
|
||||||
message = "Looted: " + ", ".join(message_parts)
|
message = get_game_message('looted_items_start', locale) + ", ".join(message_parts)
|
||||||
if dropped_parts:
|
if dropped_parts:
|
||||||
if message:
|
if message:
|
||||||
message += "\n"
|
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:
|
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
|
# Broadcast to location about corpse looting
|
||||||
if len(remaining_items) == 0:
|
if len(remaining_items) == 0:
|
||||||
@@ -465,7 +473,7 @@ async def loot_corpse(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"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",
|
"action": "player_corpse_emptied",
|
||||||
"corpse_id": req.corpse_id
|
"corpse_id": req.corpse_id
|
||||||
},
|
},
|
||||||
@@ -480,7 +488,7 @@ async def loot_corpse(
|
|||||||
message={
|
message={
|
||||||
"type": "location_update",
|
"type": "location_update",
|
||||||
"data": {
|
"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",
|
"action": "player_corpse_looted",
|
||||||
"corpse_id": req.corpse_id,
|
"corpse_id": req.corpse_id,
|
||||||
"remaining_items": remaining_items,
|
"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)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Translation maps for backend messages
|
# 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 = {
|
DIRECTION_TRANSLATIONS = {
|
||||||
'north': {'en': 'north', 'es': 'norte'},
|
'north': {'en': 'north', 'es': 'norte'},
|
||||||
'south': {'en': 'south', 'es': 'sur'},
|
'south': {'en': 'south', 'es': 'sur'},
|
||||||
@@ -39,8 +129,8 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> str:
|
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict:
|
||||||
"""Create a structured combat message with type, origin, and data.
|
"""Create a structured combat message object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_type: Type of combat message (combat_start, player_attack, etc.)
|
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.)
|
**data: Dynamic data for the message (damage, npc_name, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON string with 'type', 'origin', and 'data' fields
|
Dictionary with 'type', 'origin', and 'data' fields
|
||||||
"""
|
"""
|
||||||
return json.dumps({
|
return {
|
||||||
"type": message_type,
|
"type": message_type,
|
||||||
"origin": origin,
|
"origin": origin,
|
||||||
"data": data
|
"data": data
|
||||||
})
|
}
|
||||||
|
|
||||||
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
|
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
|
||||||
"""
|
"""
|
||||||
|
|||||||
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.
@@ -1,6 +1,8 @@
|
|||||||
import { BrowserRouter, HashRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, HashRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { useAuth } from './hooks/useAuth'
|
import { useAuth } from './hooks/useAuth'
|
||||||
|
import { AudioProvider } from './contexts/AudioContext'
|
||||||
|
import BackgroundMusic from './components/BackgroundMusic'
|
||||||
import LandingPage from './components/LandingPage'
|
import LandingPage from './components/LandingPage'
|
||||||
import Login from './components/Login'
|
import Login from './components/Login'
|
||||||
import Register from './components/Register'
|
import Register from './components/Register'
|
||||||
@@ -48,7 +50,9 @@ function CharacterRoute({ children }: { children: React.ReactNode }) {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<AudioProvider>
|
||||||
<Router>
|
<Router>
|
||||||
|
<BackgroundMusic />
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
@@ -113,6 +117,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
|
</AudioProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,47 @@
|
|||||||
.account-page {
|
.account-page {
|
||||||
min-height: 100vh;
|
|
||||||
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-container {
|
.account-container {
|
||||||
max-width: 1000px;
|
background: rgba(0, 0, 0, 0.8);
|
||||||
margin: 0 auto;
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-title {
|
.account-title {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
color: #646cff;
|
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
text-align: center;
|
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 {
|
.account-section {
|
||||||
background: rgba(42, 42, 42, 0.6);
|
margin-bottom: 3rem;
|
||||||
backdrop-filter: blur(10px);
|
padding-bottom: 2rem;
|
||||||
border: 1px solid rgba(100, 108, 255, 0.2);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 12px;
|
}
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
.account-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #646cff;
|
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
border-bottom: 1px solid rgba(100, 108, 255, 0.2);
|
color: #bbb;
|
||||||
padding-bottom: 0.5rem;
|
border-left: 4px solid #4a9eff;
|
||||||
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Account Information Grid */
|
|
||||||
.info-grid {
|
.info-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
@@ -60,41 +51,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #888;
|
color: #888;
|
||||||
font-weight: 600;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #fff;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value.premium {
|
.info-value.premium {
|
||||||
color: #ffd93d;
|
color: #ffd700;
|
||||||
font-weight: 600;
|
text-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Characters Grid */
|
/* Characters Grid */
|
||||||
.characters-grid {
|
.characters-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-card {
|
.character-card {
|
||||||
background: rgba(26, 26, 26, 0.8);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
border-radius: 6px;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
transition: all 0.3s ease;
|
transition: transform 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-card:hover {
|
.character-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-2px);
|
||||||
border-color: rgba(100, 108, 255, 0.6);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-header {
|
.character-header {
|
||||||
@@ -102,21 +90,21 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-header h3 {
|
.character-header h3 {
|
||||||
font-size: 1.3rem;
|
|
||||||
color: #fff;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-level {
|
.character-level {
|
||||||
background: linear-gradient(135deg, #646cff 0%, #8b5cf6 100%);
|
background: #4a9eff;
|
||||||
color: #fff;
|
padding: 0.2rem 0.5rem;
|
||||||
padding: 0.25rem 0.75rem;
|
border-radius: 4px;
|
||||||
border-radius: 12px;
|
font-size: 0.8rem;
|
||||||
font-size: 0.85rem;
|
font-weight: bold;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-stats {
|
.character-stats {
|
||||||
@@ -127,135 +115,219 @@
|
|||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 0.5rem;
|
||||||
gap: 0.25rem;
|
color: #aba;
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-attributes {
|
.character-attributes {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 0.75rem;
|
grid-template-columns: 1fr 1fr;
|
||||||
margin-bottom: 1rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #aaa;
|
color: #888;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-characters {
|
.no-characters {
|
||||||
color: #888;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
font-style: italic;
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings */
|
/* Settings */
|
||||||
.setting-item {
|
.setting-item {
|
||||||
margin-bottom: 2rem;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
padding-bottom: 2rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid rgba(100, 108, 255, 0.1);
|
border-radius: 6px;
|
||||||
}
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
.setting-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-header {
|
.setting-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-header h3 {
|
.setting-header h3 {
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #fff;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-form {
|
.setting-form {
|
||||||
background: rgba(26, 26, 26, 0.6);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border: 1px solid rgba(100, 108, 255, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-form .form-group {
|
.form-group {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-form .form-group:last-of-type {
|
.form-group label {
|
||||||
margin-bottom: 1.5rem;
|
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 */
|
/* Actions */
|
||||||
.account-actions {
|
.account-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
justify-content: space-between;
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-danger {
|
/* Buttons */
|
||||||
background-color: #ff6b6b;
|
.button-primary,
|
||||||
color: white;
|
.button-secondary,
|
||||||
|
.button-danger,
|
||||||
|
.button-link {
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
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 {
|
.button-danger:hover {
|
||||||
background-color: #ff5252;
|
background: rgba(220, 53, 69, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
.button-link {
|
||||||
@media (max-width: 768px) {
|
background: none;
|
||||||
.account-page {
|
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;
|
padding: 1rem;
|
||||||
}
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
.account-title {
|
text-align: center;
|
||||||
font-size: 2rem;
|
}
|
||||||
}
|
|
||||||
|
.success {
|
||||||
.account-section {
|
background: rgba(40, 167, 69, 0.1);
|
||||||
padding: 1.5rem;
|
color: #5ddc6c;
|
||||||
}
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
.info-grid {
|
margin-bottom: 1rem;
|
||||||
grid-template-columns: 1fr;
|
text-align: center;
|
||||||
}
|
|
||||||
|
|
||||||
.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%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { useAudio } from '../contexts/AudioContext'
|
||||||
import { authApi, Account, Character } from '../services/api'
|
import { authApi, Account, Character } from '../services/api'
|
||||||
import './AccountPage.css'
|
import './AccountPage.css'
|
||||||
|
|
||||||
@@ -29,6 +30,14 @@ function AccountPage() {
|
|||||||
const [passwordError, setPasswordError] = useState('')
|
const [passwordError, setPasswordError] = useState('')
|
||||||
const [passwordSuccess, setPasswordSuccess] = useState('')
|
const [passwordSuccess, setPasswordSuccess] = useState('')
|
||||||
|
|
||||||
|
// Audio state
|
||||||
|
const {
|
||||||
|
masterVolume, setMasterVolume,
|
||||||
|
musicVolume, setMusicVolume,
|
||||||
|
sfxVolume, setSfxVolume,
|
||||||
|
isMuted, setIsMuted
|
||||||
|
} = useAudio()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAccountData()
|
fetchAccountData()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -227,6 +236,63 @@ function AccountPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Settings 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">
|
<section className="account-section">
|
||||||
<h2 className="section-title">Account Settings</h2>
|
<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;
|
color: #f44336;
|
||||||
}
|
}
|
||||||
|
|
||||||
.combat-actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combat-action-btn {
|
.combat-action-btn {
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { useGameEngine } from './game/hooks/useGameEngine'
|
import { useGameEngine } from './game/hooks/useGameEngine'
|
||||||
import Combat from './game/Combat'
|
import { Combat } from './game/Combat'
|
||||||
import LocationView from './game/LocationView'
|
import LocationView from './game/LocationView'
|
||||||
import MovementControls from './game/MovementControls'
|
import MovementControls from './game/MovementControls'
|
||||||
import PlayerSidebar from './game/PlayerSidebar'
|
import PlayerSidebar from './game/PlayerSidebar'
|
||||||
|
|||||||
@@ -1,372 +1,433 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import CombatView from './CombatView'
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
|
// import { useGame } from '../../contexts/GameContext'; // Removed invalid import
|
||||||
import type { FloatingText, CombatMessage } from './CombatTypes'
|
import { CombatView } from './CombatView';
|
||||||
import api from '../../services/api'
|
import { CombatState, CombatMessage, FloatingText, AnimationState, CombatActionResponse } from './CombatTypes';
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { useTranslation } from 'react-i18next';
|
||||||
import './CombatEffects.css'
|
|
||||||
|
|
||||||
|
// Updated props interface to match Game.tsx
|
||||||
interface CombatProps {
|
interface CombatProps {
|
||||||
combatState: CombatState
|
combatState: any; // Using any for now to be flexible with backend response
|
||||||
profile: Profile | null
|
combatLog: any[];
|
||||||
playerState: PlayerState | null
|
profile: any;
|
||||||
equipment: Equipment
|
playerState: any;
|
||||||
onCombatAction: (action: string) => Promise<any>
|
equipment: any;
|
||||||
onExitCombat: () => void
|
onCombatAction: (action: string) => Promise<any>;
|
||||||
onPvPAction: (action: string) => Promise<any>
|
onPvPAction: (action: string, targetId: number) => Promise<void>;
|
||||||
onExitPvPCombat: () => void
|
onExitCombat: () => void;
|
||||||
combatLog: CombatLogEntry[]
|
onExitPvPCombat: () => Promise<void>;
|
||||||
addCombatLogEntry: (entry: CombatLogEntry) => void
|
addCombatLogEntry: (entry: any) => void;
|
||||||
updatePlayerState: (state: PlayerState) => void
|
updatePlayerState: (data: any) => void;
|
||||||
updateCombatState: (state: CombatState) => void
|
updateCombatState: (data: any) => void;
|
||||||
|
|
||||||
|
// Kept for compatibility if passed
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Combat = ({
|
export const Combat: React.FC<CombatProps> = ({
|
||||||
combatState,
|
combatState: initialCombatData,
|
||||||
|
combatLog: _combatLog,
|
||||||
profile,
|
profile,
|
||||||
playerState,
|
playerState,
|
||||||
equipment,
|
equipment: _equipment,
|
||||||
onCombatAction,
|
onCombatAction,
|
||||||
onExitCombat,
|
|
||||||
onPvPAction,
|
onPvPAction,
|
||||||
|
onExitCombat,
|
||||||
onExitPvPCombat,
|
onExitPvPCombat,
|
||||||
combatLog,
|
addCombatLogEntry: _addCombatLogEntry,
|
||||||
addCombatLogEntry,
|
|
||||||
updatePlayerState,
|
updatePlayerState,
|
||||||
updateCombatState
|
updateCombatState: _updateCombatState,
|
||||||
}: CombatProps) => {
|
onClose
|
||||||
// Visual effects state
|
}) => {
|
||||||
const [shake, setShake] = useState(false)
|
const { currentCharacter: _currentCharacter, refreshCharacters } = useAuth();
|
||||||
const [flash, setFlash] = useState(false)
|
const { t, i18n } = useTranslation();
|
||||||
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([])
|
|
||||||
const [processing, setProcessing] = useState(false)
|
|
||||||
|
|
||||||
// Timer state
|
const isPvP = initialCombatData?.is_pvp || false;
|
||||||
const [pvpTimer, setPvpTimer] = useState<number | null>(null)
|
|
||||||
const [turnTimeRemaining, setTurnTimeRemaining] = useState<number | null>(null)
|
|
||||||
|
|
||||||
// Enemy thinking indicator
|
// Helper to resolve localized names safely
|
||||||
const [enemyThinking, setEnemyThinking] = useState(false)
|
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
|
// Helper to determine initial combat message
|
||||||
const [tempPlayerHP, setTempPlayerHP] = useState<number | null>(null)
|
const getInitialLogMessage = (): CombatMessage[] => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
|
||||||
// Refs for cleanup
|
if (isPvP) {
|
||||||
const isMounted = useRef(true)
|
const isAttacker = initialCombatData?.pvp_combat?.is_attacker;
|
||||||
const floatingTextIdCounter = useRef(0)
|
return [{
|
||||||
const floatingTextTimeouts = useRef<Set<NodeJS.Timeout>>(new Set())
|
type: 'text',
|
||||||
|
origin: 'system',
|
||||||
|
timestamp,
|
||||||
|
data: { text: isAttacker ? t('combat.log.pvp_attack') : t('combat.log.pvp_defense') }
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// PvE Logic
|
||||||
// Cleanup Effects
|
// 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(() => {
|
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 () => {
|
return () => {
|
||||||
isMounted.current = false
|
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
|
||||||
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
|
};
|
||||||
floatingTextTimeouts.current.clear()
|
}, []);
|
||||||
setFloatingTexts([])
|
|
||||||
|
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
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
const msgWithTimestamp = {
|
||||||
|
...msg,
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
};
|
||||||
|
|
||||||
|
setLocalCombatState(prev => ({
|
||||||
|
...prev,
|
||||||
|
messages: [...prev.messages, msgWithTimestamp]
|
||||||
|
}));
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'combat_start':
|
||||||
|
break;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const processQueue = useCallback(async () => {
|
||||||
|
if (processingRef.current || queueRef.current.length === 0) return;
|
||||||
|
|
||||||
|
processingRef.current = true;
|
||||||
|
setIsProcessingQueue(true);
|
||||||
|
|
||||||
|
const msg = queueRef.current[0];
|
||||||
|
|
||||||
|
processMessage(msg);
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (combatState.combat_over) {
|
if (messageQueue.length > 0 && !processingRef.current) {
|
||||||
floatingTextTimeouts.current.forEach(timeout => clearTimeout(timeout))
|
processQueue();
|
||||||
floatingTextTimeouts.current.clear()
|
} else if (messageQueue.length === 0 && isProcessingQueue) {
|
||||||
setFloatingTexts([])
|
// Queue just finished processing
|
||||||
|
setIsProcessingQueue(false);
|
||||||
}
|
}
|
||||||
}, [combatState.combat_over])
|
}, [messageQueue, processQueue, isProcessingQueue]);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Timer Effects
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// PvP Timer
|
|
||||||
useEffect(() => {
|
|
||||||
if (combatState.is_pvp && combatState.pvp_combat) {
|
|
||||||
setPvpTimer(combatState.pvp_combat.time_remaining)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}, 10000)
|
|
||||||
|
|
||||||
return () => clearInterval(pollInterval)
|
|
||||||
}
|
|
||||||
}, [turnTimeRemaining, combatState, updateCombatState])
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Helper Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const addFloatingText = (text: string, x: number, y: number, type: FloatingText['type']) => {
|
|
||||||
const id = ++floatingTextIdCounter.current
|
|
||||||
setFloatingTexts(prev => [...prev, { id, text, x, y, type }])
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (isMounted.current) {
|
|
||||||
setFloatingTexts(prev => prev.filter(ft => ft.id !== id))
|
|
||||||
floatingTextTimeouts.current.delete(timeout)
|
|
||||||
}
|
|
||||||
}, 2500)
|
|
||||||
|
|
||||||
floatingTextTimeouts.current.add(timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msg as CombatMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PvE Combat Actions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const handlePvEAction = async (action: string) => {
|
const handlePvEAction = async (action: string) => {
|
||||||
if (processing) return
|
if (isProcessingQueue) return;
|
||||||
setProcessing(true)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await onCombatAction(action)
|
if (localCombatState.turn !== 'player') return;
|
||||||
const now = new Date()
|
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
|
|
||||||
// Parse message into structured parts
|
// Use the prop function instead of direct fetch
|
||||||
const messages = data.message.split('\n').filter((m: string) => m.trim())
|
const data: CombatActionResponse = await onCombatAction(action);
|
||||||
|
|
||||||
const playerMessages: any[] = []
|
if (data && data.success && data.messages) {
|
||||||
const enemyMessages: any[] = []
|
setMessageQueue(data.messages);
|
||||||
|
|
||||||
messages.forEach((msg: string) => {
|
// Apply server HP values IMMEDIATELY
|
||||||
const parsed = parseMessage(msg)
|
if (data.combat) {
|
||||||
|
setLocalCombatState(prev => ({
|
||||||
if (parsed) {
|
...prev,
|
||||||
// Structured message - use origin field
|
npcHp: data.combat.npc_hp,
|
||||||
if (parsed.origin === 'player') {
|
npcMaxHp: data.combat.npc_max_hp,
|
||||||
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,
|
|
||||||
turn: data.combat.turn,
|
turn: data.combat.turn,
|
||||||
turn_time_remaining: data.combat.turn_time_remaining,
|
|
||||||
round: data.combat.round,
|
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
|
||||||
// Store current player HP
|
setLocalCombatState(prev => ({
|
||||||
if (playerState) {
|
...prev,
|
||||||
setTempPlayerHP(playerState.health)
|
npcHp: 0
|
||||||
}
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Enemy turn with delay
|
if (data.player) {
|
||||||
if (enemyMessages.length > 0 && !data.combat_over) {
|
// Store player HP to apply when enemy_attack message is processed
|
||||||
setEnemyThinking(true)
|
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
// Store player XP to apply when xp_gain message is processed
|
||||||
setEnemyThinking(false)
|
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
|
||||||
|
refreshCharacters();
|
||||||
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 (err) {
|
||||||
} catch (error) {
|
console.error(err);
|
||||||
console.error('Combat action failed:', error)
|
|
||||||
} finally {
|
|
||||||
setProcessing(false)
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [isClosing, setIsClosing] = useState(false);
|
||||||
|
|
||||||
|
const handleCloseWrapper = () => {
|
||||||
|
if (isClosing) return;
|
||||||
|
setIsClosing(true);
|
||||||
|
|
||||||
|
// Clear all dynamic elements to allow React to reconcile locally before unmounting
|
||||||
|
setFloatingTexts([]);
|
||||||
|
setAnimState({ shaking: false, flashing: false, enemyAttacking: false, playerAttacking: false });
|
||||||
|
setMessageQueue([]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PvP Combat Actions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const handlePvPActionLocal = async (action: string) => {
|
|
||||||
if (processing) return
|
|
||||||
setProcessing(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await onPvPAction(action)
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
const now = new Date()
|
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('PvP action failed:', error)
|
|
||||||
} finally {
|
|
||||||
setProcessing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`combat-container ${shake ? 'shake-effect' : ''}`}>
|
|
||||||
<CombatView
|
<CombatView
|
||||||
combatState={combatState}
|
state={localCombatState}
|
||||||
combatLog={combatLog}
|
animState={animState}
|
||||||
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}
|
|
||||||
floatingTexts={floatingTexts}
|
floatingTexts={floatingTexts}
|
||||||
|
onAction={isPvP ? handlePvPActionWrapper : handlePvEAction}
|
||||||
|
onClose={handleCloseWrapper}
|
||||||
|
isProcessing={isProcessingQueue}
|
||||||
|
combatResult={combatResult}
|
||||||
|
equipment={_equipment}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
)
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default Combat
|
|
||||||
|
|||||||
@@ -1,328 +1,471 @@
|
|||||||
/* Combat Visual Effects */
|
/* Combat Layout */
|
||||||
|
.combat-container {
|
||||||
/* Screen Shake */
|
display: flex;
|
||||||
@keyframes shake {
|
flex-direction: column;
|
||||||
0% {
|
width: 100%;
|
||||||
transform: translate(1px, 1px) rotate(0deg);
|
margin: 0 auto;
|
||||||
}
|
/* More transparent/themed background */
|
||||||
|
background: rgba(20, 20, 20, 0.6);
|
||||||
10% {
|
backdrop-filter: blur(5px);
|
||||||
transform: translate(-1px, -2px) rotate(-1deg);
|
border-radius: 12px;
|
||||||
}
|
padding: 1rem;
|
||||||
|
color: white;
|
||||||
20% {
|
position: relative;
|
||||||
transform: translate(-3px, 0px) rotate(1deg);
|
overflow: hidden;
|
||||||
}
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shake-effect {
|
.glow-effect {
|
||||||
animation: shake 0.5s;
|
box-shadow: 0 0 10px #ff4444, 0 0 20px #ff4444;
|
||||||
animation-iteration-count: 1;
|
transition: box-shadow 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hit Flash */
|
.dead .location-image {
|
||||||
@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 {
|
|
||||||
filter: grayscale(100%);
|
filter: grayscale(100%);
|
||||||
transition: filter 0.5s ease-out;
|
transition: filter 1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fled Enemy Blueish Tint */
|
/* Enemy avatar now uses shared .location-image styles from Game.css */
|
||||||
.enemy-fled {
|
|
||||||
filter: sepia(1) saturate(3) hue-rotate(180deg) brightness(0.8);
|
/* ... existing code ... */
|
||||||
transition: filter 0.5s ease-out;
|
|
||||||
|
/* Action Buttons Center */
|
||||||
|
.combat-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
/* Center horizontally */
|
||||||
|
padding: 1rem 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating Damage Numbers */
|
.combat-header {
|
||||||
@keyframes float-up {
|
display: flex;
|
||||||
0% {
|
justify-content: center;
|
||||||
opacity: 1;
|
align-items: center;
|
||||||
transform: translateY(0) scale(1);
|
margin: 0 0 1rem 0;
|
||||||
}
|
padding: 0;
|
||||||
|
border: none;
|
||||||
50% {
|
background: transparent;
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-30px) scale(1.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-60px) scale(1.5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
z-index: 10;
|
||||||
z-index: 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-text {
|
.floating-text {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 2.5rem;
|
font-size: 1.5rem;
|
||||||
text-shadow: 3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000;
|
animation: float-up 5s forwards;
|
||||||
animation: float-up 2.5s ease-out forwards;
|
|
||||||
white-space: nowrap;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1000;
|
text-shadow: 2px 2px 0 #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-text.damage-player {
|
.type-damage {
|
||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-text.damage-enemy {
|
.type-crit {
|
||||||
color: #ff4444;
|
color: #ffaa00;
|
||||||
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-text.damage-player-dealt {
|
.type-heal {
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-text.heal {
|
|
||||||
color: #44ff44;
|
color: #44ff44;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Intent Bubble */
|
.type-miss {
|
||||||
.intent-bubble {
|
color: #aaa;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pop-in {
|
.type-info {
|
||||||
|
color: #ffff44;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-up {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(-50%) scale(0);
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(-50%) scale(1);
|
transform: translateY(-50px) scale(1.2);
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.intent-icon {
|
.type-xp {
|
||||||
font-size: 1.2em;
|
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 {
|
.shake-effect {
|
||||||
font-size: 0.9em;
|
animation: shake 0.5s cubic-bezier(.36, .07, .19, .97) both;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Intent Types */
|
@keyframes shake {
|
||||||
.intent-attack {
|
|
||||||
border-color: #ff4444;
|
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 {
|
.flash-hit {
|
||||||
border-color: #4488ff;
|
animation: flash 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intent-special {
|
@keyframes flash {
|
||||||
border-color: #ffaa00;
|
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 */
|
.turn-overlay {
|
||||||
.combat-enemy-display-inline {
|
position: absolute;
|
||||||
position: relative;
|
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 {
|
@keyframes pulse {
|
||||||
position: relative;
|
0% {
|
||||||
display: inline-block;
|
opacity: 0.7;
|
||||||
max-width: 100%;
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.combat-enemy-image-large img {
|
/* Attacking Animation */
|
||||||
max-width: 100%;
|
.attacking {
|
||||||
height: auto;
|
animation: lunge 0.3s;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.combat-view {
|
@keyframes lunge {
|
||||||
position: relative;
|
0% {
|
||||||
/* For screen shake scope if applied here */
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assuming LTR, for enemy use -20px via modifier if needed */
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Combat Container */
|
.enemy.attacking {
|
||||||
.combat-container {
|
animation: lunge-left 0.3s;
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Combat Content Wrapper - Groups enemy display, turn indicator, and combat log */
|
@keyframes lunge-left {
|
||||||
.combat-content-wrapper {
|
0% {
|
||||||
display: inline-flex;
|
transform: translateX(0);
|
||||||
flex-direction: column;
|
}
|
||||||
align-items: stretch;
|
|
||||||
gap: 1rem;
|
50% {
|
||||||
max-width: 800px;
|
transform: translateX(-20px);
|
||||||
margin: 0 auto;
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Turn Indicator - Match Enemy Image Width */
|
/* Combat Stats Layout - Staggered HP Bars */
|
||||||
.combat-turn-indicator-inline {
|
.combat-stats-container {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
}
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
/* Combat Log Styles */
|
|
||||||
.combat-log-wrapper {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.combat-log-title {
|
.stat-block {
|
||||||
margin: 0 0 10px 0;
|
background: rgba(0, 0, 0, 0.4);
|
||||||
font-size: 1.1em;
|
padding: 0.5rem 1rem;
|
||||||
color: #aaa;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combat-log-inline {
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entries {
|
.stat-block.enemy {
|
||||||
max-height: 200px;
|
width: 60%;
|
||||||
overflow-y: auto;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
margin-bottom: 0.25rem;
|
||||||
width: 100%;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for combat log */
|
.stat-block .stat-label {
|
||||||
.log-entries::-webkit-scrollbar {
|
font-weight: 600;
|
||||||
width: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entries::-webkit-scrollbar-track {
|
.stat-block .stat-numbers {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
color: #ddd;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entries::-webkit-scrollbar-thumb {
|
.stat-block.player .progress-bar {
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
}
|
||||||
|
|
||||||
|
/* Ensure progress bars look like GameHeader */
|
||||||
|
.progress-bar {
|
||||||
width: 100%;
|
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 {
|
.progress-fill {
|
||||||
background: rgba(0, 0, 0, 0.35);
|
height: 100%;
|
||||||
}
|
border-radius: 6px;
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
@@ -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 {
|
export interface CombatMessage {
|
||||||
type: 'combat_start' | 'player_attack' | 'enemy_attack' | 'victory' | 'flee_fail' | string
|
type: string;
|
||||||
origin: 'player' | 'enemy' | 'neutral'
|
origin: 'player' | 'enemy' | 'neutral' | 'system';
|
||||||
data: {
|
data: Record<string, any>;
|
||||||
damage?: number
|
timestamp?: string;
|
||||||
npc_name?: string | { en: string; es: string }
|
|
||||||
armor_absorbed?: number
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
export interface FloatingText {
|
||||||
id: number
|
id: string;
|
||||||
text: string
|
text: string;
|
||||||
x: number
|
type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp';
|
||||||
y: number
|
x: number; // Percentage 0-100
|
||||||
type: 'damage-player' | 'damage-enemy' | 'damage-player-dealt' | 'heal'
|
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 {
|
export interface CombatState {
|
||||||
// Common fields
|
inCombat: boolean;
|
||||||
in_combat: boolean
|
turn: 'player' | 'enemy';
|
||||||
combat_over: boolean
|
npcId?: string;
|
||||||
player_won?: boolean
|
npcName?: string;
|
||||||
player_fled?: boolean
|
npcHp: number;
|
||||||
|
npcMaxHp: number;
|
||||||
// PvE fields
|
npcImage?: string;
|
||||||
is_pvp: boolean
|
playerHp: number;
|
||||||
combat?: PvECombat
|
playerMaxHp: number;
|
||||||
combat_image?: string
|
messages: CombatMessage[]; // History of messages
|
||||||
|
turnTimeRemaining?: number;
|
||||||
// PvP fields
|
round: number;
|
||||||
in_pvp_combat?: boolean
|
isPvP?: boolean;
|
||||||
pvp_combat?: PvPCombat
|
opponentName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Combat Action Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combat action response from server
|
|
||||||
*/
|
|
||||||
export interface CombatActionResponse {
|
export interface CombatActionResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message: string
|
messages: CombatMessage[]; // The structured messages from this action
|
||||||
combat_over: boolean
|
combat_over: boolean;
|
||||||
player_won?: boolean
|
player_won?: boolean;
|
||||||
combat?: PvECombat
|
combat?: any; // Updated combat state from API
|
||||||
|
pvp_combat?: any; // Updated PvP combat state from API
|
||||||
player?: {
|
player?: {
|
||||||
hp: number
|
hp: number;
|
||||||
max_hp: number
|
max_hp: number;
|
||||||
xp: number
|
xp: number;
|
||||||
level: number
|
level: number;
|
||||||
}
|
};
|
||||||
|
winner_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface AnimationState {
|
||||||
* PvP Combat action response from server
|
shaking: boolean;
|
||||||
*/
|
flashing: boolean;
|
||||||
export interface PvPCombatActionResponse {
|
enemyAttacking: boolean;
|
||||||
success: boolean
|
playerAttacking: boolean;
|
||||||
message: string
|
playerHit?: boolean; // New: Player taking damage
|
||||||
combat?: PvPCombat
|
npcHit?: boolean; // New: NPC taking damage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,420 +1,274 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import React, { useEffect } from 'react';
|
||||||
import type { CombatState, CombatLogEntry, Profile, Equipment, PlayerState } from './types'
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { FloatingText, CombatMessage } from './CombatTypes'
|
import { useAudio } from '../../contexts/AudioContext';
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
|
||||||
|
import { Equipment } from './types';
|
||||||
|
import './CombatEffects.css';
|
||||||
|
|
||||||
interface CombatViewProps {
|
interface CombatViewProps {
|
||||||
combatState: CombatState
|
state: CombatState;
|
||||||
combatLog: CombatLogEntry[]
|
animState: AnimationState;
|
||||||
profile: Profile | null
|
floatingTexts: FloatingText[];
|
||||||
playerState: PlayerState | null
|
onAction: (action: string) => void;
|
||||||
equipment: Equipment
|
onClose: () => void;
|
||||||
enemyName: string
|
isProcessing: boolean;
|
||||||
enemyImage: string
|
combatResult: 'victory' | 'defeat' | 'fled' | null;
|
||||||
enemyTurnMessage: string
|
equipment?: Equipment | any;
|
||||||
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[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CombatView({
|
export const CombatView: React.FC<CombatViewProps> = ({
|
||||||
combatState,
|
state,
|
||||||
combatLog,
|
animState,
|
||||||
profile: _profile,
|
floatingTexts,
|
||||||
playerState,
|
onAction,
|
||||||
enemyName,
|
onClose,
|
||||||
enemyImage,
|
isProcessing,
|
||||||
enemyTurnMessage,
|
combatResult,
|
||||||
pvpTimeRemaining,
|
equipment
|
||||||
turnTimeRemaining,
|
}) => {
|
||||||
onCombatAction,
|
const { t } = useTranslation();
|
||||||
onPvPAction,
|
const { playSfx } = useAudio();
|
||||||
onExitCombat,
|
|
||||||
onExitPvPCombat,
|
|
||||||
flashEnemy,
|
|
||||||
buttonsDisabled,
|
|
||||||
floatingTexts = []
|
|
||||||
}: CombatViewProps) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const displayEnemyName = typeof enemyName === 'object' ? getTranslatedText(enemyName) : (enemyName || 'Enemy')
|
|
||||||
|
|
||||||
// ============================================================================
|
// SFX Logic triggered by state or anim states
|
||||||
// Message Rendering
|
useEffect(() => {
|
||||||
// ============================================================================
|
// Check for combat completion sounds
|
||||||
|
if (combatResult === 'victory') {
|
||||||
const renderCombatMessage = (msg: any) => {
|
playSfx('/audio/sfx/victory.wav');
|
||||||
// Handle string messages
|
} else if (combatResult === 'defeat') {
|
||||||
if (typeof msg === 'string') {
|
playSfx('/audio/sfx/defeat.wav');
|
||||||
return msg
|
} else if (combatResult === 'fled') {
|
||||||
|
playSfx('/audio/sfx/flee.wav');
|
||||||
}
|
}
|
||||||
|
}, [combatResult, playSfx]);
|
||||||
|
|
||||||
// Handle legacy formatted messages
|
// Track animation states to trigger attack/hit sounds
|
||||||
if (!msg || !msg.type) {
|
useEffect(() => {
|
||||||
return String(msg)
|
// 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();
|
||||||
|
|
||||||
const message = msg as CombatMessage
|
if (weaponName.includes('sword') || weaponName.includes('blade')) weaponType = 'sword';
|
||||||
const { type, data } = message
|
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';
|
||||||
|
|
||||||
switch (type) {
|
playSfx(`/audio/sfx/attack_${weaponType}.wav`, '/audio/sfx/attack_default.wav');
|
||||||
case 'combat_start':
|
} else {
|
||||||
return t('combat.messages.combat_start', { enemy: getTranslatedText(data.npc_name) })
|
// Unarmed
|
||||||
case 'player_attack':
|
playSfx('/audio/sfx/attack_punch.wav', '/audio/sfx/attack_default.wav');
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// Enemy Attack Sound
|
||||||
// Format Timer Display
|
if (animState.enemyAttacking) {
|
||||||
// ============================================================================
|
// We can use state.npcId to get specific enemy sounds
|
||||||
|
if (state.npcId) {
|
||||||
const formatTimer = (seconds: number) => {
|
playSfx(`/audio/sfx/attack_enemy_${state.npcId}.wav`, '/audio/sfx/attack_enemy_default.wav');
|
||||||
const mins = Math.floor(seconds / 60)
|
} else {
|
||||||
const secs = Math.floor(seconds % 60)
|
playSfx('/audio/sfx/attack_enemy_default.wav', '/audio/sfx/attack_default.wav');
|
||||||
return `${mins}:${String(secs).padStart(2, '0')}`
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animState.flashing) {
|
||||||
|
// Someone took damage
|
||||||
|
playSfx('/audio/sfx/hit.wav');
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [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 (
|
return (
|
||||||
<div className="combat-view">
|
<div className="combat-container">
|
||||||
<div className="combat-header-inline">
|
|
||||||
<h2 style={{ background: 'linear-gradient(90deg, #4CAF50, #2196F3)', padding: '0.5rem', borderRadius: '8px' }}>
|
{/* Header (Location View Style) */}
|
||||||
🆕 NEW COMBAT - {combatState.is_pvp ? `⚔️ ${t('combat.title')} - PvP` : `⚔️ ${t('combat.title')} - ${displayEnemyName}`}
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{combatState.is_pvp ? (
|
{/* Main Content Vertical Stack */}
|
||||||
/* ================================================================ */
|
<div className="combat-main-content">
|
||||||
/* PvP Combat UI */
|
|
||||||
/* ================================================================ */
|
{/* 1. Enemy Avatar (Location Image Style) */}
|
||||||
<div className="combat-content-wrapper">
|
{/* Shake on npcHit, Attack on enemyAttacking, Dead on victory */}
|
||||||
<div className="combat-enemy-display-inline">
|
<div className={`enemy-display ${animState.enemyAttacking ? 'attacking' : ''} ${animState.npcHit ? 'shake-effect flash-hit' : ''} ${combatResult === 'victory' ? 'dead' : ''}`}>
|
||||||
{/* Opponent Display */}
|
<div className="location-image-container">
|
||||||
<div className="combat-enemy-image-large">
|
{state.npcImage ? (
|
||||||
<div className="floating-texts-container">
|
<img src={state.npcImage} alt={state.npcName} className="location-image" />
|
||||||
{floatingTexts.map(ft => (
|
) : (
|
||||||
<div
|
<div className="enemy-placeholder">💀</div>
|
||||||
key={ft.id}
|
)}
|
||||||
className={`floating-text ${ft.type}`}
|
</div>
|
||||||
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. HP Bars (Character Sheet Style) - Staggered Lines */}
|
||||||
|
<div className="combat-stats-container">
|
||||||
|
|
||||||
|
{/* 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}
|
{ft.text}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
<div className="stat-header">
|
||||||
if (!combatState.pvp_combat) return null
|
<span className="stat-label">{t('common.enemy')}</span>
|
||||||
const opponent = combatState.pvp_combat.is_attacker ?
|
<span className="stat-numbers">{state.npcHp} / {state.npcMaxHp}</span>
|
||||||
combatState.pvp_combat.defender :
|
|
||||||
combatState.pvp_combat.attacker
|
|
||||||
|
|
||||||
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="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 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>
|
</div>
|
||||||
|
|
||||||
<div className="combat-turn-indicator-inline">
|
{/* Player HP (Right) */}
|
||||||
{combatState.pvp_combat.combat_over ? (
|
<div className={`stat-block player ${animState.playerAttacking ? 'attacking' : ''} ${animState.playerHit ? 'shake-effect flash-hit' : ''}`}>
|
||||||
<span className={combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? "your-turn" : "enemy-turn"}>
|
<div className="floating-text-layer" style={{ height: '0', overflow: 'visible' }}>
|
||||||
{combatState.pvp_combat.attacker_fled || combatState.pvp_combat.defender_fled ? `🏃 ${t('combat.fleeSuccess')}` : `💀 ${t('combat.defeat')}`}
|
{floatingTexts.filter(ft => ft.origin === 'player').map(ft => (
|
||||||
</span>
|
<div key={ft.id} className={`floating-text type-${ft.type}`} style={{ left: `${ft.x}%`, top: `${ft.y - 50}%` }}>
|
||||||
) : combatState.pvp_combat.your_turn ? (
|
|
||||||
<span className="your-turn">
|
|
||||||
✅ {t('combat.yourTurn')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)})
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="enemy-turn">
|
|
||||||
⏳ {t('combat.waiting')} ({formatTimer(pvpTimeRemaining ?? combatState.pvp_combat.time_remaining)})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="combat-actions-inline">
|
|
||||||
{!combatState.pvp_combat.combat_over ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="combat-action-btn attack-btn"
|
|
||||||
onClick={() => onPvPAction('attack')}
|
|
||||||
disabled={!combatState.pvp_combat.your_turn || buttonsDisabled}
|
|
||||||
>
|
|
||||||
{t('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>
|
|
||||||
|
|
||||||
{/* Combat Log */}
|
|
||||||
<div className="combat-log-wrapper">
|
|
||||||
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
|
|
||||||
<div className="combat-log-inline">
|
|
||||||
<div className="log-entries">
|
|
||||||
<div className="log-list">
|
|
||||||
{combatLog.length > 0 ? (
|
|
||||||
combatLog.map((entry: any) => (
|
|
||||||
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
|
|
||||||
<span className="log-time">[{entry.time}]</span>
|
|
||||||
<span className="log-message">{renderCombatMessage(entry.message)}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="log-entry"><span className="log-message">PvP Combat started...</span></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* ================================================================ */
|
|
||||||
/* PvE Combat UI */
|
|
||||||
/* ================================================================ */
|
|
||||||
<>
|
|
||||||
<div className="combat-content-wrapper">
|
|
||||||
<div className="combat-enemy-display-inline">
|
|
||||||
{/* 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}
|
{ft.text}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<img
|
|
||||||
src={enemyImage || combatState.combat?.npc_image || combatState.combat_image}
|
|
||||||
alt={enemyName || combatState.combat?.npc_name || 'Enemy'}
|
|
||||||
className={`${flashEnemy ? 'flash-hit' : ''}
|
|
||||||
${combatState.combat_over && combatState.player_won ? 'enemy-dead' : ''}
|
|
||||||
${combatState.combat_over && combatState.player_fled ? 'enemy-fled' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="combat-enemy-info-inline">
|
<div className="stat-header">
|
||||||
<div className="combat-hp-bar-container-inline enemy-hp-bar">
|
<span className="stat-label">{t('common.you')}</span>
|
||||||
<div className="combat-hp-bar-inline">
|
<span className="stat-numbers">{state.playerHp} / {state.playerMaxHp}</span>
|
||||||
<div className="combat-stat-label-inline">
|
|
||||||
{t('combat.enemyHp')}: {combatState.combat?.npc_hp || 0} / {combatState.combat?.npc_max_hp || 100}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="progress-bar">
|
||||||
className="combat-hp-fill-inline"
|
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.playerHp, state.playerMaxHp)}%`, background: 'linear-gradient(90deg, #4caf50 0%, #8bc34a 100%)' }}></div>
|
||||||
style={{
|
|
||||||
width: `${((combatState.combat?.npc_hp || 0) / (combatState.combat?.npc_max_hp || 100)) * 100}%`,
|
|
||||||
transition: 'width 0.5s ease-in-out'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className={`combat-turn-indicator-inline ${enemyTurnMessage ? 'enemy-turn-message' : ''}`}>
|
{/* 3. Actions */}
|
||||||
{!combatState.combat_over ? (
|
<div className="combat-actions">
|
||||||
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
|
<button
|
||||||
className="combat-action-btn attack-btn"
|
className="btn btn-primary full-width glow-effect"
|
||||||
onClick={() => onCombatAction('attack')}
|
onClick={onClose}
|
||||||
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
|
style={{ display: combatResult ? 'block' : 'none', margin: '0 auto' }}
|
||||||
>
|
>
|
||||||
{t('combat.actions.attack')}
|
{t('common.close')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="combat-actions-group" style={{ display: !combatResult ? 'flex' : 'none', gap: '1rem', width: '100%', justifyContent: 'center' }}>
|
||||||
<button
|
<button
|
||||||
className="combat-action-btn flee-btn"
|
className="btn btn-attack"
|
||||||
onClick={() => onCombatAction('flee')}
|
onClick={() => onAction('attack')}
|
||||||
disabled={combatState.combat?.turn !== 'player' || !!enemyTurnMessage || buttonsDisabled}
|
disabled={isProcessing || state.turn !== 'player'}
|
||||||
>
|
>
|
||||||
{t('combat.actions.flee')}
|
👊 {t('combat.actions.attack')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
className="combat-action-btn exit-btn"
|
className="btn btn-flee"
|
||||||
onClick={onExitCombat}
|
onClick={() => onAction('flee')}
|
||||||
|
disabled={isProcessing || state.turn !== 'player'}
|
||||||
>
|
>
|
||||||
{combatState.player_won ? '✅ Continue' : combatState.player_fled ? '✅ Continue' : '💀 Return'}
|
🏃 {t('combat.actions.flee')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Combat Log */}
|
{/* 4. Log (Table) */}
|
||||||
<div className="combat-log-wrapper">
|
<div className="combat-log-container">
|
||||||
<h3 className="combat-log-title">{t('combat.combatLog')}</h3>
|
<table className="combat-log-table">
|
||||||
<div className="combat-log-inline">
|
<tbody>
|
||||||
<div className="log-entries">
|
{[...state.messages].reverse().map((msg, index) => {
|
||||||
<div className="log-list">
|
let text = "";
|
||||||
{combatLog.length > 0 ? (
|
let className = `log-row log-${msg.type}`;
|
||||||
combatLog.map((entry: any) => (
|
|
||||||
<div key={entry.id} className={`log-entry ${entry.isPlayer ? 'player-log' : 'enemy-log'}`}>
|
|
||||||
<span className="log-time">[{entry.time}]</span>
|
|
||||||
<span className="log-message">{renderCombatMessage(entry.message)}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="log-entry"><span className="log-message">Combat started...</span></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CombatView
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -757,3 +757,37 @@
|
|||||||
gap: 0.5rem 1rem;
|
gap: 0.5rem 1rem;
|
||||||
align-items: center;
|
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 { MouseEvent, ChangeEvent } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAudio } from '../../contexts/AudioContext'
|
||||||
import { PlayerState, Profile, Equipment } from './types'
|
import { PlayerState, Profile, Equipment } from './types'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
@@ -35,6 +36,7 @@ function InventoryModal({
|
|||||||
onDropItem
|
onDropItem
|
||||||
}: InventoryModalProps) {
|
}: InventoryModalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { playSfx } = useAudio()
|
||||||
// Categories for the sidebar
|
// Categories for the sidebar
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'all', label: t('categories.all'), icon: '🎒' },
|
{ id: 'all', label: t('categories.all'), icon: '🎒' },
|
||||||
@@ -246,28 +248,49 @@ function InventoryModal({
|
|||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
<div className="item-actions-section">
|
<div className="item-actions-section">
|
||||||
{item.consumable && (
|
{item.consumable && (
|
||||||
<button className="action-btn use" onClick={() => onUseItem(item.item_id, item.id)}>{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 && (
|
{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 && (
|
{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">
|
<div className="drop-actions-group">
|
||||||
{item.quantity > 1 && (
|
<button className={`action-btn drop single`} onClick={() => {
|
||||||
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, item.quantity)}>{t('game.dropAll')}</button>
|
playSfx('/audio/sfx/drop.wav')
|
||||||
)}
|
onDropItem(item.item_id, item.id, 1)
|
||||||
|
}}>
|
||||||
|
{item.quantity === 1 ? t('game.drop') : 'x1' }
|
||||||
|
</button>
|
||||||
{item.quantity >= 5 && (
|
{item.quantity >= 5 && (
|
||||||
<button className="action-btn drop" onClick={() => onDropItem(item.item_id, item.id, 5)}>x5</button>
|
<button className="action-btn drop" onClick={() => {
|
||||||
|
playSfx('/audio/sfx/drop.wav')
|
||||||
|
onDropItem(item.item_id, item.id, 5)
|
||||||
|
}}>x5</button>
|
||||||
)}
|
)}
|
||||||
{item.quantity >= 10 && (
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,11 +402,29 @@ function InventoryModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Backpack */}
|
{/* Backpack - grouped by categories */}
|
||||||
{filteredItems.some((item: any) => !item.is_equipped) && (
|
{filteredItems.some((item: any) => !item.is_equipped) && (
|
||||||
<>
|
<>
|
||||||
<div className="category-header">🎒 {t('game.backpack')}</div>
|
<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 type { Location, PlayerState, CombatState, Profile, WorkbenchTab } from './types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAudio } from '../../contexts/AudioContext'
|
||||||
import Workbench from './Workbench'
|
import Workbench from './Workbench'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
@@ -83,6 +84,7 @@ function LocationView({
|
|||||||
onUncraft
|
onUncraft
|
||||||
}: LocationViewProps) {
|
}: LocationViewProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { playSfx } = useAudio()
|
||||||
return (
|
return (
|
||||||
<div className="location-view">
|
<div className="location-view">
|
||||||
<div className="location-info">
|
<div className="location-info">
|
||||||
@@ -216,7 +218,10 @@ function LocationView({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="entity-action-btn loot-btn"
|
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}
|
disabled={corpse.loot_count === 0}
|
||||||
>
|
>
|
||||||
🔍 {t('common.examine')}
|
🔍 {t('common.examine')}
|
||||||
@@ -360,7 +365,10 @@ function LocationView({
|
|||||||
{item.quantity === 1 ? (
|
{item.quantity === 1 ? (
|
||||||
<button
|
<button
|
||||||
className="entity-action-btn pickup"
|
className="entity-action-btn pickup"
|
||||||
onClick={() => onPickup(item.id, 1)}
|
onClick={() => {
|
||||||
|
playSfx('/audio/sfx/pickup.wav')
|
||||||
|
onPickup(item.id, 1)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('common.pickUp')}
|
{t('common.pickUp')}
|
||||||
</button>
|
</button>
|
||||||
@@ -368,14 +376,26 @@ function LocationView({
|
|||||||
<div className="item-pickup-btn-container">
|
<div className="item-pickup-btn-container">
|
||||||
<button className="entity-action-btn pickup">{t('common.pickUp')} ▼</button>
|
<button className="entity-action-btn pickup">{t('common.pickUp')} ▼</button>
|
||||||
<div className="item-pickup-menu">
|
<div className="item-pickup-menu">
|
||||||
<button className="item-pickup-option" onClick={() => onPickup(item.id, 1)}>{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 && (
|
{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 && (
|
{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})
|
{t('common.pickUpAll')} ({item.quantity})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -410,14 +430,14 @@ function LocationView({
|
|||||||
onClick={() => onInitiatePvP(player.id)}
|
onClick={() => onInitiatePvP(player.id)}
|
||||||
title={`Attack ${player.name || player.username}`}
|
title={`Attack ${player.name || player.username}`}
|
||||||
>
|
>
|
||||||
⚔️ Attack
|
{t('game.attack')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!player.can_pvp && player.level_diff !== undefined && Math.abs(player.level_diff) > 3 && (
|
{!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 && (
|
{!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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -224,24 +224,30 @@ function MovementControls({
|
|||||||
const cooldownRemaining = cooldownExpiry && cooldownExpiry > now
|
const cooldownRemaining = cooldownExpiry && cooldownExpiry > now
|
||||||
? Math.ceil(cooldownExpiry - now)
|
? Math.ceil(cooldownExpiry - now)
|
||||||
: 0
|
: 0
|
||||||
|
const staminaCost = action.stamina_cost || 1
|
||||||
|
const insufficientStamina = profile ? profile.stamina < staminaCost : false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={action.id}
|
key={action.id}
|
||||||
className="interact-btn"
|
className={`interact-btn ${insufficientStamina ? 'disabled' : ''}`}
|
||||||
disabled={!!combatState || cooldownRemaining > 0}
|
disabled={!!combatState || cooldownRemaining > 0 || insufficientStamina || (profile?.is_dead ?? false)}
|
||||||
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
|
onClick={() => onInteract && onInteract(interactable.instance_id, action.id)}
|
||||||
title={
|
title={
|
||||||
combatState
|
profile?.is_dead
|
||||||
? 'Cannot interact during combat'
|
? t('messages.youAreDead')
|
||||||
|
: combatState
|
||||||
|
? t('messages.cannotInteractInCombat')
|
||||||
|
: insufficientStamina
|
||||||
|
? t('messages.notEnoughStamina', { need: staminaCost, have: profile?.stamina ?? 0 })
|
||||||
: cooldownRemaining > 0
|
: cooldownRemaining > 0
|
||||||
? `Wait ${cooldownRemaining}s`
|
? t('messages.interactionCooldown', { seconds: cooldownRemaining })
|
||||||
: getTranslatedText(action.description)
|
: getTranslatedText(action.description)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{getTranslatedText(action.name)}
|
{getTranslatedText(action.name)}
|
||||||
<span className="stamina-cost">
|
<span className="stamina-cost">
|
||||||
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${action.stamina_cost}`}
|
{cooldownRemaining > 0 ? `⏳${cooldownRemaining}s` : `⚡${staminaCost}`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -910,8 +910,8 @@ export function useGameEngine(
|
|||||||
// Map API field names to playerState field names
|
// Map API field names to playerState field names
|
||||||
const mappedData: any = {}
|
const mappedData: any = {}
|
||||||
|
|
||||||
// Skip HP updates if in combat (Combat.tsx handles HP timing)
|
// HP updates are now controlled by Combat.tsx - it calls updatePlayerState at the right time
|
||||||
if (playerData.hp !== undefined && !combatState) {
|
if (playerData.hp !== undefined) {
|
||||||
mappedData.health = playerData.hp
|
mappedData.health = playerData.hp
|
||||||
}
|
}
|
||||||
if (playerData.max_hp !== undefined) {
|
if (playerData.max_hp !== undefined) {
|
||||||
@@ -929,8 +929,8 @@ export function useGameEngine(
|
|||||||
setPlayerState((prev: any) => prev ? { ...prev, ...mappedData } : null)
|
setPlayerState((prev: any) => prev ? { ...prev, ...mappedData } : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also update profile for consistency (skip HP if in combat)
|
// Also update profile for consistency
|
||||||
if (playerData.hp !== undefined && profile && !combatState) {
|
if (playerData.hp !== undefined && profile) {
|
||||||
setProfile((prev: any) => prev ? { ...prev, hp: playerData.hp } : null)
|
setProfile((prev: any) => prev ? { ...prev, hp: playerData.hp } : null)
|
||||||
}
|
}
|
||||||
if (playerData.xp !== undefined && profile) {
|
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'
|
import api, { authApi, characterApi, Account, Character } from '../services/api'
|
||||||
|
|
||||||
|
// ... (interface remains same) ...
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext)
|
||||||
|
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
"fight": "Fight",
|
"fight": "Fight",
|
||||||
"pickUp": "Pick Up",
|
"pickUp": "Pick Up",
|
||||||
"pickUpAll": "Pick Up All",
|
"pickUpAll": "Pick Up All",
|
||||||
"qty": "Qty"
|
"qty": "Qty",
|
||||||
|
"enemy": "Enemy",
|
||||||
|
"you": "You"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@@ -78,7 +80,9 @@
|
|||||||
"weight": "Weight",
|
"weight": "Weight",
|
||||||
"volume": "Volume",
|
"volume": "Volume",
|
||||||
"durability": "Durability",
|
"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": {
|
"location": {
|
||||||
"recentActivity": "📜 Recent Activity",
|
"recentActivity": "📜 Recent Activity",
|
||||||
@@ -141,6 +145,9 @@
|
|||||||
},
|
},
|
||||||
"combat": {
|
"combat": {
|
||||||
"title": "Combat",
|
"title": "Combat",
|
||||||
|
"pvp_title": "Duel",
|
||||||
|
"unknown_enemy": "Unknown Enemy",
|
||||||
|
"start": "Combat started!",
|
||||||
"inCombat": "In Combat",
|
"inCombat": "In Combat",
|
||||||
"yourTurn": "Your Turn",
|
"yourTurn": "Your Turn",
|
||||||
"enemyTurn": "Enemy's Turn",
|
"enemyTurn": "Enemy's Turn",
|
||||||
@@ -184,6 +191,20 @@
|
|||||||
"enemyMiss": "Enemy missed!",
|
"enemyMiss": "Enemy missed!",
|
||||||
"armorAbsorbed": "Armor absorbed {{armor}} damage",
|
"armorAbsorbed": "Armor absorbed {{armor}} damage",
|
||||||
"itemBroke": "{{item}} broke!"
|
"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": {
|
"equipment": {
|
||||||
|
|||||||
@@ -78,7 +78,9 @@
|
|||||||
"weight": "Peso",
|
"weight": "Peso",
|
||||||
"volume": "Volumen",
|
"volume": "Volumen",
|
||||||
"durability": "Durabilidad",
|
"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": {
|
"location": {
|
||||||
"recentActivity": "📜 Actividad Reciente",
|
"recentActivity": "📜 Actividad Reciente",
|
||||||
@@ -141,6 +143,9 @@
|
|||||||
},
|
},
|
||||||
"combat": {
|
"combat": {
|
||||||
"title": "Combate",
|
"title": "Combate",
|
||||||
|
"pvp_title": "Duelo",
|
||||||
|
"unknown_enemy": "Enemigo Desconocido",
|
||||||
|
"start": "¡Combate iniciado!",
|
||||||
"inCombat": "En Combate",
|
"inCombat": "En Combate",
|
||||||
"yourTurn": "Tu Turno",
|
"yourTurn": "Tu Turno",
|
||||||
"enemyTurn": "Turno del Enemigo",
|
"enemyTurn": "Turno del Enemigo",
|
||||||
@@ -184,6 +189,20 @@
|
|||||||
"enemyMiss": "¡El enemigo falló!",
|
"enemyMiss": "¡El enemigo falló!",
|
||||||
"armorAbsorbed": "La armadura absorbió {{armor}} de daño",
|
"armorAbsorbed": "La armadura absorbió {{armor}} de daño",
|
||||||
"itemBroke": "¡{{item}} se rompió!"
|
"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": {
|
"equipment": {
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export default defineConfig({
|
|||||||
cleanupOutdatedCaches: true,
|
cleanupOutdatedCaches: true,
|
||||||
skipWaiting: true,
|
skipWaiting: true,
|
||||||
clientsClaim: 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: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/staging\.echoesoftheash\.com\/api\/.*/i,
|
urlPattern: /^https:\/\/staging\.echoesoftheash\.com\/api\/.*/i,
|
||||||
|
|||||||
Reference in New Issue
Block a user