more rooms
more rooms, level editor changes, more logic for ability checks and trigger doors, platform body collision fixes
This commit is contained in:
+231
-35
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user