diff --git a/light_and_dark/data/config/ranged.json b/light_and_dark/data/config/ranged.json
index eb469b5..6af6978 100644
--- a/light_and_dark/data/config/ranged.json
+++ b/light_and_dark/data/config/ranged.json
@@ -1,7 +1,7 @@
{
"charge_cost": 5.0,
"damage_mult": 0.5,
- "cooldown": 0.45,
+ "cooldown": 0.63,
"projectile": {
"speed": 650.0,
"lifetime": 1.2,
diff --git a/light_and_dark/data/world/rooms/room_04.json b/light_and_dark/data/world/rooms/room_04.json
index 41d3cd8..c4a6d16 100644
--- a/light_and_dark/data/world/rooms/room_04.json
+++ b/light_and_dark/data/world/rooms/room_04.json
@@ -231,7 +231,7 @@
"y": 540,
"w": 60,
"h": 20,
- "group": "moving",
+ "group": "dissolve",
"layer": 0,
"path": [
[
@@ -242,7 +242,9 @@
820,
600
]
- ]
+ ],
+ "stand_time": 2,
+ "dissolve_time": 2
},
{
"x": 1040,
diff --git a/light_and_dark/data/world/rooms/room_05.json b/light_and_dark/data/world/rooms/room_05.json
index 8a60914..fa4bef4 100644
--- a/light_and_dark/data/world/rooms/room_05.json
+++ b/light_and_dark/data/world/rooms/room_05.json
@@ -16,15 +16,15 @@
120
],
"room5_bottom_right": [
- 1400,
- 440
+ 1420,
+ 780
]
},
"platforms": [
{
- "x": 0,
+ "x": 20,
"y": 180,
- "w": 240,
+ "w": 220,
"h": 20,
"group": "ground",
"layer": 0
@@ -33,15 +33,7 @@
"x": 0,
"y": 0,
"w": 20,
- "h": 180,
- "group": "ground",
- "layer": 0
- },
- {
- "x": 20,
- "y": 0,
- "w": 1520,
- "h": 20,
+ "h": 840,
"group": "ground",
"layer": 0
},
@@ -62,83 +54,83 @@
"layer": 0
},
{
- "x": 860,
- "y": 500,
- "w": 680,
+ "x": 0,
+ "y": 840,
+ "w": 1520,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1520,
- "y": 380,
+ "y": 680,
"w": 20,
- "h": 120,
+ "h": 180,
"group": "ground",
"layer": 0
},
{
- "x": 1320,
- "y": 360,
- "w": 220,
+ "x": 1380,
+ "y": 700,
+ "w": 20,
+ "h": 140,
+ "group": "trigger",
+ "layer": 0,
+ "name": "open_when_boss_slain",
+ "open_on_boss_dead": true
+ },
+ {
+ "x": 1380,
+ "y": 680,
+ "w": 140,
"h": 20,
- "group": "ground",
- "layer": 0
+ "group": "trigger",
+ "layer": 0,
+ "name": "open_when_boss_slain",
+ "open_on_boss_dead": true
},
{
- "x": 1520,
- "y": 20,
+ "x": 1380,
+ "y": 0,
"w": 20,
- "h": 340,
+ "h": 680,
"group": "ground",
"layer": 0
},
{
- "x": 1180,
- "y": 440,
- "w": 1,
- "h": 1,
- "group": "ground"
- },
- {
- "x": 1180,
- "y": 420,
- "w": 60,
+ "x": 1040,
+ "y": 740,
+ "w": 160,
"h": 20,
"group": "moving",
+ "layer": 0,
"path": [
[
- 1180,
- 420
+ 1040,
+ 740
],
[
- 1180,
- 360
+ 1040,
+ 200
]
- ]
+ ],
+ "open_on_boss_dead": true
}
],
"enemies": [
{
- "x": 520,
- "y": 140,
- "type": "enemy",
+ "x": 620,
+ "y": 720,
+ "type": "boss",
"layer": 0,
- "behavior": ""
- },
- {
- "x": 1000,
- "y": 460,
- "type": "enemy",
- "layer": 0,
- "behavior": ""
+ "behavior": "charger"
}
],
"npcs": [],
"props": [
{
- "x": 1400,
- "y": 360,
+ "x": 860,
+ "y": 180,
"type": "save_point",
"radius": 90,
"object_id": "lamp",
@@ -163,14 +155,14 @@
},
{
"x": 1440,
- "y": 400,
+ "y": 740,
"w": 80,
"h": 100,
- "to": "room_06",
- "spawn_id": "room6_top_right",
+ "to": "room_07",
+ "spawn_id": "room7_top_left",
"spawn": [
- 520,
- 240
+ 180,
+ 460
],
"hint": "",
"layer": 0
@@ -178,5 +170,6 @@
],
"ability_doors": [],
"room_w": 1600,
- "room_h": 900
+ "room_h": 900,
+ "mist": false
}
\ No newline at end of file
diff --git a/light_and_dark/data/world/rooms/room_07.json b/light_and_dark/data/world/rooms/room_07.json
new file mode 100644
index 0000000..09b5eae
--- /dev/null
+++ b/light_and_dark/data/world/rooms/room_07.json
@@ -0,0 +1,90 @@
+{
+ "name": "room_07",
+ "display_name": "room_07",
+ "meta": {
+ "id": "room_07",
+ "name": "room_07"
+ },
+ "bounds": {},
+ "player_spawn": [
+ 220,
+ 440
+ ],
+ "entry_spawns": {
+ "room7_top_left": [
+ 180,
+ 460
+ ]
+ },
+ "platforms": [
+ {
+ "x": 60,
+ "y": 200,
+ "w": 20,
+ "h": 340,
+ "group": "ground",
+ "layer": 0
+ },
+ {
+ "x": 80,
+ "y": 520,
+ "w": 920,
+ "h": 20,
+ "group": "ground",
+ "layer": 0
+ }
+ ],
+ "enemies": [],
+ "npcs": [],
+ "props": [
+ {
+ "x": 440,
+ "y": 494,
+ "type": "reward_chest",
+ "radius": 180,
+ "object_id": null,
+ "layer": 0,
+ "quest_id": "",
+ "coins": [
+ 30,
+ 10
+ ]
+ },
+ {
+ "x": 820,
+ "y": 500,
+ "type": "ability_pickup",
+ "radius": 180,
+ "object_id": null,
+ "layer": 0,
+ "quest_id": ""
+ },
+ {
+ "x": 640,
+ "y": 520,
+ "type": "save_point",
+ "radius": 90,
+ "object_id": "lamp"
+ }
+ ],
+ "exits": [
+ {
+ "x": 80,
+ "y": 420,
+ "w": 80,
+ "h": 100,
+ "to": "room_05",
+ "spawn_id": "room5_bottom_right",
+ "spawn": [
+ 1420,
+ 780
+ ],
+ "hint": "",
+ "layer": 0
+ }
+ ],
+ "ability_doors": [],
+ "room_w": 1600,
+ "room_h": 900,
+ "mist": false
+}
\ No newline at end of file
diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html
index ac72bfe..1544864 100644
--- a/light_and_dark/level_editor_v43.html
+++ b/light_and_dark/level_editor_v43.html
@@ -114,6 +114,7 @@
+
@@ -361,7 +362,10 @@
if(rect.quest_turned_in) out.quest_turned_in = String(rect.quest_turned_in);
if(rect.lock_when_pass) out.lock_when_pass = true;
if(rect.open_on_miniboss_dead) out.open_on_miniboss_dead = true;
+ if(rect.open_on_boss_dead) out.open_on_boss_dead = true;
if(Array.isArray(rect.respawn)) out.respawn = deepClone(rect.respawn);
+ if(rect.stand_time !== undefined) out.stand_time = +rect.stand_time;
+ if(rect.dissolve_time !== undefined) out.dissolve_time = +rect.dissolve_time;
return out;
}
return null;
@@ -1448,6 +1452,8 @@ Cancel = Choose whether to remove without saving`);
save_point: { ring: "rgba(56,189,248,0.30)", fill: "rgba(56,189,248,0.9)", label: "Lantern" },
butterfly_jar: { ring: "rgba(168,85,247,0.22)", fill: "rgba(168,85,247,0.9)", label: "Jar" },
diary: { ring: "rgba(150,110,70,0.25)", fill: "rgba(150,110,70,0.95)", label: "Diary" },
+ reward_chest: { ring: "rgba(180,120,60,0.30)", fill: "rgba(190,125,70,0.95)", label: "Chest" },
+ ability_pickup:{ ring: "rgba(200,230,255,0.30)", fill: "rgba(200,230,255,0.95)", label: "Ability" },
};
const ptype = p.type || "light_source";
const st = PROP_STYLES[ptype] || { ring: "rgba(148,163,184,0.25)", fill: "rgba(148,163,184,0.9)", label: ptype };
@@ -1553,6 +1559,7 @@ Cancel = Choose whether to remove without saving`);
if(p && (p.group === "moving")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "moving", p });
else if(p && (p.group === "trigger")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "trigger", p });
else if(p && (p.group === "hazard")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "hazard", p });
+ else if(p && (p.group === "dissolve")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "dissolve", p });
else layerable.push({ z: +(p.layer||0), cat: 1, kind: "platform", p });
}
for(const d of (room.ability_doors || [])) if(d) layerable.push({ z: +(d.layer||0), cat: 2, kind: "door", d });
@@ -1655,6 +1662,18 @@ Cancel = Choose whether to remove without saving`);
ctx.stroke();
ctx.setLineDash([]);
}
+ } else if(it.kind === "dissolve"){
+ const p = it.p;
+ const [sx,sy] = worldToScreen(p.x, p.y, canvas);
+ const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
+ ctx.fillStyle = "rgba(96,165,250,0.15)";
+ ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
+ ctx.strokeStyle = "rgba(150,210,255,0.9)";
+ ctx.lineWidth = 2*dpr;
+ ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
+ ctx.fillStyle = "rgba(229,231,235,0.9)";
+ ctx.font = `${11*dpr}px system-ui`;
+ ctx.fillText("Dissolve", sx+4*dpr, sy-4*dpr);
} else if(it.kind === "door"){
const d = it.d;
const [sx,sy] = worldToScreen(d.x, d.y, canvas);
@@ -2162,6 +2181,7 @@ Cancel = Choose whether to remove without saving`);
+
${(p.group||"ground") === "trigger" ? `
@@ -2186,6 +2206,9 @@ Cancel = Choose whether to remove without saving`);
+
+
+
` : ""}
${(p.group||"ground") === "hazard" ? `
@@ -2204,7 +2227,16 @@ Cancel = Choose whether to remove without saving`);
+
+
` : ""}
+ ${(p.group||"ground") === "dissolve" ? `
+
+
While the player stands on it, after the stand time it becomes translucent (no hitbox) for the dissolve time, then reforms.
` : ""}
@@ -2431,6 +2463,10 @@ Cancel = Choose whether to remove without saving`);
const pg=q("pg"); if(pg) pg.addEventListener("change", ()=>pushAnd(()=>{
p.group = pg.value || "ground";
if(p.group === "moving" && !Array.isArray(p.path)) p.path = [[p.x, p.y]];
+ if(p.group === "dissolve"){
+ if(p.stand_time === undefined) p.stand_time = 1.0;
+ if(p.dissolve_time === undefined) p.dissolve_time = 2.0;
+ }
if(p.group !== "trigger" && p.name !== undefined && p.name !== "") delete p.name;
}));
const ptname=q("ptname"); if(ptname) ptname.addEventListener("change", ()=>pushAnd(()=>{
@@ -2455,7 +2491,15 @@ Cancel = Choose whether to remove without saving`);
const ptminibossdead=q("ptminibossdead"); if(ptminibossdead) ptminibossdead.addEventListener("change", ()=>pushAnd(()=>{
if(ptminibossdead.checked){ p.open_on_miniboss_dead = true; } else { delete p.open_on_miniboss_dead; }
}));
+ const ptbossdead=q("ptbossdead"); if(ptbossdead) ptbossdead.addEventListener("change", ()=>pushAnd(()=>{
+ if(ptbossdead.checked){ p.open_on_boss_dead = true; } else { delete p.open_on_boss_dead; }
+ }));
const ptspeed=q("ptspeed"); if(ptspeed) ptspeed.addEventListener("change", ()=>pushAnd(()=>{ p.speed = Math.max(1, num(ptspeed.value)); }));
+ const pmbossdead=q("pmbossdead"); if(pmbossdead) pmbossdead.addEventListener("change", ()=>pushAnd(()=>{
+ if(pmbossdead.checked){ p.open_on_boss_dead = true; } else { delete p.open_on_boss_dead; }
+ }));
+ const ptstand=q("ptstand"); if(ptstand) ptstand.addEventListener("change", ()=>pushAnd(()=>{ p.stand_time = Math.max(0.1, num(ptstand.value)); }));
+ const ptdissolve=q("ptdissolve"); if(ptdissolve) ptdissolve.addEventListener("change", ()=>pushAnd(()=>{ p.dissolve_time = Math.max(0.1, num(ptdissolve.value)); }));
const hzrespawn=q("hzrespawn");
if(hzrespawn){
// preselect current respawn if it matches an entry spawn
@@ -3353,19 +3397,24 @@ Cancel = Choose whether to remove without saving`);
for(const p of (d.platforms || [])){
const group = p.group || "ground";
if(!platforms[group]) platforms[group] = [];
- if(group === "moving" || group === "trigger" || group === "hazard"){
+ if(group === "moving" || group === "trigger" || group === "hazard" || group === "dissolve"){
const obj = { x: p.x, y: p.y, w: p.w, h: p.h };
if(p.layer) obj.layer = p.layer;
if(group === "moving"){
if(Array.isArray(p.path)) obj.path = p.path;
if(p.speed !== undefined) obj.speed = p.speed;
}
+ if(group === "dissolve"){
+ if(p.stand_time !== undefined) obj.stand_time = p.stand_time;
+ if(p.dissolve_time !== undefined) obj.dissolve_time = p.dissolve_time;
+ }
if(p.name) obj.name = p.name;
if(p.quest) obj.quest = p.quest;
if(p.quest_complete) obj.quest_complete = p.quest_complete;
if(p.quest_turned_in) obj.quest_turned_in = p.quest_turned_in;
if(p.lock_when_pass) obj.lock_when_pass = true;
if(p.open_on_miniboss_dead) obj.open_on_miniboss_dead = true;
+ if(p.open_on_boss_dead) obj.open_on_boss_dead = true;
if(Array.isArray(p.respawn)) obj.respawn = p.respawn;
platforms[group].push(obj);
} else {
diff --git a/light_and_dark/main.py b/light_and_dark/main.py
index a8993fb..61ec339 100644
--- a/light_and_dark/main.py
+++ b/light_and_dark/main.py
@@ -19,16 +19,24 @@ SHOCKWAVE_LIFETIME = 2.0 # seconds
SHOCKWAVE_HEIGHT = 20 # hitbox height
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
+ATTACK_COOLDOWN_BASE = 0.49 # 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
+# --- Miniboss "Discharge" (anti-camp AOE) ---
+DISCHARGE_DAMAGE = 2 # AOE damage to the player
+DISCHARGE_CHARGE_TIME = 1.5 # seconds standing still before the blast (telegraph)
+DISCHARGE_RADIUS = 280 # blast radius in px (ignores platforms)
+DISCHARGE_COOLDOWN = 4.0 # seconds before the miniboss can discharge again
+DISCHARGE_SUSTAIN = 0.6 # how long the player must be unreachable before it commits
+DISCHARGE_ABOVE_GAP = 30 # player feet must be this far above the miniboss's head to count as unreachable
+
# --- 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
-RANGED_COOLDOWN = 0.45 # seconds between shots (feel-tweak)
+RANGED_COOLDOWN = 0.63 # seconds between shots (feel-tweak)
PROJECTILE_SPEED = 650.0 # px/sec
PROJECTILE_LIFETIME = 1.2 # seconds
PROJECTILE_SIZE = (14, 6) # width, height
@@ -79,6 +87,10 @@ SHATTER_AOE_LIFETIME = 0.25
SHATTER_ICON_SIZE = 32
SHATTER_ICON_POS = (20, 20)
+# --- Dissolve platforms (per-room "dissolve" group) ---
+DISSOLVE_STAND_TIME = 1.0 # seconds the player must stand before it dissolves
+DISSOLVE_TIME = 2.0 # seconds it stays translucent (no hitbox) once dissolved
+
# --- Tether ability ---
TETHER_BIND_DURATION = 3.0
TETHER_COOLDOWN = 6.0
@@ -578,18 +590,45 @@ def _build_mist_tile(size: int = MIST_TILE_SIZE) -> pygame.Surface:
pygame.draw.circle(surf, color, (cx + ox, cy + oy), r)
return surf
+def _build_mist_tile_slow(size: int = MIST_TILE_SIZE) -> pygame.Surface:
+ """Second, fainter mist layer with a different pattern, used to make the
+ haze visibly drift (two layers scrolling at different speeds)."""
+ surf = pygame.Surface((size, size), pygame.SRCALPHA)
+ rng = random.Random(90210)
+ surf.fill((70, 20, 115, 4))
+ for _ in range(9):
+ cx = rng.randint(0, size)
+ cy = rng.randint(0, size)
+ r = rng.randint(70, 160)
+ alpha = rng.randint(5, 12)
+ color = (110, 40, 165, alpha)
+ for ox in (-size, 0, size):
+ for oy in (-size, 0, size):
+ pygame.draw.circle(surf, color, (cx + ox, cy + oy), r)
+ return surf
+
MIST_TILE = _build_mist_tile()
+MIST_TILE_SLOW = _build_mist_tile_slow()
def draw_mist_overlay(target: pygame.Surface):
if MIST_TILE is None or not getattr(ROOM, "mist", False):
return
size = MIST_TILE_SIZE
t = pygame.time.get_ticks() / 1000.0
- dx = int((t * 10.0) % size)
- dy = int((t * 6.0) % size)
+ # Base layer drifts one way...
+ dx = int((t * 30.0) % size)
+ dy = int((t * 22.0) % size)
for tx in range(-1, WIDTH // size + 2):
for ty in range(-1, HEIGHT // size + 2):
target.blit(MIST_TILE, (tx * size - dx, ty * size - dy))
+ # ...while the fainter layer drifts slower and in a different direction,
+ # so the mist visibly curls instead of looking like a static texture.
+ if MIST_TILE_SLOW is not None:
+ dx2 = int((t * 9.0) % size)
+ dy2 = int((size - int((t * 5.0) % size)) % size)
+ for tx in range(-1, WIDTH // size + 2):
+ for ty in range(-1, HEIGHT // size + 2):
+ target.blit(MIST_TILE_SLOW, (tx * size - dx2, ty * size - dy2))
VIGNETTE_CLEAR_RADIUS = 150
_VIGNETTE_HOLE_CACHE: dict[tuple[int, int], pygame.Surface] = {}
@@ -974,7 +1013,7 @@ def normalize_room_json(room_json: dict) -> dict:
grouped["ground"].append(arr)
elif isinstance(p, dict):
group = p.get("group", "ground")
- if group in ("moving", "trigger", "hazard"):
+ if group in ("moving", "trigger", "hazard", "dissolve"):
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"]
@@ -983,6 +1022,11 @@ def normalize_room_json(room_json: dict) -> dict:
obj["path"] = p["path"]
if "speed" in p:
obj["speed"] = p["speed"]
+ if group == "dissolve":
+ if "stand_time" in p:
+ obj["stand_time"] = p["stand_time"]
+ if "dissolve_time" in p:
+ obj["dissolve_time"] = p["dissolve_time"]
if "name" in p:
obj["name"] = p["name"]
if "quest" in p:
@@ -995,6 +1039,8 @@ def normalize_room_json(room_json: dict) -> dict:
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 "open_on_boss_dead" in p:
+ obj["open_on_boss_dead"] = p["open_on_boss_dead"]
if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2:
obj["respawn"] = p["respawn"]
grouped.setdefault(group, []).append(obj)
@@ -1099,6 +1145,7 @@ class Room:
self.platform_layer: dict[int, int] = {}
self.platform_meta: dict[int, dict] = {} # id(rect) -> {"name"|"path"|"speed", ...}
+ self.platforms_dissolve: list[pygame.Rect] = []
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)
@@ -1142,6 +1189,8 @@ class Room:
meta["lock_when_pass"] = True
if tp.get("open_on_miniboss_dead"):
meta["open_on_miniboss_dead"] = True
+ if tp.get("open_on_boss_dead"):
+ meta["open_on_boss_dead"] = True
self.platform_meta[id(rect)] = meta
self.platforms_trigger.append(rect)
elif isinstance(tp, (list, tuple)) and len(tp) >= 4:
@@ -1163,7 +1212,10 @@ class Room:
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}
+ m_meta = {"path": path, "speed": speed, "i": 0, "t": 0.0}
+ if mp.get("open_on_boss_dead"):
+ m_meta["open_on_boss_dead"] = True
+ self.platform_meta[id(rect)] = m_meta
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]))
@@ -1173,16 +1225,37 @@ class Room:
except (TypeError, ValueError):
pass
self.platforms_moving.append(rect)
+ self.platforms_dissolve = []
+ for dp in platforms.get("dissolve", []):
+ if isinstance(dp, dict):
+ rect = pygame.Rect(int(dp.get("x", 0)), int(dp.get("y", 0)), int(dp.get("w", 0)), int(dp.get("h", 0)))
+ self.platform_layer[id(rect)] = int(dp.get("layer", 0))
+ self.platform_meta[id(rect)] = {
+ "stand_time": float(dp.get("stand_time", DISSOLVE_STAND_TIME)),
+ "dissolve_time": float(dp.get("dissolve_time", DISSOLVE_TIME)),
+ "timer": 0.0,
+ "dissolving": False,
+ }
+ self.platforms_dissolve.append(rect)
+ elif isinstance(dp, (list, tuple)) and len(dp) >= 4:
+ rect = pygame.Rect(int(dp[0]), int(dp[1]), int(dp[2]), int(dp[3]))
+ if len(dp) >= 5:
+ try:
+ self.platform_layer[id(rect)] = int(dp[4])
+ except (TypeError, ValueError):
+ pass
+ self.platforms_dissolve.append(rect)
else:
self.platforms_ground = []
self.platforms_floating = []
self.platforms_hazard = []
self.platforms_trigger = []
self.platforms_moving = []
+ self.platforms_dissolve = []
self.ground_platforms = self.platforms_ground[:]
self.platforms = self.platforms_ground + self.platforms_floating + self.platforms_moving
- all_platform_rects = self.platforms_ground + self.platforms_floating + self.platforms_moving + self.platforms_hazard + self.platforms_trigger
+ all_platform_rects = self.platforms_ground + self.platforms_floating + self.platforms_moving + self.platforms_hazard + self.platforms_trigger + self.platforms_dissolve
if all_platform_rects:
self.min_x = min(r.left for r in all_platform_rects)
self.max_x = max(r.right for r in all_platform_rects)
@@ -1534,9 +1607,12 @@ class Projectile:
w = max(1, int(img.get_width() * scale))
h = max(1, int(img.get_height() * scale))
img = pygame.transform.scale(img, (w, h))
- if self.dir > 0:
- img = pygame.transform.flip(img, True, False)
- surface.blit(img, (self.rect.centerx - w // 2 - cx, self.rect.centery - h // 2 - cy))
+ # Raw sprite points LEFT; flip it so it points right, then rotate to
+ # match the actual flight direction so up/down shots aren't horizontal.
+ img = pygame.transform.flip(img, True, False)
+ angle = -math.degrees(math.atan2(self.dir_y, self.dir_x))
+ img = pygame.transform.rotate(img, angle)
+ surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx, self.rect.centery - img.get_height() // 2 - cy))
return
# simple readable bolt
pygame.draw.rect(
@@ -1761,11 +1837,15 @@ class LightSource:
class SavePoint:
- """Lantern save point: pressing interact nearby saves the game and sets the respawn point."""
+ """Lantern save point: pressing interact nearby saves the game and sets the respawn point.
+
+ One-time use per room visit: once used, the lantern stays spent until the
+ player leaves the room and comes back (the flag resets on room load)."""
def __init__(self, x: int, y: int, radius: int = 90):
self.x = int(x)
self.y = int(y)
self.radius = int(radius)
+ self.used = False
self.t = random.random() * 10.0
if LAMP_RAW is not None:
self.img = LAMP_RAW
@@ -1780,6 +1860,10 @@ class SavePoint:
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy))
else:
pygame.draw.rect(surface, (150, 200, 230), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height))
+ if self.used:
+ dim = pygame.Surface((self.rect.width, self.rect.height), pygame.SRCALPHA)
+ dim.fill((30, 30, 40, 150))
+ surface.blit(dim, (self.rect.x - cx, self.rect.y - cy))
class ButterflyJar:
@@ -1933,14 +2017,14 @@ class LootPickup:
class CoinPickup:
- def __init__(self, x, y, kind: str):
+ def __init__(self, x, y, kind: str, vx: float | None = None, vy: float | None = None):
# kind: "light" or "dark"
self.kind = kind
self.rect = pygame.Rect(int(x), int(y), 16, 16)
# physics (same feel as loot)
- self.vx = random.uniform(-80, 80)
- self.vy = random.uniform(-140, -60)
+ self.vx = random.uniform(-80, 80) if vx is None else vx
+ self.vy = random.uniform(-140, -60) if vy is None else vy
self.on_ground = False
self.bob_t = random.random() * 10.0
@@ -2029,12 +2113,29 @@ class RewardChest:
if self.opened:
return False
light_amt, dark_amt = self.coins
- cx = self.rect.centerx
- cy = self.rect.top - 18 # pop out of the chest top, clear of its platform
+ # spray coins across the chest width and above its top so they don't
+ # all stack on a player standing directly in front of the chest
+ half_span = self.rect.width // 2 + 24
for _ in range(max(0, int(light_amt))):
- coin_drops.append(CoinPickup(cx, cy, "light"))
+ ox = random.randint(-half_span, half_span)
+ oy = random.randint(0, 40)
+ coin_drops.append(CoinPickup(
+ self.rect.centerx + ox,
+ self.rect.top - 18 - oy,
+ "light",
+ vx=random.uniform(-170, 170),
+ vy=random.uniform(-230, -90),
+ ))
for _ in range(max(0, int(dark_amt))):
- coin_drops.append(CoinPickup(cx, cy, "dark"))
+ ox = random.randint(-half_span, half_span)
+ oy = random.randint(0, 40)
+ coin_drops.append(CoinPickup(
+ self.rect.centerx + ox,
+ self.rect.top - 18 - oy,
+ "dark",
+ vx=random.uniform(-170, 170),
+ vy=random.uniform(-230, -90),
+ ))
self.opened = True
return True
@@ -3978,7 +4079,12 @@ class Player:
elif key_pressed(keys, "move_right"):
self.facing_right = True
- speed = PLAYER_SHADE_SPEED if self.shade else PLAYER_SPEED
+ # In mist rooms the shade form has no movement penalty — the haze is
+ # home to it, so it moves at full (light-form) speed.
+ if self.mist_active:
+ speed = PLAYER_SPEED
+ else:
+ speed = PLAYER_SHADE_SPEED if self.shade else PLAYER_SPEED
form_bonus = self.bonus_move_speed_shade_pct if self.shade else self.bonus_move_speed_light_pct
speed *= (1.0 + self.bonus_move_speed_pct + form_bonus)
@@ -4255,6 +4361,18 @@ class Enemy:
self.max_step_up = 160
self.max_step_forward = 120
+ # Miniboss "Discharge": stand-still telegraph then a platform-piercing AOE
+ # when the player camps somewhere the miniboss can't reach.
+ self.discharge_state = "idle" # "idle" | "charging"
+ self.discharge_timer = 0.0
+ self.discharge_cd = 0.0
+ self.discharge_buildup = 0.0
+ self.discharge_flash = 0.0
+ # Head height while grounded. The discharge checks the player against this
+ # (not the current airborne rect.top), so jumping up at a camping player
+ # no longer resets the discharge buildup.
+ self.discharge_ground_top = self.rect.top
+
self.contact_damage = int(cfg.get("contact_damage", 1))
self.drop_profile = str(cfg.get("drop_profile", "normal"))
@@ -4403,6 +4521,20 @@ class Enemy:
best_top = p.top
return best
+ def _player_unreachable_above(self, player) -> bool:
+ """True if the player is camping above the miniboss, out of reach."""
+ ground_top = getattr(self, "discharge_ground_top", self.rect.top)
+ return player.rect.bottom < ground_top - DISCHARGE_ABOVE_GAP
+
+ def _discharge_release(self, player):
+ """Resolve the Discharge blast: platform-piercing AOE damage."""
+ dx = player.rect.centerx - self.rect.centerx
+ dy = player.rect.centery - self.rect.centery
+ if math.hypot(dx, dy) <= DISCHARGE_RADIUS and (not player.shade):
+ player.take_damage(DISCHARGE_DAMAGE)
+ player.apply_knockback(self.rect, strength_px_per_frame=16.0, duration=0.25, pop_up_vel=-4.0)
+ self.discharge_flash = 0.35
+
def _probe_ground_now(self, platforms):
"""Refresh on_ground + ground_platform every frame (stable)."""
@@ -4833,10 +4965,43 @@ class Enemy:
self.vel_x = self.direction * desired_speed
+ # --- Miniboss Discharge: anti-camp AOE (stand still, then blast) ---
+ discharging = self.discharge_state == "charging"
+ if self.drop_profile == "miniboss":
+ # Track the resting head height so jumps at a camping player don't
+ # reset the discharge buildup (see _player_unreachable_above).
+ if self.on_ground:
+ self.discharge_ground_top = self.rect.top
+ if self.discharge_cd > 0.0:
+ self.discharge_cd = max(0.0, self.discharge_cd - dt)
+ if self.discharge_flash > 0.0:
+ self.discharge_flash = max(0.0, self.discharge_flash - dt)
+ if discharging:
+ self.discharge_timer -= dt
+ if self.discharge_timer <= 0.0:
+ self.discharge_state = "idle"
+ self.discharge_cd = DISCHARGE_COOLDOWN
+ self.discharge_buildup = 0.0
+ self._discharge_release(player)
+ discharging = False
+ elif chasing and (not stunned) and (not charg_acting):
+ if self._player_unreachable_above(player):
+ self.discharge_buildup += dt
+ if self.discharge_buildup >= DISCHARGE_SUSTAIN and self.discharge_cd <= 0.0:
+ self.discharge_state = "charging"
+ self.discharge_timer = DISCHARGE_CHARGE_TIME
+ self.discharge_buildup = 0.0
+ discharging = True
+ else:
+ self.discharge_buildup = 0.0
+ if discharging:
+ self.vel_x = 0.0
+
+
# --- CHASE LEDGE DROP ---
# If we're chasing and the player is below, step off the platform instead of edge-wiggling.
jump_target = None
- if chasing and self.on_ground and self.ground_platform is not None:
+ if chasing and (not discharging) and self.on_ground and self.ground_platform is not None:
gp = self.ground_platform
# IMPORTANT: define this right here so it's ALWAYS available
@@ -4941,7 +5106,7 @@ class Enemy:
# regular enemy jump helper (charger moves via charge states instead)
- if self.on_ground and chasing and self.jump_cd_timer <= 0 and (not charg_acting):
+ if self.on_ground and chasing and (not discharging) and self.jump_cd_timer <= 0 and (not charg_acting):
player_above = player.rect.centery < self.rect.centery - self.jump_when_player_above
if player_above and (blocked_by_wall or self.reachable_platform_ahead(platforms)):
self.vel_y = self.jump_strength
@@ -4985,6 +5150,28 @@ class Enemy:
if self.bind_timer > 0.0:
_draw_tether_chain_loop(surface, self.rect, light=self.bound_light, cx=cx, cy=cy)
+ if self.discharge_state == "charging" or self.discharge_flash > 0.0:
+ if self.discharge_state == "charging":
+ progress = 1.0 - (self.discharge_timer / DISCHARGE_CHARGE_TIME)
+ radius = max(20, int(20 + progress * (DISCHARGE_RADIUS - 20)))
+ ring_color = (190, 80, 220, 40 + int(progress * 80))
+ body_color = (220, 120, 255)
+ else:
+ radius = int(DISCHARGE_RADIUS)
+ ring_color = (255, 180, 90, 130)
+ body_color = (255, 200, 120)
+ px = self.rect.centerx - cx
+ py = self.rect.centery - cy
+ ring = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
+ pygame.draw.circle(ring, ring_color, (radius, radius), radius, 3)
+ surface.blit(ring, (px - radius, py - radius))
+ pygame.draw.rect(
+ surface,
+ body_color,
+ pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height),
+ 2,
+ )
+
def resolve_enemy_collisions(enemies):
# for i in range(len(enemies)):
@@ -5146,7 +5333,7 @@ def draw_pause_controls(surface, x, y):
f"{key_name(key_for('move_left'))}/{key_name(key_for('move_right'))} = move",
f"{key_name(key_for('jump'))} = jump",
f"{key_name(key_for('attack'))} = attack",
- f"{key_name(key_for('ranged'))} = ranged",
+ "Right Mouse Button = ranged",
f"{key_name(key_for('dash'))} = dash",
f"{key_name(key_for('shatterstep'))} = shatterstep",
f"{key_name(key_for('tether'))} = tether",
@@ -5261,6 +5448,9 @@ def trigger_wall_blocking(rect) -> bool:
- ``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).
+ - ``open_on_boss_dead``: releases the wall once the room's boss 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")
@@ -5275,11 +5465,15 @@ def trigger_wall_blocking(rect) -> bool:
present = meta["quest_turned_in"] not in quest_manager.ever_completed
elif meta.get("open_on_miniboss_dead"):
present = True # always present until released
+ elif meta.get("open_on_boss_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()
+ if meta.get("open_on_boss_dead"):
+ present = present and boss_alive_in_room()
return present
@@ -5294,6 +5488,8 @@ def enemy_trigger_wall_blocking(rect) -> bool:
meta = platform_meta.get(id(rect), {})
if meta.get("open_on_miniboss_dead"):
return miniboss_alive_in_room()
+ if meta.get("open_on_boss_dead"):
+ return boss_alive_in_room()
return trigger_wall_blocking(rect)
@@ -5306,6 +5502,15 @@ def miniboss_alive_in_room() -> bool:
return any(getattr(en, "drop_profile", "") == "miniboss" and en.health > 0 for en in enemies)
+def moving_platform_active(rect) -> bool:
+ """A moving platform with ``open_on_boss_dead`` is hidden and inert while
+ the room's boss is alive; it appears and starts moving once the boss is slain."""
+ meta = platform_meta.get(id(rect), {})
+ if meta.get("open_on_boss_dead") and boss_alive_in_room():
+ return False
+ return True
+
+
def push_rect_out_of_rects(rect, rects):
"""Push a pygame.Rect out of any overlapping rect, moving to the nearest edge.
@@ -5370,8 +5575,11 @@ def update_moving_platforms(dt: float):
"""Advance moving platforms along their waypoint paths (top-left follows the path).
A platform stops while the player or an enemy is in its way, and resumes
- from the same spot once the path is clear again."""
+ from the same spot once the path is clear again. Boss-gated platforms are
+ skipped entirely while their boss is alive."""
for rect in platforms_moving:
+ if not moving_platform_active(rect):
+ continue
mdata = platform_meta.get(id(rect))
if not mdata:
continue
@@ -5412,6 +5620,45 @@ def update_moving_platforms(dt: float):
rect.y = new_y
+def update_dissolve_platforms(dt: float):
+ """Advance dissolve-platform timers.
+
+ A dissolve platform behaves like a normal platform while solid. If the player
+ stands on it for its ``stand_time``, it turns translucent (no hitbox) for its
+ ``dissolve_time``, then returns to solid again. Returns the rects that are
+ currently solid (callers add these to the collision lists).
+ """
+ solid_rects = []
+ for rect in platforms_dissolve:
+ meta = platform_meta.get(id(rect))
+ if not meta:
+ solid_rects.append(rect)
+ continue
+ if meta.get("dissolving"):
+ meta["timer"] += dt
+ if meta["timer"] >= float(meta.get("dissolve_time", DISSOLVE_TIME)):
+ meta["dissolving"] = False
+ meta["timer"] = 0.0
+ continue
+ # Standing check uses geometry (like the moving-platform support check),
+ # not player.ground_platform, which flickers when resting exactly on top.
+ standing = (
+ player.rect.bottom >= rect.top - 2
+ and player.rect.bottom <= rect.top + 5
+ and player.rect.right > rect.left
+ and player.rect.left < rect.right
+ )
+ if standing:
+ meta["timer"] += dt
+ if meta["timer"] >= float(meta.get("stand_time", DISSOLVE_STAND_TIME)):
+ meta["dissolving"] = True
+ meta["timer"] = 0.0
+ else:
+ meta["timer"] = 0.0
+ solid_rects.append(rect)
+ return solid_rects
+
+
def draw_layerable(surface, cx, cy):
"""Draw all static room objects back-to-front by (layer, category).
@@ -5440,9 +5687,18 @@ def draw_layerable(surface, cx, cy):
items.append((getattr(reward_ability, "layer", 0), 0, "rewardability", reward_ability))
# platforms
for p in platforms:
+ # Boss-gated moving platforms are also in `platforms` (ground+floating+
+ # moving); skip them here so they're fully invisible while inactive.
+ if not moving_platform_active(p):
+ continue
items.append((platform_layer.get(id(p), 0), 1, "platform", p))
+ # dissolve platforms (solid or translucent)
+ for p in platforms_dissolve:
+ items.append((platform_layer.get(id(p), 0), 1, "dissolve", p))
# moving platforms (solid, follow a path) layer alongside platforms
for p in platforms_moving:
+ if not moving_platform_active(p):
+ continue
items.append((platform_layer.get(id(p), 0), 1, "moving", p))
# hazard zones (red) layer alongside platforms
for p in platforms_hazard:
@@ -5478,6 +5734,16 @@ def draw_layerable(surface, cx, cy):
PLATFORM_COLOR,
pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height),
)
+ elif kind == "dissolve":
+ mdata = platform_meta.get(id(obj), {})
+ drect = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
+ if mdata.get("dissolving"):
+ fade = pygame.Surface((obj.width, obj.height), pygame.SRCALPHA)
+ fade.fill((96, 165, 250, 90))
+ surface.blit(fade, (drect.x, drect.y))
+ pygame.draw.rect(surface, (150, 210, 255), drect, 1)
+ else:
+ pygame.draw.rect(surface, PLATFORM_COLOR, drect)
elif kind == "moving":
mr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
pygame.draw.rect(surface, (80, 180, 220), mr)
@@ -5735,6 +6001,7 @@ platforms_floating = ROOM.platforms_floating
platforms_hazard = ROOM.platforms_hazard
platforms_trigger = ROOM.platforms_trigger
platforms_moving = ROOM.platforms_moving
+platforms_dissolve = ROOM.platforms_dissolve
platform_meta = ROOM.platform_meta
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
@@ -5966,6 +6233,7 @@ 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, platforms_hazard, ground_platforms, platforms
global platforms_trigger, platforms_moving, platform_meta
+ global platforms_dissolve
global platform_layer
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global shatter_platforms, shatter_aoes, tether_shots
@@ -5987,6 +6255,7 @@ def apply_room(room: Room, set_player_spawn=True):
platforms_hazard = ROOM.platforms_hazard
platforms_trigger = ROOM.platforms_trigger
platforms_moving = ROOM.platforms_moving
+ platforms_dissolve = ROOM.platforms_dissolve
platform_meta = ROOM.platform_meta
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
@@ -6633,12 +6902,25 @@ def get_near_diary(player_obj, diary_obj) -> bool:
return math.hypot(dx, dy) <= DiaryObject.INTERACT_RANGE
+CHEST_INTERACT_RANGE = 70
+
+
+def get_near_chest(player_obj, chest_obj) -> bool:
+ if chest_obj is None or chest_obj.opened:
+ return False
+ dx = player_obj.rect.centerx - chest_obj.rect.centerx
+ dy = player_obj.rect.centery - chest_obj.rect.centery
+ return math.hypot(dx, dy) <= CHEST_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."""
+ """Return the nearest un-used 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:
+ if sp.used:
+ continue
r = range_px if range_px is not None else sp.radius
if r <= 0:
continue
@@ -6655,6 +6937,9 @@ 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, restore HP/Charge, and save the game."""
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
+ if sp.used:
+ return
+ sp.used = True
LANTERN_RESPAWN_ROOM = ROOM.id
LANTERN_RESPAWN_POS = (sp.x, sp.y)
# Resting at a lantern fully restores the player.
@@ -6949,7 +7234,10 @@ def draw_stats_overlay(surface):
if not stats_overlay_visible:
return
light_speed = PLAYER_SPEED * (1.0 + player.bonus_move_speed_pct + player.bonus_move_speed_light_pct)
- shade_speed = PLAYER_SHADE_SPEED * (1.0 + player.bonus_move_speed_pct + player.bonus_move_speed_shade_pct)
+ if player.mist_active:
+ shade_speed = light_speed
+ else:
+ shade_speed = PLAYER_SHADE_SPEED * (1.0 + player.bonus_move_speed_pct + player.bonus_move_speed_shade_pct)
heal_dur = player.effective_heal_duration()
cycle = player.effective_attack_duration() + player.effective_attack_cooldown()
atk_rate = (1.0 / cycle) if cycle > 0 else 0.0
@@ -7665,14 +7953,6 @@ while running:
running = False
break
- if (not paused) and (not inv_ui.visible) and (not trade_ui.visible):
- if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
- player.set_attack_dir(camera_x, camera_y)
- player.attack()
- elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 3:
- player.set_attack_dir(camera_x, camera_y)
- player.try_ranged_attack(projectiles, enemies, platforms)
-
if e.type == pygame.KEYDOWN:
was_trade_visible = trade_ui.visible
was_inv_visible = inv_ui.visible
@@ -7734,6 +8014,9 @@ while running:
near_lantern = get_near_lantern(player, save_points)
if near_lantern:
save_at_lantern(near_lantern)
+ elif get_near_chest(player, reward_chest):
+ if reward_chest.open(player):
+ player.opened_chests.add(reward_chest.id)
elif get_near_diary(player, diary_obj):
diary_ui.toggle()
else:
@@ -7764,23 +8047,6 @@ while running:
if key_is("toggle_shade", e.key):
player.toggle_shade()
- if key_is("attack", e.key):
- player.set_attack_dir(camera_x, camera_y)
- player.attack()
-
- if key_is("ranged", e.key):
- player.set_attack_dir(camera_x, camera_y)
- player.try_ranged_attack(projectiles, enemies, platforms)
-
- if key_is("dash", e.key):
- player.try_dash()
-
- if key_is("shatterstep", e.key):
- player.try_shatterstep()
-
- if key_is("tether", e.key):
- player.try_tether(tether_shots)
-
if key_is("reality_break", e.key) and player.has_reality_break:
if player.shade:
ROOM_DROP_MULT = 0.5
@@ -7802,6 +8068,29 @@ while running:
keys = pygame.key.get_pressed()
+ mouse_buttons = pygame.mouse.get_pressed()
+
+ # Hold any combat key (or mouse button) to keep re-casting the
+ # ability/attack as soon as its cooldown and resource checks allow
+ # (never faster than the cooldown).
+ if (not paused) and (not inv_ui.visible) and (not trade_ui.visible):
+ if key_pressed(keys, "attack") or mouse_buttons[0]:
+ player.set_attack_dir(camera_x, camera_y)
+ player.attack()
+
+ if key_pressed(keys, "ranged") or mouse_buttons[2]:
+ player.set_attack_dir(camera_x, camera_y)
+ player.try_ranged_attack(projectiles, enemies, platforms)
+
+ if key_pressed(keys, "dash"):
+ player.try_dash()
+
+ if key_pressed(keys, "shatterstep"):
+ player.try_shatterstep()
+
+ if key_pressed(keys, "tether"):
+ player.try_tether(tether_shots)
+
if paused:
# draw last frame + pause overlay
screen.fill(BG)
@@ -7934,6 +8223,8 @@ while running:
support_moving = None
if player.on_ground:
for rect in platforms_moving:
+ if not moving_platform_active(rect):
+ continue
if (player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and
player.rect.right > rect.left and player.rect.left < rect.right):
support_moving = rect
@@ -7958,7 +8249,13 @@ while running:
# Trigger walls: solid only while their unlock quest is not yet active.
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
+ # Dissolve platforms: solid while not dissolved; excluded from collision while dissolving.
+ solid_dissolve = update_dissolve_platforms(dt)
+
+ # Boss-gated moving platforms have no hitbox while their boss is alive.
+ inactive_moving_ids = {id(r) for r in platforms_moving if not moving_platform_active(r)}
+ player_solid = [r for r in platforms if id(r) not in inactive_moving_ids]
+ collision_platforms = player_solid + [d.rect for d in ability_doors if not d.open] + blocking_triggers + solid_dissolve
# Enemies collide with ground/floating platforms, doors and trigger walls —
# but NOT moving platforms. Moving platforms are player traversal; a platform
@@ -7969,6 +8266,7 @@ while running:
enemy_collision_platforms = [r for r in platforms if id(r) not in moving_ids]
enemy_collision_platforms += [d.rect for d in ability_doors if not d.open]
enemy_collision_platforms += enemy_blocking_triggers
+ enemy_collision_platforms += solid_dissolve
# 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
@@ -7997,6 +8295,8 @@ while running:
# Glue the player to any moving platform they're standing on, so upward movement
# doesn't leave a gap that makes on_ground flicker (bobbing / eventual fall-off).
for rect in platforms_moving:
+ if not moving_platform_active(rect):
+ continue
if (player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and
player.rect.right > rect.left and player.rect.left < rect.right):
player.rect.bottom = rect.top
@@ -8287,14 +8587,12 @@ while running:
# Pickup loot (only when inventory closed)
if not inv_ui.visible and not trade_ui.visible:
- if reward_chest and player.rect.colliderect(reward_chest.rect):
- if reward_chest.open(player):
- player.opened_chests.add(reward_chest.id)
+ # Chests open on interact (F); the interact handler calls reward_chest.open().
if reward_ability and player.rect.colliderect(reward_ability.rect):
if reward_ability.ability_id == "ranged":
player.has_ranged = True
- pickup_feed.push_center("Ranged Ability aquired (Press Q)", duration=5.0)
+ pickup_feed.push_center("Ranged Ability acquired (Right Mouse Button)", duration=5.0)
else:
pickup_feed.push_center(f"Ability acquired: {reward_ability.ability_id}", duration=5.0)
reward_ability = None
@@ -8460,6 +8758,16 @@ while running:
int(near_lantern.rect.top - camera_y),
)
+ # Chest open hint bubble
+ if not inv_ui.visible and not trade_ui.visible and not diary_ui.visible:
+ if get_near_chest(player, reward_chest):
+ draw_speech_bubble(
+ screen,
+ "Press F - Open chest",
+ int(reward_chest.rect.centerx - camera_x),
+ int(reward_chest.rect.top - camera_y),
+ )
+
# Boss health bar (if boss exists)
boss = next((en for en in enemies if getattr(en, "drop_profile", "") == "boss"), None)
if boss is not None:
diff --git a/light_and_dark/saves/new rooms 2.json b/light_and_dark/saves/new rooms 2.json
index 6d32eb9..5c77be7 100644
--- a/light_and_dark/saves/new rooms 2.json
+++ b/light_and_dark/saves/new rooms 2.json
@@ -1,22 +1,55 @@
{
"version": 1,
- "saved_at": "2026-10-01T10:39:22",
+ "saved_at": "2026-10-01T14:40:38",
"player": {
- "room_id": "room_04.json",
- "x": 509,
- "y": 240,
+ "room_id": "room_07.json",
+ "x": 620,
+ "y": 520,
"health": 10,
- "charge": 100.0,
+ "charge": 120.0,
"shade": false,
- "coins_light": 46,
- "coins_dark": 6,
- "butterfly_jars": 2,
+ "coins_light": 40,
+ "coins_dark": 20,
+ "butterfly_jars": 0,
"has_ranged": false,
"opened_chests": [
- "room_03.json@90,194"
+ "room_03.json@90,194",
+ "room_07.json@440,494"
],
"equipment": {
- "helmet": null,
+ "helmet": {
+ "id": "duskwarrior_helm",
+ "name": "Duskwarrior Helmet",
+ "set": "duskwarrior",
+ "slot": "helmet",
+ "base_stats": {},
+ "rarity_overrides": {
+ "tempered": {
+ "extra_stats": {
+ "charge": 10
+ }
+ },
+ "attuned": {
+ "extra_stats": {
+ "charge": 10,
+ "health": 5
+ }
+ },
+ "awakened": {
+ "extra_stats": {
+ "charge": 15,
+ "health": 5,
+ "charge_regen_pct": 0.1
+ }
+ }
+ },
+ "rarity": "tempered",
+ "tier": 1,
+ "stats": {
+ "charge": 10.0
+ },
+ "abilities": []
+ },
"chest": {
"id": "vitalweave_chest",
"name": "Vitalweave Chestplate",
@@ -48,15 +81,162 @@
},
"abilities": []
},
- "pants": null,
+ "pants": {
+ "id": "gravemark_greaves",
+ "name": "Gravemark Greaves",
+ "set": "gravemark",
+ "slot": "pants",
+ "base_stats": {},
+ "rarity_overrides": {
+ "tempered": {
+ "extra_stats": {
+ "charge": 10
+ }
+ },
+ "attuned": {
+ "extra_stats": {
+ "charge": 10,
+ "health": 5
+ }
+ },
+ "awakened": {
+ "extra_stats": {
+ "charge": 20,
+ "health": 5
+ }
+ }
+ },
+ "rarity": "tempered",
+ "tier": 1,
+ "stats": {
+ "charge": 10.0
+ },
+ "abilities": []
+ },
"necklace": null
}
},
- "inventory": [],
+ "inventory": [
+ {
+ "id": "dawnrunner_helm",
+ "name": "Dawnrunner Helmet",
+ "set": "dawnrunner",
+ "slot": "helmet",
+ "base_stats": {},
+ "rarity_overrides": {
+ "tempered": {
+ "extra_stats": {
+ "move_speed_light_pct": 0.1
+ }
+ },
+ "attuned": {
+ "extra_stats": {
+ "move_speed_light_pct": 0.1,
+ "heal_speed_pct": 0.1
+ }
+ },
+ "awakened": {
+ "extra_stats": {
+ "move_speed_light_pct": 0.15,
+ "heal_speed_pct": 0.2
+ }
+ }
+ },
+ "rarity": "attuned",
+ "tier": 2,
+ "stats": {
+ "move_speed_light_pct": 0.1,
+ "heal_speed_pct": 0.1
+ },
+ "abilities": []
+ },
+ {
+ "id": "paladin_helm",
+ "name": "Paladin Helmet",
+ "set": "paladin",
+ "slot": "helmet",
+ "base_stats": {},
+ "rarity_overrides": {
+ "tempered": {
+ "extra_stats": {
+ "health": 5
+ }
+ },
+ "attuned": {
+ "extra_stats": {
+ "health": 10,
+ "negate_damage_chance": 0.05
+ }
+ },
+ "awakened": {
+ "extra_stats": {
+ "health": 15,
+ "negate_damage_chance": 0.1,
+ "knockback_resistance": 0.25
+ }
+ }
+ },
+ "rarity": "tempered",
+ "tier": 1,
+ "stats": {
+ "health": 5.0
+ },
+ "abilities": []
+ },
+ {
+ "id": "dawnrunner_chest",
+ "name": "Dawnrunner Chestplate",
+ "set": "dawnrunner",
+ "slot": "chest",
+ "base_stats": {},
+ "rarity_overrides": {
+ "tempered": {
+ "extra_stats": {
+ "charge": 10
+ }
+ },
+ "attuned": {
+ "extra_stats": {
+ "charge": 15,
+ "move_speed_light_pct": 0.05
+ }
+ },
+ "awakened": {
+ "extra_stats": {
+ "charge": 15,
+ "move_speed_light_pct": 0.05,
+ "heal_plus": 1
+ }
+ }
+ },
+ "rarity": "attuned",
+ "tier": 2,
+ "stats": {
+ "charge": 15.0,
+ "move_speed_light_pct": 0.05
+ },
+ "abilities": []
+ },
+ {
+ "id": "necklace_flow",
+ "name": "Charm of Flow",
+ "slot": "necklace",
+ "item_type": "necklace",
+ "base_stats": {
+ "charge": 10
+ },
+ "rarity": "",
+ "tier": 0,
+ "abilities": [],
+ "stats": {
+ "charge": 10
+ }
+ }
+ ],
"quests": {
"active": {
"first_kills_shade": {
- "progress": 0
+ "progress": 1
}
},
"completed_one_time": [],
@@ -68,11 +248,13 @@
"boss_diary": {
"slotted": {}
},
- "defeated_bosses": [],
+ "defeated_bosses": [
+ "room_05.json"
+ ],
"broken_doors": [],
"lantern_respawn": {
- "room_id": "room_04.json",
- "x": 560,
- "y": 240
+ "room_id": "room_07.json",
+ "x": 640,
+ "y": 520
}
}
\ No newline at end of file