more rooms

more rooms, level editor changes, more logic for ability checks and trigger doors, platform body collision fixes
This commit is contained in:
2026-09-30 12:00:56 +02:00
parent 53fc17338f
commit faa8b16658
9 changed files with 935 additions and 305 deletions
+2 -1
View File
@@ -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": {
+67 -23
View File
@@ -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": [],
@@ -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
}
@@ -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
}
@@ -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
}
+65 -10
View File
@@ -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`);
<div style="margin-top:8px;">
<div class="mini">unlock when quest complete (id)</div>
<input id="ptqcomplete" type="text" value="${p.quest_complete||""}" placeholder="e.g. quest_first_kills"/>
</div>
<div style="margin-top:8px;">
<div class="mini">unlock when quest turned in (id)</div>
<input id="ptqturnedin" type="text" value="${p.quest_turned_in||""}" placeholder="e.g. quest_first_kills"/>
</div>
<div style="margin-top:8px;">
<label><input id="ptlockpass" type="checkbox" ${p.lock_when_pass?"checked":""}/> Appear once the player walks through (locks them in)</label>
</div>
<div style="margin-top:8px;">
<label><input id="ptminibossdead" type="checkbox" ${p.open_on_miniboss_dead?"checked":""}/> Present until the room's miniboss is slain</label>
</div>` : ""}
${(p.group||"ground") === "hazard" ? `
<div style="margin-top:8px;">
@@ -2204,6 +2229,13 @@ Cancel = Choose whether to remove without saving`);
<div><div class="mini">h</div><input id="adh" type="number" value="${d.h}"/></div>
<div><div class="mini">layer (lower = back)</div><input id="adlayer" type="number" value="${d.layer||0}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type</div>
<select id="adkind">
<option value="ranged" ${(d.kind||"ranged")==="ranged"?"selected":""}>Ranged door (broken by the Ranged bolt)</option>
<option value="shade" ${d.kind==="shade"?"selected":""}>Shade door (passable in shade form, solid in light)</option>
</select>
</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
@@ -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');
+231 -35
View File
@@ -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"
@@ -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
}
}
-225
View File
@@ -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
}
}