Update
223
api/database.py
@@ -329,6 +329,18 @@ character_quests = Table(
|
|||||||
UniqueConstraint("character_id", "quest_id", name="uix_char_quest")
|
UniqueConstraint("character_id", "quest_id", name="uix_char_quest")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Quests: Character History
|
||||||
|
character_quest_history = Table(
|
||||||
|
"character_quest_history",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("character_id", Integer, ForeignKey("characters.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
Column("quest_id", String, nullable=False),
|
||||||
|
Column("started_at", Float, nullable=False),
|
||||||
|
Column("completed_at", Float, default=lambda: time.time()),
|
||||||
|
Column("rewards", JSON, default={}),
|
||||||
|
)
|
||||||
|
|
||||||
# Quests: Global progress
|
# Quests: Global progress
|
||||||
global_quests = Table(
|
global_quests = Table(
|
||||||
"global_quests",
|
"global_quests",
|
||||||
@@ -405,6 +417,8 @@ async def init_db():
|
|||||||
# Quests
|
# Quests
|
||||||
"CREATE INDEX IF NOT EXISTS idx_character_quests_char ON character_quests(character_id);",
|
"CREATE INDEX IF NOT EXISTS idx_character_quests_char ON character_quests(character_id);",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_character_quests_status ON character_quests(status);",
|
"CREATE INDEX IF NOT EXISTS idx_character_quests_status ON character_quests(status);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_character_quest_history_char ON character_quest_history(character_id);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_character_quest_history_completed ON character_quest_history(completed_at);",
|
||||||
|
|
||||||
# Merchant Stock
|
# Merchant Stock
|
||||||
"CREATE INDEX IF NOT EXISTS idx_merchant_stock_npc ON merchant_stock(npc_id);",
|
"CREATE INDEX IF NOT EXISTS idx_merchant_stock_npc ON merchant_stock(npc_id);",
|
||||||
@@ -751,7 +765,9 @@ async def get_character_quests(character_id: int) -> List[Dict[str, Any]]:
|
|||||||
"""Get all quests for a character"""
|
"""Get all quests for a character"""
|
||||||
async with DatabaseSession() as session:
|
async with DatabaseSession() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(character_quests).where(character_quests.c.character_id == character_id)
|
select(character_quests)
|
||||||
|
.where(character_quests.c.character_id == character_id)
|
||||||
|
.order_by(character_quests.c.started_at.desc())
|
||||||
)
|
)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
return [dict(row._mapping) for row in rows]
|
return [dict(row._mapping) for row in rows]
|
||||||
@@ -799,61 +815,139 @@ async def accept_quest(character_id: int, quest_id: str) -> Dict[str, Any]:
|
|||||||
started_at=time.time(),
|
started_at=time.time(),
|
||||||
times_completed=0
|
times_completed=0
|
||||||
).returning(character_quests)
|
).returning(character_quests)
|
||||||
|
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
row = result.first()
|
row = result.first()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return dict(row._mapping) if row else None
|
return dict(row._mapping) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: Optional[str] = None) -> bool:
|
async def delete_character_quest(character_id: int, quest_id: str) -> bool:
|
||||||
|
"""Delete a character quest (used when completing or abandoning)"""
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
stmt = delete(character_quests).where(
|
||||||
|
and_(
|
||||||
|
character_quests.c.character_id == character_id,
|
||||||
|
character_quests.c.quest_id == quest_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def update_quest_progress(character_id: int, quest_id: str, progress: Dict, status: str = "active") -> bool:
|
||||||
"""Update quest progress"""
|
"""Update quest progress"""
|
||||||
values = {"progress": progress}
|
async with DatabaseSession() as session:
|
||||||
if status:
|
# Check if we need to update timestamp
|
||||||
values["status"] = status
|
values = {
|
||||||
|
"progress": progress,
|
||||||
|
"status": status
|
||||||
|
}
|
||||||
|
|
||||||
if status == "completed":
|
if status == "completed":
|
||||||
values["completed_at"] = time.time()
|
values["completed_at"] = time.time()
|
||||||
values["last_completed_at"] = time.time()
|
values["last_completed_at"] = time.time()
|
||||||
# Increment times_completed
|
# Increment times_completed
|
||||||
# We need to read first or use a raw update expression,
|
# We need to do this carefully atomically or just fetch-update
|
||||||
# simplest is to just increment in python for now or assume caller logic handles it
|
# Doing fetch-update for simplicity as we are inside transaction block if we used one,
|
||||||
# But let's do it right:
|
# but DatabaseSession is per-call here.
|
||||||
# We can't easily do col + 1 in a simple update call without pulling in Table object to the values
|
|
||||||
# So we'll rely on a fetch-update pattern or standard SQL
|
# Using specific update to increment
|
||||||
pass
|
stmt = update(character_quests).where(
|
||||||
|
and_(
|
||||||
async with DatabaseSession() as session:
|
character_quests.c.character_id == character_id,
|
||||||
# If completing, increment counter
|
character_quests.c.quest_id == quest_id
|
||||||
if status == "completed":
|
)
|
||||||
# We can use the column expression for atomic increment
|
).values(**values)
|
||||||
values["times_completed"] = character_quests.c.times_completed + 1
|
|
||||||
|
# Also increment times_completed separately to avoid overwrite race with simple values
|
||||||
|
stmt2 = update(character_quests).where(
|
||||||
|
and_(
|
||||||
|
character_quests.c.character_id == character_id,
|
||||||
|
character_quests.c.quest_id == quest_id
|
||||||
|
)
|
||||||
|
).values(times_completed=character_quests.c.times_completed + 1)
|
||||||
|
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.execute(stmt2)
|
||||||
|
else:
|
||||||
|
stmt = update(character_quests).where(
|
||||||
|
and_(
|
||||||
|
character_quests.c.character_id == character_id,
|
||||||
|
character_quests.c.quest_id == quest_id
|
||||||
|
)
|
||||||
|
).values(**values)
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
stmt = update(character_quests).where(
|
|
||||||
and_(
|
|
||||||
character_quests.c.character_id == character_id,
|
|
||||||
character_quests.c.quest_id == quest_id
|
|
||||||
)
|
|
||||||
).values(**values)
|
|
||||||
await session.execute(stmt)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def set_quest_cooldown(character_id: int, quest_id: str, cooldown_expires_at: float) -> bool:
|
async def set_quest_cooldown(character_id: int, quest_id: str, expires_at: float) -> bool:
|
||||||
"""Set cooldown for a quest"""
|
"""Set cooldown for a repeatable quest"""
|
||||||
async with DatabaseSession() as session:
|
async with DatabaseSession() as session:
|
||||||
stmt = update(character_quests).where(
|
stmt = update(character_quests).where(
|
||||||
and_(
|
and_(
|
||||||
character_quests.c.character_id == character_id,
|
character_quests.c.character_id == character_id,
|
||||||
character_quests.c.quest_id == quest_id
|
character_quests.c.quest_id == quest_id
|
||||||
)
|
)
|
||||||
).values(cooldown_expires_at=cooldown_expires_at)
|
).values(cooldown_expires_at=expires_at)
|
||||||
await session.execute(stmt)
|
await session.execute(stmt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# Global Quests
|
async def log_quest_completion(character_id: int, quest_id: str, started_at: float, rewards: Dict) -> bool:
|
||||||
|
"""Log a quest completion to history"""
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
stmt = insert(character_quest_history).values(
|
||||||
|
character_id=character_id,
|
||||||
|
quest_id=quest_id,
|
||||||
|
started_at=started_at,
|
||||||
|
completed_at=time.time(),
|
||||||
|
rewards=rewards
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_quest_history(character_id: int, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||||
|
"""Get quest history with pagination"""
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
# Get total count
|
||||||
|
count_stmt = select(character_quest_history.c.id).where(
|
||||||
|
character_quest_history.c.character_id == character_id
|
||||||
|
)
|
||||||
|
count_result = await session.execute(count_stmt)
|
||||||
|
total_count = len(count_result.fetchall())
|
||||||
|
|
||||||
|
# Get paged results
|
||||||
|
stmt = select(character_quest_history).where(
|
||||||
|
character_quest_history.c.character_id == character_id
|
||||||
|
).order_by(
|
||||||
|
character_quest_history.c.completed_at.desc()
|
||||||
|
).offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
rows = result.fetchall()
|
||||||
|
data = [dict(row._mapping) for row in rows]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": data,
|
||||||
|
"total": total_count,
|
||||||
|
"page": page,
|
||||||
|
"pages": (total_count + page_size - 1) // page_size
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# GLOBAL QUEST OPERATIONS
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
|
async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get global quest progress"""
|
"""Get global quest progress"""
|
||||||
async with DatabaseSession() as session:
|
async with DatabaseSession() as session:
|
||||||
@@ -864,34 +958,63 @@ async def get_global_quest(quest_id: str) -> Optional[Dict[str, Any]]:
|
|||||||
return dict(row._mapping) if row else None
|
return dict(row._mapping) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def update_global_quest(quest_id: str, progress: Dict, is_completed: bool = False) -> bool:
|
async def update_global_quest(quest_id: str, progress: Dict) -> bool:
|
||||||
"""Update or create global quest progress"""
|
"""Update global quest progress"""
|
||||||
# Upsert logic
|
|
||||||
existing = await get_global_quest(quest_id)
|
|
||||||
|
|
||||||
async with DatabaseSession() as session:
|
async with DatabaseSession() as session:
|
||||||
if existing:
|
# Upsert
|
||||||
stmt = update(global_quests).where(
|
existing = await session.execute(
|
||||||
global_quests.c.quest_id == quest_id
|
select(global_quests).where(global_quests.c.quest_id == quest_id)
|
||||||
).values(
|
)
|
||||||
global_progress=progress,
|
if existing.first():
|
||||||
is_completed=is_completed,
|
stmt = update(global_quests).where(
|
||||||
updated_at=time.time()
|
global_quests.c.quest_id == quest_id
|
||||||
)
|
).values(
|
||||||
|
global_progress=progress,
|
||||||
|
updated_at=time.time()
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
stmt = insert(global_quests).values(
|
stmt = insert(global_quests).values(
|
||||||
quest_id=quest_id,
|
quest_id=quest_id,
|
||||||
global_progress=progress,
|
global_progress=progress,
|
||||||
is_completed=is_completed,
|
updated_at=time.time()
|
||||||
updated_at=time.time()
|
)
|
||||||
)
|
|
||||||
|
|
||||||
await session.execute(stmt)
|
await session.execute(stmt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ========================================================================
|
async def get_completed_global_quests() -> List[str]:
|
||||||
|
"""Get list of IDs of all completed global quests"""
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(global_quests.c.quest_id).where(global_quests.c.is_completed == True)
|
||||||
|
)
|
||||||
|
return [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_global_quest_completed(quest_id: str) -> bool:
|
||||||
|
"""Mark a global quest as completed"""
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
stmt = update(global_quests).where(
|
||||||
|
global_quests.c.quest_id == quest_id
|
||||||
|
).values(
|
||||||
|
is_completed=True,
|
||||||
|
updated_at=time.time()
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_quest_participants(quest_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all characters who have this quest active or completed"""
|
||||||
|
async with DatabaseSession() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(character_quests).where(character_quests.c.quest_id == quest_id)
|
||||||
|
)
|
||||||
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
# MERCHANT OPERATIONS
|
# MERCHANT OPERATIONS
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
||||||
|
|||||||
32
api/main.py
@@ -88,6 +88,26 @@ async def lifespan(app: FastAPI):
|
|||||||
print(f"✅ Started {len(tasks)} background tasks in this worker")
|
print(f"✅ Started {len(tasks)} background tasks in this worker")
|
||||||
else:
|
else:
|
||||||
print("⏭️ Background tasks running in another worker")
|
print("⏭️ Background tasks running in another worker")
|
||||||
|
|
||||||
|
# APPLY GLOBAL QUEST UNLOCKS
|
||||||
|
print("🔓 Applying global quest unlocks...")
|
||||||
|
try:
|
||||||
|
completed_quests = await db.get_completed_global_quests()
|
||||||
|
print(f" - Found {len(completed_quests)} completed global quests")
|
||||||
|
for quest_id in completed_quests:
|
||||||
|
# Unlock locations
|
||||||
|
for loc in LOCATIONS.values():
|
||||||
|
if loc.unlocked_by == quest_id:
|
||||||
|
loc.locked = False
|
||||||
|
print(f" - Unlocked location: {loc.id}")
|
||||||
|
|
||||||
|
# Unlock interactables
|
||||||
|
for inter in loc.interactables:
|
||||||
|
if inter.unlocked_by == quest_id:
|
||||||
|
inter.locked = False
|
||||||
|
print(f" - Unlocked interactable: {inter.id} in {loc.id}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to apply global quest unlocks: {e}")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -150,6 +170,16 @@ try:
|
|||||||
NPCS_DATA = n_data.get("static_npcs", {})
|
NPCS_DATA = n_data.get("static_npcs", {})
|
||||||
print(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
|
print(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
|
||||||
|
|
||||||
|
# Load Enemies / Other NPCs
|
||||||
|
enemies_path = Path("./gamedata/npcs.json")
|
||||||
|
if enemies_path.exists():
|
||||||
|
with open(enemies_path, "r") as f:
|
||||||
|
e_data = json.load(f)
|
||||||
|
enemies = e_data.get("npcs", {})
|
||||||
|
# Merge into NPCS_DATA
|
||||||
|
NPCS_DATA.update(enemies)
|
||||||
|
print(f"✅ Loaded {len(enemies)} enemies/NPCs")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error loading game data: {e}")
|
print(f"❌ Error loading game data: {e}")
|
||||||
|
|
||||||
@@ -161,7 +191,7 @@ crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
|||||||
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||||
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||||
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
|
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
|
||||||
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA)
|
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)
|
||||||
trade.init_router_dependencies(ITEMS_MANAGER, NPCS_DATA)
|
trade.init_router_dependencies(ITEMS_MANAGER, NPCS_DATA)
|
||||||
npcs.init_router_dependencies()
|
npcs.init_router_dependencies()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from ..services.helpers import get_game_message
|
|||||||
from ..core.security import create_access_token, hash_password, verify_password, get_current_user
|
from ..core.security import create_access_token, hash_password, verify_password, get_current_user
|
||||||
from ..services.models import UserRegister, UserLogin
|
from ..services.models import UserRegister, UserLogin
|
||||||
from .. import database as db
|
from .. import database as db
|
||||||
|
from ..items import items_manager
|
||||||
|
from ..services.helpers import calculate_player_capacity, enrich_character_data
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||||
|
|
||||||
@@ -110,23 +112,7 @@ async def login(user: UserLogin):
|
|||||||
"is_premium": account.get("premium_expires_at") is not None,
|
"is_premium": account.get("premium_expires_at") is not None,
|
||||||
},
|
},
|
||||||
"characters": [
|
"characters": [
|
||||||
{
|
await enrich_character_data(char, items_manager)
|
||||||
"id": char["id"],
|
|
||||||
"name": char["name"],
|
|
||||||
"level": char["level"],
|
|
||||||
"xp": char["xp"],
|
|
||||||
"hp": char["hp"],
|
|
||||||
"max_hp": char["max_hp"],
|
|
||||||
"stamina": char["stamina"],
|
|
||||||
"max_stamina": char["max_stamina"],
|
|
||||||
"strength": char["strength"],
|
|
||||||
"agility": char["agility"],
|
|
||||||
"endurance": char["endurance"],
|
|
||||||
"intellect": char["intellect"],
|
|
||||||
"avatar_data": char.get("avatar_data"),
|
|
||||||
"last_played_at": char.get("last_played_at"),
|
|
||||||
"location_id": char["location_id"],
|
|
||||||
}
|
|
||||||
for char in characters
|
for char in characters
|
||||||
],
|
],
|
||||||
"needs_character_creation": len(characters) == 0
|
"needs_character_creation": len(characters) == 0
|
||||||
@@ -188,17 +174,7 @@ async def get_account(current_user: Dict[str, Any] = Depends(get_current_user)):
|
|||||||
"last_login_at": account.get("last_login_at"),
|
"last_login_at": account.get("last_login_at"),
|
||||||
},
|
},
|
||||||
"characters": [
|
"characters": [
|
||||||
{
|
await enrich_character_data(char, items_manager)
|
||||||
"id": char["id"],
|
|
||||||
"name": char["name"],
|
|
||||||
"level": char["level"],
|
|
||||||
"xp": char["xp"],
|
|
||||||
"hp": char["hp"],
|
|
||||||
"max_hp": char["max_hp"],
|
|
||||||
"location_id": char["location_id"],
|
|
||||||
"avatar_data": char.get("avatar_data"),
|
|
||||||
"last_played_at": char.get("last_played_at"),
|
|
||||||
}
|
|
||||||
for char in characters
|
for char in characters
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -373,17 +349,7 @@ async def steam_login(steam_data: Dict[str, Any]):
|
|||||||
"last_login_at": account.get("last_login_at")
|
"last_login_at": account.get("last_login_at")
|
||||||
},
|
},
|
||||||
"characters": [
|
"characters": [
|
||||||
{
|
await enrich_character_data(char, items_manager)
|
||||||
"id": char["id"],
|
|
||||||
"name": char["name"],
|
|
||||||
"level": char["level"],
|
|
||||||
"xp": char["xp"],
|
|
||||||
"hp": char["hp"],
|
|
||||||
"max_hp": char["max_hp"],
|
|
||||||
"location_id": char["location_id"],
|
|
||||||
"avatar_data": char.get("avatar_data"),
|
|
||||||
"last_played_at": char.get("last_played_at")
|
|
||||||
}
|
|
||||||
for char in characters
|
for char in characters
|
||||||
],
|
],
|
||||||
"needs_character_creation": len(characters) == 0
|
"needs_character_creation": len(characters) == 0
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ Handles character creation, selection, and deletion.
|
|||||||
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
from fastapi import APIRouter, HTTPException, Depends, status, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
from ..services.helpers import get_game_message
|
from ..items import items_manager
|
||||||
|
from ..services.helpers import enrich_character_data, get_game_message
|
||||||
|
|
||||||
from ..core.security import decode_token, create_access_token, security
|
from ..core.security import decode_token, create_access_token, security
|
||||||
from ..services.models import CharacterCreate, CharacterSelect
|
from ..services.models import CharacterCreate, CharacterSelect
|
||||||
@@ -13,7 +14,6 @@ from .. import database as db
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/characters", tags=["characters"])
|
router = APIRouter(prefix="/api/characters", tags=["characters"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||||
"""List all characters for the logged-in account"""
|
"""List all characters for the logged-in account"""
|
||||||
@@ -31,20 +31,7 @@ async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(se
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"characters": [
|
"characters": [
|
||||||
{
|
await enrich_character_data(char, items_manager)
|
||||||
"id": char["id"],
|
|
||||||
"name": char["name"],
|
|
||||||
"level": char["level"],
|
|
||||||
"xp": char["xp"],
|
|
||||||
"hp": char["hp"],
|
|
||||||
"max_hp": char["max_hp"],
|
|
||||||
"stamina": char["stamina"],
|
|
||||||
"max_stamina": char["max_stamina"],
|
|
||||||
"avatar_data": char.get("avatar_data"),
|
|
||||||
"location_id": char["location_id"],
|
|
||||||
"created_at": char["created_at"],
|
|
||||||
"last_played_at": char.get("last_played_at"),
|
|
||||||
}
|
|
||||||
for char in characters
|
for char in characters
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,9 +464,12 @@ async def combat_action(
|
|||||||
new_progress[obj['target']] = current_count + 1
|
new_progress[obj['target']] = current_count + 1
|
||||||
progres_changed = True
|
progres_changed = True
|
||||||
|
|
||||||
|
|
||||||
if progres_changed:
|
if progres_changed:
|
||||||
# Check completion
|
# Check completion
|
||||||
all_done = True
|
all_done = True
|
||||||
|
progress_str = ""
|
||||||
|
|
||||||
for obj in objectives:
|
for obj in objectives:
|
||||||
target = obj['target']
|
target = obj['target']
|
||||||
req_count = obj['count']
|
req_count = obj['count']
|
||||||
@@ -476,8 +479,10 @@ async def combat_action(
|
|||||||
if obj['type'] == 'kill_count':
|
if obj['type'] == 'kill_count':
|
||||||
if curr < req_count:
|
if curr < req_count:
|
||||||
all_done = False
|
all_done = False
|
||||||
|
# Capture progress string for the notification (if this was the target updated)
|
||||||
|
if target == combat['npc_id']:
|
||||||
|
progress_str = f" ({curr}/{req_count})"
|
||||||
elif obj['type'] == 'item_delivery':
|
elif obj['type'] == 'item_delivery':
|
||||||
# For mixed quests, we can't complete purely on kills.
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active')
|
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active')
|
||||||
@@ -486,7 +491,7 @@ async def combat_action(
|
|||||||
messages.append(create_combat_message(
|
messages.append(create_combat_message(
|
||||||
"quest_update",
|
"quest_update",
|
||||||
origin="system",
|
origin="system",
|
||||||
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
|
message=f"{get_locale_string(q_def['title'], locale)}{progress_str}"
|
||||||
))
|
))
|
||||||
quest_updated = True
|
quest_updated = True
|
||||||
|
|
||||||
@@ -501,62 +506,6 @@ async def combat_action(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update quest progress: {e}")
|
logger.error(f"Failed to update quest progress: {e}")
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|
||||||
for q_record in active_quests:
|
|
||||||
if q_record['status'] != 'active':
|
|
||||||
continue
|
|
||||||
|
|
||||||
q_def = all_quests_def.get(q_record['quest_id'])
|
|
||||||
if not q_def: continue
|
|
||||||
|
|
||||||
objectives = q_def.get('objectives', [])
|
|
||||||
current_progress = q_record.get('progress') or {}
|
|
||||||
new_progress = current_progress.copy()
|
|
||||||
progres_changed = False
|
|
||||||
|
|
||||||
for obj in objectives:
|
|
||||||
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
|
||||||
current_count = current_progress.get(obj['target'], 0)
|
|
||||||
if current_count < obj['count']:
|
|
||||||
new_progress[obj['target']] = current_count + 1
|
|
||||||
progres_changed = True
|
|
||||||
|
|
||||||
if progres_changed:
|
|
||||||
# Check completion
|
|
||||||
all_done = True
|
|
||||||
for obj in objectives:
|
|
||||||
target = obj['target']
|
|
||||||
req_count = obj['count']
|
|
||||||
curr = new_progress.get(target, 0)
|
|
||||||
|
|
||||||
# Simple check for now (ignoring items for kill quests)
|
|
||||||
if obj['type'] == 'kill_count':
|
|
||||||
if curr < req_count:
|
|
||||||
all_done = False
|
|
||||||
elif obj['type'] == 'item_delivery':
|
|
||||||
# Items are checked at hand-in usually
|
|
||||||
# But we need to know if we should mark as completed "ready to turn in" or just "objectives met"
|
|
||||||
# For mixed quests, we can't complete purely on kills.
|
|
||||||
# Let's just update progress.
|
|
||||||
pass
|
|
||||||
|
|
||||||
# We generally don't auto-complete quests, user has to hand in.
|
|
||||||
# But we can update the progress in DB.
|
|
||||||
new_status = 'active'
|
|
||||||
# If we wanted to support auto-complete, we'd do it here. Use 'active'.
|
|
||||||
|
|
||||||
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, new_status)
|
|
||||||
|
|
||||||
# Notify user
|
|
||||||
messages.append(create_combat_message(
|
|
||||||
"quest_update",
|
|
||||||
origin="system",
|
|
||||||
message=f"Quest updated: {get_locale_string(q_def['title'], locale)}"
|
|
||||||
))
|
|
||||||
quest_updated = True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update quest progress: {e}")
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -502,6 +502,10 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
|
|||||||
# Format interactables for response with cooldown info
|
# Format interactables for response with cooldown info
|
||||||
interactables_data = []
|
interactables_data = []
|
||||||
for interactable in location.interactables:
|
for interactable in location.interactables:
|
||||||
|
# Check if locked
|
||||||
|
if getattr(interactable, 'locked', False):
|
||||||
|
continue
|
||||||
|
|
||||||
actions_data = []
|
actions_data = []
|
||||||
for action in interactable.actions:
|
for action in interactable.actions:
|
||||||
# Check cooldown status for this specific action
|
# Check cooldown status for this specific action
|
||||||
@@ -556,6 +560,10 @@ async def get_current_location(request: Request, current_user: dict = Depends(ge
|
|||||||
destination_id = location.exits[direction]
|
destination_id = location.exits[direction]
|
||||||
destination_loc = LOCATIONS.get(destination_id)
|
destination_loc = LOCATIONS.get(destination_id)
|
||||||
|
|
||||||
|
# Check if destination is locked
|
||||||
|
if destination_loc and getattr(destination_loc, 'locked', False):
|
||||||
|
continue
|
||||||
|
|
||||||
if destination_loc:
|
if destination_loc:
|
||||||
# Calculate real distance using coordinates
|
# Calculate real distance using coordinates
|
||||||
distance = calculate_distance(
|
distance = calculate_distance(
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ from typing import Dict, List, Any, Optional
|
|||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from ..core.websockets import manager
|
||||||
from ..core.security import get_current_user
|
from ..core.security import get_current_user
|
||||||
from .. import database as db
|
from .. import database as db
|
||||||
from .. import game_logic
|
from .. import game_logic
|
||||||
from ..items import ItemsManager
|
from ..items import ItemsManager
|
||||||
|
from ..services.helpers import get_locale_string
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/quests",
|
prefix="/api/quests",
|
||||||
@@ -14,19 +16,110 @@ router = APIRouter(
|
|||||||
responses={404: {"description": "Not found"}},
|
responses={404: {"description": "Not found"}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Request Models
|
||||||
|
class HistoryParams:
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 20
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
QUESTS_DATA = {}
|
QUESTS_DATA = {}
|
||||||
NPCS_DATA = {}
|
NPCS_DATA = {}
|
||||||
|
LOCATIONS_DATA = {}
|
||||||
|
|
||||||
def init_router_dependencies(items_manager: ItemsManager, quests_data=None, npcs_data=None):
|
def init_router_dependencies(items_manager: ItemsManager, quests_data=None, npcs_data=None, locations_data=None):
|
||||||
global ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA
|
global ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS_DATA
|
||||||
ITEMS_MANAGER = items_manager
|
ITEMS_MANAGER = items_manager
|
||||||
if quests_data:
|
if quests_data:
|
||||||
QUESTS_DATA = quests_data
|
QUESTS_DATA = quests_data
|
||||||
if npcs_data:
|
if npcs_data:
|
||||||
NPCS_DATA = npcs_data
|
NPCS_DATA = npcs_data
|
||||||
|
if locations_data:
|
||||||
|
LOCATIONS_DATA = locations_data
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_quest_history_endpoint(
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get completed quest history with pagination"""
|
||||||
|
character_id = current_user['id']
|
||||||
|
history = await db.get_quest_history(character_id, page=page, page_size=limit)
|
||||||
|
|
||||||
|
# Enrich with quest definitions
|
||||||
|
enriched_data = []
|
||||||
|
for entry in history['data']:
|
||||||
|
quest_def = QUESTS_DATA.get(entry['quest_id'])
|
||||||
|
if quest_def:
|
||||||
|
# Merge entry data with quest def
|
||||||
|
item = dict(entry)
|
||||||
|
item['title'] = quest_def.get('title')
|
||||||
|
item['description'] = quest_def.get('description')
|
||||||
|
item['type'] = quest_def.get('type')
|
||||||
|
item['objectives'] = quest_def.get('objectives') # Fix: Copy objectives
|
||||||
|
|
||||||
|
# Enrich with giver info
|
||||||
|
if quest_def.get('giver_id'):
|
||||||
|
giver = NPCS_DATA.get(quest_def['giver_id'])
|
||||||
|
if giver:
|
||||||
|
item['giver_name'] = giver.get('name')
|
||||||
|
item['giver_image'] = giver.get('image')
|
||||||
|
|
||||||
|
# Get Location Name
|
||||||
|
if giver.get('location_id'):
|
||||||
|
loc = LOCATIONS_DATA.get(giver['location_id'])
|
||||||
|
if loc:
|
||||||
|
item['giver_location_name'] = loc.name
|
||||||
|
else:
|
||||||
|
item['giver_location_name'] = giver['location_id']
|
||||||
|
|
||||||
|
enriched_data.append(item)
|
||||||
|
else:
|
||||||
|
# Fallback if quest def removed?
|
||||||
|
enriched_data.append(entry)
|
||||||
|
|
||||||
|
# 2nd pass: Enrich objectives and rewards for all items in enriched_data
|
||||||
|
final_data = []
|
||||||
|
for q_data in enriched_data:
|
||||||
|
# ENRICH OBJECTIVES WITH NAMES
|
||||||
|
if 'objectives' in q_data:
|
||||||
|
enriched_objs = []
|
||||||
|
for obj in q_data['objectives']:
|
||||||
|
new_obj = dict(obj)
|
||||||
|
target = obj.get('target')
|
||||||
|
if obj.get('type') == 'kill_count':
|
||||||
|
npc = NPCS_DATA.get(target)
|
||||||
|
if npc:
|
||||||
|
new_obj['target_name'] = npc.get('name')
|
||||||
|
else:
|
||||||
|
logger.warning(f"NPC not found for target: {target}")
|
||||||
|
elif obj.get('type') == 'item_delivery':
|
||||||
|
item = ITEMS_MANAGER.get_item(target)
|
||||||
|
if item:
|
||||||
|
new_obj['target_name'] = item.name
|
||||||
|
else:
|
||||||
|
logger.warning(f"Item not found for target: {target}")
|
||||||
|
enriched_objs.append(new_obj)
|
||||||
|
q_data['objectives'] = enriched_objs
|
||||||
|
|
||||||
|
# ENRICH REWARDS WITH NAMES
|
||||||
|
# For history, rewards might be stored in 'rewards' json.
|
||||||
|
if 'rewards' in q_data and 'items' in q_data['rewards']:
|
||||||
|
enriched_items = {}
|
||||||
|
for item_id, qty in q_data['rewards']['items'].items():
|
||||||
|
item = ITEMS_MANAGER.get_item(item_id)
|
||||||
|
name = item.name if item else item_id
|
||||||
|
enriched_items[item_id] = {'qty': qty, 'name': name}
|
||||||
|
|
||||||
|
q_data['reward_items_details'] = enriched_items
|
||||||
|
|
||||||
|
final_data.append(q_data)
|
||||||
|
|
||||||
|
history['data'] = final_data
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
@router.get("/active")
|
@router.get("/active")
|
||||||
async def get_active_quests(current_user: dict = Depends(get_current_user)):
|
async def get_active_quests(current_user: dict = Depends(get_current_user)):
|
||||||
@@ -34,18 +127,8 @@ async def get_active_quests(current_user: dict = Depends(get_current_user)):
|
|||||||
character_id = current_user['id']
|
character_id = current_user['id']
|
||||||
quests = await db.get_character_quests(character_id)
|
quests = await db.get_character_quests(character_id)
|
||||||
|
|
||||||
# Filter for active or completed but not yet turned in?
|
|
||||||
# Usually "active" means in progress.
|
|
||||||
# We want to return detailed info merged with static data
|
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for q in quests:
|
for q in quests:
|
||||||
# If it's a repeatable quest that is on cooldown, maybe don't show it as active?
|
|
||||||
# But we want to show history?
|
|
||||||
# Let's filter by status="active" or "completed" (ready to turn in?)
|
|
||||||
# Wait, if status is "completed", it means it's done.
|
|
||||||
# For repeatable quests, "completed" means it's in cooldown.
|
|
||||||
|
|
||||||
quest_def = QUESTS_DATA.get(q['quest_id'])
|
quest_def = QUESTS_DATA.get(q['quest_id'])
|
||||||
if not quest_def:
|
if not quest_def:
|
||||||
continue
|
continue
|
||||||
@@ -63,6 +146,58 @@ async def get_active_quests(current_user: dict = Depends(get_current_user)):
|
|||||||
else:
|
else:
|
||||||
q_data['on_cooldown'] = False
|
q_data['on_cooldown'] = False
|
||||||
|
|
||||||
|
# Global Quest Progress
|
||||||
|
if quest_def.get('type') == 'global':
|
||||||
|
g_quest = await db.get_global_quest(q['quest_id'])
|
||||||
|
if g_quest:
|
||||||
|
q_data['global_progress'] = g_quest.get('global_progress', {})
|
||||||
|
q_data['global_is_completed'] = g_quest.get('is_completed', False)
|
||||||
|
|
||||||
|
# Enrich with giver info
|
||||||
|
if quest_def.get('giver_id'):
|
||||||
|
giver = NPCS_DATA.get(quest_def['giver_id'])
|
||||||
|
if giver:
|
||||||
|
q_data['giver_name'] = giver.get('name')
|
||||||
|
q_data['giver_image'] = giver.get('image')
|
||||||
|
|
||||||
|
# Get Location Name
|
||||||
|
if giver.get('location_id'):
|
||||||
|
loc = LOCATIONS_DATA.get(giver['location_id'])
|
||||||
|
if loc:
|
||||||
|
q_data['giver_location_name'] = loc.name
|
||||||
|
else:
|
||||||
|
q_data['giver_location_name'] = giver['location_id']
|
||||||
|
|
||||||
|
# ENRICH OBJECTIVES WITH NAMES
|
||||||
|
if 'objectives' in q_data:
|
||||||
|
enriched_objs = []
|
||||||
|
for obj in q_data['objectives']:
|
||||||
|
new_obj = dict(obj)
|
||||||
|
target = obj.get('target')
|
||||||
|
if obj.get('type') == 'kill_count':
|
||||||
|
npc = NPCS_DATA.get(target)
|
||||||
|
if npc:
|
||||||
|
new_obj['target_name'] = npc.get('name')
|
||||||
|
elif obj.get('type') == 'item_delivery':
|
||||||
|
item = ITEMS_MANAGER.get_item(target)
|
||||||
|
if item:
|
||||||
|
new_obj['target_name'] = item.name
|
||||||
|
enriched_objs.append(new_obj)
|
||||||
|
q_data['objectives'] = enriched_objs
|
||||||
|
|
||||||
|
# ENRICH REWARDS WITH NAMES
|
||||||
|
if 'rewards' in q_data and 'items' in q_data['rewards']:
|
||||||
|
enriched_items = {}
|
||||||
|
for item_id, qty in q_data['rewards']['items'].items():
|
||||||
|
item = ITEMS_MANAGER.get_item(item_id)
|
||||||
|
name = item.name if item else item_id
|
||||||
|
enriched_items[item_id] = {'qty': qty, 'name': name}
|
||||||
|
|
||||||
|
# Store back in a way frontend can use, or just replace items dict?
|
||||||
|
# Frontend currently iterates entries of items.
|
||||||
|
# Let's add a new field 'reward_items_details'
|
||||||
|
q_data['reward_items_details'] = enriched_items
|
||||||
|
|
||||||
result.append(q_data)
|
result.append(q_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -194,7 +329,19 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
|
|||||||
|
|
||||||
if inv_item:
|
if inv_item:
|
||||||
available = inv_item['quantity']
|
available = inv_item['quantity']
|
||||||
needed = required_count - current_count
|
needed = required_count - current_count # Personal needed (to match max count)
|
||||||
|
|
||||||
|
# GLOBAL CAP CHECK
|
||||||
|
is_global = quest_def.get('type') == 'global'
|
||||||
|
if is_global:
|
||||||
|
global_quest = await db.get_global_quest(quest_id)
|
||||||
|
global_prog = global_quest.get('global_progress', {}) if global_quest else {}
|
||||||
|
global_current_val = global_prog.get(target, 0)
|
||||||
|
global_remaining = max(0, required_count - global_current_val)
|
||||||
|
|
||||||
|
# Cap needed by global remaining
|
||||||
|
needed = min(needed, global_remaining)
|
||||||
|
|
||||||
to_take = min(available, needed)
|
to_take = min(available, needed)
|
||||||
|
|
||||||
if to_take > 0:
|
if to_take > 0:
|
||||||
@@ -207,13 +354,50 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
|
|||||||
items_deducted.append(f"{target} x{to_take}")
|
items_deducted.append(f"{target} x{to_take}")
|
||||||
|
|
||||||
# Global Quest Logic
|
# Global Quest Logic
|
||||||
if quest_def.get('type') == 'global':
|
if is_global:
|
||||||
# Update global counters
|
# Re-fetch or use existing? We need to be careful with race conditions slightly,
|
||||||
global_quest = await db.get_global_quest(quest_id)
|
# but safe enough for now to just update.
|
||||||
global_prog = global_quest['global_progress'] if global_quest else {}
|
# We already fetched 'global_prog' above.
|
||||||
global_current = global_prog.get(target, 0)
|
|
||||||
global_prog[target] = global_current + to_take
|
# Add contribution
|
||||||
|
new_global = global_current_val + to_take
|
||||||
|
global_prog[target] = new_global
|
||||||
await db.update_global_quest(quest_id, global_prog)
|
await db.update_global_quest(quest_id, global_prog)
|
||||||
|
|
||||||
|
# Check for global completion
|
||||||
|
is_global_complete = True
|
||||||
|
for obj in objectives:
|
||||||
|
t = obj['target']
|
||||||
|
req = obj['count']
|
||||||
|
# Check cached updated prog
|
||||||
|
if global_prog.get(t, 0) < req:
|
||||||
|
is_global_complete = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if is_global_complete:
|
||||||
|
# Finish global quest!
|
||||||
|
await finish_global_quest(quest_id, quest_def)
|
||||||
|
|
||||||
|
# RETURN IMMEDIATELY to prevent double rewards/deletion logic
|
||||||
|
# We construct a success response here.
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Global Quest Completed!",
|
||||||
|
"is_completed": True,
|
||||||
|
"items_deducted": items_deducted,
|
||||||
|
"rewards": ["See Global Rewards"], # Placeholders, real rewards via finish_global_quest/websocket
|
||||||
|
"completion_text": quest_def.get("completion_text", "Global Quest Finished!"),
|
||||||
|
"quest_update": {
|
||||||
|
**quest_def,
|
||||||
|
"quest_id": quest_id,
|
||||||
|
"status": "completed",
|
||||||
|
"progress": updated_progress,
|
||||||
|
"on_cooldown": quest_def.get('repeatable'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Prevent individual completion if global is not done
|
||||||
|
all_completed = False
|
||||||
|
|
||||||
if new_count < required_count:
|
if new_count < required_count:
|
||||||
all_completed = False
|
all_completed = False
|
||||||
@@ -227,23 +411,38 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
|
|||||||
if current_count < required_count:
|
if current_count < required_count:
|
||||||
all_completed = False
|
all_completed = False
|
||||||
|
|
||||||
# Save progress
|
# WEIGHT CHECK FOR REWARDS
|
||||||
status = "active"
|
|
||||||
if all_completed:
|
|
||||||
status = "completed"
|
|
||||||
|
|
||||||
await db.update_quest_progress(character_id, quest_id, updated_progress, status)
|
|
||||||
|
|
||||||
# If completed, giving rewards
|
|
||||||
rewards_msg = []
|
rewards_msg = []
|
||||||
if all_completed:
|
if all_completed:
|
||||||
rewards = quest_def.get('rewards', {})
|
rewards = quest_def.get('rewards', {})
|
||||||
|
reward_weight = 0.0
|
||||||
|
|
||||||
|
if 'items' in rewards:
|
||||||
|
for item_id, qty in rewards['items'].items():
|
||||||
|
item_def = ITEMS_MANAGER.get_item(item_id)
|
||||||
|
if item_def:
|
||||||
|
reward_weight += item_def.weight * qty
|
||||||
|
|
||||||
|
# Calculate current weight
|
||||||
|
# Calculate current weight and capacity
|
||||||
|
from ..services.helpers import calculate_player_capacity
|
||||||
|
inventory = await db.get_inventory(character_id)
|
||||||
|
current_weight, capacity, _, _ = await calculate_player_capacity(inventory, ITEMS_MANAGER)
|
||||||
|
|
||||||
|
if current_weight + reward_weight > capacity:
|
||||||
|
# Rollback? The items for delivery were already removed above!
|
||||||
|
# Ideally we should check weight BEFORE deducting delivery items.
|
||||||
|
# converting this to a "check before action" logic is hard because delivery logic is stateful.
|
||||||
|
# However, delivery items REDUCE weight. So we are likely safe unless rewards are heavier than delivered items.
|
||||||
|
# BUT, if we error here, we technically leave the quest in "partially delivered" state, which is fine.
|
||||||
|
# The user can just clear inventory and try again.
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail=f"Not enough inventory space for rewards! (Overweight by {current_weight + reward_weight - capacity:.1f})")
|
||||||
|
|
||||||
|
# Give Rewards
|
||||||
# XP
|
# XP
|
||||||
if 'xp' in rewards:
|
if 'xp' in rewards:
|
||||||
xp_gained = rewards['xp']
|
xp_gained = rewards['xp']
|
||||||
# We use current_user['xp'] but optimally we should fetch fresh player data if we want to be safe
|
|
||||||
# For simplicity and performance, assuming current_user is fresh enough (it's from dependency)
|
|
||||||
new_xp = current_user['xp'] + xp_gained
|
new_xp = current_user['xp'] + xp_gained
|
||||||
await db.update_player(character_id, xp=new_xp)
|
await db.update_player(character_id, xp=new_xp)
|
||||||
rewards_msg.append(f"{xp_gained} XP")
|
rewards_msg.append(f"{xp_gained} XP")
|
||||||
@@ -262,7 +461,10 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
|
|||||||
if 'items' in rewards:
|
if 'items' in rewards:
|
||||||
for item_id, qty in rewards['items'].items():
|
for item_id, qty in rewards['items'].items():
|
||||||
await db.add_item_to_inventory(character_id, item_id, qty)
|
await db.add_item_to_inventory(character_id, item_id, qty)
|
||||||
rewards_msg.append(f"{item_id} x{qty}") # Should assume name resolution on frontend or here
|
# Resolve name
|
||||||
|
idev = ITEMS_MANAGER.get_item(item_id)
|
||||||
|
name = idev.name if idev else item_id
|
||||||
|
rewards_msg.append(f"{name} x{qty}")
|
||||||
|
|
||||||
# Set cooldown if repeatable
|
# Set cooldown if repeatable
|
||||||
if quest_def.get('repeatable'):
|
if quest_def.get('repeatable'):
|
||||||
@@ -270,6 +472,44 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
|
|||||||
expires = time.time() + (cooldown_hours * 3600)
|
expires = time.time() + (cooldown_hours * 3600)
|
||||||
await db.set_quest_cooldown(character_id, quest_id, expires)
|
await db.set_quest_cooldown(character_id, quest_id, expires)
|
||||||
|
|
||||||
|
# LOG HISTORY
|
||||||
|
await db.log_quest_completion(
|
||||||
|
character_id=character_id,
|
||||||
|
quest_id=quest_id,
|
||||||
|
started_at=quest_record.get('started_at') or time.time(),
|
||||||
|
rewards=quest_def.get('rewards', {})
|
||||||
|
)
|
||||||
|
|
||||||
|
# REMOVE FROM ACTIVE QUESTS (DELETE)
|
||||||
|
await db.delete_character_quest(character_id, quest_id)
|
||||||
|
|
||||||
|
status = "completed"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Not completed, just update progress
|
||||||
|
status = "active"
|
||||||
|
await db.update_quest_progress(character_id, quest_id, updated_progress, status)
|
||||||
|
|
||||||
|
# ENRICH OBJECTIVES FOR RESPONSE
|
||||||
|
enriched_objs = []
|
||||||
|
for obj in objectives:
|
||||||
|
new_obj = dict(obj)
|
||||||
|
target = obj.get('target')
|
||||||
|
|
||||||
|
# Add current progress
|
||||||
|
new_obj['current'] = updated_progress.get(target, 0)
|
||||||
|
|
||||||
|
# Add names
|
||||||
|
if obj.get('type') == 'kill_count':
|
||||||
|
npc = NPCS_DATA.get(target)
|
||||||
|
if npc:
|
||||||
|
new_obj['target_name'] = npc.get('name')
|
||||||
|
elif obj.get('type') == 'item_delivery':
|
||||||
|
item = ITEMS_MANAGER.get_item(target)
|
||||||
|
if item:
|
||||||
|
new_obj['target_name'] = item.name
|
||||||
|
enriched_objs.append(new_obj)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"progress": updated_progress,
|
"progress": updated_progress,
|
||||||
@@ -281,6 +521,7 @@ async def hand_in_quest(quest_id: str, current_user: dict = Depends(get_current_
|
|||||||
"quest_id": quest_id,
|
"quest_id": quest_id,
|
||||||
"status": status,
|
"status": status,
|
||||||
"progress": updated_progress,
|
"progress": updated_progress,
|
||||||
|
"objectives": enriched_objs,
|
||||||
"on_cooldown": all_completed and quest_def.get('repeatable'),
|
"on_cooldown": all_completed and quest_def.get('repeatable'),
|
||||||
# other fields as needed
|
# other fields as needed
|
||||||
}
|
}
|
||||||
@@ -300,3 +541,78 @@ async def get_global_quest_progress(quest_id: str):
|
|||||||
if not quest:
|
if not quest:
|
||||||
return {"progress": {}}
|
return {"progress": {}}
|
||||||
return quest
|
return quest
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_global_quest(quest_id: str, quest_def: Dict):
|
||||||
|
"""
|
||||||
|
Handle global quest completion:
|
||||||
|
1. Mark global quest as completed
|
||||||
|
2. Unlock content (In-Memory)
|
||||||
|
3. Distribute rewards to all participants
|
||||||
|
4. Broadcast completion
|
||||||
|
"""
|
||||||
|
logger.info(f"🌍 Finishing Global Quest: {quest_id}")
|
||||||
|
|
||||||
|
# 1. Mark as completed in DB
|
||||||
|
await db.mark_global_quest_completed(quest_id)
|
||||||
|
|
||||||
|
# 2. Unlock content (In-Memory)
|
||||||
|
unlocks = []
|
||||||
|
|
||||||
|
# Unlock Locations
|
||||||
|
for loc in LOCATIONS_DATA.values():
|
||||||
|
if loc.unlocked_by == quest_id:
|
||||||
|
loc.locked = False
|
||||||
|
unlocks.append({"type": "location", "name": get_locale_string(loc.name, 'en'), "id": loc.id})
|
||||||
|
|
||||||
|
# Unlock interactables
|
||||||
|
for inter in loc.interactables:
|
||||||
|
if inter.unlocked_by == quest_id:
|
||||||
|
inter.locked = False
|
||||||
|
unlocks.append({"type": "interactable", "name": get_locale_string(inter.name, 'en'), "location": loc.id})
|
||||||
|
|
||||||
|
# 3. Distribute Rewards to participants
|
||||||
|
participants = await db.get_all_quest_participants(quest_id)
|
||||||
|
total_xp_pool = quest_def.get('rewards', {}).get('xp', 0)
|
||||||
|
|
||||||
|
total_required = 0
|
||||||
|
for obj in quest_def.get('objectives', []):
|
||||||
|
total_required += obj.get('count', 0)
|
||||||
|
|
||||||
|
for p in participants:
|
||||||
|
# Calculate user contribution
|
||||||
|
user_progress = p.get('progress', {})
|
||||||
|
user_contribution = 0
|
||||||
|
for obj in quest_def.get('objectives', []):
|
||||||
|
target = obj['target']
|
||||||
|
user_contribution += user_progress.get(target, 0)
|
||||||
|
|
||||||
|
if user_contribution > 0 and total_required > 0:
|
||||||
|
percentage = user_contribution / total_required
|
||||||
|
xp_reward = int(total_xp_pool * percentage)
|
||||||
|
|
||||||
|
if xp_reward > 0:
|
||||||
|
# Give XP
|
||||||
|
char = await db.get_player_by_id(p['character_id'])
|
||||||
|
if char:
|
||||||
|
new_xp = char['xp'] + xp_reward
|
||||||
|
await db.update_player(p['character_id'], xp=new_xp)
|
||||||
|
|
||||||
|
# Mark as completed (delete from active) and log history
|
||||||
|
await db.delete_character_quest(p['character_id'], quest_id)
|
||||||
|
await db.log_quest_completion(
|
||||||
|
character_id=p['character_id'],
|
||||||
|
quest_id=quest_id,
|
||||||
|
started_at=p['started_at'],
|
||||||
|
rewards={"xp": xp_reward, "note": f"Contribution: {percentage*100:.1f}%"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Broadcast
|
||||||
|
await manager.broadcast({
|
||||||
|
"type": "global_quest_completed",
|
||||||
|
"quest_id": quest_id,
|
||||||
|
"title": get_locale_string(quest_def.get('title', 'Global Quest'), 'en'),
|
||||||
|
"outcome": {
|
||||||
|
"unlocks": unlocks
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -391,3 +391,36 @@ async def consume_tool_durability(user_id: int, tools: list, inventory: list, it
|
|||||||
await db.decrease_unique_item_durability(tool['unique_item_id'], durability_cost)
|
await db.decrease_unique_item_durability(tool['unique_item_id'], durability_cost)
|
||||||
|
|
||||||
return True, "", consumed_tools
|
return True, "", consumed_tools
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_character_data(char: Dict[str, Any], items_manager: ItemsManager) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Add calculated stats (weight, volume) to character data.
|
||||||
|
"""
|
||||||
|
# Calculate weight and volume
|
||||||
|
inventory = await db.get_inventory(char['id'])
|
||||||
|
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, items_manager)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": char["id"],
|
||||||
|
"name": char["name"],
|
||||||
|
"level": char["level"],
|
||||||
|
"xp": char["xp"],
|
||||||
|
"hp": char["hp"],
|
||||||
|
"max_hp": char["max_hp"],
|
||||||
|
"stamina": char["stamina"],
|
||||||
|
"max_stamina": char["max_stamina"],
|
||||||
|
"strength": char["strength"],
|
||||||
|
"agility": char["agility"],
|
||||||
|
"endurance": char["endurance"],
|
||||||
|
"intellect": char["intellect"],
|
||||||
|
"location_id": char["location_id"],
|
||||||
|
"avatar_data": char.get("avatar_data"),
|
||||||
|
"last_played_at": char.get("last_played_at"),
|
||||||
|
"created_at": char.get("created_at"),
|
||||||
|
# Add calculated capacity
|
||||||
|
"weight": round(current_weight, 1),
|
||||||
|
"max_weight": round(max_weight, 1),
|
||||||
|
"volume": round(current_volume, 1),
|
||||||
|
"max_volume": round(max_volume, 1),
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class Interactable:
|
|||||||
name: Union[str, Dict[str, str]]
|
name: Union[str, Dict[str, str]]
|
||||||
image_path: str = ""
|
image_path: str = ""
|
||||||
actions: List[Action] = field(default_factory=list)
|
actions: List[Action] = field(default_factory=list)
|
||||||
|
unlocked_by: str = ""
|
||||||
|
locked: bool = False
|
||||||
|
|
||||||
def add_action(self, action: Action):
|
def add_action(self, action: Action):
|
||||||
self.actions.append(action)
|
self.actions.append(action)
|
||||||
@@ -63,6 +65,8 @@ class Location:
|
|||||||
x: float = 0.0 # X coordinate for distance calculations
|
x: float = 0.0 # X coordinate for distance calculations
|
||||||
y: float = 0.0 # Y coordinate for distance calculations
|
y: float = 0.0 # Y coordinate for distance calculations
|
||||||
danger_level: int = 0 # Danger level (0-5)
|
danger_level: int = 0 # Danger level (0-5)
|
||||||
|
unlocked_by: str = ""
|
||||||
|
locked: bool = False
|
||||||
|
|
||||||
def add_exit(self, direction: str, destination: str, stamina_cost: int = 5):
|
def add_exit(self, direction: str, destination: str, stamina_cost: int = 5):
|
||||||
self.exits[direction] = destination
|
self.exits[direction] = destination
|
||||||
@@ -114,9 +118,14 @@ class WorldLoader:
|
|||||||
interactable = Interactable(
|
interactable = Interactable(
|
||||||
id=template_id,
|
id=template_id,
|
||||||
name=template_data.get('name', 'Unknown'),
|
name=template_data.get('name', 'Unknown'),
|
||||||
image_path=template_data.get('image_path', '')
|
image_path=template_data.get('image_path', ''),
|
||||||
|
unlocked_by=instance_data.get('unlocked_by', template_data.get('unlocked_by', '')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set locked status if unlocked_by is present
|
||||||
|
if interactable.unlocked_by:
|
||||||
|
interactable.locked = True
|
||||||
|
|
||||||
# Get actions from template
|
# Get actions from template
|
||||||
template_actions = template_data.get('actions', {})
|
template_actions = template_data.get('actions', {})
|
||||||
|
|
||||||
@@ -211,9 +220,14 @@ class WorldLoader:
|
|||||||
y=float(loc_data.get('y', 0.0)),
|
y=float(loc_data.get('y', 0.0)),
|
||||||
danger_level=danger_level,
|
danger_level=danger_level,
|
||||||
tags=loc_data.get('tags', []),
|
tags=loc_data.get('tags', []),
|
||||||
npcs=loc_data.get('npcs', [])
|
npcs=loc_data.get('npcs', []),
|
||||||
|
unlocked_by=loc_data.get('unlocked_by', '')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set locked status if unlocked_by is present
|
||||||
|
if location.unlocked_by:
|
||||||
|
location.locked = True
|
||||||
|
|
||||||
# Add exits
|
# Add exits
|
||||||
for direction, destination in loc_data.get('exits', {}).items():
|
for direction, destination in loc_data.get('exits', {}).items():
|
||||||
location.add_exit(direction, destination)
|
location.add_exit(direction, destination)
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
# Visual Style Guide - Echoes of the Ash
|
|
||||||
|
|
||||||
This document defines the unified visual system for the application, ensuring a consistent, mature "videogame" aesthetic.
|
|
||||||
|
|
||||||
## 1. Core Design Philosophy
|
|
||||||
- **Aesthetic**: **Mature Post-Apocalyptic**. Think "High-Tech Scavenger".
|
|
||||||
- **Dark & Gritty**: Deep blacks (`#0a0a0a`) mixed with industrial dark greys (`#1a1a20`).
|
|
||||||
- **Sharp & Distinct**: Avoid overly rounded "Web 2.0" corners. Use chamfered corners (clip-path) or tight radii (2px-4px) for a militaristic/industrial feel.
|
|
||||||
- **Glassmorphism**: Use semi-transparent backgrounds with blur (`backdrop-filter`) to keep the player connected to the world.
|
|
||||||
- **Cinematic**: High contrast text, subtle glows on active elements, and distinct borders.
|
|
||||||
- **Interaction**: **Instant Feedback**.
|
|
||||||
- **Custom Tooltips**: **MANDATORY**. Do NOT use the HTML `title` attribute. All information must appear instantly in a custom game-styled tooltip anchor.
|
|
||||||
- **Micro-animations**: Subtle pulses, border glows on hover.
|
|
||||||
|
|
||||||
## 2. CSS Variables (Design Tokens)
|
|
||||||
Defined in `:root`.
|
|
||||||
|
|
||||||
### Colors
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
/* Backgrounds */
|
|
||||||
--game-bg-app: #050505; /* Deepest black */
|
|
||||||
--game-bg-panel: rgba(18, 18, 24, 0.96); /* Almost solid panels */
|
|
||||||
--game-bg-glass: rgba(10, 10, 15, 0.85); /* Overlays */
|
|
||||||
--game-bg-slot: rgba(0, 0, 0, 0.5); /* Item slots - darker than panels */
|
|
||||||
--game-bg-slot-hover: rgba(255, 255, 255, 0.08);
|
|
||||||
|
|
||||||
/* Borders / Separators */
|
|
||||||
--game-border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
--game-border-active: rgba(255, 255, 255, 0.4);
|
|
||||||
--game-border-highlight: #ff6b6b; /* Red accent border */
|
|
||||||
|
|
||||||
/* Corner Radius - Tighter for mature look */
|
|
||||||
--game-radius-xs: 2px;
|
|
||||||
--game-radius-sm: 4px;
|
|
||||||
--game-radius-md: 6px;
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--game-font-main: 'Saira Condensed', system-ui, sans-serif;
|
|
||||||
--game-text-primary: #e0e0e0; /* Off-white is better for eyes than pure white */
|
|
||||||
--game-text-secondary: #94a3b8; /* Cool grey */
|
|
||||||
--game-text-highlight: #fbbf24; /* Amber/Gold */
|
|
||||||
--game-text-danger: #ef4444;
|
|
||||||
|
|
||||||
/* Semantic Colors (Desaturated/Industrial) */
|
|
||||||
--game-color-primary: #e11d48; /* Blood Red - Action/Health */
|
|
||||||
--game-color-stamina: #d97706; /* Amber - Stamina */
|
|
||||||
--game-color-magic: #3b82f6; /* Blue - Mana/Tech */
|
|
||||||
--game-color-success: #10b981; /* Emerald - Durability High */
|
|
||||||
--game-color-warning: #f59e0b; /* Amber - Warning */
|
|
||||||
|
|
||||||
/* Rarity Colors */
|
|
||||||
--rarity-common: #9ca3af;
|
|
||||||
--rarity-uncommon: #ffffff;
|
|
||||||
--rarity-rare: #34d399;
|
|
||||||
--rarity-epic: #60a5fa;
|
|
||||||
--rarity-legendary: #fbbf24;
|
|
||||||
|
|
||||||
/* Effects */
|
|
||||||
--game-shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.8);
|
|
||||||
--game-shadow-glow: 0 0 15px rgba(225, 29, 72, 0.3); /* Subtle red glow */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Tooltip System (CRITICAL)
|
|
||||||
**Goal**: Replicate a native game HUD tooltip.
|
|
||||||
**Rule**: NEVER use `title="Description"`.
|
|
||||||
|
|
||||||
### The Component: `<GameTooltip />`
|
|
||||||
Every interactive element with info must be wrapped or associated with this component.
|
|
||||||
- **Behavior**: Appears instantly on hover (0ms delay). Follows cursor OR anchored to element.
|
|
||||||
- **Visuals**:
|
|
||||||
- Background: Solid dark (`#0f0f12`) with high opacity (98%).
|
|
||||||
- Border: Thin accent border (`1px solid --game-border-color`).
|
|
||||||
- Shadow: Strong drop shadow to separate from UI.
|
|
||||||
- Content: Supports HTML (rich text, stats, icons).
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Usage Example
|
|
||||||
<GameTooltip content={<ItemStats item={sword} />}>
|
|
||||||
<button className="game-slot">
|
|
||||||
<img src="sword.png" />
|
|
||||||
</button>
|
|
||||||
</GameTooltip>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Reusable Component Classes
|
|
||||||
|
|
||||||
### Panels & Containers
|
|
||||||
**`.game-panel`**
|
|
||||||
- **Look**: Industrial, solid.
|
|
||||||
- **CSS**:
|
|
||||||
```css
|
|
||||||
background: var(--game-bg-panel);
|
|
||||||
border: 1px solid var(--game-border-color);
|
|
||||||
box-shadow: var(--game-shadow-panel);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border-radius: var(--game-radius-sm);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
**`.game-btn`**
|
|
||||||
- **Look**: Rectangular, tactile.
|
|
||||||
- **States**:
|
|
||||||
- Normal: Dark grey background, light border.
|
|
||||||
- Hover: Glow effect, border brightens.
|
|
||||||
- Active: Presses down (transform).
|
|
||||||
- **CSS**:
|
|
||||||
```css
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-family: var(--game-font-main);
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px); /* Mecha-cut corners */
|
|
||||||
/* OR simple tight radius */
|
|
||||||
border-radius: 2px;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Items & Slots
|
|
||||||
**`.game-slot`**
|
|
||||||
- **Background**: `var(--game-bg-slot)` (Darker than panel).
|
|
||||||
- **Border**: `1px solid var(--game-border-color)`.
|
|
||||||
- **Interaction**: On hover, background lightens slightly, border acts as highlight.
|
|
||||||
|
|
||||||
## 5. Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Tooltip System (Priority)
|
|
||||||
1. Create `src/components/common/GameTooltip.tsx`.
|
|
||||||
2. Implement global tooltip context/store if needed for performance, or use a lightweight library like generic CSS hover + React portal.
|
|
||||||
3. **Audit**: Grep for `title=` in all files and replace with `<GameTooltip>`.
|
|
||||||
|
|
||||||
### Phase 2: Design Token Migration
|
|
||||||
1. Define all variables in [index.css](file:///opt/dockers/echoes_of_the_ashes/pwa/src/index.css).
|
|
||||||
2. Update `src/styles/components.css` (or new file) with utility classes.
|
|
||||||
|
|
||||||
### Phase 3: Component Reskinning
|
|
||||||
1. **InventoryModal**: Apply `.game-panel` and standard slots.
|
|
||||||
2. **PlayerSidebar**: Update bars and equipment slots.
|
|
||||||
3. **HUD/Sidebar**: Ensure borders are strict and consistent.
|
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
"en": "A broken vending machine, glass shattered.",
|
"en": "A broken vending machine, glass shattered.",
|
||||||
"es": "Una máquina expendedora rota, el vidrio está roto."
|
"es": "Una máquina expendedora rota, el vidrio está roto."
|
||||||
},
|
},
|
||||||
"image_path": "images/interactables/vending.webp",
|
"image_path": "images/interactables/vending_machine.webp",
|
||||||
"actions": {
|
"actions": {
|
||||||
"break": {
|
"break": {
|
||||||
"id": "break",
|
"id": "break",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"objectives": [
|
"objectives": [
|
||||||
{
|
{
|
||||||
"type": "item_delivery",
|
"type": "item_delivery",
|
||||||
"target": "wood_plank",
|
"target": "wood_planks",
|
||||||
"count": 1000
|
"count": 1000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 178 KiB |
@@ -83,16 +83,6 @@ textarea:focus {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Effects */
|
/* Status Effects */
|
||||||
.status-effects-container {
|
.status-effects-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
183
pwa/src/App.tsx
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserRouter, HashRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { useAuth } from './hooks/useAuth'
|
import { useAuth } from './hooks/useAuth'
|
||||||
import { AudioProvider } from './contexts/AudioContext'
|
import { AudioProvider } from './contexts/AudioContext'
|
||||||
@@ -13,7 +14,13 @@ import Profile from './components/Profile'
|
|||||||
import Leaderboards from './components/Leaderboards'
|
import Leaderboards from './components/Leaderboards'
|
||||||
import GameLayout from './components/GameLayout'
|
import GameLayout from './components/GameLayout'
|
||||||
import AccountPage from './components/AccountPage'
|
import AccountPage from './components/AccountPage'
|
||||||
|
import PublicLayout from './components/PublicLayout'
|
||||||
|
import AuthenticatedLayout from './components/AuthenticatedLayout'
|
||||||
|
import PrivacyPolicy from './components/PrivacyPolicy'
|
||||||
|
import TermsOfService from './components/TermsOfService'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
import { NotificationProvider } from './contexts/NotificationContext'
|
||||||
|
import { NotificationContainer } from './components/common/NotificationContainer'
|
||||||
|
|
||||||
// Use HashRouter for Electron (file:// protocol), BrowserRouter for web
|
// Use HashRouter for Electron (file:// protocol), BrowserRouter for web
|
||||||
const isElectron = window.location.protocol === 'file:'
|
const isElectron = window.location.protocol === 'file:'
|
||||||
@@ -47,79 +54,125 @@ function CharacterRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ZoomManager() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateZoom = () => {
|
||||||
|
const isGamePage = location.pathname.includes('/game');
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
|
if (isGamePage) {
|
||||||
|
const scale = window.innerWidth / 1920;
|
||||||
|
(document.documentElement.style as any).zoom = `${scale}`;
|
||||||
|
document.documentElement.style.setProperty('--zoom-factor', `${scale}`);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
(document.documentElement.style as any).zoom = '1';
|
||||||
|
document.documentElement.style.setProperty('--zoom-factor', '1');
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
document.documentElement.style.overflow = 'auto';
|
||||||
|
document.body.style.height = 'auto';
|
||||||
|
document.documentElement.style.height = 'auto';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateZoom();
|
||||||
|
window.addEventListener('resize', updateZoom);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateZoom);
|
||||||
|
};
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<NotificationProvider>
|
||||||
<AudioProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<AudioProvider>
|
||||||
<BackgroundMusic />
|
<Router>
|
||||||
<div className="app">
|
<ZoomManager />
|
||||||
<Routes>
|
<BackgroundMusic />
|
||||||
<Route path="/" element={<LandingPage />} />
|
<NotificationContainer />
|
||||||
<Route path="/login" element={<Login />} />
|
<div className="app">
|
||||||
<Route path="/register" element={<Register />} />
|
<Routes>
|
||||||
|
<Route element={<PublicLayout />}>
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/privacy" element={<PrivacyPolicy />} />
|
||||||
|
<Route path="/terms" element={<TermsOfService />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route
|
<Route element={<AuthenticatedLayout />}>
|
||||||
path="/characters"
|
<Route
|
||||||
element={
|
path="/characters"
|
||||||
<PrivateRoute>
|
element={
|
||||||
<CharacterSelection />
|
<PrivateRoute>
|
||||||
</PrivateRoute>
|
<CharacterSelection />
|
||||||
}
|
</PrivateRoute>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/create-character"
|
path="/create-character"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<CharacterCreation />
|
<CharacterCreation />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/account"
|
path="/account"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AccountPage />
|
<AccountPage />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route element={<GameLayout />}>
|
<Route element={<GameLayout />}>
|
||||||
<Route
|
<Route
|
||||||
path="/game"
|
path="/game"
|
||||||
element={
|
element={
|
||||||
<CharacterRoute>
|
<CharacterRoute>
|
||||||
<Game />
|
<Game />
|
||||||
</CharacterRoute>
|
</CharacterRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/profile/:playerId"
|
path="/profile/:playerId"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Profile />
|
<Profile />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/leaderboards"
|
path="/leaderboards"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Leaderboards />
|
<Leaderboards />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
</AudioProvider>
|
</AudioProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</NotificationProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
||||||
|
|||||||
@@ -3,331 +3,358 @@
|
|||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
/* Account for header */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure the main container inherits the global border radius/clip-path correctly without duplicating it */
|
||||||
.account-container {
|
.account-container {
|
||||||
background: rgba(0, 0, 0, 0.8);
|
max-width: 1000px;
|
||||||
border-radius: 8px;
|
width: 100%;
|
||||||
padding: 2rem;
|
margin: 0 auto;
|
||||||
backdrop-filter: blur(10px);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-panel-override {
|
||||||
|
border-radius: 0;
|
||||||
|
/* Let the game-panel class handle the shape */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clip paths for inner containers */
|
||||||
|
.game-panel.inner {
|
||||||
|
border-radius: 0;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
background: rgba(0, 0, 0, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-top-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-title {
|
.account-title {
|
||||||
font-size: 2.5rem;
|
font-size: 2rem;
|
||||||
margin-bottom: 2rem;
|
text-transform: uppercase;
|
||||||
text-align: center;
|
letter-spacing: 2px;
|
||||||
color: #e0e0e0;
|
margin: 0;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
color: var(--game-color-primary);
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.account-layout {
|
||||||
|
flex-direction: row;
|
||||||
|
min-height: 550px;
|
||||||
|
/* Force minimum height to prevent jumping */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs Navigation */
|
||||||
|
.account-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.account-tabs {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 250px;
|
||||||
|
min-width: 250px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-right: 1px solid var(--game-border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
/* default for mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.account-tab {
|
||||||
|
border-bottom: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tab.active {
|
||||||
|
color: var(--game-color-primary);
|
||||||
|
background: rgba(var(--game-color-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.account-tab.active {
|
||||||
|
border-bottom: 2px solid var(--game-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.account-tab.active {
|
||||||
|
border-left: 3px solid var(--game-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-tab.active .tab-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Area */
|
||||||
|
.account-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-section {
|
.account-section {
|
||||||
margin-bottom: 3rem;
|
display: flex;
|
||||||
padding-bottom: 2rem;
|
flex-direction: column;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-section:last-child {
|
/* Make sure container heights don't jump on tab swaps */
|
||||||
border-bottom: none;
|
.fixed-height-section {
|
||||||
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
text-transform: uppercase;
|
||||||
color: #bbb;
|
letter-spacing: 1px;
|
||||||
border-left: 4px solid #4a9eff;
|
color: #fff;
|
||||||
padding-left: 1rem;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
|
margin: 1.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* General Tab Adjustments */
|
||||||
.info-grid {
|
.info-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
border: 1px solid var(--game-border-color);
|
||||||
|
/* Kept borders around clipping */
|
||||||
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
color: #888;
|
font-size: 0.85rem;
|
||||||
font-size: 0.9rem;
|
text-transform: uppercase;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value.premium {
|
.info-value.premium {
|
||||||
color: #ffd700;
|
color: var(--game-color-success);
|
||||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
|
font-weight: 700;
|
||||||
|
text-shadow: 0 0 8px rgba(var(--game-color-success-rgb), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Characters Grid */
|
.character-actions-area {
|
||||||
.characters-grid {
|
margin-top: 2rem;
|
||||||
display: grid;
|
padding-top: 1.5rem;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
gap: 1.5rem;
|
}
|
||||||
|
|
||||||
|
/* Shared Settings Components */
|
||||||
|
.setting-item-ui {
|
||||||
|
border: 1px solid var(--game-border-color);
|
||||||
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-card {
|
.setting-item-ui:last-child {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
margin-bottom: 0;
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
transition: transform 0.2s, background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-card:hover {
|
.setting-header-ui {
|
||||||
transform: translateY(-2px);
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-header h3 {
|
.setting-header-ui h3 {
|
||||||
margin: 0;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-level {
|
|
||||||
background: #4a9eff;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: #aba;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-attributes {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #888;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-characters {
|
|
||||||
text-align: center;
|
|
||||||
color: #888;
|
|
||||||
padding: 2rem;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings */
|
|
||||||
.setting-item {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-header h3 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
|
||||||
|
|
||||||
.setting-form {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.8rem;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus {
|
.setting-form-ui {
|
||||||
border-color: #4a9eff;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Audio Settings */
|
|
||||||
.audio-settings {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1.2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mute-toggle {
|
.form-group-ui {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-ui label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove border-radius rounded corners on inputs explicitly */
|
||||||
|
.squared-input {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: rgba(0, 0, 0, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-settings {
|
||||||
|
border: 1px solid var(--game-border-color);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-sliders-ui {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
/* Limit max width specifically to avoid slider overflow on desktop */
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-group-ui {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
/* Ensure group spans correctly */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The wrapper contains the slider input to avoid bleeding over its container */
|
||||||
|
.slider-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 10px;
|
||||||
|
/* Prevent thumb from bleeding outside at 100% width edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-group-ui label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--game-font-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-slider {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Prevent the thumb width from pushing it out */
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mute-toggle-ui {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mute-toggle input {
|
.mute-toggle-ui input {
|
||||||
|
cursor: pointer;
|
||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
height: 1.2rem;
|
height: 1.2rem;
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-sliders {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group input[type="range"] {
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
height: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group input[type="range"]::-webkit-slider-thumb {
|
|
||||||
appearance: none;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
background: #4a9eff;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-group input[type="range"]::-webkit-slider-thumb:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Actions */
|
|
||||||
.account-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.button-primary,
|
|
||||||
.button-secondary,
|
|
||||||
.button-danger,
|
|
||||||
.button-link {
|
|
||||||
padding: 0.8rem 1.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-primary {
|
|
||||||
background: #4a9eff;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-primary:hover {
|
|
||||||
background: #3a8eef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-primary:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-secondary {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-secondary:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-danger {
|
|
||||||
background: rgba(220, 53, 69, 0.2);
|
|
||||||
color: #ff6b6b;
|
|
||||||
border: 1px solid rgba(220, 53, 69, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-danger:hover {
|
|
||||||
background: rgba(220, 53, 69, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-link {
|
|
||||||
background: none;
|
|
||||||
color: #4a9eff;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-link:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
.error {
|
.error-message-ui {
|
||||||
background: rgba(220, 53, 69, 0.1);
|
background: rgba(var(--game-color-danger-rgb), 0.1);
|
||||||
color: #ff6b6b;
|
color: var(--game-color-danger);
|
||||||
padding: 1rem;
|
padding: 0.8rem;
|
||||||
border-radius: 4px;
|
border-left: 3px solid var(--game-color-danger);
|
||||||
margin-bottom: 1rem;
|
font-size: 0.9rem;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-success {
|
.message-success-ui {
|
||||||
background: rgba(40, 167, 69, 0.1);
|
background: rgba(var(--game-color-success-rgb), 0.1);
|
||||||
color: #5ddc6c;
|
color: var(--game-color-success);
|
||||||
padding: 1rem;
|
padding: 0.8rem;
|
||||||
border-radius: 4px;
|
border-left: 3px solid var(--game-color-success);
|
||||||
margin-bottom: 1rem;
|
font-size: 0.9rem;
|
||||||
text-align: center;
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
import { useAudio } from '../contexts/AudioContext'
|
import { useAudio } from '../contexts/AudioContext'
|
||||||
import { authApi, Account, Character } from '../services/api'
|
import { authApi, Account } from '../services/api'
|
||||||
|
import { GameButton } from './common/GameButton'
|
||||||
import './AccountPage.css'
|
import './AccountPage.css'
|
||||||
|
|
||||||
function AccountPage() {
|
function AccountPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { logout } = useAuth()
|
const { currentCharacter } = useAuth()
|
||||||
const [account, setAccount] = useState<Account | null>(null)
|
const [account, setAccount] = useState<Account | null>(null)
|
||||||
const [characters, setCharacters] = useState<Character[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Tab State
|
||||||
|
const [activeTab, setActiveTab] = useState<'general' | 'audio' | 'security'>('general')
|
||||||
|
|
||||||
// Email change state
|
// Email change state
|
||||||
const [showEmailChange, setShowEmailChange] = useState(false)
|
const [showEmailChange, setShowEmailChange] = useState(false)
|
||||||
const [newEmail, setNewEmail] = useState('')
|
const [newEmail, setNewEmail] = useState('')
|
||||||
@@ -47,10 +52,10 @@ function AccountPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
const data = await authApi.getAccount()
|
const data = await authApi.getAccount()
|
||||||
setAccount(data.account)
|
setAccount(data.account)
|
||||||
setCharacters(data.characters)
|
// characters are returned as data.characters but we don't display the list here anymore
|
||||||
setError('')
|
setError('')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Failed to load account data')
|
setError(err.response?.data?.detail || t('common.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -62,7 +67,7 @@ function AccountPage() {
|
|||||||
setEmailSuccess('')
|
setEmailSuccess('')
|
||||||
|
|
||||||
if (!newEmail || !emailPassword) {
|
if (!newEmail || !emailPassword) {
|
||||||
setEmailError('Please fill in all fields')
|
setEmailError(t('common.error'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +81,7 @@ function AccountPage() {
|
|||||||
// Refresh account data
|
// Refresh account data
|
||||||
await fetchAccountData()
|
await fetchAccountData()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setEmailError(err.response?.data?.detail || 'Failed to change email')
|
setEmailError(err.response?.data?.detail || t('common.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setEmailLoading(false)
|
setEmailLoading(false)
|
||||||
}
|
}
|
||||||
@@ -88,17 +93,17 @@ function AccountPage() {
|
|||||||
setPasswordSuccess('')
|
setPasswordSuccess('')
|
||||||
|
|
||||||
if (!currentPassword || !newPassword || !confirmNewPassword) {
|
if (!currentPassword || !newPassword || !confirmNewPassword) {
|
||||||
setPasswordError('Please fill in all fields')
|
setPasswordError(t('common.error'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword !== confirmNewPassword) {
|
if (newPassword !== confirmNewPassword) {
|
||||||
setPasswordError('New passwords do not match')
|
setPasswordError(t('auth.errors.passwordMatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < 6) {
|
||||||
setPasswordError('New password must be at least 6 characters')
|
setPasswordError(t('auth.errors.passwordLength'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +116,7 @@ function AccountPage() {
|
|||||||
setConfirmNewPassword('')
|
setConfirmNewPassword('')
|
||||||
setShowPasswordChange(false)
|
setShowPasswordChange(false)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setPasswordError(err.response?.data?.detail || 'Failed to change password')
|
setPasswordError(err.response?.data?.detail || t('common.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setPasswordLoading(false)
|
setPasswordLoading(false)
|
||||||
}
|
}
|
||||||
@@ -135,7 +140,7 @@ function AccountPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="account-page">
|
<div className="account-page">
|
||||||
<div className="account-loading">Loading account...</div>
|
<div className="account-loading game-panel">{t('common.loading')}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -143,10 +148,12 @@ function AccountPage() {
|
|||||||
if (error || !account) {
|
if (error || !account) {
|
||||||
return (
|
return (
|
||||||
<div className="account-page">
|
<div className="account-page">
|
||||||
<div className="account-error">
|
<div className="account-error game-panel">
|
||||||
<h2>Error</h2>
|
<h2 className="error-title">{t('common.error')}</h2>
|
||||||
<p>{error || 'Account not found'}</p>
|
<p>{error || t('common.error')}</p>
|
||||||
<button onClick={() => navigate('/game')}>Back to Game</button>
|
<GameButton variant="secondary" onClick={() => navigate(currentCharacter ? '/game' : '/characters')}>
|
||||||
|
{t('common.back')}
|
||||||
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -154,273 +161,268 @@ function AccountPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="account-page">
|
<div className="account-page">
|
||||||
<div className="account-container">
|
<div className="account-container game-panel account-panel-override">
|
||||||
<h1 className="account-title">Account Management</h1>
|
<div className="account-header-top">
|
||||||
|
<h1 className="account-title">{t('common.accountSettings')}</h1>
|
||||||
{/* Account Information Section */}
|
<div className="account-top-actions">
|
||||||
<section className="account-section">
|
<GameButton variant="secondary" onClick={() => navigate(currentCharacter ? '/game' : '/characters')}>
|
||||||
<h2 className="section-title">Account Information</h2>
|
{currentCharacter ? t('game.dialog.back') : t('common.back')}
|
||||||
<div className="info-grid">
|
</GameButton>
|
||||||
<div className="info-item">
|
{/* Logout removed from here, user wants it only in header */}
|
||||||
<span className="info-label">Email:</span>
|
|
||||||
<span className="info-value">{account.email}</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-item">
|
|
||||||
<span className="info-label">Account Type:</span>
|
|
||||||
<span className="info-value">{getAccountTypeDisplay(account.account_type)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-item">
|
|
||||||
<span className="info-label">Premium Status:</span>
|
|
||||||
<span className={`info-value ${account.premium_expires_at ? 'premium' : ''}`}>
|
|
||||||
{account.premium_expires_at && Number(account.premium_expires_at) > Date.now() / 1000
|
|
||||||
? '✓ Premium Active'
|
|
||||||
: 'Free Account'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-item">
|
|
||||||
<span className="info-label">Created:</span>
|
|
||||||
<span className="info-value">{formatDate(account.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-item">
|
|
||||||
<span className="info-label">Last Login:</span>
|
|
||||||
<span className="info-value">{formatDate(account.last_login_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{/* Characters Section */}
|
<div className="account-layout">
|
||||||
<section className="account-section">
|
{/* Tabs Navigation */}
|
||||||
<h2 className="section-title">Your Characters</h2>
|
<div className="account-tabs">
|
||||||
{characters.length === 0 ? (
|
<button
|
||||||
<p className="no-characters">No characters yet. Create one to start playing!</p>
|
className={`account-tab ${activeTab === 'general' ? 'active' : ''}`}
|
||||||
) : (
|
onClick={() => setActiveTab('general')}
|
||||||
<div className="characters-grid">
|
>
|
||||||
{characters.map((char) => (
|
<span className="tab-icon">👤</span> {t('common.general')}
|
||||||
<div key={char.id} className="character-card">
|
</button>
|
||||||
<div className="character-header">
|
<button
|
||||||
<h3>{char.name}</h3>
|
className={`account-tab ${activeTab === 'audio' ? 'active' : ''}`}
|
||||||
<span className="character-level">Level {char.level}</span>
|
onClick={() => setActiveTab('audio')}
|
||||||
|
>
|
||||||
|
<span className="tab-icon">🎵</span> {t('common.audio')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`account-tab ${activeTab === 'security' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('security')}
|
||||||
|
>
|
||||||
|
<span className="tab-icon">🔒</span> {t('common.security')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content Areas */}
|
||||||
|
<div className="account-content">
|
||||||
|
{/* GENERAL TAB */}
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<section className="account-section animate-fade-in fixed-height-section">
|
||||||
|
<h2 className="section-title">{t('auth.accountInfo')}</h2>
|
||||||
|
<div className="info-grid game-panel inner">
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">{t('auth.email')}</span>
|
||||||
|
<span className="info-value">{account.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="character-stats">
|
<div className="info-item">
|
||||||
<div className="stat">
|
<span className="info-label">{t('auth.accountType')}</span>
|
||||||
<span className="stat-label">HP:</span>
|
<span className="info-value">{getAccountTypeDisplay(account.account_type)}</span>
|
||||||
<span className="stat-value">{char.hp}/{char.max_hp}</span>
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">{t('auth.premiumStatus')}</span>
|
||||||
|
<span className={`info-value ${account.premium_expires_at ? 'premium' : ''}`}>
|
||||||
|
{account.premium_expires_at && Number(account.premium_expires_at) > Date.now() / 1000
|
||||||
|
? t('auth.premiumActive')
|
||||||
|
: t('auth.freeAccount')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">{t('auth.created')}</span>
|
||||||
|
<span className="info-value">{formatDate(account.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">{t('auth.lastLogin')}</span>
|
||||||
|
<span className="info-value">{formatDate(account.last_login_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="character-actions-area">
|
||||||
|
<h3 className="subsection-title">{t('auth.gameActions')}</h3>
|
||||||
|
<GameButton variant="primary" onClick={() => navigate('/characters')}>
|
||||||
|
{t('auth.switchCharacter')}
|
||||||
|
</GameButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AUDIO TAB */}
|
||||||
|
{activeTab === 'audio' && (
|
||||||
|
<section className="account-section animate-fade-in fixed-height-section">
|
||||||
|
<h2 className="section-title">{t('auth.audioSettings')}</h2>
|
||||||
|
<div className="audio-settings game-panel inner">
|
||||||
|
<div className="setting-header-ui">
|
||||||
|
<h3>{t('auth.volumeControls')}</h3>
|
||||||
|
<label className="mute-toggle-ui">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isMuted}
|
||||||
|
onChange={(e) => setIsMuted(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t('auth.muteAll')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="volume-sliders-ui">
|
||||||
|
<div className="slider-group-ui">
|
||||||
|
<label>{t('auth.masterVolume')}: {Math.round(masterVolume * 100)}%</label>
|
||||||
|
<div className="slider-wrapper">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
className="game-slider"
|
||||||
|
value={masterVolume}
|
||||||
|
onChange={(e) => setMasterVolume(parseFloat(e.target.value))}
|
||||||
|
disabled={isMuted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
<div className="slider-group-ui">
|
||||||
<span className="stat-label">Stamina:</span>
|
<label>{t('auth.musicVolume')}: {Math.round(musicVolume * 100)}%</label>
|
||||||
<span className="stat-value">{char.stamina}/{char.max_stamina}</span>
|
<div className="slider-wrapper">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
className="game-slider"
|
||||||
|
value={musicVolume}
|
||||||
|
onChange={(e) => setMusicVolume(parseFloat(e.target.value))}
|
||||||
|
disabled={isMuted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="slider-group-ui">
|
||||||
|
<label>{t('auth.sfxVolume')}: {Math.round(sfxVolume * 100)}%</label>
|
||||||
|
<div className="slider-wrapper">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
className="game-slider"
|
||||||
|
value={sfxVolume}
|
||||||
|
onChange={(e) => setSfxVolume(parseFloat(e.target.value))}
|
||||||
|
disabled={isMuted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="character-attributes">
|
</div>
|
||||||
<span>STR: {char.strength}</span>
|
</section>
|
||||||
<span>AGI: {char.agility}</span>
|
)}
|
||||||
<span>END: {char.endurance}</span>
|
|
||||||
<span>INT: {char.intellect}</span>
|
{/* SECURITY TAB */}
|
||||||
|
{activeTab === 'security' && (
|
||||||
|
<section className="account-section animate-fade-in fixed-height-section">
|
||||||
|
<h2 className="section-title">{t('auth.securitySettings')}</h2>
|
||||||
|
|
||||||
|
{/* Email Change */}
|
||||||
|
<div className="setting-item-ui game-panel inner">
|
||||||
|
<div className="setting-header-ui">
|
||||||
|
<h3>{t('auth.changeEmail')}</h3>
|
||||||
|
<GameButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowEmailChange(!showEmailChange)}
|
||||||
|
>
|
||||||
|
{showEmailChange ? t('auth.cancel') : t('auth.change')}
|
||||||
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{showEmailChange && (
|
||||||
className="button-secondary"
|
<form onSubmit={handleEmailChange} className="setting-form-ui">
|
||||||
onClick={() => navigate(`/profile/${char.id}`)}
|
<div className="form-group-ui">
|
||||||
>
|
<label htmlFor="newEmail">{t('auth.email')}</label>
|
||||||
View Profile
|
<input
|
||||||
</button>
|
type="email"
|
||||||
|
id="newEmail"
|
||||||
|
className="game-input squared-input"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
|
required
|
||||||
|
disabled={emailLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-ui">
|
||||||
|
<label htmlFor="emailPassword">{t('auth.currentPassword')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="emailPassword"
|
||||||
|
className="game-input squared-input"
|
||||||
|
value={emailPassword}
|
||||||
|
onChange={(e) => setEmailPassword(e.target.value)}
|
||||||
|
placeholder={t('auth.verifyIdentity')}
|
||||||
|
required
|
||||||
|
disabled={emailLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{emailError && <div className="error-message-ui">{emailError}</div>}
|
||||||
|
{emailSuccess && <div className="message-success-ui">{emailSuccess}</div>}
|
||||||
|
<GameButton variant="primary" disabled={emailLoading} onClick={() => { }}>
|
||||||
|
{emailLoading ? t('auth.updating') : t('auth.updateEmail')}
|
||||||
|
</GameButton>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="button-primary"
|
|
||||||
onClick={() => navigate('/create-character')}
|
|
||||||
>
|
|
||||||
Create New Character
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Settings Section */}
|
{/* Password Change */}
|
||||||
<section className="account-section">
|
<div className="setting-item-ui game-panel inner">
|
||||||
<h2 className="section-title">Audio Settings</h2>
|
<div className="setting-header-ui">
|
||||||
<div className="audio-settings">
|
<h3>{t('auth.changePassword')}</h3>
|
||||||
<div className="setting-item">
|
<GameButton
|
||||||
<div className="setting-header">
|
variant="secondary"
|
||||||
<h3>Volume Controls</h3>
|
size="sm"
|
||||||
<label className="mute-toggle">
|
onClick={() => setShowPasswordChange(!showPasswordChange)}
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
{showPasswordChange ? t('auth.cancel') : t('auth.change')}
|
||||||
checked={isMuted}
|
</GameButton>
|
||||||
onChange={(e) => setIsMuted(e.target.checked)}
|
</div>
|
||||||
/>
|
{showPasswordChange && (
|
||||||
<span>Mute All</span>
|
<form onSubmit={handlePasswordChange} className="setting-form-ui">
|
||||||
</label>
|
<div className="form-group-ui">
|
||||||
</div>
|
<label htmlFor="currentPassword">{t('auth.currentPassword')}</label>
|
||||||
<div className="volume-sliders">
|
<input
|
||||||
<div className="slider-group">
|
type="password"
|
||||||
<label>Master Volume: {Math.round(masterVolume * 100)}%</label>
|
id="currentPassword"
|
||||||
<input
|
className="game-input squared-input"
|
||||||
type="range"
|
value={currentPassword}
|
||||||
min="0"
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
max="1"
|
placeholder={t('auth.passwordPlaceholderLogin')}
|
||||||
step="0.01"
|
required
|
||||||
value={masterVolume}
|
disabled={passwordLoading}
|
||||||
onChange={(e) => setMasterVolume(parseFloat(e.target.value))}
|
/>
|
||||||
disabled={isMuted}
|
</div>
|
||||||
/>
|
<div className="form-group-ui">
|
||||||
|
<label htmlFor="newPassword">{t('auth.newPassword')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="newPassword"
|
||||||
|
className="game-input squared-input"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
|
required
|
||||||
|
disabled={passwordLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-ui">
|
||||||
|
<label htmlFor="confirmNewPassword">{t('auth.confirmNewPassword')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmNewPassword"
|
||||||
|
className="game-input squared-input"
|
||||||
|
value={confirmNewPassword}
|
||||||
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||||
|
placeholder={t('auth.confirmPasswordPlaceholder')}
|
||||||
|
required
|
||||||
|
disabled={passwordLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{passwordError && <div className="error-message-ui">{passwordError}</div>}
|
||||||
|
{passwordSuccess && <div className="message-success-ui">{passwordSuccess}</div>}
|
||||||
|
<GameButton variant="primary" disabled={passwordLoading} onClick={() => { }}>
|
||||||
|
{passwordLoading ? t('auth.updating') : t('auth.updatePassword')}
|
||||||
|
</GameButton>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="slider-group">
|
</section>
|
||||||
<label>Music Volume: {Math.round(musicVolume * 100)}%</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
value={musicVolume}
|
|
||||||
onChange={(e) => setMusicVolume(parseFloat(e.target.value))}
|
|
||||||
disabled={isMuted}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="slider-group">
|
|
||||||
<label>SFX Volume: {Math.round(sfxVolume * 100)}%</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
value={sfxVolume}
|
|
||||||
onChange={(e) => setSfxVolume(parseFloat(e.target.value))}
|
|
||||||
disabled={isMuted}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="account-section">
|
|
||||||
<h2 className="section-title">Account Settings</h2>
|
|
||||||
|
|
||||||
{/* Email Change */}
|
|
||||||
<div className="setting-item">
|
|
||||||
<div className="setting-header">
|
|
||||||
<h3>Change Email</h3>
|
|
||||||
<button
|
|
||||||
className="button-link"
|
|
||||||
onClick={() => setShowEmailChange(!showEmailChange)}
|
|
||||||
>
|
|
||||||
{showEmailChange ? 'Cancel' : 'Change'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showEmailChange && (
|
|
||||||
<form onSubmit={handleEmailChange} className="setting-form">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="newEmail">New Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="newEmail"
|
|
||||||
value={newEmail}
|
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
|
||||||
placeholder="new.email@example.com"
|
|
||||||
required
|
|
||||||
disabled={emailLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="emailPassword">Current Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="emailPassword"
|
|
||||||
value={emailPassword}
|
|
||||||
onChange={(e) => setEmailPassword(e.target.value)}
|
|
||||||
placeholder="Verify your identity"
|
|
||||||
required
|
|
||||||
disabled={emailLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{emailError && <div className="error">{emailError}</div>}
|
|
||||||
{emailSuccess && <div className="message-success">{emailSuccess}</div>}
|
|
||||||
<button type="submit" className="button-primary" disabled={emailLoading}>
|
|
||||||
{emailLoading ? 'Updating...' : 'Update Email'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Password Change */}
|
|
||||||
<div className="setting-item">
|
|
||||||
<div className="setting-header">
|
|
||||||
<h3>Change Password</h3>
|
|
||||||
<button
|
|
||||||
className="button-link"
|
|
||||||
onClick={() => setShowPasswordChange(!showPasswordChange)}
|
|
||||||
>
|
|
||||||
{showPasswordChange ? 'Cancel' : 'Change'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showPasswordChange && (
|
|
||||||
<form onSubmit={handlePasswordChange} className="setting-form">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="currentPassword">Current Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="currentPassword"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
||||||
placeholder="Your current password"
|
|
||||||
required
|
|
||||||
disabled={passwordLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="newPassword">New Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="newPassword"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
placeholder="At least 6 characters"
|
|
||||||
required
|
|
||||||
disabled={passwordLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="confirmNewPassword">Confirm New Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="confirmNewPassword"
|
|
||||||
value={confirmNewPassword}
|
|
||||||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
|
||||||
placeholder="Re-enter new password"
|
|
||||||
required
|
|
||||||
disabled={passwordLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{passwordError && <div className="error">{passwordError}</div>}
|
|
||||||
{passwordSuccess && <div className="message-success">{passwordSuccess}</div>}
|
|
||||||
<button type="submit" className="button-primary" disabled={passwordLoading}>
|
|
||||||
{passwordLoading ? 'Updating...' : 'Update Password'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Actions Section */}
|
|
||||||
<section className="account-actions">
|
|
||||||
<button
|
|
||||||
className="button-secondary"
|
|
||||||
onClick={() => navigate('/game')}
|
|
||||||
>
|
|
||||||
Back to Game
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="button-danger"
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm('Are you sure you want to logout?')) {
|
|
||||||
logout()
|
|
||||||
navigate('/login')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,203 +1,156 @@
|
|||||||
.character-creation-container {
|
.char-creation-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
background: radial-gradient(circle at center, rgba(225, 29, 72, 0.08) 0%, var(--game-bg-app) 100%);
|
||||||
|
font-family: var(--game-font-main);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100vh;
|
|
||||||
padding: 2rem;
|
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-creation-card {
|
.char-creation-container {
|
||||||
background-color: #2a2a2a;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-creation-card h1 {
|
.char-creation-card {
|
||||||
font-size: 2rem;
|
padding: 3rem;
|
||||||
color: #646cff;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-title {
|
||||||
|
font-size: 3rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 0.5rem;
|
margin: 0;
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
text-shadow: 0 0 10px rgba(225, 29, 72, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.creation-subtitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #888;
|
color: var(--game-text-secondary);
|
||||||
margin-bottom: 2rem;
|
text-transform: uppercase;
|
||||||
}
|
letter-spacing: 1px;
|
||||||
|
|
||||||
.form-section {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section h2 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
color: #fff;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-hint {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #888;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.points-remaining {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.points-complete {
|
.creation-form {
|
||||||
color: #51cf66;
|
|
||||||
}
|
|
||||||
|
|
||||||
.points-over {
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-input {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 2rem;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.form-group-creation {
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-header label {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-control {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-control input {
|
.form-group-creation label {
|
||||||
flex: 1;
|
text-transform: uppercase;
|
||||||
text-align: center;
|
letter-spacing: 1px;
|
||||||
font-size: 1.2rem;
|
color: var(--game-text-secondary);
|
||||||
font-weight: bold;
|
font-size: 1rem;
|
||||||
padding: 0.5rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-button {
|
.attributes-section {
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background-color: #646cff;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.25s;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-button:hover:not(:disabled) {
|
.attributes-header {
|
||||||
background-color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-button:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-description {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #888;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-preview {
|
|
||||||
background: linear-gradient(135deg, rgba(100, 108, 255, 0.1) 0%, rgba(83, 91, 242, 0.1) 100%);
|
|
||||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stat {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
padding: 0.75rem 1rem;
|
padding-bottom: 0.5rem;
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-label {
|
.attributes-header h3 {
|
||||||
font-weight: 600;
|
text-transform: uppercase;
|
||||||
color: #aaa;
|
letter-spacing: 1px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--game-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-value {
|
.points-remaining {
|
||||||
font-size: 1.2rem;
|
font-weight: 700;
|
||||||
font-weight: bold;
|
color: var(--game-text-primary);
|
||||||
color: #646cff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.points-remaining.zero {
|
||||||
display: flex;
|
color: var(--game-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes-grid-creation {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions button {
|
.attribute-control {
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--game-bg-glass) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-name {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-buttons button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.error-message-creation {
|
||||||
.character-creation-container {
|
color: var(--game-text-danger);
|
||||||
padding: 1rem;
|
background: rgba(239, 68, 68, 0.1);
|
||||||
}
|
padding: 1rem;
|
||||||
|
border-left: 3px solid var(--game-text-danger);
|
||||||
.character-creation-card {
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creation-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-submit-btn {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.char-creation-card {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-creation-card h1 {
|
.creation-actions {
|
||||||
font-size: 1.5rem;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.stat-control input {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +1,50 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { GameButton } from './common/GameButton'
|
||||||
import './CharacterCreation.css'
|
import './CharacterCreation.css'
|
||||||
|
|
||||||
function CharacterCreation() {
|
function CharacterCreation() {
|
||||||
const { createCharacter } = useAuth()
|
const { createCharacter } = useAuth()
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [strength, setStrength] = useState(0)
|
const [stats, setStats] = useState({
|
||||||
const [agility, setAgility] = useState(0)
|
strength: 10,
|
||||||
const [endurance, setEndurance] = useState(0)
|
agility: 10,
|
||||||
const [intellect, setIntellect] = useState(0)
|
endurance: 10,
|
||||||
const [error, setError] = useState('')
|
intellect: 10
|
||||||
|
})
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const TOTAL_POINTS = 20
|
const totalPoints = 40
|
||||||
const usedPoints = strength + agility + endurance + intellect
|
const pointsUsed = stats.strength + stats.agility + stats.endurance + stats.intellect
|
||||||
const remainingPoints = TOTAL_POINTS - usedPoints
|
const pointsRemaining = totalPoints - pointsUsed
|
||||||
|
|
||||||
const calculateHP = (str: number) => 30 + (str * 2)
|
const handleStatChange = (stat: keyof typeof stats, delta: number) => {
|
||||||
const calculateStamina = (end: number) => 20 + (end * 1)
|
const newValue = stats[stat] + delta
|
||||||
|
if (newValue < 5 || newValue > 20) return
|
||||||
|
if (delta > 0 && pointsRemaining <= 0) return
|
||||||
|
|
||||||
const handleStatChange = (
|
setStats({
|
||||||
stat: 'strength' | 'agility' | 'endurance' | 'intellect',
|
...stats,
|
||||||
value: number
|
[stat]: newValue
|
||||||
) => {
|
})
|
||||||
// Prevent negative values
|
|
||||||
if (value < 0) return
|
|
||||||
|
|
||||||
const currentTotal = strength + agility + endurance + intellect
|
|
||||||
const otherStats = currentTotal - (stat === 'strength' ? strength : stat === 'agility' ? agility : stat === 'endurance' ? endurance : intellect)
|
|
||||||
|
|
||||||
// Prevent exceeding total points
|
|
||||||
if (otherStats + value > TOTAL_POINTS) {
|
|
||||||
value = TOTAL_POINTS - otherStats
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (stat) {
|
|
||||||
case 'strength':
|
|
||||||
setStrength(value)
|
|
||||||
break
|
|
||||||
case 'agility':
|
|
||||||
setAgility(value)
|
|
||||||
break
|
|
||||||
case 'endurance':
|
|
||||||
setEndurance(value)
|
|
||||||
break
|
|
||||||
case 'intellect':
|
|
||||||
setIntellect(value)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
if (pointsRemaining !== 0) {
|
||||||
|
setError('You must use all attribute points')
|
||||||
// Validation
|
|
||||||
if (name.length < 3 || name.length > 20) {
|
|
||||||
setError('Name must be between 3 and 20 characters')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usedPoints !== TOTAL_POINTS) {
|
|
||||||
setError(`You must allocate exactly ${TOTAL_POINTS} stat points (currently: ${usedPoints})`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strength < 0 || agility < 0 || endurance < 0 || intellect < 0) {
|
|
||||||
setError('Stats cannot be negative')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createCharacter({
|
await createCharacter({ ...stats, name })
|
||||||
name,
|
navigate('/game')
|
||||||
strength,
|
|
||||||
agility,
|
|
||||||
endurance,
|
|
||||||
intellect,
|
|
||||||
})
|
|
||||||
navigate('/characters')
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Failed to create character')
|
setError(err.response?.data?.detail || 'Failed to create character')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,175 +52,98 @@ function CharacterCreation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
navigate('/characters')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="character-creation-container">
|
<div className="char-creation-page">
|
||||||
<div className="character-creation-card">
|
<div className="char-creation-container">
|
||||||
<h1>Create Your Character</h1>
|
<div className="char-creation-card game-panel">
|
||||||
<p className="subtitle">Choose your name and distribute your stat points</p>
|
<h1 className="creation-title">Character Creation</h1>
|
||||||
|
<p className="creation-subtitle">Forge your survivor for the wasteland</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} className="creation-form">
|
||||||
{/* Name Input */}
|
<div className="form-group-creation">
|
||||||
<div className="form-section">
|
<label htmlFor="name">Survivor Name</label>
|
||||||
<label htmlFor="name">Character Name</label>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
id="name"
|
||||||
id="name"
|
className="game-input"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Enter character name"
|
placeholder="Enter survivor name..."
|
||||||
minLength={3}
|
required
|
||||||
maxLength={20}
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<p className="input-hint">3-20 characters, must be unique</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stat Allocation */}
|
|
||||||
<div className="form-section">
|
|
||||||
<h2>Stat Allocation</h2>
|
|
||||||
<div className="points-remaining">
|
|
||||||
<span className={remainingPoints === 0 ? 'points-complete' : remainingPoints < 0 ? 'points-over' : ''}>
|
|
||||||
Points Remaining: {remainingPoints} / {TOTAL_POINTS}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid">
|
|
||||||
<StatInput
|
|
||||||
label="Strength"
|
|
||||||
icon="💪"
|
|
||||||
value={strength}
|
|
||||||
onChange={(v) => handleStatChange('strength', v)}
|
|
||||||
description="Increases melee damage and carry capacity"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatInput
|
|
||||||
label="Agility"
|
|
||||||
icon="⚡"
|
|
||||||
value={agility}
|
|
||||||
onChange={(v) => handleStatChange('agility', v)}
|
|
||||||
description="Improves dodge chance and critical hits"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatInput
|
|
||||||
label="Endurance"
|
|
||||||
icon="🛡️"
|
|
||||||
value={endurance}
|
|
||||||
onChange={(v) => handleStatChange('endurance', v)}
|
|
||||||
description="Increases HP and stamina"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatInput
|
|
||||||
label="Intellect"
|
|
||||||
icon="🧠"
|
|
||||||
value={intellect}
|
|
||||||
onChange={(v) => handleStatChange('intellect', v)}
|
|
||||||
description="Enhances crafting and resource gathering"
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
maxLength={20}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Character Preview */}
|
<div className="attributes-section">
|
||||||
<div className="form-section character-preview">
|
<div className="attributes-header">
|
||||||
<h2>Character Preview</h2>
|
<h3>Attributes</h3>
|
||||||
<div className="preview-stats">
|
<div className={`points-remaining ${pointsRemaining === 0 ? 'zero' : ''}`}>
|
||||||
<div className="preview-stat">
|
Points Remaining: {pointsRemaining}
|
||||||
<span className="preview-label">HP:</span>
|
</div>
|
||||||
<span className="preview-value">{calculateHP(strength)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="preview-stat">
|
|
||||||
<span className="preview-label">Stamina:</span>
|
<div className="attributes-grid-creation">
|
||||||
<span className="preview-value">{calculateStamina(endurance)}</span>
|
{(Object.keys(stats) as Array<keyof typeof stats>).map((stat) => (
|
||||||
</div>
|
<div key={stat} className="attribute-control game-panel">
|
||||||
<div className="preview-stat">
|
<div className="attr-info">
|
||||||
<span className="preview-label">Level:</span>
|
<span className="attr-name">{stat}</span>
|
||||||
<span className="preview-value">1</span>
|
<span className="attr-value">{stats[stat]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="attr-buttons">
|
||||||
|
<GameButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleStatChange(stat, -1)
|
||||||
|
}}
|
||||||
|
disabled={loading || stats[stat] <= 5}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</GameButton>
|
||||||
|
<GameButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleStatChange(stat, 1)
|
||||||
|
}}
|
||||||
|
disabled={loading || stats[stat] >= 20 || pointsRemaining <= 0}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</GameButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error-message-creation">{error}</div>}
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="creation-actions">
|
||||||
<button
|
<GameButton
|
||||||
type="button"
|
variant="secondary"
|
||||||
className="button-secondary"
|
onClick={() => navigate('/characters')}
|
||||||
onClick={handleCancel}
|
disabled={loading}
|
||||||
disabled={loading}
|
>
|
||||||
>
|
Cancel
|
||||||
Cancel
|
</GameButton>
|
||||||
</button>
|
<GameButton
|
||||||
<button
|
variant="primary"
|
||||||
type="submit"
|
size="lg"
|
||||||
className="button-primary"
|
className="create-submit-btn"
|
||||||
disabled={loading || remainingPoints !== 0}
|
disabled={loading || pointsRemaining !== 0 || !name.trim()}
|
||||||
>
|
onClick={() => { }} // Handled by form submit
|
||||||
{loading ? 'Creating...' : 'Create Character'}
|
>
|
||||||
</button>
|
{loading ? 'Forging...' : 'Create Survivor'}
|
||||||
</div>
|
</GameButton>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatInput({
|
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
value: number
|
|
||||||
onChange: (value: number) => void
|
|
||||||
description: string
|
|
||||||
disabled: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="stat-input">
|
|
||||||
<div className="stat-header">
|
|
||||||
<span className="stat-icon">{icon}</span>
|
|
||||||
<label>{label}</label>
|
|
||||||
</div>
|
|
||||||
<div className="stat-control">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="stat-button"
|
|
||||||
onClick={() => onChange(value - 1)}
|
|
||||||
disabled={disabled || value <= 0}
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(parseInt(e.target.value) || 0)}
|
|
||||||
min="0"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="stat-button"
|
|
||||||
onClick={() => onChange(value + 1)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="stat-description">{description}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CharacterCreation
|
export default CharacterCreation
|
||||||
|
|||||||
@@ -1,239 +1,277 @@
|
|||||||
.character-selection-container {
|
/* Character Selection Page Styles */
|
||||||
|
|
||||||
|
/* Base container */
|
||||||
|
.char-selection-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 2rem;
|
padding: 3rem 2rem;
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
background: radial-gradient(circle at center, rgba(225, 29, 72, 0.08) 0%, var(--game-bg-app) 100%);
|
||||||
|
font-family: var(--game-font-main);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-selection-header {
|
.char-selection-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.char-selection-header {
|
||||||
|
padding: 2.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 3rem;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-bottom: 2px solid var(--game-color-primary-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-main {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
text-shadow: 0 0 15px rgba(225, 29, 72, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sub {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards Grid */
|
||||||
|
.char-cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Character Card */
|
||||||
|
.char-card-ui {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--game-bg-glass) !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
/* Default border */
|
||||||
|
height: 100%;
|
||||||
|
/* Ensure uniform height */
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-card-ui:hover {
|
||||||
|
border-color: var(--game-color-primary);
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: var(--game-shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar Section */
|
||||||
|
.char-avatar-box {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-selection-header h1 {
|
.char-avatar-box img {
|
||||||
font-size: 2.5rem;
|
|
||||||
color: #646cff;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-selection-header .subtitle {
|
|
||||||
color: #888;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.characters-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-card {
|
|
||||||
background-color: #2a2a2a;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-avatar {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: linear-gradient(135deg, #646cff 0%, #535bf2 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-avatar img {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder {
|
.char-card-ui:hover .char-avatar-box img {
|
||||||
font-size: 2rem;
|
opacity: 1;
|
||||||
font-weight: bold;
|
transform: scale(1.05);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-info {
|
.avatar-placeholder-ui {
|
||||||
text-align: center;
|
font-size: 4rem;
|
||||||
}
|
font-weight: 700;
|
||||||
|
color: var(--game-border-color);
|
||||||
.character-info h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-stats {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #aaa;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-attributes {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-attributes span {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-meta {
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-actions button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-danger {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.25s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-danger:hover {
|
|
||||||
background-color: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-danger:disabled {
|
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-character-card {
|
/* Info Section */
|
||||||
cursor: pointer;
|
.char-info-box {
|
||||||
border: 2px dashed #646cff;
|
padding: 1.5rem;
|
||||||
background-color: rgba(100, 108, 255, 0.1);
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-meta-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-meta-header h3 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge {
|
||||||
|
background: var(--game-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Preview */
|
||||||
|
.char-stats-preview {
|
||||||
|
display: flex;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--game-border-color);
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-preview {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-preview .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-preview .value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attributes Grid */
|
||||||
|
.char-attr-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr-item {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-played {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--game-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions Section */
|
||||||
|
.char-card-actions {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 250px;
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-character-card:hover {
|
/* Create Card Styles */
|
||||||
background-color: rgba(100, 108, 255, 0.2);
|
.create-card {
|
||||||
border-color: #535bf2;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
/* Approximate height of other cards */
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-character-icon {
|
.create-card:hover {
|
||||||
font-size: 4rem;
|
border-color: var(--game-color-primary);
|
||||||
color: #646cff;
|
background: rgba(225, 29, 72, 0.05) !important;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-character-card h3 {
|
.create-icon-wrapper {
|
||||||
color: #646cff;
|
width: 80px;
|
||||||
margin-bottom: 0.5rem;
|
height: 80px;
|
||||||
}
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
.create-character-subtitle {
|
display: flex;
|
||||||
color: #888;
|
align-items: center;
|
||||||
font-size: 0.9rem;
|
justify-content: center;
|
||||||
}
|
|
||||||
|
|
||||||
.premium-banner {
|
|
||||||
background: linear-gradient(135deg, #646cff 0%, #535bf2 100%);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 2rem auto 0;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-banner h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-banner p {
|
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-card:hover .create-icon-wrapper {
|
||||||
|
background: var(--game-color-primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 15px rgba(225, 29, 72, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-card h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-subtitle {
|
||||||
|
color: var(--game-text-secondary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.premium-banner button {
|
/* Premium Banner */
|
||||||
background-color: white;
|
.premium-banner-ui {
|
||||||
color: #646cff;
|
grid-column: 1 / -1;
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-banner button:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-characters {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #888;
|
padding: 2rem;
|
||||||
padding: 3rem;
|
margin-top: 2rem;
|
||||||
max-width: 500px;
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.1) 0%, rgba(37, 99, 235, 0.05) 100%);
|
||||||
margin: 0 auto;
|
border: 1px solid rgba(37, 99, 235, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-characters p {
|
.premium-banner-ui h3 {
|
||||||
margin-bottom: 1rem;
|
color: #60a5fa;
|
||||||
font-size: 1.1rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* No Characters Empty State */
|
||||||
.character-selection-container {
|
.no-chars-box {
|
||||||
padding: 1rem;
|
grid-column: 1 / -1;
|
||||||
}
|
text-align: center;
|
||||||
|
padding: 4rem;
|
||||||
.character-selection-header h1 {
|
color: var(--game-text-secondary);
|
||||||
font-size: 1.8rem;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.logout-button {
|
|
||||||
position: static;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.characters-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
import { Character } from '../services/api'
|
import { Character } from '../services/api'
|
||||||
import './CharacterSelection.css'
|
import { GameButton } from './common/GameButton'
|
||||||
import { GameTooltip } from './common/GameTooltip'
|
import { GameTooltip } from './common/GameTooltip'
|
||||||
|
import { GameModal } from './game/GameModal'
|
||||||
|
import './CharacterSelection.css'
|
||||||
|
|
||||||
function CharacterSelection() {
|
function CharacterSelection() {
|
||||||
const { characters, account, selectCharacter, deleteCharacter, logout } = useAuth()
|
const { characters, account, selectCharacter, deleteCharacter } = useAuth()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
const [characterToDelete, setCharacterToDelete] = useState<number | null>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const handleSelectCharacter = async (characterId: number) => {
|
const handleSelectCharacter = async (characterId: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -20,26 +25,31 @@ function CharacterSelection() {
|
|||||||
await selectCharacter(characterId)
|
await selectCharacter(characterId)
|
||||||
navigate('/game')
|
navigate('/game')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Failed to select character')
|
setError(err.response?.data?.detail || t('common.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteCharacter = async (characterId: number) => {
|
const confirmDelete = (characterId: number) => {
|
||||||
if (!window.confirm('Are you sure you want to delete this character? This action cannot be undone.')) {
|
setCharacterToDelete(characterId)
|
||||||
return
|
setShowDeleteModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeletingId(characterId)
|
const handleDeleteCharacter = async () => {
|
||||||
|
if (!characterToDelete) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteCharacter(characterId)
|
await deleteCharacter(characterToDelete)
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setCharacterToDelete(null)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Failed to delete character')
|
setError(err.response?.data?.detail || t('common.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,53 +62,73 @@ function CharacterSelection() {
|
|||||||
const canCreateCharacter = characters.length < maxCharacters
|
const canCreateCharacter = characters.length < maxCharacters
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="character-selection-container">
|
<div className="char-selection-page">
|
||||||
<div className="character-selection-header">
|
<div className="char-selection-container">
|
||||||
<h1>Select Your Character</h1>
|
<div className="char-selection-header game-panel">
|
||||||
<p className="subtitle">Echoes of the Ash</p>
|
<h1 className="title-main">{t('characters.title')}</h1>
|
||||||
<button className="button-secondary logout-button" onClick={logout}>
|
<p className="subtitle-sub">Echoes of the Ash</p>
|
||||||
Logout
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error-banner game-panel">{error}</div>}
|
||||||
|
|
||||||
<div className="characters-grid">
|
<div className="char-cards-grid">
|
||||||
{characters.map((character) => (
|
{characters.map((character) => (
|
||||||
<CharacterCard
|
<CharacterCard
|
||||||
key={character.id}
|
key={character.id}
|
||||||
character={character}
|
character={character}
|
||||||
onSelect={() => handleSelectCharacter(character.id)}
|
onSelect={() => handleSelectCharacter(character.id)}
|
||||||
onDelete={() => handleDeleteCharacter(character.id)}
|
onDelete={() => confirmDelete(character.id)}
|
||||||
loading={loading || deletingId === character.id}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{canCreateCharacter && (
|
{canCreateCharacter && (
|
||||||
<div className="character-card create-character-card" onClick={handleCreateCharacter}>
|
<div className="char-card-ui create-card game-panel" onClick={handleCreateCharacter}>
|
||||||
<div className="create-character-icon">+</div>
|
<div className="create-icon-wrapper">
|
||||||
<h3>Create New Character</h3>
|
<span className="create-icon">+</span>
|
||||||
<p className="create-character-subtitle">
|
</div>
|
||||||
{characters.length} / {maxCharacters} slots used
|
<h3>{t('characters.create.title', 'Create New')}</h3>
|
||||||
</p>
|
<p className="create-subtitle">
|
||||||
|
{characters.length} / {maxCharacters} {t('characters.create.slots', 'slots used')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canCreateCharacter && !isPremium && (
|
||||||
|
<div className="premium-banner-ui game-panel">
|
||||||
|
<h3>{t('characters.premium.title', 'Character Limit Reached')}</h3>
|
||||||
|
<p>{t('characters.premium.description', 'Upgrade to Premium to create up to 10 characters!')}</p>
|
||||||
|
<GameButton variant="primary" onClick={() => { }}>{t('characters.premium.upgrade', 'Upgrade to Premium')}</GameButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{characters.length === 0 && (
|
||||||
|
<div className="no-chars-box game-panel">
|
||||||
|
<p>{t('characters.noCharacters')}</p>
|
||||||
|
<p>{t('characters.createFirst')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteModal && (
|
||||||
|
<GameModal
|
||||||
|
title={t('characters.deleteModal.title', 'Delete Character')}
|
||||||
|
onClose={() => setShowDeleteModal(false)}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end', width: '100%' }}>
|
||||||
|
<GameButton variant="secondary" onClick={() => setShowDeleteModal(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</GameButton>
|
||||||
|
<GameButton variant="danger" onClick={handleDeleteCharacter}>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</GameButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>{t('characters.deleteModal.confirm', 'Are you sure you want to delete this character? This action cannot be undone.')}</p>
|
||||||
|
</GameModal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!canCreateCharacter && !isPremium && (
|
|
||||||
<div className="premium-banner">
|
|
||||||
<h3>Character Limit Reached</h3>
|
|
||||||
<p>Upgrade to Premium to create up to 10 characters!</p>
|
|
||||||
<button className="button-primary">Upgrade to Premium - $4.99</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{characters.length === 0 && (
|
|
||||||
<div className="no-characters">
|
|
||||||
<p>You don't have any characters yet.</p>
|
|
||||||
<p>Click the "Create New Character" button to get started!</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -114,64 +144,97 @@ function CharacterCard({
|
|||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
loading: boolean
|
loading: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString()
|
try {
|
||||||
|
if (!dateString) return 'Never'
|
||||||
|
// Timestamp from API is float seconds, convert to ms
|
||||||
|
const timestamp = typeof dateString === 'number' ? dateString * 1000 : dateString
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
// Check if date is valid (1970 usually means 0 timestamp/invalid in some contexts, but let's just check validity)
|
||||||
|
if (isNaN(date.getTime()) || date.getFullYear() === 1970) return 'Never'
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
} catch (e) {
|
||||||
|
return 'Invalid Date'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="character-card">
|
<div className="char-card-ui game-panel">
|
||||||
<div className="character-avatar">
|
<div className="char-avatar-box">
|
||||||
{character.avatar_data?.image ? (
|
{character.avatar_data?.image ? (
|
||||||
<img src={character.avatar_data.image} alt={character.name} />
|
<img src={character.avatar_data.image} alt={character.name} />
|
||||||
) : (
|
) : (
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder-ui">
|
||||||
{character.name.substring(0, 2).toUpperCase()}
|
{character.name.substring(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="character-info">
|
<div className="char-info-box">
|
||||||
<h3>{character.name}</h3>
|
<div className="char-meta-header">
|
||||||
<div className="character-stats">
|
<h3>{character.name}</h3>
|
||||||
<span className="stat">Level {character.level}</span>
|
<span className="level-badge" style={{ clipPath: 'var(--game-clip-path-sm)', borderRadius: 0 }}>
|
||||||
<span className="stat">HP: {character.hp}/{character.max_hp}</span>
|
{t('stats.level')} {character.level}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="char-stats-preview">
|
||||||
|
<div className="stat-preview">
|
||||||
|
<span className="label text-red-400">{t('stats.hp')}</span>
|
||||||
|
<span className="value">{character.hp}/{character.max_hp}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-preview">
|
||||||
|
<span className="label text-yellow-400">{t('stats.stamina')}</span>
|
||||||
|
<span className="value">{character.stamina}/{character.max_stamina}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-preview">
|
||||||
|
<span className="label text-blue-400">{t('stats.weight')}</span>
|
||||||
|
<span className="value">{character.weight}/{character.max_weight}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-preview">
|
||||||
|
<span className="label text-purple-400">{t('stats.volume')}</span>
|
||||||
|
<span className="value">{character.volume}/{character.max_volume}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="character-attributes">
|
<div className="char-attr-grid">
|
||||||
<GameTooltip content="Strength">
|
<GameTooltip content={t('stats.strength')}>
|
||||||
<span className="stat-icon">💪 {character.strength}</span>
|
<div className="attr-item">💪 {character.strength}</div>
|
||||||
</GameTooltip>
|
</GameTooltip>
|
||||||
<GameTooltip content="Agility">
|
<GameTooltip content={t('stats.agility')}>
|
||||||
<span>⚡ {character.agility}</span>
|
<div className="attr-item">⚡ {character.agility}</div>
|
||||||
</GameTooltip>
|
</GameTooltip>
|
||||||
<GameTooltip content="Endurance">
|
<GameTooltip content={t('stats.endurance')}>
|
||||||
<span>🛡️ {character.endurance}</span>
|
<div className="attr-item">🛡️ {character.endurance}</div>
|
||||||
</GameTooltip>
|
</GameTooltip>
|
||||||
<GameTooltip content="Intellect">
|
<GameTooltip content={t('stats.intellect')}>
|
||||||
<span>🧠 {character.intellect}</span>
|
<div className="attr-item">🧠 {character.intellect}</div>
|
||||||
</GameTooltip>
|
</GameTooltip>
|
||||||
</div>
|
</div>
|
||||||
<p className="character-meta">
|
|
||||||
Last played: {formatDate(character.last_played_at)}
|
<p className="last-played">
|
||||||
|
{t('characters.lastActive')}: {formatDate(character.last_played_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="character-actions">
|
<div className="char-card-actions">
|
||||||
<button
|
<GameButton
|
||||||
className="button-primary"
|
variant="primary"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
className="play-btn"
|
||||||
>
|
>
|
||||||
{loading ? 'Loading...' : 'Play'}
|
{loading ? t('common.loading') : t('characters.play')}
|
||||||
</button>
|
</GameButton>
|
||||||
<button
|
<GameButton
|
||||||
className="button-danger"
|
variant="danger"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Delete
|
{t('characters.delete')}
|
||||||
</button>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ html {
|
|||||||
|
|
||||||
.game-main {
|
.game-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: var(--game-padding-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -332,7 +332,7 @@ html {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
position: relative;
|
position: relative;
|
||||||
clip-path: var(--game-clip-path-sm);
|
clip-path: var(--game-clip-path);
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-search-container:focus-within {
|
.game-search-container:focus-within {
|
||||||
@@ -406,17 +406,6 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile fallback */
|
/* Mobile fallback */
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.explore-tab-desktop {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-sidebar,
|
|
||||||
.right-sidebar {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-info {
|
.location-info {
|
||||||
background: var(--game-bg-panel);
|
background: var(--game-bg-panel);
|
||||||
@@ -699,7 +688,7 @@ html {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid rgba(255, 107, 107, 0.3);
|
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||||
clip-path: var(--game-clip-path);
|
clip-path: var(--game-clip-path);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -1213,6 +1202,7 @@ body.no-scroll {
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
clip-path: var(--game-clip-path);
|
clip-path: var(--game-clip-path);
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-card:hover {
|
.entity-card:hover {
|
||||||
@@ -1379,10 +1369,12 @@ body.no-scroll {
|
|||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2031,8 +2023,8 @@ body.no-scroll {
|
|||||||
/* Changed from center to space-between */
|
/* Changed from center to space-between */
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
/* Fixed dimensions for consistent sizing */
|
/* Fixed dimensions for consistent sizing */
|
||||||
height: 100px;
|
height: 90px;
|
||||||
width: 100%;
|
width: 90px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -2072,14 +2064,17 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.equipment-slot.filled {
|
.equipment-slot.filled {
|
||||||
border-color: rgba(255, 107, 107, 0.5);
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
clip-path: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.equipment-slot.filled:hover {
|
.equipment-slot.filled:hover {
|
||||||
border-color: #ff6b6b;
|
transform: none;
|
||||||
background: rgba(255, 107, 107, 0.1);
|
box-shadow: none;
|
||||||
transform: scale(1.02);
|
background: transparent;
|
||||||
box-shadow: 0 0 15px rgba(255, 107, 107, 0.3);
|
border: none;
|
||||||
z-index: 10001;
|
z-index: 10001;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2096,36 +2091,6 @@ body.no-scroll {
|
|||||||
/* Space out elements */
|
/* Space out elements */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* New unequip button in top-right corner */
|
|
||||||
.equipment-unequip-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: rgba(244, 67, 54, 0.9);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
/* clip-path removed to ensure square box */
|
|
||||||
aspect-ratio: 1;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: bold;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
padding: 0;
|
|
||||||
z-index: 10;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equipment-unequip-btn:hover {
|
|
||||||
background: rgba(244, 67, 54, 1);
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 2px 8px rgba(244, 67, 54, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Equipment tooltip - shows on slot hover */
|
/* Equipment tooltip - shows on slot hover */
|
||||||
.equipment-tooltip {
|
.equipment-tooltip {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -3623,33 +3588,6 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Combat View */
|
/* Responsive Combat View */
|
||||||
@media (max-width: 768px) {
|
|
||||||
.combat-view {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combat-header-inline h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combat-enemy-info-inline h3 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combat-actions-inline {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
/* Side by side on mobile */
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combat-log-container {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combat-log-messages {
|
|
||||||
max-height: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Centered headings for consistency */
|
/* Centered headings for consistency */
|
||||||
.centered-heading {
|
.centered-heading {
|
||||||
@@ -3835,9 +3773,7 @@ body.no-scroll {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============= MOBILE SLIDING MENUS ============= */
|
/* Hide mobile menu elements (desktop-only game) */
|
||||||
|
|
||||||
/* Hide mobile menu buttons on desktop */
|
|
||||||
.mobile-menu-buttons {
|
.mobile-menu-buttons {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -3846,477 +3782,14 @@ body.no-scroll {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile menu overlay (darkens background when menu is open) */
|
|
||||||
.mobile-menu-overlay {
|
.mobile-menu-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
z-index: 998;
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile header toggle button */
|
|
||||||
.mobile-header-toggle {
|
.mobile-header-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Styles */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
|
|
||||||
/* Tab-style navigation bar at bottom */
|
|
||||||
.mobile-menu-buttons {
|
|
||||||
display: flex;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: rgba(20, 20, 20, 1) !important;
|
|
||||||
/* Fully opaque */
|
|
||||||
border-top: 2px solid rgba(255, 107, 107, 0.5);
|
|
||||||
z-index: 1000;
|
|
||||||
/* Always on top */
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.8);
|
|
||||||
justify-content: space-around;
|
|
||||||
gap: 0;
|
|
||||||
height: 65px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn {
|
|
||||||
flex: 1;
|
|
||||||
height: 55px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 10%;
|
|
||||||
right: 10%;
|
|
||||||
height: 3px;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 3px 3px 0 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn:active {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn.left-btn::after {
|
|
||||||
background: rgba(255, 107, 107, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn.bottom-btn::after {
|
|
||||||
background: rgba(255, 193, 7, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn.right-btn::after {
|
|
||||||
background: rgba(107, 147, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active tab styles */
|
|
||||||
.mobile-menu-btn.left-btn.active {
|
|
||||||
color: rgb(255, 107, 107);
|
|
||||||
background: rgba(255, 107, 107, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn.bottom-btn.active {
|
|
||||||
color: rgb(255, 193, 7);
|
|
||||||
background: rgba(255, 193, 7, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn.right-btn.active {
|
|
||||||
color: rgb(107, 147, 255);
|
|
||||||
background: rgba(107, 147, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn.active::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-btn:not(.active)::after {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable bottom-btn during combat */
|
|
||||||
.mobile-menu-btn.bottom-btn:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show overlay when any menu is open */
|
|
||||||
.mobile-menu-overlay {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide desktop 3-column layout on mobile */
|
|
||||||
.explore-tab-desktop {
|
|
||||||
display: block !important;
|
|
||||||
position: relative;
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile panels - hidden by default, slide in when open */
|
|
||||||
.mobile-menu-panel {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
bottom: 65px;
|
|
||||||
/* Stop 65px from bottom (above tab bar) */
|
|
||||||
width: 85vw;
|
|
||||||
max-width: 400px;
|
|
||||||
background: linear-gradient(135deg, rgba(20, 20, 20, 0.98), rgba(40, 40, 40, 0.98));
|
|
||||||
z-index: 999;
|
|
||||||
/* Below tab bar */
|
|
||||||
overflow-y: auto;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
padding: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
/* No extra padding needed */
|
|
||||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Left sidebar - slides from left */
|
|
||||||
.left-sidebar.mobile-menu-panel {
|
|
||||||
left: 0;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
border-right: 3px solid rgba(255, 107, 107, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-sidebar.mobile-menu-panel.open {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right sidebar - slides from right */
|
|
||||||
.right-sidebar.mobile-menu-panel {
|
|
||||||
right: 0;
|
|
||||||
transform: translateX(100%);
|
|
||||||
border-left: 3px solid rgba(107, 147, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-sidebar.mobile-menu-panel.open {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bottom panel (ground entities) - slides from bottom */
|
|
||||||
.ground-entities.mobile-menu-panel.bottom {
|
|
||||||
top: auto;
|
|
||||||
bottom: 65px;
|
|
||||||
/* Start 65px from bottom (above tab bar) */
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
height: calc(70vh - 65px);
|
|
||||||
/* Height minus tab bar */
|
|
||||||
transform: translateY(calc(100% + 65px));
|
|
||||||
/* Hide below screen */
|
|
||||||
border-top: 3px solid rgba(255, 193, 7, 0.5);
|
|
||||||
border-radius: 20px 20px 0 0;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ground-entities.mobile-menu-panel.bottom.open {
|
|
||||||
transform: translateY(0);
|
|
||||||
/* Slide up to bottom: 65px position */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep center content always visible on mobile */
|
|
||||||
.center-content {
|
|
||||||
display: block !important;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide sidebars and ground entities by default on mobile (until menu opened) */
|
|
||||||
.left-sidebar:not(.open),
|
|
||||||
.right-sidebar:not(.open),
|
|
||||||
.ground-entities:not(.open) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When panel is open, show it */
|
|
||||||
.mobile-menu-panel.open {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust center content to be full width on mobile */
|
|
||||||
.location-info,
|
|
||||||
.message-box {
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make compass slightly smaller on mobile when in panel */
|
|
||||||
.mobile-menu-panel .compass-grid {
|
|
||||||
grid-template-columns: repeat(3, 70px);
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-panel .compass-btn {
|
|
||||||
width: 70px;
|
|
||||||
height: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-panel .compass-center {
|
|
||||||
width: 70px;
|
|
||||||
height: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Always show item action buttons on mobile (no hover needed) */
|
|
||||||
.inventory-item-row-hover .item-actions-hover {
|
|
||||||
display: flex !important;
|
|
||||||
position: static;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-item-row-hover {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-action-btn {
|
|
||||||
min-width: 70px;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure right sidebar has proper background */
|
|
||||||
.right-sidebar {
|
|
||||||
background: linear-gradient(135deg, rgba(20, 20, 20, 0.98), rgba(40, 40, 40, 0.98));
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make combat view always visible and prominent on mobile */
|
|
||||||
.combat-view {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Combat mode - maintain tab bar */
|
|
||||||
.game-main:has(.combat-view) .mobile-menu-buttons {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix item tooltips on mobile - allow overflow and reposition */
|
|
||||||
.inventory-items-scrollable {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-panel {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-sidebar.mobile-menu-panel {
|
|
||||||
overflow-y: auto !important;
|
|
||||||
overflow-x: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-info-btn-container .item-info-tooltip {
|
|
||||||
right: auto;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
max-width: 90vw;
|
|
||||||
z-index: 10001;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make sure tooltips show on touch */
|
|
||||||
.item-info-btn-container:active .item-info-tooltip,
|
|
||||||
.item-info-btn-container.show-tooltip .item-info-tooltip {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide header on mobile, show toggle button */
|
|
||||||
.game-container {
|
|
||||||
position: relative;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
/* Prevent header from going outside viewport */
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-header {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 80%;
|
|
||||||
max-width: 300px;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 999;
|
|
||||||
background: rgba(20, 20, 20, 0.98) !important;
|
|
||||||
border-right: 2px solid rgba(255, 107, 107, 0.5);
|
|
||||||
border-bottom: none;
|
|
||||||
transform: none;
|
|
||||||
transition: left 0.3s ease;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.8);
|
|
||||||
padding: 1.5rem !important;
|
|
||||||
padding-top: 4rem !important;
|
|
||||||
/* Space for X button */
|
|
||||||
padding-bottom: calc(65px + 1.5rem) !important;
|
|
||||||
/* Space for tab bar + padding */
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-header.open {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-header h1 {
|
|
||||||
font-size: 1.3rem !important;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 107, 107, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-header .nav-links {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-header .user-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid rgba(255, 107, 107, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link,
|
|
||||||
.username-link {
|
|
||||||
padding: 0.75rem 1rem !important;
|
|
||||||
font-size: 0.95rem !important;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-secondary {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-header-toggle {
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
width: 45px;
|
|
||||||
height: 45px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(135deg, rgba(40, 40, 40, 0.95), rgba(60, 60, 60, 0.95));
|
|
||||||
border: 2px solid rgba(255, 107, 107, 0.5);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 1001;
|
|
||||||
/* Above sidebar */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-header-toggle:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make game-main fill space and account for tab bar */
|
|
||||||
.game-main {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: 65px;
|
|
||||||
/* Space for tab bar */
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Compact location titles on mobile */
|
|
||||||
.location-info h2 {
|
|
||||||
font-size: 1.2rem !important;
|
|
||||||
line-height: 1.3 !important;
|
|
||||||
margin-bottom: 0.3rem !important;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-badge {
|
|
||||||
font-size: 0.7rem !important;
|
|
||||||
padding: 0.2rem 0.4rem !important;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast notification for messages */
|
|
||||||
.message-box {
|
|
||||||
position: fixed !important;
|
|
||||||
top: 60px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 90%;
|
|
||||||
max-width: 400px;
|
|
||||||
z-index: 9999 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
animation: slideDown 0.3s ease;
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.6);
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(40, 40, 40, 0.98) !important;
|
|
||||||
/* Opaque background */
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) translateY(-20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-box.fade-out {
|
|
||||||
animation: fadeOut 0.3s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeOut {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) translateY(-20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility classes */
|
/* Utility classes */
|
||||||
.text-danger {
|
.text-danger {
|
||||||
color: #ff4444 !important;
|
color: #ff4444 !important;
|
||||||
@@ -4573,6 +4046,120 @@ body.no-scroll {
|
|||||||
border-color: #ff4444 !important;
|
border-color: #ff4444 !important;
|
||||||
box-shadow: 0 0 10px rgba(255, 68, 68, 0.5) !important;
|
box-shadow: 0 0 10px rgba(255, 68, 68, 0.5) !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
/* Ensure it stays flex */
|
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GLOBAL GAME ITEM CARD */
|
||||||
|
.game-item-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--game-bg-card);
|
||||||
|
border: 1px solid var(--game-border-color);
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
padding: 0.5rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: var(--game-shadow-sm);
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-card:hover,
|
||||||
|
.game-item-card.active {
|
||||||
|
border-color: #63b3ed;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-card.equipped {
|
||||||
|
border-color: #63b3ed;
|
||||||
|
background: rgba(66, 153, 225, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-image-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-emoji.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tier Border Colors matching LocationView/TradeModal */
|
||||||
|
.game-item-card.text-tier-0 {
|
||||||
|
border-color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-card.text-tier-1 {
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-card.text-tier-2 {
|
||||||
|
border-color: #68d391;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-card.text-tier-3 {
|
||||||
|
border-color: #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-card.text-tier-4 {
|
||||||
|
border-color: #9f7aea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-card.text-tier-5 {
|
||||||
|
border-color: #ed8936;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Item Quantity / Stack Badge */
|
||||||
|
.item-quantity-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 5px;
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
|
font-weight: bold;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-item-value-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #ffd700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 5px;
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
|
font-weight: bold;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -287,6 +287,26 @@ function Game() {
|
|||||||
// Handled by GameHeader, ignore here
|
// Handled by GameHeader, ignore here
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'global_quest_completed':
|
||||||
|
console.log('🌍 Global Quest Completed', message.data)
|
||||||
|
actions.addLocationMessage(`🎉 GLOBAL QUEST COMPLETED: ${message.data.title}`)
|
||||||
|
|
||||||
|
// Show unlocks if any
|
||||||
|
if (message.data.outcome?.unlocks && message.data.outcome.unlocks.length > 0) {
|
||||||
|
const unlocks = message.data.outcome.unlocks;
|
||||||
|
// @ts-ignore
|
||||||
|
const locationUnlocks = unlocks.filter((u: any) => u.type === 'location').map((u: any) => u.name).join(', ');
|
||||||
|
// @ts-ignore
|
||||||
|
const interactableUnlocks = unlocks.filter((u: any) => u.type === 'interactable').map((u: any) => u.name).join(', ');
|
||||||
|
|
||||||
|
if (locationUnlocks) actions.addLocationMessage(`🔓 New Locations Unlocked: ${locationUnlocks}`);
|
||||||
|
if (interactableUnlocks) actions.addLocationMessage(`🔓 New Content Unlocked: ${interactableUnlocks}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh everything to reflect unlocks and rewards
|
||||||
|
actions.fetchGameData()
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown WebSocket message type:', message.type)
|
console.log('Unknown WebSocket message type:', message.type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,63 +324,4 @@
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.2), transparent);
|
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.game-header {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left h1 {
|
|
||||||
display: none;
|
|
||||||
/* Hide full text on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title-container::after {
|
|
||||||
content: 'EotA';
|
|
||||||
/* Short title */
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
gap: 4px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
padding: 0 0.6rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
clip-path: none;
|
|
||||||
/* Simplify for mobile touch */
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-count-badge {
|
|
||||||
padding: 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dot only */
|
|
||||||
|
|
||||||
.username-link {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
max-width: 80px;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: block;
|
|
||||||
/* block for ellipsis */
|
|
||||||
line-height: 34px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,52 +1,47 @@
|
|||||||
|
/* LandingPage.css */
|
||||||
|
|
||||||
.landing-page {
|
.landing-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%);
|
background-color: #050508;
|
||||||
color: #fff;
|
color: #e2e8f0;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hero Section */
|
/* --- Hero Section --- */
|
||||||
.hero-section {
|
.hero-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
text-align: center;
|
||||||
overflow: hidden;
|
padding: 0 1rem;
|
||||||
|
background-image: url('/landing-bg.webp');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gradient Overlay for better text readability */
|
||||||
.hero-gradient {
|
.hero-gradient {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: radial-gradient(ellipse at center, rgba(100, 108, 255, 0.15) 0%, transparent 70%);
|
background: radial-gradient(circle at center, rgba(5, 5, 8, 0.4) 0%, rgba(5, 5, 8, 0.95) 90%);
|
||||||
pointer-events: none;
|
z-index: 1;
|
||||||
animation: pulse 8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-content {
|
.hero-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
text-align: center;
|
animation: fadeUp 1s ease-out;
|
||||||
animation: fadeInUp 1s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(30px);
|
transform: translateY(30px);
|
||||||
@@ -59,40 +54,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
font-size: 4rem;
|
font-size: 4rem;
|
||||||
font-weight: 700;
|
font-weight: 900;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
background: linear-gradient(135deg, #646cff 0%, #8b5cf6 100%);
|
color: #fff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-shadow: 0 0 20px rgba(225, 29, 72, 0.6);
|
||||||
|
background: linear-gradient(to right, #fff 20%, #fda4af 50%, #fff 80%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-size: 200% auto;
|
||||||
animation: glow 3s ease-in-out infinite;
|
animation: shine 5s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes glow {
|
@keyframes shine {
|
||||||
|
to {
|
||||||
0%,
|
background-position: 200% center;
|
||||||
100% {
|
|
||||||
filter: drop-shadow(0 0 20px rgba(100, 108, 255, 0.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
filter: drop-shadow(0 0 30px rgba(100, 108, 255, 0.8));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #ccc;
|
color: #cbd5e1;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 2rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-description {
|
.hero-description {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #999;
|
color: #94a3b8;
|
||||||
line-height: 1.8;
|
margin-bottom: 3rem;
|
||||||
margin-bottom: 2.5rem;
|
line-height: 1.6;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
@@ -100,146 +94,205 @@
|
|||||||
|
|
||||||
.hero-buttons {
|
.hero-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-button {
|
.hero-button {
|
||||||
padding: 1rem 2.5rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
transition: all 0.3s ease;
|
font-family: 'Saira Condensed', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 1.2rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-button:hover {
|
/* --- Features Section --- */
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Features Section */
|
|
||||||
.features-section {
|
.features-section {
|
||||||
padding: 6rem 2rem;
|
padding: 6rem 2rem;
|
||||||
background: linear-gradient(180deg, transparent 0%, rgba(100, 108, 255, 0.05) 100%);
|
background-color: #050508;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tech grid background for features */
|
||||||
|
.features-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(225, 29, 72, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(225, 29, 72, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 4rem;
|
||||||
color: #646cff;
|
color: #fff;
|
||||||
font-weight: 600;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
background: #e11d48;
|
||||||
|
margin: 1rem auto 0;
|
||||||
|
box-shadow: 0 0 10px #e11d48;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 3rem;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
z-index: 2;
|
||||||
gap: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
background: rgba(42, 42, 42, 0.6);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
backdrop-filter: blur(10px);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border: 1px solid rgba(100, 108, 255, 0.2);
|
padding: 2.5rem;
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2rem;
|
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
animation: fadeIn 0.6s ease-out;
|
clip-path: polygon(20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%, 0 20px);
|
||||||
}
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover {
|
.feature-card:hover {
|
||||||
transform: translateY(-8px);
|
transform: translateY(-10px);
|
||||||
border-color: rgba(100, 108, 255, 0.5);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
box-shadow: 0 12px 30px rgba(100, 108, 255, 0.2);
|
border-color: rgba(225, 29, 72, 0.4);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, transparent, #e11d48, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
filter: drop-shadow(0 0 10px rgba(100, 108, 255, 0.5));
|
background: rgba(225, 29, 72, 0.1);
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||||||
|
color: #e11d48;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card h3 {
|
.feature-card h3 {
|
||||||
font-size: 1.5rem;
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card p {
|
.feature-card p {
|
||||||
color: #aaa;
|
color: #94a3b8;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-screenshot {
|
.feature-screenshot {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
margin-top: 1rem;
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
transition: transform 0.3s;
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-screenshot:hover {
|
.feature-card:hover .feature-screenshot {
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
box-shadow: 0 8px 20px rgba(100, 108, 255, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* About Section */
|
/* --- About Section --- */
|
||||||
.about-section {
|
.about-section {
|
||||||
padding: 6rem 2rem;
|
padding: 6rem 2rem;
|
||||||
background: rgba(26, 26, 26, 0.8);
|
background: linear-gradient(to bottom, #050508, #0f0f13);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-content {
|
.about-content {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-content p {
|
.about-content p {
|
||||||
font-size: 1.1rem;
|
font-size: 1.2rem;
|
||||||
|
color: #cbd5e1;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: #bbb;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* --- Footer --- */
|
||||||
.landing-footer {
|
.landing-footer {
|
||||||
padding: 2rem;
|
padding: 3rem 2rem;
|
||||||
text-align: center;
|
background-color: #020203;
|
||||||
background: #0a0a0a;
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
border-top: 1px solid rgba(100, 108, 255, 0.2);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-footer p {
|
.landing-footer p {
|
||||||
color: #666;
|
color: #64748b;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links span {
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links span:hover {
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive Adjustments --- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
font-size: 1.2rem;
|
font-size: 1.1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.hero-description {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
@@ -250,19 +303,9 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-section,
|
|
||||||
.about-section {
|
|
||||||
padding: 4rem 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.hero-title {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-buttons {
|
.hero-buttons {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-button {
|
.hero-button {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { GameButton } from './common/GameButton'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import './LandingPage.css'
|
import './LandingPage.css'
|
||||||
|
|
||||||
function LandingPage() {
|
function LandingPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// Redirect authenticated users to characters page
|
// Redirect authenticated users to characters page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -19,26 +22,28 @@ function LandingPage() {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="hero-section">
|
<section className="hero-section">
|
||||||
<div className="hero-content">
|
<div className="hero-content">
|
||||||
<h1 className="hero-title">Echoes of the Ash</h1>
|
<h1 className="hero-title">{t('landing.heroTitle')}</h1>
|
||||||
<p className="hero-subtitle">Survive the Wasteland. Forge Your Legend.</p>
|
<p className="hero-subtitle">{t('landing.heroSubtitle')}</p>
|
||||||
<p className="hero-description">
|
<p className="hero-description">
|
||||||
A post-apocalyptic survival RPG where every decision matters.
|
{t('landing.about.description')}
|
||||||
Explore desolate wastelands, battle mutated creatures, craft essential gear,
|
|
||||||
and compete with other survivors in a world consumed by ash.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="hero-buttons">
|
<div className="hero-buttons">
|
||||||
<button
|
<GameButton
|
||||||
className="button-primary hero-button"
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="hero-button"
|
||||||
onClick={() => navigate('/register')}
|
onClick={() => navigate('/register')}
|
||||||
>
|
>
|
||||||
Start Your Journey
|
{t('landing.playNow')}
|
||||||
</button>
|
</GameButton>
|
||||||
<button
|
<GameButton
|
||||||
className="button-secondary hero-button"
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="hero-button"
|
||||||
onClick={() => navigate('/login')}
|
onClick={() => navigate('/login')}
|
||||||
>
|
>
|
||||||
Login
|
{t('landing.login')}
|
||||||
</button>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-gradient"></div>
|
<div className="hero-gradient"></div>
|
||||||
@@ -46,73 +51,46 @@ function LandingPage() {
|
|||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section className="features-section">
|
<section className="features-section">
|
||||||
<h2 className="section-title">Game Features</h2>
|
<h2 className="section-title">{t('landing.features')}</h2>
|
||||||
<div className="features-grid">
|
<div className="features-grid">
|
||||||
<div className="feature-card">
|
<div className="feature-card">
|
||||||
<div className="feature-icon">⚔️</div>
|
<div className="feature-icon">⚔️</div>
|
||||||
<h3>Tactical Combat</h3>
|
<h3>{t('landing.featureCards.combat.title')}</h3>
|
||||||
<p>Engage in turn-based battles against mutated creatures and hostile survivors. Choose your actions wisely!</p>
|
<p>{t('landing.featureCards.combat.description')}</p>
|
||||||
<img src="/game-combat.png" alt="Combat gameplay" className="feature-screenshot" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="feature-card">
|
<div className="feature-card">
|
||||||
<div className="feature-icon">🎒</div>
|
<div className="feature-icon">🎒</div>
|
||||||
<h3>Deep Inventory System</h3>
|
<h3>{t('landing.featureCards.survival.title')}</h3>
|
||||||
<p>Manage your equipment, craft items, and optimize your loadout for survival in the harsh wasteland.</p>
|
<p>{t('landing.featureCards.survival.description')}</p>
|
||||||
<img src="/game-inventory.png" alt="Inventory system" className="feature-screenshot" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="feature-card">
|
|
||||||
<div className="feature-icon">🗺️</div>
|
|
||||||
<h3>Explore the Wasteland</h3>
|
|
||||||
<p>Navigate through dangerous locations, discover hidden treasures, and encounter other players in real-time.</p>
|
|
||||||
<img src="/game-exploration.png" alt="Exploration gameplay" className="feature-screenshot" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="feature-card">
|
<div className="feature-card">
|
||||||
<div className="feature-icon">🔧</div>
|
<div className="feature-icon">🔧</div>
|
||||||
<h3>Crafting & Salvage</h3>
|
<h3>{t('landing.featureCards.crafting.title')}</h3>
|
||||||
<p>Scavenge materials, repair equipment, and craft powerful items to gain an edge in the wasteland.</p>
|
<p>{t('landing.featureCards.crafting.description')}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="feature-card">
|
|
||||||
<div className="feature-icon">📊</div>
|
|
||||||
<h3>Character Progression</h3>
|
|
||||||
<p>Level up your character, allocate stat points, and customize your build to match your playstyle.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="feature-card">
|
|
||||||
<div className="feature-icon">👥</div>
|
|
||||||
<h3>Multiplayer Interactions</h3>
|
|
||||||
<p>Trade with other players, engage in PvP combat, or cooperate to survive in the harsh world.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* About Section */}
|
{/* About Section */}
|
||||||
<section className="about-section">
|
<section className="about-section">
|
||||||
<h2 className="section-title">About the Game</h2>
|
<h2 className="section-title">{t('landing.about.title')}</h2>
|
||||||
<div className="about-content">
|
<div className="about-content">
|
||||||
<p>
|
<p>
|
||||||
In the aftermath of a catastrophic event that covered the world in ash,
|
{t('landing.about.description')}
|
||||||
humanity struggles to survive. Resources are scarce, dangers lurk around
|
|
||||||
every corner, and only the strongest and smartest will endure.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Create your character, explore the wasteland, battle mutated creatures,
|
|
||||||
and compete with other survivors. Will you become a legendary scavenger,
|
|
||||||
a feared warrior, or a cunning trader? The choice is yours.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Join thousands of players in this persistent online world where your
|
|
||||||
actions have consequences and your reputation matters.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="landing-footer">
|
<footer className="landing-footer">
|
||||||
<p>© 2025 Echoes of the Ash. All rights reserved.</p>
|
<p>{t('landing.footer.copyright', { year: 2026 })}</p>
|
||||||
|
<div className="footer-links">
|
||||||
|
<span onClick={() => navigate('/privacy')}>{t('landing.footer.links.privacy')}</span>
|
||||||
|
<span onClick={() => navigate('/terms')}>{t('landing.footer.links.terms')}</span>
|
||||||
|
<span onClick={() => window.open('https://discord.gg/8QWK9QcNqm', '_blank')}>{t('landing.footer.links.discord')}</span>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 6px;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@@ -101,7 +101,9 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-loading, .leaderboard-error, .leaderboard-empty {
|
.leaderboard-loading,
|
||||||
|
.leaderboard-error,
|
||||||
|
.leaderboard-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
}
|
}
|
||||||
@@ -117,7 +119,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-error button {
|
.leaderboard-error button {
|
||||||
@@ -305,293 +309,4 @@
|
|||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsive */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.game-main .leaderboards-container {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-selector {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-options {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
/* Remove tab bar spacing for leaderboards page */
|
|
||||||
.game-main {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-main .leaderboards-container {
|
|
||||||
padding: 0.75rem;
|
|
||||||
padding-top: 4rem; /* Space for hamburger button */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide desktop stat selector on mobile */
|
|
||||||
.stat-selector {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-selector h3 {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown-style selector on mobile */
|
|
||||||
.stat-options {
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
border: 2px solid rgba(107, 185, 240, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 350px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-option {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
background: transparent;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-option:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-option:first-child {
|
|
||||||
border-radius: 6px 6px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-option:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
border-radius: 0 0 6px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show only active by default */
|
|
||||||
.stat-option:not(.active) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-option.active {
|
|
||||||
background: rgba(107, 185, 240, 0.15);
|
|
||||||
border-radius: 6px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add dropdown arrow to active option */
|
|
||||||
.stat-option.active::after {
|
|
||||||
content: '▼';
|
|
||||||
position: absolute;
|
|
||||||
right: 1rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show all options when expanded */
|
|
||||||
.stat-options.expanded .stat-option:not(.active) {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-options.expanded .stat-option.active {
|
|
||||||
border-radius: 6px 6px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-options.expanded .stat-option.active::after {
|
|
||||||
content: '▲';
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-options.expanded {
|
|
||||||
background: rgba(0, 0, 0, 0.98);
|
|
||||||
border-radius: 6px;
|
|
||||||
border-color: rgba(107, 185, 240, 0.6);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaderboard-content {
|
|
||||||
padding: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaderboard-title {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaderboard-title.dropdown-open {
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-left {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clickable-title {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin: -0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clickable-title:active {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-arrow {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.98);
|
|
||||||
border: 2px solid rgba(107, 185, 240, 0.6);
|
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 12px 12px;
|
|
||||||
margin-top: -0.75rem;
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 101;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-dropdown-option {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-dropdown-option:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
border-radius: 0 0 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-dropdown-option:hover,
|
|
||||||
.title-dropdown-option:active {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-icon {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaderboard-title h2 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-top,
|
|
||||||
.pagination-bottom {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-bottom {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn {
|
|
||||||
min-width: 44px !important;
|
|
||||||
width: 44px !important;
|
|
||||||
height: 44px !important;
|
|
||||||
padding: 0.5rem !important;
|
|
||||||
font-size: 1.2rem !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-info {
|
|
||||||
min-width: 100px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-header {
|
|
||||||
display: none; /* Hide header on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-row {
|
|
||||||
grid-template-columns: 50px 1fr 70px;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-level {
|
|
||||||
order: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-value {
|
|
||||||
order: 2;
|
|
||||||
grid-column: 2 / 3;
|
|
||||||
text-align: right;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-username {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-badge {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-value .stat-value {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,85 +3,98 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 1rem;
|
padding: 2rem;
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
background: radial-gradient(circle at center, rgba(225, 29, 72, 0.1) 0%, var(--game-bg-app) 100%);
|
||||||
|
font-family: var(--game-font-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background-color: #2a2a2a;
|
max-width: 450px;
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 400px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
|
padding: 3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card h1 {
|
.auth-title {
|
||||||
font-size: 2rem;
|
font-size: 3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #646cff;
|
color: var(--game-text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #888;
|
color: var(--game-text-secondary);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
font-size: 0.9rem;
|
font-size: 1.1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1.5rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
color: var(--game-text-secondary);
|
||||||
margin-bottom: 0.5rem;
|
font-size: 1rem;
|
||||||
color: #ccc;
|
text-transform: uppercase;
|
||||||
font-size: 0.9rem;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input {
|
.game-input {
|
||||||
margin-bottom: 0;
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid var(--game-border-color);
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
font-family: var(--game-font-main);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-input:focus {
|
||||||
|
border-color: var(--game-color-primary);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
box-shadow: 0 0 10px rgba(225, 29, 72, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--game-text-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-left: 3px solid var(--game-text-danger);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-toggle {
|
.login-toggle {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
text-align: center;
|
display: flex;
|
||||||
}
|
justify-content: center;
|
||||||
|
|
||||||
.button-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #646cff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-link:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-link:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-strength {
|
.password-strength {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.25rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
text-transform: uppercase;
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.login-card {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { GameButton } from './common/GameButton'
|
||||||
import './Login.css'
|
import './Login.css'
|
||||||
|
|
||||||
function Login() {
|
function Login() {
|
||||||
@@ -10,6 +12,7 @@ function Login() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const validateEmail = (email: string) => {
|
const validateEmail = (email: string) => {
|
||||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
@@ -22,12 +25,12 @@ function Login() {
|
|||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!validateEmail(email)) {
|
if (!validateEmail(email)) {
|
||||||
setError('Please enter a valid email address')
|
setError(t('auth.errors.invalidEmail'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
setError('Password must be at least 6 characters')
|
setError(t('auth.errors.passwordLength'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ function Login() {
|
|||||||
// Navigate to character selection after successful login
|
// Navigate to character selection after successful login
|
||||||
navigate('/characters')
|
navigate('/characters')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Login failed')
|
setError(err.response?.data?.detail || t('auth.errors.loginFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -46,19 +49,20 @@ function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-container">
|
<div className="login-container">
|
||||||
<div className="login-card">
|
<div className="login-card game-panel">
|
||||||
<h1>Welcome Back</h1>
|
<h1 className="auth-title">{t('auth.loginTitle')}</h1>
|
||||||
<p className="login-subtitle">Login to continue your journey</p>
|
<p className="login-subtitle">{t('auth.loginSubtitle')}</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="email">Email Address</label>
|
<label htmlFor="email">{t('auth.email')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
|
className="game-input"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="your.email@example.com"
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
@@ -66,47 +70,45 @@ function Login() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="password">Password</label>
|
<label htmlFor="password">{t('auth.password')}</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
|
className="game-input"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Your password"
|
placeholder={t('auth.passwordPlaceholderLogin')}
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<button type="submit" className="button-primary" disabled={loading}>
|
<GameButton
|
||||||
{loading ? 'Logging in...' : 'Login'}
|
variant="primary"
|
||||||
</button>
|
size="lg"
|
||||||
|
className="auth-submit"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => { }} // Form will handle it via submit
|
||||||
|
>
|
||||||
|
{loading ? t('auth.loggingIn') : t('auth.login')}
|
||||||
|
</GameButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="login-toggle">
|
<div className="login-toggle">
|
||||||
<button
|
<GameButton
|
||||||
type="button"
|
variant="secondary"
|
||||||
className="button-link"
|
size="sm"
|
||||||
onClick={() => navigate('/register')}
|
onClick={() => navigate('/register')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Don't have an account? Register
|
{t('auth.registerLink')}
|
||||||
</button>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button-link"
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
← Back to Home
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -181,30 +181,4 @@
|
|||||||
|
|
||||||
.stat-value.highlight-stamina {
|
.stat-value.highlight-stamina {
|
||||||
color: #ffd93d;
|
color: #ffd93d;
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
|
|
||||||
/* Remove tab bar spacing for profile page */
|
|
||||||
.game-main {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-main .profile-container {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
padding: 1rem;
|
|
||||||
padding-top: 4rem;
|
|
||||||
/* Space for hamburger button */
|
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info-card {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-stats-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { GameButton } from './common/GameButton'
|
||||||
import './Login.css'
|
import './Login.css'
|
||||||
|
|
||||||
function Register() {
|
function Register() {
|
||||||
@@ -11,6 +13,7 @@ function Register() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { register } = useAuth()
|
const { register } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const validateEmail = (email: string) => {
|
const validateEmail = (email: string) => {
|
||||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
@@ -19,9 +22,9 @@ function Register() {
|
|||||||
|
|
||||||
const getPasswordStrength = (password: string): { strength: string; color: string } => {
|
const getPasswordStrength = (password: string): { strength: string; color: string } => {
|
||||||
if (password.length === 0) return { strength: '', color: '' }
|
if (password.length === 0) return { strength: '', color: '' }
|
||||||
if (password.length < 6) return { strength: 'Weak', color: '#ff6b6b' }
|
if (password.length < 6) return { strength: t('auth.strength.weak'), color: '#ff6b6b' }
|
||||||
if (password.length < 10) return { strength: 'Medium', color: '#ffd93d' }
|
if (password.length < 10) return { strength: t('auth.strength.medium'), color: '#ffd93d' }
|
||||||
return { strength: 'Strong', color: '#51cf66' }
|
return { strength: t('auth.strength.strong'), color: '#51cf66' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordStrength = getPasswordStrength(password)
|
const passwordStrength = getPasswordStrength(password)
|
||||||
@@ -32,17 +35,17 @@ function Register() {
|
|||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!validateEmail(email)) {
|
if (!validateEmail(email)) {
|
||||||
setError('Please enter a valid email address')
|
setError(t('auth.errors.invalidEmail'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
setError('Password must be at least 6 characters')
|
setError(t('auth.errors.passwordLength'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwords do not match')
|
setError(t('auth.errors.passwordMatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ function Register() {
|
|||||||
// Navigate to character selection after successful registration
|
// Navigate to character selection after successful registration
|
||||||
navigate('/characters')
|
navigate('/characters')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Registration failed')
|
setError(err.response?.data?.detail || t('auth.errors.registrationFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -61,19 +64,20 @@ function Register() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-container">
|
<div className="login-container">
|
||||||
<div className="login-card">
|
<div className="login-card game-panel">
|
||||||
<h1>Create Account</h1>
|
<h1 className="auth-title">{t('auth.registerTitle')}</h1>
|
||||||
<p className="login-subtitle">Join the survivors in the wasteland</p>
|
<p className="login-subtitle">{t('auth.registerSubtitle')}</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="email">Email Address</label>
|
<label htmlFor="email">{t('auth.email')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
|
className="game-input"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="your.email@example.com"
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
@@ -81,13 +85,14 @@ function Register() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="password">Password</label>
|
<label htmlFor="password">{t('auth.password')}</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
|
className="game-input"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="At least 6 characters"
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -102,50 +107,49 @@ function Register() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="confirmPassword">Confirm Password</label>
|
<label htmlFor="confirmPassword">{t('auth.confirmPassword')}</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
|
className="game-input"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="Re-enter your password"
|
placeholder={t('auth.confirmPasswordPlaceholder')}
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<button type="submit" className="button-primary" disabled={loading}>
|
<GameButton
|
||||||
{loading ? 'Creating Account...' : 'Create Account'}
|
variant="primary"
|
||||||
</button>
|
size="lg"
|
||||||
|
className="auth-submit"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => { }} // Form will handle it
|
||||||
|
>
|
||||||
|
{loading ? t('auth.submitting') : t('auth.submit')}
|
||||||
|
</GameButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="login-toggle">
|
<div className="login-toggle">
|
||||||
<button
|
<GameButton
|
||||||
type="button"
|
variant="secondary"
|
||||||
className="button-link"
|
size="sm"
|
||||||
onClick={() => navigate('/login')}
|
onClick={() => navigate('/login')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Already have an account? Login
|
{t('auth.loginLink')}
|
||||||
</button>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button-link"
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
← Back to Home
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default Register
|
export default Register
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface GameButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GameButton: React.FC<GameButtonProps> = ({
|
export const GameButton: React.FC<GameButtonProps> = ({
|
||||||
@@ -18,7 +19,8 @@ export const GameButton: React.FC<GameButtonProps> = ({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = '',
|
className = '',
|
||||||
style
|
style,
|
||||||
|
title
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
@@ -31,6 +33,7 @@ export const GameButton: React.FC<GameButtonProps> = ({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={style}
|
style={style}
|
||||||
|
title={title}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -71,15 +71,25 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
|
|||||||
|
|
||||||
// Use passed position (if updated dynamically) or fall back to the captured sticky position
|
// Use passed position (if updated dynamically) or fall back to the captured sticky position
|
||||||
const target = position || capturedPos;
|
const target = position || capturedPos;
|
||||||
const targetX = target.x;
|
|
||||||
const targetY = target.y;
|
|
||||||
|
|
||||||
let x = targetX - 10;
|
// Get zoom factor to adjust coordinates
|
||||||
|
const zoom = typeof window !== 'undefined'
|
||||||
|
? (parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--zoom-factor')) || 1)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const targetX = target.x / zoom;
|
||||||
|
const targetY = target.y / zoom;
|
||||||
|
|
||||||
|
// Offset from cursor
|
||||||
|
const offsetX = 5;
|
||||||
|
const offsetY = 5;
|
||||||
|
|
||||||
|
let x = targetX - offsetX;
|
||||||
|
|
||||||
// Determine flip direction first using raw position
|
// Determine flip direction first using raw position
|
||||||
let flipUp = false;
|
let flipUp = false;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight / zoom;
|
||||||
const estimatedHeight = 200; // Guess for now
|
const estimatedHeight = 200; // Guess for now
|
||||||
|
|
||||||
if (targetY + estimatedHeight > viewportHeight) {
|
if (targetY + estimatedHeight > viewportHeight) {
|
||||||
@@ -87,7 +97,7 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Adjust width constrained by viewport
|
// Adjust width constrained by viewport
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth / zoom;
|
||||||
const estimatedWidth = parseInt(width) || 200;
|
const estimatedWidth = parseInt(width) || 200;
|
||||||
if (x + estimatedWidth > viewportWidth) {
|
if (x + estimatedWidth > viewportWidth) {
|
||||||
x = viewportWidth - estimatedWidth - 10;
|
x = viewportWidth - estimatedWidth - 10;
|
||||||
@@ -97,7 +107,7 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
|
|||||||
// Apply offset based on direction
|
// Apply offset based on direction
|
||||||
// If flipping up, we want the bottom to be slightly below the mouse (y + 10)
|
// If flipping up, we want the bottom to be slightly below the mouse (y + 10)
|
||||||
// If flipping down, we want the top to be slightly above the mouse (y - 10)
|
// If flipping down, we want the top to be slightly above the mouse (y - 10)
|
||||||
const y = flipUp ? targetY + 10 : targetY - 10;
|
const y = flipUp ? targetY + offsetY : targetY - offsetY;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -105,7 +115,7 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
|
|||||||
className={`game-dropdown-menu ${className}`}
|
className={`game-dropdown-menu ${className}`}
|
||||||
style={{
|
style={{
|
||||||
top: flipUp ? 'auto' : y,
|
top: flipUp ? 'auto' : y,
|
||||||
bottom: flipUp ? (window.innerHeight - y) : 'auto',
|
bottom: flipUp ? ((window.innerHeight / zoom) - y) : 'auto',
|
||||||
left: x,
|
left: x,
|
||||||
width: width
|
width: width
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,21 +21,26 @@ export const GameTooltip: React.FC<GameTooltipProps> = ({ content, children, cla
|
|||||||
|
|
||||||
const updatePosition = (e: React.MouseEvent) => {
|
const updatePosition = (e: React.MouseEvent) => {
|
||||||
// Offset from cursor
|
// Offset from cursor
|
||||||
const offsetX = 15;
|
const offsetX = 5;
|
||||||
const offsetY = 15;
|
const offsetY = 5;
|
||||||
|
|
||||||
// Check viewport boundaries to prevent overflow
|
// Get zoom factor (CSS zoom on <html> shifts coordinate space)
|
||||||
let x = e.clientX + offsetX;
|
const zoom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--zoom-factor')) || 1;
|
||||||
let y = e.clientY + offsetY;
|
|
||||||
|
// clientX/Y are in physical viewport coords, but position:fixed uses CSS coords (divided by zoom)
|
||||||
|
let x = e.clientX / zoom - offsetX;
|
||||||
|
let y = e.clientY / zoom - offsetY;
|
||||||
|
|
||||||
// Simple boundary check (can be expanded if needed)
|
// Simple boundary check (can be expanded if needed)
|
||||||
if (tooltipRef.current) {
|
if (tooltipRef.current) {
|
||||||
const rect = tooltipRef.current.getBoundingClientRect();
|
const rect = tooltipRef.current.getBoundingClientRect();
|
||||||
if (x + rect.width > window.innerWidth) {
|
const viewW = window.innerWidth / zoom;
|
||||||
x = e.clientX - rect.width - 5;
|
const viewH = window.innerHeight / zoom;
|
||||||
|
if (x + rect.width / zoom > viewW) {
|
||||||
|
x = e.clientX / zoom - rect.width / zoom + offsetX;
|
||||||
}
|
}
|
||||||
if (y + rect.height > window.innerHeight) {
|
if (y + rect.height / zoom > viewH) {
|
||||||
y = e.clientY - rect.height - 5;
|
y = e.clientY / zoom - rect.height / zoom + offsetY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +60,11 @@ export const GameTooltip: React.FC<GameTooltipProps> = ({ content, children, cla
|
|||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
// Hide tooltip on click so it doesn't interfere with dropdowns/menus
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Render the tooltip portal
|
// Render the tooltip portal
|
||||||
const tooltip = isVisible && content ? (
|
const tooltip = isVisible && content ? (
|
||||||
createPortal(
|
createPortal(
|
||||||
@@ -94,6 +104,7 @@ export const GameTooltip: React.FC<GameTooltipProps> = ({ content, children, cla
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={handleClick}
|
||||||
style={{ display: 'contents' }} // Use contents so the wrapper doesn't affect layout
|
style={{ display: 'contents' }} // Use contents so the wrapper doesn't affect layout
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||||
import { EffectBadge } from '../game/EffectBadge';
|
import { ItemStatBadges } from './ItemStatBadges';
|
||||||
|
import { GameProgressBar } from './GameProgressBar';
|
||||||
|
|
||||||
interface ItemTooltipContentProps {
|
interface ItemTooltipContentProps {
|
||||||
item: any;
|
item: any;
|
||||||
showValue?: boolean; // Show item value (for trading)
|
showValue?: boolean; // Show item value (for trading)
|
||||||
|
valueDisplayType?: 'unit' | 'total';
|
||||||
|
tradeMarkup?: number; // Multiplier for displayed value
|
||||||
showDurability?: boolean; // Show durability bar (default: true if available)
|
showDurability?: boolean; // Show durability bar (default: true if available)
|
||||||
|
actionHint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,13 +19,13 @@ interface ItemTooltipContentProps {
|
|||||||
export const ItemTooltipContent = ({
|
export const ItemTooltipContent = ({
|
||||||
item,
|
item,
|
||||||
showValue = false,
|
showValue = false,
|
||||||
showDurability = true
|
valueDisplayType = 'total',
|
||||||
|
tradeMarkup,
|
||||||
|
showDurability = true,
|
||||||
|
actionHint
|
||||||
}: ItemTooltipContentProps) => {
|
}: ItemTooltipContentProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const stats = item.unique_stats || item.stats || {};
|
|
||||||
const effects = item.effects || {};
|
|
||||||
|
|
||||||
const maxDurability = item.max_durability;
|
const maxDurability = item.max_durability;
|
||||||
const currentDurability = item.durability;
|
const currentDurability = item.durability;
|
||||||
const hasDurability = showDurability && maxDurability && maxDurability > 0;
|
const hasDurability = showDurability && maxDurability && maxDurability > 0;
|
||||||
@@ -45,136 +49,40 @@ export const ItemTooltipContent = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Value (for trading) */}
|
{/* Value (for trading) */}
|
||||||
{showValue && item.value !== undefined && (
|
{showValue && item.value !== undefined && (() => {
|
||||||
<div className="tooltip-value">
|
const qty = item.is_infinite ? 1 : (item._displayQuantity !== undefined ? item._displayQuantity : item.quantity) || 1;
|
||||||
💰 {t('game.value')}: {item.value * (item.quantity || 1)} coins
|
const multiplier = valueDisplayType === 'total' ? qty : 1;
|
||||||
</div>
|
return (
|
||||||
)}
|
<div className="tooltip-value">
|
||||||
|
💰 {t('game.value')}: {Math.round(item.value * (tradeMarkup || 1) * multiplier)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Stat Badges */}
|
{/* Stat Badges */}
|
||||||
<div className="stat-badges-container">
|
<ItemStatBadges item={item} />
|
||||||
{/* Capacity */}
|
|
||||||
{(stats.weight_capacity) && (
|
|
||||||
<span className="stat-badge capacity">
|
|
||||||
⚖️ +{stats.weight_capacity}kg
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.volume_capacity) && (
|
|
||||||
<span className="stat-badge capacity">
|
|
||||||
📦 +{stats.volume_capacity}L
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Combat */}
|
|
||||||
{(stats.damage_min) && (
|
|
||||||
<span className="stat-badge damage">
|
|
||||||
⚔️ {stats.damage_min}-{stats.damage_max}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.armor) && (
|
|
||||||
<span className="stat-badge armor">
|
|
||||||
🛡️ +{stats.armor}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.armor_penetration) && (
|
|
||||||
<span className="stat-badge penetration">
|
|
||||||
💔 +{stats.armor_penetration} {t('stats.pen')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.crit_chance) && (
|
|
||||||
<span className="stat-badge crit">
|
|
||||||
🎯 +{Math.round(stats.crit_chance * 100)}% {t('stats.crit')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.accuracy) && (
|
|
||||||
<span className="stat-badge accuracy">
|
|
||||||
👁️ +{Math.round(stats.accuracy * 100)}% {t('stats.acc')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.dodge_chance) && (
|
|
||||||
<span className="stat-badge dodge">
|
|
||||||
💨 +{Math.round(stats.dodge_chance * 100)}% Dodge
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.lifesteal) && (
|
|
||||||
<span className="stat-badge lifesteal">
|
|
||||||
🧛 +{Math.round(stats.lifesteal * 100)}% {t('stats.life')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Attributes */}
|
|
||||||
{(stats.strength_bonus) && (
|
|
||||||
<span className="stat-badge strength">
|
|
||||||
💪 +{stats.strength_bonus} {t('stats.str')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.agility_bonus) && (
|
|
||||||
<span className="stat-badge agility">
|
|
||||||
🏃 +{stats.agility_bonus} {t('stats.agi')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.endurance_bonus) && (
|
|
||||||
<span className="stat-badge endurance">
|
|
||||||
🏋️ +{stats.endurance_bonus} {t('stats.end')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.hp_bonus) && (
|
|
||||||
<span className="stat-badge health">
|
|
||||||
❤️ +{stats.hp_bonus} {t('stats.hpMax')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(stats.stamina_bonus) && (
|
|
||||||
<span className="stat-badge stamina">
|
|
||||||
⚡ +{stats.stamina_bonus} {t('stats.stmMax')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Consumables */}
|
|
||||||
{(item.hp_restore || effects.hp_restore) && (
|
|
||||||
<span className="stat-badge health">
|
|
||||||
❤️ +{item.hp_restore || effects.hp_restore} HP
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(item.stamina_restore || effects.stamina_restore) && (
|
|
||||||
<span className="stat-badge stamina">
|
|
||||||
⚡ +{item.stamina_restore || effects.stamina_restore} Stm
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status Effects */}
|
|
||||||
{effects.status_effect && (
|
|
||||||
<EffectBadge effect={effects.status_effect} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{effects.cures && effects.cures.length > 0 && (
|
|
||||||
<span className="stat-badge cure">
|
|
||||||
💊 {t('game.cures')}: {effects.cures.map((c: string) => t(`game.effects.${c}`, c)).join(', ')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Durability Bar */}
|
{/* Durability Bar */}
|
||||||
{hasDurability && (
|
{hasDurability && (
|
||||||
<div className="durability-container">
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<div className="durability-header">
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px', fontSize: '0.8rem' }}>
|
||||||
<span>{t('game.durability')}</span>
|
<span>{t('game.durability')}</span>
|
||||||
<span className={currentDurability < maxDurability * 0.2 ? "durability-text-low" : ""}>
|
<span>{currentDurability} / {maxDurability}</span>
|
||||||
{currentDurability} / {maxDurability}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="durability-track">
|
|
||||||
<div
|
|
||||||
className={`durability-fill ${currentDurability < maxDurability * 0.2
|
|
||||||
? "low"
|
|
||||||
: currentDurability < maxDurability * 0.5
|
|
||||||
? "medium"
|
|
||||||
: "high"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, Math.max(0, (currentDurability / maxDurability) * 100))}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<GameProgressBar
|
||||||
|
value={currentDurability}
|
||||||
|
max={maxDurability}
|
||||||
|
type="durability"
|
||||||
|
height="6px"
|
||||||
|
showText={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Hint */}
|
||||||
|
{actionHint && (
|
||||||
|
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #444', color: '#aaa', fontSize: '0.8rem', fontStyle: 'italic', textAlign: 'center' }}>
|
||||||
|
{actionHint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface CombatProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
|
||||||
export const Combat: React.FC<CombatProps> = ({
|
export const Combat: React.FC<CombatProps> = ({
|
||||||
combatState: initialCombatData,
|
combatState: initialCombatData,
|
||||||
combatLog: _combatLog,
|
combatLog: _combatLog,
|
||||||
@@ -44,6 +46,7 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { currentCharacter: _currentCharacter, refreshCharacters } = useAuth();
|
const { currentCharacter: _currentCharacter, refreshCharacters } = useAuth();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { addNotification } = useNotification();
|
||||||
|
|
||||||
const isPvP = initialCombatData?.is_pvp || false;
|
const isPvP = initialCombatData?.is_pvp || false;
|
||||||
|
|
||||||
@@ -488,6 +491,10 @@ export const Combat: React.FC<CombatProps> = ({
|
|||||||
setCombatResult('fled');
|
setCombatResult('fled');
|
||||||
}, 500);
|
}, 500);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'quest_update':
|
||||||
|
addNotification(data.message || 'Quest Progress', 'quest');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { getAssetPath } from '../../utils/assetPath';
|
import { getAssetPath } from '../../utils/assetPath';
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||||
import './CombatInventoryModal.css';
|
import './CombatInventoryModal.css';
|
||||||
import { EffectBadge } from './EffectBadge';
|
import { ItemStatBadges } from '../common/ItemStatBadges';
|
||||||
import { GameButton } from '../common/GameButton';
|
import { GameButton } from '../common/GameButton';
|
||||||
|
|
||||||
interface CombatInventoryModalProps {
|
interface CombatInventoryModalProps {
|
||||||
@@ -107,33 +107,10 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="item-effects">
|
<div className="item-effects">
|
||||||
{/* Logic adapted from InventoryModal to show all relevant stats */}
|
{/* Shared stat badges */}
|
||||||
|
<ItemStatBadges item={item} />
|
||||||
|
|
||||||
{/* Consumables (Priority for combat) */}
|
{/* Combat-specific Effects (Throwables, etc) */}
|
||||||
{(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 && (
|
{item.combat_effects?.damage_min && (
|
||||||
<span className="stat-badge damage">
|
<span className="stat-badge damage">
|
||||||
💥 {item.combat_effects.damage_min}-{item.combat_effects.damage_max} Dmg
|
💥 {item.combat_effects.damage_min}-{item.combat_effects.damage_max} Dmg
|
||||||
@@ -144,60 +121,6 @@ export const CombatInventoryModal: React.FC<CombatInventoryModalProps> = ({
|
|||||||
☠️ {t(`effects.${item.combat_effects.status.name}`, item.combat_effects.status.name) as string}
|
☠️ {t(`effects.${item.combat_effects.status.name}`, item.combat_effects.status.name) as string}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -67,23 +67,29 @@
|
|||||||
|
|
||||||
/* Renamed from .options-container to match JSX */
|
/* Renamed from .options-container to match JSX */
|
||||||
.options-grid {
|
.options-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make the last item span full width if it's the only one in the row (odd number of items) */
|
/* Make the last item span full width if it's the only one in the row (odd number of items) */
|
||||||
.options-grid>*:last-child:nth-child(odd) {
|
/*.options-grid>*:last-child:nth-child(odd) {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
.option-btn {
|
.option-btn {
|
||||||
/* Base styles handled by GameButton, but ensure consistent height */
|
/* Base styles handled by GameButton, but ensure consistent height */
|
||||||
width: 100%;
|
flex: 1 1 45%;
|
||||||
|
min-width: 120px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.option-button {
|
.option-button {
|
||||||
/* Legacy style - keeping just in case */
|
/* Legacy style - keeping just in case */
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useGame } from '../../contexts/GameContext';
|
import { useGame } from '../../contexts/GameContext';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { GAME_API_URL } from '../../config';
|
import { GAME_API_URL } from '../../config';
|
||||||
import { GameModal } from './GameModal';
|
import { GameModal } from './GameModal';
|
||||||
import { GameButton } from '../common/GameButton';
|
import { GameButton } from '../common/GameButton';
|
||||||
@@ -32,7 +34,9 @@ interface Quest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClose, onTrade }) => {
|
export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClose, onTrade }) => {
|
||||||
const { token, locale, actions } = useGame();
|
const { t } = useTranslation();
|
||||||
|
const { token, locale, actions, inventory } = useGame();
|
||||||
|
const { addNotification } = useNotification();
|
||||||
const [dialogData, setDialogData] = useState<any>(null);
|
const [dialogData, setDialogData] = useState<any>(null);
|
||||||
const [currentText, setCurrentText] = useState<string>("");
|
const [currentText, setCurrentText] = useState<string>("");
|
||||||
const [quests, setQuests] = useState<Quest[]>([]);
|
const [quests, setQuests] = useState<Quest[]>([]);
|
||||||
@@ -115,7 +119,7 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
const desc = getLocalized(quest.description);
|
const desc = getLocalized(quest.description);
|
||||||
|
|
||||||
if (quest.status === 'active') {
|
if (quest.status === 'active') {
|
||||||
setCurrentText(desc + "\n\n(Quest in progress...)");
|
setCurrentText(desc + "\n\n" + t('game.dialog.questInProgress'));
|
||||||
} else {
|
} else {
|
||||||
setCurrentText(desc);
|
setCurrentText(desc);
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,8 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Refresh or update state
|
// Refresh or update state
|
||||||
setCurrentText("Quest accepted! Good luck.");
|
setCurrentText(t('game.dialog.questAccepted'));
|
||||||
|
addNotification(t('messages.questAccepted'), "success");
|
||||||
|
|
||||||
if (data.quest) {
|
if (data.quest) {
|
||||||
actions.handleQuestUpdate(data.quest);
|
actions.handleQuestUpdate(data.quest);
|
||||||
@@ -147,13 +152,36 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
alert(err.detail);
|
addNotification(err.detail, "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
addNotification(t('messages.failedToAcceptQuest'), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if player has any relevant items for the quest
|
||||||
|
const hasRequiredItems = () => {
|
||||||
|
if (!selectedQuest || !selectedQuest.objectives) return false;
|
||||||
|
|
||||||
|
// If it has kill objectives, we can always "hand in" (check progress/complete)
|
||||||
|
// unless it's ONLY item delivery.
|
||||||
|
const hasKillObjective = selectedQuest.objectives.some((o: any) => o.type === 'kill_count');
|
||||||
|
if (hasKillObjective) return true;
|
||||||
|
|
||||||
|
// Check item delivery objectives
|
||||||
|
const itemObjectives = selectedQuest.objectives.filter((o: any) => o.type === 'item_delivery');
|
||||||
|
if (itemObjectives.length === 0) return true; // No delivery needed? Should allow.
|
||||||
|
|
||||||
|
// Check if we have ANY of the required items in inventory
|
||||||
|
// @ts-ignore
|
||||||
|
return itemObjectives.some((o: any) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const invItem = inventory.find((i: any) => i.item_id === o.target);
|
||||||
|
return invItem && invItem.quantity > 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handInQuest = async () => {
|
const handInQuest = async () => {
|
||||||
if (!selectedQuest) return;
|
if (!selectedQuest) return;
|
||||||
try {
|
try {
|
||||||
@@ -166,26 +194,44 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (result.quest_update) {
|
if (result.quest_update) {
|
||||||
actions.handleQuestUpdate(result.quest_update);
|
actions.handleQuestUpdate(result.quest_update);
|
||||||
|
// Update local state to reflect new progress
|
||||||
|
setQuests(prev => prev.map(q => q.quest_id === result.quest_update.quest_id ? result.quest_update : q));
|
||||||
|
// Also update selectedQuest so the UI reflects changes immediately if we stay on this screen
|
||||||
|
setSelectedQuest(result.quest_update);
|
||||||
}
|
}
|
||||||
// Refresh game data to update inventory/stats
|
// Refresh game data to update inventory/stats
|
||||||
actions.fetchGameData();
|
actions.fetchGameData();
|
||||||
|
|
||||||
if (result.is_completed) {
|
if (result.is_completed) {
|
||||||
|
addNotification(t('messages.questCompleted'), "quest");
|
||||||
let msg = getLocalized(result.completion_text) || "Thank you!";
|
let msg = getLocalized(result.completion_text) || "Thank you!";
|
||||||
if (result.rewards && result.rewards.length > 0) {
|
if (result.rewards && result.rewards.length > 0) {
|
||||||
msg += "\n\nRewards:\n" + result.rewards.join('\n');
|
msg += "\n\n" + t('game.dialog.rewards') + ":\n" + result.rewards.join('\n');
|
||||||
}
|
}
|
||||||
setCurrentText(msg);
|
setCurrentText(msg);
|
||||||
// Remove from list
|
// Remove from list or mark as completed in local list
|
||||||
setQuests(prev => prev.filter(q => q.quest_id !== selectedQuest.quest_id));
|
setQuests(prev => prev.map(q => q.quest_id === selectedQuest.quest_id ? { ...q, status: 'completed' } : q));
|
||||||
} else {
|
} else {
|
||||||
setCurrentText(`Progress updated.\n${result.items_deducted?.join('\n')}`);
|
addNotification(t('messages.questProgressUpdated'), "info");
|
||||||
|
|
||||||
|
let feedback = t('game.dialog.progressUpdated');
|
||||||
|
if (result.items_deducted && result.items_deducted.length > 0) {
|
||||||
|
feedback += `\n\n${result.items_deducted.join('\n')}`;
|
||||||
|
}
|
||||||
|
// Append objective status
|
||||||
|
if (result.quest_update && result.quest_update.objectives) {
|
||||||
|
const objText = result.quest_update.objectives.map((o: any) => {
|
||||||
|
const targetName = o.target_name || o.target;
|
||||||
|
return `- ${targetName}: ${o.current}/${o.count}`;
|
||||||
|
}).join('\n');
|
||||||
|
feedback += `\n\n${objText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentText(feedback);
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
// Removed setTimeout to keep user in the dialog
|
||||||
resetToGreeting();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
} else {
|
||||||
alert(result.detail);
|
addNotification(result.detail, "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -225,13 +271,6 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="options-grid">
|
<div className="options-grid">
|
||||||
{/* BACK BUTTON */}
|
|
||||||
{(viewState === 'topic' || viewState === 'quest_preview') && (
|
|
||||||
<GameButton className="option-btn" size="sm" onClick={resetToGreeting}>
|
|
||||||
← Back
|
|
||||||
</GameButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* NPC TOPICS */}
|
{/* NPC TOPICS */}
|
||||||
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
|
{viewState === 'greeting' && dialogData.topics?.map((topic: Topic) => (
|
||||||
<GameButton key={topic.id} className="option-btn" size="sm" onClick={() => handleTopicClick(topic)}>
|
<GameButton key={topic.id} className="option-btn" size="sm" onClick={() => handleTopicClick(topic)}>
|
||||||
@@ -246,34 +285,35 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
className="option-btn quest-btn"
|
className="option-btn quest-btn"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleQuestClick(q)}
|
onClick={() => handleQuestClick(q)}
|
||||||
variant={q.status === 'active' ? 'warning' : 'info'}
|
variant={q.status === 'active' ? 'warning' : q.status === 'completed' ? 'success' : 'info'}
|
||||||
>
|
>
|
||||||
{q.status === 'available' ? '❗' : '❓'} {getLocalized(q.title)}
|
{q.status === 'available' ? '❗' : q.status === 'completed' ? '✅' : '❓'} {getLocalized(q.title)}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* CONFIRM QUEST ACTION */}
|
{/* CONFIRM QUEST ACTION */}
|
||||||
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
|
{viewState === 'quest_preview' && selectedQuest?.status === 'available' && (
|
||||||
<div style={{ gridColumn: 'span 2' }}>
|
<div className="full-width">
|
||||||
<GameButton className="option-btn action-btn" size="sm" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
|
<GameButton className="option-btn action-btn" size="sm" variant="success" onClick={acceptQuest} style={{ width: '100%' }}>
|
||||||
Accept Quest
|
{t('game.dialog.acceptQuest')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{viewState === 'quest_preview' && selectedQuest?.status === 'active' && (
|
{viewState === 'quest_preview' && selectedQuest?.status === 'active' && (
|
||||||
<div style={{ gridColumn: 'span 2' }}>
|
<div className="full-width">
|
||||||
<GameButton
|
<GameButton
|
||||||
className="option-btn action-btn"
|
className="option-btn action-btn"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="warning"
|
variant="warning"
|
||||||
onClick={handInQuest}
|
onClick={handInQuest}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
disabled={!hasRequiredItems()}
|
||||||
>
|
>
|
||||||
{/* If it's pure kill quest, 'Complete' makes more sense than 'Hand In' */}
|
{/* If it's pure kill quest, 'Complete' makes more sense than 'Hand In' */}
|
||||||
{selectedQuest.objectives?.some((o: any) => o.type === 'kill_count') && !selectedQuest.objectives?.some((o: any) => o.type === 'item_delivery')
|
{selectedQuest.objectives?.some((o: any) => o.type === 'kill_count') && !selectedQuest.objectives?.some((o: any) => o.type === 'item_delivery')
|
||||||
? "Complete Quest"
|
? t('game.dialog.completeQuest')
|
||||||
: "Hand In Items"}
|
: t('game.dialog.handInItems')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -281,14 +321,21 @@ export const DialogModal: React.FC<DialogModalProps> = ({ npcId, npcData, onClos
|
|||||||
{/* TRADE - Only show in greeting */}
|
{/* TRADE - Only show in greeting */}
|
||||||
{viewState === 'greeting' && npcData.trade?.enabled && (
|
{viewState === 'greeting' && npcData.trade?.enabled && (
|
||||||
<GameButton className="option-btn trade-btn" size="sm" variant="success" onClick={onTrade}>
|
<GameButton className="option-btn trade-btn" size="sm" variant="success" onClick={onTrade}>
|
||||||
💰 Trade
|
💰 {t('game.dialog.trade')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* EXIT - Span full width */}
|
{/* EXIT - Span full width */}
|
||||||
{viewState === 'greeting' && (
|
{viewState === 'greeting' && (
|
||||||
<GameButton className="option-btn exit-btn" size="sm" variant="secondary" onClick={onClose} style={{ gridColumn: 'span 2' }}>
|
<GameButton className="option-btn exit-btn full-width" size="sm" variant="secondary" onClick={onClose}>
|
||||||
Goodbye
|
{t('game.dialog.goodbye')}
|
||||||
|
</GameButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BACK BUTTON - Moved to bottom */}
|
||||||
|
{(viewState === 'topic' || viewState === 'quest_preview') && (
|
||||||
|
<GameButton className="option-btn full-width" size="sm" onClick={resetToGreeting}>
|
||||||
|
← {t('game.dialog.back')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,10 +38,11 @@
|
|||||||
|
|
||||||
/* --- Redesigned Inventory Modal --- */
|
/* --- Redesigned Inventory Modal --- */
|
||||||
/* --- Redesigned Inventory Modal --- */
|
/* --- Redesigned Inventory Modal --- */
|
||||||
.inventory-modal-redesign {
|
.game-modal-container.inventory-modal-redesign {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 85vh;
|
height: 90%;
|
||||||
|
max-height: 90%;
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
background: var(--game-bg-modal);
|
background: var(--game-bg-modal);
|
||||||
@@ -53,6 +54,15 @@
|
|||||||
clip-path: var(--game-clip-path);
|
clip-path: var(--game-clip-path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-modal-container.inventory-modal-redesign .game-modal-content {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
/* Ensure it fills the container */
|
||||||
|
}
|
||||||
|
|
||||||
/* Top Bar */
|
/* Top Bar */
|
||||||
.inventory-top-bar {
|
.inventory-top-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -233,31 +243,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-search-container {
|
.game-search-container {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--game-bg-input, rgba(0, 0, 0, 0.3));
|
|
||||||
border: 1px solid var(--game-border-color);
|
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
color: var(--game-text-primary);
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
clip-path: var(--game-clip-path-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-search-icon {
|
|
||||||
font-size: 1rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-search-input {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 1rem;
|
|
||||||
flex: 1;
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View Toggle Button */
|
/* View Toggle Button */
|
||||||
@@ -308,9 +295,10 @@
|
|||||||
/* Grid View Layout */
|
/* Grid View Layout */
|
||||||
.items-container.grid {
|
.items-container.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, 90px);
|
||||||
|
justify-content: center;
|
||||||
grid-auto-rows: max-content;
|
grid-auto-rows: max-content;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +353,9 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
box-shadow: var(--game-shadow-sm);
|
box-shadow: var(--game-shadow-sm);
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-item-card.grid:hover,
|
.inventory-item-card.grid:hover,
|
||||||
@@ -424,38 +415,23 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-quantity-badge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: -5px;
|
|
||||||
right: -5px;
|
|
||||||
background: var(--game-bg-panel);
|
|
||||||
border: 1px solid var(--game-border-color);
|
|
||||||
color: var(--game-text-primary);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 2px 6px;
|
|
||||||
clip-path: var(--game-clip-path-sm);
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: var(--game-shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Position adjustment for grid view badge */
|
|
||||||
.inventory-item-card.grid .item-quantity-badge {
|
|
||||||
bottom: 2px;
|
|
||||||
right: 2px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 1px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-equipped-indicator {
|
.item-equipped-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 2px;
|
left: 2px;
|
||||||
|
/* moved to left to free up space for clip path */
|
||||||
background: #4299e1;
|
background: #4299e1;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.65rem;
|
font-size: 0.7rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 1px 4px;
|
width: 20px;
|
||||||
border-radius: 2px;
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { PlayerState, Profile, Equipment } from './types'
|
|||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
import './InventoryModal.css'
|
import './InventoryModal.css'
|
||||||
import { EffectBadge } from './EffectBadge'
|
|
||||||
import { GameTooltip } from '../common/GameTooltip'
|
import { GameTooltip } from '../common/GameTooltip'
|
||||||
import { GameDropdown } from '../common/GameDropdown'
|
import { GameDropdown } from '../common/GameDropdown'
|
||||||
import { GameButton } from '../common/GameButton'
|
import { GameButton } from '../common/GameButton'
|
||||||
|
import { GameModal } from './GameModal'
|
||||||
|
import { ItemStatBadges } from '../common/ItemStatBadges'
|
||||||
|
import { GameProgressBar } from '../common/GameProgressBar'
|
||||||
|
import { GameItemCard } from '../common/GameItemCard'
|
||||||
import '../common/GameDropdown.css'
|
import '../common/GameDropdown.css'
|
||||||
|
|
||||||
interface InventoryModalProps {
|
interface InventoryModalProps {
|
||||||
@@ -173,112 +176,12 @@ function InventoryModal({
|
|||||||
<div className="stats-durability-column">
|
<div className="stats-durability-column">
|
||||||
{item.description && <p className="item-description-compact">{getTranslatedText(item.description)}</p>}
|
{item.description && <p className="item-description-compact">{getTranslatedText(item.description)}</p>}
|
||||||
|
|
||||||
{/* Stats Row - Button-like Badges */}
|
{/* Stats Row - Reusable Badges */}
|
||||||
<div className="stat-badges-container">
|
<ItemStatBadges item={item} />
|
||||||
{/* Capacity */}
|
|
||||||
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
|
|
||||||
<span className="stat-badge capacity">
|
|
||||||
⚖️ +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
|
|
||||||
<span className="stat-badge capacity">
|
|
||||||
📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Combat */}
|
|
||||||
{(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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
|
|
||||||
<span className="stat-badge health">
|
|
||||||
❤️ +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} {t('stats.hpMax')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
|
|
||||||
<span className="stat-badge stamina">
|
|
||||||
⚡ +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Consumables */}
|
|
||||||
{item.hp_restore && (
|
|
||||||
<span className="stat-badge health">
|
|
||||||
❤️ +{item.hp_restore} HP
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.stamina_restore && (
|
|
||||||
<span className="stat-badge stamina">
|
|
||||||
⚡ +{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 */}
|
{/* Durability Bar */}
|
||||||
{hasDurability && (
|
{hasDurability && (
|
||||||
<div className="durability-container">
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<div className="durability-header">
|
<div className="durability-header">
|
||||||
<span>{t('game.durability')}</span>
|
<span>{t('game.durability')}</span>
|
||||||
<span className={
|
<span className={
|
||||||
@@ -289,19 +192,13 @@ function InventoryModal({
|
|||||||
{currentDurability} / {maxDurability}
|
{currentDurability} / {maxDurability}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="durability-track">
|
<GameProgressBar
|
||||||
<div
|
value={currentDurability}
|
||||||
className={`durability-fill ${currentDurability < maxDurability * 0.2
|
max={maxDurability}
|
||||||
? "low"
|
type="durability"
|
||||||
: currentDurability < maxDurability * 0.5
|
height="6px"
|
||||||
? "medium"
|
showText={false}
|
||||||
: "high"
|
/>
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, Math.max(0, (currentDurability / maxDurability) * 100))}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -390,189 +287,15 @@ function InventoryModal({
|
|||||||
return effectName === itemName;
|
return effectName === itemName;
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxDurability = item.max_durability;
|
|
||||||
const currentDurability = item.durability;
|
|
||||||
const hasDurability = maxDurability && maxDurability > 0;
|
|
||||||
|
|
||||||
const tooltipContent = (
|
|
||||||
<div className="item-tooltip-content">
|
|
||||||
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
|
|
||||||
{item.emoji} {getTranslatedText(item.name)}
|
|
||||||
</div>
|
|
||||||
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
|
|
||||||
|
|
||||||
<div className="tooltip-stats">
|
|
||||||
<div>⚖️ {item.weight}kg {item.quantity > 1 && `(x${item.quantity})`}</div>
|
|
||||||
<div>📦 {item.volume}L {item.quantity > 1 && `(x${item.quantity})`}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Row - Button-like Badges */}
|
|
||||||
<div className="stat-badges-container">
|
|
||||||
{/* Capacity */}
|
|
||||||
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
|
|
||||||
<span className="stat-badge capacity">
|
|
||||||
⚖️ +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
|
|
||||||
<span className="stat-badge capacity">
|
|
||||||
📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Combat */}
|
|
||||||
{(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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</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')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.hp_bonus || item.stats?.hp_bonus) && (
|
|
||||||
<span className="stat-badge health">
|
|
||||||
❤️ +{item.unique_stats?.hp_bonus || item.stats?.hp_bonus} {t('stats.hpMax')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus) && (
|
|
||||||
<span className="stat-badge stamina">
|
|
||||||
⚡ +{item.unique_stats?.stamina_bonus || item.stats?.stamina_bonus} {t('stats.stmMax')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Consumables */}
|
|
||||||
{item.hp_restore && (
|
|
||||||
<span className="stat-badge health">
|
|
||||||
❤️ +{item.hp_restore} HP
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.stamina_restore && (
|
|
||||||
<span className="stat-badge stamina">
|
|
||||||
⚡ +{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 */}
|
|
||||||
{hasDurability && (
|
|
||||||
<div className="durability-container">
|
|
||||||
<div className="durability-header">
|
|
||||||
<span>{t('game.durability')}</span>
|
|
||||||
<span className={
|
|
||||||
currentDurability < maxDurability * 0.2
|
|
||||||
? "durability-text-low"
|
|
||||||
: ""
|
|
||||||
}>
|
|
||||||
{currentDurability} / {maxDurability}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="durability-track">
|
|
||||||
<div
|
|
||||||
className={`durability-fill ${currentDurability < maxDurability * 0.2
|
|
||||||
? "low"
|
|
||||||
: currentDurability < maxDurability * 0.5
|
|
||||||
? "medium"
|
|
||||||
: "high"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, Math.max(0, (currentDurability / maxDurability) * 100))}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="inventory-grid-wrapper">
|
<div key={i} className="inventory-grid-wrapper">
|
||||||
<GameTooltip content={tooltipContent}>
|
<GameItemCard
|
||||||
<div
|
item={item}
|
||||||
className={`inventory-item-card grid ${item.is_equipped ? 'equipped' : ''} ${activeDropdown === item.id ? 'active' : ''} text-tier-${item.tier || 0}`}
|
onClick={(e) => handleItemClick(e, item)}
|
||||||
onClick={(e) => handleItemClick(e, item)}
|
isActive={activeDropdown === item.id}
|
||||||
>
|
showEquipped={true}
|
||||||
{/* Image/Icon */}
|
showQuantity={true}
|
||||||
<div className="item-grid-image">
|
/>
|
||||||
{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}` : ''} ${item.image_path ? 'hidden' : ''}`}>
|
|
||||||
{item.emoji || '📦'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quantity Badge */}
|
|
||||||
{item.quantity > 1 && <div className="item-quantity-badge">x{item.quantity}</div>}
|
|
||||||
|
|
||||||
{/* Equipped Indicator */}
|
|
||||||
{item.is_equipped && <div className="item-equipped-indicator">E</div>}
|
|
||||||
</div>
|
|
||||||
</GameTooltip>
|
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
{/* Dropdown Menu */}
|
||||||
{activeDropdown === item.id && (
|
{activeDropdown === item.id && (
|
||||||
@@ -707,172 +430,171 @@ function InventoryModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
|
<GameModal
|
||||||
if (e.target === e.currentTarget) handleClose()
|
title={t('game.inventory')}
|
||||||
}}>
|
onClose={handleClose}
|
||||||
<div className="workbench-menu inventory-modal-redesign">
|
className="inventory-modal-redesign"
|
||||||
{/* Top Bar: Capacity & Backpack Info */}
|
>
|
||||||
<div className="inventory-top-bar">
|
{/* Top Bar: Capacity & Backpack Info */}
|
||||||
<div className="inventory-capacity-summary">
|
<div className="inventory-top-bar">
|
||||||
<div className="capacity-metric">
|
<div className="inventory-capacity-summary">
|
||||||
<span className="metric-icon">⚖️</span>
|
<div className="capacity-metric">
|
||||||
<div className="metric-bar-container">
|
<span className="metric-icon">⚖️</span>
|
||||||
<span className="metric-text" style={{ marginBottom: '4px', display: 'block' }}>
|
<div className="metric-bar-container">
|
||||||
{t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
|
<span className="metric-text" style={{ marginBottom: '4px', display: 'block' }}>
|
||||||
</span>
|
{t('game.weight')}: {(profile.current_weight || 0).toFixed(1)} / {profile.max_weight || 0} kg
|
||||||
<GameProgressBar
|
</span>
|
||||||
value={profile.current_weight || 0}
|
<GameProgressBar
|
||||||
max={profile.max_weight || 100}
|
value={profile.current_weight || 0}
|
||||||
type="weight"
|
max={profile.max_weight || 100}
|
||||||
height="8px"
|
type="weight"
|
||||||
showText={false}
|
height="8px"
|
||||||
/>
|
showText={false}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="capacity-metric">
|
|
||||||
<span className="metric-icon">📦</span>
|
|
||||||
<div className="metric-bar-container">
|
|
||||||
<span className="metric-text" style={{ marginBottom: '4px', display: 'block' }}>
|
|
||||||
{t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
|
|
||||||
</span>
|
|
||||||
<GameProgressBar
|
|
||||||
value={profile.current_volume || 0}
|
|
||||||
max={profile.max_volume || 100}
|
|
||||||
type="volume"
|
|
||||||
height="8px"
|
|
||||||
showText={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inventory-backpack-info">
|
<div className="capacity-metric">
|
||||||
{equipment?.backpack ? (
|
<span className="metric-icon">📦</span>
|
||||||
<div className="backpack-status active">
|
<div className="metric-bar-container">
|
||||||
<span className="backpack-icon">🎒</span>
|
<span className="metric-text" style={{ marginBottom: '4px', display: 'block' }}>
|
||||||
<span className="backpack-name">{getTranslatedText(equipment.backpack.name)}</span>
|
{t('game.volume')}: {(profile.current_volume || 0).toFixed(1)} / {profile.max_volume || 0} L
|
||||||
<span className="backpack-stats">
|
</span>
|
||||||
(+{equipment.backpack.unique_stats?.weight_capacity || equipment.backpack.stats?.weight_capacity || 0}kg /
|
<GameProgressBar
|
||||||
+{equipment.backpack.unique_stats?.volume_capacity || equipment.backpack.stats?.volume_capacity || 0}L)
|
value={profile.current_volume || 0}
|
||||||
</span>
|
max={profile.max_volume || 100}
|
||||||
|
type="volume"
|
||||||
|
height="8px"
|
||||||
|
showText={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inventory-backpack-info">
|
||||||
|
{equipment?.backpack ? (
|
||||||
|
<div className="backpack-status active">
|
||||||
|
<span className="backpack-icon">🎒</span>
|
||||||
|
<span className="backpack-name">{getTranslatedText(equipment.backpack.name)}</span>
|
||||||
|
<span className="backpack-stats">
|
||||||
|
(+{equipment.backpack.unique_stats?.weight_capacity || equipment.backpack.stats?.weight_capacity || 0}kg /
|
||||||
|
+{equipment.backpack.unique_stats?.volume_capacity || equipment.backpack.stats?.volume_capacity || 0}L)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="backpack-status inactive">
|
||||||
|
<span className="backpack-icon">🚫</span>
|
||||||
|
<span>{t('game.noBackpack')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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
|
||||||
|
key={cat.id}
|
||||||
|
className={`category-btn ${inventoryCategoryFilter === cat.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onSetInventoryCategoryFilter(cat.id)}
|
||||||
|
>
|
||||||
|
<span className="cat-icon">{cat.icon}</span>
|
||||||
|
<span className="cat-label">{cat.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Content: Search & List */}
|
||||||
|
<div className="inventory-content-area">
|
||||||
|
<div className="game-search-container" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<span className="game-search-icon">🔍</span>
|
||||||
|
<input
|
||||||
|
className="game-search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder={t('game.searchItems')}
|
||||||
|
value={inventoryFilter}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="inventory-view-toggle">
|
||||||
|
<button
|
||||||
|
className={`view-toggle-btn ${viewMode === 'list' ? 'active' : ''}`}
|
||||||
|
onClick={toggleViewMode}
|
||||||
|
title={viewMode === 'list' ? "Switch to Grid View" : "Switch to List View"}
|
||||||
|
>
|
||||||
|
{viewMode === 'list' ? '📋' : '🔲'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inventory-items-grid">
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span className="empty-icon">📦</span>
|
||||||
|
<p>{t('game.noItemsFound')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="backpack-status inactive">
|
inventoryCategoryFilter === 'all' ? (
|
||||||
<span className="backpack-icon">🚫</span>
|
<>
|
||||||
<span>{t('game.noBackpack')}</span>
|
{/* Equipped */}
|
||||||
</div>
|
{filteredItems.some((item: any) => item.is_equipped) && (
|
||||||
)}
|
<>
|
||||||
<button className="close-btn" onClick={handleClose}>✕</button>
|
<div className="category-header">⚔️ {t('game.equipped')}</div>
|
||||||
</div>
|
<div className={`items-container ${viewMode}`}>
|
||||||
</div>
|
{filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) =>
|
||||||
|
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="inventory-main-layout">
|
{/* Backpack - grouped by categories */}
|
||||||
{/* Left Sidebar: Categories */}
|
{filteredItems.some((item: any) => !item.is_equipped) && (
|
||||||
|
<>
|
||||||
<div className="inventory-sidebar-filters">
|
{/* Group backpack items by category */}
|
||||||
{categories.map(cat => (
|
{categories
|
||||||
<button
|
.filter(cat => cat.id !== 'all') // Exclude 'all' from subcategories
|
||||||
key={cat.id}
|
.map(cat => {
|
||||||
className={`category-btn ${inventoryCategoryFilter === cat.id ? 'active' : ''}`}
|
const categoryItems = filteredItems.filter(
|
||||||
onClick={() => onSetInventoryCategoryFilter(cat.id)}
|
(item: any) => !item.is_equipped && item.type === cat.id
|
||||||
>
|
);
|
||||||
<span className="cat-icon">{cat.icon}</span>
|
if (categoryItems.length === 0) return null;
|
||||||
<span className="cat-label">{cat.label}</span>
|
return (
|
||||||
</button>
|
<div key={cat.id} className="backpack-category-section">
|
||||||
))}
|
<div className="category-header">
|
||||||
</div>
|
<span className="subcat-icon">{cat.icon}</span>
|
||||||
|
<span className="subcat-label">{cat.label}</span>
|
||||||
{/* Right Content: Search & List */}
|
<span className="subcat-count">({categoryItems.length})</span>
|
||||||
<div className="inventory-content-area">
|
|
||||||
<div className="game-search-container" style={{ marginBottom: '1.5rem' }}>
|
|
||||||
<span className="game-search-icon">🔍</span>
|
|
||||||
<input
|
|
||||||
className="game-search-input"
|
|
||||||
type="text"
|
|
||||||
placeholder={t('game.searchItems')}
|
|
||||||
value={inventoryFilter}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onSetInventoryFilter(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* View Mode Toggle */}
|
|
||||||
<div className="inventory-view-toggle">
|
|
||||||
<button
|
|
||||||
className={`view-toggle-btn ${viewMode === 'list' ? 'active' : ''}`}
|
|
||||||
onClick={toggleViewMode}
|
|
||||||
title={viewMode === 'list' ? "Switch to Grid View" : "Switch to List View"}
|
|
||||||
>
|
|
||||||
{viewMode === 'list' ? '📋' : '🔲'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inventory-items-grid">
|
|
||||||
{filteredItems.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<span className="empty-icon">📦</span>
|
|
||||||
<p>{t('game.noItemsFound')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
inventoryCategoryFilter === 'all' ? (
|
|
||||||
<>
|
|
||||||
{/* Equipped */}
|
|
||||||
{filteredItems.some((item: any) => item.is_equipped) && (
|
|
||||||
<>
|
|
||||||
<div className="category-header">⚔️ {t('game.equipped')}</div>
|
|
||||||
<div className={`items-container ${viewMode}`}>
|
|
||||||
{filteredItems.filter((item: any) => item.is_equipped).map((item: any, i: number) =>
|
|
||||||
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Backpack - grouped by categories */}
|
|
||||||
{filteredItems.some((item: any) => !item.is_equipped) && (
|
|
||||||
<>
|
|
||||||
{/* Group backpack items by category */}
|
|
||||||
{categories
|
|
||||||
.filter(cat => cat.id !== 'all') // Exclude 'all' from subcategories
|
|
||||||
.map(cat => {
|
|
||||||
const categoryItems = filteredItems.filter(
|
|
||||||
(item: any) => !item.is_equipped && item.type === cat.id
|
|
||||||
);
|
|
||||||
if (categoryItems.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={cat.id} className="backpack-category-section">
|
|
||||||
<div className="category-header">
|
|
||||||
<span className="subcat-icon">{cat.icon}</span>
|
|
||||||
<span className="subcat-label">{cat.label}</span>
|
|
||||||
<span className="subcat-count">({categoryItems.length})</span>
|
|
||||||
</div>
|
|
||||||
<div className={`items-container ${viewMode}`}>
|
|
||||||
{categoryItems.map((item: any, i: number) =>
|
|
||||||
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className={`items-container ${viewMode}`}>
|
||||||
})}
|
{categoryItems.map((item: any, i: number) =>
|
||||||
</>
|
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
/* Single category */
|
);
|
||||||
<div className={`items-container ${viewMode}`}>
|
})}
|
||||||
{filteredItems.map((item: any, i: number) =>
|
</>
|
||||||
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
|
)}
|
||||||
)}
|
</>
|
||||||
</div>
|
) : (
|
||||||
)
|
/* Single category */
|
||||||
)}
|
<div className={`items-container ${viewMode}`}>
|
||||||
</div>
|
{filteredItems.map((item: any, i: number) =>
|
||||||
|
viewMode === 'list' ? renderItemCard(item, i) : renderGridItem(item, i)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div >
|
</div>
|
||||||
</div >
|
</GameModal >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default InventoryModal
|
export default InventoryModal
|
||||||
import { GameProgressBar } from '../common/GameProgressBar'
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@
|
|||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entity-card.grid-card {
|
.entity-card.grid-card {
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -238,17 +239,7 @@
|
|||||||
/* Overlay for text or stats on hover could be improved,
|
/* Overlay for text or stats on hover could be improved,
|
||||||
but for now we keep the tooltip */
|
but for now we keep the tooltip */
|
||||||
|
|
||||||
.grid-card .grid-quantity {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2px;
|
|
||||||
right: 2px;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-weight: bold;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-overlay {
|
.grid-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getAssetPath } from '../../utils/assetPath'
|
|||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
import { DialogModal } from './DialogModal'
|
import { DialogModal } from './DialogModal'
|
||||||
import { TradeModal } from './TradeModal'
|
import { TradeModal } from './TradeModal'
|
||||||
import { ItemTooltipContent } from '../common/ItemTooltipContent'
|
import { GameItemCard } from '../common/GameItemCard'
|
||||||
import { GameModal } from './GameModal'
|
import { GameModal } from './GameModal'
|
||||||
import './LocationView.css'
|
import './LocationView.css'
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ interface LocationViewProps {
|
|||||||
playerState: PlayerState | null
|
playerState: PlayerState | null
|
||||||
combatState: CombatState | null
|
combatState: CombatState | null
|
||||||
message: string
|
message: string
|
||||||
locationMessages: Array<{ time: string; message: string; location_name?: string }>
|
locationMessages: Array<{ time: string; message: string; location_name?: string | { [key: string]: string } }>
|
||||||
expandedCorpse: string | null
|
expandedCorpse: string | null
|
||||||
corpseDetails: any
|
corpseDetails: any
|
||||||
mobileMenuOpen: string
|
mobileMenuOpen: string
|
||||||
@@ -223,6 +223,36 @@ function LocationView({
|
|||||||
setActiveDialogNpc(npc.id);
|
setActiveDialogNpc(npc.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderItemPickupOptions = (item: any, isModal: boolean = false) => {
|
||||||
|
const options = [];
|
||||||
|
options.push({ label: `${t('common.pickUp')}${item.quantity > 1 ? ' (x1)' : ''}`, qty: 1 });
|
||||||
|
if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
|
||||||
|
if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 });
|
||||||
|
if (item.quantity > 1) options.push({ label: `${t('common.pickUpAll')}`, qty: item.quantity });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pickup-options-vertical" style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' }}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<GameButton
|
||||||
|
key={opt.label}
|
||||||
|
variant="success"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playSfx('/audio/sfx/pickup.wav');
|
||||||
|
onPickup(Number(item.id), opt.qty);
|
||||||
|
setActiveDropdown(null);
|
||||||
|
if (isModal) setEntityModal(null);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</GameButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderIndicator = (npcId: string) => {
|
const renderIndicator = (npcId: string) => {
|
||||||
const type = questIndicators[npcId];
|
const type = questIndicators[npcId];
|
||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
@@ -320,7 +350,7 @@ function LocationView({
|
|||||||
<span className="message-time">{msg.time}</span>
|
<span className="message-time">{msg.time}</span>
|
||||||
<span className="message-text">{getTranslatedText(msg.message)}</span>
|
<span className="message-text">{getTranslatedText(msg.message)}</span>
|
||||||
{msg.location_name && (
|
{msg.location_name && (
|
||||||
<span className="message-location">[{msg.location_name}]</span>
|
<span className="message-location">[{getTranslatedText(msg.location_name)}]</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -555,65 +585,16 @@ function LocationView({
|
|||||||
const isShaking = failedActionItemId == item.id;
|
const isShaking = failedActionItemId == item.id;
|
||||||
const itemId = `item-${item.id}-${i}`;
|
const itemId = `item-${item.id}-${i}`;
|
||||||
|
|
||||||
const renderPickupOptions = () => {
|
|
||||||
const options = [];
|
|
||||||
options.push({ label: `${t('common.pickUp')} (x1)`, qty: 1 });
|
|
||||||
if (item.quantity >= 5) options.push({ label: `${t('common.pickUp')} (x5)`, qty: 5 });
|
|
||||||
if (item.quantity >= 10) options.push({ label: `${t('common.pickUp')} (x10)`, qty: 10 });
|
|
||||||
if (item.quantity > 1) options.push({ label: t('common.pickUpAll'), qty: item.quantity });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pickup-options-vertical">
|
|
||||||
{options.map((opt) => (
|
|
||||||
<GameButton
|
|
||||||
key={opt.label}
|
|
||||||
variant="success"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
playSfx('/audio/sfx/pickup.wav');
|
|
||||||
onPickup(Number(item.id), opt.qty);
|
|
||||||
setActiveDropdown(null);
|
|
||||||
}}
|
|
||||||
style={{ width: '100%', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</GameButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={itemId} className={`entity-card item-card grid-card ${isShaking ? 'shake-animation' : ''}`}
|
<div key={itemId} className={`entity-wrapper ${isShaking ? 'shake-animation' : ''}`} style={{ position: 'relative' }}>
|
||||||
onClick={(e) => handleDropdownClick(e, itemId)}
|
<GameItemCard
|
||||||
>
|
item={item}
|
||||||
<GameTooltip content={
|
onClick={(e) => handleDropdownClick(e, itemId)}
|
||||||
<>
|
isActive={activeDropdown === itemId}
|
||||||
<ItemTooltipContent item={item} />
|
showQuantity={true}
|
||||||
<div style={{ color: '#4caf50', fontSize: '0.8rem', marginTop: '0.5rem', textAlign: 'center' }}>Click to Interact</div>
|
showDurability={true}
|
||||||
</>
|
className="entity-card item-card grid-card"
|
||||||
}>
|
/>
|
||||||
<div className="grid-corpse-content">
|
|
||||||
{item.image_path ? (
|
|
||||||
<img
|
|
||||||
src={getAssetPath(item.image_path)}
|
|
||||||
alt={getTranslatedText(item.name)}
|
|
||||||
className="ground-item-image"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div style={{ fontSize: '2rem', display: item.image_path ? 'none' : 'block' }} className={item.image_path ? 'hidden' : ''}>
|
|
||||||
{item.emoji}
|
|
||||||
</div>
|
|
||||||
{item.quantity > 1 && (
|
|
||||||
<div className="grid-quantity">x{item.quantity}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</GameTooltip>
|
|
||||||
|
|
||||||
{activeDropdown === itemId && (
|
{activeDropdown === itemId && (
|
||||||
<GameDropdown
|
<GameDropdown
|
||||||
@@ -622,27 +603,7 @@ function LocationView({
|
|||||||
width="200px"
|
width="200px"
|
||||||
>
|
>
|
||||||
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
|
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
|
||||||
|
{renderItemPickupOptions(item)}
|
||||||
<GameButton
|
|
||||||
variant="success"
|
|
||||||
size="sm"
|
|
||||||
className="pickup-main-btn"
|
|
||||||
onClick={() => {
|
|
||||||
playSfx('/audio/sfx/pickup.wav');
|
|
||||||
onPickup(Number(item.id), 1);
|
|
||||||
setActiveDropdown(null);
|
|
||||||
}}
|
|
||||||
style={{ width: '100%', justifyContent: 'center', marginBottom: '8px' }}
|
|
||||||
>
|
|
||||||
✋ {t('common.pickUp')}
|
|
||||||
</GameButton>
|
|
||||||
|
|
||||||
{item.quantity > 1 && (
|
|
||||||
<>
|
|
||||||
<div className="game-dropdown-divider" style={{ margin: '8px 0' }} />
|
|
||||||
{renderPickupOptions()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</GameDropdown>
|
</GameDropdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -697,7 +658,7 @@ function LocationView({
|
|||||||
<div style={{ fontSize: '2.5rem' }}>
|
<div style={{ fontSize: '2.5rem' }}>
|
||||||
🧍
|
🧍
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
|
<div className="item-quantity-badge" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
|
||||||
Lv.{player.level}
|
Lv.{player.level}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -765,72 +726,63 @@ function LocationView({
|
|||||||
{/* Corpse Loot Overlay Modal */}
|
{/* Corpse Loot Overlay Modal */}
|
||||||
{
|
{
|
||||||
expandedCorpse && corpseDetails && corpseDetails.loot_items && (
|
expandedCorpse && corpseDetails && corpseDetails.loot_items && (
|
||||||
<div className="corpse-loot-overlay" onClick={() => onSetExpandedCorpse(null)}>
|
<GameModal
|
||||||
<div className="corpse-loot-modal" onClick={(e) => e.stopPropagation()}>
|
title={t('location.lootableItems')}
|
||||||
<div className="corpse-details-header">
|
onClose={() => onSetExpandedCorpse(null)}
|
||||||
<h4>{t('location.lootableItems')}</h4>
|
className="corpse-loot-modal-wrapper"
|
||||||
<button
|
>
|
||||||
className="close-btn"
|
<div className="corpse-items-list">
|
||||||
onClick={() => {
|
{corpseDetails.loot_items.map((item: any) => (
|
||||||
onSetExpandedCorpse(null)
|
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
|
||||||
}}
|
{/* Item Image */}
|
||||||
>
|
<div className="corpse-item-image">
|
||||||
✕
|
{item.image_path ? (
|
||||||
</button>
|
<img
|
||||||
</div>
|
src={getAssetPath(item.image_path)}
|
||||||
<div className="corpse-items-list">
|
alt={item.item_name}
|
||||||
{corpseDetails.loot_items.map((item: any) => (
|
className="item-img-thumb"
|
||||||
<div key={item.index} className={`corpse-item ${!item.can_loot ? 'locked' : ''}`}>
|
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
|
||||||
{/* Item Image */}
|
onError={(e) => {
|
||||||
<div className="corpse-item-image">
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
{item.image_path ? (
|
}}
|
||||||
<img
|
/>
|
||||||
src={getAssetPath(item.image_path)}
|
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
|
||||||
alt={item.item_name}
|
|
||||||
className="item-img-thumb"
|
|
||||||
style={{ width: '40px', height: '40px', objectFit: 'contain', marginRight: '10px' }}
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : <span style={{ fontSize: '2rem', marginRight: '10px' }}>{item.emoji || '📦'}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="corpse-item-info" style={{ flex: 1 }}>
|
|
||||||
<div className="corpse-item-name">
|
|
||||||
{getTranslatedText(item.item_name)}
|
|
||||||
</div>
|
|
||||||
{item.description && <div className="corpse-item-desc" style={{ fontSize: '0.75rem', color: '#a0aec0' }}>{getTranslatedText(item.description)}</div>}
|
|
||||||
<div className="corpse-item-qty">
|
|
||||||
{t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
|
|
||||||
</div>
|
|
||||||
{item.required_tool && (
|
|
||||||
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
|
|
||||||
🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
|
|
||||||
<button
|
|
||||||
className="corpse-item-loot-btn"
|
|
||||||
onClick={() => onLootCorpseItem(expandedCorpse, item.index)}
|
|
||||||
disabled={!item.can_loot}
|
|
||||||
>
|
|
||||||
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
|
||||||
</button>
|
|
||||||
</GameTooltip>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
<div className="corpse-item-info" style={{ flex: 1 }}>
|
||||||
<button
|
<div className="corpse-item-name">
|
||||||
className="loot-all-btn"
|
{getTranslatedText(item.item_name)}
|
||||||
onClick={() => onLootCorpseItem(expandedCorpse, null)}
|
</div>
|
||||||
>
|
{item.description && <div className="corpse-item-desc" style={{ fontSize: '0.75rem', color: '#a0aec0' }}>{getTranslatedText(item.description)}</div>}
|
||||||
📦 {t('common.lootAll')}
|
<div className="corpse-item-qty">
|
||||||
</button>
|
{t('common.qty')}: {item.quantity_min}{item.quantity_min !== item.quantity_max ? `-${item.quantity_max}` : ''}
|
||||||
|
</div>
|
||||||
|
{item.required_tool && (
|
||||||
|
<div className={`corpse-item-tool ${item.has_tool ? 'has-tool' : 'needs-tool'}`}>
|
||||||
|
🔧 {getTranslatedText(item.required_tool_name)} {item.has_tool ? '✓' : '✗'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GameTooltip content={!item.can_loot ? `Requires ${getTranslatedText(item.required_tool_name)}` : 'Loot this item'}>
|
||||||
|
<button
|
||||||
|
className="corpse-item-loot-btn"
|
||||||
|
onClick={() => onLootCorpseItem(expandedCorpse, item.index)}
|
||||||
|
disabled={!item.can_loot}
|
||||||
|
>
|
||||||
|
{item.can_loot ? `📦 ${t('common.loot')}` : '🔒'}
|
||||||
|
</button>
|
||||||
|
</GameTooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button
|
||||||
|
className="loot-all-btn"
|
||||||
|
onClick={() => onLootCorpseItem(expandedCorpse, null)}
|
||||||
|
>
|
||||||
|
📦 {t('common.lootAll')}
|
||||||
|
</button>
|
||||||
|
</GameModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,158 +837,152 @@ function LocationView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
{/* Entity "Show All" Modal */}
|
{/* Entity "Show All" Modal */}
|
||||||
{entityModal && (
|
{
|
||||||
<GameModal
|
entityModal && (
|
||||||
title={entityModal.title}
|
<GameModal
|
||||||
onClose={() => setEntityModal(null)}
|
title={entityModal.title}
|
||||||
className="entity-show-all-modal"
|
onClose={() => setEntityModal(null)}
|
||||||
>
|
className="entity-show-all-modal"
|
||||||
<div className="entity-modal-grid">
|
>
|
||||||
{entityModal.type === 'enemies' && location.npcs
|
<div className="entity-modal-grid">
|
||||||
.filter((npc: any) => npc.type === 'enemy')
|
{entityModal.type === 'enemies' && location.npcs
|
||||||
.map((enemy: any) => {
|
.filter((npc: any) => npc.type === 'enemy')
|
||||||
const id = `modal-enemy-${enemy.id}`;
|
.map((enemy: any) => {
|
||||||
return (
|
const id = `modal-enemy-${enemy.id}`;
|
||||||
<div key={enemy.id} className="entity-card enemy-card grid-card"
|
return (
|
||||||
onClick={(e) => handleDropdownClick(e, id)}
|
<div key={enemy.id} className="entity-card enemy-card grid-card"
|
||||||
>
|
onClick={(e) => handleDropdownClick(e, id)}
|
||||||
{enemy.id && (
|
|
||||||
<div className="entity-image padded-image">
|
|
||||||
<img
|
|
||||||
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
|
||||||
alt={getTranslatedText(enemy.name)}
|
|
||||||
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<GameTooltip content={
|
|
||||||
<div>
|
|
||||||
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
|
|
||||||
<div>{t('location.level')} {enemy.level}</div>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div className="grid-overlay"></div>
|
|
||||||
</GameTooltip>
|
|
||||||
{activeDropdown === id && (
|
|
||||||
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
|
|
||||||
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
|
|
||||||
<GameButton variant="danger" size="sm"
|
|
||||||
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); setEntityModal(null); }}
|
|
||||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
|
||||||
>
|
|
||||||
⚔️ {t('common.fight')}
|
|
||||||
</GameButton>
|
|
||||||
</GameDropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{entityModal.type === 'corpses' && (location.corpses || []).map((corpse: any) => (
|
|
||||||
<div key={corpse.id} className="entity-card corpse-card grid-card"
|
|
||||||
onClick={(e) => handleDropdownClick(e, `modal-corpse-${corpse.id}`)}
|
|
||||||
>
|
|
||||||
<div className="grid-corpse-content">
|
|
||||||
{corpse.image_path ? (
|
|
||||||
<img src={getAssetPath(corpse.image_path)} alt={getTranslatedText(corpse.name)} className="corpse-image"
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
|
||||||
) : <div style={{ fontSize: '2rem' }}>{corpse.emoji}</div>}
|
|
||||||
<div className="corpse-loot-count">{corpse.loot_count} items</div>
|
|
||||||
</div>
|
|
||||||
{activeDropdown === `modal-corpse-${corpse.id}` && (
|
|
||||||
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
|
|
||||||
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
|
|
||||||
<GameButton variant="secondary" size="sm"
|
|
||||||
onClick={() => { playSfx('/audio/sfx/interact.wav'); onLootCorpse(String(corpse.id)); setActiveDropdown(null); setEntityModal(null); }}
|
|
||||||
disabled={corpse.loot_count === 0}
|
|
||||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
|
||||||
>
|
>
|
||||||
🔍 {t('common.examine')}
|
{enemy.id && (
|
||||||
</GameButton>
|
<div className="entity-image padded-image">
|
||||||
</GameDropdown>
|
<img
|
||||||
)}
|
src={getAssetPath(enemy.image_path || `images/npcs/${(typeof enemy.name === 'string' ? enemy.name : enemy.name?.en || '').toLowerCase().replace(/ /g, '_')}.webp`)}
|
||||||
</div>
|
alt={getTranslatedText(enemy.name)}
|
||||||
))}
|
onError={(e: any) => { e.currentTarget.style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<GameTooltip content={
|
||||||
|
<div>
|
||||||
|
<div className="tooltip-title">{getTranslatedText(enemy.name)}</div>
|
||||||
|
<div>{t('location.level')} {enemy.level}</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<div className="grid-overlay"></div>
|
||||||
|
</GameTooltip>
|
||||||
|
{activeDropdown === id && (
|
||||||
|
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
|
||||||
|
<div className="game-dropdown-header">{getTranslatedText(enemy.name)}</div>
|
||||||
|
<GameButton variant="danger" size="sm"
|
||||||
|
onClick={() => { onInitiateCombat(enemy.id); setActiveDropdown(null); setEntityModal(null); }}
|
||||||
|
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||||
|
>
|
||||||
|
⚔️ {t('common.fight')}
|
||||||
|
</GameButton>
|
||||||
|
</GameDropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{entityModal.type === 'npcs' && location.npcs
|
{entityModal.type === 'corpses' && (location.corpses || []).map((corpse: any) => (
|
||||||
.filter((npc: any) => npc.type !== 'enemy')
|
<div key={corpse.id} className="entity-card corpse-card grid-card"
|
||||||
.map((npc: any, i: number) => (
|
onClick={(e) => handleDropdownClick(e, `modal-corpse-${corpse.id}`)}
|
||||||
<div key={i} className="entity-card npc-card grid-card"
|
|
||||||
onClick={() => { handleNpcClick(npc); setEntityModal(null); }}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{npc.image_path ? (
|
|
||||||
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
) : <span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>}
|
|
||||||
<div className="grid-overlay"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{entityModal.type === 'items' && [...location.items]
|
|
||||||
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
|
|
||||||
.map((item: any, i: number) => {
|
|
||||||
const itemId = `modal-item-${item.id}-${i}`;
|
|
||||||
return (
|
|
||||||
<div key={itemId} className="entity-card item-card grid-card"
|
|
||||||
onClick={(e) => handleDropdownClick(e, itemId)}
|
|
||||||
>
|
|
||||||
<GameTooltip content={<ItemTooltipContent item={item} />}>
|
|
||||||
<div className="grid-corpse-content">
|
|
||||||
{item.image_path ? (
|
|
||||||
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="ground-item-image"
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
|
||||||
) : <div style={{ fontSize: '2rem' }}>{item.emoji}</div>}
|
|
||||||
{item.quantity > 1 && <div className="grid-quantity">x{item.quantity}</div>}
|
|
||||||
</div>
|
|
||||||
</GameTooltip>
|
|
||||||
{activeDropdown === itemId && (
|
|
||||||
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="200px">
|
|
||||||
<div className="game-dropdown-header">{getTranslatedText(item.name)}</div>
|
|
||||||
<GameButton variant="success" size="sm"
|
|
||||||
onClick={() => { playSfx('/audio/sfx/pickup.wav'); onPickup(Number(item.id), 1); setActiveDropdown(null); setEntityModal(null); }}
|
|
||||||
style={{ width: '100%', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
✋ {t('common.pickUp')}
|
|
||||||
</GameButton>
|
|
||||||
</GameDropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{entityModal.type === 'players' && (location.other_players || []).map((player: any, i: number) => {
|
|
||||||
const playerId = `modal-player-${player.id}-${i}`;
|
|
||||||
return (
|
|
||||||
<div key={i} className="entity-card player-card grid-card"
|
|
||||||
onClick={(e) => handleDropdownClick(e, playerId)}
|
|
||||||
>
|
>
|
||||||
<div className="grid-corpse-content">
|
<div className="grid-corpse-content">
|
||||||
<div style={{ fontSize: '2.5rem' }}>🧍</div>
|
{corpse.image_path ? (
|
||||||
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
|
<img src={getAssetPath(corpse.image_path)} alt={getTranslatedText(corpse.name)} className="corpse-image"
|
||||||
Lv.{player.level}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||||
</div>
|
) : <div style={{ fontSize: '2rem' }}>{corpse.emoji}</div>}
|
||||||
|
<div className="corpse-loot-count">{corpse.loot_count} items</div>
|
||||||
</div>
|
</div>
|
||||||
{activeDropdown === playerId && (
|
{activeDropdown === `modal-corpse-${corpse.id}` && (
|
||||||
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="180px">
|
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="160px">
|
||||||
<div className="game-dropdown-header">{player.name || player.username}</div>
|
<div className="game-dropdown-header">{getTranslatedText(corpse.name)}</div>
|
||||||
{player.can_pvp ? (
|
<GameButton variant="secondary" size="sm"
|
||||||
<GameButton variant="danger" size="sm"
|
onClick={() => { playSfx('/audio/sfx/interact.wav'); onLootCorpse(String(corpse.id)); setActiveDropdown(null); setEntityModal(null); }}
|
||||||
onClick={() => { onInitiatePvP(player.id); setActiveDropdown(null); setEntityModal(null); }}
|
disabled={corpse.loot_count === 0}
|
||||||
style={{ width: '100%', justifyContent: 'flex-start' }}
|
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||||
>
|
>
|
||||||
⚔️ {t('game.attack')}
|
🔍 {t('common.examine')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
) : (
|
|
||||||
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>PvP Unavailable</div>
|
|
||||||
)}
|
|
||||||
</GameDropdown>
|
</GameDropdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
{entityModal.type === 'npcs' && location.npcs
|
||||||
</GameModal>
|
.filter((npc: any) => npc.type !== 'enemy')
|
||||||
)}
|
.map((npc: any, i: number) => (
|
||||||
|
<div key={i} className="entity-card npc-card grid-card"
|
||||||
|
onClick={() => { handleNpcClick(npc); setEntityModal(null); }}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{npc.image_path ? (
|
||||||
|
<img src={getAssetPath(npc.image_path)} alt={getTranslatedText(npc.name)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : <span className="entity-icon" style={{ fontSize: '2.5rem' }}>🧑</span>}
|
||||||
|
<div className="grid-overlay"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{entityModal.type === 'items' && [...location.items]
|
||||||
|
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
|
||||||
|
.map((item: any, i: number) => {
|
||||||
|
const itemId = `modal-item-${item.id}-${i}`;
|
||||||
|
return (
|
||||||
|
<div key={itemId} style={{ position: 'relative' }}>
|
||||||
|
<GameItemCard
|
||||||
|
item={item}
|
||||||
|
onClick={(e) => handleDropdownClick(e, itemId)}
|
||||||
|
isActive={activeDropdown === itemId}
|
||||||
|
showQuantity={true}
|
||||||
|
showDurability={true}
|
||||||
|
className="entity-card item-card"
|
||||||
|
/>
|
||||||
|
{activeDropdown === itemId && (
|
||||||
|
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="200px">
|
||||||
|
<div className="game-dropdown-header">{getTranslatedText(item.name)} {item.quantity > 1 ? `(x${item.quantity})` : ''}</div>
|
||||||
|
{renderItemPickupOptions(item, true)}
|
||||||
|
</GameDropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{entityModal.type === 'players' && (location.other_players || []).map((player: any, i: number) => {
|
||||||
|
const playerId = `modal-player-${player.id}-${i}`;
|
||||||
|
return (
|
||||||
|
<div key={i} className="entity-card player-card grid-card"
|
||||||
|
onClick={(e) => handleDropdownClick(e, playerId)}
|
||||||
|
>
|
||||||
|
<div className="grid-corpse-content">
|
||||||
|
<div style={{ fontSize: '2.5rem' }}>🧍</div>
|
||||||
|
<div className="grid-quantity" style={{ top: '2px', right: '2px', bottom: 'auto', background: 'rgba(49, 130, 206, 0.8)', borderColor: 'rgba(99, 179, 237, 0.4)' }}>
|
||||||
|
Lv.{player.level}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeDropdown === playerId && (
|
||||||
|
<GameDropdown isOpen={true} onClose={() => setActiveDropdown(null)} width="180px">
|
||||||
|
<div className="game-dropdown-header">{player.name || player.username}</div>
|
||||||
|
{player.can_pvp ? (
|
||||||
|
<GameButton variant="danger" size="sm"
|
||||||
|
onClick={() => { onInitiatePvP(player.id); setActiveDropdown(null); setEntityModal(null); }}
|
||||||
|
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||||
|
>
|
||||||
|
⚔️ {t('game.attack')}
|
||||||
|
</GameButton>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: '#a0aec0' }}>PvP Unavailable</div>
|
||||||
|
)}
|
||||||
|
</GameDropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</GameModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div >
|
</div >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,9 +112,11 @@ function MovementControls({
|
|||||||
const outsideDir = location.directions.includes('outside') ? 'outside' : null;
|
const outsideDir = location.directions.includes('outside') ? 'outside' : null;
|
||||||
const enterDir = location.directions.includes('enter') ? 'enter' : null;
|
const enterDir = location.directions.includes('enter') ? 'enter' : null;
|
||||||
const exitDir = location.directions.includes('exit') ? 'exit' : null;
|
const exitDir = location.directions.includes('exit') ? 'exit' : null;
|
||||||
|
const upDir = location.directions.includes('up') ? 'up' : null;
|
||||||
|
const downDir = location.directions.includes('down') ? 'down' : null;
|
||||||
|
|
||||||
// Priority: Inside/Outside (usually mutually exclusive) > Enter/Exit
|
// Priority: Inside/Outside (usually mutually exclusive) > Enter/Exit
|
||||||
const centerDirection = insideDir || outsideDir || enterDir || exitDir;
|
const centerDirection = insideDir || outsideDir || enterDir || exitDir || upDir || downDir;
|
||||||
|
|
||||||
if (!centerDirection) {
|
if (!centerDirection) {
|
||||||
// Default Compass Icon
|
// Default Compass Icon
|
||||||
@@ -136,6 +138,8 @@ function MovementControls({
|
|||||||
let icon = '🚪';
|
let icon = '🚪';
|
||||||
if (centerDirection === 'inside') icon = '🏠';
|
if (centerDirection === 'inside') icon = '🏠';
|
||||||
if (centerDirection === 'outside') icon = '🌳';
|
if (centerDirection === 'outside') icon = '🌳';
|
||||||
|
if (centerDirection === 'up') icon = '⬆️';
|
||||||
|
if (centerDirection === 'down') icon = '⬇️';
|
||||||
|
|
||||||
const tooltipText = profile?.is_dead ? t('messages.youAreDead') :
|
const tooltipText = profile?.is_dead ? t('messages.youAreDead') :
|
||||||
movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
|
movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) :
|
||||||
@@ -191,44 +195,6 @@ function MovementControls({
|
|||||||
{renderCompassButton('southeast', '↘️', 'se')}
|
{renderCompassButton('southeast', '↘️', 'se')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(location.directions.includes('up') || location.directions.includes('down')) && (
|
|
||||||
<div className="special-moves" style={{ display: 'flex', justifyContent: 'center', gap: '0.5rem' }}>
|
|
||||||
{location.directions.includes('up') && (
|
|
||||||
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
|
|
||||||
<div className="movement-tooltip">
|
|
||||||
<div className="tooltip-title">{t('directions.up')}</div>
|
|
||||||
<div className="tooltip-stat">⚡ {t('game.stamina')}: {getStaminaCost('up')}</div>
|
|
||||||
</div>
|
|
||||||
)}>
|
|
||||||
<button
|
|
||||||
onClick={() => onMove('up')}
|
|
||||||
className="compass-center-btn"
|
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
|
||||||
style={{ padding: '0.3rem 1rem' }}
|
|
||||||
>
|
|
||||||
⬆️ {t('directions.up')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('up')}`}</span>
|
|
||||||
</button>
|
|
||||||
</GameTooltip>
|
|
||||||
)}
|
|
||||||
{location.directions.includes('down') && (
|
|
||||||
<GameTooltip content={movementCooldown > 0 ? t('messages.waitBeforeMoving', { seconds: movementCooldown }) : combatState ? t('messages.cannotTravelCombat') : (
|
|
||||||
<div className="movement-tooltip">
|
|
||||||
<div className="tooltip-title">{t('directions.down')}</div>
|
|
||||||
<div className="tooltip-stat">⚡ {t('game.stamina')}: {getStaminaCost('down')}</div>
|
|
||||||
</div>
|
|
||||||
)}>
|
|
||||||
<button
|
|
||||||
onClick={() => onMove('down')}
|
|
||||||
className="compass-center-btn"
|
|
||||||
disabled={!!combatState || movementCooldown > 0}
|
|
||||||
style={{ padding: '0.3rem 1rem' }}
|
|
||||||
>
|
|
||||||
⬇️ {t('directions.down')} <span className="compass-cost">{movementCooldown > 0 ? `⏳${movementCooldown}s` : `⚡${getStaminaCost('down')}`}</span>
|
|
||||||
</button>
|
|
||||||
</GameTooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Surroundings - outside movement controls */}
|
{/* Surroundings - outside movement controls */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useGame } from '../../contexts/GameContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { PlayerState, Profile, Equipment } from './types'
|
import type { PlayerState, Profile, Equipment } from './types'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
@@ -7,6 +8,8 @@ import InventoryModal from './InventoryModal'
|
|||||||
import { GameProgressBar } from '../common/GameProgressBar'
|
import { GameProgressBar } from '../common/GameProgressBar'
|
||||||
import { GameTooltip } from '../common/GameTooltip'
|
import { GameTooltip } from '../common/GameTooltip'
|
||||||
import { GameButton } from '../common/GameButton'
|
import { GameButton } from '../common/GameButton'
|
||||||
|
import { GameItemCard } from '../common/GameItemCard'
|
||||||
|
import { GameDropdown } from '../common/GameDropdown'
|
||||||
import { useAudio } from '../../contexts/AudioContext'
|
import { useAudio } from '../../contexts/AudioContext'
|
||||||
|
|
||||||
interface PlayerSidebarProps {
|
interface PlayerSidebarProps {
|
||||||
@@ -43,107 +46,63 @@ function PlayerSidebar({
|
|||||||
onOpenQuestJournal
|
onOpenQuestJournal
|
||||||
}: PlayerSidebarProps) {
|
}: PlayerSidebarProps) {
|
||||||
const [showInventory, setShowInventory] = useState(false)
|
const [showInventory, setShowInventory] = useState(false)
|
||||||
|
const [activeSlot, setActiveSlot] = useState<string | null>(null)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { playSfx } = useAudio()
|
const { playSfx } = useAudio()
|
||||||
|
const { state } = useGame() // Use global state to check quests
|
||||||
|
|
||||||
|
// Check if any quest is ready to turn in
|
||||||
|
const hasReadyQuests = state.quests.active?.some((q: any) => {
|
||||||
|
// Check if all objectives met
|
||||||
|
if (!q.objectives) return false;
|
||||||
|
return q.objectives.every((obj: any) => {
|
||||||
|
const current = q.progress?.[obj.target] || 0;
|
||||||
|
return current >= obj.count;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const renderEquipmentSlot = (slot: string, item: any, label: string) => {
|
const renderEquipmentSlot = (slot: string, item: any, label: string) => {
|
||||||
// Construct the tooltip content if item exists
|
// Merge with full inventory data to ensure tooltips have weight/volume
|
||||||
const tooltipContent = item ? (
|
const fullItemInfo = playerState.inventory?.find((i: any) => i.is_equipped && i.equipment_slot === slot) || item;
|
||||||
<div className="game-tooltip-stats">
|
|
||||||
<div className="item-tooltip-name" style={{ color: 'var(--game-text-highlight)', fontWeight: 'bold' }}>
|
|
||||||
{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" style={{ color: 'var(--game-text-secondary)', fontStyle: 'italic', marginBottom: '0.5rem' }}>{getTranslatedText(item.description)}</div>}
|
|
||||||
{(item.unique_stats || item.stats) && Object.keys(item.unique_stats || item.stats).length > 0 && (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'auto auto', gap: '0.25rem 1rem' }}>
|
|
||||||
{(item.unique_stats?.armor || item.stats?.armor) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.armor')}: <span style={{ color: 'var(--game-color-success)' }}>+{item.unique_stats?.armor || item.stats?.armor}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.hp_max || item.stats?.hp_max) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.hp')}: <span style={{ color: 'var(--game-color-success)' }}>+{item.unique_stats?.hp_max || item.stats?.hp_max}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.stamina_max || item.stats?.stamina_max) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.stamina')}: <span style={{ color: 'var(--game-color-stamina)' }}>+{item.unique_stats?.stamina_max || item.stats?.stamina_max}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.damage_min !== undefined || item.stats?.damage_min !== undefined) &&
|
|
||||||
(item.unique_stats?.damage_max !== undefined || item.stats?.damage_max !== undefined) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.damage')}: <span style={{ color: 'var(--game-color-primary)' }}>{item.unique_stats?.damage_min || item.stats?.damage_min}-{item.unique_stats?.damage_max || item.stats?.damage_max}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.weight')}: +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
|
|
||||||
<div className="item-tooltip-stat">
|
|
||||||
{t('stats.volume')}: +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.durability !== undefined && item.durability !== null && (
|
|
||||||
<div className="item-tooltip-stat" style={{ marginTop: '0.5rem' }}>
|
|
||||||
<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>
|
|
||||||
) : label; // Show label if no item
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`equipment-slot game-slot ${item ? 'filled' : 'empty'}`}>
|
<div className={`equipment-slot game-slot ${item ? 'filled' : 'empty'}`}>
|
||||||
{item ? (
|
{item ? (
|
||||||
<>
|
<>
|
||||||
<GameTooltip content={t('game.unequip')}>
|
<GameItemCard
|
||||||
<button className="equipment-unequip-btn game-btn game-btn-icon" onClick={(e) => { e.stopPropagation(); onUnequipItem(slot); playSfx('/audio/sfx/unequip.wav'); }}>✕</button>
|
item={fullItemInfo}
|
||||||
</GameTooltip>
|
showTooltip={activeSlot !== slot} // Hide tooltip when dropdown configures
|
||||||
<GameTooltip content={tooltipContent}>
|
showDurability={true}
|
||||||
<div className="equipment-item-content">
|
onClick={(e) => {
|
||||||
{item.image_path ? (
|
e.preventDefault();
|
||||||
<img
|
e.stopPropagation();
|
||||||
src={getAssetPath(item.image_path)}
|
// Toggle active slot
|
||||||
alt={getTranslatedText(item.name)}
|
setActiveSlot(activeSlot === slot ? null : slot);
|
||||||
className="equipment-emoji"
|
}}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
isActive={activeSlot === slot}
|
||||||
/>
|
className="equipment-item-content"
|
||||||
) : (
|
style={{ width: '100%', height: '100%' }}
|
||||||
<span className="equipment-emoji" style={{ fontSize: '2rem' }}>{item.emoji}</span>
|
/>
|
||||||
)}
|
{activeSlot === slot && (
|
||||||
{item.durability !== undefined && item.durability !== null && (
|
<GameDropdown isOpen={true} onClose={() => setActiveSlot(null)} width="160px">
|
||||||
<div className="equipment-durability-bar-container" style={{ width: '90%', marginTop: 'auto', marginBottom: '4px' }}>
|
<div className="game-dropdown-header">
|
||||||
<GameProgressBar
|
{getTranslatedText(item.name)}
|
||||||
value={item.durability}
|
</div>
|
||||||
max={item.max_durability}
|
<GameButton
|
||||||
type="durability"
|
variant="info"
|
||||||
height="4px"
|
size="sm"
|
||||||
showText={false}
|
onClick={(e) => {
|
||||||
/>
|
e.stopPropagation();
|
||||||
</div>
|
setActiveSlot(null);
|
||||||
)}
|
onUnequipItem(slot);
|
||||||
</div>
|
playSfx('/audio/sfx/unequip.wav');
|
||||||
</GameTooltip>
|
}}
|
||||||
|
style={{ width: '100%', justifyContent: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{t('game.unequip')}
|
||||||
|
</GameButton>
|
||||||
|
</GameDropdown>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<GameTooltip content={label}>
|
<GameTooltip content={label}>
|
||||||
@@ -305,13 +264,13 @@ function PlayerSidebar({
|
|||||||
</GameButton>
|
</GameButton>
|
||||||
|
|
||||||
<GameButton
|
<GameButton
|
||||||
className="quest-journal-btn"
|
className={`quest-journal-btn ${hasReadyQuests ? 'pulse-gold' : ''}`}
|
||||||
variant="secondary"
|
variant={hasReadyQuests ? 'warning' : 'secondary'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onOpenQuestJournal}
|
onClick={onOpenQuestJournal}
|
||||||
style={{ flex: 1, justifyContent: 'center' }}
|
style={{ flex: 1, justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
📜 {t('common.quests')}
|
{hasReadyQuests ? '❗ ' : '📜 '}{t('common.quests')}
|
||||||
</GameButton>
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,66 +1,37 @@
|
|||||||
.quest-journal-overlay {
|
.quest-journal-modal {
|
||||||
position: fixed;
|
width: 90vw;
|
||||||
top: 0;
|
max-width: 1200px;
|
||||||
left: 0;
|
height: 95%;
|
||||||
right: 0;
|
}
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
.quest-journal-modal .game-modal-content {
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
height: 100%;
|
||||||
}
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
.journal-container {
|
/* Manage scroll internally */
|
||||||
background: rgba(20, 20, 20, 0.95);
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
max-width: 800px;
|
|
||||||
width: 90%;
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
color: #e0e0e0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journal-title {
|
|
||||||
color: #ff9800;
|
|
||||||
border-bottom: 2px solid #555;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journal-close-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 15px;
|
|
||||||
right: 15px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #888;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journal-close-btn:hover {
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tabs - matching Workbench style but full width split */
|
||||||
.tab-container {
|
.tab-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 20px;
|
gap: 10px;
|
||||||
border-bottom: 1px solid #444;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.journal-tab {
|
.journal-tab {
|
||||||
|
flex: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
border-bottom: 3px solid transparent;
|
color: #a0aec0;
|
||||||
color: #aaa;
|
padding: 10px;
|
||||||
padding: 10px 20px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-weight: 600;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: none;
|
||||||
|
/* Override old */
|
||||||
}
|
}
|
||||||
|
|
||||||
.journal-tab:hover {
|
.journal-tab:hover {
|
||||||
@@ -69,78 +40,389 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.journal-tab.active {
|
.journal-tab.active {
|
||||||
background: rgba(255, 152, 0, 0.2);
|
background: #3182ce;
|
||||||
border-bottom: 3px solid #ff9800;
|
color: #fff;
|
||||||
color: #ff9800;
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
border-color: transparent;
|
||||||
|
/* Override old */
|
||||||
}
|
}
|
||||||
|
|
||||||
.quest-list {
|
/* Main Layout */
|
||||||
|
.journal-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quest List Column */
|
||||||
|
.quest-list-column {
|
||||||
|
width: 40%;
|
||||||
|
min-width: 300px;
|
||||||
|
border-right: 1px solid var(--game-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 15px;
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quest-card {
|
/* Search Bar (Game Style) - Removed (using Game.css global) */
|
||||||
background: rgba(0, 0, 0, 0.3);
|
.game-search-container {
|
||||||
border: 1px solid #555;
|
margin: 10px;
|
||||||
border-radius: 5px;
|
}
|
||||||
|
|
||||||
|
/* Quest List Area */
|
||||||
|
.quest-list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quest List Cards */
|
||||||
|
.quest-list-item {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quest-card.completed {
|
.quest-list-item:hover {
|
||||||
border-color: #4caf50;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quest-card h3 {
|
.quest-list-item.selected {
|
||||||
margin: 0 0 5px 0;
|
border-color: var(--game-color-primary);
|
||||||
color: #ddd;
|
box-shadow: 0 0 0 1px var(--game-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-list-item h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #eee;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-card-type {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
color: #aaa;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-list-item.selected h4 {
|
||||||
|
color: var(--game-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-status-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background-color: #2196f3;
|
||||||
|
box-shadow: 0 0 5px #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background-color: #4caf50;
|
||||||
|
box-shadow: 0 0 5px #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination - Sticky at Bottom */
|
||||||
|
.pagination-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-top: 1px solid var(--game-border-color);
|
||||||
|
background: rgba(18, 18, 18, 0.95);
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quest-card.completed h3 {
|
/* Right Column: Quest Details */
|
||||||
color: #4caf50;
|
.quest-details-column {
|
||||||
|
flex: 1;
|
||||||
|
padding: 30px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quest-desc {
|
.quest-details-header {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-details-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--game-color-primary);
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-giver-info {
|
||||||
|
margin-top: 15px;
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 10px;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-giver-image {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||||
|
background: var(--game-bg-app)
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-giver-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #ccc;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.objective-list {
|
.label {
|
||||||
|
color: #888;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: #ddd;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-description {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #e0e0e0;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 15px;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
border-left: 2px solid var(--game-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-section-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
margin-top: 25px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objective-list,
|
||||||
|
.rewards-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 10px 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.objective-item {
|
.objective-item {
|
||||||
color: #aaa;
|
padding: 12px 15px;
|
||||||
margin-bottom: 4px;
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
color: #ccc;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.objective-item.met {
|
.objective-item::before {
|
||||||
color: #8bc34a;
|
content: "○";
|
||||||
}
|
margin-right: 10px;
|
||||||
|
color: #aaa;
|
||||||
.objective-item:before {
|
|
||||||
content: '○';
|
|
||||||
margin-right: 8px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #777;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.objective-item.met:before {
|
.objective-item.met {
|
||||||
content: '✓';
|
background: rgba(76, 175, 80, 0.1);
|
||||||
color: #8bc34a;
|
border-color: rgba(76, 175, 80, 0.3);
|
||||||
|
color: #a5d6a7;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-message {
|
.objective-item.met::before {
|
||||||
text-align: center;
|
content: "✓";
|
||||||
padding: 40px;
|
color: #4caf50;
|
||||||
color: #777;
|
}
|
||||||
|
|
||||||
|
.rewards-list li {
|
||||||
|
padding: 10px;
|
||||||
|
color: #ffd700;
|
||||||
|
background: rgba(255, 215, 0, 0.05);
|
||||||
|
border: 1px solid rgba(255, 215, 0, 0.2);
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-list li::before {
|
||||||
|
content: "🎁";
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dates {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 30px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-text {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
color: #b0bec5;
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-left: 2px solid #b0bec5;
|
||||||
|
clip-path: var(--game-clip-path);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-selection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-style: italic;
|
||||||
|
background: repeating-linear-gradient(45deg,
|
||||||
|
rgba(0, 0, 0, 0.1),
|
||||||
|
rgba(0, 0, 0, 0.1) 10px,
|
||||||
|
rgba(0, 0, 0, 0.15) 10px,
|
||||||
|
rgba(0, 0, 0, 0.15) 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar Styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Quest Styles */
|
||||||
|
.objective-item.global-objective {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(33, 150, 243, 0.1);
|
||||||
|
border-color: rgba(33, 150, 243, 0.3);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objective-item.global-objective::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objective-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #4caf50;
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-progress-container {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-bg {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill.global {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #2196f3, #64b5f6);
|
||||||
|
box-shadow: 0 0 10px rgba(33, 150, 243, 0.5);
|
||||||
|
transition: width 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-contribution {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #ccc;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribution-value {
|
||||||
|
color: #ffd700;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useGame } from '../../contexts/GameContext';
|
import { useGame } from '../../contexts/GameContext';
|
||||||
import { GameModal } from './GameModal';
|
import { GameModal } from './GameModal';
|
||||||
|
import { GameButton } from '../common/GameButton';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
import './QuestJournal.css';
|
import './QuestJournal.css';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
interface Quest {
|
interface Quest {
|
||||||
quest_id: string;
|
quest_id: string;
|
||||||
@@ -14,18 +17,76 @@ interface Quest {
|
|||||||
type: string;
|
type: string;
|
||||||
completion_text?: { [key: string]: string } | string;
|
completion_text?: { [key: string]: string } | string;
|
||||||
completed_at?: number;
|
completed_at?: number;
|
||||||
|
started_at?: number;
|
||||||
|
giver_name?: { [key: string]: string } | string;
|
||||||
|
giver_location_id?: string;
|
||||||
|
giver_location_name?: { [key: string]: string } | string;
|
||||||
|
giver_image?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuestJournalProps {
|
interface QuestJournalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { GAME_API_URL } from '../../config';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
|
export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
|
||||||
const { locale, state } = useGame(); // Use global state
|
const { t } = useTranslation();
|
||||||
|
const { locale, state } = useGame();
|
||||||
|
const { addNotification } = useNotification();
|
||||||
|
|
||||||
|
// Tabs
|
||||||
const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active');
|
const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active');
|
||||||
|
|
||||||
// Derived from global state
|
// Selection - now using a composite key
|
||||||
const quests = (state.quests.active || []) as Quest[];
|
const [selectedQuestKey, setSelectedQuestKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const [activeQuests, setActiveQuests] = useState<Quest[]>([]);
|
||||||
|
const [historyQuests, setHistoryQuests] = useState<Quest[]>([]);
|
||||||
|
|
||||||
|
// Pagination for History
|
||||||
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
|
const [historyTotalPages, setHistoryTotalPages] = useState(1);
|
||||||
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
|
||||||
|
// Initial Load of Active Quests from Game State
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.quests && state.quests.active) {
|
||||||
|
setActiveQuests(state.quests.active as Quest[]);
|
||||||
|
}
|
||||||
|
}, [state.quests]);
|
||||||
|
|
||||||
|
// Fetch History when tab changes to completed or page changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'completed') {
|
||||||
|
fetchHistory(historyPage);
|
||||||
|
}
|
||||||
|
}, [activeTab, historyPage]);
|
||||||
|
|
||||||
|
const fetchHistory = async (page: number) => {
|
||||||
|
setLoadingHistory(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await axios.get(`${GAME_API_URL}/quests/history`, {
|
||||||
|
params: { page, limit: 10 }, // 10 per page for better UI fit
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
setHistoryQuests(response.data.data);
|
||||||
|
setHistoryTotalPages(response.data.pages);
|
||||||
|
setHistoryPage(response.data.page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch quest history", error);
|
||||||
|
addNotification(t('common.error'), "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingHistory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getLocalizedText = (textObj: any) => {
|
const getLocalizedText = (textObj: any) => {
|
||||||
if (typeof textObj === 'string') return textObj;
|
if (typeof textObj === 'string') return textObj;
|
||||||
@@ -33,25 +94,111 @@ export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
|
|||||||
return textObj[locale] || textObj['en'] || Object.values(textObj)[0] || '';
|
return textObj[locale] || textObj['en'] || Object.values(textObj)[0] || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredQuests = quests.filter((q: Quest) => {
|
// Filter Logic
|
||||||
if (activeTab === 'active') {
|
const getFilteredQuests = () => {
|
||||||
return q.status === 'active';
|
const source = activeTab === 'active' ? activeQuests : historyQuests;
|
||||||
|
if (!searchQuery) return source;
|
||||||
|
return source.filter(q => {
|
||||||
|
const title = getLocalizedText(q.title).toLowerCase();
|
||||||
|
return title.includes(searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuestKey = (quest: Quest) => {
|
||||||
|
return quest.quest_id + (quest.completed_at ? `_${quest.completed_at}` : `_${quest.started_at || ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredQuests = getFilteredQuests();
|
||||||
|
const selectedQuest = filteredQuests.find(q => getQuestKey(q) === selectedQuestKey) || (filteredQuests.length > 0 ? filteredQuests[0] : null);
|
||||||
|
|
||||||
|
// Automatically select first if selection is invalid/null or tab changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredQuests.length > 0) {
|
||||||
|
const currentKeyValid = selectedQuestKey && filteredQuests.some(q => getQuestKey(q) === selectedQuestKey);
|
||||||
|
if (!currentKeyValid) {
|
||||||
|
setSelectedQuestKey(getQuestKey(filteredQuests[0]));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return q.status === 'completed';
|
setSelectedQuestKey(null);
|
||||||
}
|
}
|
||||||
});
|
}, [filteredQuests, activeTab]); // Re-run when list changes or tab changes
|
||||||
|
|
||||||
|
|
||||||
|
// Renderers
|
||||||
const renderObjectives = (quest: Quest) => {
|
const renderObjectives = (quest: Quest) => {
|
||||||
return quest.objectives.map((obj, idx) => {
|
if (!quest.objectives) return null;
|
||||||
const current = quest.progress[obj.target] || 0;
|
|
||||||
const required = obj.count;
|
|
||||||
const met = current >= required;
|
|
||||||
let label = obj.target;
|
|
||||||
|
|
||||||
if (obj.type === 'kill_count') {
|
// GLOBAL QUEST RENDERING
|
||||||
label = `Kill ${obj.target}`;
|
if (quest.type === 'global') {
|
||||||
} else if (obj.type === 'item_delivery') {
|
return quest.objectives.map((obj, idx) => {
|
||||||
label = `Deliver ${obj.target}`;
|
const required = obj.count;
|
||||||
|
// Personal Progress
|
||||||
|
const personalCurrent = quest.progress?.[obj.target] || 0;
|
||||||
|
|
||||||
|
// Global Progress
|
||||||
|
// @ts-ignore - dynamic field
|
||||||
|
const globalProgressMap = quest.global_progress || {};
|
||||||
|
const globalCurrent = globalProgressMap[obj.target] || 0;
|
||||||
|
const isGlobalComplete = (quest as any).global_is_completed || globalCurrent >= required;
|
||||||
|
|
||||||
|
let label = obj.target;
|
||||||
|
if (obj.target_name) {
|
||||||
|
const targetName = getLocalizedText(obj.target_name);
|
||||||
|
label = targetName; // usually simplified for global counters? e.g. "Wood"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={idx} className="objective-item global-objective">
|
||||||
|
<div className="objective-label">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
{isGlobalComplete ? <span className="completed-badge">✅ {t('common.completed')}</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Progress Bar */}
|
||||||
|
<div className="global-progress-container">
|
||||||
|
<div className="progress-label">
|
||||||
|
<span>{t('journal.communityProgress')}</span>
|
||||||
|
<span>{globalCurrent} / {required}</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar-bg">
|
||||||
|
<div
|
||||||
|
className="progress-bar-fill global"
|
||||||
|
style={{ width: `${Math.min(100, (globalCurrent / required) * 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Personal Contribution */}
|
||||||
|
<div className="personal-contribution">
|
||||||
|
<span>{t('journal.yourContribution')}: </span>
|
||||||
|
<span className="contribution-value">{personalCurrent}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// STANDARD QUEST RENDERING
|
||||||
|
return quest.objectives.map((obj, idx) => {
|
||||||
|
const required = obj.count;
|
||||||
|
// Force completed count for history items to avoid confusing 0/X display
|
||||||
|
const isCompleted = quest.status === 'completed' || activeTab === 'completed';
|
||||||
|
const current = isCompleted ? required : (quest.progress?.[obj.target] || 0);
|
||||||
|
const met = current >= required || isCompleted;
|
||||||
|
|
||||||
|
let label = obj.target;
|
||||||
|
// Improved translation logic
|
||||||
|
if (obj.target_name) {
|
||||||
|
// If we have an enriched name, use it.
|
||||||
|
// But we still want the prefix "Fight" or "Pick Up" if applicable
|
||||||
|
const targetName = getLocalizedText(obj.target_name);
|
||||||
|
if (obj.type === 'kill_count') label = `${t('game.kill')} ${targetName}`;
|
||||||
|
else if (obj.type === 'item_delivery') label = `${t('game.pickUp')} ${targetName}`;
|
||||||
|
else label = targetName;
|
||||||
|
} else {
|
||||||
|
// Fallback to basic translation if no enriched name
|
||||||
|
if (obj.type === 'kill_count') label = `${t('game.kill')} ${obj.target}`;
|
||||||
|
else if (obj.type === 'item_delivery') label = `${t('game.pickUp')} ${obj.target}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -62,59 +209,196 @@ export const QuestJournal: React.FC<QuestJournalProps> = ({ onClose }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderDate = (timestamp?: number) => {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
return new Date(timestamp * 1000).toLocaleString(locale === 'en' ? 'en-US' : locale);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameModal
|
<GameModal
|
||||||
title="Quest Journal"
|
title={t('journal.title')}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="quest-journal-modal"
|
className="quest-journal-modal"
|
||||||
footer={
|
>
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
<div className="game-modal-content" style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}>
|
||||||
|
{/* Header / Tabs */}
|
||||||
|
<div style={{ padding: '10px 20px', background: 'rgba(0,0,0,0.5)', borderBottom: '1px solid #333' }}>
|
||||||
<div className="tab-container">
|
<div className="tab-container">
|
||||||
<button
|
<button
|
||||||
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}
|
className={`journal-tab ${activeTab === 'active' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('active')}
|
onClick={() => setActiveTab('active')}
|
||||||
>
|
>
|
||||||
Active
|
{t('journal.activeQuests')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`journal-tab ${activeTab === 'completed' ? 'active' : ''}`}
|
className={`journal-tab ${activeTab === 'completed' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('completed')}
|
onClick={() => setActiveTab('completed')}
|
||||||
>
|
>
|
||||||
Completed
|
{t('journal.history')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="journal-content">
|
|
||||||
<div className="quest-list">
|
|
||||||
{filteredQuests.length === 0 ? (
|
|
||||||
<div className="empty-message">No quests found in this category.</div>
|
|
||||||
) : (
|
|
||||||
filteredQuests.map((quest: Quest) => (
|
|
||||||
<div key={quest.quest_id} className={`quest-card ${quest.status === 'completed' ? 'completed' : ''}`}>
|
|
||||||
<h3>
|
|
||||||
{getLocalizedText(quest.title)}
|
|
||||||
{quest.type === 'global' && <span style={{ fontSize: '0.8rem', color: '#64b5f6', marginLeft: '10px' }}>GLOBAL</span>}
|
|
||||||
</h3>
|
|
||||||
<div className="quest-desc">{getLocalizedText(quest.description)}</div>
|
|
||||||
|
|
||||||
{quest.status === 'active' && (
|
{/* Main Content Area */}
|
||||||
<ul className="objective-list">
|
<div className="journal-layout">
|
||||||
{renderObjectives(quest)}
|
{/* LEFT COLUMN: LIST */}
|
||||||
</ul>
|
<div className="quest-list-column">
|
||||||
)}
|
<div className="game-search-container">
|
||||||
|
<span className="game-search-icon">🔍</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="game-search-input"
|
||||||
|
placeholder={t('journal.searchPlaceholder')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{quest.status === 'completed' && quest.completion_text && (
|
<div className="quest-list-scroll">
|
||||||
<div className="completion-text">
|
{filteredQuests.length === 0 ? (
|
||||||
"{getLocalizedText(quest.completion_text)}"
|
<div style={{ padding: '20px', color: '#777', textAlign: 'center' }}>
|
||||||
</div>
|
{loadingHistory ? t('common.loading') : t('journal.noQuests')}
|
||||||
)}
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredQuests.map(quest => {
|
||||||
|
const key = getQuestKey(quest);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`quest-list-item ${selectedQuestKey === key ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSelectedQuestKey(key)}
|
||||||
|
>
|
||||||
|
<h4>{getLocalizedText(quest.title)}</h4>
|
||||||
|
<span className="quest-card-type">
|
||||||
|
{quest.type === 'global' ? t('journal.global') : (quest.type === 'story' ? t('journal.story') : t('journal.side'))}
|
||||||
|
</span>
|
||||||
|
<div className={`quest-status-indicator status-${quest.status === 'active' ? 'active' : 'completed'}`}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls for History */}
|
||||||
|
{activeTab === 'completed' && (
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<GameButton
|
||||||
|
className="pagination-btn"
|
||||||
|
size="sm"
|
||||||
|
disabled={historyPage <= 1 || loadingHistory}
|
||||||
|
onClick={() => setHistoryPage(p => p - 1)}
|
||||||
|
>
|
||||||
|
« {t('common.prev')}
|
||||||
|
</GameButton>
|
||||||
|
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>
|
||||||
|
{loadingHistory ? '...' : `${historyPage} / ${historyTotalPages}`}
|
||||||
|
</span>
|
||||||
|
<GameButton
|
||||||
|
className="pagination-btn"
|
||||||
|
size="sm"
|
||||||
|
disabled={historyPage >= historyTotalPages || loadingHistory}
|
||||||
|
onClick={() => setHistoryPage(p => p + 1)}
|
||||||
|
>
|
||||||
|
{t('common.next')} »
|
||||||
|
</GameButton>
|
||||||
</div>
|
</div>
|
||||||
))
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN: DETAILS */}
|
||||||
|
<div className="quest-details-column">
|
||||||
|
{selectedQuest ? (
|
||||||
|
<>
|
||||||
|
<div className="quest-details-header">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<h2>{getLocalizedText(selectedQuest.title)}</h2>
|
||||||
|
{activeTab === 'active' && selectedQuest.status === 'completed' && (
|
||||||
|
<span style={{ color: '#ffd700', fontWeight: 'bold' }}>{t('journal.ready')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtitle removed to avoid redundancy as requested */}
|
||||||
|
|
||||||
|
{/* Giver Info */}
|
||||||
|
{selectedQuest.giver_name && (
|
||||||
|
<div className="quest-giver-info">
|
||||||
|
{selectedQuest.giver_image && (
|
||||||
|
<img
|
||||||
|
src={`/${selectedQuest.giver_image}`}
|
||||||
|
className="quest-giver-image"
|
||||||
|
alt="Giver"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="quest-giver-details">
|
||||||
|
<div>
|
||||||
|
<span className="label">{t('journal.giver')}:</span>
|
||||||
|
<span className="value">{getLocalizedText(selectedQuest.giver_name)}</span>
|
||||||
|
</div>
|
||||||
|
{(selectedQuest.giver_location_name || selectedQuest.giver_location_id) && (
|
||||||
|
<div>
|
||||||
|
<span className="label">{t('journal.location')}:</span>
|
||||||
|
<span className="value">
|
||||||
|
{selectedQuest.giver_location_name
|
||||||
|
? getLocalizedText(selectedQuest.giver_location_name)
|
||||||
|
: selectedQuest.giver_location_id
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quest-description">
|
||||||
|
{getLocalizedText(selectedQuest.description)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Objectives - Show for both active and completed */}
|
||||||
|
<div className="quest-section-title">{t('journal.objectives')}</div>
|
||||||
|
<ul className="objective-list">
|
||||||
|
{renderObjectives(selectedQuest)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{selectedQuest.status === 'completed' && selectedQuest.completion_text && (
|
||||||
|
<>
|
||||||
|
<div className="quest-section-title">{t('journal.completionMessage')}</div>
|
||||||
|
<div className="completion-text" style={{ fontStyle: 'italic', color: '#aaa', padding: '10px', background: 'rgba(0,0,0,0.2)' }}>
|
||||||
|
"{getLocalizedText(selectedQuest.completion_text)}"
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedQuest.rewards && (
|
||||||
|
<>
|
||||||
|
<div className="quest-section-title">{t('journal.rewards')}</div>
|
||||||
|
<ul className="rewards-list">
|
||||||
|
{selectedQuest.rewards.xp && <li>{selectedQuest.rewards.xp} {t('stats.xp')}</li>}
|
||||||
|
{(selectedQuest as any).reward_items_details ?
|
||||||
|
Object.values((selectedQuest as any).reward_items_details).map((item: any, idx) => (
|
||||||
|
<li key={idx}>{getLocalizedText(item.name)} x{item.qty}</li>
|
||||||
|
))
|
||||||
|
:
|
||||||
|
selectedQuest.rewards.items && Object.entries(selectedQuest.rewards.items).map(([id, qty]) => (
|
||||||
|
<li key={id}>{id} x{qty as any}</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="history-dates">
|
||||||
|
{/* Show accepted date for both active and completed quests if available */}
|
||||||
|
{selectedQuest.started_at && <div>{t('journal.accepted')}: {renderDate(selectedQuest.started_at)}</div>}
|
||||||
|
{activeTab === 'completed' && selectedQuest.completed_at && <div>{t('journal.completed')}: {renderDate(selectedQuest.completed_at)}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="empty-selection">{t('journal.selectQuest')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GameModal>
|
</GameModal >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
.game-modal-container.trade-modal {
|
.game-modal-container.trade-modal {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
height: 90vh;
|
max-height: 90%;
|
||||||
|
height: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trade-modal .game-modal-content {
|
.trade-modal .game-modal-content {
|
||||||
@@ -50,30 +51,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
border: 1px solid #555;
|
|
||||||
color: white;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.8rem 1rem 0.8rem 2.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid var(--game-border-color);
|
||||||
|
color: var(--game-text-primary);
|
||||||
|
font-family: var(--game-font-main);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
clip-path: var(--game-clip-path-sm);
|
||||||
|
transition: all 0.2s ease;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
/* Fixes cut-off issue */
|
|
||||||
clip-path: var(--game-clip-path-sm, polygon(0 0,
|
|
||||||
100% 0,
|
|
||||||
100% calc(100% - 5px),
|
|
||||||
calc(100% - 5px) 100%,
|
|
||||||
0 100%));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-grid {
|
.search-bar:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6bb9f0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
box-shadow: 0 0 10px rgba(107, 185, 240, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-inventory-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
|
||||||
grid-auto-rows: max-content;
|
grid-auto-rows: max-content;
|
||||||
/* Ensure rows don't stretch */
|
/* Ensure rows don't stretch */
|
||||||
|
grid-template-columns: repeat(auto-fill, 90px);
|
||||||
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
overflow-y: auto;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -91,6 +95,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
box-shadow: var(--game-shadow-sm);
|
box-shadow: var(--game-shadow-sm);
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trade-item-card:hover {
|
.trade-item-card:hover {
|
||||||
@@ -139,30 +147,10 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Exact match for quantity badge from InventoryModal.css */
|
|
||||||
.trade-item-qty {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2px;
|
|
||||||
right: 2px;
|
|
||||||
background: var(--game-bg-panel);
|
|
||||||
/* Match source */
|
|
||||||
border: 1px solid var(--game-border-color);
|
|
||||||
/* Match source */
|
|
||||||
color: var(--game-text-primary);
|
|
||||||
/* Match source */
|
|
||||||
font-size: 0.7rem;
|
|
||||||
/* Match source grid adjustment */
|
|
||||||
padding: 1px 4px;
|
|
||||||
/* Match source grid adjustment */
|
|
||||||
clip-path: var(--game-clip-path-sm);
|
|
||||||
/* Match source */
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: var(--game-shadow-sm);
|
|
||||||
/* Match source */
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trade-item-value {
|
.trade-item-value {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -210,9 +198,10 @@
|
|||||||
|
|
||||||
.cart-grid {
|
.cart-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
grid-template-columns: repeat(auto-fill, 90px);
|
||||||
|
justify-content: center;
|
||||||
grid-auto-rows: max-content;
|
grid-auto-rows: max-content;
|
||||||
gap: 8px;
|
gap: 0.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -313,4 +302,5 @@
|
|||||||
background: #1a202c;
|
background: #1a202c;
|
||||||
border: 1px solid #4a5568;
|
border: 1px solid #4a5568;
|
||||||
color: white;
|
color: white;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@ import { useGame } from '../../contexts/GameContext';
|
|||||||
import { GAME_API_URL } from '../../config';
|
import { GAME_API_URL } from '../../config';
|
||||||
import { GameModal } from './GameModal';
|
import { GameModal } from './GameModal';
|
||||||
import { GameButton } from '../common/GameButton';
|
import { GameButton } from '../common/GameButton';
|
||||||
import { GameTooltip } from '../common/GameTooltip';
|
import { GameItemCard } from '../common/GameItemCard';
|
||||||
import { getAssetPath } from '../../utils/assetPath';
|
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils';
|
import { getTranslatedText } from '../../utils/i18nUtils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import './TradeModal.css';
|
import './TradeModal.css';
|
||||||
|
|
||||||
interface TradeItem {
|
interface TradeItem {
|
||||||
@@ -51,6 +51,7 @@ interface TradeModalProps {
|
|||||||
|
|
||||||
export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
||||||
const { token, inventory: playerInv } = useGame();
|
const { token, inventory: playerInv } = useGame();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [npcStock, setNpcStock] = useState<TradeItem[]>([]);
|
const [npcStock, setNpcStock] = useState<TradeItem[]>([]);
|
||||||
const [playerItems, setPlayerItems] = useState<TradeItem[]>([]);
|
const [playerItems, setPlayerItems] = useState<TradeItem[]>([]);
|
||||||
@@ -64,6 +65,19 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
|||||||
const [npcSearch, setNpcSearch] = useState('');
|
const [npcSearch, setNpcSearch] = useState('');
|
||||||
const [playerSearch, setPlayerSearch] = useState('');
|
const [playerSearch, setPlayerSearch] = useState('');
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ id: 'all', label: t('categories.all'), icon: '🎒' },
|
||||||
|
{ id: 'weapon', label: t('categories.weapon'), icon: '⚔️' },
|
||||||
|
{ id: 'armor', label: t('categories.armor'), icon: '🛡️' },
|
||||||
|
{ id: 'clothing', label: t('categories.clothing'), icon: '👕' },
|
||||||
|
{ id: 'backpack', label: t('categories.backpack'), icon: '🎒' },
|
||||||
|
{ id: 'tool', label: t('categories.tool'), icon: '🛠️' },
|
||||||
|
{ id: 'consumable', label: t('categories.consumable'), icon: '🍖' },
|
||||||
|
{ id: 'resource', label: t('categories.resource'), icon: '📦' },
|
||||||
|
{ id: 'quest', label: t('categories.quest'), icon: '📜' },
|
||||||
|
{ id: 'misc', label: t('categories.misc'), icon: '📦' }
|
||||||
|
];
|
||||||
|
|
||||||
// Selection logic
|
// Selection logic
|
||||||
const [selectedItem, setSelectedItem] = useState<TradeItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<TradeItem | null>(null);
|
||||||
const [showQtyModal, setShowQtyModal] = useState(false);
|
const [showQtyModal, setShowQtyModal] = useState(false);
|
||||||
@@ -138,6 +152,10 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
|||||||
// Filter by search
|
// Filter by search
|
||||||
const n = getTranslatedText(item.name).toLowerCase();
|
const n = getTranslatedText(item.name).toLowerCase();
|
||||||
return n.includes(npcSearch.toLowerCase());
|
return n.includes(npcSearch.toLowerCase());
|
||||||
|
}).sort((a: any, b: any) => {
|
||||||
|
// High tier first, then name
|
||||||
|
if ((a.tier || 0) !== (b.tier || 0)) return (b.tier || 0) - (a.tier || 0);
|
||||||
|
return (getTranslatedText(a.name) || '').localeCompare(getTranslatedText(b.name) || '');
|
||||||
});
|
});
|
||||||
}, [npcStock, npcSearch, buying]);
|
}, [npcStock, npcSearch, buying]);
|
||||||
|
|
||||||
@@ -156,6 +174,10 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
|||||||
if (item._displayQuantity <= 0) return false;
|
if (item._displayQuantity <= 0) return false;
|
||||||
if (item.is_equipped) return false; // Usually can't sell equipped items directly
|
if (item.is_equipped) return false; // Usually can't sell equipped items directly
|
||||||
return true;
|
return true;
|
||||||
|
}).sort((a: any, b: any) => {
|
||||||
|
// High tier first, then name
|
||||||
|
if ((a.tier || 0) !== (b.tier || 0)) return (b.tier || 0) - (a.tier || 0);
|
||||||
|
return (getTranslatedText(a.name) || '').localeCompare(getTranslatedText(b.name) || '');
|
||||||
});
|
});
|
||||||
}, [playerItems, playerSearch, selling]);
|
}, [playerItems, playerSearch, selling]);
|
||||||
|
|
||||||
@@ -253,14 +275,46 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tooltip Renderer (Reusable) - REMOVED as we use inline now to match InventoryModal structure better
|
// Drag and Drop Logic
|
||||||
|
const [dragOverZone, setDragOverZone] = useState<'buy' | 'sell' | null>(null);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent, item: TradeItem, source: 'npc' | 'player') => {
|
||||||
|
e.dataTransfer.setData('application/json', JSON.stringify({ item, source }));
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, zone: 'buy' | 'sell') => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragOverZone !== zone) setDragOverZone(zone);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragOverZone(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, zone: 'buy' | 'sell') => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverZone(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
|
const { item, source } = data;
|
||||||
|
|
||||||
|
if (zone === 'buy' && source === 'npc') {
|
||||||
|
handleItemClick(item, 'npc');
|
||||||
|
} else if (zone === 'sell' && source === 'player') {
|
||||||
|
handleItemClick(item, 'player');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse drag data', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!npcStock || !tradeConfig) return <div className="loading-text">Loading trade data...</div>;
|
if (!npcStock || !tradeConfig) return <div className="loading-text">Loading trade data...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameModal
|
<GameModal
|
||||||
title="Trading"
|
title={t('trade.title')}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="trade-modal"
|
className="trade-modal"
|
||||||
>
|
>
|
||||||
@@ -268,85 +322,40 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
|||||||
<div className="trade-content">
|
<div className="trade-content">
|
||||||
{/* LEFT: NPC STOCK */}
|
{/* LEFT: NPC STOCK */}
|
||||||
<div className="trade-column">
|
<div className="trade-column">
|
||||||
<h3 className="column-header">Merchant Stock {tradeConfig.buy_markup && <small>(x{tradeConfig.buy_markup})</small>}</h3>
|
<h3 className="column-header">{t('trade.merchantStock')} {tradeConfig.buy_markup && <small>(x{tradeConfig.buy_markup})</small>}</h3>
|
||||||
<input
|
<div className="game-search-container" style={{ marginBottom: '0.5rem' }}>
|
||||||
type="text"
|
<span className="game-search-icon">🔍</span>
|
||||||
className="search-bar"
|
<input
|
||||||
placeholder="Filter..."
|
type="text"
|
||||||
value={npcSearch}
|
className="search-bar"
|
||||||
onChange={(e) => setNpcSearch(e.target.value)}
|
placeholder="Filter..."
|
||||||
/>
|
value={npcSearch}
|
||||||
<div className="inventory-grid">
|
onChange={(e) => setNpcSearch(e.target.value)}
|
||||||
{availableNpcStock.map((item, idx) => {
|
/>
|
||||||
// Prepare tooltip content matching InventoryModal
|
</div>
|
||||||
const tooltipContent = (
|
<div className="trade-inventory-grid">
|
||||||
<div className="item-tooltip-content">
|
{categories.filter(cat => cat.id !== 'all').map(cat => {
|
||||||
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
|
const categoryItems = availableNpcStock.filter((item: any) => item.item_type === cat.id);
|
||||||
{item.emoji} {getTranslatedText(item.name)}
|
if (categoryItems.length === 0) return null;
|
||||||
</div>
|
|
||||||
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
|
|
||||||
|
|
||||||
<div className="tooltip-stats">
|
|
||||||
<div style={{ color: '#ffd700' }}>💰 {Math.round(item.value * (tradeConfig.buy_markup || 1))}</div>
|
|
||||||
{item.weight !== undefined && <div>⚖️ {item.weight}kg</div>}
|
|
||||||
{item.volume !== undefined && <div>📦 {item.volume}L</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-badges-container">
|
|
||||||
{/* Capacity */}
|
|
||||||
{(item.unique_stats?.weight_capacity || item.stats?.weight_capacity) && (
|
|
||||||
<span className="stat-badge capacity">
|
|
||||||
⚖️ +{item.unique_stats?.weight_capacity || item.stats?.weight_capacity}kg
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(item.unique_stats?.volume_capacity || item.stats?.volume_capacity) && (
|
|
||||||
<span className="stat-badge capacity">
|
|
||||||
📦 +{item.unique_stats?.volume_capacity || item.stats?.volume_capacity}L
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Combat Stats */}
|
|
||||||
{(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>
|
|
||||||
)}
|
|
||||||
{/* Consumables */}
|
|
||||||
{item.hp_restore && (
|
|
||||||
<span className="stat-badge health">
|
|
||||||
❤️ +{item.hp_restore} HP
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.stamina_restore && (
|
|
||||||
<span className="stat-badge stamina">
|
|
||||||
⚡ +{item.stamina_restore} Stm
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameTooltip key={idx} content={tooltipContent}>
|
<React.Fragment key={cat.id}>
|
||||||
<div className={`trade-item-card text-tier-${item.tier || 0}`} onClick={() => handleItemClick(item, 'npc')}>
|
<div className="category-header" style={{ gridColumn: '1 / -1', marginTop: '10px' }}>
|
||||||
<div className="trade-item-image">
|
<span className="subcat-icon">{cat.icon}</span>
|
||||||
{item.image_path ? (
|
<span className="subcat-label">{cat.label}</span>
|
||||||
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="trade-item-img" />
|
|
||||||
) : (
|
|
||||||
<div className={`item-icon-large tier-${item.tier || 0}`} style={{ fontSize: '2.5rem' }}>{item.emoji || '📦'}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(item.is_infinite || (item as any)._displayQuantity > 1) && (
|
|
||||||
<div className="trade-item-qty">{item.is_infinite ? '∞' : `x${(item as any)._displayQuantity}`}</div>
|
|
||||||
)}
|
|
||||||
<div className="trade-item-value">{Math.round(item.value * (tradeConfig.buy_markup || 1))}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</GameTooltip>
|
{categoryItems.map((item, idx) => (
|
||||||
|
<GameItemCard
|
||||||
|
key={idx}
|
||||||
|
item={item}
|
||||||
|
onClick={() => handleItemClick(item, 'npc')}
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={(e) => handleDragStart(e, item, 'npc')}
|
||||||
|
showValue={true}
|
||||||
|
valueDisplayType="unit"
|
||||||
|
tradeMarkup={tradeConfig.buy_markup || 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -354,52 +363,68 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
|||||||
|
|
||||||
{/* CENTER: CART */}
|
{/* CENTER: CART */}
|
||||||
<div className="trade-center-column">
|
<div className="trade-center-column">
|
||||||
<div className="trade-cart-section">
|
<div
|
||||||
|
className={`trade-cart-section ${dragOverZone === 'buy' ? 'drag-over' : ''}`}
|
||||||
|
onDragOver={(e) => handleDragOver(e, 'buy')}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, 'buy')}
|
||||||
|
>
|
||||||
<div className="trade-list-header">
|
<div className="trade-list-header">
|
||||||
<span>Buying</span>
|
<span>{t('trade.buying')}</span>
|
||||||
<span style={{ color: '#ff9800' }}>{Math.round(buyTotal)}</span>
|
<span style={{ color: '#ff9800' }}>{Math.round(buyTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cart-grid">
|
<div className="cart-grid">
|
||||||
{buying.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>Empty</div>}
|
{buying.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>{t('trade.empty')}</div>}
|
||||||
{buying.map((b, i) => (
|
{buying.map((b, i) => (
|
||||||
<GameTooltip key={i} content={<div>{getTranslatedText(b.name)}<br />x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}</div>}>
|
<GameItemCard
|
||||||
<div className={`trade-item-card text-tier-${b.tier || 0}`} onClick={() => {
|
key={i}
|
||||||
|
item={b}
|
||||||
|
onClick={() => {
|
||||||
const n = [...buying]; n.splice(i, 1); setBuying(n);
|
const n = [...buying]; n.splice(i, 1); setBuying(n);
|
||||||
}}>
|
}}
|
||||||
{b.image_path ? (
|
showValue={true}
|
||||||
<img src={getAssetPath(b.image_path)} alt={getTranslatedText(b.name)} className="trade-item-img" />
|
valueDisplayType="total"
|
||||||
) : (
|
tradeMarkup={tradeConfig.buy_markup || 1}
|
||||||
<div style={{ fontSize: '24px' }}>{b.emoji || '📦'}</div>
|
actionHint={t('trade.clickToRemove')}
|
||||||
)}
|
/>
|
||||||
<div className="trade-item-qty">x{b.quantity}</div>
|
|
||||||
<div className="trade-item-value">{Math.round(b.value * (tradeConfig.buy_markup || 1) * b.quantity)}</div>
|
|
||||||
</div>
|
|
||||||
</GameTooltip>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="trade-cart-section">
|
{/* BALANCE INDICATOR MOVED TO CENTER DIVIDER */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '10px 0', borderTop: '1px solid #444', borderBottom: '1px solid #444', background: 'rgba(0,0,0,0.3)' }}>
|
||||||
|
<div className="trade-summary" style={{ flexDirection: 'row', gap: '15px' }}>
|
||||||
|
<span>{t('trade.balance')}:</span>
|
||||||
|
<span className={`trade-total ${sellTotal >= buyTotal ? 'text-green' : 'text-red'}`}>
|
||||||
|
{Math.round(sellTotal - buyTotal)} {sellTotal >= buyTotal ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`trade-cart-section ${dragOverZone === 'sell' ? 'drag-over' : ''}`}
|
||||||
|
onDragOver={(e) => handleDragOver(e, 'sell')}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, 'sell')}
|
||||||
|
>
|
||||||
<div className="trade-list-header">
|
<div className="trade-list-header">
|
||||||
<span>Selling</span>
|
<span>{t('trade.selling')}</span>
|
||||||
<span style={{ color: '#4caf50' }}>{Math.round(sellTotal)}</span>
|
<span style={{ color: '#4caf50' }}>{Math.round(sellTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cart-grid">
|
<div className="cart-grid">
|
||||||
{selling.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>Empty</div>}
|
{selling.length === 0 && <div style={{ color: '#666', gridColumn: '1 / -1', textAlign: 'center', padding: '10px' }}>{t('trade.empty')}</div>}
|
||||||
{selling.map((b, i) => (
|
{selling.map((b, i) => (
|
||||||
<GameTooltip key={i} content={<div>{getTranslatedText(b.name)}<br />x{b.quantity} - Total: {Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}</div>}>
|
<GameItemCard
|
||||||
<div className={`trade-item-card text-tier-${b.tier || 0}`} onClick={() => {
|
key={i}
|
||||||
|
item={b}
|
||||||
|
onClick={() => {
|
||||||
const n = [...selling]; n.splice(i, 1); setSelling(n);
|
const n = [...selling]; n.splice(i, 1); setSelling(n);
|
||||||
}}>
|
}}
|
||||||
{b.image_path ? (
|
showValue={true}
|
||||||
<img src={getAssetPath(b.image_path)} alt={getTranslatedText(b.name)} className="trade-item-img" />
|
valueDisplayType="total"
|
||||||
) : (
|
tradeMarkup={tradeConfig.sell_markdown || 1}
|
||||||
<div style={{ fontSize: '24px' }}>{b.emoji || '📦'}</div>
|
actionHint={t('trade.clickToRemove')}
|
||||||
)}
|
/>
|
||||||
<div className="trade-item-qty">x{b.quantity}</div>
|
|
||||||
<div className="trade-item-value">{Math.round(b.value * (tradeConfig.sell_markdown || 1) * b.quantity)}</div>
|
|
||||||
</div>
|
|
||||||
</GameTooltip>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,51 +432,40 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
|||||||
|
|
||||||
{/* RIGHT: PLAYER INVENTORY */}
|
{/* RIGHT: PLAYER INVENTORY */}
|
||||||
<div className="trade-column">
|
<div className="trade-column">
|
||||||
<h3 className="column-header">Inventory {tradeConfig.sell_markdown && <small>(x{tradeConfig.sell_markdown})</small>}</h3>
|
<h3 className="column-header">{t('trade.inventory')} {tradeConfig.sell_markdown && <small>(x{tradeConfig.sell_markdown})</small>}</h3>
|
||||||
<input
|
<div className="game-search-container" style={{ marginBottom: '0.5rem' }}>
|
||||||
type="text"
|
<span className="game-search-icon">🔍</span>
|
||||||
className="search-bar"
|
<input
|
||||||
placeholder="Filter..."
|
type="text"
|
||||||
value={playerSearch}
|
className="search-bar"
|
||||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
placeholder="Filter..."
|
||||||
/>
|
value={playerSearch}
|
||||||
<div className="inventory-grid">
|
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||||
{availablePlayerInv.map((item, idx) => {
|
/>
|
||||||
const tooltipContent = (
|
</div>
|
||||||
<div className="item-tooltip-content">
|
<div className="trade-inventory-grid">
|
||||||
<div className={`tooltip-header text-tier-${item.tier || 0}`}>
|
{categories.filter(cat => cat.id !== 'all').map(cat => {
|
||||||
{item.emoji} {getTranslatedText(item.name)}
|
const categoryItems = availablePlayerInv.filter((item: any) => item.item_type === cat.id);
|
||||||
</div>
|
if (categoryItems.length === 0) return null;
|
||||||
{item.description && <div className="tooltip-desc">{getTranslatedText(item.description)}</div>}
|
|
||||||
|
|
||||||
<div className="tooltip-stats">
|
|
||||||
<div style={{ color: '#4caf50' }}>💰 {Math.round(item.value * (tradeConfig.sell_markdown || 1))}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-badges-container">
|
|
||||||
{/* Same badges logic could be extracted but duplicating for speed/safety */}
|
|
||||||
{(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.hp_restore && <span className="stat-badge health">❤️ +{item.hp_restore} HP</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<GameTooltip key={idx} content={tooltipContent}>
|
<React.Fragment key={cat.id}>
|
||||||
<div className={`trade-item-card text-tier-${item.tier || 0}`} onClick={() => handleItemClick(item, 'player')}>
|
<div className="category-header" style={{ gridColumn: '1 / -1', marginTop: '10px' }}>
|
||||||
<div className="trade-item-image">
|
<span className="subcat-icon">{cat.icon}</span>
|
||||||
{item.image_path ? (
|
<span className="subcat-label">{cat.label}</span>
|
||||||
<img src={getAssetPath(item.image_path)} alt={getTranslatedText(item.name)} className="trade-item-img" />
|
|
||||||
) : (
|
|
||||||
<div className={`item-icon-large tier-${item.tier || 0}`} style={{ fontSize: '2.5rem' }}>{item.emoji || '📦'}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(item as any)._displayQuantity > 1 && <div className="trade-item-qty">x{(item as any)._displayQuantity}</div>}
|
|
||||||
<div className="trade-item-value">{Math.round(item.value * (tradeConfig.sell_markdown || 1))}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</GameTooltip>
|
{categoryItems.map((item, idx) => (
|
||||||
|
<GameItemCard
|
||||||
|
key={idx}
|
||||||
|
item={item}
|
||||||
|
onClick={() => handleItemClick(item, 'player')}
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={(e) => handleDragStart(e, item, 'player')}
|
||||||
|
showValue={true}
|
||||||
|
valueDisplayType="unit"
|
||||||
|
tradeMarkup={tradeConfig.sell_markdown || 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -459,46 +473,49 @@ export const TradeModal: React.FC<TradeModalProps> = ({ npcId, onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="trade-footer">
|
<div className="trade-footer">
|
||||||
<div className="trade-summary">
|
|
||||||
<span>Balance</span>
|
|
||||||
<span className={`trade-total ${sellTotal >= buyTotal ? 'text-green' : 'text-red'}`}>
|
|
||||||
{Math.round(sellTotal - buyTotal)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="trade-action-btn" onClick={executeTrade} disabled={!isValid}>
|
<button className="trade-action-btn" onClick={executeTrade} disabled={!isValid}>
|
||||||
{isValid ? "CONFIRM TRADE" : "INVALID OFFER"}
|
{isValid ? t('trade.confirmTrade') : t('trade.invalidOffer')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ width: '60px' }}></div> {/* Spacer */}
|
<div style={{ width: '60px' }}></div> {/* Spacer */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showQtyModal && selectedItem && (
|
{showQtyModal && selectedItem && (() => {
|
||||||
<div className="dialog-modal-overlay" style={{ zIndex: 1101 }}>
|
const maxAvailable = (selectedItem as any)._displayQuantity || 1;
|
||||||
<div className="quantity-modal">
|
return (
|
||||||
<h4>How many {getTranslatedText(selectedItem.name)}?</h4>
|
<div className="dialog-modal-overlay" style={{ zIndex: 1101 }}>
|
||||||
<div className="qty-controls">
|
<div className="quantity-modal">
|
||||||
<GameButton size="sm" onClick={() => setQtyInput(Math.max(1, qtyInput - 1))}>-</GameButton>
|
<h4>{t('trade.howMany', { item: getTranslatedText(selectedItem.name) })}</h4>
|
||||||
<input
|
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '10px' }}>
|
||||||
className="qty-input"
|
<GameItemCard item={selectedItem} showTooltip={false} showQuantity={false} />
|
||||||
type="number"
|
</div>
|
||||||
value={qtyInput}
|
<div className="qty-controls">
|
||||||
onChange={e => setQtyInput(parseInt(e.target.value) || 1)}
|
<GameButton size="sm" onClick={() => setQtyInput(Math.max(1, qtyInput - 1))} disabled={qtyInput <= 1}>-</GameButton>
|
||||||
min="1"
|
<input
|
||||||
/>
|
className="qty-input"
|
||||||
<GameButton size="sm" onClick={() => setQtyInput(qtyInput + 1)}>+</GameButton>
|
type="number"
|
||||||
<GameButton size="sm" onClick={() => {
|
value={qtyInput}
|
||||||
const max = (selectedItem as any)._displayQuantity || 1;
|
onChange={e => {
|
||||||
setQtyInput(max);
|
const val = parseInt(e.target.value);
|
||||||
}}>Max</GameButton>
|
if (isNaN(val)) {
|
||||||
</div>
|
setQtyInput(1);
|
||||||
<div style={{ display: 'flex', gap: '10px' }}>
|
} else {
|
||||||
<GameButton variant="primary" onClick={confirmSelection}>Confirm</GameButton>
|
setQtyInput(Math.min(Math.max(1, val), maxAvailable));
|
||||||
<GameButton variant="secondary" onClick={() => setShowQtyModal(false)}>Cancel</GameButton>
|
}
|
||||||
|
}}
|
||||||
|
min="1"
|
||||||
|
max={maxAvailable}
|
||||||
|
/>
|
||||||
|
<GameButton size="sm" onClick={() => setQtyInput(Math.min(maxAvailable, qtyInput + 1))} disabled={qtyInput >= maxAvailable}>+</GameButton>
|
||||||
|
<GameButton size="sm" onClick={() => setQtyInput(maxAvailable)}>Max</GameButton>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<GameButton variant="primary" onClick={confirmSelection}>Confirm</GameButton>
|
||||||
|
<GameButton variant="secondary" onClick={() => setShowQtyModal(false)}>Cancel</GameButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</GameModal>
|
</GameModal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,19 +13,39 @@
|
|||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-menu {
|
/* Specific Override for GameModal container when used as Workbench */
|
||||||
|
.game-modal-container.workbench-modal {
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
height: 85vh;
|
height: 90%;
|
||||||
|
max-height: 90%;
|
||||||
background: var(--game-bg-modal);
|
background: var(--game-bg-modal);
|
||||||
border: 1px solid var(--game-border-color);
|
border: 1px solid var(--game-border-color);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: var(--game-shadow-modal);
|
box-shadow: var(--game-shadow-modal);
|
||||||
overflow: hidden;
|
|
||||||
color: var(--game-text-primary);
|
color: var(--game-text-primary);
|
||||||
font-family: var(--game-font-main);
|
font-family: var(--game-font-main);
|
||||||
clip-path: var(--game-clip-path);
|
}
|
||||||
|
|
||||||
|
.game-modal-container.workbench-modal .game-modal-content {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-menu {
|
||||||
|
/* Legacy class support or internal structure if needed, but GameModal is the container */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-menu-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-header {
|
.workbench-header {
|
||||||
@@ -46,6 +66,15 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-header-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--game-bg-panel);
|
||||||
|
border-bottom: 1px solid var(--game-border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-tabs {
|
.workbench-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, type MouseEvent, type ChangeEvent } from 'react'
|
import { useState, useEffect, type ChangeEvent } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Profile, WorkbenchTab } from './types'
|
import type { Profile, WorkbenchTab } from './types'
|
||||||
import { getAssetPath } from '../../utils/assetPath'
|
import { getAssetPath } from '../../utils/assetPath'
|
||||||
import { getTranslatedText } from '../../utils/i18nUtils'
|
import { getTranslatedText } from '../../utils/i18nUtils'
|
||||||
|
import { GameModal } from './GameModal'
|
||||||
import { GameButton } from '../common/GameButton'
|
import { GameButton } from '../common/GameButton'
|
||||||
import './Workbench.css'
|
import './Workbench.css'
|
||||||
|
|
||||||
@@ -476,33 +477,31 @@ function Workbench({
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workbench-overlay" onClick={(e: MouseEvent<HTMLDivElement>) => {
|
<GameModal
|
||||||
if (e.target === e.currentTarget) onCloseCrafting()
|
title={t('game.workbench')}
|
||||||
}}>
|
onClose={onCloseCrafting}
|
||||||
<div className="workbench-menu">
|
className={`workbench-modal ${workbenchTab}`} // Add tab class for styling if needed
|
||||||
<div className="workbench-header">
|
>
|
||||||
<h3>{t('game.workbench')}</h3>
|
<div className="workbench-menu-content">
|
||||||
<div className="workbench-tabs">
|
<div className="workbench-header-tabs">
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
|
className={`tab-btn ${workbenchTab === 'craft' ? 'active' : ''}`}
|
||||||
onClick={() => onSwitchTab('craft')}
|
onClick={() => onSwitchTab('craft')}
|
||||||
>
|
>
|
||||||
{t('game.craft')}
|
{t('game.craft')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
|
className={`tab-btn ${workbenchTab === 'repair' ? 'active' : ''}`}
|
||||||
onClick={() => onSwitchTab('repair')}
|
onClick={() => onSwitchTab('repair')}
|
||||||
>
|
>
|
||||||
{t('game.repair')}
|
{t('game.repair')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
|
className={`tab-btn ${workbenchTab === 'uncraft' ? 'active' : ''}`}
|
||||||
onClick={() => onSwitchTab('uncraft')}
|
onClick={() => onSwitchTab('uncraft')}
|
||||||
>
|
>
|
||||||
{t('game.salvage')}
|
{t('game.salvage')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<button className="close-btn" onClick={onCloseCrafting}>✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="workbench-content-grid">
|
<div className="workbench-content-grid">
|
||||||
@@ -678,7 +677,7 @@ function Workbench({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GameModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export function useGameEngine(
|
|||||||
const addLocationMessage = useCallback((msg: string) => {
|
const addLocationMessage = useCallback((msg: string) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||||
const locationName = location?.name ? (typeof location.name === 'string' ? location.name : location.name.en || Object.values(location.name)[0]) : ''
|
const locationName = location?.name || ''
|
||||||
|
|
||||||
setLocationMessages((prev: LocationMessage[]) => {
|
setLocationMessages((prev: LocationMessage[]) => {
|
||||||
const newMessages = [...prev, { time: timeStr, message: msg, location_name: locationName }]
|
const newMessages = [...prev, { time: timeStr, message: msg, location_name: locationName }]
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export interface CombatLogEntry {
|
|||||||
export interface LocationMessage {
|
export interface LocationMessage {
|
||||||
time: string
|
time: string
|
||||||
message: string
|
message: string
|
||||||
location_name?: string
|
location_name?: string | { [key: string]: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Equipment {
|
export interface Equipment {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
"game": "Game",
|
"game": "Game",
|
||||||
"leaderboards": "Leaderboards",
|
"leaderboards": "Leaderboards",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
|
"accountSettings": "Account Settings",
|
||||||
|
"general": "General",
|
||||||
|
"audio": "Audio",
|
||||||
|
"security": "Security",
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
"talk": "Talk",
|
"talk": "Talk",
|
||||||
"loot": "Loot",
|
"loot": "Loot",
|
||||||
@@ -23,7 +27,10 @@
|
|||||||
"enemy": "Enemy",
|
"enemy": "Enemy",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
"quests": "Quests",
|
"quests": "Quests",
|
||||||
"all": "All"
|
"all": "All",
|
||||||
|
"prev": "Prev",
|
||||||
|
"next": "Next",
|
||||||
|
"back": "Back"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@@ -40,25 +47,88 @@
|
|||||||
"loginTitle": "Welcome Back",
|
"loginTitle": "Welcome Back",
|
||||||
"registerTitle": "Create Account",
|
"registerTitle": "Create Account",
|
||||||
"loginSubtitle": "Sign in to continue your journey",
|
"loginSubtitle": "Sign in to continue your journey",
|
||||||
"registerSubtitle": "Join the survivors"
|
"registerSubtitle": "Join the survivors",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"emailPlaceholder": "your.email@example.com",
|
||||||
|
"passwordPlaceholder": "At least 6 characters",
|
||||||
|
"passwordPlaceholderLogin": "Your password",
|
||||||
|
"confirmPasswordPlaceholder": "Re-enter your password",
|
||||||
|
"submit": "Create Account",
|
||||||
|
"submitting": "Creating Account...",
|
||||||
|
"loggingIn": "Logging in...",
|
||||||
|
"loginLink": "Already have an account? Login",
|
||||||
|
"registerLink": "Don't have an account? Register",
|
||||||
|
"errors": {
|
||||||
|
"invalidEmail": "Please enter a valid email address",
|
||||||
|
"passwordLength": "Password must be at least 6 characters",
|
||||||
|
"passwordMatch": "Passwords do not match",
|
||||||
|
"registrationFailed": "Registration failed",
|
||||||
|
"loginFailed": "Login failed"
|
||||||
|
},
|
||||||
|
"strength": {
|
||||||
|
"weak": "Weak",
|
||||||
|
"medium": "Medium",
|
||||||
|
"strong": "Strong"
|
||||||
|
},
|
||||||
|
"accountInfo": "Account Information",
|
||||||
|
"accountType": "Account Type",
|
||||||
|
"premiumStatus": "Premium Status",
|
||||||
|
"premiumActive": "✓ Premium Active",
|
||||||
|
"freeAccount": "Free Account",
|
||||||
|
"created": "Created",
|
||||||
|
"lastLogin": "Last Login",
|
||||||
|
"gameActions": "Game Actions",
|
||||||
|
"switchCharacter": "Switch Character",
|
||||||
|
"audioSettings": "Audio Settings",
|
||||||
|
"volumeControls": "Volume Controls",
|
||||||
|
"muteAll": "Mute All",
|
||||||
|
"masterVolume": "Master Volume",
|
||||||
|
"musicVolume": "Music Volume",
|
||||||
|
"sfxVolume": "SFX Volume",
|
||||||
|
"securitySettings": "Security Settings",
|
||||||
|
"changeEmail": "Change Email",
|
||||||
|
"changePassword": "Change Password",
|
||||||
|
"currentPassword": "Current Password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"confirmNewPassword": "Confirm New Password",
|
||||||
|
"verifyIdentity": "Verify your identity",
|
||||||
|
"updateEmail": "Update Email",
|
||||||
|
"updatePassword": "Update Password",
|
||||||
|
"updating": "Updating...",
|
||||||
|
"change": "Change",
|
||||||
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
"characters": {
|
"characters": {
|
||||||
"title": "Select Character",
|
"title": "Select Character",
|
||||||
"createNew": "Create New Character",
|
"createNew": "Create New Character",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"deleteModal": {
|
||||||
|
"title": "Delete Character",
|
||||||
|
"confirm": "Are you sure you want to delete this character? This action cannot be undone."
|
||||||
|
},
|
||||||
"noCharacters": "No characters yet",
|
"noCharacters": "No characters yet",
|
||||||
"createFirst": "Create your first character to begin",
|
"createFirst": "Create your first character to begin",
|
||||||
"name": "Character Name",
|
"name": "Character Name",
|
||||||
"class": "Class",
|
"class": "Class",
|
||||||
"level": "Level"
|
"level": "Level",
|
||||||
|
"lastActive": "Last active",
|
||||||
|
"create": {
|
||||||
|
"title": "Create New",
|
||||||
|
"slots": "slots used"
|
||||||
|
},
|
||||||
|
"premium": {
|
||||||
|
"title": "Character Limit Reached",
|
||||||
|
"description": "Upgrade to Premium to create up to 10 characters!",
|
||||||
|
"upgrade": "Upgrade to Premium - $4.99"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"travel": "🧭 Travel",
|
"travel": "🧭 Travel",
|
||||||
"surroundings": "🌿 Surroundings",
|
"surroundings": "🌿 Surroundings",
|
||||||
"character": "👤 Character",
|
"character": "👤 Character",
|
||||||
"equipment": "⚔️ Equipment",
|
"equipment": "⚔️ Equipment",
|
||||||
"inventory": "🎒 Open Inventory",
|
"inventory": "🎒 Inventory",
|
||||||
"workbench": "🔧 Workbench",
|
"workbench": "🔧 Workbench",
|
||||||
"craft": "🔨 Craft",
|
"craft": "🔨 Craft",
|
||||||
"repair": "🛠️ Repair",
|
"repair": "🛠️ Repair",
|
||||||
@@ -69,6 +139,7 @@
|
|||||||
"equip": "Equip",
|
"equip": "Equip",
|
||||||
"unequip": "Unequip",
|
"unequip": "Unequip",
|
||||||
"attack": "⚔️ Attack",
|
"attack": "⚔️ Attack",
|
||||||
|
"kill": "Kill",
|
||||||
"flee": "🏃 Flee",
|
"flee": "🏃 Flee",
|
||||||
"rest": "Rest",
|
"rest": "Rest",
|
||||||
"onlineCount": "{{count}} Online",
|
"onlineCount": "{{count}} Online",
|
||||||
@@ -92,7 +163,33 @@
|
|||||||
"poisoned": "Poisoned"
|
"poisoned": "Poisoned"
|
||||||
},
|
},
|
||||||
"effectAlreadyActive": "Effect already active",
|
"effectAlreadyActive": "Effect already active",
|
||||||
"found": "Found"
|
"found": "Found",
|
||||||
|
"value": "Value",
|
||||||
|
"dialog": {
|
||||||
|
"back": "Back",
|
||||||
|
"goodbye": "Goodbye",
|
||||||
|
"trade": "Trade",
|
||||||
|
"acceptQuest": "Accept Quest",
|
||||||
|
"completeQuest": "Complete Quest",
|
||||||
|
"handInItems": "Hand In Items",
|
||||||
|
"questInProgress": "(Quest in progress...)",
|
||||||
|
"questAccepted": "Quest accepted! Good luck.",
|
||||||
|
"progressUpdated": "Progress updated.",
|
||||||
|
"rewards": "Rewards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trade": {
|
||||||
|
"title": "Trade",
|
||||||
|
"buying": "Buying",
|
||||||
|
"selling": "Selling",
|
||||||
|
"merchantStock": "Merchant Stock",
|
||||||
|
"inventory": "Inventory",
|
||||||
|
"invalidOffer": "INVALID OFFER",
|
||||||
|
"confirmTrade": "CONFIRM TRADE",
|
||||||
|
"balance": "Balance",
|
||||||
|
"empty": "Empty",
|
||||||
|
"clickToRemove": "Click to remove",
|
||||||
|
"howMany": "How many {{item}}?"
|
||||||
},
|
},
|
||||||
"location": {
|
"location": {
|
||||||
"recentActivity": "📜 Recent Activity",
|
"recentActivity": "📜 Recent Activity",
|
||||||
@@ -315,7 +412,11 @@
|
|||||||
"staminaRegenerated": "Stamina regenerated",
|
"staminaRegenerated": "Stamina regenerated",
|
||||||
"combatTimeout": "⏱️ Turn skipped due to timeout!",
|
"combatTimeout": "⏱️ Turn skipped due to timeout!",
|
||||||
"interactableReady": "{{action}} is ready on {{name}}",
|
"interactableReady": "{{action}} is ready on {{name}}",
|
||||||
"waitBeforeMovingSimple": "Wait {{seconds}}s before moving"
|
"waitBeforeMovingSimple": "Wait {{seconds}}s before moving",
|
||||||
|
"questAccepted": "Quest Accepted!",
|
||||||
|
"questCompleted": "Quest Completed!",
|
||||||
|
"questProgressUpdated": "Quest Progress Updated",
|
||||||
|
"failedToAcceptQuest": "Failed to accept quest"
|
||||||
},
|
},
|
||||||
"directions": {
|
"directions": {
|
||||||
"north": "North",
|
"north": "North",
|
||||||
@@ -337,6 +438,114 @@
|
|||||||
"heroTitle": "Echoes of the Ash",
|
"heroTitle": "Echoes of the Ash",
|
||||||
"heroSubtitle": "A post-apocalyptic survival RPG",
|
"heroSubtitle": "A post-apocalyptic survival RPG",
|
||||||
"playNow": "Play Now",
|
"playNow": "Play Now",
|
||||||
"features": "Features"
|
"login": "Login",
|
||||||
|
"features": "Game Features",
|
||||||
|
"about": {
|
||||||
|
"title": "About the World",
|
||||||
|
"description": "In a world ravaged by nuclear fire, survival is the only law. Scavenge for resources, craft distinct weapons, and fight for your life against mutants and other survivors. Will you rebuild civilization or rule over the ashes?"
|
||||||
|
},
|
||||||
|
"featureCards": {
|
||||||
|
"survival": {
|
||||||
|
"title": "Hardcore Survival",
|
||||||
|
"description": "Survive in a harsh environment filled with radiation and deadly enemies. Manage your inventory and resources wisely."
|
||||||
|
},
|
||||||
|
"combat": {
|
||||||
|
"title": "Tactical Combat",
|
||||||
|
"description": "Engage in turn-based battles where every decision counts. Use skills, items, and strategy."
|
||||||
|
},
|
||||||
|
"crafting": {
|
||||||
|
"title": "Deep Crafting",
|
||||||
|
"description": "Scavenge materials to create weapons, armor, and tools essential for your survival."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {{year}} Echoes of the Ash. All rights reserved.",
|
||||||
|
"links": {
|
||||||
|
"privacy": "Privacy Policy",
|
||||||
|
"terms": "Terms of Service",
|
||||||
|
"discord": "Join Discord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"journal": {
|
||||||
|
"title": "Quest Journal",
|
||||||
|
"activeQuests": "Active Quests",
|
||||||
|
"history": "History",
|
||||||
|
"searchPlaceholder": "Search quests...",
|
||||||
|
"noQuests": "No quests found.",
|
||||||
|
"objectives": "Objectives",
|
||||||
|
"completionMessage": "Completion Message",
|
||||||
|
"rewards": "Rewards",
|
||||||
|
"accepted": "Accepted",
|
||||||
|
"completed": "Completed",
|
||||||
|
"type": "Type",
|
||||||
|
"status": "Status",
|
||||||
|
"ready": "READY",
|
||||||
|
"giver": "Giver",
|
||||||
|
"location": "Location",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"story": "Story",
|
||||||
|
"side": "Side",
|
||||||
|
"daily": "Daily",
|
||||||
|
"global": "Global",
|
||||||
|
"selectQuest": "Select a quest to view details",
|
||||||
|
"communityProgress": "Global progress",
|
||||||
|
"yourContribution": "Your contribution"
|
||||||
|
},
|
||||||
|
"legal": {
|
||||||
|
"privacy": {
|
||||||
|
"title": "Privacy Policy",
|
||||||
|
"lastUpdated": "Last updated: February 16, 2026",
|
||||||
|
"sections": {
|
||||||
|
"1": {
|
||||||
|
"title": "1. Information We Collect",
|
||||||
|
"content": "We collect your email address and game-related data (character progress, inventory, etc.) to provide the service."
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"title": "2. How We Use Information",
|
||||||
|
"content": "We use your information to manage your account, save your game progress, and improve the game experience. We do not sell your data to third parties."
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"title": "3. Data Security",
|
||||||
|
"content": "We implement security measures to maintain the safety of your personal information. However, no method of transmission over the Internet is 100% secure."
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"title": "4. Cookies",
|
||||||
|
"content": "We use local storage to maintain your login session and game settings."
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"title": "5. Contact",
|
||||||
|
"content": "If you have questions about this privacy policy, please contact us via our Discord community."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"back": "Return to Home"
|
||||||
|
},
|
||||||
|
"terms": {
|
||||||
|
"title": "Terms of Service",
|
||||||
|
"lastUpdated": "Last updated: February 16, 2026",
|
||||||
|
"sections": {
|
||||||
|
"1": {
|
||||||
|
"title": "1. Acceptance of Terms",
|
||||||
|
"content": "By accessing and using Echoes of the Ash, you accept and agree to be bound by the terms and provision of this agreement."
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"title": "2. Game Rules",
|
||||||
|
"content": "Users agree not to exploit bugs, use automation software (bots), or engage in harassment of other players. We reserve the right to ban accounts that violate these rules."
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"title": "3. User Accounts",
|
||||||
|
"content": "You are responsible for maintaining the confidentiality of your account and password. You agree to accept responsibility for all activities that occur under your account."
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"title": "4. Virtual Items",
|
||||||
|
"content": "Virtual items and currency in Echoes of the Ash have no real-world value and cannot be exchanged for real currency."
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"title": "5. Disclaimer",
|
||||||
|
"content": "The game is provided \"as is\" without warranties of any kind. We are not responsible for any data loss or service interruptions."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"back": "Return to Home"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
"game": "Juego",
|
"game": "Juego",
|
||||||
"leaderboards": "Clasificación",
|
"leaderboards": "Clasificación",
|
||||||
"account": "Cuenta",
|
"account": "Cuenta",
|
||||||
|
"accountSettings": "Ajustes de Cuenta",
|
||||||
|
"general": "General",
|
||||||
|
"audio": "Audio",
|
||||||
|
"security": "Seguridad",
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
"talk": "Hablar",
|
"talk": "Hablar",
|
||||||
"loot": "Saquear",
|
"loot": "Saquear",
|
||||||
@@ -21,7 +25,10 @@
|
|||||||
"pickUpAll": "Recoger Todo",
|
"pickUpAll": "Recoger Todo",
|
||||||
"qty": "Cant",
|
"qty": "Cant",
|
||||||
"quests": "Misiones",
|
"quests": "Misiones",
|
||||||
"all": "Todo"
|
"all": "Todo",
|
||||||
|
"prev": "Ant",
|
||||||
|
"next": "Sig",
|
||||||
|
"back": "Volver"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Iniciar sesión",
|
"login": "Iniciar sesión",
|
||||||
@@ -38,25 +45,88 @@
|
|||||||
"loginTitle": "Bienvenido de nuevo",
|
"loginTitle": "Bienvenido de nuevo",
|
||||||
"registerTitle": "Crear cuenta",
|
"registerTitle": "Crear cuenta",
|
||||||
"loginSubtitle": "Inicia sesión para continuar tu viaje",
|
"loginSubtitle": "Inicia sesión para continuar tu viaje",
|
||||||
"registerSubtitle": "Únete a los supervivientes"
|
"registerSubtitle": "Únete a los supervivientes",
|
||||||
|
"confirmPassword": "Confirmar Contraseña",
|
||||||
|
"emailPlaceholder": "tu.email@ejemplo.com",
|
||||||
|
"passwordPlaceholder": "Al menos 6 caracteres",
|
||||||
|
"passwordPlaceholderLogin": "Tu contraseña",
|
||||||
|
"confirmPasswordPlaceholder": "Reingresa tu contraseña",
|
||||||
|
"submit": "Crear Cuenta",
|
||||||
|
"submitting": "Creando Cuenta...",
|
||||||
|
"loggingIn": "Iniciando sesión...",
|
||||||
|
"loginLink": "¿Ya tienes una cuenta? Inicia sesión",
|
||||||
|
"registerLink": "¿No tienes una cuenta? Regístrate",
|
||||||
|
"errors": {
|
||||||
|
"invalidEmail": "Por favor ingresa un correo electrónico válido",
|
||||||
|
"passwordLength": "La contraseña debe tener al menos 6 caracteres",
|
||||||
|
"passwordMatch": "Las contraseñas no coinciden",
|
||||||
|
"registrationFailed": "Error en el registro",
|
||||||
|
"loginFailed": "Error al iniciar sesión"
|
||||||
|
},
|
||||||
|
"strength": {
|
||||||
|
"weak": "Débil",
|
||||||
|
"medium": "Media",
|
||||||
|
"strong": "Fuerte"
|
||||||
|
},
|
||||||
|
"accountInfo": "Información de la Cuenta",
|
||||||
|
"accountType": "Tipo de Cuenta",
|
||||||
|
"premiumStatus": "Estado Premium",
|
||||||
|
"premiumActive": "✓ Premium Activo",
|
||||||
|
"freeAccount": "Cuenta Gratuita",
|
||||||
|
"created": "Creado",
|
||||||
|
"lastLogin": "Último Acceso",
|
||||||
|
"gameActions": "Acciones de Juego",
|
||||||
|
"switchCharacter": "Cambiar Personaje",
|
||||||
|
"audioSettings": "Ajustes de Audio",
|
||||||
|
"volumeControls": "Controles de Volumen",
|
||||||
|
"muteAll": "Silenciar Todo",
|
||||||
|
"masterVolume": "Volumen Principal",
|
||||||
|
"musicVolume": "Música",
|
||||||
|
"sfxVolume": "Efectos",
|
||||||
|
"securitySettings": "Ajustes de Seguridad",
|
||||||
|
"changeEmail": "Cambiar Correo",
|
||||||
|
"changePassword": "Cambiar Contraseña",
|
||||||
|
"currentPassword": "Contraseña Actual",
|
||||||
|
"newPassword": "Nueva Contraseña",
|
||||||
|
"confirmNewPassword": "Confirmar Nueva Contraseña",
|
||||||
|
"verifyIdentity": "Verifica tu identidad",
|
||||||
|
"updateEmail": "Actualizar Correo",
|
||||||
|
"updatePassword": "Actualizar Contraseña",
|
||||||
|
"updating": "Actualizando...",
|
||||||
|
"change": "Cambiar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
},
|
},
|
||||||
"characters": {
|
"characters": {
|
||||||
"title": "Seleccionar Personaje",
|
"title": "Seleccionar Personaje",
|
||||||
"createNew": "Crear Nuevo Personaje",
|
"createNew": "Crear Nuevo Personaje",
|
||||||
"play": "Jugar",
|
"play": "Jugar",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
|
"deleteModal": {
|
||||||
|
"title": "Eliminar Personaje",
|
||||||
|
"confirm": "¿Estás seguro de que quieres eliminar este personaje? Esta acción no se puede deshacer."
|
||||||
|
},
|
||||||
"noCharacters": "Aún no hay personajes",
|
"noCharacters": "Aún no hay personajes",
|
||||||
"createFirst": "Crea tu primer personaje para comenzar",
|
"createFirst": "Crea tu primer personaje para comenzar",
|
||||||
"name": "Nombre del Personaje",
|
"name": "Nombre del Personaje",
|
||||||
"class": "Clase",
|
"class": "Clase",
|
||||||
"level": "Nivel"
|
"level": "Nivel",
|
||||||
|
"lastActive": "Última actividad",
|
||||||
|
"create": {
|
||||||
|
"title": "Crear Nuevo",
|
||||||
|
"slots": "ranuras usadas"
|
||||||
|
},
|
||||||
|
"premium": {
|
||||||
|
"title": "Límite de Personajes",
|
||||||
|
"description": "¡Mejora a Premium para crear hasta 10 personajes!",
|
||||||
|
"upgrade": "Mejorar a Premium - $4.99"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"travel": "🧭 Viajar",
|
"travel": "🧭 Viajar",
|
||||||
"surroundings": "🌿 Alrededores",
|
"surroundings": "🌿 Alrededores",
|
||||||
"character": "👤 Personaje",
|
"character": "👤 Personaje",
|
||||||
"equipment": "⚔️ Equipamiento",
|
"equipment": "⚔️ Equipamiento",
|
||||||
"inventory": "🎒 Abrir Inventario",
|
"inventory": "🎒 Inventario",
|
||||||
"workbench": "🔧 Banco de Trabajo",
|
"workbench": "🔧 Banco de Trabajo",
|
||||||
"craft": "🔨 Fabricar",
|
"craft": "🔨 Fabricar",
|
||||||
"repair": "🛠️ Reparar",
|
"repair": "🛠️ Reparar",
|
||||||
@@ -67,6 +137,7 @@
|
|||||||
"equip": "Equipar",
|
"equip": "Equipar",
|
||||||
"unequip": "Desequipar",
|
"unequip": "Desequipar",
|
||||||
"attack": "⚔️ Atacar",
|
"attack": "⚔️ Atacar",
|
||||||
|
"kill": "Mata",
|
||||||
"flee": "🏃 Huir",
|
"flee": "🏃 Huir",
|
||||||
"rest": "Descansar",
|
"rest": "Descansar",
|
||||||
"onlineCount": "{{count}} En línea",
|
"onlineCount": "{{count}} En línea",
|
||||||
@@ -90,7 +161,33 @@
|
|||||||
"poisoned": "Envenenamiento"
|
"poisoned": "Envenenamiento"
|
||||||
},
|
},
|
||||||
"effectAlreadyActive": "Efecto ya activo",
|
"effectAlreadyActive": "Efecto ya activo",
|
||||||
"found": "Encontrado"
|
"found": "Encontrado",
|
||||||
|
"value": "Valor",
|
||||||
|
"dialog": {
|
||||||
|
"back": "Atrás",
|
||||||
|
"goodbye": "Adiós",
|
||||||
|
"trade": "Comerciar",
|
||||||
|
"acceptQuest": "Aceptar Misión",
|
||||||
|
"completeQuest": "Completar Misión",
|
||||||
|
"handInItems": "Entregar Objetos",
|
||||||
|
"questInProgress": "(Misión en progreso...)",
|
||||||
|
"questAccepted": "¡Misión aceptada! Buena suerte.",
|
||||||
|
"progressUpdated": "Progreso actualizado.",
|
||||||
|
"rewards": "Recompensas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trade": {
|
||||||
|
"title": "Comercio",
|
||||||
|
"buying": "Comprando",
|
||||||
|
"selling": "Vendiendo",
|
||||||
|
"merchantStock": "Inventario del Mercader",
|
||||||
|
"inventory": "Inventario",
|
||||||
|
"invalidOffer": "OFERTA INVÁLIDA",
|
||||||
|
"confirmTrade": "CONFIRMAR COMERCIO",
|
||||||
|
"balance": "Balance",
|
||||||
|
"empty": "Vacío",
|
||||||
|
"clickToRemove": "Haz clic para remover",
|
||||||
|
"howMany": "¿Cuántos {{item}}?"
|
||||||
},
|
},
|
||||||
"location": {
|
"location": {
|
||||||
"recentActivity": "📜 Actividad Reciente",
|
"recentActivity": "📜 Actividad Reciente",
|
||||||
@@ -313,7 +410,11 @@
|
|||||||
"staminaRegenerated": "Estamina regenerada",
|
"staminaRegenerated": "Estamina regenerada",
|
||||||
"combatTimeout": "⏱️ ¡Turno saltado por tiempo agotado!",
|
"combatTimeout": "⏱️ ¡Turno saltado por tiempo agotado!",
|
||||||
"interactableReady": "{{action}} está listo en {{name}}",
|
"interactableReady": "{{action}} está listo en {{name}}",
|
||||||
"waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte"
|
"waitBeforeMovingSimple": "Espera {{seconds}}s antes de moverte",
|
||||||
|
"questAccepted": "¡Misión Aceptada!",
|
||||||
|
"questCompleted": "¡Misión Completada!",
|
||||||
|
"questProgressUpdated": "Progreso de Misión Actualizado",
|
||||||
|
"failedToAcceptQuest": "Error al aceptar la misión"
|
||||||
},
|
},
|
||||||
"directions": {
|
"directions": {
|
||||||
"north": "Norte",
|
"north": "Norte",
|
||||||
@@ -332,9 +433,117 @@
|
|||||||
"exit": "Salir"
|
"exit": "Salir"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"heroTitle": "Ecos de las Cenizas",
|
"heroTitle": "Echoes of the Ash",
|
||||||
"heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
|
"heroSubtitle": "Un RPG de supervivencia post-apocalíptico",
|
||||||
"playNow": "Jugar Ahora",
|
"playNow": "Jugar Ahora",
|
||||||
"features": "Características"
|
"login": "Iniciar Sesión",
|
||||||
|
"features": "Características del Juego",
|
||||||
|
"about": {
|
||||||
|
"title": "Sobre el Mundo",
|
||||||
|
"description": "En un mundo devastado por el fuego nuclear, la supervivencia es la única ley. Busca recursos, fabrica armas distintas y lucha por tu vida contra mutantes y otros supervivientes. ¿Reconstruirás la civilización o reinarás sobre las cenizas?"
|
||||||
|
},
|
||||||
|
"featureCards": {
|
||||||
|
"survival": {
|
||||||
|
"title": "Supervivencia Extrema",
|
||||||
|
"description": "Sobrevive en un entorno hostil lleno de radiación y enemigos mortales. Gestiona tu inventario y recursos sabiamente."
|
||||||
|
},
|
||||||
|
"combat": {
|
||||||
|
"title": "Combate Táctico",
|
||||||
|
"description": "Participa en batallas por turnos donde cada decisión cuenta. Usa habilidades, objetos y estrategia."
|
||||||
|
},
|
||||||
|
"crafting": {
|
||||||
|
"title": "Fabricación Profunda",
|
||||||
|
"description": "Busca materiales para crear armas, armaduras y herramientas esenciales para tu supervivencia."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {{year}} Echoes of the Ash. Todos los derechos reservados.",
|
||||||
|
"links": {
|
||||||
|
"privacy": "Política de Privacidad",
|
||||||
|
"terms": "Términos de Servicio",
|
||||||
|
"discord": "Unirse a Discord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"journal": {
|
||||||
|
"title": "Diario de Misiones",
|
||||||
|
"activeQuests": "Misiones Activas",
|
||||||
|
"history": "Historial",
|
||||||
|
"searchPlaceholder": "Buscar misiones...",
|
||||||
|
"noQuests": "No se encontraron misiones.",
|
||||||
|
"objectives": "Objetivos",
|
||||||
|
"completionMessage": "Mensaje de Completado",
|
||||||
|
"rewards": "Recompensas",
|
||||||
|
"accepted": "Aceptado",
|
||||||
|
"completed": "Completado",
|
||||||
|
"type": "Tipo",
|
||||||
|
"status": "Estado",
|
||||||
|
"ready": "LISTO",
|
||||||
|
"giver": "Dador",
|
||||||
|
"location": "Ubicación",
|
||||||
|
"inProgress": "En Progreso",
|
||||||
|
"story": "Historia",
|
||||||
|
"side": "Secundaria",
|
||||||
|
"daily": "Diaria",
|
||||||
|
"global": "Global",
|
||||||
|
"selectQuest": "Selecciona una misión para ver detalles",
|
||||||
|
"communityProgress": "Progreso global",
|
||||||
|
"yourContribution": "Tu contribución"
|
||||||
|
},
|
||||||
|
"legal": {
|
||||||
|
"privacy": {
|
||||||
|
"title": "Política de Privacidad",
|
||||||
|
"lastUpdated": "Última actualización: 16 de Febrero de 2026",
|
||||||
|
"sections": {
|
||||||
|
"1": {
|
||||||
|
"title": "1. Información que Recopilamos",
|
||||||
|
"content": "Recopilamos tu dirección de correo electrónico y datos relacionados con el juego (progreso del personaje, inventario, etc.) para proporcionar el servicio."
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"title": "2. Cómo Usamos la Información",
|
||||||
|
"content": "Usamos tu información para administrar tu cuenta, guardar tu progreso y mejorar la experiencia de juego. No vendemos tus datos a terceros."
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"title": "3. Seguridad de Datos",
|
||||||
|
"content": "Implementamos medidas de seguridad para mantener a salvo tu información personal. Sin embargo, ningún método de transmisión por Internet es 100% seguro."
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"title": "4. Cookies",
|
||||||
|
"content": "Usamos almacenamiento local para mantener tu sesión de inicio y configuraciones del juego."
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"title": "5. Contacto",
|
||||||
|
"content": "Si tienes preguntas sobre esta política de privacidad, contáctanos a través de nuestra comunidad de Discord."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"back": "Volver al Inicio"
|
||||||
|
},
|
||||||
|
"terms": {
|
||||||
|
"title": "Términos de Servicio",
|
||||||
|
"lastUpdated": "Última actualización: 16 de Febrero de 2026",
|
||||||
|
"sections": {
|
||||||
|
"1": {
|
||||||
|
"title": "1. Aceptación de Términos",
|
||||||
|
"content": "Al acceder y usar Echoes of the Ash, aceptas y acuerdas estar sujeto a los términos y disposiciones de este acuerdo."
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"title": "2. Reglas del Juego",
|
||||||
|
"content": "Los usuarios acuerdan no explotar errores, usar software de automatización (bots) o participar en el acoso de otros jugadores. Nos reservamos el derecho de prohibir cuentas que violen estas reglas."
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"title": "3. Cuentas de Usuario",
|
||||||
|
"content": "Eres responsable de mantener la confidencialidad de tu cuenta y contraseña. Aceptas la responsabilidad de todas las actividades que ocurran bajo tu cuenta."
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"title": "4. Objetos Virtuales",
|
||||||
|
"content": "Los objetos virtuales y la moneda en Echoes of the Ash no tienen valor en el mundo real y no pueden intercambiarse por moneda real."
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"title": "5. Descargo de Responsabilidad",
|
||||||
|
"content": "El juego se proporciona \"tal cual\" sin garantías de ningún tipo. No somos responsables de ninguna pérdida de datos o interrupciones del servicio."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"back": "Volver al Inicio"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,20 +87,6 @@
|
|||||||
/* Default (1080p and below) */
|
/* Default (1080p and below) */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 2200px) {
|
|
||||||
:root {
|
|
||||||
--location-content-width: 1000px;
|
|
||||||
/* 1440p */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 3400px) {
|
|
||||||
:root {
|
|
||||||
--location-content-width: 1400px;
|
|
||||||
/* 4K */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Reusable Game Classes --- */
|
/* --- Reusable Game Classes --- */
|
||||||
|
|
||||||
/* Panels */
|
/* Panels */
|
||||||
|
|||||||