flyer boss, town
flyer boss adjustments, town addition
This commit is contained in:
+149
-47
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user