flyer boss, town

flyer boss adjustments, town addition
This commit is contained in:
2026-10-05 15:04:20 +02:00
parent 73cfd870f6
commit d56540785f
14 changed files with 491 additions and 123 deletions
+149 -47
View File
@@ -1499,6 +1499,28 @@ BOSS_TOKEN_SPRITE_RAW = _load_first_existing([
OBJECTS_DIR / "Boss_Token.png",
])
BOSS_TOKEN_SPRITE_CACHE: dict[str, pygame.Surface | None] = {}
def boss_token_sprite(boss_id) -> pygame.Surface | None:
"""Per-boss token sprite. Uses ``token_sprite`` from BOSS_DEFS_DEFAULT when
set, else ``<boss_id>_token.png``; falls back to the shared token sprite."""
key = str(boss_id or "")
if key in BOSS_TOKEN_SPRITE_CACHE:
return BOSS_TOKEN_SPRITE_CACHE[key]
bd = BOSS_DEFS_DEFAULT.get(key, {}) if key else {}
candidates: list[Path] = []
if isinstance(bd, dict) and bd.get("token_sprite"):
candidates.append(TOKENS_DIR / str(bd["token_sprite"]))
if key:
candidates.append(TOKENS_DIR / f"{key}_token.png")
img = _load_first_existing(candidates) if candidates else None
if img is None:
img = BOSS_TOKEN_SPRITE_RAW
BOSS_TOKEN_SPRITE_CACHE[key] = img
return img
# 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([
@@ -1569,12 +1591,13 @@ def roll_from_table(table: dict) -> str | None:
class Shockwave:
def __init__(self, x, y, direction: int, speed: float, lifetime: float, height: int, damage: int):
def __init__(self, x, y, direction: int, speed: float, lifetime: float, height: int, damage: int, poison: bool = True):
self.rect = pygame.Rect(int(x), int(y), 26, int(height))
self.dir = 1 if direction >= 0 else -1
self.speed = float(speed)
self.t = float(lifetime)
self.damage = int(damage)
self.poison = bool(poison)
def update(self, dt: float, platforms: list[pygame.Rect]):
self.rect.x += int(self.dir * self.speed * dt)
@@ -2105,16 +2128,16 @@ class Corpse:
)
def spawn_boss_shockwave(boss, shockwaves_list: list[Shockwave]):
def spawn_boss_shockwave(boss, shockwaves_list: list[Shockwave], poison: bool = True):
y = boss.rect.bottom - boss.shockwave_height
x = boss.rect.centerx
shockwaves_list.append(Shockwave(
x, y, -1,
boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage
boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage, poison
))
shockwaves_list.append(Shockwave(
x, y, 1,
boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage
boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage, poison
))
@@ -2780,20 +2803,23 @@ class InventoryUI:
self.slot_icons = load_slot_icons()
self.slot_icon_cache = {} # (slot, rarity) -> Surface
self.token_icon = None # lazily cached scaled boss-token icon
self.token_icons = {} # boss_id -> lazily cached scaled boss-token icon
self.icon_size = int(_ui(["inventory_ui", "icons", "size"], 16))
self.icon_gap = int(_ui(["inventory_ui", "icons", "gap"], 6))
def _get_token_icon(self) -> pygame.Surface | None:
if self.token_icon is None:
if BOSS_TOKEN_SPRITE_RAW is not None:
def _get_token_icon(self, boss_id=None) -> pygame.Surface | None:
key = str(boss_id or "")
if key not in self.token_icons:
raw = boss_token_sprite(boss_id)
icon = None
if raw is not None:
try:
self.token_icon = pygame.transform.smoothscale(
BOSS_TOKEN_SPRITE_RAW, (self.icon_size, self.icon_size))
icon = pygame.transform.smoothscale(raw, (self.icon_size, self.icon_size))
except Exception:
self.token_icon = None
return self.token_icon
icon = None
self.token_icons[key] = icon
return self.token_icons[key]
def toggle(self):
@@ -3111,7 +3137,8 @@ class InventoryUI:
except Exception:
pass
self._blit(surface, f"Inventory (I to close) — {room_name}",
title = f"Inventory (I to close) — {room_name}"
self._blit(surface, self._ellipsize(title, self.w - self.pad * 2),
self.x + self.pad, self.y + self.pad, (240, 240, 240))
# --- Detail areas (reserve bottom space FIRST) ---
@@ -3181,7 +3208,7 @@ class InventoryUI:
# --- slot icon (tinted by rarity), or token icon for boss tokens ---
icon = None
if it.get("item_type") == "boss_token":
icon = self._get_token_icon()
icon = self._get_token_icon(it.get("boss_id"))
if icon is None:
icon = self._get_slot_icon(slot, rarity)
icon_y = y + (self.line_h - self.icon_size) // 2
@@ -3227,13 +3254,16 @@ class InventoryUI:
surface.blit(icon, (eq_x, icon_y))
text_x = eq_x + (self.icon_size + self.icon_gap)
avail = (self.x + self.w - self.pad) - text_x
if not it:
self._blit(surface, f"{slot}: (empty)", text_x, eq_y, (170, 170, 170))
self._blit(surface, self._ellipsize(f"{slot}: (empty)", max(0, avail)),
text_x, eq_y, (170, 170, 170))
else:
name = it.get("name", "?")
left = f"{slot}: {name} "
tag = f"[{rarity}]" if rarity else ""
tag_w = self.font.size(tag)[0] if tag else 0
left = self._ellipsize(f"{slot}: {name} ", max(0, avail - tag_w))
self._blit(surface, left, text_x, eq_y, (230, 230, 230))
if tag:
self._blit(surface, tag, text_x + self.font.size(left)[0], eq_y, self._rarity_color(rarity))
@@ -3290,7 +3320,10 @@ class InventoryUI:
# Hint text (NOW hint_y exists)
self._blit(
surface,
"UP/DOWN move ENTER equip / unequip LEFT/RIGHT switch panel",
self._ellipsize(
"UP/DOWN move ENTER equip / unequip LEFT/RIGHT switch panel",
self.w - self.pad * 2 - 6,
),
self.x + self.pad + 6,
hint_y + 4,
(160, 160, 160),
@@ -3328,12 +3361,15 @@ class InventoryUI:
name = selected_item.get("name", selected_item.get("id", "<?>"))
rarity = selected_item.get("rarity", "?")
self._blit(surface, f"{name} ", self.x + self.pad + 6, stats_y + 24, (230, 230, 230))
if rarity:
self._blit(surface, f"[{rarity}]",
self.x + self.pad + 6 + self.font.size(f"{name} ")[0],
stats_y + 24,
self._rarity_color(rarity))
name_x = self.x + self.pad + 6
stats_avail = (self.x + self.w - self.pad) - name_x
tag = f"[{rarity}]" if rarity else ""
tag_w = self.font.size(tag)[0] if tag else 0
name_fit = self._ellipsize(f"{name} ", max(0, stats_avail - tag_w))
self._blit(surface, name_fit, name_x, stats_y + 24, (230, 230, 230))
if tag:
self._blit(surface, tag, name_x + self.font.size(name_fit)[0],
stats_y + 24, self._rarity_color(rarity))
compare_item = None
@@ -3386,10 +3422,11 @@ class InventoryUI:
if bonus_lines:
y_bonus = y_stats if not had_stats else (y_stats + 2)
max_y = stats_bottom
bonus_avail = (self.x + self.w - self.pad) - stats_x
for line in bonus_lines[:2]:
if y_bonus > max_y:
break
self._blit(surface, line, stats_x, y_bonus, (200, 200, 200))
self._blit(surface, self._ellipsize(line, max(0, bonus_avail)), stats_x, y_bonus, (200, 200, 200))
y_bonus += 16
# --- Scroll hint + tiny scrollbar ---
total = len(self.inv.items)
@@ -4628,6 +4665,9 @@ class Enemy:
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_startup_delay = float(flyer_cfg.get("startup_delay", 1.5))
self.flyer_phase2_hp_pct = float(flyer_cfg.get("phase2_hp_pct", 0.30))
self.flyer_hard_phase3_hp_pct = float(flyer_cfg.get("hard_phase3_hp_pct", 0.20))
self.flyer_state = "hover" # hover | diving | grounded
self.flyer_grounded_timer = 0.0
self.flyer_bob_t = random.random() * 10.0
@@ -4636,6 +4676,9 @@ class Enemy:
self.flyer_summon_cd = 0.0
self.flyer_aoe_cd = 0.0
self.flyer_shot_cd = 0.0
self.flyer_startup_timer = self.flyer_startup_delay
self.flyer_cycle_casts_left = 0
self.flyer_hard = False
# --- summoned boss minion: expires after a short life, then heals the
# boss; killing it early damages the boss. Color fades toward white. ---
@@ -4657,6 +4700,10 @@ class Enemy:
self.last_chase_dir = self.direction
self.aggro_drop_timer = 0.0
# Bosses pull aggro once (first light-form entry into their range) and
# then track the player permanently, ignoring range and shade form.
self.boss_engaged = False
# When aggro breaks near an edge: keep walking off naturally, then fall
self.stepoff_timer = 0.0
self.stepoff_dir = self.direction
@@ -4703,6 +4750,8 @@ class Enemy:
self.flyer_summon_cd = 0.0
self.flyer_aoe_cd = 0.0
self.flyer_shot_cd = 0.0
self.flyer_startup_timer = self.flyer_startup_delay
self.flyer_cycle_casts_left = 0
self.turn_cooldown = 0.0
self.last_x = self.rect.x
@@ -4951,6 +5000,9 @@ class Enemy:
self.slow_mult = 1.0
self._behavior_tick(dt)
if self.flyer_startup_timer > 0.0:
self.flyer_startup_timer = max(0.0, self.flyer_startup_timer - dt)
stunned = self.stun_timer > 0.0
if self.stun_timer > 0.0:
self.stun_timer = max(0.0, self.stun_timer - dt)
@@ -4958,7 +5010,12 @@ class Enemy:
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)
if self.drop_profile == "boss":
if (not self.boss_engaged) and (not player.shade) and distance <= self.detection_range:
self.boss_engaged = True
chasing = self.boss_engaged
else:
chasing = (not player.shade) and (distance <= self.detection_range)
feared = self.fear_timer > 0.0
if feared:
@@ -5040,7 +5097,7 @@ class Enemy:
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)
spawn_boss_shockwave(self, shockwaves_list, poison=False)
break
def _flyer_update_grounded(self, dt, platforms, player, speed, feared):
@@ -5093,25 +5150,49 @@ class Enemy:
self.vel_y = 0.0
self.vel_x = 0.0
def _flyer_max_casts(self) -> int:
"""Number of ranged skills the boss may cast in the upcoming cycle.
Normal: 1, or 2 at/below phase2_hp_pct.
Hard: 2 (starts as if already in phase 2), or all 3 at/below hard_phase3_hp_pct."""
frac = (self.health / self.max_health) if self.max_health > 0 else 0.0
if getattr(self, "flyer_hard", False):
return 3 if frac <= self.flyer_hard_phase3_hp_pct else 2
return 2 if frac <= self.flyer_phase2_hp_pct else 1
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
# Give the boss a calm intro instead of opening with a slam.
if self.flyer_startup_timer > 0.0:
return
# Otherwise pick from the ranged kit (shuffle so it isn't a fixed order).
# Start a new attack cycle whenever the slam comes off cooldown. Each
# cycle the boss casts a limited number of ranged skills (see
# _flyer_max_casts); it never chains more than that.
if self.flyer_slam_cd <= 0.0:
self.flyer_cycle_casts_left = self._flyer_max_casts()
self.flyer_slam_cd = self.flyer_slam_cooldown
# Slam takes priority whenever the player is below the boss.
if player.rect.centery > self.rect.centery + 20:
self.flyer_state = "diving"
self.vel_y = 6.0
return
if self.flyer_cycle_casts_left <= 0:
return
casts = 0
for skill in random.sample(("shot", "summon", "aoe"), 3):
if casts >= self.flyer_cycle_casts_left:
break
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:
casts += 1
elif 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:
casts += 1
elif skill == "aoe" and self.flyer_aoe_cd <= 0.0:
self._flyer_cast_aoe(aoes_list)
return
casts += 1
self.flyer_cycle_casts_left -= casts
def _flyer_cast_shot(self, player, projectiles_list):
sx = self.rect.centerx
@@ -5176,8 +5257,12 @@ class Enemy:
# 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 [])
self._flyer_update(
dt, platforms, player, shockwaves_list,
projectiles_list if projectiles_list is not None else [],
enemies_list if enemies_list is not None else [],
aoes_list if aoes_list is not None else [],
)
return
if self.jump_cd_timer > 0:
@@ -5216,7 +5301,12 @@ class Enemy:
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)
if self.drop_profile == "boss":
if (not self.boss_engaged) and (not player.shade) and distance <= self.detection_range:
self.boss_engaged = True
chasing = self.boss_engaged
else:
chasing = (not player.shade) and (distance <= self.detection_range)
feared = self.fear_timer > 0.0
if feared:
@@ -7232,6 +7322,14 @@ BOSS_DEFS_DEFAULT = {
"room": "room_boss_01.json",
"refight_room": "room_boss_refight_01.json",
"token_name": "Gloom Warden's Token",
"token_sprite": "Boss1_token.png",
},
"boss_02": {
"name": "Sky Terror",
"room": "room_08.json",
"refight_room": "room_boss_fly_refight.json",
"token_name": "Sky Terror's Token",
"token_sprite": "Boss_fly_token.png",
},
}
@@ -7320,6 +7418,8 @@ def start_refight(boss_id: str, variant: str = "normal") -> bool:
en.dash_speed_mult *= 1.5
en.retreat_speed_mult *= 1.5
en.attack_cooldown = max(0.6, en.attack_cooldown * 0.75)
elif getattr(en, "behavior", "") == "flyer":
en.flyer_hard = True
else:
en.shockwave_speed *= 2.0
en.slam_cooldown_base = max(0.25, en.slam_cooldown_base / 2.0)
@@ -7363,8 +7463,9 @@ class TokenPickup:
bob_y = int(math.sin(self.bob_t) * LOOT_BOB_AMP) if self.on_ground else 0
x = int(self.rect.x - cx)
y = int(self.rect.y - cy + bob_y)
if BOSS_TOKEN_SPRITE_RAW is not None:
img = pygame.transform.scale(BOSS_TOKEN_SPRITE_RAW, (self.rect.width, self.rect.height))
raw = boss_token_sprite(self.boss_id)
if raw is not None:
img = pygame.transform.scale(raw, (self.rect.width, self.rect.height))
surface.blit(img, (x, y))
return
pygame.draw.circle(surface, (60, 200, 190), (x + 8, y + 8), 7)
@@ -7618,10 +7719,10 @@ class DiaryUI:
icon_x = cell.centerx - 16
icon_y = cell.y + 8
has_token = (tier >= 0 or carried is not None)
if BOSS_TOKEN_SPRITE_RAW is not None and has_token:
icon = BOSS_TOKEN_SPRITE_RAW
raw_token = boss_token_sprite(boss_id)
if raw_token is not None and has_token:
icon = pygame.transform.scale(raw_token, (32, 32))
if tier < 0:
icon = icon.copy()
icon.set_alpha(120)
surface.blit(icon, (icon_x, icon_y))
if tier >= 1:
@@ -8920,7 +9021,7 @@ while running:
# Update enemies
for en in enemies:
en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves,
projectiles, enemies, boss_aoes)
boss_projectiles, enemies, boss_aoes)
# Enemies that touch a hazard zone die instantly, dropping nothing.
for en in enemies:
@@ -9029,7 +9130,8 @@ while running:
did_hit = player.take_damage(sw.damage)
if did_hit:
player.apply_knockback(sw.rect, strength_px_per_frame=14.0, duration=0.22, pop_up_vel=-4.5)
player.apply_poison(BOSS_POISON_DURATION)
if sw.poison:
player.apply_poison(BOSS_POISON_DURATION)
sw.t = 0 # one-hit
# Flying-boss AOE rings hit the player (one hit per ring)