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
+5 -1
View File
@@ -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 |
+3 -1
View File
@@ -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": {
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

+30 -2
View File
@@ -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": [],
@@ -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
}
+20 -13
View File
@@ -88,10 +88,10 @@
<div class="row">
<button id="downloadActiveBtn" class="good">Download Active</button>
<button id="downloadCheckedBtn">Download Checked</button>
<button id="downloadAllBtn">Download All</button>
<button id="downloadAllBtn">Download Modified</button>
</div>
<div class="muted" style="margin-top:6px;">
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.
</div>
</div>
@@ -176,7 +176,7 @@
</div> <div class="row" style="margin-bottom:8px;">
<label class="muted" style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="showLinksChk"/>
Draw link lines (auto-enables Compare)
Draw link lines
</label>
</div>
@@ -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`);
<option value="" ${beh===""?"selected":""}>default (from enemy type)</option>
<option value="shockwave" ${beh==="shockwave"?"selected":""}>shockwave (slam + shockwave)</option>
<option value="charger" ${beh==="charger"?"selected":""}>charger (retreat-charge + dash)</option>
<option value="flyer" ${beh==="flyer"?"selected":""}>flyer (air boss: slam, minions, aoe, homing shot)</option>
</select>
</div>
` : ""}
@@ -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
+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)