feat: progress
This commit is contained in:
+199
-23
@@ -24,6 +24,14 @@ 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
|
||||||
|
CHEST_SPAWN_RATE = 0.015
|
||||||
|
MAX_CHESTS = 15
|
||||||
|
CHEST_LOOT_RANGE = 3
|
||||||
|
ATTACK_RANGE_BASE = 4
|
||||||
|
WEAPON_SLOT_COUNT = 5
|
||||||
|
BLOCK_BUILD_COST_WOOD = 2
|
||||||
|
BLOCK_HEALTH = 5
|
||||||
|
|
||||||
# Data Models
|
# Data Models
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -57,10 +65,18 @@ class Player:
|
|||||||
defense: int = 2
|
defense: int = 2
|
||||||
movement_capacity: int = 1
|
movement_capacity: int = 1
|
||||||
gathering_capacity: int = 10
|
gathering_capacity: int = 10
|
||||||
|
attack_range: float = ATTACK_RANGE_BASE
|
||||||
|
weapon_slots: List[Dict] = field(default_factory=lambda: [None] * WEAPON_SLOT_COUNT)
|
||||||
|
equipped_weapon_slot: int = -1
|
||||||
active: bool = True
|
active: bool = True
|
||||||
session_id: str = ""
|
session_id: str = ""
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
|
equipped_weapon = None
|
||||||
|
if 0 <= self.equipped_weapon_slot < len(self.weapon_slots):
|
||||||
|
equipped_weapon = self.weapon_slots[self.equipped_weapon_slot]
|
||||||
|
attack_bonus = equipped_weapon.get("attack_bonus", 0) if equipped_weapon else 0
|
||||||
|
range_bonus = equipped_weapon.get("range_bonus", 0) if equipped_weapon else 0
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
@@ -74,9 +90,13 @@ class Player:
|
|||||||
"max_action_points": self.max_action_points,
|
"max_action_points": self.max_action_points,
|
||||||
"inventory": self.inventory,
|
"inventory": self.inventory,
|
||||||
"attack": self.attack,
|
"attack": self.attack,
|
||||||
|
"effective_attack": self.attack + attack_bonus,
|
||||||
|
"attack_range": self.attack_range + range_bonus,
|
||||||
"defense": self.defense,
|
"defense": self.defense,
|
||||||
"movement_capacity": self.movement_capacity,
|
"movement_capacity": self.movement_capacity,
|
||||||
"gathering_capacity": self.gathering_capacity,
|
"gathering_capacity": self.gathering_capacity,
|
||||||
|
"weapon_slots": self.weapon_slots,
|
||||||
|
"equipped_weapon_slot": self.equipped_weapon_slot,
|
||||||
"active": self.active,
|
"active": self.active,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +108,7 @@ class Monster:
|
|||||||
max_health: int
|
max_health: int
|
||||||
level: int
|
level: int
|
||||||
attack: int
|
attack: int
|
||||||
|
defense: int = 1
|
||||||
is_boss: bool = False
|
is_boss: bool = False
|
||||||
boss_progress: float = 0.0 # Percentage of damage done
|
boss_progress: float = 0.0 # Percentage of damage done
|
||||||
|
|
||||||
@@ -99,10 +120,28 @@ class Monster:
|
|||||||
"max_health": self.max_health,
|
"max_health": self.max_health,
|
||||||
"level": self.level,
|
"level": self.level,
|
||||||
"attack": self.attack,
|
"attack": self.attack,
|
||||||
|
"defense": self.defense,
|
||||||
"is_boss": self.is_boss,
|
"is_boss": self.is_boss,
|
||||||
"boss_progress": self.boss_progress,
|
"boss_progress": self.boss_progress,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chest:
|
||||||
|
id: str
|
||||||
|
position: Position
|
||||||
|
opened: bool = False
|
||||||
|
rarity: str = "rare"
|
||||||
|
weapon: Dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"position": self.position.to_dict(),
|
||||||
|
"opened": self.opened,
|
||||||
|
"rarity": self.rarity,
|
||||||
|
"weapon": self.weapon,
|
||||||
|
}
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Structure:
|
class Structure:
|
||||||
id: str
|
id: str
|
||||||
@@ -143,16 +182,18 @@ class GameWorld:
|
|||||||
self.monsters: Dict[str, Monster] = {}
|
self.monsters: Dict[str, Monster] = {}
|
||||||
self.structures: Dict[str, Structure] = {}
|
self.structures: Dict[str, Structure] = {}
|
||||||
self.resources: Dict[str, Resource] = {}
|
self.resources: Dict[str, Resource] = {}
|
||||||
|
self.chests: Dict[str, Chest] = {}
|
||||||
self.boss: Monster = None
|
self.boss: Monster = None
|
||||||
self.game_start_time: float = 0
|
self.game_start_time: float = 0
|
||||||
self.is_game_active: bool = False
|
self.is_game_active: bool = False
|
||||||
self.connected_sessions: Dict[str, str] = {} # session_id -> player_id
|
self.connected_sessions: Dict[str, str] = {} # session_id -> player_id
|
||||||
self.generation = 0
|
self.generation = 0
|
||||||
self._init_resources()
|
self._init_resources()
|
||||||
|
self._init_chests()
|
||||||
|
|
||||||
def _init_resources(self):
|
def _init_resources(self):
|
||||||
"""Initialize static resources on the map"""
|
"""Initialize static resources on the map"""
|
||||||
for _ in range(50):
|
for _ in range(RESOURCE_NODE_COUNT):
|
||||||
resource_id = f"resource_{len(self.resources)}"
|
resource_id = f"resource_{len(self.resources)}"
|
||||||
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||||
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||||
@@ -164,12 +205,37 @@ class GameWorld:
|
|||||||
amount=random.randint(50, 200),
|
amount=random.randint(50, 200),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _init_chests(self):
|
||||||
|
"""Spawn rare chests with weapon loot."""
|
||||||
|
for _ in range(8):
|
||||||
|
self._spawn_chest()
|
||||||
|
|
||||||
|
def _spawn_chest(self):
|
||||||
|
chest_id = f"chest_{len(self.chests)}_{self.generation}_{random.randint(100, 999)}"
|
||||||
|
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||||
|
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||||
|
weapon_templates = [
|
||||||
|
{"name": "Long Spear", "attack_bonus": 2, "range_bonus": 2},
|
||||||
|
{"name": "Hunting Bow", "attack_bonus": 1, "range_bonus": 3},
|
||||||
|
{"name": "Battle Axe", "attack_bonus": 3, "range_bonus": 1},
|
||||||
|
]
|
||||||
|
self.chests[chest_id] = Chest(
|
||||||
|
id=chest_id,
|
||||||
|
position=Position(x, y, 0),
|
||||||
|
weapon=random.choice(weapon_templates),
|
||||||
|
)
|
||||||
|
|
||||||
def start_game(self):
|
def start_game(self):
|
||||||
"""Start or restart the game"""
|
"""Start or restart the game"""
|
||||||
self.game_start_time = datetime.now().timestamp()
|
self.game_start_time = datetime.now().timestamp()
|
||||||
self.is_game_active = True
|
self.is_game_active = True
|
||||||
self.boss = None
|
self.boss = None
|
||||||
self.monsters.clear()
|
self.monsters.clear()
|
||||||
|
self.chests.clear()
|
||||||
|
self.resources.clear()
|
||||||
|
self.structures.clear()
|
||||||
|
self._init_resources()
|
||||||
|
self._init_chests()
|
||||||
self.generation += 1
|
self.generation += 1
|
||||||
# Keep players but reset their state
|
# Keep players but reset their state
|
||||||
for player in self.players.values():
|
for player in self.players.values():
|
||||||
@@ -180,6 +246,9 @@ class GameWorld:
|
|||||||
player.health = player.max_health
|
player.health = player.max_health
|
||||||
player.action_points = ACTION_POINTS_MAX
|
player.action_points = ACTION_POINTS_MAX
|
||||||
player.inventory = {"wood": 0, "stone": 0}
|
player.inventory = {"wood": 0, "stone": 0}
|
||||||
|
player.weapon_slots = [None] * WEAPON_SLOT_COUNT
|
||||||
|
player.equipped_weapon_slot = -1
|
||||||
|
player.attack_range = ATTACK_RANGE_BASE
|
||||||
player.position = Position(
|
player.position = Position(
|
||||||
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
|
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
|
||||||
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
|
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
|
||||||
@@ -214,6 +283,10 @@ class GameWorld:
|
|||||||
if random.random() < MONSTER_SPAWN_RATE and len(self.monsters) < MAX_MONSTERS:
|
if random.random() < MONSTER_SPAWN_RATE and len(self.monsters) < MAX_MONSTERS:
|
||||||
self._spawn_monster()
|
self._spawn_monster()
|
||||||
|
|
||||||
|
# Spawn rare chests over time
|
||||||
|
if random.random() < CHEST_SPAWN_RATE and len(self.chests) < MAX_CHESTS:
|
||||||
|
self._spawn_chest()
|
||||||
|
|
||||||
# Regenerate player action points
|
# Regenerate player action points
|
||||||
for player in self.players.values():
|
for player in self.players.values():
|
||||||
if not player.active:
|
if not player.active:
|
||||||
@@ -243,6 +316,7 @@ class GameWorld:
|
|||||||
max_health=health,
|
max_health=health,
|
||||||
level=level,
|
level=level,
|
||||||
attack=3 + level,
|
attack=3 + level,
|
||||||
|
defense=1 + level // 2,
|
||||||
is_boss=False,
|
is_boss=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -269,17 +343,34 @@ class GameWorld:
|
|||||||
max_health=health,
|
max_health=health,
|
||||||
level=int(avg_level) + 5,
|
level=int(avg_level) + 5,
|
||||||
attack=15 + int(avg_level),
|
attack=15 + int(avg_level),
|
||||||
|
defense=4 + int(avg_level // 2),
|
||||||
is_boss=True,
|
is_boss=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_nearby_structure_bonuses(self, position: Position, radius: int = 10) -> Dict:
|
def _player_effective_attack(player: Player) -> int:
|
||||||
bonuses = {}
|
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
|
||||||
for structure in self.structures.values():
|
w = player.weapon_slots[player.equipped_weapon_slot]
|
||||||
dist = ((structure.position.x - position.x) ** 2 + (structure.position.y - position.y) ** 2) ** 0.5
|
if w:
|
||||||
if dist <= radius:
|
return player.attack + int(w.get("attack_bonus", 0))
|
||||||
for key, value in structure.bonuses.items():
|
return player.attack
|
||||||
bonuses[key] = bonuses.get(key, 0) + value
|
|
||||||
return bonuses
|
def _player_effective_range(player: Player) -> float:
|
||||||
|
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
|
||||||
|
w = player.weapon_slots[player.equipped_weapon_slot]
|
||||||
|
if w:
|
||||||
|
return player.attack_range + float(w.get("range_bonus", 0))
|
||||||
|
return player.attack_range
|
||||||
|
|
||||||
|
def _get_nearby_structure_bonuses_impl(self, position: Position, radius: int = 10) -> Dict:
|
||||||
|
bonuses = {}
|
||||||
|
for structure in self.structures.values():
|
||||||
|
dist = ((structure.position.x - position.x) ** 2 + (structure.position.y - position.y) ** 2) ** 0.5
|
||||||
|
if dist <= radius:
|
||||||
|
for key, value in structure.bonuses.items():
|
||||||
|
bonuses[key] = bonuses.get(key, 0) + value
|
||||||
|
return bonuses
|
||||||
|
|
||||||
|
GameWorld._get_nearby_structure_bonuses = _get_nearby_structure_bonuses_impl
|
||||||
|
|
||||||
# Use plain dicts instead of Pydantic models
|
# Use plain dicts instead of Pydantic models
|
||||||
|
|
||||||
@@ -377,6 +468,7 @@ async def get_game_state():
|
|||||||
"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()},
|
||||||
"resources": {rid: r.to_dict() for rid, r in game_world.resources.items()},
|
"resources": {rid: r.to_dict() for rid, r in game_world.resources.items()},
|
||||||
|
"chests": {cid: c.to_dict() for cid, c in game_world.chests.items()},
|
||||||
"game_active": game_world.is_game_active,
|
"game_active": game_world.is_game_active,
|
||||||
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
|
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
|
||||||
}
|
}
|
||||||
@@ -403,10 +495,19 @@ async def move_player(player_id: str, request: Request):
|
|||||||
if distance > player.movement_capacity:
|
if distance > player.movement_capacity:
|
||||||
raise HTTPException(status_code=400, detail="Movement exceeds capacity")
|
raise HTTPException(status_code=400, detail="Movement exceeds capacity")
|
||||||
|
|
||||||
player.position.x += dx
|
next_x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x + dx))
|
||||||
player.position.y += dy
|
next_y = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.y + dy))
|
||||||
player.position.x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x))
|
|
||||||
player.position.y = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.y))
|
# Blocks are impassable.
|
||||||
|
for s in game_world.structures.values():
|
||||||
|
if s.structure_type != "block":
|
||||||
|
continue
|
||||||
|
bdist = ((s.position.x - next_x) ** 2 + (s.position.y - next_y) ** 2) ** 0.5
|
||||||
|
if bdist < 0.6:
|
||||||
|
raise HTTPException(status_code=400, detail="Blocked by structure")
|
||||||
|
|
||||||
|
player.position.x = next_x
|
||||||
|
player.position.y = next_y
|
||||||
player.action_points -= 1
|
player.action_points -= 1
|
||||||
|
|
||||||
return {"position": player.position.to_dict(), "action_points": player.action_points}
|
return {"position": player.position.to_dict(), "action_points": player.action_points}
|
||||||
@@ -429,6 +530,10 @@ async def player_action(player_id: str, request: Request):
|
|||||||
result = _handle_gather(player, body.get("target_id"))
|
result = _handle_gather(player, body.get("target_id"))
|
||||||
elif action_type == "build":
|
elif action_type == "build":
|
||||||
result = _handle_build(player, body.get("tx"), body.get("ty"), body.get("structure_type"))
|
result = _handle_build(player, body.get("tx"), body.get("ty"), body.get("structure_type"))
|
||||||
|
elif action_type == "loot":
|
||||||
|
result = _handle_loot(player)
|
||||||
|
elif action_type == "equip":
|
||||||
|
result = _handle_equip(player, body.get("slot_index"))
|
||||||
else:
|
else:
|
||||||
return {"action": action_type, "success": False, "reason": "Unknown action"}
|
return {"action": action_type, "success": False, "reason": "Unknown action"}
|
||||||
|
|
||||||
@@ -440,14 +545,17 @@ async def player_action(player_id: str, request: Request):
|
|||||||
|
|
||||||
def _handle_attack(player: Player, target_id: str) -> Dict:
|
def _handle_attack(player: Player, target_id: str) -> Dict:
|
||||||
"""Handle player attack"""
|
"""Handle player attack"""
|
||||||
|
attack_range = _player_effective_range(player)
|
||||||
|
attack_power = _player_effective_attack(player)
|
||||||
|
|
||||||
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]
|
||||||
dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5
|
dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5
|
||||||
|
|
||||||
if dist > 5:
|
if dist > attack_range:
|
||||||
return {"success": False, "reason": "Target too far"}
|
return {"success": False, "reason": "Target too far"}
|
||||||
|
|
||||||
damage = max(1, player.attack + random.randint(-2, 2) - monster.defense)
|
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
|
||||||
monster.health -= damage
|
monster.health -= damage
|
||||||
|
|
||||||
if monster.health <= 0:
|
if monster.health <= 0:
|
||||||
@@ -462,10 +570,10 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
|||||||
monster = game_world.boss
|
monster = game_world.boss
|
||||||
dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5
|
dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5
|
||||||
|
|
||||||
if dist > 5:
|
if dist > attack_range:
|
||||||
return {"success": False, "reason": "Boss too far"}
|
return {"success": False, "reason": "Boss too far"}
|
||||||
|
|
||||||
damage = max(1, player.attack + 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
|
||||||
monster.boss_progress = (monster.max_health - monster.health) / monster.max_health * 100
|
monster.boss_progress = (monster.max_health - monster.health) / monster.max_health * 100
|
||||||
@@ -481,6 +589,19 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
|||||||
|
|
||||||
return {"success": True, "damage": damage, "boss_health_remaining": monster.health}
|
return {"success": True, "damage": damage, "boss_health_remaining": monster.health}
|
||||||
|
|
||||||
|
if target_id in game_world.structures:
|
||||||
|
structure = game_world.structures[target_id]
|
||||||
|
if structure.structure_type != "block":
|
||||||
|
return {"success": False, "reason": "Only block structures are destructible"}
|
||||||
|
dist = ((structure.position.x - player.position.x) ** 2 + (structure.position.y - player.position.y) ** 2) ** 0.5
|
||||||
|
if dist > attack_range:
|
||||||
|
return {"success": False, "reason": "Block too far"}
|
||||||
|
structure.health -= 1
|
||||||
|
if structure.health <= 0:
|
||||||
|
del game_world.structures[target_id]
|
||||||
|
return {"success": True, "damage": 1, "block_destroyed": True}
|
||||||
|
return {"success": True, "damage": 1, "block_health_remaining": structure.health}
|
||||||
|
|
||||||
return {"success": False, "reason": "Target not found"}
|
return {"success": False, "reason": "Target not found"}
|
||||||
|
|
||||||
def _handle_gather(player: Player, target_id: str) -> Dict:
|
def _handle_gather(player: Player, target_id: str) -> Dict:
|
||||||
@@ -517,10 +638,15 @@ def _handle_gather(player: Player, target_id: str) -> Dict:
|
|||||||
|
|
||||||
def _handle_build(player: Player, tx: float, ty: float, structure_type: str) -> Dict:
|
def _handle_build(player: Player, tx: float, ty: float, structure_type: str) -> Dict:
|
||||||
"""Handle structure building"""
|
"""Handle structure building"""
|
||||||
if structure_type not in ["house", "farm", "guard_tower"]:
|
if structure_type not in ["house", "farm", "guard_tower", "block"]:
|
||||||
return {"success": False, "reason": "Invalid structure type"}
|
return {"success": False, "reason": "Invalid structure type"}
|
||||||
|
|
||||||
costs = {"house": {"wood": 20, "stone": 10}, "farm": {"wood": 15, "stone": 5}, "guard_tower": {"wood": 30, "stone": 20}}
|
costs = {
|
||||||
|
"house": {"wood": 20, "stone": 10},
|
||||||
|
"farm": {"wood": 15, "stone": 5},
|
||||||
|
"guard_tower": {"wood": 30, "stone": 20},
|
||||||
|
"block": {"wood": BLOCK_BUILD_COST_WOOD, "stone": 0},
|
||||||
|
}
|
||||||
cost = costs[structure_type]
|
cost = costs[structure_type]
|
||||||
|
|
||||||
for material, amount in cost.items():
|
for material, amount in cost.items():
|
||||||
@@ -531,9 +657,20 @@ def _handle_build(player: Player, tx: float, ty: float, structure_type: str) ->
|
|||||||
for material, amount in cost.items():
|
for material, amount in cost.items():
|
||||||
player.inventory[material] -= amount
|
player.inventory[material] -= amount
|
||||||
|
|
||||||
# Create structure
|
# 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
|
||||||
|
if structure_type == "block":
|
||||||
|
px, py = player.position.x, player.position.y - 1
|
||||||
|
# Cannot place on top of players or existing structures.
|
||||||
|
for p in game_world.players.values():
|
||||||
|
if ((p.position.x - px) ** 2 + (p.position.y - py) ** 2) ** 0.5 < 0.6:
|
||||||
|
return {"success": False, "reason": "Cannot place block on player"}
|
||||||
|
for s in game_world.structures.values():
|
||||||
|
if ((s.position.x - px) ** 2 + (s.position.y - py) ** 2) ** 0.5 < 0.6:
|
||||||
|
return {"success": False, "reason": "Tile already occupied"}
|
||||||
|
|
||||||
if structure_type == "farm":
|
if structure_type == "farm":
|
||||||
bonuses["action_regen"] = 2
|
bonuses["action_regen"] = 2
|
||||||
elif structure_type == "guard_tower":
|
elif structure_type == "guard_tower":
|
||||||
@@ -543,15 +680,47 @@ def _handle_build(player: Player, tx: float, ty: float, structure_type: str) ->
|
|||||||
|
|
||||||
game_world.structures[structure_id] = Structure(
|
game_world.structures[structure_id] = Structure(
|
||||||
id=structure_id,
|
id=structure_id,
|
||||||
position=Position(tx, ty, 0),
|
position=Position(px, py, 0),
|
||||||
structure_type=structure_type,
|
structure_type=structure_type,
|
||||||
owner_id=player.id,
|
owner_id=player.id,
|
||||||
health=100,
|
health=BLOCK_HEALTH if structure_type == "block" else 100,
|
||||||
bonuses=bonuses,
|
bonuses=bonuses,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"success": True, "structure_id": structure_id, "inventory": player.inventory}
|
return {"success": True, "structure_id": structure_id, "inventory": player.inventory}
|
||||||
|
|
||||||
|
def _handle_loot(player: Player) -> Dict:
|
||||||
|
nearby = []
|
||||||
|
for chest in game_world.chests.values():
|
||||||
|
if chest.opened:
|
||||||
|
continue
|
||||||
|
dist = ((chest.position.x - player.position.x) ** 2 + (chest.position.y - player.position.y) ** 2) ** 0.5
|
||||||
|
if dist <= CHEST_LOOT_RANGE:
|
||||||
|
nearby.append((dist, chest))
|
||||||
|
|
||||||
|
if not nearby:
|
||||||
|
return {"success": False, "reason": "No chest nearby"}
|
||||||
|
|
||||||
|
nearby.sort(key=lambda x: x[0])
|
||||||
|
chest = nearby[0][1]
|
||||||
|
empty_slot = next((i for i, item in enumerate(player.weapon_slots) if item is None), None)
|
||||||
|
if empty_slot is None:
|
||||||
|
return {"success": False, "reason": "Weapon slots full"}
|
||||||
|
|
||||||
|
player.weapon_slots[empty_slot] = chest.weapon
|
||||||
|
chest.opened = True
|
||||||
|
return {"success": True, "weapon": chest.weapon, "slot_index": empty_slot, "weapon_slots": player.weapon_slots}
|
||||||
|
|
||||||
|
def _handle_equip(player: Player, slot_index) -> Dict:
|
||||||
|
if slot_index is None:
|
||||||
|
return {"success": False, "reason": "slot_index is required"}
|
||||||
|
if not isinstance(slot_index, int) or slot_index < 0 or slot_index >= len(player.weapon_slots):
|
||||||
|
return {"success": False, "reason": "Invalid slot index"}
|
||||||
|
if player.weapon_slots[slot_index] is None:
|
||||||
|
return {"success": False, "reason": "No weapon in that slot"}
|
||||||
|
player.equipped_weapon_slot = slot_index
|
||||||
|
return {"success": True, "equipped_weapon_slot": slot_index, "weapon": player.weapon_slots[slot_index]}
|
||||||
|
|
||||||
@app.websocket("/ws/{player_id}")
|
@app.websocket("/ws/{player_id}")
|
||||||
async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
||||||
"""WebSocket for real-time game updates"""
|
"""WebSocket for real-time game updates"""
|
||||||
@@ -560,6 +729,10 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
|||||||
return
|
return
|
||||||
|
|
||||||
player = game_world.players[player_id]
|
player = game_world.players[player_id]
|
||||||
|
# Capture the session at the time this WS connection is established so that
|
||||||
|
# if the player re-logs (new session) while this socket is still open, the
|
||||||
|
# finally block doesn't incorrectly mark the re-logged player as inactive.
|
||||||
|
ws_session_id = player.session_id
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -579,6 +752,7 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
|||||||
"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()},
|
||||||
"resources": {rid: r.to_dict() for rid, r in game_world.resources.items()},
|
"resources": {rid: r.to_dict() for rid, r in game_world.resources.items()},
|
||||||
|
"chests": {cid: c.to_dict() for cid, c in game_world.chests.items()},
|
||||||
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
|
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
|
||||||
"elapsed_time": game_world.get_elapsed_time(),
|
"elapsed_time": game_world.get_elapsed_time(),
|
||||||
}
|
}
|
||||||
@@ -586,7 +760,9 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"WebSocket error: {e}")
|
print(f"WebSocket error: {e}")
|
||||||
finally:
|
finally:
|
||||||
player.active = False
|
# Only deactivate if this WS belongs to the current session (not superseded by a re-login).
|
||||||
|
if player.session_id == ws_session_id:
|
||||||
|
player.active = False
|
||||||
|
|
||||||
@app.post("/api/game/start")
|
@app.post("/api/game/start")
|
||||||
async def start_game():
|
async def start_game():
|
||||||
|
|||||||
+168
-60
@@ -403,6 +403,10 @@
|
|||||||
<span class="stat-label">Attack:</span>
|
<span class="stat-label">Attack:</span>
|
||||||
<span class="stat-value" id="statAttack">5</span>
|
<span class="stat-value" id="statAttack">5</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Range:</span>
|
||||||
|
<span class="stat-value" id="statRange">4</span>
|
||||||
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Defense:</span>
|
<span class="stat-label">Defense:</span>
|
||||||
<span class="stat-value" id="statDefense">2</span>
|
<span class="stat-value" id="statDefense">2</span>
|
||||||
@@ -455,19 +459,19 @@
|
|||||||
<div class="camera-controls" id="cameraControls">
|
<div class="camera-controls" id="cameraControls">
|
||||||
<div class="cam-label">📷 CAMERA</div>
|
<div class="cam-label">📷 CAMERA</div>
|
||||||
<div class="cam-row">
|
<div class="cam-row">
|
||||||
<button class="cam-btn" id="camUp" title="Tilt up">▲</button>
|
<button type="button" class="cam-btn" id="camUp" title="Tilt up">▲</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cam-row">
|
<div class="cam-row">
|
||||||
<button class="cam-btn" id="camLeft" title="Orbit left">◀</button>
|
<button type="button" class="cam-btn" id="camLeft" title="Orbit left">◀</button>
|
||||||
<button class="cam-btn" id="camCenter" title="Re-center on player">⊙</button>
|
<button type="button" class="cam-btn" id="camCenter" title="Re-center on player">⊙</button>
|
||||||
<button class="cam-btn" id="camRight" title="Orbit right">▶</button>
|
<button type="button" class="cam-btn" id="camRight" title="Orbit right">▶</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cam-row">
|
<div class="cam-row">
|
||||||
<button class="cam-btn" id="camDown" title="Tilt down">▼</button>
|
<button type="button" class="cam-btn" id="camDown" title="Tilt down">▼</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cam-row" style="margin-top:4px; gap:4px;">
|
<div class="cam-row" style="margin-top:4px; gap:4px;">
|
||||||
<button class="cam-btn wide" id="camZoomIn" title="Zoom in">🔍 +</button>
|
<button type="button" class="cam-btn wide" id="camZoomIn" title="Zoom in">🔍 +</button>
|
||||||
<button class="cam-btn wide" id="camZoomOut" title="Zoom out">🔍 −</button>
|
<button type="button" class="cam-btn wide" id="camZoomOut" title="Zoom out">🔍 −</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -475,19 +479,24 @@
|
|||||||
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Controls</div>
|
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Controls</div>
|
||||||
<div class="controls-help">Each action costs 1 AP. Hover buttons to see details.</div>
|
<div class="controls-help">Each action costs 1 AP. Hover buttons to see details.</div>
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button class="action-button" id="moveUpBtn" title="Move north by 1 tile (cost: 1 AP)">⬆ N</button>
|
<button type="button" class="action-button" id="moveUpBtn" title="Move north by 1 tile (cost: 1 AP)">⬆ N</button>
|
||||||
<button class="action-button" id="moveDownBtn" title="Move south by 1 tile (cost: 1 AP)">⬇ S</button>
|
<button type="button" class="action-button" id="moveDownBtn" title="Move south by 1 tile (cost: 1 AP)">⬇ S</button>
|
||||||
<button class="action-button" id="moveLeftBtn" title="Move west by 1 tile (cost: 1 AP)">⬅ W</button>
|
<button type="button" class="action-button" id="moveLeftBtn" title="Move west by 1 tile (cost: 1 AP)">⬅ W</button>
|
||||||
<button class="action-button" id="moveRightBtn" title="Move east by 1 tile (cost: 1 AP)">➡ E</button>
|
<button type="button" class="action-button" id="moveRightBtn" title="Move east by 1 tile (cost: 1 AP)">➡ E</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-group" style="margin-top: 10px;">
|
<div class="button-group" style="margin-top: 10px;">
|
||||||
<button class="action-button" id="gatherBtn" title="Gather nearby trees/mountains within radius 5 (cost: 1 AP)">🌳 Gather</button>
|
<button type="button" class="action-button" id="gatherBtn" title="Gather nearby trees/mountains within radius 5 (cost: 1 AP)">🌳 Gather</button>
|
||||||
<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 class="action-button" id="buildHouseBtn" title="Build House (20 wood, 10 stone)">🏠 House</button>
|
<button type="button" class="action-button" id="buildHouseBtn" title="Build House (20 wood, 10 stone)">🏠 House</button>
|
||||||
<button class="action-button" id="buildFarmBtn" title="Build Farm (15 wood, 5 stone)">🌾 Farm</button>
|
<button type="button" class="action-button" id="buildFarmBtn" title="Build Farm (15 wood, 5 stone)">🌾 Farm</button>
|
||||||
<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="Build Guard Tower (30 wood, 20 stone)">🛡️ Tower</button>
|
||||||
|
</div>
|
||||||
|
<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="lootBtn" title="Loot nearest chest in range">🗝 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>
|
||||||
@@ -512,6 +521,7 @@
|
|||||||
let gameActive = true;
|
let gameActive = true;
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let cameraKeyBindingsAttached = false;
|
let cameraKeyBindingsAttached = false;
|
||||||
|
let cameraAutoFollow = true;
|
||||||
|
|
||||||
const API_BASE = 'http://localhost:8000/api';
|
const API_BASE = 'http://localhost:8000/api';
|
||||||
|
|
||||||
@@ -529,7 +539,8 @@
|
|||||||
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.attack;
|
document.getElementById('statAttack').textContent = currentPlayer.effective_attack ?? currentPlayer.attack;
|
||||||
|
document.getElementById('statRange').textContent = (currentPlayer.attack_range ?? 4).toFixed(1);
|
||||||
document.getElementById('statDefense').textContent = currentPlayer.defense;
|
document.getElementById('statDefense').textContent = currentPlayer.defense;
|
||||||
document.getElementById('statMove').textContent = currentPlayer.movement_capacity;
|
document.getElementById('statMove').textContent = currentPlayer.movement_capacity;
|
||||||
|
|
||||||
@@ -556,11 +567,25 @@
|
|||||||
let ossBossObject = null;
|
let ossBossObject = null;
|
||||||
const structureObjects = {};
|
const structureObjects = {};
|
||||||
const resourceObjects = {};
|
const resourceObjects = {};
|
||||||
|
const chestObjects = {};
|
||||||
|
|
||||||
function initScene() {
|
function initScene() {
|
||||||
const canvas = document.getElementById('gameCanvas');
|
const canvas = document.getElementById('gameCanvas');
|
||||||
scene = new THREE.Scene();
|
scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color(0x0a0a0a);
|
|
||||||
|
// Sky-like vertical gradient background (top light blue -> horizon pale -> lower blue).
|
||||||
|
const skyCanvas = document.createElement('canvas');
|
||||||
|
skyCanvas.width = 8;
|
||||||
|
skyCanvas.height = 512;
|
||||||
|
const skyCtx = skyCanvas.getContext('2d');
|
||||||
|
const grad = skyCtx.createLinearGradient(0, 0, 0, skyCanvas.height);
|
||||||
|
grad.addColorStop(0.0, '#7ecbff');
|
||||||
|
grad.addColorStop(0.45, '#bfe5ff');
|
||||||
|
grad.addColorStop(1.0, '#5aa7df');
|
||||||
|
skyCtx.fillStyle = grad;
|
||||||
|
skyCtx.fillRect(0, 0, skyCanvas.width, skyCanvas.height);
|
||||||
|
const skyTexture = new THREE.CanvasTexture(skyCanvas);
|
||||||
|
scene.background = skyTexture;
|
||||||
|
|
||||||
camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
|
camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
|
||||||
camera.position.set(0, 50, 50);
|
camera.position.set(0, 50, 50);
|
||||||
@@ -579,6 +604,8 @@
|
|||||||
controls.minDistance = 10;
|
controls.minDistance = 10;
|
||||||
controls.maxDistance = 200;
|
controls.maxDistance = 200;
|
||||||
controls.enableKeys = false; // We handle arrow keys ourselves.
|
controls.enableKeys = false; // We handle arrow keys ourselves.
|
||||||
|
// Keep follow mode only until the user takes manual camera control.
|
||||||
|
controls.addEventListener('start', () => { cameraAutoFollow = false; });
|
||||||
|
|
||||||
// Lighting
|
// Lighting
|
||||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
||||||
@@ -599,8 +626,8 @@
|
|||||||
ground.receiveShadow = true;
|
ground.receiveShadow = true;
|
||||||
scene.add(ground);
|
scene.add(ground);
|
||||||
|
|
||||||
// Grid helper
|
// Grid helper: smaller cells so 1-tile movement matches visible grid steps.
|
||||||
const gridHelper = new THREE.GridHelper(500, 50, 0x444444, 0x222222);
|
const gridHelper = new THREE.GridHelper(500, 500, 0x4a5d66, 0x2b3b42);
|
||||||
gridHelper.position.y = 0.1;
|
gridHelper.position.y = 0.1;
|
||||||
scene.add(gridHelper);
|
scene.add(gridHelper);
|
||||||
|
|
||||||
@@ -721,13 +748,24 @@
|
|||||||
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);
|
||||||
|
|
||||||
playerSpheres[player.id] = { mesh: sphere, sprite };
|
const northGeo = new THREE.ConeGeometry(0.18, 0.6, 8);
|
||||||
|
const northMat = new THREE.MeshPhongMaterial({ color: player.color });
|
||||||
|
const northArrow = new THREE.Mesh(northGeo, northMat);
|
||||||
|
// Rotate so the tip points north (-Z direction in Three.js world space).
|
||||||
|
northArrow.rotation.x = -Math.PI / 2;
|
||||||
|
northArrow.position.set(player.position.x, 0.9, player.position.y - 0.8);
|
||||||
|
scene.add(northArrow);
|
||||||
|
|
||||||
|
playerSpheres[player.id] = { mesh: sphere, sprite, northArrow };
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePlayerSphere(player) {
|
function updatePlayerSphere(player) {
|
||||||
if (playerSpheres[player.id]) {
|
if (playerSpheres[player.id]) {
|
||||||
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) {
|
||||||
|
playerSpheres[player.id].northArrow.position.set(player.position.x, 0.9, player.position.y - 0.8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,6 +800,9 @@
|
|||||||
} else if (structure.structure_type === 'guard_tower') {
|
} else if (structure.structure_type === 'guard_tower') {
|
||||||
geometry = new THREE.ConeGeometry(0.35, 1.4, 4);
|
geometry = new THREE.ConeGeometry(0.35, 1.4, 4);
|
||||||
color = 0x808080;
|
color = 0x808080;
|
||||||
|
} else if (structure.structure_type === 'block') {
|
||||||
|
geometry = new THREE.BoxGeometry(0.9, 0.9, 0.9);
|
||||||
|
color = 0x7a5f3e;
|
||||||
}
|
}
|
||||||
const material = new THREE.MeshPhongMaterial({ color });
|
const material = new THREE.MeshPhongMaterial({ color });
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
@@ -771,36 +812,38 @@
|
|||||||
structureObjects[structure.id] = mesh;
|
structureObjects[structure.id] = mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createChestMesh(chest) {
|
||||||
|
const baseGeo = new THREE.BoxGeometry(0.9, 0.55, 0.7);
|
||||||
|
const baseMat = new THREE.MeshPhongMaterial({ color: 0x8b5a2b });
|
||||||
|
const base = new THREE.Mesh(baseGeo, baseMat);
|
||||||
|
base.position.y = 0.28;
|
||||||
|
|
||||||
|
const lidGeo = new THREE.BoxGeometry(0.9, 0.2, 0.7);
|
||||||
|
const lidMat = new THREE.MeshPhongMaterial({ color: 0xd1a13a });
|
||||||
|
const lid = new THREE.Mesh(lidGeo, lidMat);
|
||||||
|
lid.position.y = 0.63;
|
||||||
|
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.add(base);
|
||||||
|
if (!chest.opened) group.add(lid);
|
||||||
|
group.position.set(chest.position.x, 0, chest.position.y);
|
||||||
|
scene.add(group);
|
||||||
|
chestObjects[chest.id] = group;
|
||||||
|
}
|
||||||
|
|
||||||
function createResourceMesh(resource) {
|
function createResourceMesh(resource) {
|
||||||
let mesh;
|
const isTree = resource.resource_type === 'tree';
|
||||||
if (resource.resource_type === 'tree') {
|
const geometry = isTree
|
||||||
const trunkGeo = new THREE.CylinderGeometry(0.12, 0.18, 0.8, 8);
|
? new THREE.ConeGeometry(0.45, 1.1, 8)
|
||||||
const trunkMat = new THREE.MeshPhongMaterial({ color: 0x8B4513 });
|
: new THREE.BoxGeometry(0.9, 0.75, 0.9);
|
||||||
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
|
const material = new THREE.MeshPhongMaterial({
|
||||||
trunk.position.y = 0.4;
|
color: isTree ? 0x2e8b57 : 0x6f7b86,
|
||||||
const foliageMat = new THREE.MeshPhongMaterial({ color: 0x228b22 });
|
});
|
||||||
const foliage1 = new THREE.Mesh(new THREE.ConeGeometry(0.85, 1.3, 8), foliageMat);
|
|
||||||
foliage1.position.y = 1.3;
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
const foliage2 = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.0, 8), foliageMat);
|
mesh.position.set(resource.position.x, isTree ? 0.55 : 0.38, resource.position.y);
|
||||||
foliage2.position.y = 2.1;
|
|
||||||
mesh = new THREE.Group();
|
|
||||||
mesh.add(trunk, foliage1, foliage2);
|
|
||||||
} else {
|
|
||||||
const rockMat = new THREE.MeshPhongMaterial({ color: 0x8a8a8a, flatShading: true });
|
|
||||||
const main = new THREE.Mesh(new THREE.DodecahedronGeometry(0.65, 0), rockMat);
|
|
||||||
main.position.y = 0.5;
|
|
||||||
main.rotation.y = Math.random() * Math.PI;
|
|
||||||
const side1 = new THREE.Mesh(new THREE.DodecahedronGeometry(0.38, 0), rockMat);
|
|
||||||
side1.position.set(0.6, 0.25, 0.12);
|
|
||||||
side1.rotation.y = Math.random() * Math.PI;
|
|
||||||
const side2 = new THREE.Mesh(new THREE.DodecahedronGeometry(0.28, 0), rockMat);
|
|
||||||
side2.position.set(-0.5, 0.2, 0.25);
|
|
||||||
side2.rotation.y = Math.random() * Math.PI;
|
|
||||||
mesh = new THREE.Group();
|
|
||||||
mesh.add(main, side1, side2);
|
|
||||||
}
|
|
||||||
mesh.position.set(resource.position.x, 0, resource.position.y);
|
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
resourceObjects[resource.id] = mesh;
|
resourceObjects[resource.id] = mesh;
|
||||||
}
|
}
|
||||||
@@ -822,6 +865,7 @@
|
|||||||
if (!(gameState.players || {})[pid]) {
|
if (!(gameState.players || {})[pid]) {
|
||||||
scene.remove(playerSpheres[pid].mesh);
|
scene.remove(playerSpheres[pid].mesh);
|
||||||
scene.remove(playerSpheres[pid].sprite);
|
scene.remove(playerSpheres[pid].sprite);
|
||||||
|
if (playerSpheres[pid].northArrow) scene.remove(playerSpheres[pid].northArrow);
|
||||||
delete playerSpheres[pid];
|
delete playerSpheres[pid];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -873,7 +917,20 @@
|
|||||||
if (!resources[rid]) { scene.remove(resourceObjects[rid]); delete resourceObjects[rid]; }
|
if (!resources[rid]) { scene.remove(resourceObjects[rid]); delete resourceObjects[rid]; }
|
||||||
}
|
}
|
||||||
|
|
||||||
focusCameraOnPlayer(false);
|
const chests = gameState.chests || {};
|
||||||
|
for (const chest of Object.values(chests)) {
|
||||||
|
if (!chestObjects[chest.id]) createChestMesh(chest);
|
||||||
|
}
|
||||||
|
for (const cid in chestObjects) {
|
||||||
|
if (!chests[cid]) {
|
||||||
|
scene.remove(chestObjects[cid]);
|
||||||
|
delete chestObjects[cid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraAutoFollow) {
|
||||||
|
focusCameraOnPlayer(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function animate() {
|
function animate() {
|
||||||
@@ -981,12 +1038,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performAction(actionType, targetId = null, tx = null, ty = null, structureType = null) {
|
async function performAction(actionType, targetId = null, tx = null, ty = null, structureType = null, slotIndex = null) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/player/${currentPlayerId}/action`, {
|
const response = await fetch(`${API_BASE}/player/${currentPlayerId}/action`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action_type: actionType, target_id: targetId, tx, ty, structure_type: structureType }),
|
body: JSON.stringify({ action_type: actionType, target_id: targetId, tx, ty, structure_type: structureType, slot_index: slotIndex }),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -1017,7 +1074,10 @@
|
|||||||
function bindCamBtn(id, az, pl, zm = 0) {
|
function bindCamBtn(id, az, pl, zm = 0) {
|
||||||
const btn = document.getElementById(id);
|
const btn = document.getElementById(id);
|
||||||
let interval = null;
|
let interval = null;
|
||||||
const fire = () => rotateCameraBy(az, pl, zm);
|
const fire = () => {
|
||||||
|
cameraAutoFollow = false;
|
||||||
|
rotateCameraBy(az, pl, zm);
|
||||||
|
};
|
||||||
btn.addEventListener('mousedown', () => { fire(); interval = setInterval(fire, 80); });
|
btn.addEventListener('mousedown', () => { fire(); interval = setInterval(fire, 80); });
|
||||||
btn.addEventListener('touchstart', (e) => { e.preventDefault(); fire(); interval = setInterval(fire, 80); }, { passive: false });
|
btn.addEventListener('touchstart', (e) => { e.preventDefault(); fire(); interval = setInterval(fire, 80); }, { passive: false });
|
||||||
const stop = () => clearInterval(interval);
|
const stop = () => clearInterval(interval);
|
||||||
@@ -1031,7 +1091,10 @@
|
|||||||
bindCamBtn('camDown', 0, PL);
|
bindCamBtn('camDown', 0, PL);
|
||||||
bindCamBtn('camZoomIn', 0, 0, -ZM);
|
bindCamBtn('camZoomIn', 0, 0, -ZM);
|
||||||
bindCamBtn('camZoomOut', 0, 0, ZM);
|
bindCamBtn('camZoomOut', 0, 0, ZM);
|
||||||
document.getElementById('camCenter').onclick = () => focusCameraOnPlayer(true);
|
document.getElementById('camCenter').onclick = () => {
|
||||||
|
cameraAutoFollow = true;
|
||||||
|
focusCameraOnPlayer(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (!cameraKeyBindingsAttached) {
|
if (!cameraKeyBindingsAttached) {
|
||||||
cameraKeyBindingsAttached = true;
|
cameraKeyBindingsAttached = true;
|
||||||
@@ -1045,6 +1108,7 @@
|
|||||||
|
|
||||||
if (["arrowleft", "arrowright", "arrowup", "arrowdown", "z", "q", "s", "d"].includes(key)) {
|
if (["arrowleft", "arrowright", "arrowup", "arrowdown", "z", "q", "s", "d"].includes(key)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
cameraAutoFollow = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'arrowleft') rotateCameraBy(rotationStep, 0, 0);
|
if (key === 'arrowleft') rotateCameraBy(rotationStep, 0, 0);
|
||||||
@@ -1059,14 +1123,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('attackBtn').onclick = () => {
|
document.getElementById('attackBtn').onclick = () => {
|
||||||
if (gameState && gameState.boss) {
|
if (!gameState || !currentPlayer) {
|
||||||
performAction('attack', gameState.boss.id);
|
|
||||||
} else if (gameState && Object.keys(gameState.monsters).length > 0) {
|
|
||||||
const targetId = Object.keys(gameState.monsters)[0];
|
|
||||||
performAction('attack', targetId);
|
|
||||||
} else {
|
|
||||||
showMessage('No targets available');
|
showMessage('No targets available');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const range = currentPlayer.attack_range || 4;
|
||||||
|
const candidates = [];
|
||||||
|
if (gameState.boss) candidates.push(gameState.boss);
|
||||||
|
for (const m of Object.values(gameState.monsters || {})) candidates.push(m);
|
||||||
|
for (const s of Object.values(gameState.structures || {})) {
|
||||||
|
if (s.structure_type === 'block') candidates.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearest = null;
|
||||||
|
let nearestDist = Infinity;
|
||||||
|
for (const target of candidates) {
|
||||||
|
const dist = Math.hypot(target.position.x - currentPlayer.position.x, target.position.y - currentPlayer.position.y);
|
||||||
|
if (dist <= range && dist < nearestDist) {
|
||||||
|
nearestDist = dist;
|
||||||
|
nearest = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nearest) {
|
||||||
|
showMessage(`No target in range (${range.toFixed(1)})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performAction('attack', nearest.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('gatherBtn').onclick = () => {
|
document.getElementById('gatherBtn').onclick = () => {
|
||||||
@@ -1084,6 +1168,30 @@
|
|||||||
document.getElementById('buildTowerBtn').onclick = () => {
|
document.getElementById('buildTowerBtn').onclick = () => {
|
||||||
performAction('build', null, currentPlayer.position.x, currentPlayer.position.y, 'guard_tower');
|
performAction('build', null, currentPlayer.position.x, currentPlayer.position.y, 'guard_tower');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
document.getElementById('buildBlockBtn').onclick = () => {
|
||||||
|
performAction('build', null, null, null, 'block');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('lootBtn').onclick = async () => {
|
||||||
|
const data = await performAction('loot');
|
||||||
|
if (data && data.success && typeof data.slot_index === 'number') {
|
||||||
|
await performAction('equip', null, null, null, null, data.slot_index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('equipBtn').onclick = () => {
|
||||||
|
if (!currentPlayer || !Array.isArray(currentPlayer.weapon_slots)) {
|
||||||
|
showMessage('No weapon slots available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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