Backup before cleanup
@@ -20,6 +20,7 @@ COPY data/ ./data/
|
||||
COPY gamedata/ ./gamedata/
|
||||
|
||||
# Copy migration scripts
|
||||
COPY migrations/ ./migrations/
|
||||
COPY migrate_*.py ./
|
||||
|
||||
# Copy startup script
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from api.services.helpers import get_game_message
|
||||
from .services.constants import PVP_TURN_TIMEOUT
|
||||
import os
|
||||
import fcntl
|
||||
@@ -401,7 +402,7 @@ async def check_pvp_combat_timers(manager=None):
|
||||
await db.update_pvp_combat(combat['id'], {
|
||||
'turn': new_turn,
|
||||
'turn_started_at': time.time(),
|
||||
'last_action': f"Turn timeout - {current_turn}'s turn skipped|{time.time()}"
|
||||
'last_action': f"turn_timeout:{current_turn}|{time.time()}"
|
||||
})
|
||||
|
||||
processed += 1
|
||||
@@ -423,10 +424,16 @@ async def check_pvp_combat_timers(manager=None):
|
||||
"turn": new_turn,
|
||||
"time_remaining": time_remaining,
|
||||
"turn_timeout": "skipped",
|
||||
"last_action": f"Turn timeout - {current_turn}'s turn skipped"
|
||||
"last_action": f"turn_timeout:{current_turn}"
|
||||
},
|
||||
"is_pvp": True,
|
||||
"message": f"⏱️ Turn skipped due to timeout!"
|
||||
"messages": [
|
||||
{
|
||||
"type": "combat_timeout",
|
||||
"origin": "system",
|
||||
"timestamp": time.time()
|
||||
}
|
||||
]
|
||||
},
|
||||
"timestamp": time.time()
|
||||
}
|
||||
@@ -519,6 +526,8 @@ async def cleanup_interactable_cooldowns(manager=None, world_locations=None):
|
||||
"data": {
|
||||
"instance_id": cooldown_info['instance_id'],
|
||||
"action_id": cooldown_info['action_id'],
|
||||
"name": cooldown_info['name'],
|
||||
"action_name": cooldown_info['action_name'],
|
||||
"message": f"{cooldown_info['action_name']} is ready on {cooldown_info['name']}"
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
@@ -638,7 +647,7 @@ async def process_status_effects(manager=None):
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(300) # Wait 5 minutes
|
||||
await asyncio.sleep(60) # Wait 1 minute (requested by user)
|
||||
|
||||
start_time = time.time()
|
||||
logger.info("Running status effects processor...")
|
||||
@@ -658,28 +667,39 @@ async def process_status_effects(manager=None):
|
||||
|
||||
for player_id in affected_players:
|
||||
try:
|
||||
# Get current status effects (after decrement)
|
||||
effects = await db.get_player_status_effects(player_id)
|
||||
# Get current status effects (after decrement), INCLUDING expired (0 ticks)
|
||||
effects = await db.get_player_status_effects(player_id, min_ticks=0)
|
||||
|
||||
if not effects:
|
||||
continue
|
||||
|
||||
# Calculate total damage
|
||||
from api.game_logic import calculate_status_damage
|
||||
total_damage = calculate_status_damage(effects)
|
||||
# Prepare detailed effects data for frontend
|
||||
effects_data = [
|
||||
{
|
||||
"name": e['effect_name'],
|
||||
"ticks_remaining": e['ticks_remaining'],
|
||||
"effect_icon": e.get('effect_icon')
|
||||
}
|
||||
for e in effects
|
||||
]
|
||||
|
||||
if total_damage > 0:
|
||||
damage_dealt += total_damage
|
||||
# Calculate total impact (positive = damage, negative = healing)
|
||||
from api.game_logic import calculate_status_impact
|
||||
total_impact = calculate_status_impact(effects)
|
||||
|
||||
if total_impact > 0:
|
||||
# DAMAGE LOGIC
|
||||
damage_dealt += total_impact
|
||||
player = await db.get_player_by_id(player_id)
|
||||
|
||||
if not player or player['is_dead']:
|
||||
continue
|
||||
|
||||
new_hp = max(0, player['hp'] - total_damage)
|
||||
new_hp = max(0, player['hp'] - total_impact)
|
||||
|
||||
# Check if player died from status effects
|
||||
if new_hp <= 0:
|
||||
await db.update_player(player_id, {'hp': 0, 'is_dead': True})
|
||||
await db.update_player(player_id, hp=0, is_dead=True)
|
||||
deaths += 1
|
||||
|
||||
# Only create corpse if player has items
|
||||
@@ -701,6 +721,7 @@ async def process_status_effects(manager=None):
|
||||
# Notify player of death
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
locale = player.get('locale', 'en')
|
||||
await manager.send_personal_message(
|
||||
player_id,
|
||||
{
|
||||
@@ -708,7 +729,7 @@ async def process_status_effects(manager=None):
|
||||
"data": {
|
||||
"hp": 0,
|
||||
"is_dead": True,
|
||||
"message": "You died from status effects"
|
||||
"message": get_game_message('diedFromStatus', locale)
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -717,10 +738,11 @@ async def process_status_effects(manager=None):
|
||||
logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects")
|
||||
else:
|
||||
# Apply damage and notify player
|
||||
await db.update_player(player_id, {'hp': new_hp})
|
||||
await db.update_player(player_id, hp=new_hp)
|
||||
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
locale = player.get('locale', 'en')
|
||||
await manager.send_personal_message(
|
||||
player_id,
|
||||
{
|
||||
@@ -728,8 +750,44 @@ async def process_status_effects(manager=None):
|
||||
"data": {
|
||||
"hp": new_hp,
|
||||
"max_hp": player['max_hp'],
|
||||
"damage": total_damage,
|
||||
"message": f"You took {total_damage} damage from status effects"
|
||||
"damage": total_impact,
|
||||
"message": get_game_message('statusDamage', locale, damage=total_impact),
|
||||
"effects": effects_data
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
elif total_impact < 0:
|
||||
# HEALING LOGIC
|
||||
heal_amount = abs(total_impact)
|
||||
player = await db.get_player_by_id(player_id)
|
||||
|
||||
if not player or player['is_dead']:
|
||||
continue
|
||||
|
||||
# Don't heal if already full
|
||||
if player['hp'] >= player['max_hp']:
|
||||
continue
|
||||
|
||||
new_hp = min(player['max_hp'], player['hp'] + heal_amount)
|
||||
real_heal = new_hp - player['hp']
|
||||
|
||||
if real_heal > 0:
|
||||
await db.update_player(player_id, hp=new_hp)
|
||||
|
||||
if manager:
|
||||
from datetime import datetime
|
||||
locale = player.get('locale', 'en')
|
||||
await manager.send_personal_message(
|
||||
player_id,
|
||||
{
|
||||
"type": "status_effect_heal",
|
||||
"data": {
|
||||
"hp": new_hp,
|
||||
"max_hp": player['max_hp'],
|
||||
"heal": real_heal,
|
||||
"message": get_game_message('statusHeal', locale, heal=real_heal),
|
||||
"effects": effects_data
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -738,10 +796,13 @@ async def process_status_effects(manager=None):
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing status effects for player {player_id}: {e}")
|
||||
|
||||
# CLEANUP: Remove expired effects now that we've notified the user
|
||||
await db.clean_expired_status_effects()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
f"Processed status effects for {len(affected_players)} players "
|
||||
f"({damage_dealt} total damage, {deaths} deaths) in {elapsed:.3f}s"
|
||||
f"({damage_dealt} damage, {deaths} deaths) in {elapsed:.3f}s"
|
||||
)
|
||||
|
||||
# Warn if taking too long (potential scaling issue)
|
||||
|
||||
226
api/database.py
@@ -262,8 +262,12 @@ player_status_effects = Table(
|
||||
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("effect_name", String(50), nullable=False),
|
||||
Column("effect_icon", String(10), nullable=False),
|
||||
Column("effect_type", String(20), default="damage"), # 'damage', 'buff', 'debuff'
|
||||
Column("damage_per_tick", Integer, nullable=False, default=0),
|
||||
Column("value", Integer, default=0), # Generic value (buff %, damage, etc.)
|
||||
Column("ticks_remaining", Integer, nullable=False),
|
||||
Column("persist_after_combat", Boolean, default=False), # Keep after combat ends
|
||||
Column("source", String(50), nullable=True), # 'item:molotov', 'action:defend'
|
||||
Column("applied_at", Float, nullable=False),
|
||||
)
|
||||
|
||||
@@ -2030,18 +2034,99 @@ async def remove_empty_npc_corpses() -> int:
|
||||
# STATUS EFFECTS FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
async def get_player_status_effects(player_id: int):
|
||||
async def add_effect(
|
||||
player_id: int,
|
||||
effect_name: str,
|
||||
effect_icon: str,
|
||||
ticks_remaining: int,
|
||||
effect_type: str = "damage",
|
||||
damage_per_tick: int = 0,
|
||||
value: int = 0,
|
||||
persist_after_combat: bool = False,
|
||||
source: str = None
|
||||
) -> int:
|
||||
"""
|
||||
Add a status effect to a player.
|
||||
If the effect already exists, it refreshes the duration (ticks_remaining).
|
||||
Returns the effect ID.
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
# Check if effect already exists
|
||||
result = await session.execute(
|
||||
select(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.effect_name == effect_name
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_effect = result.first()
|
||||
|
||||
if existing_effect:
|
||||
# Refresh duration
|
||||
await session.execute(
|
||||
update(player_status_effects).where(
|
||||
player_status_effects.c.id == existing_effect.id
|
||||
).values(
|
||||
ticks_remaining=ticks_remaining,
|
||||
applied_at=time.time()
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return existing_effect.id
|
||||
else:
|
||||
# Insert new effect
|
||||
stmt = insert(player_status_effects).values(
|
||||
character_id=player_id,
|
||||
effect_name=effect_name,
|
||||
effect_icon=effect_icon,
|
||||
effect_type=effect_type,
|
||||
damage_per_tick=damage_per_tick,
|
||||
value=value,
|
||||
ticks_remaining=ticks_remaining,
|
||||
persist_after_combat=persist_after_combat,
|
||||
source=source,
|
||||
applied_at=time.time()
|
||||
).returning(player_status_effects.c.id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
await session.commit()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def get_player_effects(player_id: int, min_ticks: int = 1) -> List[Dict[str, Any]]:
|
||||
"""Get all active status effects for a player."""
|
||||
async with DatabaseSession() as session:
|
||||
result = await session.execute(
|
||||
select(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
player_status_effects.c.ticks_remaining >= min_ticks
|
||||
)
|
||||
)
|
||||
)
|
||||
return [row._asdict() for row in result.fetchall()]
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
async def get_player_status_effects(player_id: int, min_ticks: int = 1):
|
||||
"""Alias for get_player_effects for backward compatibility."""
|
||||
return await get_player_effects(player_id, min_ticks)
|
||||
|
||||
|
||||
async def remove_effect(player_id: int, effect_name: str) -> bool:
|
||||
"""Remove a specific effect from a player by name."""
|
||||
async with DatabaseSession() as session:
|
||||
await session.execute(
|
||||
delete(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.effect_name == effect_name
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def remove_all_status_effects(player_id: int):
|
||||
@@ -2052,36 +2137,141 @@ async def remove_all_status_effects(player_id: int):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def decrement_all_status_effect_ticks():
|
||||
"""
|
||||
Decrement ticks for all active status effects and return affected player IDs.
|
||||
Used by background processor.
|
||||
"""
|
||||
async def clean_expired_status_effects():
|
||||
"""Remove all status effects with <= 0 ticks."""
|
||||
async with DatabaseSession() as session:
|
||||
# Get player IDs with effects before updating
|
||||
from sqlalchemy import distinct
|
||||
result = await session.execute(
|
||||
select(distinct(player_status_effects.c.character_id)).where(
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
await session.execute(
|
||||
delete(player_status_effects).where(player_status_effects.c.ticks_remaining <= 0)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def remove_non_persistent_effects(player_id: int):
|
||||
"""Remove effects where persist_after_combat is False. Called when combat ends."""
|
||||
async with DatabaseSession() as session:
|
||||
await session.execute(
|
||||
delete(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.persist_after_combat == False
|
||||
)
|
||||
)
|
||||
)
|
||||
affected_players = [row[0] for row in result.fetchall()]
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def tick_player_effects(player_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Decrement ticks and return effects that were applied this tick.
|
||||
Used during combat when player receives a turn.
|
||||
Returns list of effects with their current state (before tick was applied).
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
# Get effects before decrementing
|
||||
result = await session.execute(
|
||||
select(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
effects = [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
if not effects:
|
||||
return []
|
||||
|
||||
# Decrement ticks
|
||||
await session.execute(
|
||||
update(player_status_effects).where(
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1)
|
||||
)
|
||||
|
||||
# Remove expired effects
|
||||
await session.execute(
|
||||
delete(player_status_effects).where(player_status_effects.c.ticks_remaining <= 0)
|
||||
delete(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == player_id,
|
||||
player_status_effects.c.ticks_remaining <= 0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return affected_players
|
||||
return effects
|
||||
|
||||
|
||||
async def decrement_all_status_effect_ticks():
|
||||
"""
|
||||
Decrement ticks for all active status effects and return affected player IDs.
|
||||
Used by background processor. Only processes players NOT in combat.
|
||||
"""
|
||||
async with DatabaseSession() as session:
|
||||
from sqlalchemy import distinct
|
||||
|
||||
# Get all players with active effects
|
||||
result = await session.execute(
|
||||
select(distinct(player_status_effects.c.character_id)).where(
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
)
|
||||
all_players = [row[0] for row in result.fetchall()]
|
||||
|
||||
# Filter out players in combat - they process effects on turn
|
||||
players_to_process = []
|
||||
for pid in all_players:
|
||||
if not await is_player_in_combat(pid):
|
||||
players_to_process.append(pid)
|
||||
|
||||
if not players_to_process:
|
||||
return []
|
||||
|
||||
# Decrement ticks only for players not in combat
|
||||
for pid in players_to_process:
|
||||
await session.execute(
|
||||
update(player_status_effects).where(
|
||||
and_(
|
||||
player_status_effects.c.character_id == pid,
|
||||
player_status_effects.c.ticks_remaining > 0
|
||||
)
|
||||
).values(ticks_remaining=player_status_effects.c.ticks_remaining - 1)
|
||||
)
|
||||
|
||||
# NOTE: We do NOT remove expired effects here anymore.
|
||||
# They will be processed by the background task (to apply final tick)
|
||||
# and then cleaned up via clean_expired_status_effects()
|
||||
|
||||
await session.commit()
|
||||
return players_to_process
|
||||
|
||||
|
||||
async def is_player_in_combat(player_id: int) -> bool:
|
||||
"""Check if player is in any active combat (PvE or PvP)."""
|
||||
async with DatabaseSession() as session:
|
||||
# Check PvE combat
|
||||
pve = await session.execute(
|
||||
select(active_combats.c.id).where(active_combats.c.character_id == player_id)
|
||||
)
|
||||
if pve.first():
|
||||
return True
|
||||
|
||||
# Check PvP combat
|
||||
pvp = await session.execute(
|
||||
select(pvp_combats.c.id).where(
|
||||
or_(
|
||||
pvp_combats.c.attacker_character_id == player_id,
|
||||
pvp_combats.c.defender_character_id == player_id
|
||||
)
|
||||
)
|
||||
)
|
||||
if pvp.first():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -335,6 +335,55 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
|
||||
effects = {}
|
||||
effects_msg = []
|
||||
|
||||
# 1. Apply Status Effects (e.g. Regeneration from Bandage)
|
||||
if 'status_effect' in item.effects:
|
||||
status_data = item.effects['status_effect']
|
||||
|
||||
# Check if effect already exists
|
||||
current_effects = await db.get_player_effects(player_id)
|
||||
effect_name = status_data['name']
|
||||
|
||||
# Handle potential dict/string difference in validation (db stores as string usually)
|
||||
# But we need to compare with what's in the DB.
|
||||
# DB get_player_effects returns list of dicts with 'effect_name' key.
|
||||
|
||||
is_active = False
|
||||
for effect in current_effects:
|
||||
# Simple string comparison should suffice as both should be localized keys or raw strings
|
||||
if effect['effect_name'] == effect_name:
|
||||
is_active = True
|
||||
break
|
||||
|
||||
if is_active:
|
||||
return {"success": False, "message": get_game_message('effect_already_active', locale)}
|
||||
|
||||
await db.add_effect(
|
||||
player_id=player['id'],
|
||||
effect_name=status_data['name'],
|
||||
effect_icon=status_data.get('icon', '✨'),
|
||||
effect_type=status_data.get('type', 'buff'),
|
||||
damage_per_tick=status_data.get('damage_per_tick', 0),
|
||||
value=status_data.get('value', 0),
|
||||
ticks_remaining=status_data.get('ticks', 3),
|
||||
persist_after_combat=True, # Consumable effects usually persist
|
||||
source=f"item:{item.id}"
|
||||
)
|
||||
effects['status_applied'] = status_data['name']
|
||||
effects_msg.append(f"Applied {get_locale_string(status_data['name'], locale) if isinstance(status_data['name'], dict) else status_data['name']}")
|
||||
|
||||
# 2. Cure Status Effects
|
||||
if 'cures' in item.effects:
|
||||
cures = item.effects['cures']
|
||||
cured_list = []
|
||||
for cure_effect in cures:
|
||||
if await db.remove_effect(player['id'], cure_effect):
|
||||
cured_list.append(cure_effect)
|
||||
|
||||
if cured_list:
|
||||
effects['cured'] = cured_list
|
||||
effects_msg.append(f"{get_game_message('cured', locale)}: {', '.join(cured_list)}")
|
||||
|
||||
# 3. Direct Healing (Legacy/Instant)
|
||||
if 'hp_restore' in item.effects:
|
||||
hp_restore = item.effects['hp_restore']
|
||||
old_hp = player['hp']
|
||||
@@ -496,15 +545,17 @@ async def check_and_apply_level_up(player_id: int) -> Dict[str, Any]:
|
||||
# STATUS EFFECTS UTILITIES
|
||||
# ============================================================================
|
||||
|
||||
def calculate_status_damage(effects: list) -> int:
|
||||
def calculate_status_impact(effects: list) -> int:
|
||||
"""
|
||||
Calculate total damage from all status effects.
|
||||
Calculate total impact from all status effects.
|
||||
Positive value = Damage
|
||||
Negative value = Healing
|
||||
|
||||
Args:
|
||||
effects: List of status effect dicts
|
||||
|
||||
Returns:
|
||||
Total damage per tick
|
||||
Total impact per tick
|
||||
"""
|
||||
return sum(effect.get('damage_per_tick', 0) for effect in effects)
|
||||
|
||||
@@ -513,8 +564,6 @@ def calculate_status_damage(effects: list) -> int:
|
||||
# COMBAT UTILITIES
|
||||
# ============================================================================
|
||||
|
||||
return message, player_defeated
|
||||
|
||||
|
||||
def generate_npc_intent(npc_def, combat_state: dict) -> dict:
|
||||
"""
|
||||
@@ -548,6 +597,96 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
||||
if not player:
|
||||
return [], True
|
||||
|
||||
messages = []
|
||||
|
||||
# 1. PROCESS NPC STATUS EFFECTS
|
||||
npc_hp = combat['npc_hp']
|
||||
npc_max_hp = combat['npc_max_hp']
|
||||
npc_status_str = combat.get('npc_status_effects', '')
|
||||
|
||||
if npc_status_str:
|
||||
# Parse status: "bleeding:5:3" (name:dmg:ticks) or multiple "bleeding:5:3|burning:2:2"
|
||||
# Handling multiple effects separated by |
|
||||
effects_list = npc_status_str.split('|')
|
||||
active_effects = []
|
||||
npc_damage_taken = 0
|
||||
npc_healing_received = 0
|
||||
|
||||
for effect_str in effects_list:
|
||||
if not effect_str: continue
|
||||
try:
|
||||
parts = effect_str.split(':')
|
||||
if len(parts) >= 3:
|
||||
name = parts[0]
|
||||
dmg = int(parts[1])
|
||||
ticks = int(parts[2])
|
||||
|
||||
# Apply effect
|
||||
if ticks > 0:
|
||||
if dmg > 0:
|
||||
npc_damage_taken += dmg
|
||||
messages.append(create_combat_message(
|
||||
"effect_damage",
|
||||
origin="enemy",
|
||||
damage=dmg,
|
||||
effect_name=name,
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
elif dmg < 0:
|
||||
heal = abs(dmg)
|
||||
npc_healing_received += heal
|
||||
messages.append(create_combat_message(
|
||||
"effect_heal", # Check if this message type exists or fallback
|
||||
origin="enemy",
|
||||
heal=heal,
|
||||
effect_name=name,
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
|
||||
# Decrement tick
|
||||
ticks -= 1
|
||||
if ticks > 0:
|
||||
active_effects.append(f"{name}:{dmg}:{ticks}")
|
||||
except Exception as e:
|
||||
print(f"Error parsing NPC status: {e}")
|
||||
|
||||
# Update NPC active effects
|
||||
new_status_str = "|".join(active_effects)
|
||||
if new_status_str != npc_status_str:
|
||||
await db.update_combat(player_id, {'npc_status_effects': new_status_str})
|
||||
|
||||
# Apply Total Damage/Healing
|
||||
if npc_damage_taken > 0:
|
||||
npc_hp = max(0, npc_hp - npc_damage_taken)
|
||||
|
||||
if npc_healing_received > 0:
|
||||
npc_hp = min(npc_max_hp, npc_hp + npc_healing_received)
|
||||
|
||||
# Update NPC HP in DB
|
||||
await db.update_combat(player_id, {'npc_hp': npc_hp})
|
||||
|
||||
# Check if NPC died from effects
|
||||
if npc_hp <= 0:
|
||||
messages.append(create_combat_message(
|
||||
"victory",
|
||||
origin="neutral",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
# Award XP/Loot logic handled in combat route mostly, but we need to signal it.
|
||||
# Returning true for player_defeated is definitely WRONG here if NPC died.
|
||||
# The router usually handles "victory" check after action.
|
||||
# But here this is triggered during NPC turn (which happens after Player turn).
|
||||
# If NPC dies on its OWN turn, we need to handle it.
|
||||
# However, typically NPC dies on Player turn.
|
||||
# If NPC dies from bleeding on its turn, the player wins.
|
||||
# We need to signal this back to router.
|
||||
# But the current return signature is (messages, player_defeated).
|
||||
# We might need to handle the win logic here or update signature.
|
||||
# For now, let's update HP and let the flow continue.
|
||||
# Wait, if NPC is dead, it shouldn't attack!
|
||||
# returning here prevents NPC from attacking if it died from status effects
|
||||
return messages, False
|
||||
|
||||
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
|
||||
current_intent_str = combat.get('npc_intent', 'attack')
|
||||
# Handle legacy/null
|
||||
@@ -556,84 +695,98 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
|
||||
|
||||
intent_type = current_intent_str
|
||||
|
||||
messages = []
|
||||
actual_damage = 0
|
||||
|
||||
# EXECUTE INTENT
|
||||
if intent_type == 'defend':
|
||||
# NPC defends - heals 5% HP
|
||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"enemy_defend",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
heal=heal_amount
|
||||
))
|
||||
|
||||
elif intent_type == 'special':
|
||||
# Strong attack (1.5x damage)
|
||||
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"enemy_special",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
damage=npc_damage,
|
||||
armor_absorbed=armor_absorbed
|
||||
))
|
||||
if npc_hp > 0: # Only attack if alive
|
||||
if intent_type == 'defend':
|
||||
# NPC defends - heals 5% HP
|
||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message(
|
||||
"item_broken",
|
||||
origin="player",
|
||||
item_name=armor['name'],
|
||||
emoji=armor['emoji']
|
||||
))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
else: # Default 'attack'
|
||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
|
||||
# Enrage bonus if NPC is below 30% HP
|
||||
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
|
||||
if is_enraged:
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
messages.append(create_combat_message(
|
||||
"enemy_enraged",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name
|
||||
"enemy_defend",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
heal=heal_amount
|
||||
))
|
||||
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"enemy_attack",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
damage=npc_damage,
|
||||
armor_absorbed=armor_absorbed
|
||||
))
|
||||
elif intent_type == 'special':
|
||||
# Strong attack (1.5x damage)
|
||||
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message(
|
||||
"item_broken",
|
||||
origin="player",
|
||||
item_name=armor['name'],
|
||||
emoji=armor['emoji']
|
||||
))
|
||||
messages.append(create_combat_message(
|
||||
"enemy_special",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
damage=npc_damage,
|
||||
armor_absorbed=armor_absorbed
|
||||
))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message(
|
||||
"item_broken",
|
||||
origin="player",
|
||||
item_name=armor['name'],
|
||||
emoji=armor['emoji']
|
||||
))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
else: # Default 'attack'
|
||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
|
||||
# Enrage bonus if NPC is below 30% HP
|
||||
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
|
||||
if is_enraged:
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
messages.append(create_combat_message(
|
||||
"enemy_enraged",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
|
||||
# Check if player is defending (reduces damage by value%)
|
||||
player_effects = await db.get_player_effects(player_id)
|
||||
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
|
||||
if defending_effect:
|
||||
reduction = defending_effect.get('value', 50) / 100 # Default 50% reduction
|
||||
npc_damage = int(npc_damage * (1 - reduction))
|
||||
messages.append(create_combat_message(
|
||||
"damage_reduced",
|
||||
origin="player",
|
||||
reduction=int(reduction * 100)
|
||||
))
|
||||
# Remove defending effect after use
|
||||
await db.remove_effect(player_id, 'defending')
|
||||
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"enemy_attack",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
damage=npc_damage,
|
||||
armor_absorbed=armor_absorbed
|
||||
))
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message(
|
||||
"item_broken",
|
||||
origin="player",
|
||||
item_name=armor['name'],
|
||||
emoji=armor['emoji']
|
||||
))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
# GENERATE NEXT INTENT
|
||||
|
||||
|
||||
12
api/items.py
@@ -45,6 +45,10 @@ class Item:
|
||||
uncraft_yield: list = None # Materials yielded from uncrafting (before loss chance)
|
||||
uncraft_loss_chance: float = 0.3 # Chance to lose materials when uncrafting (0.3 = 30%)
|
||||
uncraft_tools: list = None # Tools required for uncrafting
|
||||
# Combat system
|
||||
combat_usable: bool = False # Can be used during combat
|
||||
combat_only: bool = False # Can ONLY be used during combat
|
||||
combat_effects: Dict[str, Any] = None # Effects applied in combat (damage, status)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.stats is None:
|
||||
@@ -65,7 +69,8 @@ class Item:
|
||||
self.uncraft_yield = []
|
||||
if self.uncraft_tools is None:
|
||||
self.uncraft_tools = []
|
||||
self.craft_materials = []
|
||||
if self.combat_effects is None:
|
||||
self.combat_effects = {}
|
||||
|
||||
|
||||
class ItemsManager:
|
||||
@@ -129,7 +134,10 @@ class ItemsManager:
|
||||
uncraftable=item_data.get('uncraftable', False),
|
||||
uncraft_yield=item_data.get('uncraft_yield', []),
|
||||
uncraft_loss_chance=item_data.get('uncraft_loss_chance', 0.3),
|
||||
uncraft_tools=item_data.get('uncraft_tools', [])
|
||||
uncraft_tools=item_data.get('uncraft_tools', []),
|
||||
combat_usable=item_data.get('combat_usable', is_consumable), # Default: consumables are combat usable
|
||||
combat_only=item_data.get('combat_only', False),
|
||||
combat_effects=item_data.get('combat_effects', {})
|
||||
)
|
||||
self.items[item_id] = item
|
||||
|
||||
|
||||
@@ -249,7 +249,59 @@ async def combat_action(
|
||||
|
||||
messages = []
|
||||
combat_over = False
|
||||
player_won = False
|
||||
|
||||
# Process status effects (bleeding, etc.) before action
|
||||
active_effects = await db.tick_player_effects(player['id'])
|
||||
|
||||
# Process status effects before action
|
||||
if active_effects:
|
||||
from ..game_logic import calculate_status_impact
|
||||
total_impact = calculate_status_impact(active_effects)
|
||||
|
||||
if total_impact > 0:
|
||||
# DAMAGE
|
||||
damage = total_impact
|
||||
new_hp = max(0, player['hp'] - damage)
|
||||
await db.update_player_hp(player['id'], new_hp)
|
||||
player['hp'] = new_hp # Update local reference
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"effect_damage",
|
||||
origin="player",
|
||||
damage=damage,
|
||||
effect_name="status effects"
|
||||
))
|
||||
|
||||
if new_hp <= 0:
|
||||
# Player died from effects
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
await db.end_combat(player['id'])
|
||||
|
||||
return {
|
||||
"player": player,
|
||||
"combat": None,
|
||||
"messages": messages + [create_combat_message("died", origin="player", message="You died from status effects!")],
|
||||
"active_effects": [],
|
||||
"round": combat['round']
|
||||
}
|
||||
elif total_impact < 0:
|
||||
# HEALING
|
||||
heal = abs(total_impact)
|
||||
new_hp = min(player['max_hp'], player['hp'] + heal)
|
||||
actual_heal = new_hp - player['hp']
|
||||
|
||||
if actual_heal > 0:
|
||||
await db.update_player_hp(player['id'], new_hp)
|
||||
player['hp'] = new_hp
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"effect_heal",
|
||||
origin="player",
|
||||
heal=actual_heal,
|
||||
effect_name="status effects"
|
||||
))
|
||||
|
||||
|
||||
|
||||
if req.action == 'attack':
|
||||
# Calculate player damage
|
||||
@@ -382,6 +434,9 @@ async def combat_action(
|
||||
loot_remaining=json.dumps(corpse_loot_dicts)
|
||||
)
|
||||
|
||||
|
||||
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
await db.end_combat(player['id'])
|
||||
|
||||
# Update Redis: Delete combat state cache
|
||||
@@ -456,6 +511,7 @@ async def combat_action(
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
await db.end_combat(player['id'])
|
||||
|
||||
# Broadcast to location that player fled from combat
|
||||
@@ -557,6 +613,7 @@ async def combat_action(
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
await db.end_combat(player['id'])
|
||||
|
||||
# Broadcast to location that player died (and corpse if created)
|
||||
@@ -584,6 +641,249 @@ async def combat_action(
|
||||
await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True)
|
||||
await db.update_combat(player['id'], {'turn': 'player', 'turn_started_at': time.time()})
|
||||
|
||||
elif req.action == 'defend':
|
||||
# Apply "defending" status effect - reduces incoming damage by 50% for 1 turn
|
||||
await db.add_effect(
|
||||
player_id=player['id'],
|
||||
effect_name='defending',
|
||||
effect_icon='🛡️',
|
||||
effect_type='buff',
|
||||
value=50, # 50% damage reduction
|
||||
ticks_remaining=1,
|
||||
persist_after_combat=False,
|
||||
source='action:defend'
|
||||
)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"defend",
|
||||
origin="player",
|
||||
message=get_game_message('defend_text', locale, name=player['name'])
|
||||
))
|
||||
|
||||
# NPC's turn after defend
|
||||
npc_attack_messages, player_defeated = await game_logic.npc_attack(
|
||||
player['id'],
|
||||
{'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp']},
|
||||
npc_def,
|
||||
reduce_armor_durability
|
||||
)
|
||||
messages.extend(npc_attack_messages)
|
||||
if player_defeated:
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
combat_over = True
|
||||
|
||||
elif req.action == 'use_item':
|
||||
combat_over = False
|
||||
# Validate item_id provided
|
||||
if not req.item_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="item_id required for use_item action"
|
||||
)
|
||||
|
||||
# Get the item from inventory
|
||||
player_inventory = await db.get_inventory(player['id'])
|
||||
inv_item = None
|
||||
for item in player_inventory:
|
||||
if item['item_id'] == req.item_id:
|
||||
inv_item = item
|
||||
break
|
||||
|
||||
if not inv_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Item not found in inventory"
|
||||
)
|
||||
|
||||
# Get item definition
|
||||
item_def = ITEMS_MANAGER.get_item(req.item_id)
|
||||
if not item_def:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unknown item"
|
||||
)
|
||||
|
||||
# Check if item is combat usable
|
||||
if not item_def.combat_usable:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This item cannot be used in combat"
|
||||
)
|
||||
|
||||
# Apply item effects
|
||||
item_name = get_locale_string(item_def.name, locale)
|
||||
effects_applied = []
|
||||
|
||||
# 1. Apply Status Effects (e.g. Regeneration from Bandage)
|
||||
if item_def.effects.get('status_effect'):
|
||||
status_data = item_def.effects['status_effect']
|
||||
await db.add_effect(
|
||||
player_id=player['id'],
|
||||
effect_name=status_data['name'],
|
||||
effect_icon=status_data.get('icon', '✨'),
|
||||
effect_type=status_data.get('type', 'buff'),
|
||||
damage_per_tick=status_data.get('damage_per_tick', 0),
|
||||
value=status_data.get('value', 0),
|
||||
ticks_remaining=status_data.get('ticks', 3),
|
||||
persist_after_combat=True, # Consumable effects usually persist
|
||||
source=f"item:{item_def.id}"
|
||||
)
|
||||
effects_applied.append(f"Applied {status_data['name']}")
|
||||
|
||||
# 2. Cure Status Effects
|
||||
if item_def.effects.get('cures'):
|
||||
cures = item_def.effects['cures']
|
||||
for cure_effect in cures:
|
||||
if await db.remove_effect(player['id'], cure_effect):
|
||||
effects_applied.append(f"Cured {cure_effect}")
|
||||
|
||||
# 3. Handle Direct healing (legacy/instant)
|
||||
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
|
||||
hp_restore = item_def.effects['hp_restore']
|
||||
old_hp = player['hp']
|
||||
new_hp = min(player.get('max_hp', 100), old_hp + hp_restore)
|
||||
actual_restored = new_hp - old_hp
|
||||
if actual_restored > 0:
|
||||
await db.update_player_hp(player['id'], new_hp)
|
||||
effects_applied.append(f"+{actual_restored} HP")
|
||||
|
||||
if item_def.effects.get('stamina_restore'):
|
||||
stamina_restore = item_def.effects['stamina_restore']
|
||||
old_stamina = player['stamina']
|
||||
new_stamina = min(player.get('max_stamina', 100), old_stamina + stamina_restore)
|
||||
actual_restored = new_stamina - old_stamina
|
||||
if actual_restored > 0:
|
||||
await db.update_player_stamina(player['id'], new_stamina)
|
||||
effects_applied.append(f"+{actual_restored} Stamina")
|
||||
|
||||
# Handle combat effects (throwables)
|
||||
combat_effects = item_def.combat_effects or {}
|
||||
|
||||
# Direct damage from throwable
|
||||
if combat_effects.get('damage_min') and combat_effects.get('damage_max'):
|
||||
damage = random.randint(combat_effects['damage_min'], combat_effects['damage_max'])
|
||||
new_npc_hp = max(0, combat['npc_hp'] - damage)
|
||||
effects_applied.append(f"{damage} damage")
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"item_damage",
|
||||
origin="player",
|
||||
damage=damage,
|
||||
item_name=item_name
|
||||
))
|
||||
|
||||
# Check if NPC is defeated
|
||||
if new_npc_hp <= 0:
|
||||
messages.append(create_combat_message(
|
||||
"victory",
|
||||
origin="neutral",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
combat_over = True
|
||||
player_won = True
|
||||
|
||||
# Award XP
|
||||
xp_gained = npc_def.xp_reward
|
||||
new_xp = player['xp'] + xp_gained
|
||||
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_statistics(player['id'], enemies_killed=1, damage_dealt=damage, increment=True)
|
||||
|
||||
# Check for level up
|
||||
level_up_result = await game_logic.check_and_apply_level_up(player['id'])
|
||||
if level_up_result['leveled_up']:
|
||||
messages.append(create_combat_message(
|
||||
"level_up",
|
||||
origin="player",
|
||||
level=level_up_result['new_level'],
|
||||
stat_points=level_up_result['levels_gained']
|
||||
))
|
||||
|
||||
# Create corpse with loot
|
||||
import json as json_module
|
||||
corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else []
|
||||
corpse_loot_dicts = []
|
||||
for loot in corpse_loot:
|
||||
if hasattr(loot, '__dict__'):
|
||||
corpse_loot_dicts.append({
|
||||
'item_id': loot.item_id,
|
||||
'quantity_min': loot.quantity_min,
|
||||
'quantity_max': loot.quantity_max,
|
||||
'required_tool': loot.required_tool
|
||||
})
|
||||
else:
|
||||
corpse_loot_dicts.append(loot)
|
||||
await db.create_npc_corpse(
|
||||
npc_id=combat['npc_id'],
|
||||
location_id=player['location_id'],
|
||||
loot_remaining=json_module.dumps(corpse_loot_dicts)
|
||||
)
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
await db.end_combat(player['id'])
|
||||
else:
|
||||
# Update NPC HP
|
||||
await db.update_combat(player['id'], {'npc_hp': new_npc_hp})
|
||||
|
||||
# Apply status effect from item (e.g., burning from molotov)
|
||||
status_effect = combat_effects.get('status')
|
||||
if status_effect and not combat_over:
|
||||
# Apply to NPC via combat status (simplified - NPC status stored in combat record)
|
||||
npc_status = f"{status_effect['name']}:{status_effect.get('damage_per_tick', 0)}:{status_effect.get('ticks', 1)}"
|
||||
await db.update_combat(player['id'], {'npc_status_effects': npc_status})
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"effect_applied",
|
||||
origin="player",
|
||||
effect_name=status_effect['name'],
|
||||
effect_icon=status_effect.get('icon', '🔥'),
|
||||
target="enemy"
|
||||
))
|
||||
|
||||
# Consume the item
|
||||
await db.remove_item_from_inventory(player['id'], req.item_id, 1)
|
||||
await db.update_player_statistics(player['id'], items_used=1, increment=True)
|
||||
|
||||
# Add item used message
|
||||
effects_str = f" ({', '.join(effects_applied)})" if effects_applied else ""
|
||||
|
||||
# Calculate total restored amounts for frontend floating text
|
||||
hp_restored_val = 0
|
||||
stamina_restored_val = 0
|
||||
|
||||
if item_def.effects.get('hp_restore'):
|
||||
hp_restored_val = min(player.get('max_hp', 100), old_hp + item_def.effects['hp_restore']) - old_hp
|
||||
|
||||
if item_def.effects.get('stamina_restore'):
|
||||
stamina_restored_val = min(player.get('max_stamina', 100), old_stamina + item_def.effects['stamina_restore']) - old_stamina
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"item_used",
|
||||
origin="player",
|
||||
item_name=item_name,
|
||||
effects=effects_str,
|
||||
hp_restore=hp_restored_val if hp_restored_val > 0 else None,
|
||||
stamina_restore=stamina_restored_val if stamina_restored_val > 0 else None
|
||||
))
|
||||
|
||||
# NPC's turn after using item (if combat not over)
|
||||
if not combat_over:
|
||||
npc_attack_messages, player_defeated = await game_logic.npc_attack(
|
||||
player['id'],
|
||||
{'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp']},
|
||||
npc_def,
|
||||
reduce_armor_durability
|
||||
)
|
||||
messages.extend(npc_attack_messages)
|
||||
|
||||
if player_defeated:
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
combat_over = True
|
||||
|
||||
|
||||
# Get updated combat state if not over
|
||||
updated_combat = None
|
||||
if not combat_over:
|
||||
|
||||
@@ -586,6 +586,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
# Check if player has this tool (find one with highest durability)
|
||||
tool_found = False
|
||||
tool_durability = 0
|
||||
tool_max_durability = 0
|
||||
best_tool_unique = None
|
||||
|
||||
for check_item in inventory:
|
||||
@@ -596,6 +597,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
best_tool_unique = unique
|
||||
tool_found = True
|
||||
tool_durability = unique.get('durability', 0)
|
||||
tool_max_durability = unique.get('max_durability', 100)
|
||||
|
||||
|
||||
tools_info.append({
|
||||
@@ -604,7 +606,8 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
'emoji': tool_def.emoji if tool_def else '🔧',
|
||||
'durability_cost': durability_cost,
|
||||
'has_tool': tool_found,
|
||||
'tool_durability': tool_durability
|
||||
'tool_durability': tool_durability,
|
||||
'tool_max_durability': tool_max_durability
|
||||
})
|
||||
if not tool_found:
|
||||
has_tools = False
|
||||
@@ -633,7 +636,7 @@ async def get_repairable_items(current_user: dict = Depends(get_current_user)):
|
||||
})
|
||||
|
||||
# Sort: repairable items first (can_repair=True), then by durability percent (lowest first), then by name
|
||||
repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], x['name']))
|
||||
repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], str(x['name'])))
|
||||
|
||||
return {'repairable_items': repairable_items}
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ async def _get_enriched_inventory(player_id: int):
|
||||
"unique_stats": unique_stats,
|
||||
"hp_restore": item.effects.get('hp_restore') if item.effects else None,
|
||||
"stamina_restore": item.effects.get('stamina_restore') if item.effects else None,
|
||||
"effects": item.effects,
|
||||
"damage_min": item.stats.get('damage_min') if item.stats else None,
|
||||
"damage_max": item.stats.get('damage_max') if item.stats else None,
|
||||
"stats": item.stats,
|
||||
@@ -183,6 +184,10 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# Get player status effects
|
||||
status_effects = await db.get_player_effects(player_id)
|
||||
player['status_effects'] = status_effects
|
||||
|
||||
# Get location
|
||||
location = LOCATIONS.get(player['location_id'])
|
||||
|
||||
@@ -274,6 +279,7 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
"tier": tier if tier is not None else None,
|
||||
"hp_restore": item.effects.get('hp_restore') if item.effects else None,
|
||||
"stamina_restore": item.effects.get('stamina_restore') if item.effects else None,
|
||||
"effects": item.effects,
|
||||
"damage_min": item.stats.get('damage_min') if item.stats else None,
|
||||
"damage_max": item.stats.get('damage_max') if item.stats else None
|
||||
})
|
||||
@@ -325,6 +331,10 @@ async def get_player_profile(current_user: dict = Depends(get_current_user)):
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# Get player status effects
|
||||
status_effects = await db.get_player_effects(player_id)
|
||||
player['status_effects'] = status_effects
|
||||
|
||||
# Get capacity metrics (weight/volume) using the helper function
|
||||
# We don't need the inventory array itself, just the capacity calculations
|
||||
|
||||
@@ -96,7 +96,13 @@ GAME_MESSAGES = {
|
||||
# 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"}
|
||||
'cannot_use': {'en': "This item cannot be used", 'es': "Este objeto no se puede usar"},
|
||||
'cured': {'en': "Cured", 'es': "Curado"},
|
||||
|
||||
# Status Effects
|
||||
'statusDamage': {'en': "You took {damage} damage from status effects", 'es': "Has recibido {damage} de daño por efectos de estado"},
|
||||
'statusHeal': {'en': "You recovered {heal} HP from status effects", 'es': "Has recuperado {heal} vida por efectos de estado"},
|
||||
'diedFromStatus': {'en': "You died from status effects", 'es': "Has muerto por efectos de estado"},
|
||||
}
|
||||
|
||||
def get_game_message(key: str, lang: str = 'en', **kwargs) -> str:
|
||||
|
||||
@@ -79,7 +79,8 @@ class InitiateCombatRequest(BaseModel):
|
||||
|
||||
|
||||
class CombatActionRequest(BaseModel):
|
||||
action: str # 'attack', 'defend', 'flee'
|
||||
action: str # 'attack', 'defend', 'flee', 'use_item'
|
||||
item_id: Optional[str] = None # For use_item action
|
||||
|
||||
|
||||
class PvPCombatInitiateRequest(BaseModel):
|
||||
@@ -91,7 +92,8 @@ class PvPAcknowledgeRequest(BaseModel):
|
||||
|
||||
|
||||
class PvPCombatActionRequest(BaseModel):
|
||||
action: str # 'attack', 'defend', 'flee'
|
||||
action: str # 'attack', 'defend', 'flee', 'use_item'
|
||||
item_id: Optional[str] = None # For use_item action
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
115
build_log.txt
Normal file
@@ -0,0 +1,115 @@
|
||||
--progress is a global compose flag, better use `docker compose --progress xx build ...
|
||||
Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Building
|
||||
Image echoes_of_the_ashes-echoes_of_the_ashes_api Building
|
||||
#1 [internal] load local bake definitions
|
||||
#1 reading from stdin 1.25kB done
|
||||
#1 DONE 0.0s
|
||||
|
||||
#2 [internal] load build definition from Dockerfile.pwa
|
||||
#2 transferring dockerfile: 810B done
|
||||
#2 DONE 0.0s
|
||||
|
||||
#3 [internal] load metadata for docker.io/library/nginx:alpine
|
||||
#3 DONE 0.0s
|
||||
|
||||
#4 [internal] load metadata for docker.io/library/node:20-alpine
|
||||
#4 DONE 0.4s
|
||||
|
||||
#5 [internal] load .dockerignore
|
||||
#5 transferring context: 2B done
|
||||
#5 DONE 0.0s
|
||||
|
||||
#6 [build 1/6] FROM docker.io/library/node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8
|
||||
#6 DONE 0.0s
|
||||
|
||||
#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine
|
||||
#7 DONE 0.0s
|
||||
|
||||
#8 [internal] load build context
|
||||
#8 transferring context: 589.63MB 3.7s done
|
||||
#8 DONE 3.8s
|
||||
|
||||
#9 [build 2/6] WORKDIR /app
|
||||
#9 CACHED
|
||||
|
||||
#10 [build 3/6] COPY pwa/package*.json ./
|
||||
#10 CACHED
|
||||
|
||||
#11 [build 4/6] RUN npm install
|
||||
#11 CACHED
|
||||
|
||||
#12 [build 5/6] COPY pwa/ ./
|
||||
#12 CACHED
|
||||
|
||||
#13 [build 6/6] RUN npm run build
|
||||
#13 0.305
|
||||
#13 0.305 > echoes-of-the-ashes-pwa@1.0.0 build
|
||||
#13 0.305 > tsc && vite build
|
||||
#13 0.305
|
||||
#13 4.381 vite v5.4.21 building for production...
|
||||
#13 4.436 transforming...
|
||||
#13 5.170 ✓ 111 modules transformed.
|
||||
#13 5.474 [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`
|
||||
#13 7.266
|
||||
#13 7.266 PWA v0.17.5
|
||||
#13 7.266 mode generateSW
|
||||
#13 7.266 precache 3 entries (0.00 KiB)
|
||||
#13 7.266 files generated
|
||||
#13 7.266 dist/sw.js
|
||||
#13 7.266 dist/workbox-4b126c97.js
|
||||
#13 7.267 warnings
|
||||
#13 7.267 One of the glob patterns doesn't match any files. Please remove or fix the following: {
|
||||
#13 7.267 "globDirectory": "/app/dist",
|
||||
#13 7.267 "globPattern": "**/*.{js,css,html,ico,svg,woff,woff2}",
|
||||
#13 7.267 "globIgnores": [
|
||||
#13 7.267 "**/node_modules/**/*",
|
||||
#13 7.267 "sw.js",
|
||||
#13 7.267 "workbox-*.js"
|
||||
#13 7.267 ]
|
||||
#13 7.267 }
|
||||
#13 7.267
|
||||
#13 7.273 x Build failed in 2.87s
|
||||
#13 7.273 error during build:
|
||||
#13 7.273 [vite-plugin-pwa:build] [plugin vite-plugin-pwa:build] src/components/common/GameProgressBar.tsx: There was an error during the build:
|
||||
#13 7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx"
|
||||
#13 7.273 Additionally, handling the error in the 'buildEnd' hook caused the following error:
|
||||
#13 7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx"
|
||||
#13 7.273 file: /app/src/components/common/GameProgressBar.tsx
|
||||
#13 7.273 at getRollupError (file:///app/node_modules/rollup/dist/es/shared/parseAst.js:401:41)
|
||||
#13 7.273 at file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23347:39
|
||||
#13 7.273 at async catchUnfinishedHookActions (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:22805:16)
|
||||
#13 7.273 at async rollupInternal (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23330:5)
|
||||
#13 7.273 at async build (file:///app/node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:65709:14)
|
||||
#13 7.273 at async CAC.<anonymous> (file:///app/node_modules/vite/dist/node/cli.js:829:5)
|
||||
#13 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
------
|
||||
> [build 6/6] RUN npm run build:
|
||||
7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx"
|
||||
7.273 Additionally, handling the error in the 'buildEnd' hook caused the following error:
|
||||
7.273 Could not resolve "./InventoryModal.css" from "src/components/common/GameProgressBar.tsx"
|
||||
7.273 file: /app/src/components/common/GameProgressBar.tsx
|
||||
7.273 at getRollupError (file:///app/node_modules/rollup/dist/es/shared/parseAst.js:401:41)
|
||||
7.273 at file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23347:39
|
||||
7.273 at async catchUnfinishedHookActions (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:22805:16)
|
||||
7.273 at async rollupInternal (file:///app/node_modules/rollup/dist/es/shared/node-entry.js:23330:5)
|
||||
7.273 at async build (file:///app/node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:65709:14)
|
||||
7.273 at async CAC.<anonymous> (file:///app/node_modules/vite/dist/node/cli.js:829:5)
|
||||
------
|
||||
Dockerfile.pwa:22
|
||||
|
||||
--------------------
|
||||
|
||||
20 |
|
||||
|
||||
21 | # Build the application
|
||||
|
||||
22 | >>> RUN npm run build
|
||||
|
||||
23 |
|
||||
|
||||
24 | # Production stage
|
||||
|
||||
--------------------
|
||||
|
||||
failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
|
||||
|
||||
90
build_log_2.txt
Normal file
@@ -0,0 +1,90 @@
|
||||
--progress is a global compose flag, better use `docker compose --progress xx build ...
|
||||
Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Building
|
||||
Image echoes_of_the_ashes-echoes_of_the_ashes_api Building
|
||||
#1 [internal] load local bake definitions
|
||||
#1 reading from stdin 1.25kB done
|
||||
#1 DONE 0.0s
|
||||
|
||||
#2 [internal] load build definition from Dockerfile.pwa
|
||||
#2 transferring dockerfile: 810B done
|
||||
#2 DONE 0.0s
|
||||
|
||||
#3 [internal] load metadata for docker.io/library/nginx:alpine
|
||||
#3 DONE 0.0s
|
||||
|
||||
#4 [internal] load metadata for docker.io/library/node:20-alpine
|
||||
#4 DONE 0.7s
|
||||
|
||||
#5 [internal] load .dockerignore
|
||||
#5 transferring context: 2B done
|
||||
#5 DONE 0.0s
|
||||
|
||||
#6 [build 1/6] FROM docker.io/library/node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8
|
||||
#6 DONE 0.0s
|
||||
|
||||
#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine
|
||||
#7 DONE 0.0s
|
||||
|
||||
#8 [internal] load build context
|
||||
#8 transferring context: 2.35MB 0.6s done
|
||||
#8 DONE 0.6s
|
||||
|
||||
#9 [build 2/6] WORKDIR /app
|
||||
#9 CACHED
|
||||
|
||||
#10 [build 3/6] COPY pwa/package*.json ./
|
||||
#10 CACHED
|
||||
|
||||
#11 [build 4/6] RUN npm install
|
||||
#11 CACHED
|
||||
|
||||
#12 [build 5/6] COPY pwa/ ./
|
||||
#12 DONE 2.9s
|
||||
|
||||
#13 [build 6/6] RUN npm run build
|
||||
#13 0.256
|
||||
#13 0.256 > echoes-of-the-ashes-pwa@1.0.0 build
|
||||
#13 0.256 > tsc && vite build
|
||||
#13 0.256
|
||||
#13 4.328 vite v5.4.21 building for production...
|
||||
#13 4.379 transforming...
|
||||
#13 5.865 ✓ 160 modules transformed.
|
||||
#13 5.995 rendering chunks...
|
||||
#13 6.118 computing gzip size...
|
||||
#13 6.129 dist/manifest.webmanifest 0.46 kB
|
||||
#13 6.129 dist/index.html 1.00 kB │ gzip: 0.50 kB
|
||||
#13 6.129 dist/assets/index-DvVzkIfD.css 111.05 kB │ gzip: 19.61 kB
|
||||
#13 6.129 dist/assets/workbox-window.prod.es5-vqzQaGvo.js 5.72 kB │ gzip: 2.35 kB
|
||||
#13 6.129 dist/assets/index-RV0Szog0.js 454.56 kB │ gzip: 135.53 kB
|
||||
#13 6.130 ✓ built in 1.78s
|
||||
#13 6.404 [baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`
|
||||
#13 8.218
|
||||
#13 8.218 PWA v0.17.5
|
||||
#13 8.218 mode generateSW
|
||||
#13 8.218 precache 7 entries (559.91 KiB)
|
||||
#13 8.218 files generated
|
||||
#13 8.218 dist/sw.js
|
||||
#13 8.218 dist/workbox-4b126c97.js
|
||||
#13 DONE 8.3s
|
||||
|
||||
#7 [stage-1 1/4] FROM docker.io/library/nginx:alpine
|
||||
#7 CACHED
|
||||
|
||||
#14 [stage-1 2/4] COPY --from=build /app/dist /usr/share/nginx/html
|
||||
#14 DONE 0.0s
|
||||
|
||||
#15 [stage-1 3/4] COPY images/ /usr/share/nginx/html/images/
|
||||
#15 DONE 0.0s
|
||||
|
||||
#16 [stage-1 4/4] COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
#16 DONE 0.0s
|
||||
|
||||
#17 exporting to image
|
||||
#17 exporting layers 0.1s done
|
||||
#17 writing image sha256:04e99cea3a418401aff49901e27724e5132a721881a9c01bda68dd302a496587 done
|
||||
#17 naming to docker.io/library/echoes_of_the_ashes-echoes_of_the_ashes_pwa done
|
||||
#17 DONE 0.1s
|
||||
|
||||
#18 resolving provenance for metadata file
|
||||
#18 DONE 0.0s
|
||||
Image echoes_of_the_ashes-echoes_of_the_ashes_pwa Built
|
||||
@@ -33,7 +33,7 @@
|
||||
"wood_planks": {
|
||||
"name": {
|
||||
"en": "Wood Planks",
|
||||
"es": "Tablillas de madera"
|
||||
"es": "Tablas de madera"
|
||||
},
|
||||
"weight": 3.0,
|
||||
"volume": 2.0,
|
||||
@@ -314,14 +314,25 @@
|
||||
"es": "Vendaje"
|
||||
},
|
||||
"description": {
|
||||
"en": "Clean cloth bandages for treating minor wounds. Can stop bleeding.",
|
||||
"es": "Vendajes limpios de tela para tratar heridas menores. Pueden detener la sangrado."
|
||||
"en": "Clean cloth bandages for treating minor wounds. Applies regeneration and stops bleeding.",
|
||||
"es": "Vendajes limpios de tela para tratar heridas menores. Aplica regeneración y detiene el sangrado."
|
||||
},
|
||||
"weight": 0.1,
|
||||
"volume": 0.1,
|
||||
"type": "consumable",
|
||||
"hp_restore": 15,
|
||||
"treats": "Bleeding",
|
||||
"effects": {
|
||||
"status_effect": {
|
||||
"name": "regeneration",
|
||||
"icon": "❤️",
|
||||
"type": "buff",
|
||||
"damage_per_tick": -5,
|
||||
"ticks": 3,
|
||||
"value": 15
|
||||
},
|
||||
"cures": [
|
||||
"bleeding"
|
||||
]
|
||||
},
|
||||
"emoji": "🩹",
|
||||
"image_path": "images/items/bandage.webp"
|
||||
},
|
||||
@@ -458,11 +469,11 @@
|
||||
"knife": {
|
||||
"name": {
|
||||
"en": "Knife",
|
||||
"es": ""
|
||||
"es": "Cuchillo"
|
||||
},
|
||||
"description": {
|
||||
"en": "A sharp survival knife in decent condition.",
|
||||
"es": ""
|
||||
"es": "Un cuchillo de supervivencia afilado en buen estado."
|
||||
},
|
||||
"weight": 0.3,
|
||||
"volume": 0.2,
|
||||
@@ -547,11 +558,11 @@
|
||||
"rusty_pipe": {
|
||||
"name": {
|
||||
"en": "Rusty Pipe",
|
||||
"es": ""
|
||||
"es": "Tubería oxidada"
|
||||
},
|
||||
"description": {
|
||||
"en": "Heavy metal pipe. Crude but effective.",
|
||||
"es": ""
|
||||
"es": "Tubería de metal oxidada. Bruta pero efectiva."
|
||||
},
|
||||
"weight": 1.5,
|
||||
"volume": 0.8,
|
||||
@@ -567,11 +578,11 @@
|
||||
"tattered_rucksack": {
|
||||
"name": {
|
||||
"en": "Tattered Rucksack",
|
||||
"es": ""
|
||||
"es": "Mochila rústica"
|
||||
},
|
||||
"description": {
|
||||
"en": "An old backpack with torn straps. Still functional.",
|
||||
"es": ""
|
||||
"es": "Una mochila vieja con tirantes rotos. Todavía funcional."
|
||||
},
|
||||
"weight": 1.0,
|
||||
"volume": 0.5,
|
||||
@@ -614,11 +625,11 @@
|
||||
"hiking_backpack": {
|
||||
"name": {
|
||||
"en": "Hiking Backpack",
|
||||
"es": ""
|
||||
"es": "Mochila de senderismo"
|
||||
},
|
||||
"description": {
|
||||
"en": "A quality backpack with multiple compartments.",
|
||||
"es": ""
|
||||
"es": "Una mochila de calidad con múltiples compartimentos."
|
||||
},
|
||||
"weight": 1.5,
|
||||
"volume": 0.7,
|
||||
@@ -650,11 +661,11 @@
|
||||
"flashlight": {
|
||||
"name": {
|
||||
"en": "Flashlight",
|
||||
"es": ""
|
||||
"es": "Linterna"
|
||||
},
|
||||
"description": {
|
||||
"en": "A battery-powered flashlight. Batteries low but working.",
|
||||
"es": ""
|
||||
"es": "Una linterna alimentada por pilas. Las pilas están casi agotadas pero funcionan."
|
||||
},
|
||||
"weight": 0.3,
|
||||
"volume": 0.2,
|
||||
@@ -670,7 +681,7 @@
|
||||
"old_photograph": {
|
||||
"name": {
|
||||
"en": "Old Photograph",
|
||||
"es": ""
|
||||
"es": "Fotografía vieja"
|
||||
},
|
||||
"weight": 0.01,
|
||||
"volume": 0.01,
|
||||
@@ -679,13 +690,13 @@
|
||||
"image_path": "images/items/old_photograph.webp",
|
||||
"description": {
|
||||
"en": "A useful old photograph.",
|
||||
"es": ""
|
||||
"es": "Una fotografía vieja útil."
|
||||
}
|
||||
},
|
||||
"key_ring": {
|
||||
"name": {
|
||||
"en": "Key Ring",
|
||||
"es": ""
|
||||
"es": "Anillo de llaves"
|
||||
},
|
||||
"weight": 0.1,
|
||||
"volume": 0.05,
|
||||
@@ -694,17 +705,17 @@
|
||||
"image_path": "images/items/key_ring.webp",
|
||||
"description": {
|
||||
"en": "A useful key ring.",
|
||||
"es": ""
|
||||
"es": "Un anillo de llaves útil."
|
||||
}
|
||||
},
|
||||
"makeshift_spear": {
|
||||
"name": {
|
||||
"en": "Makeshift Spear",
|
||||
"es": ""
|
||||
"es": "Pica improvisado"
|
||||
},
|
||||
"description": {
|
||||
"en": "A crude spear made from a sharpened stick and scrap metal.",
|
||||
"es": ""
|
||||
"es": "Una pica improvisada hecha de un palo afilado y metal desechado."
|
||||
},
|
||||
"weight": 1.2,
|
||||
"volume": 2.0,
|
||||
@@ -751,11 +762,11 @@
|
||||
"reinforced_bat": {
|
||||
"name": {
|
||||
"en": "Reinforced Bat",
|
||||
"es": ""
|
||||
"es": "Bate de béisbol reforzado"
|
||||
},
|
||||
"description": {
|
||||
"en": "A wooden bat wrapped with scrap metal and nails. Brutal.",
|
||||
"es": ""
|
||||
"es": "Un bate de béisbol envuelto con metal desechado y clavos. Brutal."
|
||||
},
|
||||
"weight": 1.8,
|
||||
"volume": 1.5,
|
||||
@@ -808,11 +819,11 @@
|
||||
"leather_vest": {
|
||||
"name": {
|
||||
"en": "Leather Vest",
|
||||
"es": ""
|
||||
"es": "Chaleco de cuero"
|
||||
},
|
||||
"description": {
|
||||
"en": "A makeshift vest crafted from leather scraps. Provides basic protection.",
|
||||
"es": ""
|
||||
"es": "Un chaleco improvisado hecho de cuero desechado. Proporciona protección básica."
|
||||
},
|
||||
"weight": 1.5,
|
||||
"volume": 1.0,
|
||||
@@ -859,11 +870,11 @@
|
||||
"cloth_bandana": {
|
||||
"name": {
|
||||
"en": "Cloth Bandana",
|
||||
"es": ""
|
||||
"es": "Banda de tela"
|
||||
},
|
||||
"description": {
|
||||
"en": "A simple cloth head covering. Keeps the sun and dust out.",
|
||||
"es": ""
|
||||
"es": "Una cobertura simple para la cabeza. Mantiene el sol y la arena fuera."
|
||||
},
|
||||
"weight": 0.1,
|
||||
"volume": 0.1,
|
||||
@@ -897,11 +908,11 @@
|
||||
"sturdy_boots": {
|
||||
"name": {
|
||||
"en": "Sturdy Boots",
|
||||
"es": ""
|
||||
"es": "Botas fuertes"
|
||||
},
|
||||
"description": {
|
||||
"en": "Reinforced boots for traversing the wasteland.",
|
||||
"es": ""
|
||||
"es": "Botas reforzadas para cruzar el desierto."
|
||||
},
|
||||
"weight": 1.0,
|
||||
"volume": 0.8,
|
||||
@@ -948,11 +959,11 @@
|
||||
"padded_pants": {
|
||||
"name": {
|
||||
"en": "Padded Pants",
|
||||
"es": ""
|
||||
"es": "Pantalones reforzados"
|
||||
},
|
||||
"description": {
|
||||
"en": "Pants reinforced with extra padding for protection.",
|
||||
"es": ""
|
||||
"es": "Pantalones reforzados con un relleno extra para protección."
|
||||
},
|
||||
"weight": 0.8,
|
||||
"volume": 0.6,
|
||||
@@ -995,11 +1006,11 @@
|
||||
"reinforced_pack": {
|
||||
"name": {
|
||||
"en": "Reinforced Pack",
|
||||
"es": ""
|
||||
"es": "Mochila reforzada"
|
||||
},
|
||||
"description": {
|
||||
"en": "A custom-built backpack with metal frame and extra pockets.",
|
||||
"es": ""
|
||||
"es": "Una mochila personalizada con un marco de metal y bolsillos extra."
|
||||
},
|
||||
"weight": 2.0,
|
||||
"volume": 0.9,
|
||||
@@ -1085,11 +1096,11 @@
|
||||
"hammer": {
|
||||
"name": {
|
||||
"en": "Hammer",
|
||||
"es": ""
|
||||
"es": "Martillo"
|
||||
},
|
||||
"description": {
|
||||
"en": "A basic tool for crafting and repairs. Essential for any survivor.",
|
||||
"es": ""
|
||||
"es": "Una herramienta básica para la fabricación y reparaciones. Esencial para cualquier superviviente."
|
||||
},
|
||||
"weight": 0.8,
|
||||
"volume": 0.4,
|
||||
@@ -1124,11 +1135,11 @@
|
||||
"screwdriver": {
|
||||
"name": {
|
||||
"en": "Screwdriver",
|
||||
"es": ""
|
||||
"es": "Destornillador"
|
||||
},
|
||||
"description": {
|
||||
"en": "A flathead screwdriver. Useful for repairs and scavenging.",
|
||||
"es": ""
|
||||
"es": "Un destornillador de cabeza plana. Útil para reparaciones y recogida de material."
|
||||
},
|
||||
"weight": 0.2,
|
||||
"volume": 0.2,
|
||||
@@ -1163,6 +1174,130 @@
|
||||
"damage_min": 5,
|
||||
"damage_max": 8
|
||||
}
|
||||
},
|
||||
"pipe_bomb": {
|
||||
"name": {
|
||||
"en": "Pipe Bomb",
|
||||
"es": "Bomba improvisada"
|
||||
},
|
||||
"type": "throwable",
|
||||
"weight": 0.5,
|
||||
"volume": 0.3,
|
||||
"emoji": "💣",
|
||||
"image_path": "images/items/pipe_bomb.webp",
|
||||
"description": {
|
||||
"en": "An improvised explosive. Deals heavy damage when thrown.",
|
||||
"es": "Un explosivo improvisado. Causa gran daño cuando se lanza."
|
||||
},
|
||||
"stackable": true,
|
||||
"combat_usable": true,
|
||||
"combat_effects": {
|
||||
"damage_min": 15,
|
||||
"damage_max": 25
|
||||
}
|
||||
},
|
||||
"molotov_cocktail": {
|
||||
"name": {
|
||||
"en": "Molotov Cocktail",
|
||||
"es": "Cóctel Molotov"
|
||||
},
|
||||
"type": "throwable",
|
||||
"weight": 0.4,
|
||||
"volume": 0.3,
|
||||
"emoji": "🔥",
|
||||
"image_path": "images/items/molotov.webp",
|
||||
"description": {
|
||||
"en": "A bottle filled with flammable liquid. Sets the target on fire.",
|
||||
"es": "Una botella llena de líquido inflamable. Prende fuego al objetivo."
|
||||
},
|
||||
"stackable": true,
|
||||
"combat_usable": true,
|
||||
"combat_effects": {
|
||||
"damage_min": 10,
|
||||
"damage_max": 15,
|
||||
"status": {
|
||||
"name": "burning",
|
||||
"icon": "🔥",
|
||||
"damage_per_tick": 3,
|
||||
"ticks": 3,
|
||||
"persist_after_combat": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"smoke_bomb": {
|
||||
"name": {
|
||||
"en": "Smoke Bomb",
|
||||
"es": "Bomba de humo"
|
||||
},
|
||||
"type": "throwable",
|
||||
"weight": 0.3,
|
||||
"volume": 0.2,
|
||||
"emoji": "💨",
|
||||
"image_path": "images/items/smoke_bomb.webp",
|
||||
"description": {
|
||||
"en": "Creates a smoke screen. Greatly increases flee chance for 1 turn.",
|
||||
"es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 turno."
|
||||
},
|
||||
"stackable": true,
|
||||
"combat_usable": true,
|
||||
"combat_only": true,
|
||||
"combat_effects": {
|
||||
"status": {
|
||||
"name": "smoke_cover",
|
||||
"icon": "💨",
|
||||
"value": 50,
|
||||
"ticks": 1,
|
||||
"persist_after_combat": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"stim_pack": {
|
||||
"name": {
|
||||
"en": "Stim Pack",
|
||||
"es": "Estimulante"
|
||||
},
|
||||
"type": "consumable",
|
||||
"weight": 0.2,
|
||||
"volume": 0.1,
|
||||
"emoji": "💉",
|
||||
"image_path": "images/items/stim_pack.webp",
|
||||
"description": {
|
||||
"en": "A combat stimulant that instantly restores health. Only usable in combat.",
|
||||
"es": "Un estimulante de combate que restaura salud instantáneamente. Solo usable en combate."
|
||||
},
|
||||
"stackable": true,
|
||||
"consumable": true,
|
||||
"combat_usable": true,
|
||||
"combat_only": true,
|
||||
"hp_restore": 20
|
||||
},
|
||||
"adrenaline_shot": {
|
||||
"name": {
|
||||
"en": "Adrenaline Shot",
|
||||
"es": "Inyección de adrenalina"
|
||||
},
|
||||
"type": "consumable",
|
||||
"weight": 0.1,
|
||||
"volume": 0.1,
|
||||
"emoji": "⚡",
|
||||
"image_path": "images/items/adrenaline.webp",
|
||||
"description": {
|
||||
"en": "Increases damage output for 2 turns. Only usable in combat.",
|
||||
"es": "Aumenta el daño durante 2 turnos. Solo usable en combate."
|
||||
},
|
||||
"stackable": true,
|
||||
"consumable": true,
|
||||
"combat_usable": true,
|
||||
"combat_only": true,
|
||||
"combat_effects": {
|
||||
"status": {
|
||||
"name": "empowered",
|
||||
"icon": "⚡",
|
||||
"value": 25,
|
||||
"ticks": 2,
|
||||
"persist_after_combat": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,10 @@
|
||||
"flee_chance": 0.3,
|
||||
"status_inflict_chance": 0.15,
|
||||
"image_path": "images/npcs/feral_dog.webp",
|
||||
"death_message": "The feral dog whimpers and collapses. Perhaps it was just hungry..."
|
||||
"death_message": {
|
||||
"en": "The feral dog whimpers and collapses. Perhaps it was just hungry...",
|
||||
"es": "El perro salvaje gemía y se derrumbó. Quizás solo estaba hambriento..."
|
||||
}
|
||||
},
|
||||
"raider_scout": {
|
||||
"npc_id": "raider_scout",
|
||||
@@ -110,7 +113,10 @@
|
||||
"flee_chance": 0.2,
|
||||
"status_inflict_chance": 0.1,
|
||||
"image_path": "images/npcs/raider_scout.webp",
|
||||
"death_message": "The raider scout falls with a final gasp. Their supplies are yours."
|
||||
"death_message": {
|
||||
"en": "The raider scout falls with a final gasp. Their supplies are yours.",
|
||||
"es": "El explorador cae con un último gemido. Sus suministros son tuyos."
|
||||
}
|
||||
},
|
||||
"mutant_rat": {
|
||||
"npc_id": "mutant_rat",
|
||||
@@ -154,7 +160,10 @@
|
||||
"flee_chance": 0.5,
|
||||
"status_inflict_chance": 0.25,
|
||||
"image_path": "images/npcs/mutant_rat.webp",
|
||||
"death_message": "The mutant rat squeals its last and goes still."
|
||||
"death_message": {
|
||||
"en": "The mutant rat squeals its last and goes still.",
|
||||
"es": "La rata mutante gemía por última vez y se detuvo."
|
||||
}
|
||||
},
|
||||
"infected_human": {
|
||||
"npc_id": "infected_human",
|
||||
@@ -204,17 +213,20 @@
|
||||
"flee_chance": 0.1,
|
||||
"status_inflict_chance": 0.3,
|
||||
"image_path": "images/npcs/infected_human.webp",
|
||||
"death_message": "The infected human finally finds peace in death."
|
||||
"death_message": {
|
||||
"en": "The infected human finally finds peace in death.",
|
||||
"es": "El humano infectado finalmente encuentra paz en la muerte."
|
||||
}
|
||||
},
|
||||
"scavenger": {
|
||||
"npc_id": "scavenger",
|
||||
"name": {
|
||||
"en": "Hostile Scavenger",
|
||||
"es": ""
|
||||
"es": "Superviviente hostil"
|
||||
},
|
||||
"description": {
|
||||
"en": "Another survivor, but this one sees you as competition. They won't share territory.",
|
||||
"es": ""
|
||||
"es": "Otro superviviente, eres su competencia. No compartirá el territorio."
|
||||
},
|
||||
"emoji": "💀",
|
||||
"hp_min": 25,
|
||||
@@ -278,7 +290,10 @@
|
||||
"flee_chance": 0.25,
|
||||
"status_inflict_chance": 0.05,
|
||||
"image_path": "images/npcs/scavenger.webp",
|
||||
"death_message": "The scavenger's struggle ends. Survival has no mercy."
|
||||
"death_message": {
|
||||
"en": "The scavenger's struggle ends. Survival has no mercy.",
|
||||
"es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia."
|
||||
}
|
||||
}
|
||||
},
|
||||
"danger_levels": {
|
||||
|
||||
BIN
images-source/items/stimpack.png
Normal file
|
After Width: | Height: | Size: 578 KiB |
@@ -16,7 +16,7 @@ echo " Source: $SOURCE_DIR"
|
||||
echo " Output: $OUTPUT_DIR"
|
||||
echo ""
|
||||
|
||||
for category in items locations npcs interactables characters; do
|
||||
for category in items locations npcs interactables characters placeholder; do
|
||||
src="$SOURCE_DIR/$category"
|
||||
out="$OUTPUT_DIR/$category"
|
||||
|
||||
@@ -38,7 +38,7 @@ for category in items locations npcs interactables characters; do
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$category" == "items" ]]; then
|
||||
if [[ "$category" == "items" || "$category" == "placeholder" ]]; then
|
||||
# Special processing for items: remove white background and resize
|
||||
echo " ➜ Converting item: $filename"
|
||||
tmp="/tmp/${base}_clean.png"
|
||||
|
||||
BIN
images-source/placeholder/backpack_placeholder.png
Normal file
|
After Width: | Height: | Size: 665 KiB |
BIN
images-source/placeholder/feet_placeholder.png
Normal file
|
After Width: | Height: | Size: 631 KiB |
BIN
images-source/placeholder/head_placeholder.png
Normal file
|
After Width: | Height: | Size: 522 KiB |
BIN
images-source/placeholder/legs_placeholder.png
Normal file
|
After Width: | Height: | Size: 525 KiB |
BIN
images-source/placeholder/torso_placeholder.png
Normal file
|
After Width: | Height: | Size: 710 KiB |
BIN
images-source/placeholder/weapon_placeholder.png
Normal file
|
After Width: | Height: | Size: 556 KiB |
BIN
images/items/stimpack.webp
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
images/placeholder/backpack_placeholder.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
images/placeholder/feet_placeholder.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
images/placeholder/head_placeholder.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
images/placeholder/legs_placeholder.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
images/placeholder/torso_placeholder.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
images/placeholder/weapon_placeholder.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
@@ -1,18 +1,24 @@
|
||||
-- Add persistent status effects table
|
||||
CREATE TABLE IF NOT EXISTS player_status_effects (
|
||||
DROP TABLE IF EXISTS player_status_effects CASCADE;
|
||||
|
||||
CREATE TABLE player_status_effects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES players(telegram_id) ON DELETE CASCADE,
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
effect_name VARCHAR(50) NOT NULL,
|
||||
effect_icon VARCHAR(10) NOT NULL,
|
||||
damage_per_tick INTEGER NOT NULL DEFAULT 0,
|
||||
effect_type VARCHAR(20) DEFAULT 'damage',
|
||||
value INTEGER DEFAULT 0,
|
||||
ticks_remaining INTEGER NOT NULL,
|
||||
persist_after_combat BOOLEAN DEFAULT FALSE,
|
||||
source VARCHAR(50),
|
||||
applied_at FLOAT NOT NULL,
|
||||
CONSTRAINT valid_ticks CHECK (ticks_remaining >= 0),
|
||||
CONSTRAINT valid_damage CHECK (damage_per_tick >= 0)
|
||||
);
|
||||
|
||||
-- Create index for efficient querying by player
|
||||
CREATE INDEX IF NOT EXISTS idx_status_effects_player ON player_status_effects(player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_status_effects_player ON player_status_effects(character_id);
|
||||
|
||||
-- Create index for background processor to find active effects
|
||||
CREATE INDEX IF NOT EXISTS idx_status_effects_active ON player_status_effects(player_id, ticks_remaining) WHERE ticks_remaining > 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_status_effects_active ON player_status_effects(character_id, ticks_remaining) WHERE ticks_remaining > 0;
|
||||
|
||||
@@ -7,6 +7,9 @@ import asyncio
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import text
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Database connection
|
||||
DB_USER = os.getenv("POSTGRES_USER")
|
||||
|
||||
89
migrations/migrate_combat_effects.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration: Add combat effect fields to player_status_effects table.
|
||||
Adds effect_type, value, persist_after_combat, and source columns.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
DB_USER = os.getenv("POSTGRES_USER")
|
||||
DB_PASS = os.getenv("POSTGRES_PASSWORD")
|
||||
DB_NAME = os.getenv("POSTGRES_DB")
|
||||
DB_HOST = os.getenv("POSTGRES_HOST", "echoes_of_the_ashes_db")
|
||||
DB_PORT = os.getenv("POSTGRES_PORT", "5432")
|
||||
|
||||
DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
|
||||
|
||||
async def run_migration():
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
print("Adding new columns to player_status_effects table...")
|
||||
|
||||
# Add effect_type column (damage, buff, debuff)
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE player_status_effects
|
||||
ADD COLUMN IF NOT EXISTS effect_type VARCHAR(20) DEFAULT 'damage'
|
||||
"""))
|
||||
print("✓ Added effect_type column")
|
||||
except Exception as e:
|
||||
print(f"Note: effect_type column may already exist: {e}")
|
||||
|
||||
# Add value column (generic value for effect - damage amount, buff %, etc.)
|
||||
# This replaces/supplements damage_per_tick for more flexibility
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE player_status_effects
|
||||
ADD COLUMN IF NOT EXISTS value INTEGER DEFAULT 0
|
||||
"""))
|
||||
print("✓ Added value column")
|
||||
except Exception as e:
|
||||
print(f"Note: value column may already exist: {e}")
|
||||
|
||||
# Add persist_after_combat column
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE player_status_effects
|
||||
ADD COLUMN IF NOT EXISTS persist_after_combat BOOLEAN DEFAULT FALSE
|
||||
"""))
|
||||
print("✓ Added persist_after_combat column")
|
||||
except Exception as e:
|
||||
print(f"Note: persist_after_combat column may already exist: {e}")
|
||||
|
||||
# Add source column to track where effect came from
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE player_status_effects
|
||||
ADD COLUMN IF NOT EXISTS source VARCHAR(50) DEFAULT NULL
|
||||
"""))
|
||||
print("✓ Added source column")
|
||||
except Exception as e:
|
||||
print(f"Note: source column may already exist: {e}")
|
||||
|
||||
# Create index on persist_after_combat for background task queries
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_status_effects_persist
|
||||
ON player_status_effects(persist_after_combat)
|
||||
WHERE persist_after_combat = TRUE
|
||||
"""))
|
||||
print("✓ Created persist_after_combat index")
|
||||
except Exception as e:
|
||||
print(f"Note: Index may already exist: {e}")
|
||||
|
||||
print("\n✓ Migration completed successfully!")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_migration())
|
||||
46
migrations/migrate_fix_damage_constraint.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration: Drop valid_damage constraint from player_status_effects table.
|
||||
This constraint prevents negative damage (healing) for status effects.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
DB_USER = os.getenv("POSTGRES_USER")
|
||||
DB_PASS = os.getenv("POSTGRES_PASSWORD")
|
||||
DB_NAME = os.getenv("POSTGRES_DB")
|
||||
DB_HOST = os.getenv("POSTGRES_HOST", "echoes_of_the_ashes_db")
|
||||
DB_PORT = os.getenv("POSTGRES_PORT", "5432")
|
||||
|
||||
DATABASE_URL = f"postgresql+psycopg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
|
||||
|
||||
async def run_migration():
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
print("Removing restrictive constraint from player_status_effects table...")
|
||||
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE player_status_effects
|
||||
DROP CONSTRAINT IF EXISTS valid_damage
|
||||
"""))
|
||||
print("✓ Dropped valid_damage constraint")
|
||||
except Exception as e:
|
||||
print(f"Error dropping constraint: {e}")
|
||||
|
||||
print("\n✓ Migration completed successfully!")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_migration())
|
||||
@@ -16,9 +16,11 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Cache static assets
|
||||
# Cache static assets - DISABLE CACHE FOR DEVELOPMENT
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Service worker should never be cached
|
||||
|
||||
BIN
pwa/public/audio/bgm-old.wav
Normal file
BIN
pwa/public/audio/sfx/inventory_close.wav
Normal file
BIN
pwa/public/audio/sfx/inventory_open.wav
Normal file
@@ -56,7 +56,8 @@
|
||||
border-color: #535bf2;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #3a3a3a;
|
||||
@@ -66,7 +67,8 @@ input, textarea {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #646cff;
|
||||
}
|
||||
@@ -85,8 +87,48 @@ input:focus, textarea:focus {
|
||||
.container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status Effects */
|
||||
.status-effects-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status-effect-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
background-color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.status-effect-badge.damage {
|
||||
background-color: rgba(231, 76, 60, 0.2);
|
||||
border-color: #e74c3c;
|
||||
color: #ffdce0;
|
||||
}
|
||||
|
||||
.status-effect-badge.buff {
|
||||
background-color: rgba(46, 204, 113, 0.2);
|
||||
border-color: #2ecc71;
|
||||
color: #d4efdf;
|
||||
}
|
||||
|
||||
.effect-icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.effect-timer {
|
||||
font-family: monospace;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } 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 { audioContext, masterVolume, musicVolume, isMuted, getAudioBuffer } = useAudio();
|
||||
|
||||
// We only need refs for the source (track) and the gain (volume)
|
||||
// The context is now shared.
|
||||
const sourceNodeRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const musicGainNodeRef = useRef<GainNode | null>(null);
|
||||
|
||||
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [playbackError, setPlaybackError] = useState(false);
|
||||
|
||||
// Routes where music should play
|
||||
const shouldPlayMusic = () => {
|
||||
const shouldPlayMusic = useCallback(() => {
|
||||
// Game main view
|
||||
if (pathname === '/game') return true;
|
||||
// Leaderboards
|
||||
@@ -21,73 +28,142 @@ export default function BackgroundMusic() {
|
||||
if (pathname.startsWith('/profile/')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
// Calculate effective volume
|
||||
const effectiveVolume = isMuted ? 0 : masterVolume * musicVolume;
|
||||
|
||||
// Load Audio Buffer (using shared cache)
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
// For static assets in public folder:
|
||||
// Browser: use absolute path from root
|
||||
// Electron: use relative path
|
||||
const loadAudio = async () => {
|
||||
setIsLoading(true);
|
||||
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);
|
||||
const buffer = await getAudioBuffer(src);
|
||||
if (buffer) {
|
||||
setAudioBuffer(buffer);
|
||||
} else {
|
||||
console.error('Failed to load background music buffer');
|
||||
setPlaybackError(true);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
handlePlay();
|
||||
if (audioContext) {
|
||||
loadAudio();
|
||||
}
|
||||
}, [audioContext, getAudioBuffer]);
|
||||
|
||||
// Attempts to resume audio if the user interacts with the page
|
||||
const retryPlay = () => {
|
||||
if (shouldPlayMusic() && audio.paused) {
|
||||
handlePlay();
|
||||
// Setup Gain Node
|
||||
useEffect(() => {
|
||||
if (audioContext && !musicGainNodeRef.current) {
|
||||
const gain = audioContext.createGain();
|
||||
gain.connect(audioContext.destination);
|
||||
musicGainNodeRef.current = gain;
|
||||
}
|
||||
}, [audioContext]);
|
||||
|
||||
// Playback Logic
|
||||
const playMusic = useCallback(() => {
|
||||
if (!audioContext || !audioBuffer || !musicGainNodeRef.current) return;
|
||||
|
||||
// If already playing, do nothing
|
||||
if (sourceNodeRef.current) return;
|
||||
|
||||
try {
|
||||
// Ensure context is running (handled globally but good to check)
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().catch(e => console.warn(e));
|
||||
}
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.loop = true;
|
||||
source.connect(musicGainNodeRef.current);
|
||||
source.start(0);
|
||||
sourceNodeRef.current = source;
|
||||
setPlaybackError(false);
|
||||
|
||||
// Cleanup on end (though looping, so only if loop=false or stopped)
|
||||
source.onended = () => {
|
||||
if (sourceNodeRef.current === source) {
|
||||
sourceNodeRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Start playback failed:', error);
|
||||
setPlaybackError(true);
|
||||
}
|
||||
}, [audioContext, audioBuffer]);
|
||||
|
||||
const stopMusic = useCallback(() => {
|
||||
if (sourceNodeRef.current) {
|
||||
try {
|
||||
sourceNodeRef.current.stop();
|
||||
sourceNodeRef.current.disconnect();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
sourceNodeRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle Volume Changes
|
||||
useEffect(() => {
|
||||
if (musicGainNodeRef.current && audioContext) {
|
||||
const currentTime = audioContext.currentTime;
|
||||
musicGainNodeRef.current.gain.setTargetAtTime(effectiveVolume, currentTime, 0.1);
|
||||
}
|
||||
}, [effectiveVolume, audioContext]);
|
||||
|
||||
// Control Play/Stop based on Route and Readiness
|
||||
useEffect(() => {
|
||||
if (isLoading || !audioContext) return;
|
||||
|
||||
const handleAudioLogic = () => {
|
||||
if (shouldPlayMusic()) {
|
||||
if (!sourceNodeRef.current) {
|
||||
playMusic();
|
||||
}
|
||||
} else {
|
||||
stopMusic();
|
||||
}
|
||||
};
|
||||
|
||||
if (playbackError) {
|
||||
document.addEventListener('click', retryPlay, { once: true });
|
||||
}
|
||||
handleAudioLogic();
|
||||
|
||||
}, [shouldPlayMusic, isLoading, audioContext, playMusic, stopMusic]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopMusic();
|
||||
// Don't close context, it's shared
|
||||
};
|
||||
}, [stopMusic]);
|
||||
|
||||
// Monitor state for overlay
|
||||
const [isSuspended, setIsSuspended] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!audioContext) return;
|
||||
|
||||
const updateState = () => setIsSuspended(audioContext.state === 'suspended');
|
||||
updateState();
|
||||
|
||||
const interval = setInterval(updateState, 1000);
|
||||
audioContext.addEventListener('statechange', updateState);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', retryPlay);
|
||||
clearInterval(interval);
|
||||
audioContext.removeEventListener('statechange', updateState);
|
||||
};
|
||||
}, [audioContext]);
|
||||
|
||||
}, [pathname, effectiveVolume, playbackError]);
|
||||
// Render overlay if music should play but is blocked
|
||||
if (!shouldPlayMusic()) return null;
|
||||
|
||||
// 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;
|
||||
// If not suspended and no error, don't show overlay
|
||||
if (!isSuspended && !playbackError) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -96,7 +172,7 @@ export default function BackgroundMusic() {
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 9999,
|
||||
background: 'rgba(74, 158, 255, 0.9)',
|
||||
background: 'rgba(255, 74, 74, 0.9)',
|
||||
color: 'white',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
@@ -106,14 +182,15 @@ export default function BackgroundMusic() {
|
||||
animation: 'pulse 2s infinite'
|
||||
}}
|
||||
onClick={() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.play()
|
||||
.then(() => setPlaybackError(false))
|
||||
.catch(e => console.error(e));
|
||||
if (audioContext) {
|
||||
audioContext.resume().then(() => {
|
||||
// Attempt to play again
|
||||
playMusic();
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
🎵 Click to Enable Audio
|
||||
{playbackError ? '⚠️ Audio Error' : '🎵 Click to Enable Audio'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1909,7 +1909,6 @@ body.no-scroll {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
max-width: 50px;
|
||||
flex: 1;
|
||||
/* Allow content to grow */
|
||||
justify-content: space-between;
|
||||
@@ -2047,15 +2046,11 @@ body.no-scroll {
|
||||
}
|
||||
|
||||
.equipment-emoji {
|
||||
max-width: 50px;
|
||||
max-height: 50px;
|
||||
font-size: 1.2rem;
|
||||
/* Reduced for better fit */
|
||||
line-height: 1;
|
||||
/* Prevent clipping */
|
||||
margin-top: 0.25rem;
|
||||
/* Add small margin */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.equipment-emoji.hidden {
|
||||
|
||||
@@ -219,6 +219,64 @@ function Game() {
|
||||
}
|
||||
break
|
||||
|
||||
case 'interactable_ready':
|
||||
// Interactable cooldown finished
|
||||
if (message.data?.action_name && message.data?.name) {
|
||||
actions.addLocationMessage(t('messages.interactableReady', {
|
||||
action: message.data.action_name,
|
||||
name: message.data.name
|
||||
}))
|
||||
} else if (message.data?.message) {
|
||||
actions.addLocationMessage(message.data.message)
|
||||
}
|
||||
break
|
||||
|
||||
case 'status_effect_damage':
|
||||
if (message.data?.damage) {
|
||||
actions.addLocationMessage(t('messages.statusDamage', { damage: message.data.damage }))
|
||||
actions.updatePlayerState({ hp: message.data.hp })
|
||||
|
||||
if (message.data.effects && Array.isArray(message.data.effects)) {
|
||||
message.data.effects.forEach((e: any) => {
|
||||
actions.updateStatusEffect(e.name, e.ticks_remaining)
|
||||
})
|
||||
} else if (message.data.name && message.data.ticks_remaining !== undefined) {
|
||||
actions.updateStatusEffect(message.data.name, message.data.ticks_remaining)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'status_effect_heal':
|
||||
if (message.data?.heal) {
|
||||
actions.addLocationMessage(t('messages.statusHeal', { heal: message.data.heal }))
|
||||
actions.updatePlayerState({ hp: message.data.hp })
|
||||
|
||||
if (message.data.effects && Array.isArray(message.data.effects)) {
|
||||
message.data.effects.forEach((e: any) => {
|
||||
actions.updateStatusEffect(e.name, e.ticks_remaining)
|
||||
})
|
||||
} else if (message.data.name && message.data.ticks_remaining !== undefined) {
|
||||
actions.updateStatusEffect(message.data.name, message.data.ticks_remaining)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'player_died':
|
||||
if (message.data?.is_dead) {
|
||||
actions.addLocationMessage(t('messages.diedStatus'))
|
||||
actions.updatePlayerState({ hp: 0, is_dead: true })
|
||||
}
|
||||
break
|
||||
|
||||
case 'stamina_update':
|
||||
if (message.data?.stamina) {
|
||||
// Only show message if significant change or if it's the regeneration event
|
||||
// actions.addLocationMessage(t('messages.staminaRegenerated'))
|
||||
// (commented out to avoid spam, usually stamina update is silent or subtle)
|
||||
actions.updatePlayerState({ stamina: message.data.stamina })
|
||||
}
|
||||
break
|
||||
|
||||
case 'player_count_update':
|
||||
// Handled by GameHeader, ignore here
|
||||
break
|
||||
|
||||
102
pwa/src/components/common/GameProgressBar.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import '../game/InventoryModal.css'; // Reusing existing styles for now, or ensure classes are global
|
||||
|
||||
interface GameProgressBarProps {
|
||||
value: number;
|
||||
max: number;
|
||||
type?: 'weight' | 'volume' | 'health' | 'enemy_health' | 'stamina' | 'xp' | 'durability'; // types map to colors
|
||||
showText?: boolean;
|
||||
label?: React.ReactNode;
|
||||
unit?: string;
|
||||
height?: string;
|
||||
align?: 'left' | 'right';
|
||||
labelAlignment?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const GameProgressBar: React.FC<GameProgressBarProps> = ({
|
||||
value,
|
||||
max,
|
||||
type = 'weight',
|
||||
showText = false,
|
||||
label,
|
||||
unit = '',
|
||||
height = '8px',
|
||||
align = 'left',
|
||||
labelAlignment
|
||||
}) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / (max || 1)) * 100));
|
||||
|
||||
// Map types to CSS classes used in InventoryModal.css or inline styles
|
||||
const getFillClass = () => {
|
||||
switch (type) {
|
||||
case 'weight': return 'metric-fill weight';
|
||||
case 'volume': return 'metric-fill volume';
|
||||
case 'health': return 'durability-fill high'; // borrowing green
|
||||
case 'enemy_health': return 'durability-fill low'; // borrowing red
|
||||
case 'stamina': return 'durability-fill medium'; // borrowing yellow
|
||||
case 'xp': return 'durability-fill medium'; // XP usually gold/yellow
|
||||
case 'durability': return 'metric-fill'; // Use inline gradient
|
||||
default: return 'metric-fill';
|
||||
}
|
||||
};
|
||||
|
||||
// Custom coloring for health/stamina if not using classes matching InventoryModal exactly
|
||||
const getGradient = () => {
|
||||
switch (type) {
|
||||
// InventoryModal.css defines .weight and .volume gradients
|
||||
// We can rely on classes if we import the CSS in parent or here
|
||||
case 'health': return 'linear-gradient(90deg, #10b981, #059669)';
|
||||
case 'enemy_health': return 'linear-gradient(90deg, #ef4444, #b91c1c)'; // Red
|
||||
case 'stamina': return 'linear-gradient(90deg, #eab308, #ca8a04)';
|
||||
case 'xp': return 'linear-gradient(90deg, #8b5cf6, #7c3aed)'; // Purple for XP?
|
||||
case 'durability':
|
||||
if (percentage < 15) return 'linear-gradient(90deg, #ef4444, #b91c1c)'; // Red
|
||||
if (percentage < 50) return 'linear-gradient(90deg, #eab308, #ca8a04)'; // Yellow
|
||||
return 'linear-gradient(90deg, #10b981, #059669)'; // Green
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = Number.isInteger(value) ? value : value.toFixed(1);
|
||||
const displayMax = Number.isInteger(max) ? max : max.toFixed(1);
|
||||
|
||||
const effectiveLabelAlign = labelAlignment || align;
|
||||
|
||||
return (
|
||||
<div className="game-progress-container" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{showText && (
|
||||
<div className="progress-text" style={{ fontSize: '0.8rem', color: '#cbd5e0', display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||||
{effectiveLabelAlign === 'left' ? (
|
||||
<>
|
||||
{label && <span style={{ fontWeight: 500 }}>{label}</span>}
|
||||
<span style={{ fontFamily: 'monospace' }}>{displayValue}/{displayMax}{unit ? ` ${unit}` : ''}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontFamily: 'monospace' }}>{displayValue}/{displayMax}{unit ? ` ${unit}` : ''}</span>
|
||||
{label && <span style={{ fontWeight: 500 }}>{label}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="progress-track" style={{
|
||||
height,
|
||||
backgroundColor: '#2d3748',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
justifyContent: align === 'right' ? 'flex-end' : 'flex-start'
|
||||
}}>
|
||||
<div
|
||||
className={getFillClass()}
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
background: getGradient(),
|
||||
transition: 'width 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
// import { useGame } from '../../contexts/GameContext'; // Removed invalid import
|
||||
import { CombatView } from './CombatView';
|
||||
import { CombatInventoryModal } from './CombatInventoryModal';
|
||||
import { CombatState, CombatMessage, FloatingText, AnimationState, CombatActionResponse } from './CombatTypes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -143,6 +144,7 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
const [messageQueue, setMessageQueue] = useState<CombatMessage[]>([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
const [combatResult, setCombatResult] = useState<'victory' | 'defeat' | 'fled' | null>(null);
|
||||
const [showSuppliesModal, setShowSuppliesModal] = useState(false);
|
||||
|
||||
// --- Refs ---
|
||||
const processingRef = useRef(false);
|
||||
@@ -465,6 +467,19 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
}, 2000);
|
||||
break;
|
||||
|
||||
case 'item_used':
|
||||
if (data.hp_restore) {
|
||||
setTimeout(() => addFloatingText(`+${data.hp_restore}`, 'heal', 'player'), 200);
|
||||
}
|
||||
if (data.stamina_restore) {
|
||||
setTimeout(() => addFloatingText(`+${data.stamina_restore}`, 'stamina', 'player'), 400);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'effect_applied':
|
||||
addFloatingText(`${data.effect_icon || ''} ${data.effect_name}`, 'info', data.target === 'enemy' ? 'enemy' : 'player');
|
||||
break;
|
||||
|
||||
case 'flee_success':
|
||||
if (cleanupIntervalRef.current) clearInterval(cleanupIntervalRef.current);
|
||||
setTimeout(() => {
|
||||
@@ -655,17 +670,78 @@ export const Combat: React.FC<CombatProps> = ({
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleUseItem = async (itemId: string) => {
|
||||
// Close modal and use item in combat
|
||||
setShowSuppliesModal(false);
|
||||
if (isPvP) {
|
||||
await handlePvPActionWrapper('use_item');
|
||||
} else {
|
||||
await handlePvEActionWithItem('use_item', itemId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePvEActionWithItem = async (action: string, itemId?: string) => {
|
||||
if (isProcessingQueue) return;
|
||||
|
||||
try {
|
||||
if (localCombatState.turn !== 'player') return;
|
||||
|
||||
// Build action payload
|
||||
const actionPayload = itemId ? `${action}:${itemId}` : action;
|
||||
const data: CombatActionResponse = await onCombatAction(actionPayload);
|
||||
|
||||
if (data && data.success && data.messages) {
|
||||
setMessageQueue(data.messages);
|
||||
|
||||
if (data.combat) {
|
||||
setLocalCombatState(prev => ({
|
||||
...prev,
|
||||
npcHp: data.combat.npc_hp,
|
||||
npcMaxHp: data.combat.npc_max_hp,
|
||||
turn: data.combat.turn,
|
||||
round: data.combat.round,
|
||||
npcName: resolveName(data.combat.npc_name) || prev.npcName
|
||||
}));
|
||||
} else if (data.combat_over && data.player_won) {
|
||||
setLocalCombatState(prev => ({
|
||||
...prev,
|
||||
npcHp: 0
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.player) {
|
||||
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
|
||||
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
|
||||
refreshCharacters();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<CombatView
|
||||
state={localCombatState}
|
||||
animState={animState}
|
||||
floatingTexts={floatingTexts}
|
||||
onAction={isPvP ? handlePvPActionWrapper : handlePvEAction}
|
||||
onClose={handleCloseWrapper}
|
||||
isProcessing={isProcessingQueue}
|
||||
combatResult={combatResult}
|
||||
equipment={_equipment}
|
||||
/>
|
||||
<>
|
||||
<CombatView
|
||||
state={localCombatState}
|
||||
animState={animState}
|
||||
floatingTexts={floatingTexts}
|
||||
onAction={isPvP ? handlePvPActionWrapper : handlePvEAction}
|
||||
onClose={handleCloseWrapper}
|
||||
onShowSupplies={() => setShowSuppliesModal(true)}
|
||||
isProcessing={isProcessingQueue}
|
||||
combatResult={combatResult}
|
||||
equipment={_equipment}
|
||||
playerName={profile?.name}
|
||||
/>
|
||||
{/* Supplies modal */}
|
||||
<CombatInventoryModal
|
||||
isOpen={showSuppliesModal}
|
||||
onClose={() => setShowSuppliesModal(false)}
|
||||
onUseItem={handleUseItem}
|
||||
inventory={playerState?.inventory || []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -266,7 +266,11 @@
|
||||
}
|
||||
|
||||
.type-info {
|
||||
color: #ffff44;
|
||||
color: #44aaff;
|
||||
}
|
||||
|
||||
.type-stamina {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
@keyframes float-up {
|
||||
|
||||
369
pwa/src/components/game/CombatInventoryModal.css
Normal file
@@ -0,0 +1,369 @@
|
||||
/* Shared Backdrop (Refined) */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Combat Modal Container - Matches Inventory Redesign */
|
||||
.combat-inventory-modal {
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
/* Slightly wider for better card display */
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, #1e2a38 0%, #121820 100%);
|
||||
border: 1px solid #3a4b5c;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.8);
|
||||
overflow: hidden;
|
||||
color: #e0e6ed;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.combat-inventory-modal .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 1px solid #3a4b5c;
|
||||
}
|
||||
|
||||
.combat-inventory-modal .modal-header h3 {
|
||||
margin: 0;
|
||||
color: #ff6b6b;
|
||||
/* Reddish for combat focus */
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.combat-inventory-modal .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #a0aec0;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.combat-inventory-modal .close-btn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* Flex container for scrollable list */
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Search Input */
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #3a4b5c;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Items List */
|
||||
.items-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
text-align: center;
|
||||
color: #718096;
|
||||
padding: 2rem;
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Item Card - Matching Inventory Compact Style */
|
||||
.combat-item-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: rgba(26, 32, 44, 0.8);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.combat-item-card:hover {
|
||||
border-color: #ff6b6b;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Image Section */
|
||||
.item-image-section {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.item-img-thumb {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.item-icon-large {
|
||||
font-size: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.item-icon-large.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item-quantity-badge {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
background: #2d3748;
|
||||
border: 1px solid #4a5568;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
.item-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-description {
|
||||
font-size: 0.85rem;
|
||||
color: #a0aec0;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Stat Badges */
|
||||
.item-effects {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Badge Colors */
|
||||
.stat-badge.healing,
|
||||
.stat-badge.health {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6ee7b7;
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.stamina,
|
||||
.stat-badge.crit {
|
||||
background-color: rgba(234, 179, 8, 0.2);
|
||||
color: #fde047;
|
||||
border-color: rgba(234, 179, 8, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.damage,
|
||||
.stat-badge.penetration {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.armor {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.accuracy {
|
||||
background-color: rgba(20, 184, 166, 0.2);
|
||||
color: #5eead4;
|
||||
border-color: rgba(20, 184, 166, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.dodge {
|
||||
background-color: rgba(99, 102, 241, 0.2);
|
||||
color: #a5b4fc;
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.lifesteal {
|
||||
background-color: rgba(236, 72, 153, 0.2);
|
||||
color: #f9a8d4;
|
||||
border-color: rgba(236, 72, 153, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.strength {
|
||||
background-color: rgba(249, 115, 22, 0.2);
|
||||
color: #fdba74;
|
||||
border-color: rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.agility {
|
||||
background-color: rgba(6, 182, 212, 0.2);
|
||||
color: #67e8f9;
|
||||
border-color: rgba(6, 182, 212, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.endurance {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6ee7b7;
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.capacity {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6ee7b7;
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
/* Use Button (Embedded in card) */
|
||||
.btn-use {
|
||||
background: rgba(72, 187, 120, 0.2);
|
||||
color: #48bb78;
|
||||
border: 1px solid rgba(72, 187, 120, 0.4);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
margin-left: 0.5rem;
|
||||
height: fit-content;
|
||||
align-self: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-use:hover {
|
||||
background: rgba(72, 187, 120, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Tier Colors for Names/Icons */
|
||||
.text-tier-0 {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.text-tier-1 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.text-tier-2 {
|
||||
color: #68d391;
|
||||
}
|
||||
|
||||
.text-tier-3 {
|
||||
color: #63b3ed;
|
||||
}
|
||||
|
||||
.text-tier-4 {
|
||||
color: #9f7aea;
|
||||
}
|
||||
|
||||
.text-tier-5 {
|
||||
color: #ed8936;
|
||||
}
|
||||
|
||||
.item-icon-large.tier-0 {
|
||||
text-shadow: 0 0 10px rgba(160, 174, 192, 0.3);
|
||||
}
|
||||
|
||||
.item-icon-large.tier-1 {
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.item-icon-large.tier-2 {
|
||||
text-shadow: 0 0 10px rgba(104, 211, 145, 0.3);
|
||||
}
|
||||
|
||||
.item-icon-large.tier-3 {
|
||||
text-shadow: 0 0 10px rgba(99, 179, 237, 0.3);
|
||||
}
|
||||
|
||||
.item-icon-large.tier-4 {
|
||||
text-shadow: 0 0 10px rgba(159, 122, 234, 0.3);
|
||||
}
|
||||
|
||||
.item-icon-large.tier-5 {
|
||||
text-shadow: 0 0 10px rgba(237, 137, 54, 0.3);
|
||||
}
|
||||
212
pwa/src/components/game/CombatInventoryModal.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getAssetPath } from '../../utils/assetPath';
|
||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||
import './CombatInventoryModal.css';
|
||||
import { EffectBadge } from './EffectBadge';
|
||||
|
||||
interface CombatInventoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUseItem: (itemId: string) => void;
|
||||
inventory: any[];
|
||||
}
|
||||
|
||||
export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUseItem,
|
||||
inventory
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const combatItems = useMemo(() => {
|
||||
if (!inventory) return [];
|
||||
|
||||
return inventory.filter(item => {
|
||||
// Check if item is usable in combat
|
||||
// If explicit combat_usable flag is present, respect it.
|
||||
// If not, fallback to 'consumable' type check, but ideally we want explicit flags.
|
||||
// Some items might be consumable but not combat usable (e.g. quest items, or long-cast items)
|
||||
// For now, checks: combat_usable OR (consumable AND has effects)
|
||||
|
||||
const isCombatUsable = item.combat_usable === true;
|
||||
const isConsumable = item.type === 'consumable' || item.category === 'consumable' || item.consumable === true;
|
||||
|
||||
// Allow if strictly combat_usable, or if consumable and not explicitly restricted
|
||||
const allowed = isCombatUsable || (isConsumable && item.combat_only !== false);
|
||||
|
||||
const itemName = getTranslatedText(item.name).toLowerCase();
|
||||
const matchesSearch = itemName.includes(searchTerm.toLowerCase());
|
||||
|
||||
return allowed && matchesSearch && item.quantity > 0;
|
||||
});
|
||||
}, [inventory, searchTerm]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="combat-inventory-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{t('combat.modal.supplies_title')}</h3>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder={t('combat.modal.search_items')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="items-list">
|
||||
{combatItems.length === 0 ? (
|
||||
<div className="no-items">
|
||||
<span style={{ fontSize: '3rem', opacity: 0.5 }}>📦</span>
|
||||
{t('combat.modal.no_combat_items')}
|
||||
</div>
|
||||
) : (
|
||||
combatItems.map((item, index) => (
|
||||
<div key={`${item.item_id}-${index}`} className="combat-item-card" onClick={() => onUseItem(item.item_id)}>
|
||||
{/* Image Section */}
|
||||
<div className="item-image-section">
|
||||
{item.image_path ? (
|
||||
<img
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="item-img-thumb"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
const icon = (e.target as HTMLImageElement).nextElementSibling;
|
||||
if (icon) icon.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`item-icon-large ${item.tier ? `tier-${item.tier}` : 'tier-0'} ${item.image_path ? 'hidden' : ''}`}>
|
||||
{item.emoji || '📦'}
|
||||
</div>
|
||||
{item.quantity > 1 && <div className="item-quantity-badge">x{item.quantity}</div>}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="item-details">
|
||||
<h4 className={`item-name text-tier-${item.tier || 0}`}>
|
||||
{getTranslatedText(item.name)}
|
||||
</h4>
|
||||
{item.description && (
|
||||
<p className="item-description">{getTranslatedText(item.description)}</p>
|
||||
)}
|
||||
|
||||
<div className="item-effects">
|
||||
{/* Logic adapted from InventoryModal to show all relevant stats */}
|
||||
|
||||
{/* Consumables (Priority for combat) */}
|
||||
{(item.effects?.hp_restore || item.hp_restore) && (
|
||||
<span className="stat-badge healing">
|
||||
❤️ +{item.effects?.hp_restore || item.hp_restore} HP
|
||||
</span>
|
||||
)}
|
||||
{(item.effects?.stamina_restore || item.stamina_restore) && (
|
||||
<span className="stat-badge stamina">
|
||||
⚡ +{item.effects?.stamina_restore || item.stamina_restore} Stm
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
{/* Status Effects & Cures */}
|
||||
{item.effects?.status_effect && (
|
||||
<EffectBadge effect={item.effects.status_effect} />
|
||||
)}
|
||||
|
||||
{item.effects?.cures && item.effects.cures.length > 0 && (
|
||||
<span className="stat-badge cure">
|
||||
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => getTranslatedText(c)).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Combat Effects (Throwables, etc) */}
|
||||
{item.combat_effects?.damage_min && (
|
||||
<span className="stat-badge damage">
|
||||
💥 {item.combat_effects.damage_min}-{item.combat_effects.damage_max} Dmg
|
||||
</span>
|
||||
)}
|
||||
{item.combat_effects?.status && (
|
||||
<span className="stat-badge damage">
|
||||
☠️ {t(`effects.${item.combat_effects.status.name}`, item.combat_effects.status.name) as string}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Stats & Unique Stats (If applicable) */}
|
||||
{(item.unique_stats?.damage_min || item.stats?.damage_min) && (
|
||||
<span className="stat-badge damage">
|
||||
⚔️ {item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}
|
||||
</span>
|
||||
)}
|
||||
{(item.unique_stats?.armor || item.stats?.armor) && (
|
||||
<span className="stat-badge armor">
|
||||
🛡️ +{item.unique_stats?.armor || item.stats?.armor}
|
||||
</span>
|
||||
)}
|
||||
{(item.unique_stats?.armor_penetration || item.stats?.armor_penetration) && (
|
||||
<span className="stat-badge penetration">
|
||||
💔 +{item.unique_stats?.armor_penetration || item.stats?.armor_penetration} {t('stats.pen') as string}
|
||||
</span>
|
||||
)}
|
||||
{(item.unique_stats?.crit_chance || item.stats?.crit_chance) && (
|
||||
<span className="stat-badge crit">
|
||||
🎯 +{Math.round((item.unique_stats?.crit_chance || item.stats?.crit_chance) * 100)}% {t('stats.crit') as string}
|
||||
</span>
|
||||
)}
|
||||
{(item.unique_stats?.accuracy || item.stats?.accuracy) && (
|
||||
<span className="stat-badge accuracy">
|
||||
👁️ +{Math.round((item.unique_stats?.accuracy || item.stats?.accuracy) * 100)}% {t('stats.acc') as string}
|
||||
</span>
|
||||
)}
|
||||
{(item.unique_stats?.dodge_chance || item.stats?.dodge_chance) && (
|
||||
<span className="stat-badge dodge">
|
||||
💨 +{Math.round((item.unique_stats?.dodge_chance || item.stats?.dodge_chance) * 100)}% Dodge
|
||||
</span>
|
||||
)}
|
||||
{(item.unique_stats?.lifesteal || item.stats?.lifesteal) && (
|
||||
<span className="stat-badge lifesteal">
|
||||
🧛 +{Math.round((item.unique_stats?.lifesteal || item.stats?.lifesteal) * 100)}% {t('stats.life') as string}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Attributes */}
|
||||
{(item.unique_stats?.strength_bonus || item.stats?.strength_bonus) && (
|
||||
<span className="stat-badge strength">
|
||||
💪 +{item.unique_stats?.strength_bonus || item.stats?.strength_bonus} {t('stats.str') as string}
|
||||
</span>
|
||||
)}
|
||||
{(item.unique_stats?.agility_bonus || item.stats?.agility_bonus) && (
|
||||
<span className="stat-badge agility">
|
||||
🏃 +{item.unique_stats?.agility_bonus || item.stats?.agility_bonus} {t('stats.agi') as string}
|
||||
</span>
|
||||
)}
|
||||
{(item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus) && (
|
||||
<span className="stat-badge endurance">
|
||||
🏋️ +{item.unique_stats?.endurance_bonus || item.stats?.endurance_bonus} {t('stats.end') as string}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button className="btn-use">
|
||||
{t('game.use')}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ export interface CombatMessage {
|
||||
export interface FloatingText {
|
||||
id: string;
|
||||
text: string;
|
||||
type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp';
|
||||
type: 'damage' | 'heal' | 'info' | 'crit' | 'miss' | 'xp' | 'stamina';
|
||||
x: number; // Percentage 0-100
|
||||
y: number; // Percentage 0-100
|
||||
origin: 'player' | 'enemy';
|
||||
|
||||
@@ -4,16 +4,19 @@ import { useAudio } from '../../contexts/AudioContext';
|
||||
import { CombatState, AnimationState, FloatingText } from './CombatTypes';
|
||||
import { Equipment } from './types';
|
||||
import './CombatEffects.css';
|
||||
import { GameProgressBar } from '../common/GameProgressBar';
|
||||
|
||||
interface CombatViewProps {
|
||||
state: CombatState;
|
||||
animState: AnimationState;
|
||||
floatingTexts: FloatingText[];
|
||||
onAction: (action: string) => void;
|
||||
onAction: (action: string, itemId?: string) => void;
|
||||
onClose: () => void;
|
||||
onShowSupplies: () => void;
|
||||
isProcessing: boolean;
|
||||
combatResult: 'victory' | 'defeat' | 'fled' | null;
|
||||
equipment?: Equipment | any;
|
||||
playerName?: string;
|
||||
}
|
||||
|
||||
export const CombatView: React.FC<CombatViewProps> = ({
|
||||
@@ -22,9 +25,11 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
floatingTexts,
|
||||
onAction,
|
||||
onClose,
|
||||
onShowSupplies,
|
||||
isProcessing,
|
||||
combatResult,
|
||||
equipment
|
||||
equipment,
|
||||
playerName
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { playSfx } = useAudio();
|
||||
@@ -109,10 +114,6 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
}
|
||||
}, [state.messages]);
|
||||
|
||||
const getHealthPercent = (current: number, max: number) => {
|
||||
return Math.max(0, Math.min(100, (current / max) * 100));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="combat-container">
|
||||
|
||||
@@ -158,7 +159,6 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
<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 => (
|
||||
@@ -167,13 +167,15 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="stat-header">
|
||||
<span className="stat-label">{t('common.enemy')}</span>
|
||||
<span className="stat-numbers">{state.npcHp} / {state.npcMaxHp}</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.npcHp, state.npcMaxHp)}%`, background: 'linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%)' }}></div>
|
||||
</div>
|
||||
<GameProgressBar
|
||||
label={state.npcName || t('common.enemy')}
|
||||
value={state.npcHp}
|
||||
max={state.npcMaxHp}
|
||||
type="enemy_health"
|
||||
showText={true}
|
||||
height="10px"
|
||||
labelAlignment="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Player HP (Right) */}
|
||||
@@ -185,14 +187,16 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stat-header">
|
||||
<span className="stat-label">{t('common.you')}</span>
|
||||
<span className="stat-numbers">{state.playerHp} / {state.playerMaxHp}</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill health" style={{ width: `${getHealthPercent(state.playerHp, state.playerMaxHp)}%`, background: 'linear-gradient(90deg, #4caf50 0%, #8bc34a 100%)' }}></div>
|
||||
</div>
|
||||
<GameProgressBar
|
||||
label={playerName || t('common.you')}
|
||||
value={state.playerHp}
|
||||
max={state.playerMaxHp}
|
||||
type="health"
|
||||
showText={true}
|
||||
height="10px"
|
||||
align="right"
|
||||
labelAlignment="left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,7 +210,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
{t('common.close')}
|
||||
</button>
|
||||
|
||||
<div className="combat-actions-group" style={{ display: !combatResult ? 'flex' : 'none', gap: '1rem', width: '100%', justifyContent: 'center' }}>
|
||||
<div className="combat-actions-group" style={{ display: !combatResult ? 'grid' : 'none', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
|
||||
<button
|
||||
className="btn btn-attack"
|
||||
onClick={() => onAction('attack')}
|
||||
@@ -215,6 +219,22 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
👊 {t('combat.actions.attack')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-defend"
|
||||
onClick={() => onAction('defend')}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🛡️ {t('combat.actions.defend')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-supplies"
|
||||
onClick={onShowSupplies}
|
||||
disabled={isProcessing || !state.yourTurn}
|
||||
>
|
||||
🎒 {t('combat.actions.supplies')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-flee"
|
||||
onClick={() => onAction('flee')}
|
||||
@@ -238,6 +258,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
} else {
|
||||
switch (msg.type) {
|
||||
case 'combat_start': text = t('combat.start'); break;
|
||||
case 'combat_timeout': text = t('combat.turn_timeout'); className += " text-warning"; break;
|
||||
case 'player_attack':
|
||||
if (msg.origin === 'enemy') {
|
||||
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
|
||||
@@ -267,6 +288,18 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
}
|
||||
break;
|
||||
case 'text': text = msg.data?.text || ""; break;
|
||||
case 'item_used':
|
||||
text = t('combat.log.item_used', { item: msg.data?.item_name || '' });
|
||||
if (msg.data?.effects) text += msg.data.effects; // Append effects string if backend still sends it
|
||||
className += " text-info";
|
||||
break;
|
||||
case 'effect_applied':
|
||||
text = t('combat.log.effect_applied', {
|
||||
effect: msg.data?.effect_name,
|
||||
target: msg.data?.target === 'enemy' ? t('common.enemy') : t('common.you')
|
||||
});
|
||||
className += " text-warning";
|
||||
break;
|
||||
default: text = msg.type;
|
||||
}
|
||||
}
|
||||
@@ -286,12 +319,13 @@ export const CombatView: React.FC<CombatViewProps> = ({
|
||||
</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>
|
||||
{
|
||||
isProcessing && !combatResult && state.turn === 'enemy' && (
|
||||
<div className="turn-overlay">
|
||||
{t('combat.enemy_turn')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
41
pwa/src/components/game/EffectBadge.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||
|
||||
interface EffectBadgeProps {
|
||||
effect: {
|
||||
name: string | any;
|
||||
icon?: string;
|
||||
type?: 'buff' | 'debuff' | 'damage';
|
||||
damage_per_tick?: number;
|
||||
ticks?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const EffectBadge: React.FC<EffectBadgeProps> = ({ effect }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Determine class based on type or fallback to damage logic
|
||||
const badgeClass = effect.type === 'buff' ? 'buff' : 'damage';
|
||||
|
||||
// For translation of effect name
|
||||
const effectName = typeof effect.name === 'string'
|
||||
? t(`game.effects.${effect.name}`, effect.name)
|
||||
: getTranslatedText(effect.name);
|
||||
|
||||
return (
|
||||
<span className={`stat-badge ${badgeClass}`}>
|
||||
{effect.icon}
|
||||
{effect.damage_per_tick ? (
|
||||
<>
|
||||
{effect.damage_per_tick < 0 ?
|
||||
`+${Math.abs(effect.damage_per_tick)}` :
|
||||
`-${effect.damage_per_tick}`} HP
|
||||
{effect.ticks && ` (${effect.ticks})`}
|
||||
</>
|
||||
) : (
|
||||
effectName
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -356,6 +356,7 @@
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -513,12 +514,19 @@
|
||||
/* Variant Colors */
|
||||
.stat-badge.capacity,
|
||||
.stat-badge.endurance,
|
||||
.stat-badge.health {
|
||||
.stat-badge.health,
|
||||
.stat-badge.buff {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6ee7b7;
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.cure {
|
||||
background-color: rgba(45, 212, 191, 0.2);
|
||||
color: #5eead4;
|
||||
border-color: rgba(45, 212, 191, 0.4);
|
||||
}
|
||||
|
||||
.stat-badge.damage,
|
||||
.stat-badge.penetration {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
@@ -662,6 +670,18 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:disabled,
|
||||
.action-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(100%);
|
||||
pointer-events: none;
|
||||
background: rgba(144, 144, 144, 0.2) !important;
|
||||
color: #a0aec0 !important;
|
||||
border-color: rgba(160, 174, 192, 0.4) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.action-btn.use {
|
||||
background: rgba(72, 187, 120, 0.2);
|
||||
color: #48bb78;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { MouseEvent, ChangeEvent } from 'react'
|
||||
import { MouseEvent, ChangeEvent, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAudio } from '../../contexts/AudioContext'
|
||||
import { PlayerState, Profile, Equipment } from './types'
|
||||
import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import './InventoryModal.css'
|
||||
import { EffectBadge } from './EffectBadge'
|
||||
|
||||
interface InventoryModalProps {
|
||||
playerState: PlayerState
|
||||
@@ -37,7 +38,22 @@ function InventoryModal({
|
||||
}: InventoryModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { playSfx } = useAudio()
|
||||
// Categories for the sidebar
|
||||
|
||||
// Play sound on mount
|
||||
useEffect(() => {
|
||||
playSfx('/audio/sfx/inventory_open.wav')
|
||||
|
||||
// Return cleanup/close sound? usage of "onClose" typically handles it.
|
||||
// We can't easily do it on unmount if the parent unmounts it instantly.
|
||||
// But for "close" button click we can play it.
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
playSfx('/audio/sfx/inventory_close.wav')
|
||||
onClose()
|
||||
}
|
||||
|
||||
// ... existing categories ...
|
||||
const categories = [
|
||||
{ id: 'all', label: t('categories.all'), icon: '🎒' },
|
||||
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
|
||||
@@ -213,6 +229,18 @@ function InventoryModal({
|
||||
⚡ +{item.stamina_restore} Stm
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Status Effects */}
|
||||
{item.effects?.status_effect && (
|
||||
<EffectBadge effect={item.effects.status_effect} />
|
||||
)}
|
||||
|
||||
{item.effects?.cures && item.effects.cures.length > 0 && (
|
||||
<span className="stat-badge cure">
|
||||
💊 {t('game.cures')}: {item.effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Durability Bar */}
|
||||
@@ -248,10 +276,30 @@ function InventoryModal({
|
||||
{/* Right: Actions */}
|
||||
<div className="item-actions-section">
|
||||
{item.consumable && (
|
||||
<button className="action-btn use" onClick={() => {
|
||||
playSfx('/audio/sfx/use.wav')
|
||||
onUseItem(item.item_id, item.id)
|
||||
}}>{t('game.use')}</button>
|
||||
(() => {
|
||||
const statusEffect = item.effects?.status_effect;
|
||||
const isEffectActive = statusEffect && playerState.status_effects.some((e: any) => {
|
||||
const effectName = typeof e.effect_name === 'string' ? e.effect_name : e.effect_name['en'];
|
||||
const itemName = typeof statusEffect.name === 'string' ? statusEffect.name : statusEffect.name['en'];
|
||||
return effectName === itemName;
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`action-btn use ${isEffectActive ? 'disabled' : ''}`}
|
||||
disabled={isEffectActive}
|
||||
title={isEffectActive ? t('game.effectAlreadyActive') : ''}
|
||||
onClick={() => {
|
||||
if (!isEffectActive) {
|
||||
playSfx('/audio/sfx/use.wav')
|
||||
onUseItem(item.item_id, item.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('game.use')}
|
||||
</button>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
{item.equippable && !item.is_equipped && (
|
||||
<button className="action-btn equip" onClick={() => {
|
||||
@@ -271,7 +319,7 @@ function InventoryModal({
|
||||
playSfx('/audio/sfx/drop.wav')
|
||||
onDropItem(item.item_id, item.id, 1)
|
||||
}}>
|
||||
{item.quantity === 1 ? t('game.drop') : 'x1' }
|
||||
{item.quantity === 1 ? t('game.drop') : 'x1'}
|
||||
</button>
|
||||
{item.quantity >= 5 && (
|
||||
<button className="action-btn drop" onClick={() => {
|
||||
@@ -301,7 +349,7 @@ function InventoryModal({
|
||||
|
||||
return (
|
||||
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
if (e.target === e.currentTarget) handleClose()
|
||||
}}>
|
||||
<div className="workbench-menu inventory-modal-redesign">
|
||||
{/* Top Bar: Capacity & Backpack Info */}
|
||||
@@ -354,12 +402,13 @@ function InventoryModal({
|
||||
<span>{t('game.noBackpack')}</span>
|
||||
</div>
|
||||
)}
|
||||
<button className="close-btn" onClick={onClose}>✕</button>
|
||||
<button className="close-btn" onClick={handleClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inventory-main-layout">
|
||||
{/* Left Sidebar: Categories */}
|
||||
|
||||
<div className="inventory-sidebar-filters">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PlayerState, Profile, Equipment } from './types'
|
||||
import { getAssetPath } from '../../utils/assetPath'
|
||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||
import InventoryModal from './InventoryModal'
|
||||
import { GameProgressBar } from '../common/GameProgressBar'
|
||||
|
||||
interface PlayerSidebarProps {
|
||||
playerState: PlayerState
|
||||
@@ -37,13 +38,10 @@ function PlayerSidebar({
|
||||
onSpendPoint
|
||||
}: PlayerSidebarProps) {
|
||||
const [showInventory, setShowInventory] = useState(false)
|
||||
|
||||
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const renderEquipmentSlot = (slot: string, item: any, emoji: string, label: string) => (
|
||||
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`}>
|
||||
const renderEquipmentSlot = (slot: string, item: any, label: string) => (
|
||||
<div className={`equipment-slot ${item ? 'filled' : 'empty'}`} title={!item ? label : ''}>
|
||||
{item ? (
|
||||
<>
|
||||
<button className="equipment-unequip-btn" onClick={() => onUnequipItem(slot)} title={t('game.unequip')}>✕</button>
|
||||
@@ -53,22 +51,31 @@ function PlayerSidebar({
|
||||
src={getAssetPath(item.image_path)}
|
||||
alt={getTranslatedText(item.name)}
|
||||
className="equipment-emoji"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
const icon = (e.target as HTMLImageElement).nextElementSibling;
|
||||
if (icon) icon.classList.remove('hidden');
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : null}
|
||||
<span className={`equipment-emoji ${item.image_path ? 'hidden' : ''}`}>{item.emoji}</span>
|
||||
<span className={`equipment-name ${item.tier ? `tier-${item.tier}` : ''}`}>{getTranslatedText(item.name)}</span>
|
||||
{item.durability && item.durability !== null && (
|
||||
<span className="equipment-durability">{item.durability}/{item.max_durability}</span>
|
||||
) : (
|
||||
<span className="equipment-emoji" style={{ fontSize: '2rem' }}>{item.emoji}</span>
|
||||
)}
|
||||
{item.durability !== undefined && item.durability !== null && (
|
||||
<div className="equipment-durability-bar-container" style={{ width: '90%', marginTop: 'auto', marginBottom: '4px' }}>
|
||||
<GameProgressBar
|
||||
value={item.durability}
|
||||
max={item.max_durability}
|
||||
type="durability"
|
||||
height="4px"
|
||||
showText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="equipment-tooltip">
|
||||
<div className="item-tooltip-name">{getTranslatedText(item.name)}</div>
|
||||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||||
<div className="item-tooltip-stat" style={{ color: '#fbbf24' }}>
|
||||
⭐ Tier: {item.tier}
|
||||
</div>
|
||||
)}
|
||||
{item.description && <div className="item-tooltip-desc">{getTranslatedText(item.description)}</div>}
|
||||
{/* Use unique_stats if available, otherwise fall back to base stats */}
|
||||
{(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && (
|
||||
<>
|
||||
{(item.unique_stats?.armor || item.stats?.armor) && (
|
||||
@@ -106,84 +113,97 @@ function PlayerSidebar({
|
||||
)}
|
||||
{item.durability !== undefined && item.durability !== null && (
|
||||
<div className="item-tooltip-stat">
|
||||
{t('stats.durability')}: {item.durability}/{item.max_durability}
|
||||
</div>
|
||||
)}
|
||||
{item.tier !== undefined && item.tier !== null && item.tier > 0 && (
|
||||
<div className="item-tooltip-stat">
|
||||
⭐ Tier: {item.tier}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
|
||||
<span>{t('stats.durability')}:</span>
|
||||
<span>{item.durability}/{item.max_durability}</span>
|
||||
</div>
|
||||
<GameProgressBar
|
||||
value={item.durability}
|
||||
max={item.max_durability}
|
||||
type="durability"
|
||||
height="6px"
|
||||
showText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="equipment-emoji">{emoji}</span>
|
||||
<span className="equipment-slot-label">{label}</span>
|
||||
<img
|
||||
src={getAssetPath(`/images/placeholder/${slot}_placeholder.webp`)}
|
||||
alt={label}
|
||||
className="equipment-placeholder-img"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', opacity: 0.5 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className={`right-sidebar mobile-menu-panel ${mobileMenuOpen === 'right' ? 'open' : ''}`}>
|
||||
{/* Profile Stats */}
|
||||
<div className="profile-sidebar">
|
||||
<h3>{t('game.character')}</h3>
|
||||
<h3 className="sidebar-title">
|
||||
{profile?.name || 'Character'} <span className="title-level">(Lv. {profile?.level || 1})</span>
|
||||
</h3>
|
||||
|
||||
<div className="sidebar-stat-bars">
|
||||
<div className="sidebar-stat-bar">
|
||||
<div className="sidebar-stat-header">
|
||||
<span className="sidebar-stat-label">{t('stats.hp')}</span>
|
||||
<span className="sidebar-stat-numbers">{playerState.health}/{playerState.max_health}</span>
|
||||
</div>
|
||||
<div className="sidebar-progress-bar">
|
||||
<div
|
||||
className="sidebar-progress-fill health"
|
||||
style={{ width: `${(playerState.health / playerState.max_health) * 100}%` }}
|
||||
></div>
|
||||
<span className="progress-percentage">{Math.round((playerState.health / playerState.max_health) * 100)}%</span>
|
||||
</div>
|
||||
<GameProgressBar
|
||||
value={playerState.health}
|
||||
max={playerState.max_health}
|
||||
type="health"
|
||||
showText={true}
|
||||
height="10px"
|
||||
label={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{t('stats.hp')}
|
||||
<div className="status-indicators" style={{ display: 'flex', gap: '5px' }}>
|
||||
{playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => (
|
||||
<span key={e.id} className={`stat-indicator ${e.damage_per_tick > 0 ? 'negative' : 'positive'}`} style={{
|
||||
color: e.damage_per_tick > 0 ? '#ff6b6b' : '#4caf50',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{e.damage_per_tick > 0 ? `-${e.damage_per_tick}` : `+${Math.abs(e.damage_per_tick)}`}/t ({e.ticks_remaining})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-stat-bar">
|
||||
<div className="sidebar-stat-header">
|
||||
<span className="sidebar-stat-label">{t('stats.stamina')}</span>
|
||||
<span className="sidebar-stat-numbers">{playerState.stamina}/{playerState.max_stamina}</span>
|
||||
</div>
|
||||
<div className="sidebar-progress-bar">
|
||||
<div
|
||||
className="sidebar-progress-fill stamina"
|
||||
style={{ width: `${(playerState.stamina / playerState.max_stamina) * 100}%` }}
|
||||
></div>
|
||||
<span className="progress-percentage">{Math.round((playerState.stamina / playerState.max_stamina) * 100)}%</span>
|
||||
<GameProgressBar
|
||||
value={playerState.stamina}
|
||||
max={playerState.max_stamina}
|
||||
type="stamina"
|
||||
showText={true}
|
||||
height="10px"
|
||||
label={t('stats.stamina')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-stat-bar">
|
||||
<GameProgressBar
|
||||
value={profile?.xp || 0}
|
||||
max={(profile?.level || 1) * 100}
|
||||
type="xp"
|
||||
showText={true}
|
||||
height="10px"
|
||||
label={t('stats.xp')}
|
||||
/>
|
||||
<div className="xp-text-detail" style={{ fontSize: '0.7rem', color: '#718096', textAlign: 'right', marginTop: '2px' }}>
|
||||
{Math.floor(((profile?.level || 1) * 100) - (profile?.xp || 0))} XP to next level
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile && (
|
||||
<div className="sidebar-stats">
|
||||
<div className="sidebar-stat-row">
|
||||
<span className="sidebar-label">{t('stats.level')}:</span>
|
||||
<span className="sidebar-value">{profile.level}</span>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-stat-bar">
|
||||
<div className="sidebar-stat-header">
|
||||
<span className="sidebar-stat-label">{t('stats.xp')}</span>
|
||||
<span className="sidebar-stat-numbers">{profile.xp} / {(profile.level * 100)}</span>
|
||||
</div>
|
||||
<div className="sidebar-progress-bar">
|
||||
<div
|
||||
className="sidebar-progress-fill xp"
|
||||
style={{ width: `${(profile.xp / (profile.level * 100)) * 100}%` }}
|
||||
></div>
|
||||
<span className="progress-percentage">{Math.round((profile.xp / (profile.level * 100)) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.unspent_points > 0 && (
|
||||
<div className="sidebar-stat-row highlight">
|
||||
<span className="sidebar-label">{t('stats.unspentPoints')}:</span>
|
||||
@@ -229,86 +249,78 @@ function PlayerSidebar({
|
||||
|
||||
{/* Inventory Capacity - matching HP/Stamina/XP style */}
|
||||
<div className="sidebar-stat-bar">
|
||||
<div className="sidebar-stat-header">
|
||||
<span className="sidebar-stat-label">{t('stats.weight')}</span>
|
||||
<span className="sidebar-stat-numbers">{(profile.current_weight || 0).toFixed(1)}/{profile.max_weight || 0}kg</span>
|
||||
</div>
|
||||
<div className="sidebar-progress-bar">
|
||||
<div
|
||||
className="sidebar-progress-fill weight"
|
||||
style={{ width: `${Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100)}%` }}
|
||||
></div>
|
||||
<span className="progress-percentage">{Math.round(Math.min(100, ((profile.current_weight || 0) / (profile.max_weight || 1)) * 100))}%</span>
|
||||
</div>
|
||||
<GameProgressBar
|
||||
value={profile.current_weight || 0}
|
||||
max={profile.max_weight || 0}
|
||||
type="weight"
|
||||
showText={true}
|
||||
label={t('stats.weight')}
|
||||
unit="kg"
|
||||
height="10px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-stat-bar">
|
||||
<div className="sidebar-stat-header">
|
||||
<span className="sidebar-stat-label">{t('stats.volume')}</span>
|
||||
<span className="sidebar-stat-numbers">{(profile.current_volume || 0).toFixed(1)}/{profile.max_volume || 0}L</span>
|
||||
</div>
|
||||
<div className="sidebar-progress-bar">
|
||||
<div
|
||||
className="sidebar-progress-fill volume"
|
||||
style={{ width: `${Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100)}%` }}
|
||||
></div>
|
||||
<span className="progress-percentage">{Math.round(Math.min(100, ((profile.current_volume || 0) / (profile.max_volume || 1)) * 100))}%</span>
|
||||
</div>
|
||||
<GameProgressBar
|
||||
value={profile.current_volume || 0}
|
||||
max={profile.max_volume || 0}
|
||||
type="volume"
|
||||
showText={true}
|
||||
label={t('stats.volume')}
|
||||
unit="L"
|
||||
height="10px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="open-inventory-btn"
|
||||
onClick={() => setShowInventory(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
backgroundColor: '#2c3e50',
|
||||
border: '1px solid #34495e',
|
||||
borderRadius: '8px',
|
||||
color: '#ecf0f1',
|
||||
fontSize: '1.1rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{t('game.inventory')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="open-inventory-btn"
|
||||
onClick={() => setShowInventory(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
marginTop: '1rem',
|
||||
backgroundColor: '#2c3e50',
|
||||
border: '1px solid #34495e',
|
||||
borderRadius: '8px',
|
||||
color: '#ecf0f1',
|
||||
fontSize: '1rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{t('game.inventory')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Equipment Display - Proper Grid Layout */}
|
||||
<div className="equipment-sidebar">
|
||||
<h3>{t('game.equipment')}</h3>
|
||||
<div className="equipment-grid">
|
||||
{/* Row 1: Head */}
|
||||
<div className="equipment-row">
|
||||
{renderEquipmentSlot('head', equipment.head, '🪖', t('equipment.head'))}
|
||||
{renderEquipmentSlot('head', equipment.head, t('equipment.head'))}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Weapon, Torso, Backpack */}
|
||||
<div className="equipment-row three-cols">
|
||||
{renderEquipmentSlot('weapon', equipment.weapon, '⚔️', t('equipment.weapon'))}
|
||||
{renderEquipmentSlot('torso', equipment.torso, '👕', t('equipment.torso'))}
|
||||
{renderEquipmentSlot('backpack', equipment.backpack, '🎒', t('equipment.backpack'))}
|
||||
{renderEquipmentSlot('weapon', equipment.weapon, t('equipment.weapon'))}
|
||||
{renderEquipmentSlot('torso', equipment.torso, t('equipment.torso'))}
|
||||
{renderEquipmentSlot('backpack', equipment.backpack, t('equipment.backpack'))}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Legs & Feet */}
|
||||
<div className="equipment-row two-cols">
|
||||
{renderEquipmentSlot('legs', equipment.legs, '👖', t('equipment.legs'))}
|
||||
{renderEquipmentSlot('feet', equipment.feet, '👟', t('equipment.feet'))}
|
||||
{renderEquipmentSlot('legs', equipment.legs, t('equipment.legs'))}
|
||||
{renderEquipmentSlot('feet', equipment.feet, t('equipment.feet'))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Inventory Modal */}
|
||||
{showInventory && profile && (
|
||||
<InventoryModal
|
||||
playerState={playerState}
|
||||
|
||||
@@ -305,7 +305,7 @@ function Workbench({
|
||||
<div key={i} className={`requirement-item ${tool.has_tool ? 'met' : 'missing'}`}>
|
||||
<span>{tool.emoji} {getTranslatedText(tool.name)}</span>
|
||||
<span>
|
||||
{tool.has_tool ? `✅ ${tool.tool_durability}/${tool.max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
|
||||
{tool.has_tool ? `✅ ${tool.tool_durability}/${tool.tool_max_durability} (Cost: ${tool.durability_cost})` : `❌ Missing (Cost: ${tool.durability_cost})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -136,6 +136,7 @@ export interface GameEngineActions {
|
||||
removePlayerFromLocation: (playerId: number) => void
|
||||
addNPCToLocation: (npc: any) => void
|
||||
removeNPCFromLocation: (enemyId: string) => void
|
||||
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
|
||||
}
|
||||
|
||||
export function useGameEngine(
|
||||
@@ -243,7 +244,7 @@ export function useGameEngine(
|
||||
stamina: gameState.player.stamina,
|
||||
max_stamina: gameState.player.max_stamina,
|
||||
inventory: gameState.inventory || [],
|
||||
status_effects: []
|
||||
status_effects: gameState.player.status_effects || []
|
||||
})
|
||||
setEquipment(gameState.equipment || {})
|
||||
|
||||
@@ -275,7 +276,7 @@ export function useGameEngine(
|
||||
stamina: gameState.player.stamina,
|
||||
max_stamina: gameState.player.max_stamina,
|
||||
inventory: gameState.inventory || [],
|
||||
status_effects: []
|
||||
status_effects: gameState.player.status_effects || []
|
||||
})
|
||||
|
||||
setLocation(locationRes.data)
|
||||
@@ -458,6 +459,42 @@ export function useGameEngine(
|
||||
setLoadedTabs(new Set())
|
||||
}
|
||||
|
||||
const updateStatusEffect = useCallback((effectName: string | any, remainingTicks: number) => {
|
||||
setPlayerState((prev: PlayerState | null) => {
|
||||
if (!prev) return null
|
||||
|
||||
if (!prev) return null
|
||||
const target = typeof effectName === 'object'
|
||||
? (effectName.en || Object.values(effectName)[0])
|
||||
: effectName
|
||||
|
||||
if (remainingTicks <= 0) {
|
||||
return {
|
||||
...prev,
|
||||
status_effects: prev.status_effects.filter(e => {
|
||||
const current = typeof e.effect_name === 'object'
|
||||
? (e.effect_name.en || Object.values(e.effect_name)[0])
|
||||
: e.effect_name
|
||||
return current !== target
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
status_effects: prev.status_effects.map(e => {
|
||||
const current = typeof e.effect_name === 'object'
|
||||
? (e.effect_name.en || Object.values(e.effect_name)[0])
|
||||
: e.effect_name
|
||||
if (current === target) {
|
||||
return { ...e, ticks_remaining: remainingTicks }
|
||||
}
|
||||
return e
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// State object
|
||||
const state: GameEngineState = {
|
||||
playerState,
|
||||
@@ -720,8 +757,13 @@ export function useGameEngine(
|
||||
|
||||
const handleCombatAction = async (action: string) => {
|
||||
try {
|
||||
// setEnemyTurnMessage('Processing...') // Handled by Combat.tsx now
|
||||
const response = await api.post('/api/game/combat/action', { action })
|
||||
let payload: any = { action }
|
||||
if (action.includes(':')) {
|
||||
const [act, itemId] = action.split(':')
|
||||
payload = { action: act, item_id: itemId }
|
||||
}
|
||||
|
||||
const response = await api.post('/api/game/combat/action', payload)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
setMessage(error.response?.data?.detail || 'Combat action failed')
|
||||
@@ -754,11 +796,19 @@ export function useGameEngine(
|
||||
|
||||
const handlePvPAction = async (action: string, _targetId: number) => {
|
||||
try {
|
||||
const response = await api.post('/api/game/pvp/action', { action })
|
||||
let payload: any = { action }
|
||||
if (action.includes(':')) {
|
||||
const [act, itemId] = action.split(':')
|
||||
payload = { action: act, item_id: itemId }
|
||||
}
|
||||
|
||||
const response = await api.post('/api/game/pvp/action', payload)
|
||||
setMessage(response.data.message || 'Action performed!')
|
||||
await fetchGameData()
|
||||
return response.data // Return data so caller can use it
|
||||
} catch (error: any) {
|
||||
setMessage(error.response?.data?.detail || 'PvP action failed')
|
||||
throw error // Re-throw so caller knows it failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1086,7 +1136,8 @@ export function useGameEngine(
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
},
|
||||
updateStatusEffect
|
||||
}
|
||||
|
||||
// Polling fallback for PvP Combat reliability
|
||||
|
||||
@@ -8,7 +8,17 @@ export interface PlayerState {
|
||||
stamina: number
|
||||
max_stamina: number
|
||||
inventory: any[]
|
||||
status_effects: any[]
|
||||
status_effects: StatusEffect[]
|
||||
}
|
||||
|
||||
export interface StatusEffect {
|
||||
id: number
|
||||
effect_name: string | any
|
||||
effect_icon: string
|
||||
effect_type: string
|
||||
damage_per_tick: number
|
||||
value: number
|
||||
ticks_remaining: number
|
||||
}
|
||||
|
||||
export interface DirectionDetail {
|
||||
@@ -53,6 +63,8 @@ export interface Profile {
|
||||
current_weight?: number
|
||||
max_volume?: number
|
||||
current_volume?: number
|
||||
|
||||
status_effects?: StatusEffect[]
|
||||
}
|
||||
|
||||
export interface CombatLogEntry {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { isElectronApp } from '../utils/assetPath';
|
||||
|
||||
interface AudioContextType {
|
||||
@@ -11,12 +11,17 @@ interface AudioContextType {
|
||||
setSfxVolume: (val: number) => void;
|
||||
setIsMuted: (val: boolean) => void;
|
||||
playSfx: (path: string, fallbackPath?: string) => void;
|
||||
audioContext: AudioContext | null;
|
||||
getAudioBuffer: (path: string) => Promise<AudioBuffer | null>;
|
||||
}
|
||||
|
||||
const AudioContext = createContext<AudioContextType | undefined>(undefined);
|
||||
|
||||
// Cache for decoded audio buffers to prevent re-fetching/re-decoding
|
||||
const bufferCache: Record<string, AudioBuffer> = {};
|
||||
|
||||
export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// Initialize state from localStorage or defaults
|
||||
// Volume State
|
||||
const [masterVolume, setMasterVolumeState] = useState(() => {
|
||||
const saved = localStorage.getItem('audio_masterVolume');
|
||||
return saved ? parseFloat(saved) : 1.0;
|
||||
@@ -34,7 +39,62 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
return saved ? JSON.parse(saved) : false;
|
||||
});
|
||||
|
||||
// Persistence wrappers
|
||||
// Web Audio API State
|
||||
const [audioContext, setAudioContext] = useState<AudioContext | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null); // Ref for immediate access in loops/events
|
||||
const sfxGainNodeRef = useRef<GainNode | null>(null);
|
||||
|
||||
// Initialize AudioContext on mount
|
||||
useEffect(() => {
|
||||
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
||||
const ctx = new AudioContextClass();
|
||||
audioContextRef.current = ctx;
|
||||
setAudioContext(ctx);
|
||||
|
||||
// Create a dedicated GainNode for SFX
|
||||
const sfxGain = ctx.createGain();
|
||||
sfxGain.connect(ctx.destination);
|
||||
sfxGainNodeRef.current = sfxGain;
|
||||
|
||||
return () => {
|
||||
if (ctx.state !== 'closed') {
|
||||
ctx.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Global "Unlock" Listener for Autoplay Policy
|
||||
useEffect(() => {
|
||||
const unlockAudio = () => {
|
||||
const ctx = audioContextRef.current;
|
||||
if (ctx && ctx.state === 'suspended') {
|
||||
ctx.resume().then(() => {
|
||||
console.log('AudioContext resumed via user interaction');
|
||||
}).catch(e => console.error('Failed to resume AudioContext:', e));
|
||||
}
|
||||
// Once resumed, we can generally remove these listeners,
|
||||
// but Chrome sometimes needs multiple checks if it suspends again.
|
||||
// Usually one accepted interaction is enough for the session.
|
||||
};
|
||||
|
||||
const events = ['click', 'touchstart', 'keydown', 'mousedown'];
|
||||
events.forEach(e => document.addEventListener(e, unlockAudio, { passive: true }));
|
||||
|
||||
return () => {
|
||||
events.forEach(e => document.removeEventListener(e, unlockAudio));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update SFX Gain when volumes change
|
||||
useEffect(() => {
|
||||
if (sfxGainNodeRef.current && audioContextRef.current) {
|
||||
const effectiveSfxVol = isMuted ? 0 : masterVolume * sfxVolume;
|
||||
const currentTime = audioContextRef.current.currentTime;
|
||||
sfxGainNodeRef.current.gain.setTargetAtTime(effectiveSfxVol, currentTime, 0.1);
|
||||
}
|
||||
}, [masterVolume, sfxVolume, isMuted]);
|
||||
|
||||
// Volume Setters with Persistence
|
||||
const setMasterVolume = (val: number) => {
|
||||
setMasterVolumeState(val);
|
||||
localStorage.setItem('audio_masterVolume', val.toString());
|
||||
@@ -55,39 +115,75 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
localStorage.setItem('audio_isMuted', JSON.stringify(val));
|
||||
};
|
||||
|
||||
const playSfx = (path: string, fallbackPath?: string) => {
|
||||
if (isMuted) return;
|
||||
// Helper: Resolve Path
|
||||
const resolvePath = useCallback((p: string) => {
|
||||
if (p.startsWith('http') || p.startsWith('file')) return p;
|
||||
const cleanPath = p.startsWith('/') ? p.slice(1) : p;
|
||||
return isElectronApp() ? `./${cleanPath}` : `/${cleanPath}`;
|
||||
}, []);
|
||||
|
||||
// Calculate effective volume
|
||||
const effectiveVolume = masterVolume * sfxVolume;
|
||||
if (effectiveVolume <= 0) return;
|
||||
// Helper: Fetch and Decode Audio
|
||||
const getAudioBuffer = useCallback(async (path: string): Promise<AudioBuffer | null> => {
|
||||
const ctx = audioContextRef.current;
|
||||
if (!ctx) return null;
|
||||
|
||||
// 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 resolvedPath = resolvePath(path);
|
||||
|
||||
const primarySrc = resolvePath(path);
|
||||
const audio = new Audio(primarySrc);
|
||||
audio.volume = effectiveVolume;
|
||||
// Check cache
|
||||
if (bufferCache[resolvedPath]) {
|
||||
return bufferCache[resolvedPath];
|
||||
}
|
||||
|
||||
const playPromise = audio.play();
|
||||
try {
|
||||
const response = await fetch(resolvedPath);
|
||||
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const decodedBuffer = await ctx.decodeAudioData(arrayBuffer);
|
||||
|
||||
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));
|
||||
// Store in cache
|
||||
bufferCache[resolvedPath] = decodedBuffer;
|
||||
return decodedBuffer;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load audio: ${path}`, error);
|
||||
return null;
|
||||
}
|
||||
}, [resolvePath]);
|
||||
|
||||
// Play SFX
|
||||
const playSfx = useCallback(async (path: string, fallbackPath?: string) => {
|
||||
// Early exit if essentially muted
|
||||
if (isMuted || (masterVolume * sfxVolume) <= 0) return;
|
||||
|
||||
const ctx = audioContextRef.current;
|
||||
const sfxGain = sfxGainNodeRef.current;
|
||||
|
||||
if (!ctx || !sfxGain) return;
|
||||
|
||||
// Ensure context is running
|
||||
if (ctx.state === 'suspended') {
|
||||
try {
|
||||
await ctx.resume();
|
||||
} catch (e) {
|
||||
// If this fails (e.g. no user gesture yet), we can't play
|
||||
console.warn('AudioContext suspended, cannot play SFX');
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
let buffer = await getAudioBuffer(path);
|
||||
|
||||
if (!buffer && fallbackPath) {
|
||||
console.warn(`Primary SFX failed: ${path}, trying fallback: ${fallbackPath}`);
|
||||
buffer = await getAudioBuffer(fallbackPath);
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(sfxGain);
|
||||
source.start(0);
|
||||
}
|
||||
}, [isMuted, masterVolume, sfxVolume, getAudioBuffer]);
|
||||
|
||||
return (
|
||||
<AudioContext.Provider value={{
|
||||
@@ -99,7 +195,9 @@ export const AudioProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
setMusicVolume,
|
||||
setSfxVolume,
|
||||
setIsMuted,
|
||||
playSfx
|
||||
playSfx,
|
||||
audioContext,
|
||||
getAudioBuffer
|
||||
}}>
|
||||
{children}
|
||||
</AudioContext.Provider>
|
||||
|
||||
@@ -82,7 +82,15 @@
|
||||
"durability": "Durability",
|
||||
"noItemsFound": "No items found in this category",
|
||||
"levelDifferenceTooHigh": "Level difference too high",
|
||||
"areaTooSafeForPvP": "Area too safe for PvP"
|
||||
"areaTooSafeForPvP": "Area too safe for PvP",
|
||||
"cures": "Cures",
|
||||
"effects": {
|
||||
"regeneration": "Regeneration",
|
||||
"bleeding": "Bleeding",
|
||||
"burning": "Burning",
|
||||
"poisoned": "Poisoned"
|
||||
},
|
||||
"effectAlreadyActive": "Effect already active"
|
||||
},
|
||||
"location": {
|
||||
"recentActivity": "📜 Recent Activity",
|
||||
@@ -179,12 +187,16 @@
|
||||
"turnTimer": "Turn Timer",
|
||||
"actions": {
|
||||
"attack": "Attack",
|
||||
"defend": "Defend",
|
||||
"flee": "Flee",
|
||||
"supplies": "Supplies",
|
||||
"useItem": "Use Item"
|
||||
},
|
||||
"status": {
|
||||
"attacking": "Attacking...",
|
||||
"defending": "Bracing for impact...",
|
||||
"fleeing": "Fleeing...",
|
||||
"usingItem": "Using item...",
|
||||
"waiting": "Waiting for opponent..."
|
||||
},
|
||||
"events": {
|
||||
@@ -193,7 +205,11 @@
|
||||
"playerMiss": "You missed!",
|
||||
"enemyMiss": "Enemy missed!",
|
||||
"armorAbsorbed": "Armor absorbed {{armor}} damage",
|
||||
"itemBroke": "{{item}} broke!"
|
||||
"itemBroke": "{{item}} broke!",
|
||||
"defendSuccess": "You brace yourself, reducing incoming damage!",
|
||||
"damageReduced": "Defending! Damage reduced by {{reduction}}%",
|
||||
"itemUsed": "Used {{item}}{{effects}}",
|
||||
"itemDamage": "{{item}} deals {{damage}} damage!"
|
||||
},
|
||||
"log": {
|
||||
"combat_start": "Combat started!",
|
||||
@@ -207,7 +223,17 @@
|
||||
"enemy_miss": "Enemy missed!",
|
||||
"item_broken": "Your {{item}} broke!",
|
||||
"xp_gain": "You gained {{xp}} XP!",
|
||||
"flee_success": "You managed to escape!"
|
||||
"flee_success": "You managed to escape!",
|
||||
"defend": "You brace for impact!",
|
||||
"item_used": "Used {{item}}",
|
||||
"effect_applied": "Applied {{effect}} to {{target}}",
|
||||
"item_damage": "{{item}} deals {{damage}} damage!",
|
||||
"damage_reduced": "Damage reduced by {{reduction}}%"
|
||||
},
|
||||
"modal": {
|
||||
"supplies_title": "Combat Supplies",
|
||||
"no_combat_items": "No combat items available",
|
||||
"search_items": "Search items..."
|
||||
}
|
||||
},
|
||||
"equipment": {
|
||||
@@ -271,6 +297,13 @@
|
||||
"enemyDespawned": "A wandering enemy has left the area",
|
||||
"corpsesDecayed": "{{count}} corpses have decayed",
|
||||
"itemsDecayed": "{{count}} dropped items have decayed",
|
||||
"statusDamage": "You took {{damage}} damage from status effects",
|
||||
"statusHeal": "You recovered {{heal}} HP from status effects",
|
||||
"diedStatus": "You died from status effects",
|
||||
"wanderingEnemyAppeared": "A wandering enemy left the area",
|
||||
"staminaRegenerated": "Stamina regenerated",
|
||||
"combatTimeout": "⏱️ Turn skipped due to timeout!",
|
||||
"interactableReady": "{{action}} is ready on {{name}}",
|
||||
"waitBeforeMovingSimple": "Wait {{seconds}}s before moving"
|
||||
},
|
||||
"directions": {
|
||||
|
||||
@@ -80,7 +80,15 @@
|
||||
"durability": "Durabilidad",
|
||||
"noItemsFound": "No se encontraron objetos en esta categoría",
|
||||
"levelDifferenceTooHigh": "Nivel demasiado alto",
|
||||
"areaTooSafeForPvP": "Área demasiado segura para PvP"
|
||||
"areaTooSafeForPvP": "Área demasiado segura para PvP",
|
||||
"cures": "Cura",
|
||||
"effects": {
|
||||
"regeneration": "Regeneración",
|
||||
"bleeding": "Sangrado",
|
||||
"burning": "Quemadura",
|
||||
"poisoned": "Envenenamiento"
|
||||
},
|
||||
"effectAlreadyActive": "Efecto ya activo"
|
||||
},
|
||||
"location": {
|
||||
"recentActivity": "📜 Actividad Reciente",
|
||||
@@ -177,12 +185,16 @@
|
||||
},
|
||||
"actions": {
|
||||
"attack": "Atacar",
|
||||
"defend": "Defender",
|
||||
"flee": "Huir",
|
||||
"supplies": "Suministros",
|
||||
"useItem": "Usar Objeto"
|
||||
},
|
||||
"status": {
|
||||
"attacking": "Atacando...",
|
||||
"defending": "Preparándose...",
|
||||
"fleeing": "Huyendo...",
|
||||
"usingItem": "Usando objeto...",
|
||||
"waiting": "Esperando al oponente..."
|
||||
},
|
||||
"events": {
|
||||
@@ -191,7 +203,11 @@
|
||||
"playerMiss": "¡Fallaste!",
|
||||
"enemyMiss": "¡El enemigo falló!",
|
||||
"armorAbsorbed": "La armadura absorbió {{armor}} de daño",
|
||||
"itemBroke": "¡{{item}} se rompió!"
|
||||
"itemBroke": "¡{{item}} se rompió!",
|
||||
"defendSuccess": "¡Te preparas para resistir, reduciendo el daño recibido!",
|
||||
"damageReduced": "¡Defendiendo! Daño reducido en {{reduction}}%",
|
||||
"itemUsed": "Usaste {{item}}{{effects}}",
|
||||
"itemDamage": "{{item}} inflige {{damage}} de daño!"
|
||||
},
|
||||
"log": {
|
||||
"combat_start": "¡Combate iniciado!",
|
||||
@@ -205,7 +221,17 @@
|
||||
"enemy_miss": "¡El enemigo falló!",
|
||||
"item_broken": "¡Tu {{item}} se rompió!",
|
||||
"flee_success": "¡Lograste escapar!",
|
||||
"flee_fail": "¡No pudiste escapar!"
|
||||
"flee_fail": "¡No pudiste escapar!",
|
||||
"defend": "¡Te preparas para el impacto!",
|
||||
"item_used": "Usaste {{item}}",
|
||||
"effect_applied": "Aplicado {{effect}} a {{target}}",
|
||||
"item_damage": "{{item}} inflige {{damage}} de daño!",
|
||||
"damage_reduced": "Daño reducido en {{reduction}}%"
|
||||
},
|
||||
"modal": {
|
||||
"supplies_title": "Suministros de Combate",
|
||||
"no_combat_items": "No hay objetos de combate disponibles",
|
||||
"search_items": "Buscar objetos..."
|
||||
}
|
||||
},
|
||||
"equipment": {
|
||||
@@ -268,7 +294,14 @@
|
||||
"enemyAppeared": "¡Un {{name}} ha aparecido!",
|
||||
"enemyDespawned": "Un enemigo errante ha abandonado el área",
|
||||
"corpsesDecayed": "{{count}} cadáveres se han descompuesto",
|
||||
"itemsDecayed": "{{count}} objetos caídos se han descompuesto",
|
||||
"itemsDecayed": "{{count}} objeto(s) tirado(s) se han descompuesto",
|
||||
"statusDamage": "Has recibido {{damage}} de daño por efectos de estado",
|
||||
"statusHeal": "Has recuperado {{heal}} PS por efectos de estado",
|
||||
"diedStatus": "Has muerto debido a efectos de estado",
|
||||
"wanderingEnemyAppeared": "¡Un enemigo errante abandonó el área",
|
||||
"staminaRegenerated": "Estamina regenerada",
|
||||
"combatTimeout": "⏱️ ¡Turno saltado por tiempo agotado!",
|
||||
"interactableReady": "{{action}} está listo en {{name}}",
|
||||
"waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte"
|
||||
},
|
||||
"directions": {
|
||||
|
||||