# boss_ai.py : IA des boss Vexaris et Morveth # l'etat interne du boss est stocke dans enemy.data (pas broadcast au client) import logging from buff_system import player_is_immune from combat import circle_overlap, normalize from constants import ( ARENA_HEIGHT, ARENA_WIDTH, ENEMY_ATTACK_COOLDOWN, MORVETH_BURST_COOLDOWN, MORVETH_BURST_COUNT, MORVETH_CHARGE_COOLDOWN, MORVETH_CHARGE_DURATION, MORVETH_CHARGE_SPEED, MORVETH_HITBOX_RADIUS, MORVETH_MELEE_DAMAGE, MORVETH_SG_DAMAGE, MORVETH_SPEED_P1, MORVETH_SPEED_P2, MORVETH_SPEED_P3, PLAYER_HITBOX_RADIUS, SOULGATE_HITBOX_RADIUS, SOULGATE_X, SOULGATE_Y, TICK_DURATION, VEXARIS_BURST_COOLDOWN, VEXARIS_BURST_COUNT, VEXARIS_CHARGE_COOLDOWN, VEXARIS_CHARGE_DURATION, VEXARIS_CHARGE_SPEED, VEXARIS_HITBOX_RADIUS, VEXARIS_MELEE_DAMAGE, VEXARIS_SG_DAMAGE, VEXARIS_SPEED_P1, VEXARIS_SPEED_P2, ) logger = logging.getLogger(__name__) def nearest_player(ex, ey, players): # joueur le plus proche de (ex, ey), None si liste vide best = None best_dist = float("inf") for p in players: d = (p.x - ex) ** 2 + (p.y - ey) ** 2 # carre, evite math.sqrt if d < best_dist: best_dist = d best = p return best def update_boss_ai(enemy, state_players, state_soulgate, pending_spawns): """dispatch vers la bonne IA selon le type de boss. pending_spawns = liste de (type_ennemi, x, y) à créer. On n'appelle pas spawn_enemy() directement depuis ici pour éviter de modifier la liste d'ennemis pendant qu'on l'itère dans game_loop. """ alive = [p for p in state_players if p.alive] # uniquement les joueurs vivants if enemy.type == "morveth": _update_morveth(enemy, alive, state_soulgate, pending_spawns) else: # Vexaris ou Général (les généraux ont une IA simplifiée du même type) _update_vexaris(enemy, alive, state_soulgate, pending_spawns) # Vexaris def _update_vexaris(enemy, alive_players, soulgate, pending_spawns): # 2 phases (>50% / <=50%), charge sur joueur, burst d'eclats en phase 2 phase2 = enemy.hp <= enemy.max_hp // 2 speed = VEXARIS_SPEED_P2 if phase2 else VEXARIS_SPEED_P1 d = enemy.data _init_vexaris_data(d) _tick_boss_cooldowns(enemy, d, phase2, VEXARIS_BURST_COOLDOWN) _check_soulgate_contact(enemy, soulgate, VEXARIS_HITBOX_RADIUS, VEXARIS_SG_DAMAGE) _vexaris_burst(d, enemy, phase2, pending_spawns) if d["is_charging"]: _boss_charge_move(enemy, d, alive_players, VEXARIS_CHARGE_SPEED, VEXARIS_HITBOX_RADIUS, VEXARIS_MELEE_DAMAGE * 2) elif d["charge_cd"] <= 0 and alive_players: _start_charge(d, enemy, alive_players, VEXARIS_CHARGE_DURATION, VEXARIS_CHARGE_COOLDOWN, "Vexaris") else: _boss_normal_move(enemy, alive_players, speed, VEXARIS_HITBOX_RADIUS, VEXARIS_MELEE_DAMAGE) def _init_vexaris_data(d): if "charge_cd" in d: return d.update({ "charge_cd": VEXARIS_CHARGE_COOLDOWN, "charge_timer": 0.0, "charge_tx": 0.0, "charge_ty": 0.0, "is_charging": False, "burst_cd": VEXARIS_BURST_COOLDOWN, }) def _vexaris_burst(d, enemy, phase2, pending): """Spawn un burst d'éclats autour de Vexaris (seulement en phase 2, si CD = 0).""" if not phase2 or d["burst_cd"] > 0: return # pas en phase 2, ou burst en cooldown → on ne fait rien # Spawner VEXARIS_BURST_COUNT éclats à la position du boss for _ in range(VEXARIS_BURST_COUNT): pending.append(("eclat", enemy.x, enemy.y)) # ("type", x, y) — game_loop les crée après d["burst_cd"] = VEXARIS_BURST_COOLDOWN # réinitialiser le cooldown du burst logger.debug("Vexaris burst : %d eclats", VEXARIS_BURST_COUNT) # Morveth def _update_morveth(enemy, alive_players, soulgate, pending_spawns): # 3 phases : <75% (p2), <25% (p3), invoque des generaux a 75/50/25 hp_pct = enemy.hp / enemy.max_hp phase3 = hp_pct <= 0.25 phase2 = hp_pct <= 0.75 speed = MORVETH_SPEED_P3 if phase3 else (MORVETH_SPEED_P2 if phase2 else MORVETH_SPEED_P1) d = enemy.data _init_morveth_data(d) _tick_boss_cooldowns(enemy, d, phase2, MORVETH_BURST_COOLDOWN) _morveth_spawn_generals(d, hp_pct, enemy, pending_spawns) # invoquer généraux aux seuils HP _check_soulgate_contact(enemy, soulgate, MORVETH_HITBOX_RADIUS, MORVETH_SG_DAMAGE) _morveth_burst(d, enemy, phase2, phase3, pending_spawns) # En phase 3, le cooldown de charge est divisé par 2 → il charge 2× plus souvent charge_reset = MORVETH_CHARGE_COOLDOWN / 2 if phase3 else MORVETH_CHARGE_COOLDOWN if d["is_charging"]: _boss_charge_move(enemy, d, alive_players, MORVETH_CHARGE_SPEED, MORVETH_HITBOX_RADIUS, MORVETH_MELEE_DAMAGE * 2) elif d["charge_cd"] <= 0 and alive_players: _start_charge(d, enemy, alive_players, MORVETH_CHARGE_DURATION, charge_reset, "Morveth") else: _boss_normal_move(enemy, alive_players, speed, MORVETH_HITBOX_RADIUS, MORVETH_MELEE_DAMAGE) def _init_morveth_data(d): # init des champs internes de Morveth, fait 1 seule fois if "charge_cd" in d: return d.update({ "charge_cd": MORVETH_CHARGE_COOLDOWN, "charge_timer": 0.0, "charge_tx": 0.0, "charge_ty": 0.0, "is_charging": False, "burst_cd": MORVETH_BURST_COOLDOWN, "general_1_spawned": False, # généraux déjà invoqués ? (évite de les respawner) "general_2_spawned": False, "general_3_spawned": False, }) def _morveth_spawn_generals(d, hp_pct, enemy, pending): """Invoque un Général à chaque seuil HP (une seule fois par seuil). hp_pct = pourcentage de HP de Morveth (0.0 à 1.0). Les seuils : 75%, 50%, 25%. Le flag (ex: "general_1_spawned") empêche de respawner si Morveth repasse sous le seuil. """ thresholds = [ # (seuil HP, flag dans d, offset X spawn, offset Y spawn) (0.75, "general_1_spawned", -3, 0), # général 1 à gauche de Morveth (0.50, "general_2_spawned", 3, 0), # général 2 à droite (0.25, "general_3_spawned", 0, 2), # général 3 devant ] for pct, flag, dx, dy in thresholds: if hp_pct <= pct and not d[flag]: # seuil atteint ET pas encore spawné pending.append(("general", enemy.x + dx, enemy.y + dy)) # ajouter à la file d[flag] = True # marquer comme spawné pour ne pas recommencer logger.info("Morveth invoque General (%s)", flag) def _morveth_burst(d, enemy, phase2, phase3, pending): """Burst d'éclats de Morveth. En phase 3, le cooldown est divisé par 2.""" if not phase2 or d["burst_cd"] > 0: return for _ in range(MORVETH_BURST_COUNT): pending.append(("eclat", enemy.x, enemy.y)) # Phase 3 = rage → burst 2× plus fréquent d["burst_cd"] = MORVETH_BURST_COOLDOWN / 2 if phase3 else MORVETH_BURST_COOLDOWN logger.debug("Morveth burst : %d eclats", MORVETH_BURST_COUNT) # helpers partages Vexaris/Morveth def _tick_boss_cooldowns(enemy, d, phase2, burst_cd_const): if enemy.attack_cooldown > 0: enemy.attack_cooldown = max(0.0, enemy.attack_cooldown - TICK_DURATION) d["charge_cd"] = max(0.0, d["charge_cd"] - TICK_DURATION) if phase2: d["burst_cd"] = max(0.0, d["burst_cd"] - TICK_DURATION) def _check_soulgate_contact(enemy, soulgate, hitbox, damage): sg_x, sg_y = float(SOULGATE_X), float(SOULGATE_Y) if not circle_overlap(enemy.x, enemy.y, hitbox, sg_x, sg_y, float(SOULGATE_HITBOX_RADIUS)): return if enemy.attack_cooldown > 0: return soulgate.hp = max(0, soulgate.hp - damage) enemy.attack_cooldown = ENEMY_ATTACK_COOLDOWN def _start_charge(d, enemy, alive, duration, cooldown, name): # la cible est fixee au declenchement (le joueur peut bouger pendant la charge) target = nearest_player(enemy.x, enemy.y, alive) if target is None: return d["is_charging"] = True d["charge_timer"] = duration d["charge_tx"] = target.x d["charge_ty"] = target.y d["charge_cd"] = cooldown logger.debug("%s charge vers %s", name, target.username) def _boss_charge_move(enemy, d, alive_players, charge_speed, hitbox, charge_damage): d["charge_timer"] -= TICK_DURATION if d["charge_timer"] <= 0.0: d["is_charging"] = False return # direction vers la cible figee au declenchement (le joueur peut bouger pendant la charge) dx, dy = normalize(d["charge_tx"] - enemy.x, d["charge_ty"] - enemy.y) if dx != 0.0 or dy != 0.0: half_w, half_h = ARENA_WIDTH / 2, ARENA_HEIGHT / 2 enemy.x = max(-half_w, min(half_w, enemy.x + dx * charge_speed * TICK_DURATION)) enemy.y = max(-half_h, min(half_h, enemy.y + dy * charge_speed * TICK_DURATION)) # check collision avec chaque joueur vivant non immunise for player in alive_players: if not circle_overlap(enemy.x, enemy.y, hitbox, player.x, player.y, float(PLAYER_HITBOX_RADIUS)): continue if player.alive and not player_is_immune(player): player.hp = max(0, player.hp - charge_damage) if player.hp <= 0: player.alive = False def _boss_normal_move(enemy, alive_players, speed, hitbox, melee_damage): if not alive_players: return target = nearest_player(enemy.x, enemy.y, alive_players) if target is None: return dx, dy = normalize(target.x - enemy.x, target.y - enemy.y) enemy.x += dx * speed * TICK_DURATION enemy.y += dy * speed * TICK_DURATION if not circle_overlap(enemy.x, enemy.y, hitbox, target.x, target.y, float(PLAYER_HITBOX_RADIUS)): return if enemy.attack_cooldown > 0 or player_is_immune(target): return target.hp = max(0, target.hp - melee_damage) if target.hp <= 0: target.alive = False enemy.attack_cooldown = ENEMY_ATTACK_COOLDOWN