level editor overhaul

more objects, NPCs, better toolbar and snapping
This commit is contained in:
2026-09-29 12:13:56 +02:00
parent db8aa53d3f
commit f676e59008
8 changed files with 1062 additions and 576 deletions
+200 -138
View File
@@ -868,15 +868,27 @@ ROOM_DEFAULT = {
}
def rects_from_list(list_of_rects) -> list[pygame.Rect]:
def rects_from_list(list_of_rects, layer_map: dict[int, int] | None = None) -> list[pygame.Rect]:
out = []
if not isinstance(list_of_rects, list):
return out
for r in list_of_rects:
if isinstance(r, (list, tuple)) and len(r) == 4:
out.append(pygame.Rect(int(r[0]), int(r[1]), int(r[2]), int(r[3])))
if isinstance(r, (list, tuple)) and len(r) >= 4:
rect = pygame.Rect(int(r[0]), int(r[1]), int(r[2]), int(r[3]))
out.append(rect)
if layer_map is not None and len(r) >= 5:
try:
layer_map[id(rect)] = int(r[4])
except (TypeError, ValueError):
pass
elif isinstance(r, dict):
out.append(pygame.Rect(int(r.get("x", 0)), int(r.get("y", 0)), int(r.get("w", 0)), int(r.get("h", 0))))
rect = pygame.Rect(int(r.get("x", 0)), int(r.get("y", 0)), int(r.get("w", 0)), int(r.get("h", 0)))
out.append(rect)
if layer_map is not None:
try:
layer_map[id(rect)] = int(r.get("layer", 0))
except (TypeError, ValueError):
pass
return out
@@ -985,13 +997,14 @@ class Room:
self.kill_y = int((room_json.get("bounds", {}) or {}).get("kill_y", 1400))
self.platform_layer: dict[int, int] = {}
platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {}
if isinstance(platforms, list):
self.platforms_ground = rects_from_list(platforms)
self.platforms_ground = rects_from_list(platforms, self.platform_layer)
self.platforms_floating = []
elif isinstance(platforms, dict):
self.platforms_ground = rects_from_list(platforms.get("ground", []))
self.platforms_floating = rects_from_list(platforms.get("floating", []))
self.platforms_ground = rects_from_list(platforms.get("ground", []), self.platform_layer)
self.platforms_floating = rects_from_list(platforms.get("floating", []), self.platform_layer)
else:
self.platforms_ground = []
self.platforms_floating = []
@@ -1036,7 +1049,13 @@ class Room:
rect = list(d)
if rect is None:
continue
normalized_ability_doors.append({"rect": rect})
nd = {"rect": rect}
if isinstance(d, dict):
try:
nd["layer"] = int(d.get("layer", 0))
except (TypeError, ValueError):
nd["layer"] = 0
normalized_ability_doors.append(nd)
self.ability_doors = normalized_ability_doors
self.exit_triggers: list[dict] = []
@@ -1057,6 +1076,7 @@ class Room:
"hint": str(ex.get("hint", "")),
"requires_boss_dead": bool(ex.get("requires_boss_dead", False)),
"requires_ranged": bool(ex.get("requires_ranged", False)),
"layer": int(ex.get("layer", 0)),
})
@@ -1553,7 +1573,7 @@ class LightSource:
class SavePoint:
"""Lantern save point: auto-saves and becomes the respawn point when touched."""
"""Lantern save point: pressing interact nearby saves the game and sets the respawn point."""
def __init__(self, x: int, y: int, radius: int = 90):
self.x = int(x)
self.y = int(y)
@@ -1568,13 +1588,6 @@ class SavePoint:
def draw(self, surface, cx, cy):
self.t += 0.03
pulse = 0.10 + 0.05 * (0.5 + 0.5 * math.sin(self.t))
r = self.radius
overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA)
alpha = int(255 * pulse)
pygame.draw.circle(overlay, (150, 220, 255, alpha), (r, r), r, 2)
surface.blit(overlay, (self.x - r - cx, self.y - r - cy))
if self.img is not None:
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy))
else:
@@ -1808,6 +1821,7 @@ def spawn_ability_doors(room) -> list[AbilityDoor]:
rect = pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))
door = AbilityDoor(rect, room_id=room.id)
door.open = is_door_broken(room.id, rect)
door.layer = int(d.get("layer", 0))
out.append(door)
return out
@@ -1862,13 +1876,14 @@ class AbilityPickup:
class NPC:
def __init__(self, x, y, npc_type: str = "vendor", trade_cost: int | None = None,
offers=None, greet_light: str | None = None, greet_shade: str | None = None,
greet_hint: str | None = None):
greet_hint: str | None = None, layer: int = 0):
self.type = npc_type
self.trade_cost = int(trade_cost) if trade_cost is not None else None
self.offers = offers if isinstance(offers, list) else []
self.greet_light = greet_light
self.greet_shade = greet_shade
self.greet_hint = greet_hint
self.layer = int(layer)
self.rect = pygame.Rect(int(x), int(y), 36, 54)
def draw(self, surface, cx, cy):
@@ -4597,7 +4612,9 @@ def spawn_enemies_from_room(room: Room):
w, h = cfg.get("rect", [40, 60])
x0 = int(pos[0] - (w / 2))
y0 = int(pos[1] - (h / 2))
out.append(Enemy(x0, y0, t))
enemy = Enemy(x0, y0, t)
enemy.layer = int(ent.get("layer", 0))
out.append(enemy)
return out
@@ -4630,7 +4647,8 @@ def spawn_npcs_from_room(room: Room):
greet_hint = _npc(["defaults", t, "greet_hint"], None)
out.append(NPC(pos[0], pos[1], t, trade_cost=trade_cost, offers=offers,
greet_light=greet_light, greet_shade=greet_shade, greet_hint=greet_hint))
greet_light=greet_light, greet_shade=greet_shade, greet_hint=greet_hint,
layer=int(ent.get("layer", 0))))
return out
@@ -4759,12 +4777,12 @@ def draw_speech_bubble(surface, text: str, center_x: int, bottom_y: int, hint_te
surface.blit(hint, (hint_x, hint_y))
def draw_npc_house(surface, npc: NPC, cx, cy):
# Simple backdrop house behind the NPC
def draw_house_shape(surface, cx, cy, center_x: int, bottom_y: int):
"""Draw the procedural house with its bottom-center anchored at (center_x, bottom_y)."""
house_w = 140
house_h = 90
base_x = int(npc.rect.centerx - house_w // 2 - cx)
base_y = int(npc.rect.bottom - house_h + 6 - cy)
base_x = int(center_x - house_w // 2 - cx)
base_y = int(bottom_y - house_h - cy)
# wall
pygame.draw.rect(surface, (180, 170, 150), (base_x, base_y + 30, house_w, house_h - 30))
@@ -4787,6 +4805,84 @@ def draw_npc_house(surface, npc: NPC, cx, cy):
pygame.draw.polygon(surface, (210, 170, 110), roof_pts)
pygame.draw.polygon(surface, (135, 100, 65), roof_pts, 2)
class HouseObject:
"""A standalone house prop placed in a room (bottom-center anchored)."""
def __init__(self, x: int, y: int, layer: int = 0):
self.x = int(x)
self.y = int(y)
self.layer = int(layer)
self.rect = pygame.Rect(int(x) - 70, int(y) - 90, 140, 90)
def draw(self, surface, cx, cy):
draw_house_shape(surface, cx, cy, self.x, self.y)
def draw_layerable(surface, cx, cy):
"""Draw all static room objects back-to-front by (layer, category).
Lower layer = further back. Ties use category order so default rooms keep a
sensible look: props/houses (0) < platforms (1) < ability doors (2) < exits (3)
< enemies (4) < NPCs (5). Any object's layer can be raised/lowered to push it
into the foreground or background.
"""
items = []
# props / houses
for ls in light_sources:
items.append((getattr(ls, "layer", 0), 0, "light", ls))
for sp in save_points:
items.append((getattr(sp, "layer", 0), 0, "savepoint", sp))
for bj in butterfly_jars:
items.append((getattr(bj, "layer", 0), 0, "jar", bj))
for h in houses:
items.append((getattr(h, "layer", 0), 0, "house", h))
for qg in quest_givers:
items.append((getattr(qg, "layer", 0), 0, "questgiver", qg))
if diary_obj is not None:
items.append((getattr(diary_obj, "layer", 0), 0, "diary", diary_obj))
if reward_chest is not None:
items.append((getattr(reward_chest, "layer", 0), 0, "rewardchest", reward_chest))
if reward_ability is not None:
items.append((getattr(reward_ability, "layer", 0), 0, "rewardability", reward_ability))
# platforms
for p in platforms:
items.append((platform_layer.get(id(p), 0), 1, "platform", p))
# ability doors
for d in ability_doors:
items.append((getattr(d, "layer", 0), 2, "door", d))
# exits (respect locked-exit hiding)
for ex in ROOM.exit_triggers:
if ex.get("requires_boss_dead", False) and boss_alive_in_room():
continue
if ex.get("requires_ranged", False) and not player.has_ranged:
continue
items.append((ex.get("layer", 0), 3, "exit", ex))
# enemies
for en in enemies:
items.append((getattr(en, "layer", 0), 4, "enemy", en))
# npcs
for n in npcs:
items.append((getattr(n, "layer", 0), 5, "npc", n))
items.sort(key=lambda it: (it[0], it[1]))
for _z, _cat, kind, obj in items:
if kind in ("light", "savepoint", "jar", "house", "questgiver", "diary", "rewardchest", "rewardability", "door", "enemy", "npc"):
obj.draw(surface, cx, cy)
elif kind == "platform":
pygame.draw.rect(
surface,
PLATFORM_COLOR,
pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height),
)
elif kind == "exit":
rr = obj["rect"]
pygame.draw.rect(
surface,
(140, 220, 140),
pygame.Rect(rr.x - cx, rr.y - cy, rr.width, rr.height),
2,
)
def draw_ability_icon_bg(surface, x: int, y: int, size: int, shade: bool):
pad = 4
w = size + pad * 2
@@ -5020,6 +5116,7 @@ platforms_ground = ROOM.platforms_ground
platforms_floating = ROOM.platforms_floating
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
platform_layer = ROOM.platform_layer
# ----------------------------
@@ -5046,6 +5143,7 @@ tether_shots: list[TetherShot] = []
light_sources: list[LightSource] = []
save_points: list[SavePoint] = []
butterfly_jars: list[ButterflyJar] = []
houses: list[HouseObject] = []
dust_puffs: list[DustPuff] = []
class QuestGiver:
"""Interactable environment object that hands out a quest."""
@@ -5119,6 +5217,7 @@ def spawn_room_props(room: Room, player_obj: "Player"):
lights: list[LightSource] = []
save_points: list[SavePoint] = []
butterfly_jars: list[ButterflyJar] = []
houses: list[HouseObject] = []
quest_givers: list[QuestGiver] = []
diary_obj = None
@@ -5145,27 +5244,40 @@ def spawn_room_props(room: Room, player_obj: "Player"):
else:
coins = REWARD_CHEST_COINS
reward_chest_obj = RewardChest(x, y, opened=player_obj.reward_chest_opened, coins=coins)
reward_chest_obj.layer = int(p.get("layer", 0))
elif p_type == "ability_pickup" and reward_ability_obj is None:
ability_id = str(p.get("ability", "ranged"))
if ability_id == "ranged" and player_obj.has_ranged:
continue
reward_ability_obj = AbilityPickup(x, y, ability_id=ability_id)
reward_ability_obj.layer = int(p.get("layer", 0))
elif p_type == "light_source":
radius = int(p.get("radius", 180))
lights.append(LightSource(x, y, radius))
ls = LightSource(x, y, radius)
ls.layer = int(p.get("layer", 0))
lights.append(ls)
elif p_type == "save_point":
radius = int(p.get("radius", 90))
save_points.append(SavePoint(x, y, radius))
sp = SavePoint(x, y, radius)
sp.layer = int(p.get("layer", 0))
save_points.append(sp)
elif p_type == "butterfly_jar":
key = jar_key(room.id, x, y)
if key not in COLLECTED_JARS:
butterfly_jars.append(ButterflyJar(x, y, key=key))
bj = ButterflyJar(x, y, key=key)
bj.layer = int(p.get("layer", 0))
butterfly_jars.append(bj)
elif p_type == "quest_giver":
quest_givers.append(QuestGiver(x, y, str(p.get("quest_id", ""))))
qg = QuestGiver(x, y, str(p.get("quest_id", "")))
qg.layer = int(p.get("layer", 0))
quest_givers.append(qg)
elif p_type == "diary":
diary_obj = DiaryObject(x, y)
diary_obj.layer = int(p.get("layer", 0))
elif p_type == "house":
houses.append(HouseObject(x, y, layer=int(p.get("layer", 0))))
return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, quest_givers, diary_obj
return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, houses, quest_givers, diary_obj
def rebuild_room_butterfly_jars():
@@ -5186,7 +5298,7 @@ def rebuild_room_butterfly_jars():
butterfly_jars.append(ButterflyJar(x, y, key=key))
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, quest_givers, diary_obj = spawn_room_props(ROOM, player)
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player)
def grant_full_set(set_id: str, rarity: str, equip: bool = True) -> bool:
slots = ["helmet", "chest", "pants"]
@@ -5231,9 +5343,10 @@ def grant_five_tempered() -> dict | None:
def apply_room(room: Room, set_player_spawn=True):
"""Updates room-dependent lists + respawns enemies. Keeps inventory/equipment."""
global ROOM, KILL_Y, platforms_ground, platforms_floating, ground_platforms, platforms
global platform_layer
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global shatter_platforms, shatter_aoes, tether_shots
global light_sources, save_points, butterfly_jars
global light_sources, save_points, butterfly_jars, houses
global dust_puffs
global dust_puffs
global reward_chest, reward_ability, quest_givers, diary_obj
@@ -5248,6 +5361,7 @@ def apply_room(room: Room, set_player_spawn=True):
platforms_floating = ROOM.platforms_floating
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
platform_layer = ROOM.platform_layer
if is_town_room(ROOM):
town_title.trigger(ROOM.id, town_display_name(ROOM))
@@ -5267,6 +5381,7 @@ def apply_room(room: Room, set_player_spawn=True):
light_sources = []
save_points = []
butterfly_jars = []
houses = []
dust_puffs = []
dust_puffs = []
ROOM_DROP_MULT = 1.0
@@ -5278,7 +5393,7 @@ def apply_room(room: Room, set_player_spawn=True):
reward_chest = None
reward_ability = None
diary_obj = None
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, quest_givers, diary_obj = spawn_room_props(ROOM, player)
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player)
if set_player_spawn:
player.start_x = int(ROOM.player_spawn[0])
@@ -5845,6 +5960,37 @@ def get_near_diary(player_obj, diary_obj) -> bool:
return math.hypot(dx, dy) <= DiaryObject.INTERACT_RANGE
def get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -> SavePoint | None:
"""Return the nearest lantern the player can interact with, or None."""
best = None
best_d = None
px, py = player_obj.rect.midbottom
for sp in save_point_list:
r = range_px if range_px is not None else sp.radius
if r <= 0:
continue
dx = px - sp.x
dy = py - sp.y
d2 = dx * dx + dy * dy
if d2 <= (r * r):
if best is None or d2 < best_d:
best = sp
best_d = d2
return best
def save_at_lantern(sp: SavePoint):
"""Set the lantern as the respawn point and save the game."""
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
LANTERN_RESPAWN_ROOM = ROOM.id
LANTERN_RESPAWN_POS = (sp.x, sp.y)
_do_save_game()
pickup_feed.push_center("Lantern - game saved", duration=2.0)
# Butterfly jars lost on death respawn after saving at a lantern in a different room.
if respawn_jars_if_elsewhere():
pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5)
def _ellipsize_text(text: str, fnt, max_w: int) -> str:
if fnt.size(text)[0] <= max_w:
return text
@@ -6783,7 +6929,6 @@ pause_menu_index = 0
settings_index = 0
settings_wait_action: str | None = None
pause_use_mouse = False
prev_near_save_point = False
while running:
dt = clock.tick(60) / 1000.0
@@ -6895,7 +7040,10 @@ while running:
pickup_feed.push_center(f"Debug: +5 {inst.get('name', 'Item')} (tempered)", duration=3.0)
if key_is("interact", e.key) and not inv_ui.visible and not was_trade_visible and not diary_ui.visible:
if get_near_diary(player, diary_obj):
near_lantern = get_near_lantern(player, save_points)
if near_lantern:
save_at_lantern(near_lantern)
elif get_near_diary(player, diary_obj):
diary_ui.toggle()
else:
near_npc = get_near_npc(player, npcs)
@@ -6961,16 +7109,9 @@ while running:
# draw last frame + pause overlay
screen.fill(BG)
# NPC backdrop houses (behind platforms)
for npc in npcs:
if npc.type == "vendor":
draw_npc_house(screen, npc, camera_x, camera_y)
# Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs)
draw_layerable(screen, camera_x, camera_y)
for p in platforms:
pygame.draw.rect(
screen, PLATFORM_COLOR,
pygame.Rect(p.x - camera_x, p.y - camera_y, p.width, p.height)
)
for sp in shatter_platforms:
sp.draw(screen, camera_x, camera_y)
for aoe in shatter_aoes:
@@ -6978,20 +7119,6 @@ while running:
for dp in dust_puffs:
dp.draw(screen, camera_x, camera_y)
for ex in ROOM.exit_triggers:
if ex.get("requires_boss_dead", False) and boss_alive_in_room():
continue
if ex.get("requires_ranged", False) and not player.has_ranged:
continue
rr = ex["rect"]
pygame.draw.rect(
screen,
(140, 220, 140),
pygame.Rect(rr.x - camera_x, rr.y - camera_y, rr.width, rr.height),
2,
)
for drop in loot_drops:
drop.draw(screen, camera_x, camera_y)
for c in coin_drops:
@@ -7004,25 +7131,10 @@ while running:
pr.draw(screen, camera_x, camera_y)
for ts in tether_shots:
ts.draw(screen, camera_x, camera_y)
for ls in light_sources:
ls.draw(screen, camera_x, camera_y)
for sp in save_points:
sp.draw(screen, camera_x, camera_y)
for bj in butterfly_jars:
bj.draw(screen, camera_x, camera_y)
if corpse is not None and corpse.room_id == ROOM.id:
corpse.draw(screen, camera_x, camera_y)
player.draw(screen, camera_x, camera_y)
for npc in npcs:
npc.draw(screen, camera_x, camera_y)
for en in enemies:
en.draw(screen, camera_x, camera_y)
if reward_chest:
reward_chest.draw(screen, camera_x, camera_y)
if reward_ability:
reward_ability.draw(screen, camera_x, camera_y)
if VIGNETTE_LIGHT and VIGNETTE_SHADE:
if player.shade:
@@ -7108,7 +7220,8 @@ while running:
player.charge_regen_mult = 2.0
break
# Lantern save point: touching a lantern sets the respawn point + auto-saves.
# Lantern save point: being near a lantern enables the "Press F to save" prompt;
# the actual save + respawn point update happens when the player presses interact.
near_save_point = False
for sp in save_points:
px, py = player.rect.midbottom
@@ -7116,16 +7229,7 @@ while running:
dy = py - sp.y
if (dx * dx + dy * dy) <= (sp.radius * sp.radius):
near_save_point = True
LANTERN_RESPAWN_ROOM = ROOM.id
LANTERN_RESPAWN_POS = (sp.x, sp.y)
break
if near_save_point and not prev_near_save_point:
_do_save_game()
pickup_feed.push_center("Lantern - game saved", duration=2.0)
# Butterfly jars lost on death respawn after saving at a lantern in a different room.
if respawn_jars_if_elsewhere():
pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5)
prev_near_save_point = near_save_point
collision_platforms = platforms + [d.rect for d in ability_doors if not d.open]
player_collision_platforms = collision_platforms + [p.rect for p in shatter_platforms if p.alive]
@@ -7403,9 +7507,9 @@ while running:
f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})",
duration=2.5,
)
butterfly_jars.remove(bj)
else:
pickup_feed.push_center("You can't carry any more butterfly jars!", duration=2.0)
butterfly_jars.remove(bj)
for t in token_drops[:]:
if player.rect.colliderect(t.rect):
token = make_boss_token(t.boss_id)
@@ -7434,23 +7538,9 @@ while running:
# ----------------------------
screen.fill(BG)
# NPC backdrop houses (behind platforms)
for npc in npcs:
if npc.type == "vendor":
draw_npc_house(screen, npc, camera_x, camera_y)
# Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs)
draw_layerable(screen, camera_x, camera_y)
for ls in light_sources:
ls.draw(screen, camera_x, camera_y)
for sp in save_points:
sp.draw(screen, camera_x, camera_y)
for bj in butterfly_jars:
bj.draw(screen, camera_x, camera_y)
for p in platforms:
pygame.draw.rect(
screen, PLATFORM_COLOR,
pygame.Rect(p.x - camera_x, p.y - camera_y, p.width, p.height)
)
for sp in shatter_platforms:
sp.draw(screen, camera_x, camera_y)
for aoe in shatter_aoes:
@@ -7458,26 +7548,6 @@ while running:
for dp in dust_puffs:
dp.draw(screen, camera_x, camera_y)
# ability doors (ranged-locked)
for d in ability_doors:
d.draw(screen, camera_x, camera_y)
# exits / doorways
for ex in ROOM.exit_triggers:
# hide locked exits
if ex.get("requires_boss_dead", False) and boss_alive_in_room():
continue
if ex.get("requires_ranged", False) and not player.has_ranged:
continue
rr = ex["rect"]
pygame.draw.rect(
screen,
(140, 220, 140),
pygame.Rect(rr.x - camera_x, rr.y - camera_y, rr.width, rr.height),
2,
)
# loot
for drop in loot_drops:
drop.draw(screen, camera_x, camera_y)
@@ -7490,21 +7560,6 @@ while running:
for t in token_drops:
t.draw(screen, camera_x, camera_y)
# reward room props
if reward_chest:
reward_chest.draw(screen, camera_x, camera_y)
if reward_ability:
reward_ability.draw(screen, camera_x, camera_y)
# quest giver objects
for qg in quest_givers:
qg.draw(screen, camera_x, camera_y)
# diary object (boss token diary)
if diary_obj:
diary_obj.draw(screen, camera_x, camera_y)
# shockwaves
for sw in shockwaves:
sw.draw(screen, camera_x, camera_y)
@@ -7516,14 +7571,10 @@ while running:
ts.draw(screen, camera_x, camera_y)
# entities
for npc in npcs:
npc.draw(screen, camera_x, camera_y)
# entities (NPCs/enemies are drawn by draw_layerable for layer support)
if corpse is not None and corpse.room_id == ROOM.id:
corpse.draw(screen, camera_x, camera_y)
player.draw(screen, camera_x, camera_y)
for en in enemies:
en.draw(screen, camera_x, camera_y)
# NPC greeting bubble (shown near the player)
greet_npc = get_near_npc(player, npcs)
@@ -7576,6 +7627,17 @@ while running:
int(diary_obj.rect.top - camera_y),
)
# Lantern save hint bubble (shown near the player)
if not inv_ui.visible and not trade_ui.visible and not diary_ui.visible:
near_lantern = get_near_lantern(player, save_points)
if near_lantern:
draw_speech_bubble(
screen,
"Press F - Save at lantern",
int(near_lantern.rect.centerx - camera_x),
int(near_lantern.rect.top - camera_y),
)
# Boss health bar (if boss exists)
boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None)
if boss is not None: