From faa8b166582fe2cc5f28f5e384b6d473ba977357 Mon Sep 17 00:00:00 2001 From: Halica Date: Wed, 30 Sep 2026 12:00:56 +0200 Subject: [PATCH] more rooms more rooms, level editor changes, more logic for ability checks and trigger doors, platform body collision fixes --- light_and_dark/data/config/physics.json | 3 +- light_and_dark/data/world/rooms/room_03.json | 90 +++-- light_and_dark/data/world/rooms/room_04.json | 308 ++++++++++++++++++ light_and_dark/data/world/rooms/room_05.json | 154 +++++++++ light_and_dark/data/world/rooms/room_06.json | 92 ++++++ light_and_dark/level_editor_v43.html | 75 ++++- light_and_dark/main.py | 266 +++++++++++++-- .../{new rooms.json => new rooms 2.json} | 27 +- light_and_dark/saves/save 1.json | 225 ------------- 9 files changed, 935 insertions(+), 305 deletions(-) create mode 100644 light_and_dark/data/world/rooms/room_04.json create mode 100644 light_and_dark/data/world/rooms/room_05.json create mode 100644 light_and_dark/data/world/rooms/room_06.json rename light_and_dark/saves/{new rooms.json => new rooms 2.json} (57%) delete mode 100644 light_and_dark/saves/save 1.json diff --git a/light_and_dark/data/config/physics.json b/light_and_dark/data/config/physics.json index d782a6b..38519e1 100644 --- a/light_and_dark/data/config/physics.json +++ b/light_and_dark/data/config/physics.json @@ -5,7 +5,8 @@ "jump_light": -12.0, "jump_shade": -9.0, "gravity_light": 0.5, - "gravity_shade": 0.4 + "gravity_shade": 0.4, + "shade_door_grace": 0.6 }, "resources": { diff --git a/light_and_dark/data/world/rooms/room_03.json b/light_and_dark/data/world/rooms/room_03.json index 39d26a2..46d6dc2 100644 --- a/light_and_dark/data/world/rooms/room_03.json +++ b/light_and_dark/data/world/rooms/room_03.json @@ -93,49 +93,56 @@ "w": 20, "h": 160, "group": "trigger", - "layer": 0 + "layer": 0, + "quest": "quest_first_kills" }, { "x": 1100, "y": 620, "w": 320, "h": 20, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 800, "y": 320, "w": 160, "h": 20, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 1580, "y": 320, "w": 20, "h": 580, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 1220, "y": 760, "w": 360, "h": 20, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 20, "y": 880, "w": 1560, "h": 20, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 20, "y": 640, "w": 20, "h": 240, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 160, @@ -143,28 +150,33 @@ "w": 20, "h": 240, "group": "trigger", - "name": "first_quest_complete" + "layer": 0, + "name": "first_quest_complete", + "quest_turned_in": "quest_first_kills" }, { "x": 580, "y": 640, "w": 20, "h": 120, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 480, "y": 740, "w": 100, "h": 20, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 400, "y": 400, "w": 100, "h": 20, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 220, @@ -172,6 +184,7 @@ "w": 80, "h": 20, "group": "moving", + "layer": 0, "path": [ [ 220, @@ -188,41 +201,48 @@ "y": 220, "w": 140, "h": 20, - "group": "ground" + "group": "ground", + "layer": 0 }, { "x": 1520, "y": 640, "w": 60, "h": 20, - "group": "ground" + "group": "ground", + "layer": 0 } ], "enemies": [ { "x": 600, "y": 840, - "type": "enemy" + "type": "enemy", + "layer": 0 }, { "x": 960, "y": 840, - "type": "enemy" + "type": "enemy", + "layer": 0 }, { "x": 200, "y": 580, - "type": "enemy" + "type": "enemy", + "layer": 0 }, { "x": 520, "y": 700, - "type": "enemy" + "type": "enemy", + "layer": 0 }, { "x": 1520, "y": 840, - "type": "enemy" + "type": "enemy", + "layer": 0 } ], "npcs": [], @@ -235,6 +255,28 @@ "object_id": "quest_point", "layer": 0, "quest_id": "quest_first_kills" + }, + { + "x": 90, + "y": 194, + "type": "reward_chest", + "radius": 180, + "object_id": null, + "layer": 0, + "quest_id": "", + "coins": [ + 10, + 2 + ] + }, + { + "x": 880, + "y": 320, + "type": "light_source", + "radius": 180, + "object_id": "flow_butterfly", + "layer": 0, + "quest_id": "" } ], "exits": [ @@ -257,12 +299,14 @@ "y": 780, "w": 80, "h": 100, - "to": "", - "spawn_id": "", + "to": "room_04", + "spawn_id": "room4_top_right", "spawn": [ - 120, - 780 - ] + 1480, + 100 + ], + "hint": "", + "layer": 0 } ], "ability_doors": [], diff --git a/light_and_dark/data/world/rooms/room_04.json b/light_and_dark/data/world/rooms/room_04.json new file mode 100644 index 0000000..df2dc0d --- /dev/null +++ b/light_and_dark/data/world/rooms/room_04.json @@ -0,0 +1,308 @@ +{ + "name": "room_04", + "display_name": "room_04", + "meta": { + "id": "room_04", + "name": "room_04" + }, + "bounds": {}, + "player_spawn": [ + 1440, + 100 + ], + "entry_spawns": { + "room4_top_right": [ + 1480, + 100 + ], + "hazard_respawn": [ + 1080, + 180 + ], + "room4_bottom_left": [ + 120, + 620 + ], + "room4_bottom_right": [ + 1480, + 640 + ] + }, + "platforms": [ + { + "x": 1340, + "y": 160, + "w": 260, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 160, + "y": 0, + "w": 1440, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 1580, + "y": 20, + "w": 20, + "h": 140, + "group": "ground", + "layer": 0 + }, + { + "x": 1340, + "y": 180, + "w": 20, + "h": 60, + "group": "ground", + "layer": 0 + }, + { + "x": 1020, + "y": 240, + "w": 340, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 920, + "y": 240, + "w": 100, + "h": 20, + "group": "hazard", + "layer": 0, + "respawn": [ + 1080, + 180 + ] + }, + { + "x": 460, + "y": 240, + "w": 460, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 900, + "y": 260, + "w": 140, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 160, + "y": 20, + "w": 20, + "h": 520, + "group": "ground", + "layer": 0 + }, + { + "x": 20, + "y": 700, + "w": 1580, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 0, + "y": 520, + "w": 160, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 0, + "y": 540, + "w": 20, + "h": 180, + "group": "ground", + "layer": 0 + }, + { + "x": 240, + "y": 280, + "w": 180, + "h": 20, + "group": "moving", + "layer": 0, + "path": [ + [ + 240, + 280 + ], + [ + 240, + 640 + ] + ] + }, + { + "x": 460, + "y": 260, + "w": 20, + "h": 280, + "group": "ground", + "layer": 0 + }, + { + "x": 480, + "y": 300, + "w": 880, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 1340, + "y": 320, + "w": 20, + "h": 220, + "group": "ground", + "layer": 0 + }, + { + "x": 460, + "y": 540, + "w": 20, + "h": 160, + "group": "trigger", + "layer": 0, + "name": "close_when_pass", + "lock_when_pass": true, + "open_on_miniboss_dead": true + }, + { + "x": 1340, + "y": 540, + "w": 20, + "h": 160, + "group": "trigger", + "layer": 0, + "name": "open_miniboss_dead", + "open_on_miniboss_dead": true + }, + { + "x": 1360, + "y": 520, + "w": 240, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 1580, + "y": 540, + "w": 20, + "h": 160, + "group": "ground", + "layer": 0 + }, + { + "x": 720, + "y": 620, + "w": 60, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 820, + "y": 540, + "w": 60, + "h": 20, + "group": "ground", + "layer": 0 + } + ], + "enemies": [ + { + "x": 760, + "y": 200, + "type": "enemy", + "layer": 0 + }, + { + "x": 880, + "y": 640, + "type": "miniboss", + "layer": 0 + } + ], + "npcs": [], + "props": [ + { + "x": 560, + "y": 240, + "type": "save_point", + "radius": 90, + "object_id": "lamp" + } + ], + "exits": [ + { + "x": 1500, + "y": 60, + "w": 80, + "h": 100, + "to": "room_03", + "spawn_id": "room3_bottom_left", + "spawn": [ + 140, + 800 + ], + "hint": "", + "layer": 0 + }, + { + "x": 20, + "y": 600, + "w": 80, + "h": 100, + "to": "room_06", + "spawn_id": "room6_top_right", + "spawn": [ + 520, + 240 + ], + "hint": "", + "layer": 0 + }, + { + "x": 1500, + "y": 600, + "w": 80, + "h": 100, + "to": "room_05", + "spawn_id": "room5_top_left", + "spawn": [ + 120, + 120 + ], + "hint": "", + "layer": 0 + } + ], + "ability_doors": [ + { + "x": 160, + "y": 540, + "w": 20, + "h": 160, + "layer": 0, + "kind": "shade" + } + ], + "room_w": 1600, + "room_h": 900 +} \ No newline at end of file diff --git a/light_and_dark/data/world/rooms/room_05.json b/light_and_dark/data/world/rooms/room_05.json new file mode 100644 index 0000000..b2c6dc5 --- /dev/null +++ b/light_and_dark/data/world/rooms/room_05.json @@ -0,0 +1,154 @@ +{ + "name": "room_05", + "display_name": "room_05", + "meta": { + "id": "room_05", + "name": "room_05" + }, + "bounds": {}, + "player_spawn": [ + 140, + 100 + ], + "entry_spawns": { + "room5_top_left": [ + 120, + 120 + ], + "room5_bottom_right": [ + 1400, + 440 + ] + }, + "platforms": [ + { + "x": 0, + "y": 180, + "w": 240, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 0, + "y": 0, + "w": 20, + "h": 180, + "group": "ground", + "layer": 0 + }, + { + "x": 20, + "y": 0, + "w": 1520, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 340, + "y": 180, + "w": 240, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 700, + "y": 180, + "w": 200, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 860, + "y": 500, + "w": 680, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 1520, + "y": 380, + "w": 20, + "h": 120, + "group": "ground", + "layer": 0 + }, + { + "x": 1320, + "y": 360, + "w": 220, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 1520, + "y": 20, + "w": 20, + "h": 340, + "group": "ground", + "layer": 0 + } + ], + "enemies": [ + { + "x": 520, + "y": 140, + "type": "enemy", + "layer": 0 + }, + { + "x": 1000, + "y": 460, + "type": "enemy", + "layer": 0 + } + ], + "npcs": [], + "props": [ + { + "x": 1400, + "y": 360, + "type": "save_point", + "radius": 90, + "object_id": "lamp" + } + ], + "exits": [ + { + "x": 20, + "y": 80, + "w": 80, + "h": 100, + "to": "room_06", + "spawn_id": "room4_bottom_right", + "spawn": [ + 1480, + 640 + ], + "hint": "", + "layer": 0 + }, + { + "x": 1440, + "y": 400, + "w": 80, + "h": 100, + "to": "", + "spawn_id": "", + "spawn": [ + 1440, + 400 + ], + "hint": "", + "layer": 0 + } + ], + "ability_doors": [], + "room_w": 1600, + "room_h": 900 +} \ No newline at end of file diff --git a/light_and_dark/data/world/rooms/room_06.json b/light_and_dark/data/world/rooms/room_06.json new file mode 100644 index 0000000..25c3b9b --- /dev/null +++ b/light_and_dark/data/world/rooms/room_06.json @@ -0,0 +1,92 @@ +{ + "name": "room_06", + "display_name": "room_06", + "meta": { + "id": "room_06", + "name": "room_06" + }, + "bounds": {}, + "player_spawn": [ + 480, + 240 + ], + "entry_spawns": { + "room6_top_right": [ + 520, + 240 + ] + }, + "platforms": [ + { + "x": 0, + "y": 300, + "w": 660, + "h": 20, + "group": "ground" + }, + { + "x": 420, + "y": 180, + "w": 240, + "h": 20, + "group": "ground" + }, + { + "x": 640, + "y": 200, + "w": 20, + "h": 100, + "group": "ground" + }, + { + "x": 420, + "y": 0, + "w": 20, + "h": 180, + "group": "ground" + }, + { + "x": 0, + "y": 0, + "w": 420, + "h": 20, + "group": "ground" + }, + { + "x": 0, + "y": 20, + "w": 20, + "h": 280, + "group": "ground" + } + ], + "enemies": [], + "npcs": [], + "props": [ + { + "x": 140, + "y": 260, + "type": "quest_giver", + "radius": 180, + "object_id": "quest_point", + "quest_id": "first_kills_shade" + } + ], + "exits": [ + { + "x": 560, + "y": 200, + "w": 80, + "h": 100, + "to": "room_04", + "spawn_id": "room4_bottom_left", + "spawn": [ + 120, + 620 + ] + } + ], + "ability_doors": [], + "room_w": 1600, + "room_h": 900 +} \ 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 f999175..56fc0ee 100644 --- a/light_and_dark/level_editor_v43.html +++ b/light_and_dark/level_editor_v43.html @@ -350,6 +350,9 @@ if(rect.name) out.name = String(rect.name); if(rect.quest) out.quest = String(rect.quest); if(rect.quest_complete) out.quest_complete = String(rect.quest_complete); + 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(Array.isArray(rect.respawn)) out.respawn = deepClone(rect.respawn); return out; } @@ -505,13 +508,14 @@ if(Array.isArray(src.ability_doors)){ for(const d of src.ability_doors){ if(!d) continue; + const kind = (typeof d === "object" && d.kind === "shade") ? "shade" : "ranged"; if(Array.isArray(d) && d.length >= 4){ - r.ability_doors.push({ x:+d[0]||0, y:+d[1]||0, w:+d[2]||0, h:+d[3]||0, layer: d.length>=5 ? (+d[4]||0) : 0 }); + r.ability_doors.push({ x:+d[0]||0, y:+d[1]||0, w:+d[2]||0, h:+d[3]||0, layer: d.length>=5 ? (+d[4]||0) : 0, kind }); } else if(typeof d === "object"){ if(Array.isArray(d.rect)){ - r.ability_doors.push({ x:+d.rect[0]||0, y:+d.rect[1]||0, w:+d.rect[2]||0, h:+d.rect[3]||0, layer: +(d.layer||0) }); + r.ability_doors.push({ x:+d.rect[0]||0, y:+d.rect[1]||0, w:+d.rect[2]||0, h:+d.rect[3]||0, layer: +(d.layer||0), kind }); } else { - r.ability_doors.push({ x:+d.x||0, y:+d.y||0, w:+d.w||0, h:+d.h||0, layer: +(d.layer||0) }); + r.ability_doors.push({ x:+d.x||0, y:+d.y||0, w:+d.w||0, h:+d.h||0, layer: +(d.layer||0), kind }); } } } @@ -1645,11 +1649,19 @@ Cancel = Choose whether to remove without saving`); const d = it.d; const [sx,sy] = worldToScreen(d.x, d.y, canvas); const [sx2,sy2] = worldToScreen(d.x+d.w, d.y+d.h, canvas); - ctx.fillStyle = "rgba(16,185,129,0.18)"; - ctx.fillRect(sx,sy, sx2-sx, sy2-sy); - ctx.strokeStyle = "rgba(16,185,129,0.75)"; - ctx.lineWidth = 2*dpr; - ctx.strokeRect(sx,sy, sx2-sx, sy2-sy); + if(d.kind === "shade"){ + ctx.fillStyle = "rgba(129,110,190,0.20)"; + ctx.fillRect(sx,sy, sx2-sx, sy2-sy); + ctx.strokeStyle = "rgba(129,110,190,0.80)"; + ctx.lineWidth = 2*dpr; + ctx.strokeRect(sx,sy, sx2-sx, sy2-sy); + } else { + ctx.fillStyle = "rgba(16,185,129,0.18)"; + ctx.fillRect(sx,sy, sx2-sx, sy2-sy); + ctx.strokeStyle = "rgba(16,185,129,0.75)"; + ctx.lineWidth = 2*dpr; + ctx.strokeRect(sx,sy, sx2-sx, sy2-sy); + } } else if(it.kind === "exit"){ const ex = it.ex; const [sx,sy] = worldToScreen(ex.x, ex.y, canvas); @@ -1948,7 +1960,10 @@ Cancel = Choose whether to remove without saving`); } else if(sid){ pos = (dstWrap.data.entry_spawns && dstWrap.data.entry_spawns[sid]) ? dstWrap.data.entry_spawns[sid] : null; } - if(!pos && Array.isArray(ex.spawn) && ex.spawn.length >= 2){ + // Only fall back to the raw spawn coordinate when the exit has NO spawn_id. + // A stale spawn_id (e.g. from a target room that doesn't define it) would + // point the line at an arbitrary spot, so skip those broken links instead. + if(!pos && !sid && Array.isArray(ex.spawn) && ex.spawn.length >= 2){ pos = ex.spawn; } if(!pos) continue; @@ -2143,6 +2158,16 @@ Cancel = Choose whether to remove without saving`);
unlock when quest complete (id)
+
+
+
unlock when quest turned in (id)
+ +
+
+ +
+
+
` : ""} ${(p.group||"ground") === "hazard" ? `
@@ -2204,6 +2229,13 @@ Cancel = Choose whether to remove without saving`);
h
layer (lower = back)
+
+
type
+ +
@@ -2376,6 +2408,16 @@ Cancel = Choose whether to remove without saving`); const v = ptqcomplete.value.trim(); if(v){ p.quest_complete = v; } else { delete p.quest_complete; } })); + const ptqturnedin=q("ptqturnedin"); if(ptqturnedin) ptqturnedin.addEventListener("change", ()=>pushAnd(()=>{ + const v = ptqturnedin.value.trim(); + if(v){ p.quest_turned_in = v; } else { delete p.quest_turned_in; } + })); + const ptlockpass=q("ptlockpass"); if(ptlockpass) ptlockpass.addEventListener("change", ()=>pushAnd(()=>{ + if(ptlockpass.checked){ p.lock_when_pass = true; } else { delete p.lock_when_pass; } + })); + 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 ptspeed=q("ptspeed"); if(ptspeed) ptspeed.addEventListener("change", ()=>pushAnd(()=>{ p.speed = Math.max(1, num(ptspeed.value)); })); const hzrespawn=q("hzrespawn"); if(hzrespawn){ @@ -2462,6 +2504,7 @@ Cancel = Choose whether to remove without saving`); })); }); const adlayer=q("adlayer"); if(adlayer) adlayer.addEventListener("change", ()=>pushAnd(()=>{ d.layer = Math.round(num(adlayer.value)); })); + const adkind=q("adkind"); if(adkind) adkind.addEventListener("change", ()=>pushAnd(()=>{ d.kind = adkind.value === "shade" ? "shade" : "ranged"; })); const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{ room.ability_doors.splice(sel.index,1); state.selection=null; @@ -3279,6 +3322,9 @@ Cancel = Choose whether to remove without saving`); 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(Array.isArray(p.respawn)) obj.respawn = p.respawn; platforms[group].push(obj); } else { @@ -3315,10 +3361,11 @@ Cancel = Choose whether to remove without saving`); return out; }); - // ability doors back to {rect:[x,y,w,h]} (+ layer when set) + // ability doors back to {rect:[x,y,w,h]} (+ layer/kind when set) const ability_doors = (d.ability_doors || []).map(ad => { const out = { rect: [ad.x, ad.y, ad.w, ad.h] }; if(ad.layer) out.layer = ad.layer; + if(ad.kind === "shade") out.kind = "shade"; return out; }); @@ -3850,6 +3897,14 @@ els.canvasA.addEventListener("pointerdown", onPointerDown); e.preventDefault(); deleteSelection(); return; } + // Tool switching shortcuts (match the toolbar button titles). + const toolByKey = { v:"select", p:"platform", e:"enemy_spawn", n:"npc_spawn", o:"object_spawn" }; + const toolKey = toolByKey[e.key.toLowerCase()]; + if(toolKey){ + e.preventDefault(); + setTool(toolKey); + return; + } if(e.key.toLowerCase()==='f'){ e.preventDefault(); fitViewToRoom(getActiveRoom(),'A'); diff --git a/light_and_dark/main.py b/light_and_dark/main.py index 609e197..42371ea 100644 --- a/light_and_dark/main.py +++ b/light_and_dark/main.py @@ -438,6 +438,7 @@ JUMP_LIGHT = float(_phys(["player", "jump_light"], -12.0)) JUMP_SHADE = float(_phys(["player", "jump_shade"], -9.0)) GRAVITY_LIGHT = float(_phys(["player", "gravity_light"], 0.5)) GRAVITY_SHADE = float(_phys(["player", "gravity_shade"], 0.4)) +SHADE_DOOR_GRACE = float(_phys(["player", "shade_door_grace"], 0.6)) # Sprite offsets (tune to align art with hitbox) PLAYER_SPRITE_OFFSET_X = int(_cfg(["player", "sprite_offset_x"], 0)) @@ -941,6 +942,12 @@ def normalize_room_json(room_json: dict) -> dict: obj["quest"] = p["quest"] if "quest_complete" in p: obj["quest_complete"] = p["quest_complete"] + if "quest_turned_in" in p: + obj["quest_turned_in"] = p["quest_turned_in"] + if "lock_when_pass" in p: + obj["lock_when_pass"] = p["lock_when_pass"] + if "open_on_miniboss_dead" in p: + obj["open_on_miniboss_dead"] = p["open_on_miniboss_dead"] if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2: obj["respawn"] = p["respawn"] grouped.setdefault(group, []).append(obj) @@ -1018,9 +1025,14 @@ def normalize_room_json(room_json: dict) -> dict: norm_doors.append({"rect": [d[0], d[1], d[2], d[3]]}) elif isinstance(d, dict): if isinstance(d.get("rect"), (list, tuple)) and len(d["rect"]) == 4: - norm_doors.append(d) + nd = dict(d) + nd["kind"] = d.get("kind", "ranged") + norm_doors.append(nd) elif all(k in d for k in ("x", "y", "w", "h")): - norm_doors.append({"rect": [d.get("x", 0), d.get("y", 0), d.get("w", 0), d.get("h", 0)]}) + norm_doors.append({ + "rect": [d.get("x", 0), d.get("y", 0), d.get("w", 0), d.get("h", 0)], + "kind": d.get("kind", "ranged"), + }) out["ability_doors"] = norm_doors return out @@ -1077,6 +1089,12 @@ class Room: meta["quest"] = str(tp.get("quest")) if tp.get("quest_complete"): meta["quest_complete"] = str(tp.get("quest_complete")) + if tp.get("quest_turned_in"): + meta["quest_turned_in"] = str(tp.get("quest_turned_in")) + if tp.get("lock_when_pass"): + meta["lock_when_pass"] = True + if tp.get("open_on_miniboss_dead"): + meta["open_on_miniboss_dead"] = True self.platform_meta[id(rect)] = meta self.platforms_trigger.append(rect) elif isinstance(tp, (list, tuple)) and len(tp) >= 4: @@ -1155,7 +1173,7 @@ class Room: rect = list(d) if rect is None: continue - nd = {"rect": rect} + nd = {"rect": rect, "kind": d.get("kind", "ranged") if isinstance(d, dict) else "ranged"} if isinstance(d, dict): try: nd["layer"] = int(d.get("layer", 0)) @@ -1421,6 +1439,8 @@ class Projectile: for d in doors: if getattr(d, "open", False): continue + if getattr(d, "kind", "ranged") == "shade": + continue # shade doors cannot be shot open if self.rect.colliderect(d.rect): d.open = True mark_door_broken(getattr(d, "room_id", None), d.rect) @@ -1906,17 +1926,22 @@ class CoinPickup: class AbilityDoor: - def __init__(self, rect: pygame.Rect, room_id: str | None = None): + def __init__(self, rect: pygame.Rect, room_id: str | None = None, kind: str = "ranged"): self.rect = rect self.open = False self.room_id = room_id + self.kind = "shade" if str(kind) == "shade" else "ranged" def draw(self, surface, cx, cy): if self.open: return r = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) - pygame.draw.rect(surface, (120, 180, 120), r) - pygame.draw.rect(surface, (40, 80, 40), r, 2) + if self.kind == "shade": + pygame.draw.rect(surface, (110, 100, 170), r) + pygame.draw.rect(surface, (60, 50, 110), r, 2) + else: + pygame.draw.rect(surface, (120, 180, 120), r) + pygame.draw.rect(surface, (40, 80, 40), r, 2) def spawn_ability_doors(room) -> list[AbilityDoor]: @@ -1925,7 +1950,7 @@ def spawn_ability_doors(room) -> list[AbilityDoor]: if not isinstance(d, dict): continue rect = pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0]))) - door = AbilityDoor(rect, room_id=room.id) + door = AbilityDoor(rect, room_id=room.id, kind=d.get("kind", "ranged")) door.open = is_door_broken(room.id, rect) door.layer = int(d.get("layer", 0)) out.append(door) @@ -1942,8 +1967,12 @@ class RewardChest: if self.opened: return False light_amt, dark_amt = self.coins - player.coins_light += int(light_amt) - player.coins_dark += int(dark_amt) + cx = self.rect.centerx + cy = self.rect.top - 18 # pop out of the chest top, clear of its platform + for _ in range(max(0, int(light_amt))): + coin_drops.append(CoinPickup(cx, cy, "light")) + for _ in range(max(0, int(dark_amt))): + coin_drops.append(CoinPickup(cx, cy, "dark")) self.opened = True return True @@ -3260,6 +3289,7 @@ class Player: self.on_ground = False self.ground_platform = None self.shade = False + self.shade_door_grace = 0.0 # grace period to pass shade doors after a form switch self.charge = float(BASE_MAX_CHARGE) self.health = int(BASE_MAX_HEALTH) @@ -3601,6 +3631,7 @@ class Player: if not self.shade and self.charge <= 0: return self.shade = not self.shade + self.shade_door_grace = SHADE_DOOR_GRACE def attack(self): if self.healing: @@ -3813,6 +3844,9 @@ class Player: self.just_air_jumped = False self.fairy_bob_t += dt + if self.shade_door_grace > 0.0: + self.shade_door_grace = max(0.0, self.shade_door_grace - dt) + if self.invincibility_timer > 0: self.invincibility_timer -= dt @@ -3967,6 +4001,7 @@ class Player: if self.charge <= 0: self.charge = 0 self.shade = False + self.shade_door_grace = SHADE_DOOR_GRACE else: # no regen while healing, attacking, or during post-attack lockout if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0): @@ -4164,6 +4199,9 @@ class Enemy: self.drop_ignore_timer = 0.0 self.drop_ignore_platform = None + # True when killed by touching a hazard zone (drops nothing). + self.hazard_kill = False + def take_damage(self, dmg): self.health = max(0.0, float(self.health) - float(dmg)) @@ -4176,6 +4214,7 @@ class Enemy: self.rect.topleft = (self.start_x, self.start_y) self.vel_x = 0.0 self.vel_y = 0.0 + self.hazard_kill = False cfg = ENEMY_TYPES.get(self.type_id, ENEMY_TYPES.get("enemy", {})) self.max_health = float(cfg.get("health", 3)) if isinstance(cfg, dict) else 3.0 self.health = float(self.max_health) @@ -4719,6 +4758,8 @@ def spawn_enemies_from_room(room: Room): t = str(ent.get("type", "enemy")) if t == "boss" and is_boss_defeated(room.id): continue + if t == "miniboss" and room.id in DEFEATED_MINIBOSSES: + continue pos = ent.get("pos", None) if not (isinstance(pos, (list, tuple)) and len(pos) == 2): if "x" in ent and "y" in ent: @@ -4939,18 +4980,79 @@ class HouseObject: def trigger_wall_blocking(rect) -> bool: """A trigger wall blocks while its unlock condition is NOT met. - - ``quest``: wall stays until the quest is accepted (becomes active). + - ``quest``: wall stays until the quest is accepted (becomes active), + and stays open afterwards (once ever completed). - ``quest_complete``: wall stays until the quest objective is done (is_complete, or the quest has been completed at least once). + - ``quest_turned_in``: wall stays until the quest has been completed + and turned in (only ever_completed counts). + - ``lock_when_pass``: wall is gone initially and only appears once the + player has walked through it (arena lock). + - ``open_on_miniboss_dead``: releases the wall once the room's miniboss + is slain (combines with the conditions above; with no other condition + the wall is simply present until then). Walls with no condition are inert placeholders (never block).""" meta = platform_meta.get(id(rect), {}) qid = meta.get("quest") - if qid: - return qid not in quest_manager.active - qc = meta.get("quest_complete") - if qc: - return not (quest_manager.is_complete(qc) or qc in quest_manager.ever_completed) - return False + if meta.get("lock_when_pass"): + present = id(rect) in LOCKED_TRIGGER_WALLS + elif qid: + present = qid not in quest_manager.active and qid not in quest_manager.ever_completed + elif meta.get("quest_complete"): + qc = meta["quest_complete"] + present = not (quest_manager.is_complete(qc) or qc in quest_manager.ever_completed) + elif meta.get("quest_turned_in"): + present = meta["quest_turned_in"] not in quest_manager.ever_completed + elif meta.get("open_on_miniboss_dead"): + present = True # always present until released + else: + return False # inert placeholder + + if meta.get("open_on_miniboss_dead"): + present = present and miniboss_alive_in_room() + return present + + +# Lock-on-pass trigger walls (per-session, keyed by rect id). +LOCKED_TRIGGER_WALLS: set[int] = set() +TRIGGER_WALL_TOUCH: dict[int, bool] = {} + + +def miniboss_alive_in_room() -> bool: + return any(getattr(en, "type_id", "") == "miniboss" and en.health > 0 for en in enemies) + + +def push_rect_out_of_rects(rect, rects): + """Push a pygame.Rect out of any overlapping rect, moving to the nearest edge. + + Used to safely eject the player from a platform they've ended up inside + (e.g. a shade door that became solid again, or an awkward spawn point) + instead of letting the physics resolve it with a jarring teleport.""" + for obs in rects: + if not rect.colliderect(obs): + continue + inter = rect.clip(obs) + if inter.width <= 0 or inter.height <= 0: + continue + if inter.width < inter.height: + # narrower overlap on X: push to the nearest horizontal side + if rect.right - obs.left <= obs.right - rect.left: + rect.right = obs.left + else: + rect.left = obs.right + else: + # narrower overlap on Y: push to the nearest vertical side + if rect.bottom - obs.top <= obs.bottom - rect.top: + rect.bottom = obs.top + else: + rect.top = obs.bottom + return rect + + +def reset_trigger_wall_locks(): + """Clear lock-on-pass trigger state (on room change).""" + LOCKED_TRIGGER_WALLS.clear() + TRIGGER_WALL_TOUCH.clear() prev_moving_pos: dict[int, tuple[int, int]] = {} @@ -5228,7 +5330,7 @@ def _draw_tether_chain_loop(surface, rect: pygame.Rect, light: bool, cx: float, def get_pause_menu_rects(): - items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"] + items = ["Resume", "Settings", "Main Menu", "Close Game"] rects = [] start_y = HEIGHT // 2 + 20 for i, label in enumerate(items): @@ -5240,7 +5342,7 @@ def get_pause_menu_rects(): def draw_pause_menu(surface, selected_idx: int, use_mouse: bool): - items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"] + items = ["Resume", "Settings", "Main Menu", "Close Game"] rects = get_pause_menu_rects() mouse_pos = pygame.mouse.get_pos() hover_idx = None @@ -5557,6 +5659,7 @@ def apply_room(room: Room, set_player_spawn=True): global token_drops global ACTIVE_REFIGHT global ROOM_DROP_MULT, ROOM_COIN_MULT + reset_trigger_wall_locks() ROOM = room @@ -5609,6 +5712,8 @@ def apply_room(room: Room, set_player_spawn=True): player.rect.midbottom = (player.start_x, player.start_y) player.vel_y = 0.0 player.on_ground = False + # Don't spawn embedded in a platform: push out to the nearest edge. + push_rect_out_of_rects(player.rect, ROOM.platforms) def boss_alive_in_room() -> bool: return any(getattr(en, "type_id", "") == "boss" and en.health > 0 for en in enemies) @@ -5618,6 +5723,7 @@ def reset_room_combat_state(): """Rebuild room enemies/NPCs/doors and clear transient combat objects.""" global enemies, npcs, loot_drops, shockwaves, coin_drops, token_drops, projectiles global shatter_platforms, shatter_aoes, dust_puffs, tether_shots, ability_doors + reset_trigger_wall_locks() enemies = spawn_enemies_from_room(ROOM) npcs = spawn_npcs_from_room(ROOM) loot_drops = [] @@ -5722,6 +5828,10 @@ camera_x = camera_y = 0.0 # Track bosses defeated per room id (runtime only) DEFEATED_BOSSES: set[str] = set() +# Minibosses stay defeated (per room) until the player saves at a lantern +# in a different room — then they respawn. Runtime only, like butterfly jars. +DEFEATED_MINIBOSSES: set[str] = set() + # Reality Break: per-room drop multipliers (reset on room change) ROOM_DROP_MULT = 1.0 ROOM_COIN_MULT = 1.0 @@ -5734,6 +5844,17 @@ def mark_boss_defeated(room_id: str): DEFEATED_BOSSES.add(room_id) +def respawn_defeated_minibosses_if_elsewhere() -> bool: + """Minibosses stay defeated until the player saves at a lantern in a + different room than the one where the miniboss was slain. Returns True + if any defeated miniboss was respawned (i.e. removed from the set).""" + if not DEFEATED_MINIBOSSES: + return False + before = len(DEFEATED_MINIBOSSES) + DEFEATED_MINIBOSSES.intersection_update({ROOM.id}) + return len(DEFEATED_MINIBOSSES) != before + + # ---------------------------- # Quest system # ---------------------------- @@ -5856,6 +5977,11 @@ class QuestManager: def get(self, quest_id: str) -> Quest | None: return self.quests.get(quest_id) + def requires_shade(self, quest_id: str) -> bool: + """Whether a quest can only be taken/collected in shade form.""" + q = self.quests.get(quest_id) + return bool(q is not None and q.objective.get("shade")) + def quests_from(self, giver_id: str) -> list[Quest]: return [q for q in self.quests.values() if q.giver == giver_id] @@ -5899,8 +6025,10 @@ class QuestManager: else: self.completed_one_time.add(quest_id) - def progress_kill(self, enemy_type: str) -> list[str]: + def progress_kill(self, enemy_type: str, shade: bool = False) -> list[str]: """Progress every active kill-quest matching this enemy type. + A quest whose objective has ``shade: true`` only counts kills made + while the player is in shade form. Returns quest ids that just became complete.""" completed = [] for qid in list(self.active.keys()): @@ -5910,6 +6038,8 @@ class QuestManager: obj = q.objective if obj.get("kind") != "kill" or str(obj.get("target", "")) != enemy_type: continue + if obj.get("shade") and not shade: + continue was = self.is_complete(qid) self.progress(qid, 1) if not was and self.is_complete(qid): @@ -5994,6 +6124,17 @@ QUEST_DEFS_DEFAULT = [ "coin_range": [5, 15], }, }, + { + "id": "first_kills_shade", + "name": "First Kills (Shade)", + "description": "Kill 4 enemies while in shade form.", + "type": "one_time", + "giver": "object:quest_point", + "objective": {"kind": "kill", "target": "enemy", "count": 4, "shade": True}, + "reward": { + "guaranteed_item": {"item_id": "necklace_placeholder"}, + }, + }, ] quest_manager = QuestManager() @@ -6203,6 +6344,8 @@ def save_at_lantern(sp: SavePoint): pickup_feed.push_center("Lantern - game saved, fully restored", duration=2.0) if respawn_jars_if_elsewhere(): pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5) + if respawn_defeated_minibosses_if_elsewhere(): + pickup_feed.push_center("Defeated minibosses have respawned!", duration=2.5) def _ellipsize_text(text: str, fnt, max_w: int) -> str: @@ -6645,8 +6788,9 @@ def apply_save_data(data: dict): DEFEATED_BOSSES.update(data.get("defeated_bosses") or []) BROKEN_DOORS.clear() BROKEN_DOORS.update(data.get("broken_doors") or []) - # Butterfly jars respawn at their points on reload; the corpse is session-only. + # Butterfly jars and minibosses respawn on reload (session-only); corpse too. COLLECTED_JARS.clear() + DEFEATED_MINIBOSSES.clear() JAR_DEATH_ROOM = None corpse = None @@ -6671,6 +6815,8 @@ def apply_save_data(data: dict): apply_room(room, set_player_spawn=True) player.respawn() + # respawn() re-places at the raw spawn; make sure we're not embedded. + push_rect_out_of_rects(player.rect, room.platforms) if "health" in pdata: player.health = int(pdata["health"]) if "charge" in pdata: @@ -6759,8 +6905,6 @@ def _do_save_game(): def _return_to_main_menu(): global GAME_MODE, paused, pause_menu, pause_menu_index, pause_use_mouse global settings_wait_action - if CURRENT_SAVE_NAME: - _do_save_game() inv_ui.visible = False trade_ui.visible = False trade_ui.active_npc = None @@ -7190,12 +7334,10 @@ while running: if idx == 0: paused = False elif idx == 1: - _do_save_game() - elif idx == 2: pause_menu = "settings" - elif idx == 3: + elif idx == 2: _return_to_main_menu() - elif idx == 4: + elif idx == 3: running = False break @@ -7210,21 +7352,19 @@ while running: if paused and pause_menu == "main": if key_is("ui_up", e.key): - pause_menu_index = (pause_menu_index - 1) % 5 + pause_menu_index = (pause_menu_index - 1) % 4 pause_use_mouse = False elif key_is("ui_down", e.key): - pause_menu_index = (pause_menu_index + 1) % 5 + pause_menu_index = (pause_menu_index + 1) % 4 pause_use_mouse = False elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): if pause_menu_index == 0: paused = False elif pause_menu_index == 1: - _do_save_game() - elif pause_menu_index == 2: pause_menu = "settings" - elif pause_menu_index == 3: + elif pause_menu_index == 2: _return_to_main_menu() - elif pause_menu_index == 4: + elif pause_menu_index == 3: running = False elif key_is("pause", e.key): paused = False @@ -7271,7 +7411,11 @@ while running: else: near_qg = get_near_quest_giver(player, quest_givers) if near_qg: - interact_quest_giver(near_qg) + if quest_manager.requires_shade(near_qg.quest_id) and not player.shade: + # Shade-gated quest: no interaction while in light form. + pass + else: + interact_quest_giver(near_qg) if key_is("pause", e.key): if paused: @@ -7479,7 +7623,22 @@ while running: blocking_triggers = [t for t in platforms_trigger if trigger_wall_blocking(t)] collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] + blocking_triggers - player_collision_platforms = collision_platforms + [p.rect for p in shatter_platforms if p.alive] + + # Shade doors are solid for the world, but the player can pass through them + # freely while in shade form — and for a short grace period after switching + # forms, so they aren't instantly ejected from a door they're standing in. + player_ignores_shade_doors = player.shade or player.shade_door_grace > 0.0 + if player_ignores_shade_doors: + shade_door_ids = {id(d.rect) for d in ability_doors if (not d.open) and d.kind == "shade"} + player_collision_platforms = [r for r in collision_platforms if id(r) not in shade_door_ids] + else: + player_collision_platforms = collision_platforms + # Grace expired: safely eject the player from any shade door they're + # still inside (nearest edge) before collision runs. + for d in ability_doors: + if (not d.open) and d.kind == "shade" and player.rect.colliderect(d.rect): + push_rect_out_of_rects(player.rect, [d.rect]) + player_collision_platforms = player_collision_platforms + [p.rect for p in shatter_platforms if p.alive] # Shade + clipping an enemy drains charge twice as fast (shade still avoids damage). if player.shade: @@ -7500,6 +7659,24 @@ while running: player.ground_platform = rect break + # Lock-on-pass trigger walls: gone initially, they appear once the player has + # walked through them (crossing from one side to the other) — arena lock. + for rect in platforms_trigger: + meta = platform_meta.get(id(rect), {}) + if not meta.get("lock_when_pass"): + continue + key = id(rect) + if key in LOCKED_TRIGGER_WALLS: + continue + entry = TRIGGER_WALL_TOUCH.get(key) + if entry is None: + if player.rect.colliderect(rect): + TRIGGER_WALL_TOUCH[key] = "L" if player.rect.centerx < rect.centerx else "R" + elif entry == "L" and player.rect.left >= rect.right: + LOCKED_TRIGGER_WALLS.add(key) + elif entry == "R" and player.rect.right <= rect.left: + LOCKED_TRIGGER_WALLS.add(key) + # Hazard zones: touching one deals 1 damage and teleports the player to its linked respawn # (defaults to the room's player spawn). A lethal hit falls through to the normal death respawn. for hz in platforms_hazard: @@ -7592,10 +7769,21 @@ while running: player.shatterstep_landing_pending = False player.shatterstep_landing_damage = 0.0 + # Never respawn embedded in a platform: push out to the nearest edge. + push_rect_out_of_rects(player.rect, ROOM.platforms) + # Update enemies for en in enemies: en.update(dt, collision_platforms, ground_platforms, player, shockwaves) + # Enemies that touch a hazard zone die instantly, dropping nothing. + for en in enemies: + if en.health <= 0: + continue + if any(en.rect.colliderect(hz) for hz in platforms_hazard): + en.health = 0.0 + en.hazard_kill = True + # Enemy enemies do NOT physically collide. # (Prevents enemies from shoving each other into platforms / off ledges.) # resolve_enemy_collisions(enemies) @@ -7702,6 +7890,8 @@ while running: if en.health > 0: alive.append(en) else: + if getattr(en, "hazard_kill", False): + continue # hazard deaths drop nothing and give no quest credit loot_drops.extend(spawn_drops_from_enemy(en)) etype = getattr(en, "type_id", "") if etype == "boss": @@ -7709,9 +7899,11 @@ while running: bid = boss_id_for_room(ROOM.id) if bid and ACTIVE_REFIGHT is None: token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid)) + elif etype == "miniboss": + DEFEATED_MINIBOSSES.add(ROOM.id) # Quest kill tracking - for qid in quest_manager.progress_kill(etype): + for qid in quest_manager.progress_kill(etype, player.shade): q = quest_manager.get(qid) if q: pickup_feed.push_plain(f"Quest fulfilled: {q.name} - return to the quest object!") @@ -7874,6 +8066,10 @@ while running: # Quest object hint bubble (shown near the player) near_qg = get_near_quest_giver(player, quest_givers) + # Shade-gated quests are only interactable while in shade form; in light + # form the object is visible but shows no interaction prompt. + if near_qg and quest_manager.requires_shade(near_qg.quest_id) and not player.shade: + near_qg = None if near_qg: q = quest_manager.get(near_qg.quest_id) label = "Press F" diff --git a/light_and_dark/saves/new rooms.json b/light_and_dark/saves/new rooms 2.json similarity index 57% rename from light_and_dark/saves/new rooms.json rename to light_and_dark/saves/new rooms 2.json index bab9da9..2da7780 100644 --- a/light_and_dark/saves/new rooms.json +++ b/light_and_dark/saves/new rooms 2.json @@ -1,18 +1,20 @@ { "version": 1, - "saved_at": "2026-09-29T18:54:05", + "saved_at": "2026-09-30T11:51:08", "player": { - "room_id": "room_02.json", - "x": 774, - "y": 320, + "room_id": "room_04.json", + "x": 590, + "y": 240, "health": 10, "charge": 100.0, "shade": false, - "coins_light": 4, - "coins_dark": 0, - "butterfly_jars": 1, + "coins_light": 39, + "coins_dark": 4, + "butterfly_jars": 2, "has_ranged": false, - "reward_chest_opened": false, + "opened_chests": [ + "room_03.json@90,194" + ], "equipment": { "helmet": null, "chest": null, @@ -24,6 +26,9 @@ "quests": { "active": {}, "completed_one_time": [], + "ever_completed": [ + "quest_first_kills" + ], "tokens": 0 }, "boss_diary": { @@ -32,8 +37,8 @@ "defeated_bosses": [], "broken_doors": [], "lantern_respawn": { - "room_id": "room_02.json", - "x": 720, - "y": 320 + "room_id": "room_04.json", + "x": 560, + "y": 240 } } \ No newline at end of file diff --git a/light_and_dark/saves/save 1.json b/light_and_dark/saves/save 1.json deleted file mode 100644 index 455da7d..0000000 --- a/light_and_dark/saves/save 1.json +++ /dev/null @@ -1,225 +0,0 @@ -{ - "version": 1, - "saved_at": "2026-09-29T12:14:42", - "player": { - "room_id": "room_town_gloomreach.json", - "x": 1250, - "y": 360, - "health": 12, - "charge": 125.0, - "shade": false, - "coins_light": 0, - "coins_dark": 0, - "butterfly_jars": 3, - "has_ranged": true, - "reward_chest_opened": true, - "equipment": { - "helmet": { - "id": "adaptive_traveller_helm", - "name": "Adaptive Traveller's Helmet", - "set": "adaptive_traveller", - "slot": "helmet", - "base_stats": {}, - "rarity_overrides": { - "tempered": { - "extra_stats": { - "charge": 10 - } - }, - "attuned": { - "extra_stats": { - "charge": 15 - } - }, - "awakened": { - "extra_stats": { - "charge": 15, - "charge_regen_pct": 0.15 - } - } - }, - "rarity": "tempered", - "tier": 1, - "stats": { - "charge": 10.0 - }, - "abilities": [] - }, - "chest": { - "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": [] - }, - "pants": { - "id": "dawnrunner_greaves", - "name": "Dawnrunner Greaves", - "set": "dawnrunner", - "slot": "pants", - "base_stats": {}, - "rarity_overrides": { - "tempered": { - "extra_stats": { - "heal_speed_pct": 0.1 - } - }, - "attuned": { - "extra_stats": { - "heal_speed_pct": 0.1, - "move_speed_light_pct": 0.1 - } - }, - "awakened": { - "extra_stats": { - "heal_speed_pct": 0.15, - "move_speed_light_pct": 0.15 - } - } - }, - "rarity": "tempered", - "tier": 1, - "stats": { - "heal_speed_pct": 0.1 - }, - "abilities": [] - }, - "necklace": { - "id": "necklace_placeholder", - "name": "Plain Necklace", - "slot": "necklace", - "item_type": "necklace", - "base_stats": { - "health": 2 - }, - "rarity": "", - "tier": 0, - "abilities": [], - "stats": { - "health": 2 - } - } - } - }, - "inventory": [ - { - "id": "twister_helm", - "name": "Twister Helmet", - "set": "twister", - "slot": "helmet", - "base_stats": {}, - "rarity_overrides": { - "tempered": { - "extra_stats": { - "attack_speed_pct": 0.1 - } - }, - "attuned": { - "extra_stats": { - "attack_speed_pct": 0.15 - } - }, - "awakened": { - "extra_stats": { - "attack_speed_pct": 0.15, - "charge_regen_pct": 0.15 - } - } - }, - "rarity": "tempered", - "tier": 1, - "stats": { - "attack_speed_pct": 0.1 - }, - "abilities": [] - }, - { - "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": "tempered", - "tier": 1, - "stats": { - "move_speed_light_pct": 0.1 - }, - "abilities": [] - } - ], - "quests": { - "active": { - "quest_first_kills": { - "progress": 21 - } - }, - "completed_one_time": [], - "tokens": 0 - }, - "boss_diary": { - "slotted": { - "boss_01": 1 - } - }, - "defeated_bosses": [ - "room_boss_01.json" - ], - "broken_doors": [ - "room_07.json:-980,2180,80,600", - "room_07.json:1940,2720,360,20", - "room_07.json:4100,2180,40,600", - "room_reward_01.json:1580,60,40,300", - "room_reward_01.json:1700,60,60,300" - ], - "lantern_respawn": { - "room_id": "room_town_gloomreach.json", - "x": 1300, - "y": 360 - } -} \ No newline at end of file