room overhaul

more editor changes, new rooms
This commit is contained in:
2026-09-29 18:55:29 +02:00
parent f676e59008
commit a9e999b49e
14 changed files with 1110 additions and 1288 deletions
+213 -19
View File
@@ -21,6 +21,10 @@ SHOCKWAVE_DAMAGE = 2
BOSS_POISON_DURATION = 4.0 # boss attacks poison the player for this many seconds
ATTACK_COOLDOWN_BASE = 0.35 # recovery between melee swings; attack speed reduces it
# Horizontal deadzone: enemies only turn toward the player when the player is this far
# to one side, so a player standing directly above/below no longer makes them wiggle.
CHASE_DEADZONE = 6
# --- Ranged ability / projectile settings ---
RANGED_CHARGE_COST = 5.0 # costs more than melee (melee costs 1)
RANGED_DAMAGE_MULT = 0.5 # 50% of melee damage
@@ -915,13 +919,32 @@ def normalize_room_json(room_json: dict) -> dict:
if isinstance(platforms, list):
grouped = {"ground": [], "floating": []}
for p in platforms:
if isinstance(p, (list, tuple)) and len(p) == 4:
grouped["ground"].append([p[0], p[1], p[2], p[3]])
if isinstance(p, (list, tuple)) and len(p) >= 4:
arr = [p[0], p[1], p[2], p[3]]
if len(p) >= 5:
arr.append(p[4])
grouped["ground"].append(arr)
elif isinstance(p, dict):
rect = [p.get("x", 0), p.get("y", 0), p.get("w", 0), p.get("h", 0)]
group = p.get("group", "ground")
grouped.setdefault(group, [])
grouped[group].append(rect)
if group in ("moving", "trigger", "hazard"):
obj = {"x": p.get("x", 0), "y": p.get("y", 0), "w": p.get("w", 0), "h": p.get("h", 0)}
if "layer" in p and p.get("layer"):
obj["layer"] = p["layer"]
if group == "moving":
if isinstance(p.get("path"), list):
obj["path"] = p["path"]
if "speed" in p:
obj["speed"] = p["speed"]
if "name" in p:
obj["name"] = p["name"]
if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2:
obj["respawn"] = p["respawn"]
grouped.setdefault(group, []).append(obj)
else:
rect = [p.get("x", 0), p.get("y", 0), p.get("w", 0), p.get("h", 0)]
if p.get("layer"):
rect.append(p["layer"])
grouped.setdefault(group, []).append(rect)
out["platforms"] = grouped
elif not isinstance(platforms, dict):
out["platforms"] = {"ground": [], "floating": []}
@@ -954,6 +977,20 @@ def normalize_room_json(room_json: dict) -> dict:
norm_npcs.append(n)
out["npcs"] = norm_npcs
# props -> ensure pos:[x,y]
props = out.get("props", [])
if isinstance(props, list):
norm_props = []
for p in props:
if not isinstance(p, dict):
continue
if not (isinstance(p.get("pos"), (list, tuple)) and len(p["pos"]) == 2):
if "x" in p and "y" in p:
p = dict(p)
p["pos"] = [p.get("x", 0), p.get("y", 0)]
norm_props.append(p)
out["props"] = norm_props
# exits -> ensure rect:[x,y,w,h]
exits = out.get("exits", [])
if isinstance(exits, list):
@@ -998,18 +1035,78 @@ class Room:
self.kill_y = int((room_json.get("bounds", {}) or {}).get("kill_y", 1400))
self.platform_layer: dict[int, int] = {}
self.platform_meta: dict[int, dict] = {} # id(rect) -> {"name"|"path"|"speed", ...}
platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {}
if isinstance(platforms, list):
self.platforms_ground = rects_from_list(platforms, self.platform_layer)
self.platforms_floating = []
self.platforms_hazard = []
self.platforms_trigger = []
self.platforms_moving = []
elif isinstance(platforms, dict):
self.platforms_ground = rects_from_list(platforms.get("ground", []), self.platform_layer)
self.platforms_floating = rects_from_list(platforms.get("floating", []), self.platform_layer)
self.platforms_hazard = []
for hp in platforms.get("hazard", []):
if isinstance(hp, dict):
rect = pygame.Rect(int(hp.get("x", 0)), int(hp.get("y", 0)), int(hp.get("w", 0)), int(hp.get("h", 0)))
self.platform_layer[id(rect)] = int(hp.get("layer", 0))
respawn = hp.get("respawn")
if isinstance(respawn, (list, tuple)) and len(respawn) >= 2:
self.platform_meta[id(rect)] = {"respawn": [int(respawn[0]), int(respawn[1])]}
self.platforms_hazard.append(rect)
elif isinstance(hp, (list, tuple)) and len(hp) >= 4:
rect = pygame.Rect(int(hp[0]), int(hp[1]), int(hp[2]), int(hp[3]))
if len(hp) >= 5:
try:
self.platform_layer[id(rect)] = int(hp[4])
except (TypeError, ValueError):
pass
self.platforms_hazard.append(rect)
self.platforms_trigger = []
for tp in platforms.get("trigger", []):
if isinstance(tp, dict):
rect = pygame.Rect(int(tp.get("x", 0)), int(tp.get("y", 0)), int(tp.get("w", 0)), int(tp.get("h", 0)))
self.platform_layer[id(rect)] = int(tp.get("layer", 0))
self.platform_meta[id(rect)] = {"name": str(tp.get("name", ""))}
self.platforms_trigger.append(rect)
elif isinstance(tp, (list, tuple)) and len(tp) >= 4:
rect = pygame.Rect(int(tp[0]), int(tp[1]), int(tp[2]), int(tp[3]))
if len(tp) >= 5:
try:
self.platform_layer[id(rect)] = int(tp[4])
except (TypeError, ValueError):
pass
self.platforms_trigger.append(rect)
self.platforms_moving = []
for mp in platforms.get("moving", []):
if isinstance(mp, dict):
rect = pygame.Rect(int(mp.get("x", 0)), int(mp.get("y", 0)), int(mp.get("w", 0)), int(mp.get("h", 0)))
self.platform_layer[id(rect)] = int(mp.get("layer", 0))
path = mp.get("path") or []
if isinstance(path, list) and all(isinstance(w, (list, tuple)) and len(w) >= 2 for w in path):
path = [[int(w[0]), int(w[1])] for w in path]
else:
path = [[rect.x, rect.y]]
speed = float(mp.get("speed", 120))
self.platform_meta[id(rect)] = {"path": path, "speed": speed, "i": 0, "t": 0.0}
self.platforms_moving.append(rect)
elif isinstance(mp, (list, tuple)) and len(mp) >= 4:
rect = pygame.Rect(int(mp[0]), int(mp[1]), int(mp[2]), int(mp[3]))
if len(mp) >= 5:
try:
self.platform_layer[id(rect)] = int(mp[4])
except (TypeError, ValueError):
pass
self.platforms_moving.append(rect)
else:
self.platforms_ground = []
self.platforms_floating = []
self.platforms_hazard = []
self.platforms_trigger = []
self.platforms_moving = []
self.ground_platforms = self.platforms_ground[:]
self.platforms = self.platforms_ground + self.platforms_floating
self.platforms = self.platforms_ground + self.platforms_floating + self.platforms_moving
self.enemies = room_json.get("enemies", []) if isinstance(room_json, dict) else []
if not isinstance(self.enemies, list):
@@ -4222,7 +4319,10 @@ class Enemy:
self.direction = -1 if dx > 0 else 1
if chasing:
self.direction = 1 if dx > 0 else -1
if dx < -CHASE_DEADZONE:
self.direction = -1
elif dx > CHASE_DEADZONE:
self.direction = 1
self.last_chase_dir = self.direction # remember chase intent
# aggro just broke (shade OR distance)
@@ -4431,7 +4531,11 @@ class Enemy:
# --- PATROL / CHASE INTENT ---
if chasing:
self.direction = 1 if player.rect.centerx > self.rect.centerx else -1
cdx = player.rect.centerx - self.rect.centerx
if cdx < -CHASE_DEADZONE:
self.direction = -1
elif cdx > CHASE_DEADZONE:
self.direction = 1
elif self.stepoff_timer > 0.0:
# aggro just broke near an edge: keep walking off naturally
self.direction = self.stepoff_dir
@@ -4818,6 +4922,45 @@ class HouseObject:
draw_house_shape(surface, cx, cy, self.x, self.y)
def update_moving_platforms(dt: float):
"""Advance moving platforms along their waypoint paths (top-left follows the path)."""
for rect in platforms_moving:
mdata = platform_meta.get(id(rect))
if not mdata:
continue
path = mdata.get("path") or []
if len(path) < 2:
continue
n = len(path)
speed = float(mdata.get("speed", 120))
i = mdata.get("i", 0)
t = mdata.get("t", 0.0)
remaining = speed * dt
while remaining > 0:
x0, y0 = path[i]
x1, y1 = path[(i + 1) % n]
dx = x1 - x0
dy = y1 - y0
seg_len = math.hypot(dx, dy)
if seg_len <= 0:
i = (i + 1) % n
continue
to_end = (1.0 - t) * seg_len
if remaining < to_end:
t += remaining / seg_len
remaining = 0.0
else:
remaining -= to_end
t = 0.0
i = (i + 1) % n
mdata["i"] = i
mdata["t"] = t
x0, y0 = path[i]
x1, y1 = path[(i + 1) % n]
rect.x = int(round(x0 + (x1 - x0) * t))
rect.y = int(round(y0 + (y1 - y0) * t))
def draw_layerable(surface, cx, cy):
"""Draw all static room objects back-to-front by (layer, category).
@@ -4847,6 +4990,15 @@ def draw_layerable(surface, cx, cy):
# platforms
for p in platforms:
items.append((platform_layer.get(id(p), 0), 1, "platform", p))
# moving platforms (solid, follow a path) layer alongside platforms
for p in platforms_moving:
items.append((platform_layer.get(id(p), 0), 1, "moving", p))
# hazard zones (red) layer alongside platforms
for p in platforms_hazard:
items.append((platform_layer.get(id(p), 0), 1, "hazard", p))
# trigger placeholders (yellow, non-solid, no behavior yet)
for p in platforms_trigger:
items.append((platform_layer.get(id(p), 0), 1, "trigger", p))
# ability doors
for d in ability_doors:
items.append((getattr(d, "layer", 0), 2, "door", d))
@@ -4874,6 +5026,18 @@ def draw_layerable(surface, cx, cy):
PLATFORM_COLOR,
pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height),
)
elif kind == "moving":
mr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
pygame.draw.rect(surface, (80, 180, 220), mr)
pygame.draw.rect(surface, (180, 230, 255), mr, 2)
elif kind == "hazard":
hz = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
pygame.draw.rect(surface, (200, 40, 40), hz)
pygame.draw.rect(surface, (255, 120, 120), hz, 2)
elif kind == "trigger":
tr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
pygame.draw.rect(surface, (220, 190, 60), tr)
pygame.draw.rect(surface, (255, 230, 120), tr, 2)
elif kind == "exit":
rr = obj["rect"]
pygame.draw.rect(
@@ -5114,6 +5278,10 @@ ROOM = room_manager.current_room
KILL_Y = ROOM.kill_y
platforms_ground = ROOM.platforms_ground
platforms_floating = ROOM.platforms_floating
platforms_hazard = ROOM.platforms_hazard
platforms_trigger = ROOM.platforms_trigger
platforms_moving = ROOM.platforms_moving
platform_meta = ROOM.platform_meta
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
platform_layer = ROOM.platform_layer
@@ -5155,10 +5323,8 @@ class QuestGiver:
self.bob_t = random.random() * 10.0
def draw(self, surface, cx, cy):
bob_y = int(math.sin(self.bob_t) * 2)
self.bob_t += 0.05
x = int(self.rect.x - cx)
y = int(self.rect.y - cy + bob_y)
y = int(self.rect.y - cy)
pygame.draw.rect(surface, (255, 130, 180), (x, y, self.rect.width, self.rect.height))
pygame.draw.rect(surface, (160, 40, 90), (x, y, self.rect.width, self.rect.height), 2)
@@ -5342,7 +5508,8 @@ 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 ROOM, KILL_Y, platforms_ground, platforms_floating, platforms_hazard, ground_platforms, platforms
global platforms_trigger, platforms_moving, platform_meta
global platform_layer
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global shatter_platforms, shatter_aoes, tether_shots
@@ -5359,6 +5526,10 @@ def apply_room(room: Room, set_player_spawn=True):
KILL_Y = ROOM.kill_y
platforms_ground = ROOM.platforms_ground
platforms_floating = ROOM.platforms_floating
platforms_hazard = ROOM.platforms_hazard
platforms_trigger = ROOM.platforms_trigger
platforms_moving = ROOM.platforms_moving
platform_meta = ROOM.platform_meta
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
platform_layer = ROOM.platform_layer
@@ -5941,10 +6112,8 @@ class DiaryObject:
self.bob_t = random.random() * 10.0
def draw(self, surface, cx, cy):
bob_y = int(math.sin(self.bob_t) * 2)
self.bob_t += 0.05
x = int(self.rect.x - cx)
y = int(self.rect.y - cy + bob_y)
y = int(self.rect.y - cy)
pygame.draw.rect(surface, (150, 110, 70), (x, y, self.rect.width, self.rect.height))
pygame.draw.rect(surface, (90, 60, 30), (x, y, self.rect.width, self.rect.height), 2)
pygame.draw.line(surface, (90, 60, 30),
@@ -5980,13 +6149,19 @@ def get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -
def save_at_lantern(sp: SavePoint):
"""Set the lantern as the respawn point and save the game."""
"""Set the lantern as the respawn point, restore HP/Charge, and save the game."""
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
LANTERN_RESPAWN_ROOM = ROOM.id
LANTERN_RESPAWN_POS = (sp.x, sp.y)
# Resting at a lantern fully restores the player.
player._cancel_heal()
player.health = player.max_health
player.charge = player.max_charge
player.poison_timer = 0.0
player.poison_acc = 0.0
player._clamp_resources()
_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.
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)
@@ -7231,6 +7406,9 @@ while running:
near_save_point = True
break
# Move moving platforms before collision is computed so the player collides correctly.
update_moving_platforms(dt)
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]
@@ -7242,6 +7420,22 @@ while running:
player.update(keys, player_collision_platforms, dt)
# 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:
if player.rect.colliderect(hz):
player.take_damage(1)
mdata = platform_meta.get(id(hz), {})
respawn = mdata.get("respawn")
if respawn:
spx, spy = respawn
else:
spx, spy = ROOM.player_spawn
player.rect.midbottom = (int(spx), int(spy))
player.vel_y = 0.0
player.on_ground = False
break
if player.is_shatterstep_active() and (not player.shade) and (not player.on_ground):
if (not player.shatterstep_platform_spawned) and player.shatterstep_platform_cd <= 0.0:
jump_v0 = abs(JUMP_LIGHT)
@@ -7301,7 +7495,7 @@ while running:
player.shatterstep_landing_pending = False
player.shatterstep_landing_damage = 0.0
else:
# Respawn at the latest lantern save point if one has been touched.
# Normal death respawn: latest lantern save point, else the room spawn.
if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS:
lx, ly = LANTERN_RESPAWN_POS
if LANTERN_RESPAWN_ROOM != ROOM.id: