feat: more items
This commit is contained in:
+192
-34
@@ -13,18 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
DEBUG_MODE = os.getenv("DEBUG", "0").strip().lower() in {"1", "true", "yes", "on"}
|
DEBUG_MODE = os.getenv("DEBUG", "0").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
GAME_DURATION = 30 * 60 # 30 minutes in seconds
|
GAME_DURATION = 30 * 60 # 30 minutes in seconds
|
||||||
GRID_SIZE = 500 # Grid size in tiles
|
GRID_SIZE = 200 # Grid size in tiles
|
||||||
PLAYER_START_SPAWN_RANGE = 50
|
PLAYER_START_SPAWN_RANGE = 50
|
||||||
BOSS_SPAWN_DISTANCE = 150
|
BOSS_SPAWN_DISTANCE = 70
|
||||||
ACTION_POINTS_MAX = 20
|
ACTION_POINTS_MAX = 30
|
||||||
# In debug mode AP regenerates much faster for rapid testing.
|
# In debug mode AP regenerates much faster for rapid testing.
|
||||||
ACTION_POINTS_REGEN_INTERVAL = float(os.getenv("ACTION_REGEN_INTERVAL", "1" if DEBUG_MODE else "60"))
|
ACTION_POINTS_REGEN_INTERVAL = float(os.getenv("ACTION_REGEN_INTERVAL", "1" if DEBUG_MODE else "60"))
|
||||||
MONSTER_SPAWN_RATE = 0.1 # Probability per game tick
|
HP_REGEN_INTERVAL = float(os.getenv("HP_REGEN_INTERVAL", "2" if DEBUG_MODE else "15")) # seconds per 1 HP
|
||||||
|
HP_REGEN_AMOUNT = 1 # HP healed per interval
|
||||||
|
MONSTER_SPAWN_RATE = 0.05 # Probability per game tick
|
||||||
MAX_MONSTERS = 50
|
MAX_MONSTERS = 50
|
||||||
BOSS_HEALTH_BASE = 1000
|
BOSS_HEALTH_BASE = 1000
|
||||||
MIN_LEVEL_FOR_BOSS = 10
|
MIN_LEVEL_FOR_BOSS = 10
|
||||||
BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1
|
BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1
|
||||||
RESOURCE_NODE_COUNT = 180
|
RESOURCE_NODE_COUNT = 540
|
||||||
CHEST_SPAWN_RATE = 0.015
|
CHEST_SPAWN_RATE = 0.015
|
||||||
MAX_CHESTS = 15
|
MAX_CHESTS = 15
|
||||||
CHEST_LOOT_RANGE = 3
|
CHEST_LOOT_RANGE = 3
|
||||||
@@ -60,6 +62,7 @@ class Player:
|
|||||||
action_points: int = ACTION_POINTS_MAX
|
action_points: int = ACTION_POINTS_MAX
|
||||||
max_action_points: int = ACTION_POINTS_MAX
|
max_action_points: int = ACTION_POINTS_MAX
|
||||||
last_action_regen: float = 0
|
last_action_regen: float = 0
|
||||||
|
last_hp_regen: float = 0
|
||||||
inventory: Dict[str, int] = field(default_factory=lambda: {"wood": 0, "stone": 0})
|
inventory: Dict[str, int] = field(default_factory=lambda: {"wood": 0, "stone": 0})
|
||||||
attack: int = 5
|
attack: int = 5
|
||||||
defense: int = 2
|
defense: int = 2
|
||||||
@@ -190,6 +193,9 @@ class GameWorld:
|
|||||||
self.generation = 0
|
self.generation = 0
|
||||||
self._init_resources()
|
self._init_resources()
|
||||||
self._init_chests()
|
self._init_chests()
|
||||||
|
# Auto-start a game session immediately so the timer runs from launch.
|
||||||
|
self.game_start_time = datetime.now().timestamp()
|
||||||
|
self.is_game_active = True
|
||||||
|
|
||||||
def _init_resources(self):
|
def _init_resources(self):
|
||||||
"""Initialize static resources on the map"""
|
"""Initialize static resources on the map"""
|
||||||
@@ -218,6 +224,27 @@ class GameWorld:
|
|||||||
{"name": "Long Spear", "attack_bonus": 2, "range_bonus": 2},
|
{"name": "Long Spear", "attack_bonus": 2, "range_bonus": 2},
|
||||||
{"name": "Hunting Bow", "attack_bonus": 1, "range_bonus": 3},
|
{"name": "Hunting Bow", "attack_bonus": 1, "range_bonus": 3},
|
||||||
{"name": "Battle Axe", "attack_bonus": 3, "range_bonus": 1},
|
{"name": "Battle Axe", "attack_bonus": 3, "range_bonus": 1},
|
||||||
|
{
|
||||||
|
"name": "Banner of Vigor",
|
||||||
|
"attack_bonus": 1,
|
||||||
|
"range_bonus": 1,
|
||||||
|
"aura_radius": 6,
|
||||||
|
"aura_bonuses": {"action_regen": 1, "hp_regen": 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Warden Standard",
|
||||||
|
"attack_bonus": 0,
|
||||||
|
"range_bonus": 1,
|
||||||
|
"aura_radius": 6,
|
||||||
|
"aura_bonuses": {"defense": 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Windstep Boots",
|
||||||
|
"attack_bonus": 0,
|
||||||
|
"range_bonus": 0,
|
||||||
|
"aura_radius": 5,
|
||||||
|
"aura_bonuses": {"free_move_chance": 0.5},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
self.chests[chest_id] = Chest(
|
self.chests[chest_id] = Chest(
|
||||||
id=chest_id,
|
id=chest_id,
|
||||||
@@ -293,15 +320,23 @@ class GameWorld:
|
|||||||
continue
|
continue
|
||||||
if current_time - player.last_action_regen >= ACTION_POINTS_REGEN_INTERVAL:
|
if current_time - player.last_action_regen >= ACTION_POINTS_REGEN_INTERVAL:
|
||||||
regen_amount = 1
|
regen_amount = 1
|
||||||
nearby_bonuses = self._get_nearby_structure_bonuses(player.position)
|
nearby_bonuses = _player_nearby_bonuses(player)
|
||||||
if "action_regen" in nearby_bonuses:
|
if "action_regen" in nearby_bonuses:
|
||||||
regen_amount = nearby_bonuses["action_regen"]
|
regen_amount += int(nearby_bonuses["action_regen"])
|
||||||
player.action_points = min(
|
player.action_points = min(
|
||||||
player.max_action_points,
|
player.max_action_points,
|
||||||
player.action_points + regen_amount,
|
player.action_points + regen_amount,
|
||||||
)
|
)
|
||||||
player.last_action_regen = current_time
|
player.last_action_regen = current_time
|
||||||
|
|
||||||
|
# HP regeneration
|
||||||
|
if current_time - player.last_hp_regen >= HP_REGEN_INTERVAL:
|
||||||
|
effective_max_health = _player_effective_max_health(player)
|
||||||
|
hp_regen_amount = HP_REGEN_AMOUNT + int(_player_nearby_bonuses(player).get("hp_regen", 0))
|
||||||
|
if player.health < effective_max_health:
|
||||||
|
player.health = min(effective_max_health, player.health + hp_regen_amount)
|
||||||
|
player.last_hp_regen = current_time
|
||||||
|
|
||||||
def _spawn_monster(self):
|
def _spawn_monster(self):
|
||||||
"""Spawn a random monster on the map"""
|
"""Spawn a random monster on the map"""
|
||||||
monster_id = f"monster_{len(self.monsters)}_{self.generation}"
|
monster_id = f"monster_{len(self.monsters)}_{self.generation}"
|
||||||
@@ -361,6 +396,58 @@ def _player_effective_range(player: Player) -> float:
|
|||||||
return player.attack_range + float(w.get("range_bonus", 0))
|
return player.attack_range + float(w.get("range_bonus", 0))
|
||||||
return player.attack_range
|
return player.attack_range
|
||||||
|
|
||||||
|
def _player_nearby_bonuses(player: Player) -> Dict:
|
||||||
|
if "game_world" not in globals():
|
||||||
|
return {}
|
||||||
|
bonuses = dict(game_world._get_nearby_structure_bonuses(player.position))
|
||||||
|
|
||||||
|
for other_player in game_world.players.values():
|
||||||
|
if not other_player.active:
|
||||||
|
continue
|
||||||
|
if not (0 <= other_player.equipped_weapon_slot < len(other_player.weapon_slots)):
|
||||||
|
continue
|
||||||
|
item = other_player.weapon_slots[other_player.equipped_weapon_slot]
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
aura_bonuses = item.get("aura_bonuses") or {}
|
||||||
|
aura_radius = float(item.get("aura_radius", 0))
|
||||||
|
if not aura_bonuses or aura_radius <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dist = ((other_player.position.x - player.position.x) ** 2 + (other_player.position.y - player.position.y) ** 2) ** 0.5
|
||||||
|
if dist > aura_radius:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key, value in aura_bonuses.items():
|
||||||
|
bonuses[key] = bonuses.get(key, 0) + value
|
||||||
|
|
||||||
|
return bonuses
|
||||||
|
|
||||||
|
def _player_effective_defense(player: Player) -> int:
|
||||||
|
bonuses = _player_nearby_bonuses(player)
|
||||||
|
return player.defense + int(bonuses.get("defense", 0))
|
||||||
|
|
||||||
|
def _player_effective_max_health(player: Player) -> int:
|
||||||
|
bonuses = _player_nearby_bonuses(player)
|
||||||
|
return player.max_health + int(bonuses.get("max_health", 0))
|
||||||
|
|
||||||
|
def _serialize_player(player: Player) -> Dict:
|
||||||
|
data = player.to_dict()
|
||||||
|
bonuses = _player_nearby_bonuses(player)
|
||||||
|
effective_defense = _player_effective_defense(player)
|
||||||
|
effective_max_health = _player_effective_max_health(player)
|
||||||
|
|
||||||
|
if player.health > effective_max_health:
|
||||||
|
player.health = effective_max_health
|
||||||
|
|
||||||
|
data["base_defense"] = player.defense
|
||||||
|
data["defense"] = effective_defense
|
||||||
|
data["base_max_health"] = player.max_health
|
||||||
|
data["max_health"] = effective_max_health
|
||||||
|
data["health"] = player.health
|
||||||
|
data["nearby_bonuses"] = bonuses
|
||||||
|
return data
|
||||||
|
|
||||||
def _get_nearby_structure_bonuses_impl(self, position: Position, radius: int = 10) -> Dict:
|
def _get_nearby_structure_bonuses_impl(self, position: Position, radius: int = 10) -> Dict:
|
||||||
bonuses = {}
|
bonuses = {}
|
||||||
for structure in self.structures.values():
|
for structure in self.structures.values():
|
||||||
@@ -444,7 +531,7 @@ async def login(request: Request):
|
|||||||
print("[13] Session created", flush=True)
|
print("[13] Session created", flush=True)
|
||||||
|
|
||||||
print("[14] Converting player to dict...", flush=True)
|
print("[14] Converting player to dict...", flush=True)
|
||||||
player_dict = player.to_dict()
|
player_dict = _serialize_player(player)
|
||||||
print(f"[15] Player dict has {len(player_dict)} keys", flush=True)
|
print(f"[15] Player dict has {len(player_dict)} keys", flush=True)
|
||||||
|
|
||||||
print("[16] Building response...", flush=True)
|
print("[16] Building response...", flush=True)
|
||||||
@@ -463,7 +550,7 @@ async def get_game_state():
|
|||||||
"""Get full game state"""
|
"""Get full game state"""
|
||||||
return {
|
return {
|
||||||
"grid_size": GRID_SIZE,
|
"grid_size": GRID_SIZE,
|
||||||
"players": {pid: p.to_dict() for pid, p in game_world.players.items()},
|
"players": {pid: _serialize_player(p) for pid, p in game_world.players.items()},
|
||||||
"monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()},
|
"monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()},
|
||||||
"boss": game_world.boss.to_dict() if game_world.boss else None,
|
"boss": game_world.boss.to_dict() if game_world.boss else None,
|
||||||
"structures": {sid: s.to_dict() for sid, s in game_world.structures.items()},
|
"structures": {sid: s.to_dict() for sid, s in game_world.structures.items()},
|
||||||
@@ -487,7 +574,11 @@ async def move_player(player_id: str, request: Request):
|
|||||||
if not player.active:
|
if not player.active:
|
||||||
raise HTTPException(status_code=400, detail="Player not active")
|
raise HTTPException(status_code=400, detail="Player not active")
|
||||||
|
|
||||||
if player.action_points < 1:
|
nearby_bonuses = _player_nearby_bonuses(player)
|
||||||
|
free_move_chance = max(0.0, min(0.95, float(nearby_bonuses.get("free_move_chance", 0))))
|
||||||
|
ap_cost = 0 if random.random() < free_move_chance else 1
|
||||||
|
|
||||||
|
if player.action_points < ap_cost:
|
||||||
raise HTTPException(status_code=400, detail="Insufficient action points")
|
raise HTTPException(status_code=400, detail="Insufficient action points")
|
||||||
|
|
||||||
# Calculate distance
|
# Calculate distance
|
||||||
@@ -508,9 +599,14 @@ async def move_player(player_id: str, request: Request):
|
|||||||
|
|
||||||
player.position.x = next_x
|
player.position.x = next_x
|
||||||
player.position.y = next_y
|
player.position.y = next_y
|
||||||
player.action_points -= 1
|
player.action_points -= ap_cost
|
||||||
|
|
||||||
return {"position": player.position.to_dict(), "action_points": player.action_points}
|
return {
|
||||||
|
"position": player.position.to_dict(),
|
||||||
|
"action_points": player.action_points,
|
||||||
|
"ap_cost": ap_cost,
|
||||||
|
"free_move_triggered": ap_cost == 0,
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/player/{player_id}/action")
|
@app.post("/api/player/{player_id}/action")
|
||||||
async def player_action(player_id: str, request: Request):
|
async def player_action(player_id: str, request: Request):
|
||||||
@@ -520,10 +616,16 @@ async def player_action(player_id: str, request: Request):
|
|||||||
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
player = game_world.players[player_id]
|
player = game_world.players[player_id]
|
||||||
|
action_type = body.get("action_type")
|
||||||
|
|
||||||
|
# Equip is free — no AP required
|
||||||
|
if action_type == "equip":
|
||||||
|
result = _handle_equip(player, body.get("slot_index"))
|
||||||
|
return result
|
||||||
|
|
||||||
if player.action_points < 1:
|
if player.action_points < 1:
|
||||||
raise HTTPException(status_code=400, detail="Insufficient action points")
|
raise HTTPException(status_code=400, detail="Insufficient action points")
|
||||||
|
|
||||||
action_type = body.get("action_type")
|
|
||||||
if action_type == "attack":
|
if action_type == "attack":
|
||||||
result = _handle_attack(player, body.get("target_id"))
|
result = _handle_attack(player, body.get("target_id"))
|
||||||
elif action_type == "gather":
|
elif action_type == "gather":
|
||||||
@@ -547,6 +649,36 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
|||||||
"""Handle player attack"""
|
"""Handle player attack"""
|
||||||
attack_range = _player_effective_range(player)
|
attack_range = _player_effective_range(player)
|
||||||
attack_power = _player_effective_attack(player)
|
attack_power = _player_effective_attack(player)
|
||||||
|
effective_defense = _player_effective_defense(player)
|
||||||
|
|
||||||
|
def max_retaliation_damage(monster_attack: int) -> int:
|
||||||
|
# Retaliation roll is random in [-1, +2], so +2 is the worst-case outcome.
|
||||||
|
return max(0, monster_attack + 2 - effective_defense)
|
||||||
|
|
||||||
|
def apply_retaliation(monster_attack: int) -> Dict:
|
||||||
|
retaliation_damage = max(0, monster_attack + random.randint(-1, 2) - effective_defense)
|
||||||
|
if retaliation_damage <= 0:
|
||||||
|
return {"retaliation_damage": 0, "player_health": player.health, "player_defeated": False}
|
||||||
|
|
||||||
|
player.health = max(0, player.health - retaliation_damage)
|
||||||
|
defeated = player.health <= 0
|
||||||
|
respawn_position = None
|
||||||
|
|
||||||
|
if defeated:
|
||||||
|
player.health = player.max_health
|
||||||
|
player.position = Position(
|
||||||
|
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
|
||||||
|
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
respawn_position = player.position.to_dict()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"retaliation_damage": retaliation_damage,
|
||||||
|
"player_health": player.health,
|
||||||
|
"player_defeated": defeated,
|
||||||
|
"respawn_position": respawn_position,
|
||||||
|
}
|
||||||
|
|
||||||
if target_id in game_world.monsters:
|
if target_id in game_world.monsters:
|
||||||
monster = game_world.monsters[target_id]
|
monster = game_world.monsters[target_id]
|
||||||
@@ -555,6 +687,12 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
|||||||
if dist > attack_range:
|
if dist > attack_range:
|
||||||
return {"success": False, "reason": "Target too far"}
|
return {"success": False, "reason": "Target too far"}
|
||||||
|
|
||||||
|
min_damage = max(1, attack_power - 2 - monster.defense)
|
||||||
|
# If monster certainly survives this hit and guaranteed max retaliation is lethal,
|
||||||
|
# block the attack before consuming AP.
|
||||||
|
if monster.health > min_damage and player.health <= max_retaliation_damage(monster.attack):
|
||||||
|
return {"success": False, "reason": "Attack blocked: retaliation would be lethal"}
|
||||||
|
|
||||||
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
|
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
|
||||||
monster.health -= damage
|
monster.health -= damage
|
||||||
|
|
||||||
@@ -564,7 +702,13 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
|||||||
player.level = 1 + int(player.exp / 100)
|
player.level = 1 + int(player.exp / 100)
|
||||||
return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True}
|
return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True}
|
||||||
|
|
||||||
return {"success": True, "damage": damage, "monster_health_remaining": monster.health}
|
retaliation = apply_retaliation(monster.attack)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"damage": damage,
|
||||||
|
"monster_health_remaining": monster.health,
|
||||||
|
**retaliation,
|
||||||
|
}
|
||||||
|
|
||||||
elif game_world.boss and target_id == game_world.boss.id:
|
elif game_world.boss and target_id == game_world.boss.id:
|
||||||
monster = game_world.boss
|
monster = game_world.boss
|
||||||
@@ -573,6 +717,10 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
|||||||
if dist > attack_range:
|
if dist > attack_range:
|
||||||
return {"success": False, "reason": "Boss too far"}
|
return {"success": False, "reason": "Boss too far"}
|
||||||
|
|
||||||
|
min_damage = max(1, attack_power - 2 - monster.defense)
|
||||||
|
if monster.health > min_damage and player.health <= max_retaliation_damage(monster.attack + 2):
|
||||||
|
return {"success": False, "reason": "Attack blocked: boss retaliation would be lethal"}
|
||||||
|
|
||||||
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
|
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
|
||||||
monster.health -= damage
|
monster.health -= damage
|
||||||
old_progress = monster.boss_progress
|
old_progress = monster.boss_progress
|
||||||
@@ -587,7 +735,13 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
|||||||
game_world.boss = None
|
game_world.boss = None
|
||||||
return {"success": True, "damage": damage, "boss_killed": True}
|
return {"success": True, "damage": damage, "boss_killed": True}
|
||||||
|
|
||||||
return {"success": True, "damage": damage, "boss_health_remaining": monster.health}
|
retaliation = apply_retaliation(monster.attack + 2)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"damage": damage,
|
||||||
|
"boss_health_remaining": monster.health,
|
||||||
|
**retaliation,
|
||||||
|
}
|
||||||
|
|
||||||
if target_id in game_world.structures:
|
if target_id in game_world.structures:
|
||||||
structure = game_world.structures[target_id]
|
structure = game_world.structures[target_id]
|
||||||
@@ -607,20 +761,22 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
|||||||
def _handle_gather(player: Player, target_id: str) -> Dict:
|
def _handle_gather(player: Player, target_id: str) -> Dict:
|
||||||
"""Gather from all resources within GATHER_RADIUS of the player (target_id is ignored)."""
|
"""Gather from all resources within GATHER_RADIUS of the player (target_id is ignored)."""
|
||||||
GATHER_RADIUS = 5
|
GATHER_RADIUS = 5
|
||||||
|
# Map resource_type -> inventory key
|
||||||
|
RESOURCE_MAP = {"tree": "wood", "mountain": "stone"}
|
||||||
gathered_total = {"wood": 0, "stone": 0}
|
gathered_total = {"wood": 0, "stone": 0}
|
||||||
depleted = []
|
depleted = []
|
||||||
remaining_capacity = player.gathering_capacity
|
|
||||||
|
|
||||||
for rid, resource in list(game_world.resources.items()):
|
for rid, resource in list(game_world.resources.items()):
|
||||||
if remaining_capacity <= 0:
|
|
||||||
break
|
|
||||||
dist = ((resource.position.x - player.position.x) ** 2 +
|
dist = ((resource.position.x - player.position.x) ** 2 +
|
||||||
(resource.position.y - player.position.y) ** 2) ** 0.5
|
(resource.position.y - player.position.y) ** 2) ** 0.5
|
||||||
if dist <= GATHER_RADIUS:
|
if dist <= GATHER_RADIUS:
|
||||||
amount = min(remaining_capacity, resource.amount)
|
inv_key = RESOURCE_MAP.get(resource.resource_type)
|
||||||
|
if not inv_key:
|
||||||
|
continue
|
||||||
|
# Gather from every node in range (per-node cap = gathering_capacity).
|
||||||
|
amount = min(player.gathering_capacity, resource.amount)
|
||||||
resource.amount -= amount
|
resource.amount -= amount
|
||||||
gathered_total[resource.resource_type] = gathered_total.get(resource.resource_type, 0) + amount
|
gathered_total[inv_key] = gathered_total.get(inv_key, 0) + amount
|
||||||
remaining_capacity -= amount
|
|
||||||
if resource.amount <= 0:
|
if resource.amount <= 0:
|
||||||
depleted.append(rid)
|
depleted.append(rid)
|
||||||
|
|
||||||
@@ -631,8 +787,8 @@ def _handle_gather(player: Player, target_id: str) -> Dict:
|
|||||||
if total_gathered == 0:
|
if total_gathered == 0:
|
||||||
return {"success": False, "reason": "No resources within reach (radius 5)"}
|
return {"success": False, "reason": "No resources within reach (radius 5)"}
|
||||||
|
|
||||||
for rtype, amt in gathered_total.items():
|
for inv_key, amt in gathered_total.items():
|
||||||
player.inventory[rtype] = player.inventory.get(rtype, 0) + amt
|
player.inventory[inv_key] = player.inventory.get(inv_key, 0) + amt
|
||||||
|
|
||||||
return {"success": True, "gathered": gathered_total, "inventory": player.inventory}
|
return {"success": True, "gathered": gathered_total, "inventory": player.inventory}
|
||||||
|
|
||||||
@@ -649,27 +805,29 @@ def _handle_build(player: Player, tx: float, ty: float, structure_type: str) ->
|
|||||||
}
|
}
|
||||||
cost = costs[structure_type]
|
cost = costs[structure_type]
|
||||||
|
|
||||||
for material, amount in cost.items():
|
|
||||||
if player.inventory.get(material, 0) < amount:
|
|
||||||
return {"success": False, "reason": f"Insufficient {material}"}
|
|
||||||
|
|
||||||
# Deduct cost
|
|
||||||
for material, amount in cost.items():
|
|
||||||
player.inventory[material] -= amount
|
|
||||||
|
|
||||||
# Create structure (block is placed 1 tile north of player)
|
# Create structure (block is placed 1 tile north of player)
|
||||||
structure_id = f"struct_{len(game_world.structures)}"
|
structure_id = f"struct_{len(game_world.structures)}"
|
||||||
bonuses = {}
|
bonuses = {}
|
||||||
px, py = tx, ty
|
px, py = tx, ty
|
||||||
if structure_type == "block":
|
if structure_type == "block":
|
||||||
px, py = player.position.x, player.position.y - 1
|
px, py = player.position.x, player.position.y - 1
|
||||||
# Cannot place on top of players or existing structures.
|
# Cannot place on top of players.
|
||||||
for p in game_world.players.values():
|
for p in game_world.players.values():
|
||||||
if ((p.position.x - px) ** 2 + (p.position.y - py) ** 2) ** 0.5 < 0.6:
|
if ((p.position.x - px) ** 2 + (p.position.y - py) ** 2) ** 0.5 < 0.6:
|
||||||
return {"success": False, "reason": "Cannot place block on player"}
|
return {"success": False, "reason": "Cannot place block on player"}
|
||||||
|
|
||||||
|
# No structure can be placed on an existing structure.
|
||||||
for s in game_world.structures.values():
|
for s in game_world.structures.values():
|
||||||
if ((s.position.x - px) ** 2 + (s.position.y - py) ** 2) ** 0.5 < 0.6:
|
if ((s.position.x - px) ** 2 + (s.position.y - py) ** 2) ** 0.5 < 0.6:
|
||||||
return {"success": False, "reason": "Tile already occupied"}
|
return {"success": False, "reason": "Tile already occupied by a structure"}
|
||||||
|
|
||||||
|
for material, amount in cost.items():
|
||||||
|
if player.inventory.get(material, 0) < amount:
|
||||||
|
return {"success": False, "reason": f"Insufficient {material}"}
|
||||||
|
|
||||||
|
# Deduct cost only after placement is validated.
|
||||||
|
for material, amount in cost.items():
|
||||||
|
player.inventory[material] -= amount
|
||||||
|
|
||||||
if structure_type == "farm":
|
if structure_type == "farm":
|
||||||
bonuses["action_regen"] = 2
|
bonuses["action_regen"] = 2
|
||||||
@@ -747,7 +905,7 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
|||||||
|
|
||||||
state = {
|
state = {
|
||||||
"type": "state_update",
|
"type": "state_update",
|
||||||
"players": {pid: p.to_dict() for pid, p in game_world.players.items()},
|
"players": {pid: _serialize_player(p) for pid, p in game_world.players.items()},
|
||||||
"monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()},
|
"monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()},
|
||||||
"boss": game_world.boss.to_dict() if game_world.boss else None,
|
"boss": game_world.boss.to_dict() if game_world.boss else None,
|
||||||
"structures": {sid: s.to_dict() for sid, s in game_world.structures.items()},
|
"structures": {sid: s.to_dict() for sid, s in game_world.structures.items()},
|
||||||
|
|||||||
+434
-58
@@ -181,6 +181,18 @@
|
|||||||
background: #44ff44;
|
background: #44ff44;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-bar-fill.exp {
|
||||||
|
background: #f1c40f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-value { transition: color 0.15s; }
|
||||||
|
.inv-value.flash { color: #66ff88 !important; }
|
||||||
|
|
||||||
|
@keyframes inv-flash {
|
||||||
|
0% { color: #66ff88; }
|
||||||
|
100% { color: inherit; }
|
||||||
|
}
|
||||||
|
|
||||||
.inventory {
|
.inventory {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
border-top: 1px solid #667eea;
|
border-top: 1px solid #667eea;
|
||||||
@@ -385,6 +397,9 @@
|
|||||||
<span class="stat-label">Experience:</span>
|
<span class="stat-label">Experience:</span>
|
||||||
<span class="stat-value" id="statExp">0</span>
|
<span class="stat-value" id="statExp">0</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-bar">
|
||||||
|
<div class="stat-bar-fill exp" id="expBar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Health:</span>
|
<span class="stat-label">Health:</span>
|
||||||
<span class="stat-value" id="statHealth">100/100</span>
|
<span class="stat-value" id="statHealth">100/100</span>
|
||||||
@@ -416,16 +431,22 @@
|
|||||||
<span class="stat-value" id="statMove">5</span>
|
<span class="stat-value" id="statMove">5</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top: 8px; border-top: 1px solid #667eea; padding-top: 8px;">
|
||||||
|
<div style="color: #667eea; font-weight: bold; margin-bottom: 6px;">Nearby Buffs</div>
|
||||||
|
<div id="activeBuffs" style="font-size:0.8em; color:#bbb; line-height:1.25;">No nearby buffs</div>
|
||||||
|
</div>
|
||||||
<div class="inventory">
|
<div class="inventory">
|
||||||
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Inventory</div>
|
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Inventory</div>
|
||||||
<div class="inventory-item">
|
<div class="inventory-item">
|
||||||
<span>Wood:</span>
|
<span>🪵 Wood:</span>
|
||||||
<span id="invWood">0</span>
|
<span id="invWood" class="inv-value">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="inventory-item">
|
<div class="inventory-item">
|
||||||
<span>Stone:</span>
|
<span>🪨 Stone:</span>
|
||||||
<span id="invStone">0</span>
|
<span id="invStone" class="inv-value">0</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="color: #667eea; font-weight: bold; margin: 10px 0 6px;">Equipment</div>
|
||||||
|
<div id="weaponSlots" style="font-size:0.82em; color:#ccc;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -489,14 +510,13 @@
|
|||||||
<button type="button" class="action-button" id="attackBtn" title="Attack nearest selected enemy target (cost: 1 AP on success)">⚔️ Attack</button>
|
<button type="button" class="action-button" id="attackBtn" title="Attack nearest selected enemy target (cost: 1 AP on success)">⚔️ Attack</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-group" style="margin-top: 10px;">
|
<div class="button-group" style="margin-top: 10px;">
|
||||||
<button type="button" class="action-button" id="buildHouseBtn" title="Build House (20 wood, 10 stone)">🏠 House</button>
|
<button type="button" class="action-button" id="buildHouseBtn" title="House (+20 max HP bonus nearby) — costs 20 🪵 + 10 🪨">🏠 House (20w 10s)</button>
|
||||||
<button type="button" class="action-button" id="buildFarmBtn" title="Build Farm (15 wood, 5 stone)">🌾 Farm</button>
|
<button type="button" class="action-button" id="buildFarmBtn" title="Farm (doubles AP regeneration nearby) — costs 15 🪵 + 5 🪨">🌾 Farm (15w 5s)</button>
|
||||||
<button type="button" class="action-button" id="buildTowerBtn" title="Build Guard Tower (30 wood, 20 stone)">🛡️ Tower</button>
|
<button type="button" class="action-button" id="buildTowerBtn" title="Guard Tower (+2 defense bonus nearby) — costs 30 🪵 + 20 🪨">🛡️ Tower (30w 20s)</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-group" style="margin-top: 10px;">
|
<div class="button-group" style="margin-top: 10px;">
|
||||||
<button type="button" class="action-button" id="buildBlockBtn" title="Build Block 1 tile north (cost: 2 wood)">🧱 Block</button>
|
<button type="button" class="action-button" id="buildBlockBtn" title="Place an impassable block 1 tile north — costs 2 🪵">🧱 Block (2w)</button>
|
||||||
<button type="button" class="action-button" id="lootBtn" title="Loot nearest chest in range">🗝 Loot</button>
|
<button type="button" class="action-button" id="lootBtn" title="Loot nearest chest within range 3 — auto-equips weapon">🗝 Loot</button>
|
||||||
<button type="button" class="action-button" id="equipBtn" title="Equip first available weapon slot">⚒ Equip</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="status-message" id="statusMessage">Ready for adventure!</div>
|
<div class="status-message" id="statusMessage">Ready for adventure!</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,6 +542,7 @@
|
|||||||
let ws = null;
|
let ws = null;
|
||||||
let cameraKeyBindingsAttached = false;
|
let cameraKeyBindingsAttached = false;
|
||||||
let cameraAutoFollow = true;
|
let cameraAutoFollow = true;
|
||||||
|
let cameraFollowOnMove = true;
|
||||||
|
|
||||||
const API_BASE = 'http://localhost:8000/api';
|
const API_BASE = 'http://localhost:8000/api';
|
||||||
|
|
||||||
@@ -530,22 +551,94 @@
|
|||||||
document.getElementById('statusMessage').textContent = msg;
|
document.getElementById('statusMessage').textContent = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flashEl(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('flash');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('flash');
|
||||||
|
setTimeout(() => el.classList.remove('flash'), 600);
|
||||||
|
}
|
||||||
|
|
||||||
function updateUI() {
|
function updateUI() {
|
||||||
if (!currentPlayer) return;
|
if (!currentPlayer) return;
|
||||||
|
|
||||||
document.getElementById('statLevel').textContent = currentPlayer.level;
|
document.getElementById('statLevel').textContent = currentPlayer.level;
|
||||||
document.getElementById('statExp').textContent = currentPlayer.exp;
|
document.getElementById('statExp').textContent = currentPlayer.exp;
|
||||||
|
const levelProgress = ((currentPlayer.exp || 0) % 100);
|
||||||
|
document.getElementById('expBar').style.width = `${Math.max(0, Math.min(100, levelProgress))}%`;
|
||||||
document.getElementById('statHealth').textContent = `${currentPlayer.health}/${currentPlayer.max_health}`;
|
document.getElementById('statHealth').textContent = `${currentPlayer.health}/${currentPlayer.max_health}`;
|
||||||
document.getElementById('healthBar').style.width = `${(currentPlayer.health / currentPlayer.max_health) * 100}%`;
|
document.getElementById('healthBar').style.width = `${(currentPlayer.health / currentPlayer.max_health) * 100}%`;
|
||||||
document.getElementById('statActionPoints').textContent = currentPlayer.action_points;
|
document.getElementById('statActionPoints').textContent = currentPlayer.action_points;
|
||||||
document.getElementById('actionBar').style.width = `${(currentPlayer.action_points / currentPlayer.max_action_points) * 100}%`;
|
document.getElementById('actionBar').style.width = `${(currentPlayer.action_points / currentPlayer.max_action_points) * 100}%`;
|
||||||
document.getElementById('statAttack').textContent = currentPlayer.effective_attack ?? currentPlayer.attack;
|
document.getElementById('statAttack').textContent = currentPlayer.effective_attack ?? currentPlayer.attack;
|
||||||
document.getElementById('statRange').textContent = (currentPlayer.attack_range ?? 4).toFixed(1);
|
document.getElementById('statRange').textContent = (currentPlayer.attack_range ?? 4).toFixed(1);
|
||||||
document.getElementById('statDefense').textContent = currentPlayer.defense;
|
const nearbyBonuses = currentPlayer.nearby_bonuses || {};
|
||||||
|
const defenseBonus = Number(nearbyBonuses.defense || 0);
|
||||||
|
const baseDefense = Number(currentPlayer.base_defense ?? currentPlayer.defense ?? 0);
|
||||||
|
const effectiveDefense = Number(currentPlayer.defense ?? baseDefense);
|
||||||
|
document.getElementById('statDefense').textContent = defenseBonus > 0
|
||||||
|
? `${baseDefense} (+${defenseBonus}) = ${effectiveDefense}`
|
||||||
|
: effectiveDefense;
|
||||||
document.getElementById('statMove').textContent = currentPlayer.movement_capacity;
|
document.getElementById('statMove').textContent = currentPlayer.movement_capacity;
|
||||||
|
|
||||||
document.getElementById('invWood').textContent = currentPlayer.inventory.wood || 0;
|
const buffRows = [];
|
||||||
document.getElementById('invStone').textContent = currentPlayer.inventory.stone || 0;
|
const actionRegen = Number(nearbyBonuses.action_regen || 0);
|
||||||
|
const healthBonus = Number(nearbyBonuses.max_health || 0);
|
||||||
|
const hpRegenBonus = Number(nearbyBonuses.hp_regen || 0);
|
||||||
|
const freeMoveChance = Number(nearbyBonuses.free_move_chance || 0);
|
||||||
|
if (actionRegen > 0) buffRows.push(`⚡ Nearby aura: +${actionRegen} AP per regen tick`);
|
||||||
|
if (defenseBonus > 0) buffRows.push(`🛡️ Nearby aura: +${defenseBonus} defense`);
|
||||||
|
if (healthBonus > 0) buffRows.push(`❤️ Nearby aura: +${healthBonus} max HP`);
|
||||||
|
if (hpRegenBonus > 0) buffRows.push(`💚 Nearby aura: +${hpRegenBonus} HP per regen tick`);
|
||||||
|
if (freeMoveChance > 0) buffRows.push(`👢 Nearby aura: ${Math.round(freeMoveChance * 100)}% chance moves cost 0 AP`);
|
||||||
|
document.getElementById('activeBuffs').innerHTML = buffRows.length > 0
|
||||||
|
? buffRows.join('<br>')
|
||||||
|
: '<span style="color:#555">No nearby buffs</span>';
|
||||||
|
|
||||||
|
const newWood = currentPlayer.inventory.wood || 0;
|
||||||
|
const newStone = currentPlayer.inventory.stone || 0;
|
||||||
|
const prevWood = parseInt(document.getElementById('invWood').textContent) || 0;
|
||||||
|
const prevStone = parseInt(document.getElementById('invStone').textContent) || 0;
|
||||||
|
document.getElementById('invWood').textContent = newWood;
|
||||||
|
document.getElementById('invStone').textContent = newStone;
|
||||||
|
if (newWood !== prevWood) flashEl('invWood');
|
||||||
|
if (newStone !== prevStone) flashEl('invStone');
|
||||||
|
|
||||||
|
// Weapon slots
|
||||||
|
const slots = currentPlayer.weapon_slots || [];
|
||||||
|
const equippedIdx = currentPlayer.equipped_weapon_slot ?? -1;
|
||||||
|
const slotEl = document.getElementById('weaponSlots');
|
||||||
|
if (slotEl) {
|
||||||
|
const filled = slots.filter(Boolean);
|
||||||
|
if (filled.length === 0) {
|
||||||
|
slotEl.innerHTML = '<span style="color:#555">No weapons</span>';
|
||||||
|
} else {
|
||||||
|
slotEl.innerHTML = slots.map((w, i) => {
|
||||||
|
if (!w) return '';
|
||||||
|
const eq = i === equippedIdx;
|
||||||
|
const border = eq ? 'border:1px solid #f1c40f' : 'border:1px solid #444';
|
||||||
|
const bg = eq ? 'background:#2a2600' : 'background:#1e1e1e';
|
||||||
|
const icon = eq ? '⚔️ ' : '· ';
|
||||||
|
const aura = w.aura_bonuses || {};
|
||||||
|
const auraParts = [];
|
||||||
|
if (aura.action_regen) auraParts.push(`+${aura.action_regen} AP regen`);
|
||||||
|
if (aura.hp_regen) auraParts.push(`+${aura.hp_regen} HP regen`);
|
||||||
|
if (aura.defense) auraParts.push(`+${aura.defense} def`);
|
||||||
|
if (aura.max_health) auraParts.push(`+${aura.max_health} max HP`);
|
||||||
|
if (aura.free_move_chance) auraParts.push(`${Math.round(aura.free_move_chance * 100)}% free moves`);
|
||||||
|
const auraLine = auraParts.length > 0
|
||||||
|
? `<div style="color:#7fd6ff; margin-top:2px;">Aura (${w.aura_radius || 0}): ${auraParts.join(', ')}</div>`
|
||||||
|
: '';
|
||||||
|
return `<div data-slot="${i}" style="padding:3px 6px;margin:2px 0;border-radius:4px;${border};${bg};cursor:pointer;"
|
||||||
|
title="Click to equip">
|
||||||
|
${icon}<b>${w.name}</b>
|
||||||
|
<span style="color:#aaa"> +${w.attack_bonus}atk +${w.range_bonus}rng</span>
|
||||||
|
${auraLine}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (gameState) {
|
if (gameState) {
|
||||||
const activePlayerCount = Object.values(gameState.players).filter(p => p.active).length;
|
const activePlayerCount = Object.values(gameState.players).filter(p => p.active).length;
|
||||||
@@ -568,6 +661,8 @@
|
|||||||
const structureObjects = {};
|
const structureObjects = {};
|
||||||
const resourceObjects = {};
|
const resourceObjects = {};
|
||||||
const chestObjects = {};
|
const chestObjects = {};
|
||||||
|
const worldEffects = [];
|
||||||
|
let previousFrameTime = performance.now();
|
||||||
|
|
||||||
function initScene() {
|
function initScene() {
|
||||||
const canvas = document.getElementById('gameCanvas');
|
const canvas = document.getElementById('gameCanvas');
|
||||||
@@ -619,7 +714,7 @@
|
|||||||
scene.add(directionalLight);
|
scene.add(directionalLight);
|
||||||
|
|
||||||
// Ground plane
|
// Ground plane
|
||||||
const groundGeometry = new THREE.PlaneGeometry(500, 500);
|
const groundGeometry = new THREE.PlaneGeometry(200, 200);
|
||||||
const groundMaterial = new THREE.MeshLambertMaterial({ color: 0x1a3a1a });
|
const groundMaterial = new THREE.MeshLambertMaterial({ color: 0x1a3a1a });
|
||||||
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
||||||
ground.rotation.x = -Math.PI / 2;
|
ground.rotation.x = -Math.PI / 2;
|
||||||
@@ -627,7 +722,7 @@
|
|||||||
scene.add(ground);
|
scene.add(ground);
|
||||||
|
|
||||||
// Grid helper: smaller cells so 1-tile movement matches visible grid steps.
|
// Grid helper: smaller cells so 1-tile movement matches visible grid steps.
|
||||||
const gridHelper = new THREE.GridHelper(500, 500, 0x4a5d66, 0x2b3b42);
|
const gridHelper = new THREE.GridHelper(200, 200, 0x4a5d66, 0x2b3b42);
|
||||||
gridHelper.position.y = 0.1;
|
gridHelper.position.y = 0.1;
|
||||||
scene.add(gridHelper);
|
scene.add(gridHelper);
|
||||||
|
|
||||||
@@ -730,21 +825,28 @@
|
|||||||
sphere.receiveShadow = true;
|
sphere.receiveShadow = true;
|
||||||
scene.add(sphere);
|
scene.add(sphere);
|
||||||
|
|
||||||
// Add label
|
// Compact nameplate — canvas is exactly as tall as the pill so no
|
||||||
const canvas = document.createElement('canvas');
|
// transparent headroom inflates the visible sprite quad.
|
||||||
const ctx = canvas.getContext('2d');
|
const nameCvs = document.createElement('canvas');
|
||||||
canvas.width = 256;
|
const nameCtx = nameCvs.getContext('2d');
|
||||||
canvas.height = 128;
|
nameCvs.width = 256;
|
||||||
ctx.fillStyle = 'white';
|
nameCvs.height = 44; // just enough for one line of 28px text
|
||||||
ctx.font = '32px Arial';
|
nameCtx.font = 'bold 28px Arial';
|
||||||
ctx.textAlign = 'center';
|
const textWidth = nameCtx.measureText(player.username).width;
|
||||||
ctx.textBaseline = 'middle';
|
const bgWidth = Math.min(nameCvs.width, textWidth + 22);
|
||||||
ctx.fillText(player.username, 128, 64);
|
const bgX = (nameCvs.width - bgWidth) / 2;
|
||||||
|
drawRoundedRect(nameCtx, bgX, 0, bgWidth, nameCvs.height, 8, 'rgba(0,0,0,0.50)');
|
||||||
|
nameCtx.fillStyle = '#ffffff';
|
||||||
|
nameCtx.textAlign = 'center';
|
||||||
|
nameCtx.textBaseline = 'middle';
|
||||||
|
nameCtx.fillText(player.username, nameCvs.width / 2, nameCvs.height / 2);
|
||||||
|
|
||||||
const texture = new THREE.CanvasTexture(canvas);
|
// Scale sprite so world-height matches canvas aspect: width=3.2, height=3.2*(44/256)≈0.55
|
||||||
const spriteMaterial = new THREE.SpriteMaterial({ map: texture });
|
const texture = new THREE.CanvasTexture(nameCvs);
|
||||||
|
const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false });
|
||||||
const sprite = new THREE.Sprite(spriteMaterial);
|
const sprite = new THREE.Sprite(spriteMaterial);
|
||||||
sprite.scale.set(4, 2, 1);
|
const spriteW = 3.2;
|
||||||
|
sprite.scale.set(spriteW, spriteW * (nameCvs.height / nameCvs.width), 1);
|
||||||
sprite.position.set(player.position.x, 2.5, player.position.y);
|
sprite.position.set(player.position.x, 2.5, player.position.y);
|
||||||
scene.add(sprite);
|
scene.add(sprite);
|
||||||
|
|
||||||
@@ -753,10 +855,29 @@
|
|||||||
const northArrow = new THREE.Mesh(northGeo, northMat);
|
const northArrow = new THREE.Mesh(northGeo, northMat);
|
||||||
// Rotate so the tip points north (-Z direction in Three.js world space).
|
// Rotate so the tip points north (-Z direction in Three.js world space).
|
||||||
northArrow.rotation.x = -Math.PI / 2;
|
northArrow.rotation.x = -Math.PI / 2;
|
||||||
northArrow.position.set(player.position.x, 0.9, player.position.y - 0.8);
|
northArrow.position.set(player.position.x, 0.5, player.position.y - 1.1);
|
||||||
scene.add(northArrow);
|
scene.add(northArrow);
|
||||||
|
|
||||||
playerSpheres[player.id] = { mesh: sphere, sprite, northArrow };
|
playerSpheres[player.id] = { mesh: sphere, sprite, northArrow };
|
||||||
|
setPlayerVisualState(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayerVisualState(player) {
|
||||||
|
const sphereObj = playerSpheres[player.id];
|
||||||
|
if (!sphereObj) return;
|
||||||
|
const isActive = player.active !== false;
|
||||||
|
const opacity = isActive ? 1.0 : 0.35;
|
||||||
|
|
||||||
|
sphereObj.mesh.material.transparent = !isActive;
|
||||||
|
sphereObj.mesh.material.opacity = opacity;
|
||||||
|
|
||||||
|
sphereObj.sprite.material.transparent = !isActive;
|
||||||
|
sphereObj.sprite.material.opacity = opacity;
|
||||||
|
|
||||||
|
if (sphereObj.northArrow) {
|
||||||
|
sphereObj.northArrow.material.transparent = !isActive;
|
||||||
|
sphereObj.northArrow.material.opacity = opacity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePlayerSphere(player) {
|
function updatePlayerSphere(player) {
|
||||||
@@ -764,9 +885,85 @@
|
|||||||
playerSpheres[player.id].mesh.position.set(player.position.x, 0.5, player.position.y);
|
playerSpheres[player.id].mesh.position.set(player.position.x, 0.5, player.position.y);
|
||||||
playerSpheres[player.id].sprite.position.set(player.position.x, 2.5, player.position.y);
|
playerSpheres[player.id].sprite.position.set(player.position.x, 2.5, player.position.y);
|
||||||
if (playerSpheres[player.id].northArrow) {
|
if (playerSpheres[player.id].northArrow) {
|
||||||
playerSpheres[player.id].northArrow.position.set(player.position.x, 0.9, player.position.y - 0.8);
|
playerSpheres[player.id].northArrow.position.set(player.position.x, 0.5, player.position.y - 1.1);
|
||||||
|
}
|
||||||
|
setPlayerVisualState(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTextSprite(initialText, textColor = '#ffffff', bgColor = 'rgba(0, 0, 0, 0.7)') {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 256;
|
||||||
|
canvas.height = 96;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false });
|
||||||
|
const sprite = new THREE.Sprite(material);
|
||||||
|
sprite.scale.set(3.5, 1.3, 1);
|
||||||
|
|
||||||
|
const spriteObj = { sprite, canvas, ctx, texture, textColor, bgColor };
|
||||||
|
updateTextSprite(spriteObj, initialText);
|
||||||
|
return spriteObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRoundedRect(ctx, x, y, width, height, radius, fillStyle) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = fillStyle;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgressBarSprite() {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 200;
|
||||||
|
canvas.height = 40;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false });
|
||||||
|
const sprite = new THREE.Sprite(material);
|
||||||
|
sprite.scale.set(2.2, 0.35, 1);
|
||||||
|
|
||||||
|
return { sprite, canvas, ctx, texture };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgressBarSprite(barObj, ratio, fillColor = '#ff5555') {
|
||||||
|
const { canvas, ctx, texture } = barObj;
|
||||||
|
const clampedRatio = Math.max(0, Math.min(1, ratio));
|
||||||
|
const pad = 4;
|
||||||
|
const trackX = 12;
|
||||||
|
const trackY = 10;
|
||||||
|
const trackW = canvas.width - 24;
|
||||||
|
const trackH = 20;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
drawRoundedRect(ctx, trackX - pad, trackY - pad, trackW + pad * 2, trackH + pad * 2, 8, 'rgba(0, 0, 0, 0.65)');
|
||||||
|
drawRoundedRect(ctx, trackX, trackY, trackW, trackH, 6, '#333333');
|
||||||
|
drawRoundedRect(ctx, trackX, trackY, trackW * clampedRatio, trackH, 6, fillColor);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTextSprite(spriteObj, text) {
|
||||||
|
const { canvas, ctx, texture, textColor, bgColor } = spriteObj;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.font = 'bold 30px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
|
||||||
|
texture.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMonsterCube(monster) {
|
function createMonsterCube(monster) {
|
||||||
@@ -776,7 +973,70 @@
|
|||||||
cube.position.set(monster.position.x, 0.3, monster.position.y);
|
cube.position.set(monster.position.x, 0.3, monster.position.y);
|
||||||
cube.castShadow = true;
|
cube.castShadow = true;
|
||||||
scene.add(cube);
|
scene.add(cube);
|
||||||
monsterCubes[monster.id] = cube;
|
|
||||||
|
const label = createTextSprite(`Lv ${monster.level} HP ${monster.health}/${monster.max_health}`, '#ffdede');
|
||||||
|
label.sprite.position.set(monster.position.x, 1.25, monster.position.y);
|
||||||
|
scene.add(label.sprite);
|
||||||
|
|
||||||
|
const hpBar = createProgressBarSprite();
|
||||||
|
hpBar.sprite.position.set(monster.position.x, 0.95, monster.position.y);
|
||||||
|
updateProgressBarSprite(hpBar, monster.max_health > 0 ? monster.health / monster.max_health : 0);
|
||||||
|
scene.add(hpBar.sprite);
|
||||||
|
|
||||||
|
monsterCubes[monster.id] = { mesh: cube, label, hpBar };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnFloatingText(text, worldX, worldZ, color = '#ffffff') {
|
||||||
|
const textObj = createTextSprite(text, color, 'rgba(0, 0, 0, 0.55)');
|
||||||
|
textObj.sprite.scale.set(2.5, 0.95, 1);
|
||||||
|
textObj.sprite.position.set(worldX, 1.4, worldZ);
|
||||||
|
scene.add(textObj.sprite);
|
||||||
|
worldEffects.push({
|
||||||
|
type: 'text',
|
||||||
|
object: textObj.sprite,
|
||||||
|
life: 0.9,
|
||||||
|
maxLife: 0.9,
|
||||||
|
velocityY: 1.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnGroundPulse(worldX, worldZ, color = 0xffffff) {
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(0.22, 0.34, 24),
|
||||||
|
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9, side: THREE.DoubleSide })
|
||||||
|
);
|
||||||
|
ring.rotation.x = -Math.PI / 2;
|
||||||
|
ring.position.set(worldX, 0.06, worldZ);
|
||||||
|
scene.add(ring);
|
||||||
|
worldEffects.push({
|
||||||
|
type: 'pulse',
|
||||||
|
object: ring,
|
||||||
|
life: 0.45,
|
||||||
|
maxLife: 0.45,
|
||||||
|
growth: 2.2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWorldEffects(deltaTime) {
|
||||||
|
for (let i = worldEffects.length - 1; i >= 0; i--) {
|
||||||
|
const fx = worldEffects[i];
|
||||||
|
fx.life -= deltaTime;
|
||||||
|
if (fx.life <= 0) {
|
||||||
|
scene.remove(fx.object);
|
||||||
|
worldEffects.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = fx.life / fx.maxLife;
|
||||||
|
if (fx.type === 'text') {
|
||||||
|
fx.object.position.y += fx.velocityY * deltaTime;
|
||||||
|
fx.object.material.opacity = t;
|
||||||
|
} else if (fx.type === 'pulse') {
|
||||||
|
const scale = 1 + (1 - t) * fx.growth;
|
||||||
|
fx.object.scale.set(scale, scale, scale);
|
||||||
|
fx.object.material.opacity = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBossMesh(monster) {
|
function createBossMesh(monster) {
|
||||||
@@ -833,15 +1093,39 @@
|
|||||||
|
|
||||||
function createResourceMesh(resource) {
|
function createResourceMesh(resource) {
|
||||||
const isTree = resource.resource_type === 'tree';
|
const isTree = resource.resource_type === 'tree';
|
||||||
const geometry = isTree
|
|
||||||
? new THREE.ConeGeometry(0.45, 1.1, 8)
|
|
||||||
: new THREE.BoxGeometry(0.9, 0.75, 0.9);
|
|
||||||
const material = new THREE.MeshPhongMaterial({
|
|
||||||
color: isTree ? 0x2e8b57 : 0x6f7b86,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
let mesh;
|
||||||
mesh.position.set(resource.position.x, isTree ? 0.55 : 0.38, resource.position.y);
|
if (isTree) {
|
||||||
|
// Trunk
|
||||||
|
const trunkGeo = new THREE.CylinderGeometry(0.1, 0.14, 0.55, 7);
|
||||||
|
const trunkMat = new THREE.MeshPhongMaterial({ color: 0x8b5e3c });
|
||||||
|
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
|
||||||
|
trunk.position.y = 0.28;
|
||||||
|
trunk.castShadow = true;
|
||||||
|
|
||||||
|
// Canopy
|
||||||
|
const canopyGeo = new THREE.ConeGeometry(0.52, 1.2, 8);
|
||||||
|
const canopyMat = new THREE.MeshPhongMaterial({ color: 0x2e8b57 });
|
||||||
|
const canopy = new THREE.Mesh(canopyGeo, canopyMat);
|
||||||
|
canopy.position.y = 1.15; // sits on top of trunk
|
||||||
|
canopy.castShadow = true;
|
||||||
|
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.add(trunk);
|
||||||
|
group.add(canopy);
|
||||||
|
group.position.set(resource.position.x, 0, resource.position.y);
|
||||||
|
group.receiveShadow = true;
|
||||||
|
scene.add(group);
|
||||||
|
resourceObjects[resource.id] = group;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mountain / rock
|
||||||
|
const geometry = new THREE.DodecahedronGeometry(0.52, 0);
|
||||||
|
const material = new THREE.MeshPhongMaterial({ color: 0x6f7b86 });
|
||||||
|
mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.position.set(resource.position.x, 0.52, resource.position.y);
|
||||||
|
mesh.rotation.set(Math.random() * 0.4, Math.random() * Math.PI, Math.random() * 0.4);
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
mesh.receiveShadow = true;
|
mesh.receiveShadow = true;
|
||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
@@ -875,14 +1159,26 @@
|
|||||||
if (!monsterCubes[monster.id]) {
|
if (!monsterCubes[monster.id]) {
|
||||||
createMonsterCube(monster);
|
createMonsterCube(monster);
|
||||||
} else {
|
} else {
|
||||||
monsterCubes[monster.id].position.set(monster.position.x, 0.3, monster.position.y);
|
monsterCubes[monster.id].mesh.position.set(monster.position.x, 0.3, monster.position.y);
|
||||||
|
monsterCubes[monster.id].label.sprite.position.set(monster.position.x, 1.25, monster.position.y);
|
||||||
|
monsterCubes[monster.id].hpBar.sprite.position.set(monster.position.x, 0.95, monster.position.y);
|
||||||
|
updateTextSprite(
|
||||||
|
monsterCubes[monster.id].label,
|
||||||
|
`Lv ${monster.level} HP ${Math.max(0, monster.health)}/${monster.max_health}`
|
||||||
|
);
|
||||||
|
updateProgressBarSprite(
|
||||||
|
monsterCubes[monster.id].hpBar,
|
||||||
|
monster.max_health > 0 ? monster.health / monster.max_health : 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deleted monsters
|
// Remove deleted monsters
|
||||||
for (const mid in monsterCubes) {
|
for (const mid in monsterCubes) {
|
||||||
if (!(gameState.monsters || {})[mid]) {
|
if (!(gameState.monsters || {})[mid]) {
|
||||||
scene.remove(monsterCubes[mid]);
|
scene.remove(monsterCubes[mid].mesh);
|
||||||
|
scene.remove(monsterCubes[mid].label.sprite);
|
||||||
|
scene.remove(monsterCubes[mid].hpBar.sprite);
|
||||||
delete monsterCubes[mid];
|
delete monsterCubes[mid];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -919,7 +1215,15 @@
|
|||||||
|
|
||||||
const chests = gameState.chests || {};
|
const chests = gameState.chests || {};
|
||||||
for (const chest of Object.values(chests)) {
|
for (const chest of Object.values(chests)) {
|
||||||
if (!chestObjects[chest.id]) createChestMesh(chest);
|
if (chest.opened) {
|
||||||
|
// Remove from scene as soon as it's marked opened
|
||||||
|
if (chestObjects[chest.id]) {
|
||||||
|
scene.remove(chestObjects[chest.id]);
|
||||||
|
delete chestObjects[chest.id];
|
||||||
|
}
|
||||||
|
} else if (!chestObjects[chest.id]) {
|
||||||
|
createChestMesh(chest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const cid in chestObjects) {
|
for (const cid in chestObjects) {
|
||||||
if (!chests[cid]) {
|
if (!chests[cid]) {
|
||||||
@@ -935,11 +1239,28 @@
|
|||||||
|
|
||||||
function animate() {
|
function animate() {
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
|
const now = performance.now();
|
||||||
|
const deltaTime = Math.min((now - previousFrameTime) / 1000, 0.05);
|
||||||
|
previousFrameTime = now;
|
||||||
updateGameScene();
|
updateGameScene();
|
||||||
|
updateWorldEffects(deltaTime);
|
||||||
if (controls) controls.update();
|
if (controls) controls.update();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function followCameraByPlayerDelta(previousPosition, nextPosition) {
|
||||||
|
if (!camera || !controls || !cameraFollowOnMove || !previousPosition || !nextPosition) return;
|
||||||
|
const deltaX = (nextPosition.x ?? 0) - (previousPosition.x ?? 0);
|
||||||
|
const deltaZ = (nextPosition.y ?? 0) - (previousPosition.y ?? 0);
|
||||||
|
if (Math.abs(deltaX) < 0.0001 && Math.abs(deltaZ) < 0.0001) return;
|
||||||
|
|
||||||
|
controls.target.x += deltaX;
|
||||||
|
controls.target.z += deltaZ;
|
||||||
|
camera.position.x += deltaX;
|
||||||
|
camera.position.z += deltaZ;
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== API FUNCTIONS ====================
|
// ==================== API FUNCTIONS ====================
|
||||||
async function login(username, color) {
|
async function login(username, color) {
|
||||||
try {
|
try {
|
||||||
@@ -976,8 +1297,12 @@
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
if (data.type === 'state_update') {
|
if (data.type === 'state_update') {
|
||||||
|
const previousPosition = currentPlayer ? { ...currentPlayer.position } : null;
|
||||||
gameState = data;
|
gameState = data;
|
||||||
currentPlayer = gameState.players[currentPlayerId] || currentPlayer;
|
currentPlayer = gameState.players[currentPlayerId] || currentPlayer;
|
||||||
|
if (currentPlayer && previousPosition) {
|
||||||
|
followCameraByPlayerDelta(previousPosition, currentPlayer.position);
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('timeRemaining').textContent = formatTime(data.time_remaining);
|
document.getElementById('timeRemaining').textContent = formatTime(data.time_remaining);
|
||||||
|
|
||||||
@@ -1011,6 +1336,7 @@
|
|||||||
|
|
||||||
async function movePlayer(dx, dy) {
|
async function movePlayer(dx, dy) {
|
||||||
try {
|
try {
|
||||||
|
const previousPosition = currentPlayer ? { ...currentPlayer.position } : null;
|
||||||
const response = await fetch(`${API_BASE}/player/${currentPlayerId}/move`, {
|
const response = await fetch(`${API_BASE}/player/${currentPlayerId}/move`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1028,6 +1354,7 @@
|
|||||||
currentPlayer.position = data.position;
|
currentPlayer.position = data.position;
|
||||||
currentPlayer.action_points = data.action_points ?? currentPlayer.action_points;
|
currentPlayer.action_points = data.action_points ?? currentPlayer.action_points;
|
||||||
updatePlayerSphere(currentPlayer);
|
updatePlayerSphere(currentPlayer);
|
||||||
|
followCameraByPlayerDelta(previousPosition, currentPlayer.position);
|
||||||
focusCameraOnPlayer(false);
|
focusCameraOnPlayer(false);
|
||||||
updateUI();
|
updateUI();
|
||||||
}
|
}
|
||||||
@@ -1052,6 +1379,38 @@
|
|||||||
} else {
|
} else {
|
||||||
showMessage(`Action failed: ${data.reason || 'Unknown error'}`);
|
showMessage(`Action failed: ${data.reason || 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.success && currentPlayer) {
|
||||||
|
if (typeof data.player_health === 'number') {
|
||||||
|
currentPlayer.health = data.player_health;
|
||||||
|
}
|
||||||
|
if (data.respawn_position) {
|
||||||
|
currentPlayer.position = data.respawn_position;
|
||||||
|
updatePlayerSphere(currentPlayer);
|
||||||
|
focusCameraOnPlayer(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === 'gather' && data.gathered) {
|
||||||
|
const wood = data.gathered.wood || 0;
|
||||||
|
const stone = data.gathered.stone || 0;
|
||||||
|
const parts = [];
|
||||||
|
if (wood > 0) parts.push(`+${wood} wood`);
|
||||||
|
if (stone > 0) parts.push(`+${stone} stone`);
|
||||||
|
if (parts.length > 0) {
|
||||||
|
spawnGroundPulse(currentPlayer.position.x, currentPlayer.position.y, 0x66ff88);
|
||||||
|
spawnFloatingText(parts.join(' '), currentPlayer.position.x, currentPlayer.position.y, '#9cff9c');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actionType === 'attack') {
|
||||||
|
if (typeof data.damage === 'number') {
|
||||||
|
spawnFloatingText(`-${data.damage} HP`, currentPlayer.position.x, currentPlayer.position.y, '#ff9999');
|
||||||
|
}
|
||||||
|
if (typeof data.retaliation_damage === 'number' && data.retaliation_damage > 0) {
|
||||||
|
spawnGroundPulse(currentPlayer.position.x, currentPlayer.position.y, 0xff6666);
|
||||||
|
spawnFloatingText(`Retaliated: -${data.retaliation_damage}`, currentPlayer.position.x, currentPlayer.position.y, '#ff7777');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Action failed:', error);
|
console.error('Action failed:', error);
|
||||||
@@ -1122,7 +1481,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('attackBtn').onclick = () => {
|
document.getElementById('attackBtn').onclick = async () => {
|
||||||
if (!gameState || !currentPlayer) {
|
if (!gameState || !currentPlayer) {
|
||||||
showMessage('No targets available');
|
showMessage('No targets available');
|
||||||
return;
|
return;
|
||||||
@@ -1150,11 +1509,19 @@
|
|||||||
showMessage(`No target in range (${range.toFixed(1)})`);
|
showMessage(`No target in range (${range.toFixed(1)})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
performAction('attack', nearest.id);
|
spawnGroundPulse(nearest.position.x, nearest.position.y, 0xffaa66);
|
||||||
|
const result = await performAction('attack', nearest.id);
|
||||||
|
if (result && result.success && typeof result.damage === 'number') {
|
||||||
|
spawnFloatingText(`-${result.damage}`, nearest.position.x, nearest.position.y, '#ffb0b0');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('gatherBtn').onclick = () => {
|
document.getElementById('gatherBtn').onclick = async () => {
|
||||||
performAction('gather', null);
|
const data = await performAction('gather', null);
|
||||||
|
if (data && data.success && data.inventory && currentPlayer) {
|
||||||
|
currentPlayer.inventory = data.inventory;
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('buildHouseBtn').onclick = () => {
|
document.getElementById('buildHouseBtn').onclick = () => {
|
||||||
@@ -1176,22 +1543,31 @@
|
|||||||
document.getElementById('lootBtn').onclick = async () => {
|
document.getElementById('lootBtn').onclick = async () => {
|
||||||
const data = await performAction('loot');
|
const data = await performAction('loot');
|
||||||
if (data && data.success && typeof data.slot_index === 'number') {
|
if (data && data.success && typeof data.slot_index === 'number') {
|
||||||
await performAction('equip', null, null, null, null, data.slot_index);
|
if (data.weapon_slots) currentPlayer.weapon_slots = data.weapon_slots;
|
||||||
|
const equipData = await performAction('equip', null, null, null, null, data.slot_index);
|
||||||
|
if (equipData && equipData.success) {
|
||||||
|
currentPlayer.equipped_weapon_slot = equipData.equipped_weapon_slot;
|
||||||
|
showMessage(`Equipped: ${data.weapon ? data.weapon.name : 'weapon'}!`);
|
||||||
|
}
|
||||||
|
updateUI();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('equipBtn').onclick = () => {
|
// Click a weapon in the inventory panel to equip it (no AP cost).
|
||||||
if (!currentPlayer || !Array.isArray(currentPlayer.weapon_slots)) {
|
document.getElementById('weaponSlots').addEventListener('click', async (e) => {
|
||||||
showMessage('No weapon slots available');
|
const targetEl = e.target instanceof Element ? e.target : e.target?.parentElement;
|
||||||
return;
|
if (!targetEl) return;
|
||||||
|
const div = targetEl.closest('[data-slot]');
|
||||||
|
if (!div) return;
|
||||||
|
const slotIndex = parseInt(div.dataset.slot);
|
||||||
|
if (Number.isNaN(slotIndex)) return;
|
||||||
|
const equipData = await performAction('equip', null, null, null, null, slotIndex);
|
||||||
|
if (equipData && equipData.success) {
|
||||||
|
currentPlayer.equipped_weapon_slot = equipData.equipped_weapon_slot;
|
||||||
|
showMessage(`Equipped: ${currentPlayer.weapon_slots[slotIndex]?.name}`);
|
||||||
|
updateUI();
|
||||||
}
|
}
|
||||||
const slot = currentPlayer.weapon_slots.findIndex(w => w);
|
});
|
||||||
if (slot < 0) {
|
|
||||||
showMessage('No weapon to equip');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
performAction('equip', null, null, null, null, slot);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== LOGIN FORM ====================
|
// ==================== LOGIN FORM ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user