more rooms

more rooms, level editor changes, more logic for ability checks and trigger doors, platform body collision fixes
This commit is contained in:
2026-09-30 12:00:56 +02:00
parent 53fc17338f
commit faa8b16658
9 changed files with 935 additions and 305 deletions
+231 -35
View File
@@ -438,6 +438,7 @@ JUMP_LIGHT = float(_phys(["player", "jump_light"], -12.0))
JUMP_SHADE = float(_phys(["player", "jump_shade"], -9.0))
GRAVITY_LIGHT = float(_phys(["player", "gravity_light"], 0.5))
GRAVITY_SHADE = float(_phys(["player", "gravity_shade"], 0.4))
SHADE_DOOR_GRACE = float(_phys(["player", "shade_door_grace"], 0.6))
# Sprite offsets (tune to align art with hitbox)
PLAYER_SPRITE_OFFSET_X = int(_cfg(["player", "sprite_offset_x"], 0))
@@ -941,6 +942,12 @@ def normalize_room_json(room_json: dict) -> dict:
obj["quest"] = p["quest"]
if "quest_complete" in p:
obj["quest_complete"] = p["quest_complete"]
if "quest_turned_in" in p:
obj["quest_turned_in"] = p["quest_turned_in"]
if "lock_when_pass" in p:
obj["lock_when_pass"] = p["lock_when_pass"]
if "open_on_miniboss_dead" in p:
obj["open_on_miniboss_dead"] = p["open_on_miniboss_dead"]
if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2:
obj["respawn"] = p["respawn"]
grouped.setdefault(group, []).append(obj)
@@ -1018,9 +1025,14 @@ def normalize_room_json(room_json: dict) -> dict:
norm_doors.append({"rect": [d[0], d[1], d[2], d[3]]})
elif isinstance(d, dict):
if isinstance(d.get("rect"), (list, tuple)) and len(d["rect"]) == 4:
norm_doors.append(d)
nd = dict(d)
nd["kind"] = d.get("kind", "ranged")
norm_doors.append(nd)
elif all(k in d for k in ("x", "y", "w", "h")):
norm_doors.append({"rect": [d.get("x", 0), d.get("y", 0), d.get("w", 0), d.get("h", 0)]})
norm_doors.append({
"rect": [d.get("x", 0), d.get("y", 0), d.get("w", 0), d.get("h", 0)],
"kind": d.get("kind", "ranged"),
})
out["ability_doors"] = norm_doors
return out
@@ -1077,6 +1089,12 @@ class Room:
meta["quest"] = str(tp.get("quest"))
if tp.get("quest_complete"):
meta["quest_complete"] = str(tp.get("quest_complete"))
if tp.get("quest_turned_in"):
meta["quest_turned_in"] = str(tp.get("quest_turned_in"))
if tp.get("lock_when_pass"):
meta["lock_when_pass"] = True
if tp.get("open_on_miniboss_dead"):
meta["open_on_miniboss_dead"] = True
self.platform_meta[id(rect)] = meta
self.platforms_trigger.append(rect)
elif isinstance(tp, (list, tuple)) and len(tp) >= 4:
@@ -1155,7 +1173,7 @@ class Room:
rect = list(d)
if rect is None:
continue
nd = {"rect": rect}
nd = {"rect": rect, "kind": d.get("kind", "ranged") if isinstance(d, dict) else "ranged"}
if isinstance(d, dict):
try:
nd["layer"] = int(d.get("layer", 0))
@@ -1421,6 +1439,8 @@ class Projectile:
for d in doors:
if getattr(d, "open", False):
continue
if getattr(d, "kind", "ranged") == "shade":
continue # shade doors cannot be shot open
if self.rect.colliderect(d.rect):
d.open = True
mark_door_broken(getattr(d, "room_id", None), d.rect)
@@ -1906,17 +1926,22 @@ class CoinPickup:
class AbilityDoor:
def __init__(self, rect: pygame.Rect, room_id: str | None = None):
def __init__(self, rect: pygame.Rect, room_id: str | None = None, kind: str = "ranged"):
self.rect = rect
self.open = False
self.room_id = room_id
self.kind = "shade" if str(kind) == "shade" else "ranged"
def draw(self, surface, cx, cy):
if self.open:
return
r = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height)
pygame.draw.rect(surface, (120, 180, 120), r)
pygame.draw.rect(surface, (40, 80, 40), r, 2)
if self.kind == "shade":
pygame.draw.rect(surface, (110, 100, 170), r)
pygame.draw.rect(surface, (60, 50, 110), r, 2)
else:
pygame.draw.rect(surface, (120, 180, 120), r)
pygame.draw.rect(surface, (40, 80, 40), r, 2)
def spawn_ability_doors(room) -> list[AbilityDoor]:
@@ -1925,7 +1950,7 @@ def spawn_ability_doors(room) -> list[AbilityDoor]:
if not isinstance(d, dict):
continue
rect = pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))
door = AbilityDoor(rect, room_id=room.id)
door = AbilityDoor(rect, room_id=room.id, kind=d.get("kind", "ranged"))
door.open = is_door_broken(room.id, rect)
door.layer = int(d.get("layer", 0))
out.append(door)
@@ -1942,8 +1967,12 @@ class RewardChest:
if self.opened:
return False
light_amt, dark_amt = self.coins
player.coins_light += int(light_amt)
player.coins_dark += int(dark_amt)
cx = self.rect.centerx
cy = self.rect.top - 18 # pop out of the chest top, clear of its platform
for _ in range(max(0, int(light_amt))):
coin_drops.append(CoinPickup(cx, cy, "light"))
for _ in range(max(0, int(dark_amt))):
coin_drops.append(CoinPickup(cx, cy, "dark"))
self.opened = True
return True
@@ -3260,6 +3289,7 @@ class Player:
self.on_ground = False
self.ground_platform = None
self.shade = False
self.shade_door_grace = 0.0 # grace period to pass shade doors after a form switch
self.charge = float(BASE_MAX_CHARGE)
self.health = int(BASE_MAX_HEALTH)
@@ -3601,6 +3631,7 @@ class Player:
if not self.shade and self.charge <= 0:
return
self.shade = not self.shade
self.shade_door_grace = SHADE_DOOR_GRACE
def attack(self):
if self.healing:
@@ -3813,6 +3844,9 @@ class Player:
self.just_air_jumped = False
self.fairy_bob_t += dt
if self.shade_door_grace > 0.0:
self.shade_door_grace = max(0.0, self.shade_door_grace - dt)
if self.invincibility_timer > 0:
self.invincibility_timer -= dt
@@ -3967,6 +4001,7 @@ class Player:
if self.charge <= 0:
self.charge = 0
self.shade = False
self.shade_door_grace = SHADE_DOOR_GRACE
else:
# no regen while healing, attacking, or during post-attack lockout
if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0):
@@ -4164,6 +4199,9 @@ class Enemy:
self.drop_ignore_timer = 0.0
self.drop_ignore_platform = None
# True when killed by touching a hazard zone (drops nothing).
self.hazard_kill = False
def take_damage(self, dmg):
self.health = max(0.0, float(self.health) - float(dmg))
@@ -4176,6 +4214,7 @@ class Enemy:
self.rect.topleft = (self.start_x, self.start_y)
self.vel_x = 0.0
self.vel_y = 0.0
self.hazard_kill = False
cfg = ENEMY_TYPES.get(self.type_id, ENEMY_TYPES.get("enemy", {}))
self.max_health = float(cfg.get("health", 3)) if isinstance(cfg, dict) else 3.0
self.health = float(self.max_health)
@@ -4719,6 +4758,8 @@ def spawn_enemies_from_room(room: Room):
t = str(ent.get("type", "enemy"))
if t == "boss" and is_boss_defeated(room.id):
continue
if t == "miniboss" and room.id in DEFEATED_MINIBOSSES:
continue
pos = ent.get("pos", None)
if not (isinstance(pos, (list, tuple)) and len(pos) == 2):
if "x" in ent and "y" in ent:
@@ -4939,18 +4980,79 @@ class HouseObject:
def trigger_wall_blocking(rect) -> bool:
"""A trigger wall blocks while its unlock condition is NOT met.
- ``quest``: wall stays until the quest is accepted (becomes active).
- ``quest``: wall stays until the quest is accepted (becomes active),
and stays open afterwards (once ever completed).
- ``quest_complete``: wall stays until the quest objective is done
(is_complete, or the quest has been completed at least once).
- ``quest_turned_in``: wall stays until the quest has been completed
and turned in (only ever_completed counts).
- ``lock_when_pass``: wall is gone initially and only appears once the
player has walked through it (arena lock).
- ``open_on_miniboss_dead``: releases the wall once the room's miniboss
is slain (combines with the conditions above; with no other condition
the wall is simply present until then).
Walls with no condition are inert placeholders (never block)."""
meta = platform_meta.get(id(rect), {})
qid = meta.get("quest")
if qid:
return qid not in quest_manager.active
qc = meta.get("quest_complete")
if qc:
return not (quest_manager.is_complete(qc) or qc in quest_manager.ever_completed)
return False
if meta.get("lock_when_pass"):
present = id(rect) in LOCKED_TRIGGER_WALLS
elif qid:
present = qid not in quest_manager.active and qid not in quest_manager.ever_completed
elif meta.get("quest_complete"):
qc = meta["quest_complete"]
present = not (quest_manager.is_complete(qc) or qc in quest_manager.ever_completed)
elif meta.get("quest_turned_in"):
present = meta["quest_turned_in"] not in quest_manager.ever_completed
elif meta.get("open_on_miniboss_dead"):
present = True # always present until released
else:
return False # inert placeholder
if meta.get("open_on_miniboss_dead"):
present = present and miniboss_alive_in_room()
return present
# Lock-on-pass trigger walls (per-session, keyed by rect id).
LOCKED_TRIGGER_WALLS: set[int] = set()
TRIGGER_WALL_TOUCH: dict[int, bool] = {}
def miniboss_alive_in_room() -> bool:
return any(getattr(en, "type_id", "") == "miniboss" and en.health > 0 for en in enemies)
def push_rect_out_of_rects(rect, rects):
"""Push a pygame.Rect out of any overlapping rect, moving to the nearest edge.
Used to safely eject the player from a platform they've ended up inside
(e.g. a shade door that became solid again, or an awkward spawn point)
instead of letting the physics resolve it with a jarring teleport."""
for obs in rects:
if not rect.colliderect(obs):
continue
inter = rect.clip(obs)
if inter.width <= 0 or inter.height <= 0:
continue
if inter.width < inter.height:
# narrower overlap on X: push to the nearest horizontal side
if rect.right - obs.left <= obs.right - rect.left:
rect.right = obs.left
else:
rect.left = obs.right
else:
# narrower overlap on Y: push to the nearest vertical side
if rect.bottom - obs.top <= obs.bottom - rect.top:
rect.bottom = obs.top
else:
rect.top = obs.bottom
return rect
def reset_trigger_wall_locks():
"""Clear lock-on-pass trigger state (on room change)."""
LOCKED_TRIGGER_WALLS.clear()
TRIGGER_WALL_TOUCH.clear()
prev_moving_pos: dict[int, tuple[int, int]] = {}
@@ -5228,7 +5330,7 @@ def _draw_tether_chain_loop(surface, rect: pygame.Rect, light: bool, cx: float,
def get_pause_menu_rects():
items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"]
items = ["Resume", "Settings", "Main Menu", "Close Game"]
rects = []
start_y = HEIGHT // 2 + 20
for i, label in enumerate(items):
@@ -5240,7 +5342,7 @@ def get_pause_menu_rects():
def draw_pause_menu(surface, selected_idx: int, use_mouse: bool):
items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"]
items = ["Resume", "Settings", "Main Menu", "Close Game"]
rects = get_pause_menu_rects()
mouse_pos = pygame.mouse.get_pos()
hover_idx = None
@@ -5557,6 +5659,7 @@ def apply_room(room: Room, set_player_spawn=True):
global token_drops
global ACTIVE_REFIGHT
global ROOM_DROP_MULT, ROOM_COIN_MULT
reset_trigger_wall_locks()
ROOM = room
@@ -5609,6 +5712,8 @@ def apply_room(room: Room, set_player_spawn=True):
player.rect.midbottom = (player.start_x, player.start_y)
player.vel_y = 0.0
player.on_ground = False
# Don't spawn embedded in a platform: push out to the nearest edge.
push_rect_out_of_rects(player.rect, ROOM.platforms)
def boss_alive_in_room() -> bool:
return any(getattr(en, "type_id", "") == "boss" and en.health > 0 for en in enemies)
@@ -5618,6 +5723,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
reset_trigger_wall_locks()
enemies = spawn_enemies_from_room(ROOM)
npcs = spawn_npcs_from_room(ROOM)
loot_drops = []
@@ -5722,6 +5828,10 @@ camera_x = camera_y = 0.0
# Track bosses defeated per room id (runtime only)
DEFEATED_BOSSES: set[str] = set()
# Minibosses stay defeated (per room) until the player saves at a lantern
# in a different room — then they respawn. Runtime only, like butterfly jars.
DEFEATED_MINIBOSSES: set[str] = set()
# Reality Break: per-room drop multipliers (reset on room change)
ROOM_DROP_MULT = 1.0
ROOM_COIN_MULT = 1.0
@@ -5734,6 +5844,17 @@ def mark_boss_defeated(room_id: str):
DEFEATED_BOSSES.add(room_id)
def respawn_defeated_minibosses_if_elsewhere() -> bool:
"""Minibosses stay defeated until the player saves at a lantern in a
different room than the one where the miniboss was slain. Returns True
if any defeated miniboss was respawned (i.e. removed from the set)."""
if not DEFEATED_MINIBOSSES:
return False
before = len(DEFEATED_MINIBOSSES)
DEFEATED_MINIBOSSES.intersection_update({ROOM.id})
return len(DEFEATED_MINIBOSSES) != before
# ----------------------------
# Quest system
# ----------------------------
@@ -5856,6 +5977,11 @@ class QuestManager:
def get(self, quest_id: str) -> Quest | None:
return self.quests.get(quest_id)
def requires_shade(self, quest_id: str) -> bool:
"""Whether a quest can only be taken/collected in shade form."""
q = self.quests.get(quest_id)
return bool(q is not None and q.objective.get("shade"))
def quests_from(self, giver_id: str) -> list[Quest]:
return [q for q in self.quests.values() if q.giver == giver_id]
@@ -5899,8 +6025,10 @@ class QuestManager:
else:
self.completed_one_time.add(quest_id)
def progress_kill(self, enemy_type: str) -> list[str]:
def progress_kill(self, enemy_type: str, shade: bool = False) -> list[str]:
"""Progress every active kill-quest matching this enemy type.
A quest whose objective has ``shade: true`` only counts kills made
while the player is in shade form.
Returns quest ids that just became complete."""
completed = []
for qid in list(self.active.keys()):
@@ -5910,6 +6038,8 @@ class QuestManager:
obj = q.objective
if obj.get("kind") != "kill" or str(obj.get("target", "")) != enemy_type:
continue
if obj.get("shade") and not shade:
continue
was = self.is_complete(qid)
self.progress(qid, 1)
if not was and self.is_complete(qid):
@@ -5994,6 +6124,17 @@ QUEST_DEFS_DEFAULT = [
"coin_range": [5, 15],
},
},
{
"id": "first_kills_shade",
"name": "First Kills (Shade)",
"description": "Kill 4 enemies while in shade form.",
"type": "one_time",
"giver": "object:quest_point",
"objective": {"kind": "kill", "target": "enemy", "count": 4, "shade": True},
"reward": {
"guaranteed_item": {"item_id": "necklace_placeholder"},
},
},
]
quest_manager = QuestManager()
@@ -6203,6 +6344,8 @@ def save_at_lantern(sp: SavePoint):
pickup_feed.push_center("Lantern - game saved, fully restored", duration=2.0)
if respawn_jars_if_elsewhere():
pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5)
if respawn_defeated_minibosses_if_elsewhere():
pickup_feed.push_center("Defeated minibosses have respawned!", duration=2.5)
def _ellipsize_text(text: str, fnt, max_w: int) -> str:
@@ -6645,8 +6788,9 @@ def apply_save_data(data: dict):
DEFEATED_BOSSES.update(data.get("defeated_bosses") or [])
BROKEN_DOORS.clear()
BROKEN_DOORS.update(data.get("broken_doors") or [])
# Butterfly jars respawn at their points on reload; the corpse is session-only.
# Butterfly jars and minibosses respawn on reload (session-only); corpse too.
COLLECTED_JARS.clear()
DEFEATED_MINIBOSSES.clear()
JAR_DEATH_ROOM = None
corpse = None
@@ -6671,6 +6815,8 @@ def apply_save_data(data: dict):
apply_room(room, set_player_spawn=True)
player.respawn()
# respawn() re-places at the raw spawn; make sure we're not embedded.
push_rect_out_of_rects(player.rect, room.platforms)
if "health" in pdata:
player.health = int(pdata["health"])
if "charge" in pdata:
@@ -6759,8 +6905,6 @@ def _do_save_game():
def _return_to_main_menu():
global GAME_MODE, paused, pause_menu, pause_menu_index, pause_use_mouse
global settings_wait_action
if CURRENT_SAVE_NAME:
_do_save_game()
inv_ui.visible = False
trade_ui.visible = False
trade_ui.active_npc = None
@@ -7190,12 +7334,10 @@ while running:
if idx == 0:
paused = False
elif idx == 1:
_do_save_game()
elif idx == 2:
pause_menu = "settings"
elif idx == 3:
elif idx == 2:
_return_to_main_menu()
elif idx == 4:
elif idx == 3:
running = False
break
@@ -7210,21 +7352,19 @@ while running:
if paused and pause_menu == "main":
if key_is("ui_up", e.key):
pause_menu_index = (pause_menu_index - 1) % 5
pause_menu_index = (pause_menu_index - 1) % 4
pause_use_mouse = False
elif key_is("ui_down", e.key):
pause_menu_index = (pause_menu_index + 1) % 5
pause_menu_index = (pause_menu_index + 1) % 4
pause_use_mouse = False
elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
if pause_menu_index == 0:
paused = False
elif pause_menu_index == 1:
_do_save_game()
elif pause_menu_index == 2:
pause_menu = "settings"
elif pause_menu_index == 3:
elif pause_menu_index == 2:
_return_to_main_menu()
elif pause_menu_index == 4:
elif pause_menu_index == 3:
running = False
elif key_is("pause", e.key):
paused = False
@@ -7271,7 +7411,11 @@ while running:
else:
near_qg = get_near_quest_giver(player, quest_givers)
if near_qg:
interact_quest_giver(near_qg)
if quest_manager.requires_shade(near_qg.quest_id) and not player.shade:
# Shade-gated quest: no interaction while in light form.
pass
else:
interact_quest_giver(near_qg)
if key_is("pause", e.key):
if paused:
@@ -7479,7 +7623,22 @@ while running:
blocking_triggers = [t for t in platforms_trigger if trigger_wall_blocking(t)]
collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] + blocking_triggers
player_collision_platforms = collision_platforms + [p.rect for p in shatter_platforms if p.alive]
# Shade doors are solid for the world, but the player can pass through them
# freely while in shade form — and for a short grace period after switching
# forms, so they aren't instantly ejected from a door they're standing in.
player_ignores_shade_doors = player.shade or player.shade_door_grace > 0.0
if player_ignores_shade_doors:
shade_door_ids = {id(d.rect) for d in ability_doors if (not d.open) and d.kind == "shade"}
player_collision_platforms = [r for r in collision_platforms if id(r) not in shade_door_ids]
else:
player_collision_platforms = collision_platforms
# Grace expired: safely eject the player from any shade door they're
# still inside (nearest edge) before collision runs.
for d in ability_doors:
if (not d.open) and d.kind == "shade" and player.rect.colliderect(d.rect):
push_rect_out_of_rects(player.rect, [d.rect])
player_collision_platforms = player_collision_platforms + [p.rect for p in shatter_platforms if p.alive]
# Shade + clipping an enemy drains charge twice as fast (shade still avoids damage).
if player.shade:
@@ -7500,6 +7659,24 @@ while running:
player.ground_platform = rect
break
# Lock-on-pass trigger walls: gone initially, they appear once the player has
# walked through them (crossing from one side to the other) — arena lock.
for rect in platforms_trigger:
meta = platform_meta.get(id(rect), {})
if not meta.get("lock_when_pass"):
continue
key = id(rect)
if key in LOCKED_TRIGGER_WALLS:
continue
entry = TRIGGER_WALL_TOUCH.get(key)
if entry is None:
if player.rect.colliderect(rect):
TRIGGER_WALL_TOUCH[key] = "L" if player.rect.centerx < rect.centerx else "R"
elif entry == "L" and player.rect.left >= rect.right:
LOCKED_TRIGGER_WALLS.add(key)
elif entry == "R" and player.rect.right <= rect.left:
LOCKED_TRIGGER_WALLS.add(key)
# Hazard zones: touching one deals 1 damage and teleports the player to its linked respawn
# (defaults to the room's player spawn). A lethal hit falls through to the normal death respawn.
for hz in platforms_hazard:
@@ -7592,10 +7769,21 @@ while running:
player.shatterstep_landing_pending = False
player.shatterstep_landing_damage = 0.0
# Never respawn embedded in a platform: push out to the nearest edge.
push_rect_out_of_rects(player.rect, ROOM.platforms)
# Update enemies
for en in enemies:
en.update(dt, collision_platforms, ground_platforms, player, shockwaves)
# Enemies that touch a hazard zone die instantly, dropping nothing.
for en in enemies:
if en.health <= 0:
continue
if any(en.rect.colliderect(hz) for hz in platforms_hazard):
en.health = 0.0
en.hazard_kill = True
# Enemy enemies do NOT physically collide.
# (Prevents enemies from shoving each other into platforms / off ledges.)
# resolve_enemy_collisions(enemies)
@@ -7702,6 +7890,8 @@ while running:
if en.health > 0:
alive.append(en)
else:
if getattr(en, "hazard_kill", False):
continue # hazard deaths drop nothing and give no quest credit
loot_drops.extend(spawn_drops_from_enemy(en))
etype = getattr(en, "type_id", "")
if etype == "boss":
@@ -7709,9 +7899,11 @@ 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))
elif etype == "miniboss":
DEFEATED_MINIBOSSES.add(ROOM.id)
# Quest kill tracking
for qid in quest_manager.progress_kill(etype):
for qid in quest_manager.progress_kill(etype, player.shade):
q = quest_manager.get(qid)
if q:
pickup_feed.push_plain(f"Quest fulfilled: {q.name} - return to the quest object!")
@@ -7874,6 +8066,10 @@ while running:
# Quest object hint bubble (shown near the player)
near_qg = get_near_quest_giver(player, quest_givers)
# Shade-gated quests are only interactable while in shade form; in light
# form the object is visible but shows no interaction prompt.
if near_qg and quest_manager.requires_shade(near_qg.quest_id) and not player.shade:
near_qg = None
if near_qg:
q = quest_manager.get(near_qg.quest_id)
label = "Press F"