attack changes

dissolve platforms, mouse for attack, directional attack (i think), hold for attack
This commit is contained in:
2026-10-02 08:12:53 +02:00
parent bf4e8de590
commit c7558a3947
7 changed files with 760 additions and 136 deletions
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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,
+53 -60
View File
@@ -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
}
@@ -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
}
+50 -1
View File
@@ -114,6 +114,7 @@
<option value="hazard">Hazard (red - damage)</option>
<option value="moving">Moving (path)</option>
<option value="trigger">Trigger (name)</option>
<option value="dissolve">Dissolve (timed)</option>
</select>
</div>
<div class="muted" style="margin-top:8px;">
@@ -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`);
<option value="hazard" ${p.group==="hazard"?"selected":""}>Hazard (red - damage)</option>
<option value="moving" ${p.group==="moving"?"selected":""}>Moving (path)</option>
<option value="trigger" ${p.group==="trigger"?"selected":""}>Trigger (name)</option>
<option value="dissolve" ${p.group==="dissolve"?"selected":""}>Dissolve (timed)</option>
</select>
</div>
${(p.group||"ground") === "trigger" ? `
@@ -2186,6 +2206,9 @@ Cancel = Choose whether to remove without saving`);
</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>
<div style="margin-top:8px;">
<label><input id="ptbossdead" type="checkbox" ${p.open_on_boss_dead?"checked":""}/> Present until the room's boss is slain</label>
</div>` : ""}
${(p.group||"ground") === "hazard" ? `
<div style="margin-top:8px;">
@@ -2204,7 +2227,16 @@ Cancel = Choose whether to remove without saving`);
<div style="margin-top:8px;">
<div class="mini">speed (px/s)</div>
<input id="ptspeed" type="number" value="${p.speed||120}"/>
</div>
<div style="margin-top:8px;">
<label><input id="pmbossdead" type="checkbox" ${p.open_on_boss_dead?"checked":""}/> Hidden until the room's boss is slain</label>
</div>` : ""}
${(p.group||"ground") === "dissolve" ? `
<div class="grid2" style="margin-top:8px;">
<div><div class="mini">stand time (s)</div><input id="ptstand" type="number" step="0.1" min="0.1" value="${p.stand_time!==undefined?p.stand_time:1.0}"/></div>
<div><div class="mini">dissolve time (s)</div><input id="ptdissolve" type="number" step="0.1" min="0.1" value="${p.dissolve_time!==undefined?p.dissolve_time:2.0}"/></div>
</div>
<div class="muted" style="margin-top:6px;">While the player stands on it, after the stand time it becomes translucent (no hitbox) for the dissolve time, then reforms.</div>` : ""}
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
@@ -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 {
+363 -55
View File
@@ -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:
+199 -17
View File
@@ -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
}
}