diff --git a/backend.py b/backend.py index 70f280e..79920f6 100644 --- a/backend.py +++ b/backend.py @@ -24,6 +24,14 @@ MAX_MONSTERS = 50 BOSS_HEALTH_BASE = 1000 MIN_LEVEL_FOR_BOSS = 10 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 @dataclass @@ -57,10 +65,18 @@ class Player: defense: int = 2 movement_capacity: int = 1 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 session_id: str = "" 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 { "id": self.id, "username": self.username, @@ -74,9 +90,13 @@ class Player: "max_action_points": self.max_action_points, "inventory": self.inventory, "attack": self.attack, + "effective_attack": self.attack + attack_bonus, + "attack_range": self.attack_range + range_bonus, "defense": self.defense, "movement_capacity": self.movement_capacity, "gathering_capacity": self.gathering_capacity, + "weapon_slots": self.weapon_slots, + "equipped_weapon_slot": self.equipped_weapon_slot, "active": self.active, } @@ -88,6 +108,7 @@ class Monster: max_health: int level: int attack: int + defense: int = 1 is_boss: bool = False boss_progress: float = 0.0 # Percentage of damage done @@ -99,10 +120,28 @@ class Monster: "max_health": self.max_health, "level": self.level, "attack": self.attack, + "defense": self.defense, "is_boss": self.is_boss, "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 class Structure: id: str @@ -143,16 +182,18 @@ class GameWorld: self.monsters: Dict[str, Monster] = {} self.structures: Dict[str, Structure] = {} self.resources: Dict[str, Resource] = {} + self.chests: Dict[str, Chest] = {} self.boss: Monster = None self.game_start_time: float = 0 self.is_game_active: bool = False self.connected_sessions: Dict[str, str] = {} # session_id -> player_id self.generation = 0 self._init_resources() + self._init_chests() def _init_resources(self): """Initialize static resources on the map""" - for _ in range(50): + for _ in range(RESOURCE_NODE_COUNT): resource_id = f"resource_{len(self.resources)}" x = 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), ) + 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): """Start or restart the game""" self.game_start_time = datetime.now().timestamp() self.is_game_active = True self.boss = None self.monsters.clear() + self.chests.clear() + self.resources.clear() + self.structures.clear() + self._init_resources() + self._init_chests() self.generation += 1 # Keep players but reset their state for player in self.players.values(): @@ -180,6 +246,9 @@ class GameWorld: player.health = player.max_health player.action_points = ACTION_POINTS_MAX 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( 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: 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 for player in self.players.values(): if not player.active: @@ -243,6 +316,7 @@ class GameWorld: max_health=health, level=level, attack=3 + level, + defense=1 + level // 2, is_boss=False, ) @@ -269,17 +343,34 @@ class GameWorld: max_health=health, level=int(avg_level) + 5, attack=15 + int(avg_level), + defense=4 + int(avg_level // 2), is_boss=True, ) - def _get_nearby_structure_bonuses(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 +def _player_effective_attack(player: Player) -> int: + if 0 <= player.equipped_weapon_slot < len(player.weapon_slots): + w = player.weapon_slots[player.equipped_weapon_slot] + if w: + return player.attack + int(w.get("attack_bonus", 0)) + return player.attack + +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 @@ -377,6 +468,7 @@ async def get_game_state(): "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()}, "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, "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: raise HTTPException(status_code=400, detail="Movement exceeds capacity") - player.position.x += dx - 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)) + next_x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x + dx)) + next_y = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.y + dy)) + + # 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 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")) elif action_type == "build": 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: 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: """Handle player attack""" + attack_range = _player_effective_range(player) + attack_power = _player_effective_attack(player) + if target_id in game_world.monsters: monster = game_world.monsters[target_id] 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"} - 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 if monster.health <= 0: @@ -462,10 +570,10 @@ def _handle_attack(player: Player, target_id: str) -> Dict: monster = game_world.boss 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"} - 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 old_progress = monster.boss_progress 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} + 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"} 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: """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"} - 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] 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(): player.inventory[material] -= amount - # Create structure + # Create structure (block is placed 1 tile north of player) structure_id = f"struct_{len(game_world.structures)}" 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": bonuses["action_regen"] = 2 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( id=structure_id, - position=Position(tx, ty, 0), + position=Position(px, py, 0), structure_type=structure_type, owner_id=player.id, - health=100, + health=BLOCK_HEALTH if structure_type == "block" else 100, bonuses=bonuses, ) 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}") async def websocket_endpoint(websocket: WebSocket, player_id: str): """WebSocket for real-time game updates""" @@ -560,6 +729,10 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str): return 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() 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, "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()}, + "chests": {cid: c.to_dict() for cid, c in game_world.chests.items()}, "time_remaining": max(0, GAME_DURATION - 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: print(f"WebSocket error: {e}") 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") async def start_game(): diff --git a/index.html b/index.html index ae48310..aae6298 100644 --- a/index.html +++ b/index.html @@ -403,6 +403,10 @@ Attack: 5 +
+ Range: + 4 +
Defense: 2 @@ -455,19 +459,19 @@
📷 CAMERA
- +
- - - + + +
- +
- - + +
@@ -475,19 +479,24 @@
Controls
Each action costs 1 AP. Hover buttons to see details.
- - - - + + + +
- - + +
- - - + + + +
+
+ + +
Ready for adventure!
@@ -512,6 +521,7 @@ let gameActive = true; let ws = null; let cameraKeyBindingsAttached = false; + let cameraAutoFollow = true; const API_BASE = 'http://localhost:8000/api'; @@ -529,7 +539,8 @@ document.getElementById('healthBar').style.width = `${(currentPlayer.health / currentPlayer.max_health) * 100}%`; document.getElementById('statActionPoints').textContent = currentPlayer.action_points; 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('statMove').textContent = currentPlayer.movement_capacity; @@ -556,11 +567,25 @@ let ossBossObject = null; const structureObjects = {}; const resourceObjects = {}; + const chestObjects = {}; function initScene() { const canvas = document.getElementById('gameCanvas'); 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.position.set(0, 50, 50); @@ -579,6 +604,8 @@ controls.minDistance = 10; controls.maxDistance = 200; 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 const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); @@ -599,8 +626,8 @@ ground.receiveShadow = true; scene.add(ground); - // Grid helper - const gridHelper = new THREE.GridHelper(500, 50, 0x444444, 0x222222); + // Grid helper: smaller cells so 1-tile movement matches visible grid steps. + const gridHelper = new THREE.GridHelper(500, 500, 0x4a5d66, 0x2b3b42); gridHelper.position.y = 0.1; scene.add(gridHelper); @@ -721,13 +748,24 @@ sprite.position.set(player.position.x, 2.5, player.position.y); 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) { if (playerSpheres[player.id]) { 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); + 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') { geometry = new THREE.ConeGeometry(0.35, 1.4, 4); 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 mesh = new THREE.Mesh(geometry, material); @@ -771,36 +812,38 @@ 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) { - let mesh; - if (resource.resource_type === 'tree') { - const trunkGeo = new THREE.CylinderGeometry(0.12, 0.18, 0.8, 8); - const trunkMat = new THREE.MeshPhongMaterial({ color: 0x8B4513 }); - const trunk = new THREE.Mesh(trunkGeo, trunkMat); - trunk.position.y = 0.4; - 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 foliage2 = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.0, 8), foliageMat); - 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); + 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); + mesh.position.set(resource.position.x, isTree ? 0.55 : 0.38, resource.position.y); mesh.castShadow = true; + mesh.receiveShadow = true; scene.add(mesh); resourceObjects[resource.id] = mesh; } @@ -822,6 +865,7 @@ if (!(gameState.players || {})[pid]) { scene.remove(playerSpheres[pid].mesh); scene.remove(playerSpheres[pid].sprite); + if (playerSpheres[pid].northArrow) scene.remove(playerSpheres[pid].northArrow); delete playerSpheres[pid]; } } @@ -873,7 +917,20 @@ 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() { @@ -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 { const response = await fetch(`${API_BASE}/player/${currentPlayerId}/action`, { method: 'POST', 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(); @@ -1017,7 +1074,10 @@ function bindCamBtn(id, az, pl, zm = 0) { const btn = document.getElementById(id); 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('touchstart', (e) => { e.preventDefault(); fire(); interval = setInterval(fire, 80); }, { passive: false }); const stop = () => clearInterval(interval); @@ -1031,7 +1091,10 @@ bindCamBtn('camDown', 0, PL); bindCamBtn('camZoomIn', 0, 0, -ZM); bindCamBtn('camZoomOut', 0, 0, ZM); - document.getElementById('camCenter').onclick = () => focusCameraOnPlayer(true); + document.getElementById('camCenter').onclick = () => { + cameraAutoFollow = true; + focusCameraOnPlayer(true); + }; if (!cameraKeyBindingsAttached) { cameraKeyBindingsAttached = true; @@ -1045,6 +1108,7 @@ if (["arrowleft", "arrowright", "arrowup", "arrowdown", "z", "q", "s", "d"].includes(key)) { event.preventDefault(); + cameraAutoFollow = false; } if (key === 'arrowleft') rotateCameraBy(rotationStep, 0, 0); @@ -1059,14 +1123,34 @@ } document.getElementById('attackBtn').onclick = () => { - if (gameState && gameState.boss) { - 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 { + if (!gameState || !currentPlayer) { 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 = () => { @@ -1084,6 +1168,30 @@ document.getElementById('buildTowerBtn').onclick = () => { 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 ====================