From c7558a3947b858180895f0448b3e06164688b642 Mon Sep 17 00:00:00 2001 From: Halica Date: Fri, 2 Oct 2026 08:12:53 +0200 Subject: [PATCH] attack changes dissolve platforms, mouse for attack, directional attack (i think), hold for attack --- light_and_dark/data/config/ranged.json | 2 +- light_and_dark/data/world/rooms/room_04.json | 6 +- light_and_dark/data/world/rooms/room_05.json | 113 +++-- light_and_dark/data/world/rooms/room_07.json | 90 ++++ light_and_dark/level_editor_v43.html | 51 ++- light_and_dark/main.py | 418 ++++++++++++++++--- light_and_dark/saves/new rooms 2.json | 216 +++++++++- 7 files changed, 760 insertions(+), 136 deletions(-) create mode 100644 light_and_dark/data/world/rooms/room_07.json 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`);
speed (px/s)
+
+
+
` : ""} + ${(p.group||"ground") === "dissolve" ? ` +
+
stand time (s)
+
dissolve time (s)
+
+
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