SOULGATE/server/boss_ai.py
2026-05-04 03:42:47 +02:00

255 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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