flying boss

boss addtion, small editor changes
This commit is contained in:
2026-10-02 10:56:51 +02:00
parent c7558a3947
commit 73cfd870f6
8 changed files with 742 additions and 20 deletions
+565 -3
View File
@@ -433,6 +433,9 @@ MINIBOSS_HIT_COLOR = _color(["enemy", "miniboss_hit"])
BOSS_COLOR = _color(["enemy", "boss"])
BOSS_HIT_COLOR = _color(["enemy", "boss_hit"])
FLYER_BOSS_COLOR = _color(["enemy", "boss_flyer"], (150, 90, 220))
FLYER_BOSS_HIT_COLOR = _color(["enemy", "boss_flyer_hit"], (210, 170, 255))
PLAYER_HIT_COLOR = (255, 255, 0)
HEAL_DARK = _color(["healing", "dark"])
@@ -907,6 +910,49 @@ ENEMY_TYPES_DEFAULT = {
"above_retreat": True
}
},
"boss_flyer": {
"rect": [120, 90],
"health": 26,
"speed": 3.0,
"air_speed": 3.0,
"detection_range": 620,
"contact_damage": 3,
"drop_profile": "boss",
"behavior": "flyer",
"flyer": {
"flight_height": 220,
"bob_amplitude": 20,
"bob_speed": 2.2,
"hover_speed": 2.6,
"slam_cd": 6.0,
"slam_dive_vel": 16.0,
"ground_time": 2.0,
"summon_cd": 8.0,
"summon_max": 3,
"summon_type": "boss_minion",
"summon_life": 6.0,
"summon_heal": 4,
"summon_damage": 3,
"aoe_cd": 9.0,
"aoe_radius": 300,
"aoe_damage": 1,
"aoe_duration": 0.9,
"shot_cd": 5.0,
"shot_damage": 5,
"shot_speed": 300,
"shot_lifetime": 2.2,
"shot_homing": 1.8
}
},
"boss_minion": {
"rect": [34, 50],
"health": 4,
"speed": 2.4,
"air_speed": 2.2,
"detection_range": 320,
"contact_damage": 1,
"drop_profile": "normal"
},
}
ENEMY_DROPS_DEFAULT = {
@@ -1453,6 +1499,15 @@ BOSS_TOKEN_SPRITE_RAW = _load_first_existing([
OBJECTS_DIR / "Boss_Token.png",
])
# Flying boss projectile sprite (faces RIGHT by default; flipped when fired left)
ENEMY_SKILLS_DIR = DATA_ROOT / "sprites" / "enemy_skills"
BOSS_PROJECTILE_RAW = _load_first_existing([
ENEMY_SKILLS_DIR / "boss_ranged.png",
ENEMY_SKILLS_DIR / "boss_ranged",
ENEMY_SKILLS_DIR / "Boss_ranged.png",
])
BOSS_PROJECTILE_RAW_LEFT = pygame.transform.flip(BOSS_PROJECTILE_RAW, True, False) if BOSS_PROJECTILE_RAW is not None else None
def _scale_icon(img: pygame.Surface | None, size: int) -> pygame.Surface | None:
if img is None:
return None
@@ -1628,6 +1683,123 @@ class Projectile:
)
class BossProjectile:
"""Homing projectile fired by the flying boss. Slightly curves toward the
player each frame. Uses boss_ranged.png (faces right; flipped when fired
to the left)."""
def __init__(self, x, y, dx, dy, damage, speed, lifetime, homing, player):
self.rect = pygame.Rect(0, 0, 26, 26)
self.rect.center = (int(x), int(y))
self.dir_x = float(dx)
self.dir_y = float(dy)
d = math.hypot(self.dir_x, self.dir_y)
if d > 0.0001:
self.dir_x /= d
self.dir_y /= d
self.speed = float(speed)
self.t = float(lifetime)
self.damage = float(damage)
self.homing = float(homing)
self.player = player
def update(self, dt: float, platforms: list[pygame.Rect]):
# slight homing: steer the direction toward the player
px = self.player.rect.centerx - self.rect.centerx
py = self.player.rect.centery - self.rect.centery
plen = math.hypot(px, py)
if plen > 1.0:
tx = px / plen
ty = py / plen
k = min(1.0, self.homing * dt)
self.dir_x += (tx - self.dir_x) * k
self.dir_y += (ty - self.dir_y) * k
d = math.hypot(self.dir_x, self.dir_y)
if d > 0.0001:
self.dir_x /= d
self.dir_y /= d
self.rect.x += int(self.dir_x * self.speed * dt)
self.rect.y += int(self.dir_y * self.speed * dt)
self.t -= dt
for p in platforms:
if self.rect.colliderect(p):
self.t = 0
break
@property
def alive(self) -> bool:
return self.t > 0
def draw(self, surface, cx, cy):
if BOSS_PROJECTILE_RAW is not None:
if self.dir_x >= 0:
img = BOSS_PROJECTILE_RAW
angle = -math.degrees(math.atan2(self.dir_y, self.dir_x))
else:
img = BOSS_PROJECTILE_RAW_LEFT
angle = math.degrees(math.atan2(self.dir_y, -self.dir_x))
img = pygame.transform.rotate(img, angle)
surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx,
self.rect.centery - img.get_height() // 2 - cy))
return
# fallback: glowing bolt
pygame.draw.rect(
surface,
(255, 120, 60),
pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height),
)
pygame.draw.rect(
surface,
(0, 0, 0),
pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height),
1,
)
class BossAoe:
"""Huge expanding circular blast: low damage, high knockback, one hit."""
def __init__(self, x, y, max_radius, damage, duration,
knock_strength=16.0, knock_duration=0.35, pop_up=-6.0):
self.center = (int(x), int(y))
self.max_radius = int(max_radius)
self.radius = 0
self.damage = int(damage)
self.full_duration = max(0.05, float(duration))
self.t = float(duration)
self.hit = False
self.knock_strength = float(knock_strength)
self.knock_duration = float(knock_duration)
self.pop_up = float(pop_up)
def update(self, dt: float):
self.t -= dt
progress = 1.0 - max(0.0, self.t / self.full_duration)
self.radius = int(self.max_radius * progress)
@property
def alive(self) -> bool:
return self.t > 0
def hits(self, rect: pygame.Rect) -> bool:
if self.hit:
return False
cx, cy = self.center
nx = max(rect.left, min(cx, rect.right))
ny = max(rect.top, min(cy, rect.bottom))
return math.hypot(cx - nx, cy - ny) <= self.radius
def draw(self, surface, cx, cy):
progress = 1.0 - max(0.0, self.t / self.full_duration)
alpha = int(110 * (1.0 - progress * 0.65))
px = self.center[0] - cx
py = self.center[1] - cy
ring = pygame.Surface((self.radius * 2 + 6, self.radius * 2 + 6), pygame.SRCALPHA)
pygame.draw.circle(ring, (255, 120, 60, alpha), (self.radius + 3, self.radius + 3), self.radius, 4)
pygame.draw.circle(ring, (255, 210, 130, alpha), (self.radius + 3, self.radius + 3), self.radius, 1)
surface.blit(ring, (px - self.radius - 3, py - self.radius - 3))
class TetherShot:
def __init__(self, x, y, direction: int, player_ref: "Player"):
w, h = TETHER_SIZE
@@ -4430,6 +4602,51 @@ class Enemy:
self.charge_timer = 0.0
self.attack_cd = 0.0
# --- Flying boss ("flyer") behavior state ---
flyer_cfg = (cfg.get("flyer", {}) if isinstance(cfg, dict) else {})
if not isinstance(flyer_cfg, dict):
flyer_cfg = {}
self.flyer_flight_height = float(flyer_cfg.get("flight_height", 100))
self.flyer_bob_amplitude = float(flyer_cfg.get("bob_amplitude", 20))
self.flyer_bob_speed = float(flyer_cfg.get("bob_speed", 2.2))
self.flyer_hover_speed = float(flyer_cfg.get("hover_speed", 2.6))
self.flyer_slam_cooldown = float(flyer_cfg.get("slam_cd", 6.0))
self.flyer_dive_vel = float(flyer_cfg.get("slam_dive_vel", 16.0))
self.flyer_ground_time = float(flyer_cfg.get("ground_time", 2.0))
self.flyer_summon_cooldown = float(flyer_cfg.get("summon_cd", 8.0))
self.flyer_summon_max = int(flyer_cfg.get("summon_max", 3))
self.flyer_summon_type = str(flyer_cfg.get("summon_type", "boss_minion"))
self.flyer_summon_life = float(flyer_cfg.get("summon_life", 6.0))
self.flyer_summon_heal = float(flyer_cfg.get("summon_heal", 4))
self.flyer_summon_damage = float(flyer_cfg.get("summon_damage", 3))
self.flyer_aoe_cooldown = float(flyer_cfg.get("aoe_cd", 9.0))
self.flyer_aoe_radius = float(flyer_cfg.get("aoe_radius", 300))
self.flyer_aoe_damage = int(flyer_cfg.get("aoe_damage", 1))
self.flyer_aoe_duration = float(flyer_cfg.get("aoe_duration", 0.9))
self.flyer_shot_cooldown = float(flyer_cfg.get("shot_cd", 5.0))
self.flyer_shot_damage = float(flyer_cfg.get("shot_damage", 5))
self.flyer_shot_speed = float(flyer_cfg.get("shot_speed", 300))
self.flyer_shot_lifetime = float(flyer_cfg.get("shot_lifetime", 2.2))
self.flyer_shot_homing = float(flyer_cfg.get("shot_homing", 1.8))
self.flyer_state = "hover" # hover | diving | grounded
self.flyer_grounded_timer = 0.0
self.flyer_bob_t = random.random() * 10.0
self.flyer_last_ground_top = self.rect.bottom
self.flyer_slam_cd = 0.0
self.flyer_summon_cd = 0.0
self.flyer_aoe_cd = 0.0
self.flyer_shot_cd = 0.0
# --- summoned boss minion: expires after a short life, then heals the
# boss; killing it early damages the boss. Color fades toward white. ---
self.is_minion = (self.type_id == "boss_minion")
self.minion_timer = 0.0
self.minion_duration = 0.0
self.minion_owner = None
self.minion_heal = 0.0
self.minion_damage = 0.0
self.minion_expired = False
# --- anti-jiggle helpers ---
self.turn_cooldown = 0.0 # prevents rapid flip-flop at edges/walls
self.last_x = self.rect.x
@@ -4478,6 +4695,15 @@ class Enemy:
self.charge_timer = 0.0
self.attack_cd = 0.0
self.flyer_state = "hover"
self.flyer_grounded_timer = 0.0
self.flyer_bob_t = random.random() * 10.0
self.flyer_last_ground_top = self.rect.bottom
self.flyer_slam_cd = 0.0
self.flyer_summon_cd = 0.0
self.flyer_aoe_cd = 0.0
self.flyer_shot_cd = 0.0
self.turn_cooldown = 0.0
self.last_x = self.rect.x
self.stuck_timer = 0.0
@@ -4617,6 +4843,11 @@ class Enemy:
elif self.behavior == "charger":
if self.attack_cd > 0:
self.attack_cd -= dt
elif self.behavior == "flyer":
for attr in ("flyer_slam_cd", "flyer_summon_cd", "flyer_aoe_cd", "flyer_shot_cd"):
v = getattr(self, attr)
if v > 0:
setattr(self, attr, max(0.0, v - dt))
def _behavior_update(self, dt, player, distance, dx, stunned):
"""Choose/advance an attack per behavior. Runs before gravity/move."""
@@ -4700,7 +4931,255 @@ class Enemy:
return self.charge_dir * self.speed * self.dash_speed_mult
return None
def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave]):
# --- Flying boss ("flyer") behavior ---
def _flyer_update(self, dt, platforms, player, shockwaves_list,
projectiles_list, enemies_list, aoes_list):
"""The flying boss hovers in the air (bobbing like wings), and uses its
aerial skills: slam when the player is below, ground minion summoning,
a huge low-damage/high-knockback AOE, and a slightly-homing shot."""
# own timer ticks (the flyer bypasses the shared ground-based update body)
if self.hit_timer > 0:
self.hit_timer -= dt
if self.bind_timer > 0.0:
self.bind_timer = max(0.0, self.bind_timer - dt)
if self.fear_timer > 0.0:
self.fear_timer = max(0.0, self.fear_timer - dt)
if self.slow_timer > 0.0:
self.slow_timer = max(0.0, self.slow_timer - dt)
if self.slow_timer == 0.0:
self.slow_mult = 1.0
self._behavior_tick(dt)
stunned = self.stun_timer > 0.0
if self.stun_timer > 0.0:
self.stun_timer = max(0.0, self.stun_timer - dt)
dx = player.rect.centerx - self.rect.centerx
dy = player.rect.centery - self.rect.centery
distance = math.hypot(dx, dy)
chasing = (not player.shade) and (distance <= self.detection_range)
feared = self.fear_timer > 0.0
if feared:
chasing = False
self.direction = -1 if dx > 0 else 1
elif chasing and abs(dx) > 8:
self.direction = 1 if dx > 0 else -1
speed = self.flyer_hover_speed
if self.slow_timer > 0.0:
speed *= self.slow_mult
if feared:
speed *= TETHER_FEAR_SPEED_MULT
if self.flyer_state == "diving":
self._flyer_update_diving(dt, platforms, speed, shockwaves_list)
elif self.flyer_state == "grounded":
ground_speed = self.speed * (self.slow_mult if self.slow_timer > 0.0 else 1.0)
if feared:
ground_speed *= TETHER_FEAR_SPEED_MULT
self._flyer_update_grounded(dt, platforms, player, ground_speed, feared)
else:
self._flyer_update_hover(dt, platforms, player, speed, chasing, feared,
stunned, projectiles_list, enemies_list, aoes_list)
if self.rect.top > (ROOM.kill_y + 400):
self.respawn()
return
self.was_chasing = chasing
def _flyer_update_hover(self, dt, platforms, player, speed, chasing, feared,
stunned, projectiles_list, enemies_list, aoes_list):
ground = self.support_below(platforms, 5000)
if ground is not None:
self.flyer_last_ground_top = ground.top
ground_top = getattr(self, "flyer_last_ground_top", self.rect.bottom)
self.flyer_bob_t += dt * self.flyer_bob_speed
bob = math.sin(self.flyer_bob_t) * self.flyer_bob_amplitude
target_y = ground_top - self.flyer_flight_height + bob
if feared:
self.vel_x = -self.direction * speed
elif chasing and not stunned:
self.vel_x = self.direction * speed
else:
self.vel_x *= 0.9
if abs(self.vel_x) < 0.5:
self.vel_x = 0.0
# ease vertically toward the hover target (stable, frame-rate-based like the rest)
diff = target_y - self.rect.y
self.rect.y += int(diff * min(1.0, 0.12))
self.rect.x += int(self.vel_x)
# room bounds safety net (the flying boss ignores platform walls; it
# hovers above the terrain and only needs to stay inside the room)
lo = getattr(ROOM, "min_x", None)
hi = getattr(ROOM, "max_x", None)
if lo is not None:
self.rect.left = max(self.rect.left, lo)
if hi is not None:
self.rect.right = min(self.rect.right, hi)
room_h = getattr(ROOM, "room_h", 900)
self.rect.y = max(20, min(self.rect.y, room_h - self.rect.height))
if chasing and (not stunned) and (not feared):
self._flyer_cast_skills(dt, player, projectiles_list, enemies_list, aoes_list, platforms)
def _flyer_update_diving(self, dt, platforms, speed, shockwaves_list):
# dive down until we hit a platform, then slam and stay grounded a moment.
self.vel_y = min(self.flyer_dive_vel, self.vel_y + 1.0)
self.rect.y += int(self.vel_y)
self.rect.x += int(self.direction * speed * dt)
for p in platforms:
if self.rect.colliderect(p):
self.rect.bottom = p.top
self.vel_y = 0.0
self.flyer_state = "grounded"
self.flyer_grounded_timer = self.flyer_ground_time
self.flyer_last_ground_top = p.top
spawn_boss_shockwave(self, shockwaves_list)
break
def _flyer_update_grounded(self, dt, platforms, player, speed, feared):
"""After a slam the boss stays grounded briefly: walks toward the player
and can deal contact damage, then lifts back into the air."""
self.flyer_grounded_timer -= dt
dx = player.rect.centerx - self.rect.centerx
if feared:
self.direction = -1 if dx > 0 else 1
elif abs(dx) > 8:
self.direction = 1 if dx > 0 else -1
# gravity + vertical move/landing
self.vel_y = min(24.0, self.vel_y + 0.5)
self.rect.y += int(self.vel_y)
landed = False
for p in platforms:
if self.rect.colliderect(p):
if self.vel_y > 0:
self.rect.bottom = p.top
self.vel_y = 0.0
landed = True
elif self.vel_y < 0:
self.rect.top = p.bottom
self.vel_y = 0.0
self.on_ground = landed
# horizontal move + wall collision
self.vel_x = self.direction * speed
self.rect.x += int(self.vel_x)
for p in platforms:
if self.rect.colliderect(p):
if self.vel_x > 0:
self.rect.right = p.left
elif self.vel_x < 0:
self.rect.left = p.right
self.vel_x = 0.0
break
# room bounds safety net
lo = getattr(ROOM, "min_x", None)
hi = getattr(ROOM, "max_x", None)
if lo is not None:
self.rect.left = max(self.rect.left, lo)
if hi is not None:
self.rect.right = min(self.rect.right, hi)
if self.flyer_grounded_timer <= 0.0:
self.flyer_state = "hover"
self.vel_y = 0.0
self.vel_x = 0.0
def _flyer_cast_skills(self, dt, player, projectiles_list, enemies_list, aoes_list, platforms):
# Slam takes priority whenever the player is below the boss.
if self.flyer_slam_cd <= 0.0 and player.rect.centery > self.rect.centery + 20:
self.flyer_state = "diving"
self.vel_y = 6.0
self.flyer_slam_cd = self.flyer_slam_cooldown
return
# Otherwise pick from the ranged kit (shuffle so it isn't a fixed order).
for skill in random.sample(("shot", "summon", "aoe"), 3):
if skill == "shot" and self.flyer_shot_cd <= 0.0:
self._flyer_cast_shot(player, projectiles_list)
return
if skill == "summon" and self.flyer_summon_cd <= 0.0:
if self._flyer_cast_summon(enemies_list, platforms):
return
if skill == "aoe" and self.flyer_aoe_cd <= 0.0:
self._flyer_cast_aoe(aoes_list)
return
def _flyer_cast_shot(self, player, projectiles_list):
sx = self.rect.centerx
sy = self.rect.centery
tx = player.rect.centerx - sx
ty = player.rect.centery - sy
length = math.hypot(tx, ty)
if length < 1.0:
tx, ty, length = 1.0, 0.0, 1.0
projectiles_list.append(BossProjectile(
sx, sy, tx / length, ty / length,
self.flyer_shot_damage, self.flyer_shot_speed,
self.flyer_shot_lifetime, self.flyer_shot_homing, player,
))
self.flyer_shot_cd = self.flyer_shot_cooldown
def _flyer_cast_summon(self, enemies_list, platforms) -> bool:
alive = sum(
1 for en in enemies_list
if getattr(en, "type_id", "") == self.flyer_summon_type and en.health > 0
)
if alive >= self.flyer_summon_max:
return False
ground = self.support_below(platforms, 5000)
if ground is not None:
gx = self.rect.centerx
gy = ground.top
else:
gx = self.rect.centerx
gy = int(getattr(ROOM, "room_h", 900)) - 40
s_cfg = ENEMY_TYPES.get(self.flyer_summon_type, ENEMY_TYPES.get("enemy", {}))
sw = int(s_cfg.get("rect", [40, 60])[0]) if isinstance(s_cfg, dict) else 40
sh = int(s_cfg.get("rect", [40, 60])[1]) if isinstance(s_cfg, dict) else 60
minion = Enemy(gx - sw // 2, gy - sh, self.flyer_summon_type)
minion.is_minion = True
minion.minion_owner = self
minion.minion_duration = self.flyer_summon_life
minion.minion_timer = self.flyer_summon_life
minion.minion_heal = self.flyer_summon_heal
minion.minion_damage = self.flyer_summon_damage
enemies_list.append(minion)
self.flyer_summon_cd = self.flyer_summon_cooldown
return True
def _flyer_cast_aoe(self, aoes_list):
aoes_list.append(BossAoe(
self.rect.centerx, self.rect.centery,
self.flyer_aoe_radius, self.flyer_aoe_damage, self.flyer_aoe_duration,
))
self.flyer_aoe_cd = self.flyer_aoe_cooldown
def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave],
projectiles_list=None, enemies_list=None, aoes_list=None):
# Summoned minions expire after their short life: no drops, heals the boss.
if getattr(self, "is_minion", False) and self.minion_duration > 0:
self.minion_timer -= dt
if self.minion_timer <= 0.0:
self.minion_timer = 0.0
self.health = 0.0
self.hazard_kill = True
self.minion_expired = True
# Flying boss: completely replaces the ground-based AI body.
if self.behavior == "flyer":
self._flyer_update(dt, platforms, player, shockwaves_list,
projectiles_list or [], enemies_list or [], aoes_list or [])
return
if self.jump_cd_timer > 0:
self.jump_cd_timer -= dt
@@ -5126,13 +5605,21 @@ class Enemy:
def draw(self, surface, cx, cy):
if self.drop_profile == "boss":
if self.behavior == "flyer":
color = FLYER_BOSS_HIT_COLOR if self.hit_timer > 0 else FLYER_BOSS_COLOR
elif self.drop_profile == "boss":
color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR
elif self.drop_profile == "miniboss":
color = MINIBOSS_HIT_COLOR if self.hit_timer > 0 else MINIBOSS_COLOR
else:
color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR
# Summoned minions fade toward white as their life runs out.
if getattr(self, "is_minion", False) and self.minion_duration > 0:
frac = max(0.0, min(1.0, self.minion_timer / self.minion_duration))
light = 1.0 - frac
color = tuple(int(c + (255 - c) * light * 0.9) for c in color)
pygame.draw.rect(
surface,
color,
@@ -6026,6 +6513,8 @@ shockwaves: list[Shockwave] = []
coin_drops: list[CoinPickup] = []
token_drops: list = []
projectiles: list[Projectile] = []
boss_projectiles: list[BossProjectile] = []
boss_aoes: list[BossAoe] = []
shatter_platforms: list[ShatterPlatform] = []
shatter_aoes: list[ShatterAOE] = []
tether_shots: list[TetherShot] = []
@@ -6236,6 +6725,7 @@ def apply_room(room: Room, set_player_spawn=True):
global platforms_dissolve
global platform_layer
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global boss_projectiles, boss_aoes
global shatter_platforms, shatter_aoes, tether_shots
global light_sources, save_points, butterfly_jars, houses
global dust_puffs
@@ -6273,6 +6763,8 @@ def apply_room(room: Room, set_player_spawn=True):
coin_drops = []
token_drops = []
projectiles = []
boss_projectiles = []
boss_aoes = []
shatter_platforms = []
shatter_aoes = []
tether_shots = []
@@ -6310,6 +6802,7 @@ def reset_room_combat_state():
"""Rebuild room enemies/NPCs/doors and clear transient combat objects."""
global enemies, npcs, loot_drops, shockwaves, coin_drops, token_drops, projectiles
global shatter_platforms, shatter_aoes, dust_puffs, tether_shots, ability_doors
global boss_projectiles, boss_aoes
reset_trigger_wall_locks()
enemies = spawn_enemies_from_room(ROOM)
npcs = spawn_npcs_from_room(ROOM)
@@ -6318,6 +6811,8 @@ def reset_room_combat_state():
coin_drops = []
token_drops = []
projectiles = []
boss_projectiles = []
boss_aoes = []
shatter_platforms = []
shatter_aoes = []
dust_puffs = []
@@ -8113,8 +8608,12 @@ while running:
t.draw(screen, camera_x, camera_y)
for sw in shockwaves:
sw.draw(screen, camera_x, camera_y)
for a in boss_aoes:
a.draw(screen, camera_x, camera_y)
for pr in projectiles:
pr.draw(screen, camera_x, camera_y)
for bp in boss_projectiles:
bp.draw(screen, camera_x, camera_y)
for ts in tether_shots:
ts.draw(screen, camera_x, camera_y)
if corpse is not None and corpse.room_id == ROOM.id:
@@ -8420,7 +8919,8 @@ while running:
# Update enemies
for en in enemies:
en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves)
en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves,
projectiles, enemies, boss_aoes)
# Enemies that touch a hazard zone die instantly, dropping nothing.
for en in enemies:
@@ -8447,6 +8947,12 @@ while running:
if any(box.colliderect(en.rect) for box in atk_boxes):
player.swing_hit_ids.add(eid)
en.take_damage(dmg)
# Downward strike bounce: hitting an enemy from above while
# swinging down bounces the player up (stomp/pogo).
if player.attack_dir_y > 0.3 and player.rect.bottom <= en.rect.top + 6:
player.vel_y = min(player.vel_y, -8.0)
player.on_ground = False
player.ground_platform = None
# Dash interactions
if player.is_dashing():
@@ -8485,6 +8991,11 @@ while running:
pr.update(dt, collision_platforms, enemies, ability_doors)
projectiles = [pr for pr in projectiles if pr.alive]
# Update flying-boss projectiles (homing shots)
for bp in boss_projectiles:
bp.update(dt, collision_platforms)
boss_projectiles = [bp for bp in boss_projectiles if bp.alive]
# Update tether shots
for ts in tether_shots:
ts.update(dt, collision_platforms, enemies)
@@ -8496,6 +9007,11 @@ while running:
sw.update(dt, collision_platforms)
shockwaves = [sw for sw in shockwaves if sw.alive]
# Update flying-boss AOE rings
for a in boss_aoes:
a.update(dt)
boss_aoes = [a for a in boss_aoes if a.alive]
# Update shatter effects
for sp in shatter_platforms:
sp.update(dt)
@@ -8516,6 +9032,27 @@ while running:
player.apply_poison(BOSS_POISON_DURATION)
sw.t = 0 # one-hit
# Flying-boss AOE rings hit the player (one hit per ring)
for a in boss_aoes:
if (not player.shade) and a.hits(player.rect):
did_hit = player.take_damage(a.damage)
if did_hit:
player.apply_knockback(
pygame.Rect(a.center[0] - 2, a.center[1] - 2, 4, 4),
strength_px_per_frame=a.knock_strength,
duration=a.knock_duration,
pop_up_vel=a.pop_up,
)
a.hit = True
# Flying-boss homing projectiles hit the player
for bp in boss_projectiles[:]:
if (not player.shade) and player.rect.colliderect(bp.rect):
did_hit = player.take_damage(bp.damage)
if did_hit:
player.apply_knockback(bp.rect, strength_px_per_frame=10.0, duration=0.18, pop_up_vel=-3.0)
boss_projectiles.remove(bp)
# Update loot drops (gravity + landing)
@@ -8541,8 +9078,20 @@ while running:
if en.health > 0:
alive.append(en)
else:
# Summoned minion expired on its own: heals the boss, no drops.
if getattr(en, "minion_expired", False):
owner = getattr(en, "minion_owner", None)
if owner is not None and owner.health > 0:
owner.health = min(owner.max_health, owner.health + getattr(en, "minion_heal", 0.0))
continue
if getattr(en, "hazard_kill", False):
continue # hazard deaths drop nothing and give no quest credit
# Player-killed minion: damages the boss, no drops.
if getattr(en, "type_id", "") == "boss_minion":
owner = getattr(en, "minion_owner", None)
if owner is not None and owner.health > 0:
owner.take_damage(getattr(en, "minion_damage", 0.0))
continue
loot_drops.extend(spawn_drops_from_enemy(en))
etype = getattr(en, "type_id", "")
eprof = getattr(en, "drop_profile", "")
@@ -8551,6 +9100,13 @@ while running:
bid = boss_id_for_room(ROOM.id)
if bid and ACTIVE_REFIGHT is None:
token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid))
# A defeated flying boss takes its summoned minions down with it.
for other in enemies:
if other is en:
continue
if getattr(other, "type_id", "") == "boss_minion" and other.health > 0:
other.health = 0.0
other.hazard_kill = True
elif eprof == "miniboss":
DEFEATED_MINIBOSSES.add(ROOM.id)
@@ -8680,9 +9236,15 @@ while running:
for sw in shockwaves:
sw.draw(screen, camera_x, camera_y)
# flying-boss AOE rings
for a in boss_aoes:
a.draw(screen, camera_x, camera_y)
# projectiles
for pr in projectiles:
pr.draw(screen, camera_x, camera_y)
for bp in boss_projectiles:
bp.draw(screen, camera_x, camera_y)
for ts in tether_shots:
ts.draw(screen, camera_x, camera_y)