diff --git a/light_and_dark/README.md b/light_and_dark/README.md index 78dcdda..1b723f3 100644 --- a/light_and_dark/README.md +++ b/light_and_dark/README.md @@ -37,6 +37,10 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop | Stats overlay | H | | Pause / Settings | Esc | +**Mouse**: **Left button** = melee attack, **Right button** = ranged shot. + +**Hold-to-recast**: Melee (E / left mouse) and Ranged (Q / right mouse) can be **held** — the attack auto-recasts as soon as its cooldown is ready, and never faster than the cooldown. + --- ## The two forms @@ -77,7 +81,7 @@ When you die you lose your **butterfly jars for good** and drop your coins. A ** |---|---|---|---| | **Melee** | E | 1 damage, costs 1 Charge | **2 damage**, costs 1 Charge | | **Dash** | Tab | Costs 10 Charge; **stuns** enemies hit | Costs 1 HP; deals **4 damage** to enemies hit | -| **Ranged** | Q | Costs 5 Charge, 0.45s cooldown, 0.5 damage; **breaks green ability walls** | Same, but 1.0 damage | +| **Ranged** | Q | Costs 5 Charge, 0.63s cooldown, 0.5 damage; **breaks green ability walls** | Same, but 1.0 damage | | **Shatterstep** | R | Spawns a **platform under your feet** while airborne (extra jumps) | On landing, creates a damaging **shockwave AOE** below you | | **Tether** | X | **Binds** the enemy (3s) and **fears** it (runs away) | **Insta-kills** enemies below a HP threshold (normal ⅓, miniboss 20%, boss 5%); otherwise **slows** them | | **Reality Break** | C | Doubles drop & coin rates and glows enemies | **Instantly kills all enemies** but halves drop & coin rates | diff --git a/light_and_dark/data/config/colors.json b/light_and_dark/data/config/colors.json index 606802e..71ca63c 100644 --- a/light_and_dark/data/config/colors.json +++ b/light_and_dark/data/config/colors.json @@ -22,7 +22,9 @@ "miniboss": [170, 40, 210], "miniboss_hit": [220, 140, 255], "boss": [240, 220, 80], - "boss_hit": [255, 245, 160] + "boss_hit": [255, 245, 160], + "boss_flyer": [150, 90, 220], + "boss_flyer_hit": [210, 170, 255] }, "healing": { diff --git a/light_and_dark/data/enemies/enemy_types.json b/light_and_dark/data/enemies/enemy_types.json index 3201e71..1e344a8 100644 --- a/light_and_dark/data/enemies/enemy_types.json +++ b/light_and_dark/data/enemies/enemy_types.json @@ -40,6 +40,51 @@ "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" } } diff --git a/light_and_dark/data/sprites/enemy_skills/boss_ranged.png b/light_and_dark/data/sprites/enemy_skills/boss_ranged.png new file mode 100644 index 0000000..9cd9170 Binary files /dev/null and b/light_and_dark/data/sprites/enemy_skills/boss_ranged.png differ diff --git a/light_and_dark/data/world/rooms/room_07.json b/light_and_dark/data/world/rooms/room_07.json index 09b5eae..8cdc410 100644 --- a/light_and_dark/data/world/rooms/room_07.json +++ b/light_and_dark/data/world/rooms/room_07.json @@ -14,6 +14,10 @@ "room7_top_left": [ 180, 460 + ], + "room7_bottom_right": [ + 1040, + 460 ] }, "platforms": [ @@ -28,10 +32,18 @@ { "x": 80, "y": 520, - "w": 920, + "w": 1080, "h": 20, "group": "ground", "layer": 0 + }, + { + "x": 1160, + "y": 320, + "w": 20, + "h": 220, + "group": "ground", + "layer": 0 } ], "enemies": [], @@ -64,7 +76,9 @@ "y": 520, "type": "save_point", "radius": 90, - "object_id": "lamp" + "object_id": "lamp", + "layer": 0, + "quest_id": "" } ], "exits": [ @@ -81,6 +95,20 @@ ], "hint": "", "layer": 0 + }, + { + "x": 1080, + "y": 420, + "w": 80, + "h": 100, + "to": "room_08", + "spawn_id": "room8_bottom_left", + "spawn": [ + 300, + 400 + ], + "hint": "", + "layer": 0 } ], "ability_doors": [], diff --git a/light_and_dark/data/world/rooms/room_08.json b/light_and_dark/data/world/rooms/room_08.json new file mode 100644 index 0000000..2e414b4 --- /dev/null +++ b/light_and_dark/data/world/rooms/room_08.json @@ -0,0 +1,74 @@ +{ + "name": "room_08", + "display_name": "room_08", + "meta": { + "id": "room_08", + "name": "room_08" + }, + "bounds": {}, + "player_spawn": [ + 340, + 300 + ], + "entry_spawns": { + "room8_bottom_left": [ + 300, + 400 + ] + }, + "platforms": [ + { + "x": 200, + "y": 460, + "w": 1340, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 180, + "y": 120, + "w": 20, + "h": 360, + "group": "ground", + "layer": 0 + }, + { + "x": 1520, + "y": 100, + "w": 20, + "h": 360, + "group": "ground", + "layer": 0 + } + ], + "enemies": [ + { + "x": 820, + "y": 220, + "type": "boss", + "layer": 0, + "behavior": "flyer" + } + ], + "npcs": [], + "props": [], + "exits": [ + { + "x": 200, + "y": 360, + "w": 80, + "h": 100, + "to": "room_07", + "spawn_id": "room7_bottom_right", + "spawn": [ + 1040, + 460 + ] + } + ], + "ability_doors": [], + "room_w": 1600, + "room_h": 900, + "mist": false +} \ No newline at end of file diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html index 1544864..3723d4f 100644 --- a/light_and_dark/level_editor_v43.html +++ b/light_and_dark/level_editor_v43.html @@ -88,10 +88,10 @@
- +
- 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)