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, "charge_cost": 5.0,
"damage_mult": 0.5, "damage_mult": 0.5,
"cooldown": 0.45, "cooldown": 0.63,
"projectile": { "projectile": {
"speed": 650.0, "speed": 650.0,
"lifetime": 1.2, "lifetime": 1.2,
+4 -2
View File
@@ -231,7 +231,7 @@
"y": 540, "y": 540,
"w": 60, "w": 60,
"h": 20, "h": 20,
"group": "moving", "group": "dissolve",
"layer": 0, "layer": 0,
"path": [ "path": [
[ [
@@ -242,7 +242,9 @@
820, 820,
600 600
] ]
] ],
"stand_time": 2,
"dissolve_time": 2
}, },
{ {
"x": 1040, "x": 1040,
+53 -60
View File
@@ -16,15 +16,15 @@
120 120
], ],
"room5_bottom_right": [ "room5_bottom_right": [
1400, 1420,
440 780
] ]
}, },
"platforms": [ "platforms": [
{ {
"x": 0, "x": 20,
"y": 180, "y": 180,
"w": 240, "w": 220,
"h": 20, "h": 20,
"group": "ground", "group": "ground",
"layer": 0 "layer": 0
@@ -33,15 +33,7 @@
"x": 0, "x": 0,
"y": 0, "y": 0,
"w": 20, "w": 20,
"h": 180, "h": 840,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 0,
"w": 1520,
"h": 20,
"group": "ground", "group": "ground",
"layer": 0 "layer": 0
}, },
@@ -62,83 +54,83 @@
"layer": 0 "layer": 0
}, },
{ {
"x": 860, "x": 0,
"y": 500, "y": 840,
"w": 680, "w": 1520,
"h": 20, "h": 20,
"group": "ground", "group": "ground",
"layer": 0 "layer": 0
}, },
{ {
"x": 1520, "x": 1520,
"y": 380, "y": 680,
"w": 20, "w": 20,
"h": 120, "h": 180,
"group": "ground", "group": "ground",
"layer": 0 "layer": 0
}, },
{ {
"x": 1320, "x": 1380,
"y": 360, "y": 700,
"w": 220, "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, "h": 20,
"group": "ground", "group": "trigger",
"layer": 0 "layer": 0,
"name": "open_when_boss_slain",
"open_on_boss_dead": true
}, },
{ {
"x": 1520, "x": 1380,
"y": 20, "y": 0,
"w": 20, "w": 20,
"h": 340, "h": 680,
"group": "ground", "group": "ground",
"layer": 0 "layer": 0
}, },
{ {
"x": 1180, "x": 1040,
"y": 440, "y": 740,
"w": 1, "w": 160,
"h": 1,
"group": "ground"
},
{
"x": 1180,
"y": 420,
"w": 60,
"h": 20, "h": 20,
"group": "moving", "group": "moving",
"layer": 0,
"path": [ "path": [
[ [
1180, 1040,
420 740
], ],
[ [
1180, 1040,
360 200
] ]
] ],
"open_on_boss_dead": true
} }
], ],
"enemies": [ "enemies": [
{ {
"x": 520, "x": 620,
"y": 140, "y": 720,
"type": "enemy", "type": "boss",
"layer": 0, "layer": 0,
"behavior": "" "behavior": "charger"
},
{
"x": 1000,
"y": 460,
"type": "enemy",
"layer": 0,
"behavior": ""
} }
], ],
"npcs": [], "npcs": [],
"props": [ "props": [
{ {
"x": 1400, "x": 860,
"y": 360, "y": 180,
"type": "save_point", "type": "save_point",
"radius": 90, "radius": 90,
"object_id": "lamp", "object_id": "lamp",
@@ -163,14 +155,14 @@
}, },
{ {
"x": 1440, "x": 1440,
"y": 400, "y": 740,
"w": 80, "w": 80,
"h": 100, "h": 100,
"to": "room_06", "to": "room_07",
"spawn_id": "room6_top_right", "spawn_id": "room7_top_left",
"spawn": [ "spawn": [
520, 180,
240 460
], ],
"hint": "", "hint": "",
"layer": 0 "layer": 0
@@ -178,5 +170,6 @@
], ],
"ability_doors": [], "ability_doors": [],
"room_w": 1600, "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="hazard">Hazard (red - damage)</option>
<option value="moving">Moving (path)</option> <option value="moving">Moving (path)</option>
<option value="trigger">Trigger (name)</option> <option value="trigger">Trigger (name)</option>
<option value="dissolve">Dissolve (timed)</option>
</select> </select>
</div> </div>
<div class="muted" style="margin-top:8px;"> <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.quest_turned_in) out.quest_turned_in = String(rect.quest_turned_in);
if(rect.lock_when_pass) out.lock_when_pass = true; 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_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(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 out;
} }
return null; 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" }, 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" }, 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" }, 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 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 }; 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 }); 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 === "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 === "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 }); 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 }); 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.stroke();
ctx.setLineDash([]); 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"){ } else if(it.kind === "door"){
const d = it.d; const d = it.d;
const [sx,sy] = worldToScreen(d.x, d.y, canvas); 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="hazard" ${p.group==="hazard"?"selected":""}>Hazard (red - damage)</option>
<option value="moving" ${p.group==="moving"?"selected":""}>Moving (path)</option> <option value="moving" ${p.group==="moving"?"selected":""}>Moving (path)</option>
<option value="trigger" ${p.group==="trigger"?"selected":""}>Trigger (name)</option> <option value="trigger" ${p.group==="trigger"?"selected":""}>Trigger (name)</option>
<option value="dissolve" ${p.group==="dissolve"?"selected":""}>Dissolve (timed)</option>
</select> </select>
</div> </div>
${(p.group||"ground") === "trigger" ? ` ${(p.group||"ground") === "trigger" ? `
@@ -2186,6 +2206,9 @@ Cancel = Choose whether to remove without saving`);
</div> </div>
<div style="margin-top:8px;"> <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> <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>` : ""} </div>` : ""}
${(p.group||"ground") === "hazard" ? ` ${(p.group||"ground") === "hazard" ? `
<div style="margin-top:8px;"> <div style="margin-top:8px;">
@@ -2204,7 +2227,16 @@ Cancel = Choose whether to remove without saving`);
<div style="margin-top:8px;"> <div style="margin-top:8px;">
<div class="mini">speed (px/s)</div> <div class="mini">speed (px/s)</div>
<input id="ptspeed" type="number" value="${p.speed||120}"/> <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>` : ""} </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;"> <div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button> <button id="delSel" class="danger">Delete</button>
</div> </div>
@@ -2431,6 +2463,10 @@ Cancel = Choose whether to remove without saving`);
const pg=q("pg"); if(pg) pg.addEventListener("change", ()=>pushAnd(()=>{ const pg=q("pg"); if(pg) pg.addEventListener("change", ()=>pushAnd(()=>{
p.group = pg.value || "ground"; p.group = pg.value || "ground";
if(p.group === "moving" && !Array.isArray(p.path)) p.path = [[p.x, p.y]]; 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; if(p.group !== "trigger" && p.name !== undefined && p.name !== "") delete p.name;
})); }));
const ptname=q("ptname"); if(ptname) ptname.addEventListener("change", ()=>pushAnd(()=>{ 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(()=>{ 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; } 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 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"); const hzrespawn=q("hzrespawn");
if(hzrespawn){ if(hzrespawn){
// preselect current respawn if it matches an entry spawn // 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 || [])){ for(const p of (d.platforms || [])){
const group = p.group || "ground"; const group = p.group || "ground";
if(!platforms[group]) platforms[group] = []; 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 }; const obj = { x: p.x, y: p.y, w: p.w, h: p.h };
if(p.layer) obj.layer = p.layer; if(p.layer) obj.layer = p.layer;
if(group === "moving"){ if(group === "moving"){
if(Array.isArray(p.path)) obj.path = p.path; if(Array.isArray(p.path)) obj.path = p.path;
if(p.speed !== undefined) obj.speed = p.speed; 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.name) obj.name = p.name;
if(p.quest) obj.quest = p.quest; if(p.quest) obj.quest = p.quest;
if(p.quest_complete) obj.quest_complete = p.quest_complete; 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.quest_turned_in) obj.quest_turned_in = p.quest_turned_in;
if(p.lock_when_pass) obj.lock_when_pass = true; 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_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; if(Array.isArray(p.respawn)) obj.respawn = p.respawn;
platforms[group].push(obj); platforms[group].push(obj);
} else { } else {
+363 -55
View File
@@ -19,16 +19,24 @@ SHOCKWAVE_LIFETIME = 2.0 # seconds
SHOCKWAVE_HEIGHT = 20 # hitbox height SHOCKWAVE_HEIGHT = 20 # hitbox height
SHOCKWAVE_DAMAGE = 2 SHOCKWAVE_DAMAGE = 2
BOSS_POISON_DURATION = 4.0 # boss attacks poison the player for this many seconds 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 # 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. # to one side, so a player standing directly above/below no longer makes them wiggle.
CHASE_DEADZONE = 6 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 ability / projectile settings ---
RANGED_CHARGE_COST = 5.0 # costs more than melee (melee costs 1) RANGED_CHARGE_COST = 5.0 # costs more than melee (melee costs 1)
RANGED_DAMAGE_MULT = 0.5 # 50% of melee damage 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_SPEED = 650.0 # px/sec
PROJECTILE_LIFETIME = 1.2 # seconds PROJECTILE_LIFETIME = 1.2 # seconds
PROJECTILE_SIZE = (14, 6) # width, height PROJECTILE_SIZE = (14, 6) # width, height
@@ -79,6 +87,10 @@ SHATTER_AOE_LIFETIME = 0.25
SHATTER_ICON_SIZE = 32 SHATTER_ICON_SIZE = 32
SHATTER_ICON_POS = (20, 20) 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 ability ---
TETHER_BIND_DURATION = 3.0 TETHER_BIND_DURATION = 3.0
TETHER_COOLDOWN = 6.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) pygame.draw.circle(surf, color, (cx + ox, cy + oy), r)
return surf 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 = _build_mist_tile()
MIST_TILE_SLOW = _build_mist_tile_slow()
def draw_mist_overlay(target: pygame.Surface): def draw_mist_overlay(target: pygame.Surface):
if MIST_TILE is None or not getattr(ROOM, "mist", False): if MIST_TILE is None or not getattr(ROOM, "mist", False):
return return
size = MIST_TILE_SIZE size = MIST_TILE_SIZE
t = pygame.time.get_ticks() / 1000.0 t = pygame.time.get_ticks() / 1000.0
dx = int((t * 10.0) % size) # Base layer drifts one way...
dy = int((t * 6.0) % size) dx = int((t * 30.0) % size)
dy = int((t * 22.0) % size)
for tx in range(-1, WIDTH // size + 2): for tx in range(-1, WIDTH // size + 2):
for ty in range(-1, HEIGHT // size + 2): for ty in range(-1, HEIGHT // size + 2):
target.blit(MIST_TILE, (tx * size - dx, ty * size - dy)) 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_CLEAR_RADIUS = 150
_VIGNETTE_HOLE_CACHE: dict[tuple[int, int], pygame.Surface] = {} _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) grouped["ground"].append(arr)
elif isinstance(p, dict): elif isinstance(p, dict):
group = p.get("group", "ground") 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)} 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"): if "layer" in p and p.get("layer"):
obj["layer"] = p["layer"] obj["layer"] = p["layer"]
@@ -983,6 +1022,11 @@ def normalize_room_json(room_json: dict) -> dict:
obj["path"] = p["path"] obj["path"] = p["path"]
if "speed" in p: if "speed" in p:
obj["speed"] = p["speed"] 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: if "name" in p:
obj["name"] = p["name"] obj["name"] = p["name"]
if "quest" in p: if "quest" in p:
@@ -995,6 +1039,8 @@ def normalize_room_json(room_json: dict) -> dict:
obj["lock_when_pass"] = p["lock_when_pass"] obj["lock_when_pass"] = p["lock_when_pass"]
if "open_on_miniboss_dead" in p: if "open_on_miniboss_dead" in p:
obj["open_on_miniboss_dead"] = p["open_on_miniboss_dead"] 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: if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2:
obj["respawn"] = p["respawn"] obj["respawn"] = p["respawn"]
grouped.setdefault(group, []).append(obj) grouped.setdefault(group, []).append(obj)
@@ -1099,6 +1145,7 @@ class Room:
self.platform_layer: dict[int, int] = {} self.platform_layer: dict[int, int] = {}
self.platform_meta: dict[int, dict] = {} # id(rect) -> {"name"|"path"|"speed", ...} 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 {} platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {}
if isinstance(platforms, list): if isinstance(platforms, list):
self.platforms_ground = rects_from_list(platforms, self.platform_layer) self.platforms_ground = rects_from_list(platforms, self.platform_layer)
@@ -1142,6 +1189,8 @@ class Room:
meta["lock_when_pass"] = True meta["lock_when_pass"] = True
if tp.get("open_on_miniboss_dead"): if tp.get("open_on_miniboss_dead"):
meta["open_on_miniboss_dead"] = True 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.platform_meta[id(rect)] = meta
self.platforms_trigger.append(rect) self.platforms_trigger.append(rect)
elif isinstance(tp, (list, tuple)) and len(tp) >= 4: elif isinstance(tp, (list, tuple)) and len(tp) >= 4:
@@ -1163,7 +1212,10 @@ class Room:
else: else:
path = [[rect.x, rect.y]] path = [[rect.x, rect.y]]
speed = float(mp.get("speed", 120)) 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) self.platforms_moving.append(rect)
elif isinstance(mp, (list, tuple)) and len(mp) >= 4: elif isinstance(mp, (list, tuple)) and len(mp) >= 4:
rect = pygame.Rect(int(mp[0]), int(mp[1]), int(mp[2]), int(mp[3])) rect = pygame.Rect(int(mp[0]), int(mp[1]), int(mp[2]), int(mp[3]))
@@ -1173,16 +1225,37 @@ class Room:
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
self.platforms_moving.append(rect) 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: else:
self.platforms_ground = [] self.platforms_ground = []
self.platforms_floating = [] self.platforms_floating = []
self.platforms_hazard = [] self.platforms_hazard = []
self.platforms_trigger = [] self.platforms_trigger = []
self.platforms_moving = [] self.platforms_moving = []
self.platforms_dissolve = []
self.ground_platforms = self.platforms_ground[:] self.ground_platforms = self.platforms_ground[:]
self.platforms = self.platforms_ground + self.platforms_floating + self.platforms_moving 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: if all_platform_rects:
self.min_x = min(r.left for r in 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) 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)) w = max(1, int(img.get_width() * scale))
h = max(1, int(img.get_height() * scale)) h = max(1, int(img.get_height() * scale))
img = pygame.transform.scale(img, (w, h)) img = pygame.transform.scale(img, (w, h))
if self.dir > 0: # Raw sprite points LEFT; flip it so it points right, then rotate to
img = pygame.transform.flip(img, True, False) # match the actual flight direction so up/down shots aren't horizontal.
surface.blit(img, (self.rect.centerx - w // 2 - cx, self.rect.centery - h // 2 - cy)) 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 return
# simple readable bolt # simple readable bolt
pygame.draw.rect( pygame.draw.rect(
@@ -1761,11 +1837,15 @@ class LightSource:
class SavePoint: 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): def __init__(self, x: int, y: int, radius: int = 90):
self.x = int(x) self.x = int(x)
self.y = int(y) self.y = int(y)
self.radius = int(radius) self.radius = int(radius)
self.used = False
self.t = random.random() * 10.0 self.t = random.random() * 10.0
if LAMP_RAW is not None: if LAMP_RAW is not None:
self.img = LAMP_RAW self.img = LAMP_RAW
@@ -1780,6 +1860,10 @@ class SavePoint:
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy)) surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy))
else: else:
pygame.draw.rect(surface, (150, 200, 230), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height)) 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: class ButterflyJar:
@@ -1933,14 +2017,14 @@ class LootPickup:
class CoinPickup: 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" # kind: "light" or "dark"
self.kind = kind self.kind = kind
self.rect = pygame.Rect(int(x), int(y), 16, 16) self.rect = pygame.Rect(int(x), int(y), 16, 16)
# physics (same feel as loot) # physics (same feel as loot)
self.vx = random.uniform(-80, 80) self.vx = random.uniform(-80, 80) if vx is None else vx
self.vy = random.uniform(-140, -60) self.vy = random.uniform(-140, -60) if vy is None else vy
self.on_ground = False self.on_ground = False
self.bob_t = random.random() * 10.0 self.bob_t = random.random() * 10.0
@@ -2029,12 +2113,29 @@ class RewardChest:
if self.opened: if self.opened:
return False return False
light_amt, dark_amt = self.coins light_amt, dark_amt = self.coins
cx = self.rect.centerx # spray coins across the chest width and above its top so they don't
cy = self.rect.top - 18 # pop out of the chest top, clear of its platform # 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))): 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))): 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 self.opened = True
return True return True
@@ -3978,7 +4079,12 @@ class Player:
elif key_pressed(keys, "move_right"): elif key_pressed(keys, "move_right"):
self.facing_right = True 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 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) speed *= (1.0 + self.bonus_move_speed_pct + form_bonus)
@@ -4255,6 +4361,18 @@ class Enemy:
self.max_step_up = 160 self.max_step_up = 160
self.max_step_forward = 120 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.contact_damage = int(cfg.get("contact_damage", 1))
self.drop_profile = str(cfg.get("drop_profile", "normal")) self.drop_profile = str(cfg.get("drop_profile", "normal"))
@@ -4403,6 +4521,20 @@ class Enemy:
best_top = p.top best_top = p.top
return best 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): def _probe_ground_now(self, platforms):
"""Refresh on_ground + ground_platform every frame (stable).""" """Refresh on_ground + ground_platform every frame (stable)."""
@@ -4833,10 +4965,43 @@ class Enemy:
self.vel_x = self.direction * desired_speed 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 --- # --- CHASE LEDGE DROP ---
# If we're chasing and the player is below, step off the platform instead of edge-wiggling. # If we're chasing and the player is below, step off the platform instead of edge-wiggling.
jump_target = None 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 gp = self.ground_platform
# IMPORTANT: define this right here so it's ALWAYS available # 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) # 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 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)): if player_above and (blocked_by_wall or self.reachable_platform_ahead(platforms)):
self.vel_y = self.jump_strength self.vel_y = self.jump_strength
@@ -4985,6 +5150,28 @@ class Enemy:
if self.bind_timer > 0.0: if self.bind_timer > 0.0:
_draw_tether_chain_loop(surface, self.rect, light=self.bound_light, cx=cx, cy=cy) _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): def resolve_enemy_collisions(enemies):
# for i in range(len(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('move_left'))}/{key_name(key_for('move_right'))} = move",
f"{key_name(key_for('jump'))} = jump", f"{key_name(key_for('jump'))} = jump",
f"{key_name(key_for('attack'))} = attack", 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('dash'))} = dash",
f"{key_name(key_for('shatterstep'))} = shatterstep", f"{key_name(key_for('shatterstep'))} = shatterstep",
f"{key_name(key_for('tether'))} = tether", 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 - ``open_on_miniboss_dead``: releases the wall once the room's miniboss
is slain (combines with the conditions above; with no other condition is slain (combines with the conditions above; with no other condition
the wall is simply present until then). 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).""" Walls with no condition are inert placeholders (never block)."""
meta = platform_meta.get(id(rect), {}) meta = platform_meta.get(id(rect), {})
qid = meta.get("quest") 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 present = meta["quest_turned_in"] not in quest_manager.ever_completed
elif meta.get("open_on_miniboss_dead"): elif meta.get("open_on_miniboss_dead"):
present = True # always present until released present = True # always present until released
elif meta.get("open_on_boss_dead"):
present = True # always present until released
else: else:
return False # inert placeholder return False # inert placeholder
if meta.get("open_on_miniboss_dead"): if meta.get("open_on_miniboss_dead"):
present = present and miniboss_alive_in_room() present = present and miniboss_alive_in_room()
if meta.get("open_on_boss_dead"):
present = present and boss_alive_in_room()
return present return present
@@ -5294,6 +5488,8 @@ def enemy_trigger_wall_blocking(rect) -> bool:
meta = platform_meta.get(id(rect), {}) meta = platform_meta.get(id(rect), {})
if meta.get("open_on_miniboss_dead"): if meta.get("open_on_miniboss_dead"):
return miniboss_alive_in_room() return miniboss_alive_in_room()
if meta.get("open_on_boss_dead"):
return boss_alive_in_room()
return trigger_wall_blocking(rect) 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) 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): def push_rect_out_of_rects(rect, rects):
"""Push a pygame.Rect out of any overlapping rect, moving to the nearest edge. """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). """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 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: for rect in platforms_moving:
if not moving_platform_active(rect):
continue
mdata = platform_meta.get(id(rect)) mdata = platform_meta.get(id(rect))
if not mdata: if not mdata:
continue continue
@@ -5412,6 +5620,45 @@ def update_moving_platforms(dt: float):
rect.y = new_y 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): def draw_layerable(surface, cx, cy):
"""Draw all static room objects back-to-front by (layer, category). """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)) items.append((getattr(reward_ability, "layer", 0), 0, "rewardability", reward_ability))
# platforms # platforms
for p in 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)) 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 # moving platforms (solid, follow a path) layer alongside platforms
for p in platforms_moving: for p in platforms_moving:
if not moving_platform_active(p):
continue
items.append((platform_layer.get(id(p), 0), 1, "moving", p)) items.append((platform_layer.get(id(p), 0), 1, "moving", p))
# hazard zones (red) layer alongside platforms # hazard zones (red) layer alongside platforms
for p in platforms_hazard: for p in platforms_hazard:
@@ -5478,6 +5734,16 @@ def draw_layerable(surface, cx, cy):
PLATFORM_COLOR, PLATFORM_COLOR,
pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height), 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": elif kind == "moving":
mr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height) mr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
pygame.draw.rect(surface, (80, 180, 220), mr) pygame.draw.rect(surface, (80, 180, 220), mr)
@@ -5735,6 +6001,7 @@ platforms_floating = ROOM.platforms_floating
platforms_hazard = ROOM.platforms_hazard platforms_hazard = ROOM.platforms_hazard
platforms_trigger = ROOM.platforms_trigger platforms_trigger = ROOM.platforms_trigger
platforms_moving = ROOM.platforms_moving platforms_moving = ROOM.platforms_moving
platforms_dissolve = ROOM.platforms_dissolve
platform_meta = ROOM.platform_meta platform_meta = ROOM.platform_meta
ground_platforms = ROOM.ground_platforms ground_platforms = ROOM.ground_platforms
platforms = ROOM.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.""" """Updates room-dependent lists + respawns enemies. Keeps inventory/equipment."""
global ROOM, KILL_Y, platforms_ground, platforms_floating, platforms_hazard, ground_platforms, platforms global ROOM, KILL_Y, platforms_ground, platforms_floating, platforms_hazard, ground_platforms, platforms
global platforms_trigger, platforms_moving, platform_meta global platforms_trigger, platforms_moving, platform_meta
global platforms_dissolve
global platform_layer global platform_layer
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global shatter_platforms, shatter_aoes, tether_shots 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_hazard = ROOM.platforms_hazard
platforms_trigger = ROOM.platforms_trigger platforms_trigger = ROOM.platforms_trigger
platforms_moving = ROOM.platforms_moving platforms_moving = ROOM.platforms_moving
platforms_dissolve = ROOM.platforms_dissolve
platform_meta = ROOM.platform_meta platform_meta = ROOM.platform_meta
ground_platforms = ROOM.ground_platforms ground_platforms = ROOM.ground_platforms
platforms = ROOM.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 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: 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 = None
best_d = None best_d = None
px, py = player_obj.rect.midbottom px, py = player_obj.rect.midbottom
for sp in save_point_list: for sp in save_point_list:
if sp.used:
continue
r = range_px if range_px is not None else sp.radius r = range_px if range_px is not None else sp.radius
if r <= 0: if r <= 0:
continue 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): def save_at_lantern(sp: SavePoint):
"""Set the lantern as the respawn point, restore HP/Charge, and save the game.""" """Set the lantern as the respawn point, restore HP/Charge, and save the game."""
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
if sp.used:
return
sp.used = True
LANTERN_RESPAWN_ROOM = ROOM.id LANTERN_RESPAWN_ROOM = ROOM.id
LANTERN_RESPAWN_POS = (sp.x, sp.y) LANTERN_RESPAWN_POS = (sp.x, sp.y)
# Resting at a lantern fully restores the player. # Resting at a lantern fully restores the player.
@@ -6949,7 +7234,10 @@ def draw_stats_overlay(surface):
if not stats_overlay_visible: if not stats_overlay_visible:
return return
light_speed = PLAYER_SPEED * (1.0 + player.bonus_move_speed_pct + player.bonus_move_speed_light_pct) 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() heal_dur = player.effective_heal_duration()
cycle = player.effective_attack_duration() + player.effective_attack_cooldown() cycle = player.effective_attack_duration() + player.effective_attack_cooldown()
atk_rate = (1.0 / cycle) if cycle > 0 else 0.0 atk_rate = (1.0 / cycle) if cycle > 0 else 0.0
@@ -7665,14 +7953,6 @@ while running:
running = False running = False
break 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: if e.type == pygame.KEYDOWN:
was_trade_visible = trade_ui.visible was_trade_visible = trade_ui.visible
was_inv_visible = inv_ui.visible was_inv_visible = inv_ui.visible
@@ -7734,6 +8014,9 @@ while running:
near_lantern = get_near_lantern(player, save_points) near_lantern = get_near_lantern(player, save_points)
if near_lantern: if near_lantern:
save_at_lantern(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): elif get_near_diary(player, diary_obj):
diary_ui.toggle() diary_ui.toggle()
else: else:
@@ -7764,23 +8047,6 @@ while running:
if key_is("toggle_shade", e.key): if key_is("toggle_shade", e.key):
player.toggle_shade() 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 key_is("reality_break", e.key) and player.has_reality_break:
if player.shade: if player.shade:
ROOM_DROP_MULT = 0.5 ROOM_DROP_MULT = 0.5
@@ -7802,6 +8068,29 @@ while running:
keys = pygame.key.get_pressed() 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: if paused:
# draw last frame + pause overlay # draw last frame + pause overlay
screen.fill(BG) screen.fill(BG)
@@ -7934,6 +8223,8 @@ while running:
support_moving = None support_moving = None
if player.on_ground: if player.on_ground:
for rect in platforms_moving: 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 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.right > rect.left and player.rect.left < rect.right):
support_moving = rect support_moving = rect
@@ -7958,7 +8249,13 @@ while running:
# Trigger walls: solid only while their unlock quest is not yet active. # 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)] 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 — # Enemies collide with ground/floating platforms, doors and trigger walls —
# but NOT moving platforms. Moving platforms are player traversal; a platform # 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 = [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 += [d.rect for d in ability_doors if not d.open]
enemy_collision_platforms += enemy_blocking_triggers 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 # 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 # 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 # 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). # doesn't leave a gap that makes on_ground flicker (bobbing / eventual fall-off).
for rect in platforms_moving: 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 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.right > rect.left and player.rect.left < rect.right):
player.rect.bottom = rect.top player.rect.bottom = rect.top
@@ -8287,14 +8587,12 @@ while running:
# Pickup loot (only when inventory closed) # Pickup loot (only when inventory closed)
if not inv_ui.visible and not trade_ui.visible: if not inv_ui.visible and not trade_ui.visible:
if reward_chest and player.rect.colliderect(reward_chest.rect): # Chests open on interact (F); the interact handler calls reward_chest.open().
if reward_chest.open(player):
player.opened_chests.add(reward_chest.id)
if reward_ability and player.rect.colliderect(reward_ability.rect): if reward_ability and player.rect.colliderect(reward_ability.rect):
if reward_ability.ability_id == "ranged": if reward_ability.ability_id == "ranged":
player.has_ranged = True 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: else:
pickup_feed.push_center(f"Ability acquired: {reward_ability.ability_id}", duration=5.0) pickup_feed.push_center(f"Ability acquired: {reward_ability.ability_id}", duration=5.0)
reward_ability = None reward_ability = None
@@ -8460,6 +8758,16 @@ while running:
int(near_lantern.rect.top - camera_y), 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 health bar (if boss exists)
boss = next((en for en in enemies if getattr(en, "drop_profile", "") == "boss"), None) boss = next((en for en in enemies if getattr(en, "drop_profile", "") == "boss"), None)
if boss is not None: if boss is not None:
+199 -17
View File
@@ -1,22 +1,55 @@
{ {
"version": 1, "version": 1,
"saved_at": "2026-10-01T10:39:22", "saved_at": "2026-10-01T14:40:38",
"player": { "player": {
"room_id": "room_04.json", "room_id": "room_07.json",
"x": 509, "x": 620,
"y": 240, "y": 520,
"health": 10, "health": 10,
"charge": 100.0, "charge": 120.0,
"shade": false, "shade": false,
"coins_light": 46, "coins_light": 40,
"coins_dark": 6, "coins_dark": 20,
"butterfly_jars": 2, "butterfly_jars": 0,
"has_ranged": false, "has_ranged": false,
"opened_chests": [ "opened_chests": [
"room_03.json@90,194" "room_03.json@90,194",
"room_07.json@440,494"
], ],
"equipment": { "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": { "chest": {
"id": "vitalweave_chest", "id": "vitalweave_chest",
"name": "Vitalweave Chestplate", "name": "Vitalweave Chestplate",
@@ -48,15 +81,162 @@
}, },
"abilities": [] "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 "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": { "quests": {
"active": { "active": {
"first_kills_shade": { "first_kills_shade": {
"progress": 0 "progress": 1
} }
}, },
"completed_one_time": [], "completed_one_time": [],
@@ -68,11 +248,13 @@
"boss_diary": { "boss_diary": {
"slotted": {} "slotted": {}
}, },
"defeated_bosses": [], "defeated_bosses": [
"room_05.json"
],
"broken_doors": [], "broken_doors": [],
"lantern_respawn": { "lantern_respawn": {
"room_id": "room_04.json", "room_id": "room_07.json",
"x": 560, "x": 640,
"y": 240 "y": 520
} }
} }