255 lines
9.8 KiB
Python
255 lines
9.8 KiB
Python
# 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
|