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 +