- Downloads are per-room. Checked download saves one file per checked room; Download All saves every loaded room.
+ Downloads are per-room. Checked download saves one file per checked room; Download Modified saves every room you've changed.
@@ -176,7 +176,7 @@
@@ -288,6 +288,17 @@
dash_duration: 0.7, attack_cooldown: 1.8, above_y: 40
}
},
+ 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" },
};
function seedDefaultObjectCatalog(){
@@ -2317,6 +2328,7 @@ Cancel = Choose whether to remove without saving`);
+
` : ""}
@@ -3783,14 +3795,9 @@ Cancel = Choose whether to remove without saving`);
refreshUI();
});
+ els.showLinksChk.checked = false; // always start unchecked (no auto Compare)
els.showLinksChk.addEventListener("change", ()=>{
- if(els.showLinksChk.checked){
- if(!state.compareEnabled){
- state.compareEnabled = true;
- const other = [...state.rooms.keys()].find(k => k !== state.activeId);
- state.compareId = other || state.activeId;
- }
- }
+ // Lines only draw while Compare is on too (see drawLinkLinesForCanvas).
// overlay updates in render loop
refreshUI();
});
@@ -3850,8 +3857,8 @@ els.compareRoomSelect.addEventListener("change", ()=>{
});
els.downloadAllBtn.addEventListener("click", async ()=>{
- const rooms = [...state.rooms.values()];
- if(!rooms.length){ setStatus("No rooms loaded."); return; }
+ const rooms = [...state.rooms.values()].filter(r=>r.dirty);
+ if(!rooms.length){ setStatus("No modified rooms."); return; }
let i=0;
for(const r of rooms){
downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
@@ -3860,7 +3867,7 @@ els.compareRoomSelect.addEventListener("change", ()=>{
await new Promise(res=>setTimeout(res, 250));
}
refreshRoomsList();
- setStatus(`Downloaded ${i} room(s).`);
+ setStatus(`Downloaded ${i} modified room(s).`);
});
// canvas pointer handlers
diff --git a/light_and_dark/main.py b/light_and_dark/main.py
index 61ec339..ad08442 100644
--- a/light_and_dark/main.py
+++ b/light_and_dark/main.py
@@ -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)