diff --git a/light_and_dark/data/config/game_config.json b/light_and_dark/data/config/game_config.json
index 6e097d2..b17ff2a 100644
--- a/light_and_dark/data/config/game_config.json
+++ b/light_and_dark/data/config/game_config.json
@@ -6,7 +6,7 @@
"fps": 60
},
"colors": {
- "bg": [30, 30, 30],
+ "bg": [58, 58, 64],
"player_light": [200, 200, 200],
"player_dark": [60, 60, 60],
"platform": [80, 80, 80],
diff --git a/light_and_dark/data/config/physics.json b/light_and_dark/data/config/physics.json
index 38519e1..f0d5c1f 100644
--- a/light_and_dark/data/config/physics.json
+++ b/light_and_dark/data/config/physics.json
@@ -6,6 +6,7 @@
"jump_shade": -9.0,
"gravity_light": 0.5,
"gravity_shade": 0.4,
+ "jump_release_gravity_mult": 3.0,
"shade_door_grace": 0.6
},
@@ -13,6 +14,7 @@
"base_max_charge": 100.0,
"charge_drain_rate": 40.0,
"charge_refill_rate": 10.0,
+ "charge_empty_lock": 1.5,
"base_max_health": 10,
"invincibility_duration": 1.0
},
diff --git a/light_and_dark/data/enemies/enemy_types.json b/light_and_dark/data/enemies/enemy_types.json
index b8696b5..3201e71 100644
--- a/light_and_dark/data/enemies/enemy_types.json
+++ b/light_and_dark/data/enemies/enemy_types.json
@@ -25,7 +25,21 @@
"air_speed": 1.4,
"detection_range": 500,
"contact_damage": 3,
- "drop_profile": "boss"
-}
+ "drop_profile": "boss",
+ "behavior": "shockwave",
+ "charger": {
+ "retreat_dist": 150,
+ "retreat_jump_vel": -14.0,
+ "retreat_speed_mult": 1.2,
+ "charge_speed_mult": 4.0,
+ "charge_duration": 1.1,
+ "dash_min_dist": 160,
+ "dash_max_dist": 420,
+ "dash_speed_mult": 4.2,
+ "dash_duration": 0.7,
+ "attack_cooldown": 1.8,
+ "above_y": 40
+ }
+ }
}
diff --git a/light_and_dark/data/sprites/abilities/basic_attack.png b/light_and_dark/data/sprites/abilities/basic_attack.png
new file mode 100644
index 0000000..80ede8f
Binary files /dev/null and b/light_and_dark/data/sprites/abilities/basic_attack.png differ
diff --git a/light_and_dark/data/world/rooms/room_04.json b/light_and_dark/data/world/rooms/room_04.json
index df2dc0d..41d3cd8 100644
--- a/light_and_dark/data/world/rooms/room_04.json
+++ b/light_and_dark/data/world/rooms/room_04.json
@@ -132,7 +132,7 @@
{
"x": 240,
"y": 280,
- "w": 180,
+ "w": 160,
"h": 20,
"group": "moving",
"layer": 0,
@@ -213,16 +213,54 @@
"y": 620,
"w": 60,
"h": 20,
- "group": "ground",
- "layer": 0
+ "group": "moving",
+ "layer": 0,
+ "path": [
+ [
+ 720,
+ 620
+ ],
+ [
+ 720,
+ 540
+ ]
+ ]
},
{
"x": 820,
"y": 540,
"w": 60,
"h": 20,
- "group": "ground",
- "layer": 0
+ "group": "moving",
+ "layer": 0,
+ "path": [
+ [
+ 820,
+ 540
+ ],
+ [
+ 820,
+ 600
+ ]
+ ]
+ },
+ {
+ "x": 1040,
+ "y": 580,
+ "w": 100,
+ "h": 20,
+ "group": "moving",
+ "layer": 0,
+ "path": [
+ [
+ 1040,
+ 580
+ ],
+ [
+ 1040,
+ 480
+ ]
+ ]
}
],
"enemies": [
@@ -230,13 +268,15 @@
"x": 760,
"y": 200,
"type": "enemy",
- "layer": 0
+ "layer": 0,
+ "behavior": ""
},
{
"x": 880,
"y": 640,
"type": "miniboss",
- "layer": 0
+ "layer": 0,
+ "behavior": ""
}
],
"npcs": [],
@@ -246,7 +286,9 @@
"y": 240,
"type": "save_point",
"radius": 90,
- "object_id": "lamp"
+ "object_id": "lamp",
+ "layer": 0,
+ "quest_id": ""
}
],
"exits": [
@@ -304,5 +346,6 @@
}
],
"room_w": 1600,
- "room_h": 900
+ "room_h": 900,
+ "mist": false
}
\ No newline at end of file
diff --git a/light_and_dark/data/world/rooms/room_05.json b/light_and_dark/data/world/rooms/room_05.json
index b2c6dc5..8a60914 100644
--- a/light_and_dark/data/world/rooms/room_05.json
+++ b/light_and_dark/data/world/rooms/room_05.json
@@ -92,6 +92,30 @@
"h": 340,
"group": "ground",
"layer": 0
+ },
+ {
+ "x": 1180,
+ "y": 440,
+ "w": 1,
+ "h": 1,
+ "group": "ground"
+ },
+ {
+ "x": 1180,
+ "y": 420,
+ "w": 60,
+ "h": 20,
+ "group": "moving",
+ "path": [
+ [
+ 1180,
+ 420
+ ],
+ [
+ 1180,
+ 360
+ ]
+ ]
}
],
"enemies": [
@@ -99,13 +123,15 @@
"x": 520,
"y": 140,
"type": "enemy",
- "layer": 0
+ "layer": 0,
+ "behavior": ""
},
{
"x": 1000,
"y": 460,
"type": "enemy",
- "layer": 0
+ "layer": 0,
+ "behavior": ""
}
],
"npcs": [],
@@ -115,7 +141,9 @@
"y": 360,
"type": "save_point",
"radius": 90,
- "object_id": "lamp"
+ "object_id": "lamp",
+ "layer": 0,
+ "quest_id": ""
}
],
"exits": [
@@ -124,7 +152,7 @@
"y": 80,
"w": 80,
"h": 100,
- "to": "room_06",
+ "to": "room_04",
"spawn_id": "room4_bottom_right",
"spawn": [
1480,
@@ -138,11 +166,11 @@
"y": 400,
"w": 80,
"h": 100,
- "to": "",
- "spawn_id": "",
+ "to": "room_06",
+ "spawn_id": "room6_top_right",
"spawn": [
- 1440,
- 400
+ 520,
+ 240
],
"hint": "",
"layer": 0
diff --git a/light_and_dark/data/world/rooms/room_06.json b/light_and_dark/data/world/rooms/room_06.json
index 25c3b9b..f93fa00 100644
--- a/light_and_dark/data/world/rooms/room_06.json
+++ b/light_and_dark/data/world/rooms/room_06.json
@@ -22,42 +22,48 @@
"y": 300,
"w": 660,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 420,
"y": 180,
"w": 240,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 640,
"y": 200,
"w": 20,
"h": 100,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 420,
"y": 0,
"w": 20,
"h": 180,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 0,
"y": 0,
"w": 420,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 0,
"y": 20,
"w": 20,
"h": 280,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
}
],
"enemies": [],
@@ -69,6 +75,7 @@
"type": "quest_giver",
"radius": 180,
"object_id": "quest_point",
+ "layer": 0,
"quest_id": "first_kills_shade"
}
],
@@ -83,10 +90,13 @@
"spawn": [
120,
620
- ]
+ ],
+ "hint": "",
+ "layer": 0
}
],
"ability_doors": [],
"room_w": 1600,
- "room_h": 900
+ "room_h": 900,
+ "mist": true
}
\ No newline at end of file
diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html
index 56fc0ee..ac72bfe 100644
--- a/light_and_dark/level_editor_v43.html
+++ b/light_and_dark/level_editor_v43.html
@@ -88,9 +88,10 @@
- Downloads are per-room. Checked download will save one file per checked room.
+ Downloads are per-room. Checked download saves one file per checked room; Download All saves every loaded room.
@@ -278,7 +279,14 @@
const DEFAULT_ENEMY_TYPES = {
enemy: { rect: [40, 60], health: 3, speed: 2.2, air_speed: 2.0, detection_range: 240, contact_damage: 1, drop_profile: "normal" },
miniboss: { rect: [70, 95], health: 18, speed: 1.8, air_speed: 1.6, detection_range: 360, contact_damage: 2, drop_profile: "miniboss" },
- boss: { rect: [110, 120], health: 10, speed: 1.6, air_speed: 1.4, detection_range: 500, contact_damage: 3, drop_profile: "boss" },
+ boss: {
+ rect: [110, 120], health: 10, speed: 1.6, air_speed: 1.4, detection_range: 500, contact_damage: 3, drop_profile: "boss", behavior: "shockwave",
+ charger: {
+ retreat_dist: 150, retreat_jump_vel: -14.0, retreat_speed_mult: 1.2, charge_speed_mult: 4.0,
+ charge_duration: 1.1, dash_min_dist: 160, dash_max_dist: 420, dash_speed_mult: 4.2,
+ dash_duration: 0.7, attack_cooldown: 1.8, above_y: 40
+ }
+ },
};
function seedDefaultObjectCatalog(){
@@ -435,9 +443,9 @@
for(const e of src.enemies){
if(e && typeof e === "object"){
if(Array.isArray(e.pos)){
- r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy", layer: +(e.layer||0) });
+ r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy", layer: +(e.layer||0), behavior: e.behavior || "" });
} else if(typeof e.x === "number" || typeof e.y === "number"){
- r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy", layer: +(e.layer||0) });
+ r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy", layer: +(e.layer||0), behavior: e.behavior || "" });
}
}
}
@@ -524,7 +532,7 @@
// ----- keep other top-level fields (future-proof) -----
r._extras = {};
if(src && typeof src === "object"){
- const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","npcs","exits","ability_doors","entry_spawns","props","name","display_name","room_w","room_h"]);
+ const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","npcs","exits","ability_doors","entry_spawns","props","name","display_name","room_w","room_h","mist"]);
for(const k of Object.keys(src)){
if(!keep.has(k)) r._extras[k] = deepClone(src[k]);
}
@@ -533,6 +541,7 @@
// some editor fields for convenience
if(!r.room_w) r.room_w = src.room_w || 1600;
if(!r.room_h) r.room_h = src.room_h || 900;
+ r.mist = !!(src && src.mist);
return r;
}
@@ -573,6 +582,7 @@
newRoomBtn: document.getElementById("newRoomBtn"),
downloadActiveBtn: document.getElementById("downloadActiveBtn"),
downloadCheckedBtn: document.getElementById("downloadCheckedBtn"),
+ downloadAllBtn: document.getElementById("downloadAllBtn"),
activeRoomLabel: document.getElementById("activeRoomLabel"),
status: document.getElementById("status"),
@@ -2121,7 +2131,15 @@ Cancel = Choose whether to remove without saving`);
const roomWrap = sel ? state.rooms.get(sel.roomId) : getActiveRoom();
if(!roomWrap) return "No room loaded.
";
const room = roomWrap.data;
- if(!sel) return "Select something to edit properties.
";
+ if(!sel){
+ return `
+ Selection missing.
";
+ const eType = e.type || "enemy";
+ const eDef = state.enemyTypes && state.enemyTypes[eType] ? state.enemyTypes[eType] : null;
+ const eProf = (eDef && eDef.drop_profile) ? eDef.drop_profile : "normal";
+ const beh = e.behavior || ((eDef && eDef.behavior) ? eDef.behavior : "chaser");
+ const bossBehavior = (eProf === "boss");
return `
layer (lower = back)
@@ -2371,14 +2404,18 @@ Cancel = Choose whether to remove without saving`);
function num(v){ const n = Number(v); return Number.isFinite(n) ? n : 0; }
- if(!sel) return;
-
const pushAnd = (fn)=>{
pushHistory(roomWrap.id);
fn();
refreshUI();
};
+ if(!sel){
+ const rm = q("roommist");
+ if(rm) rm.addEventListener("change", ()=>pushAnd(()=>{ room.mist = rm.checked; }));
+ return;
+ }
+
if(sel.kind==="platform"){
const p = room.platforms[sel.index]; if(!p) return;
["px","py","pw","ph"].forEach(id=>{
@@ -2511,11 +2548,15 @@ Cancel = Choose whether to remove without saving`);
}));
} else if(sel.kind==="enemy"){
const e = room.enemies[sel.index]; if(!e) return;
- const enx=q("enx"), eny=q("eny"), ent=q("ent"), enlayer=q("enlayer");
+ const enx=q("enx"), eny=q("eny"), ent=q("ent"), enlayer=q("enlayer"), enbeh=q("enbeh");
if(enx) enx.addEventListener("change", ()=>pushAnd(()=>{ e.x=num(enx.value); }));
if(eny) eny.addEventListener("change", ()=>pushAnd(()=>{ e.y=num(eny.value); }));
if(ent) ent.addEventListener("change", ()=>pushAnd(()=>{ e.type=ent.value.trim()||"enemy"; }));
if(enlayer) enlayer.addEventListener("change", ()=>pushAnd(()=>{ e.layer = Math.round(num(enlayer.value)); }));
+ if(enbeh) enbeh.addEventListener("change", ()=>pushAnd(()=>{
+ const v = enbeh.value;
+ if(v) e.behavior = v; else delete e.behavior;
+ }));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.enemies.splice(sel.index,1);
state.selection=null;
@@ -3338,6 +3379,7 @@ Cancel = Choose whether to remove without saving`);
const enemies = (d.enemies || []).map(e => {
const out = { type: e.type || "enemy", pos: [e.x, e.y] };
if(e.layer) out.layer = e.layer;
+ if(e.behavior) out.behavior = e.behavior;
return out;
});
@@ -3400,6 +3442,7 @@ Cancel = Choose whether to remove without saving`);
// include entry spawns (new feature) for your spawn linking
if(d.entry_spawns && typeof d.entry_spawns === "object") out.entry_spawns = d.entry_spawns;
if(props.length) out.props = props;
+ if(d.mist) out.mist = true;
// carry over any extra keys that were in the original
if(d._extras && typeof d._extras === "object"){
@@ -3757,6 +3800,20 @@ els.compareRoomSelect.addEventListener("change", ()=>{
setStatus(`Downloaded ${i} checked room(s).`);
});
+ els.downloadAllBtn.addEventListener("click", async ()=>{
+ const rooms = [...state.rooms.values()];
+ if(!rooms.length){ setStatus("No rooms loaded."); return; }
+ let i=0;
+ for(const r of rooms){
+ downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
+ r.dirty = false;
+ i++;
+ await new Promise(res=>setTimeout(res, 250));
+ }
+ refreshRoomsList();
+ setStatus(`Downloaded ${i} room(s).`);
+ });
+
// canvas pointer handlers
// middle-mouse pan (and prevent browser autoscroll / sidebar scrolling)
function startMousePan(e, canvasKey){
diff --git a/light_and_dark/main.py b/light_and_dark/main.py
index 42371ea..a8993fb 100644
--- a/light_and_dark/main.py
+++ b/light_and_dark/main.py
@@ -438,6 +438,7 @@ JUMP_LIGHT = float(_phys(["player", "jump_light"], -12.0))
JUMP_SHADE = float(_phys(["player", "jump_shade"], -9.0))
GRAVITY_LIGHT = float(_phys(["player", "gravity_light"], 0.5))
GRAVITY_SHADE = float(_phys(["player", "gravity_shade"], 0.4))
+JUMP_RELEASE_GRAVITY_MULT = float(_phys(["player", "jump_release_gravity_mult"], 3.0))
SHADE_DOOR_GRACE = float(_phys(["player", "shade_door_grace"], 0.6))
# Sprite offsets (tune to align art with hitbox)
@@ -450,6 +451,7 @@ PLAYER_HITBOX_H = int(_cfg(["player", "hitbox_h"], 72))
BASE_MAX_CHARGE = float(_phys(["resources", "base_max_charge"], 100.0))
CHARGE_DRAIN_RATE = float(_phys(["resources", "charge_drain_rate"], 40.0))
CHARGE_REFILL_RATE = float(_phys(["resources", "charge_refill_rate"], 10.0))
+CHARGE_EMPTY_LOCK = float(_phys(["resources", "charge_empty_lock"], 1.5))
BASE_MAX_HEALTH = int(_phys(["resources", "base_max_health"], 10))
INVINCIBILITY_DURATION = float(_phys(["resources", "invincibility_duration"], 1.0))
@@ -558,6 +560,37 @@ VIGNETTE_LIGHT_EDGE = build_vignette_surface(WIDTH, HEIGHT, (0, 0, 0), edge_alph
VIGNETTE_SHADE = build_vignette_surface(WIDTH, HEIGHT, (90, 30, 130), edge_alpha=120, radius_scale=1.0, base_alpha=85, min_alpha=0.05)
VIGNETTE_SHADE_EDGE = build_vignette_surface(WIDTH, HEIGHT, (90, 30, 130), edge_alpha=120, radius_scale=1.0, base_alpha=0, min_alpha=0.0)
+# --- Mist overlay (per-room environmental haze; tiles seamlessly) ---
+MIST_TILE_SIZE = 320
+
+def _build_mist_tile(size: int = MIST_TILE_SIZE) -> pygame.Surface:
+ surf = pygame.Surface((size, size), pygame.SRCALPHA)
+ rng = random.Random(1337)
+ surf.fill((80, 25, 130, 6))
+ for _ in range(14):
+ cx = rng.randint(0, size)
+ cy = rng.randint(0, size)
+ r = rng.randint(50, 130)
+ alpha = rng.randint(8, 20)
+ color = (96, 30, 150, 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()
+
+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)
+ 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))
+
VIGNETTE_CLEAR_RADIUS = 150
_VIGNETTE_HOLE_CACHE: dict[tuple[int, int], pygame.Surface] = {}
@@ -820,6 +853,20 @@ ENEMY_TYPES_DEFAULT = {
"detection_range": 520,
"contact_damage": 3,
"drop_profile": "boss",
+ "behavior": "shockwave",
+ "charger": {
+ "retreat_dist": 150,
+ "retreat_speed_mult": 1.2,
+ "retreat_jump_vel": -14.0,
+ "charge_speed_mult": 4.0,
+ "charge_duration": 1.1,
+ "dash_min_dist": 160,
+ "dash_max_dist": 420,
+ "dash_speed_mult": 4.2,
+ "dash_duration": 0.7,
+ "attack_cooldown": 1.8,
+ "above_retreat": True
+ }
},
}
@@ -1135,6 +1182,14 @@ class Room:
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
+ 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)
+ else:
+ self.min_x = 0
+ self.max_x = 2000
+
self.enemies = room_json.get("enemies", []) if isinstance(room_json, dict) else []
if not isinstance(self.enemies, list):
self.enemies = []
@@ -1157,6 +1212,8 @@ class Room:
self.coin_drops_cfg = room_json.get("coin_drops", None) if isinstance(room_json, dict) else None
+ self.mist = bool(room_json.get("mist", False)) if isinstance(room_json, dict) else False
+
self.ability_doors = room_json.get("ability_doors", []) if isinstance(room_json, dict) else []
if not isinstance(self.ability_doors, list):
self.ability_doors = []
@@ -1275,6 +1332,7 @@ TETHER_SPRITE_LIGHT_PATH = ABILITY_SPRITES_DIR / "Tether_light_ability.png"
TETHER_SPRITE_DARK_PATH = ABILITY_SPRITES_DIR / "Tether_dark_ability.png"
TETHER_EDGE_LIGHT_PATH = ABILITY_SPRITES_DIR / "Tether_light_ability_edge.png"
TETHER_EDGE_DARK_PATH = ABILITY_SPRITES_DIR / "Tether_dark_ability_edge.png"
+ATTACK_SPRITE_PATH = ABILITY_SPRITES_DIR / "basic_attack.png"
def _load_image_or_none(p: Path):
try:
@@ -1295,6 +1353,7 @@ TETHER_SPRITE_LIGHT_RAW = _load_image_or_none(TETHER_SPRITE_LIGHT_PATH)
TETHER_SPRITE_DARK_RAW = _load_image_or_none(TETHER_SPRITE_DARK_PATH)
TETHER_EDGE_LIGHT_RAW = _load_image_or_none(TETHER_EDGE_LIGHT_PATH)
TETHER_EDGE_DARK_RAW = _load_image_or_none(TETHER_EDGE_DARK_PATH)
+ATTACK_SPRITE_RAW = _load_image_or_none(ATTACK_SPRITE_PATH)
def _load_first_existing(names: list[Path]):
@@ -1418,17 +1477,20 @@ class Shockwave:
class Projectile:
- def __init__(self, x, y, direction: int, damage: float):
+ def __init__(self, x, y, dx: float, dy: float, damage: float):
w, h = PROJECTILE_SIZE
self.rect = pygame.Rect(int(x), int(y), int(w), int(h))
- self.dir = 1 if direction >= 0 else -1
+ self.dir = 1 if dx >= 0 else -1
+ self.dir_x = float(dx)
+ self.dir_y = float(dy)
self.speed = float(PROJECTILE_SPEED)
self.t = float(PROJECTILE_LIFETIME)
self.damage = float(damage)
def update(self, dt: float, platforms: list[pygame.Rect], enemies: list, doors=None):
# move
- self.rect.x += int(self.dir * self.speed * dt)
+ self.rect.x += int(self.dir_x * self.speed * dt)
+ self.rect.y += int(self.dir_y * self.speed * dt)
self.t -= dt
if self.t <= 0:
@@ -3288,6 +3350,7 @@ class Player:
self.vel_y = 0.0
self.on_ground = False
self.ground_platform = None
+ self.mist_active = False
self.shade = False
self.shade_door_grace = 0.0 # grace period to pass shade doors after a form switch
@@ -3346,7 +3409,9 @@ class Player:
self.attack_duration = 0.2
self.attack_cd = 0.0
self.facing_right = True
- self.damage_applied = False
+ self.attack_dir_x = 1.0
+ self.attack_dir_y = 0.0
+ self.swing_hit_ids = set()
self.dash_cd = 0.0
self.dash_timer = 0.0
self.dash_dir = 1
@@ -3357,6 +3422,7 @@ class Player:
self.jump_held_last = False
# --- charge regen lockout (prevents instant refill after attacking) ---
self.charge_regen_lock = 0.0
+ self.charge_empty_lock = 0.0
self.charge_drain_mult = 1.0 # set per-frame (e.g. 2x when shade-clipping an enemy)
self.poison_timer = 0.0
self.poison_acc = 0.0
@@ -3595,7 +3661,7 @@ class Player:
else:
if self.charge < DASH_LIGHT_CHARGE_COST:
return False
- self.charge -= DASH_LIGHT_CHARGE_COST
+ self._spend_charge(DASH_LIGHT_CHARGE_COST)
self.charge_regen_lock = max(self.charge_regen_lock, 0.35)
self.dash_timer = DASH_DURATION
@@ -3633,18 +3699,41 @@ class Player:
self.shade = not self.shade
self.shade_door_grace = SHADE_DOOR_GRACE
+ def set_attack_dir(self, camera_x: float, camera_y: float):
+ """Aim the next melee/ranged attack at the mouse cursor (world space)."""
+ mx, my = pygame.mouse.get_pos()
+ dx = (mx + camera_x) - self.rect.centerx
+ dy = (my + camera_y) - self.rect.centery
+ length = math.hypot(dx, dy)
+ if length < 1.0:
+ dx, dy = 1.0, 0.0
+ length = 1.0
+ self.attack_dir_x = dx / length
+ self.attack_dir_y = dy / length
+
+ def _spend_charge(self, amount: float):
+ """Drain charge; hitting exactly 0 starts a regen pause."""
+ self.charge -= amount
+ if self.charge <= 0.0:
+ self.charge = 0.0
+ self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK)
+
def attack(self):
if self.healing:
return
if self.charge >= 1 and not self.attacking and self.attack_cd <= 0.0:
self.attacking = True
self.attack_timer = self.effective_attack_duration()
- self.charge -= 1
- self.damage_applied = False
+ self._spend_charge(1)
+ self.swing_hit_ids.clear()
# lock regen for a bit (tweak this number to taste)
self.charge_regen_lock = max(self.charge_regen_lock, 0.35)
+ # face the aim direction so the sprite follows the mouse
+ if abs(self.attack_dir_x) >= 0.01:
+ self.facing_right = self.attack_dir_x > 0
+
def try_ranged_attack(self, projectiles_list: list, enemies_list: list, platforms_list: list):
if not self.has_ranged:
return
@@ -3656,18 +3745,17 @@ class Player:
return
# ranged costs more than melee
- self.charge -= RANGED_CHARGE_COST
+ self._spend_charge(RANGED_CHARGE_COST)
# 50% of melee attack damage (supports 0.5 now because enemy health is float)
melee_dmg = 2.0 if self.shade else 1.0
dmg = melee_dmg * RANGED_DAMAGE_MULT
- # spawn from roughly chest height, slightly in front
- px = self.rect.centerx + (16 if self.facing_right else -16)
- py = self.rect.centery - 6
+ # spawn from roughly chest height, offset toward the aim direction
+ px = self.rect.centerx + int(self.attack_dir_x * 16)
+ py = self.rect.centery - 6 + int(self.attack_dir_y * 10)
- direction = 1 if self.facing_right else -1
- projectiles_list.append(Projectile(px, py, direction, dmg))
+ projectiles_list.append(Projectile(px, py, self.attack_dir_x, self.attack_dir_y, dmg))
# cooldown + small regen lock so it doesn't instantly refill
self.ranged_cd = RANGED_COOLDOWN
@@ -3681,37 +3769,31 @@ class Player:
return boxes[0] if boxes else None
def get_attack_hitboxes(self):
- """Wide-at-player, narrow-outward 'inverse cone' melee hitbox.
-
- The near segment overlaps ~30% into the player's body so enemies you're
- hugging (common in shade) still get hit; the far tip keeps its height so
- it reliably connects at max reach.
- """
+ """Melee hitbox built from a strip of small boxes along the aim
+ direction, so attacks can fire in any direction (mouse-aimed)."""
if not self.attacking:
return []
body_overlap = int(self.rect.width * 0.3) # ~30% into the body
+ half_w = self.rect.width * 0.5
- # Near (wide) segment
- w1 = 28 # wider close to player
- h1 = int(self.rect.height * 0.65)
- y1 = self.rect.centery - h1 // 2
+ # reach from just inside the body out to near width + far tip
+ start = half_w - body_overlap
+ end = half_w + 28 + 18
+ n = 6
+ step = (end - start) / n
- # Far (tip) segment: extends reach, slightly narrower but same height
- w2 = 18
- h2 = h1
- y2 = self.rect.centery - h2 // 2
+ # square cells keep the hitbox thickness consistent (~18px) in any
+ # aim direction; axis-aligned squares never get thinner than 18px
+ cross = 18
- if self.facing_right:
- x1 = self.rect.right - body_overlap
- x2 = self.rect.right + w1
- else:
- x1 = self.rect.left - w1
- x2 = self.rect.left - w1 - w2
-
- near = pygame.Rect(int(x1), int(y1), int(w1 + body_overlap), int(h1))
- far = pygame.Rect(int(x2), int(y2), int(w2), int(h2))
- return [near, far]
+ boxes = []
+ for i in range(n):
+ t = start + (i + 0.5) * step
+ cx = self.rect.centerx + self.attack_dir_x * t
+ cy = self.rect.centery + self.attack_dir_y * t
+ boxes.append(pygame.Rect(int(cx - cross // 2), int(cy - cross // 2), cross, cross))
+ return boxes
def take_damage(self, amount) -> bool:
@@ -3778,6 +3860,9 @@ class Player:
self.attacking = False
self.attack_timer = 0.0
self.attack_cd = 0.0
+ self.swing_hit_ids.clear()
+ self.attack_dir_x = 1.0
+ self.attack_dir_y = 0.0
self.poison_timer = 0.0
self.poison_acc = 0.0
self.invincibility_timer = 0.0
@@ -3785,6 +3870,8 @@ class Player:
self.health = self.max_health
self.charge = self.max_charge
+ self.charge_regen_lock = 0.0
+ self.charge_empty_lock = 0.0
def consume_butterfly_jar(self) -> bool:
"""Consume one butterfly jar to instantly refill charge. Returns True if consumed."""
@@ -3793,6 +3880,7 @@ class Player:
self.butterfly_jars -= 1
self.charge = self.max_charge
self.charge_regen_lock = 0.0
+ self.charge_empty_lock = 0.0
return True
def _start_heal_if_possible(self, keys):
@@ -3820,7 +3908,10 @@ class Player:
return
drain = self.heal_drain_rate() * dt
+ prev_charge = self.charge
self.charge = max(0.0, self.charge - drain)
+ if prev_charge > 0.0 and self.charge <= 0.0:
+ self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK)
self.heal_progress += dt
dur = self.effective_heal_duration()
@@ -3852,6 +3943,8 @@ class Player:
if self.charge_regen_lock > 0:
self.charge_regen_lock = max(0.0, self.charge_regen_lock - dt)
+ if self.charge_empty_lock > 0:
+ self.charge_empty_lock = max(0.0, self.charge_empty_lock - dt)
if self.dash_cd > 0.0:
self.dash_cd = max(0.0, self.dash_cd - dt)
@@ -3879,10 +3972,11 @@ class Player:
if self.reality_break_flash > 0.0:
self.reality_break_flash = max(0.0, self.reality_break_flash - dt)
- if key_pressed(keys, "move_left"):
- self.facing_right = False
- elif key_pressed(keys, "move_right"):
- self.facing_right = True
+ if not self.attacking:
+ if key_pressed(keys, "move_left"):
+ self.facing_right = False
+ elif key_pressed(keys, "move_right"):
+ self.facing_right = True
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
@@ -3931,6 +4025,8 @@ class Player:
gravity = GRAVITY_SHADE if self.shade else GRAVITY_LIGHT
+ if self.vel_y < 0 and (not key_pressed(keys, "jump")):
+ gravity *= JUMP_RELEASE_GRAVITY_MULT
self.vel_y += gravity
dy = self.vel_y
@@ -3997,14 +4093,18 @@ class Player:
self._update_heal(keys, dt)
if self.shade:
- self.charge -= CHARGE_DRAIN_RATE * getattr(self, "charge_drain_mult", 1.0) * dt
+ prev_charge = self.charge
+ if not self.mist_active:
+ self.charge -= CHARGE_DRAIN_RATE * getattr(self, "charge_drain_mult", 1.0) * dt
if self.charge <= 0:
self.charge = 0
+ if prev_charge > 0:
+ self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK)
self.shade = False
self.shade_door_grace = SHADE_DOOR_GRACE
else:
- # no regen while healing, attacking, or during post-attack lockout
- if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0):
+ # no regen while healing, attacking, or during post-attack/empty lockout
+ if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0) and (self.charge_empty_lock <= 0.0):
regen = CHARGE_REFILL_RATE * (1.0 + self.bonus_charge_regen_pct) * self.charge_regen_mult
self.charge = min(self.max_charge, self.charge + regen * dt)
@@ -4087,6 +4187,15 @@ class Player:
self._draw_fairy(surface, cx, cy, draw_x, draw_y, img.get_width(), img.get_height())
surface.blit(img, (draw_x, draw_y))
+ if self.attacking and ATTACK_SPRITE_RAW is not None:
+ ang = math.degrees(math.atan2(-self.attack_dir_y, self.attack_dir_x))
+ atk_img = pygame.transform.rotate(ATTACK_SPRITE_RAW, ang)
+ off = 40.0
+ sx = self.rect.centerx + self.attack_dir_x * off
+ sy = self.rect.centery + self.attack_dir_y * off
+ surface.blit(atk_img, (int(sx - atk_img.get_width() / 2 - cx),
+ int(sy - atk_img.get_height() / 2 - cy)))
+
# Debug: show player hitbox
pygame.draw.rect(
surface,
@@ -4098,7 +4207,7 @@ class Player:
if self.attacking:
for atk in self.get_attack_hitboxes():
pygame.draw.rect(surface, (255, 0, 0),
- pygame.Rect(atk.x - cx, atk.y - cy, atk.width, atk.height))
+ pygame.Rect(atk.x - cx, atk.y - cy, atk.width, atk.height), 1)
@@ -4106,7 +4215,7 @@ class Player:
# Enemy (configured via JSON) + Boss slam
# ----------------------------
class Enemy:
- def __init__(self, x, y, type_id: str):
+ def __init__(self, x, y, type_id: str, behavior: str | None = None):
self.type_id = type_id
cfg = ENEMY_TYPES.get(type_id, ENEMY_TYPES.get("enemy", {}))
if not isinstance(cfg, dict):
@@ -4149,6 +4258,9 @@ class Enemy:
self.contact_damage = int(cfg.get("contact_damage", 1))
self.drop_profile = str(cfg.get("drop_profile", "normal"))
+ # Behavior dispatch (chaser = default patrol/chase; shockwave = slam boss; charger = retreat-charge boss)
+ self.behavior = str(behavior or cfg.get("behavior", "chaser"))
+
# Boss slam/shockwave tuning (supports per-enemy overrides)
self.slam_cooldown_base = float(BOSS_SLAM_COOLDOWN)
self.slam_trigger_close = float(BOSS_TRIGGER_CLOSE)
@@ -4180,6 +4292,26 @@ class Enemy:
self.bound_light = True
self.reality_break_glow = False
+ # Charger behavior state (retreat-jump, then charge; or direct dash)
+ self.charge_cfg = charge_cfg = (cfg.get("charger", {}) if isinstance(cfg, dict) else {})
+ if not isinstance(charge_cfg, dict):
+ charge_cfg = {}
+ self.retreat_dist = float(charge_cfg.get("retreat_dist", 150))
+ self.retreat_jump_vel = float(charge_cfg.get("retreat_jump_vel", -14.0))
+ self.retreat_speed_mult = float(charge_cfg.get("retreat_speed_mult", 1.2))
+ self.charge_speed_mult = float(charge_cfg.get("charge_speed_mult", 4.0))
+ self.charge_duration = float(charge_cfg.get("charge_duration", 1.1))
+ self.dash_min_dist = float(charge_cfg.get("dash_min_dist", 160))
+ self.dash_max_dist = float(charge_cfg.get("dash_max_dist", 420))
+ self.dash_speed_mult = float(charge_cfg.get("dash_speed_mult", 4.2))
+ self.dash_duration = float(charge_cfg.get("dash_duration", 0.7))
+ self.attack_cooldown = float(charge_cfg.get("attack_cooldown", 1.8))
+ self.charge_above_y = float(charge_cfg.get("above_y", 40))
+ self.charge_state = "idle" # idle | retreating | charging | dashing
+ self.charge_dir = 1
+ self.charge_timer = 0.0
+ self.attack_cd = 0.0
+
# --- anti-jiggle helpers ---
self.turn_cooldown = 0.0 # prevents rapid flip-flop at edges/walls
self.last_x = self.rect.x
@@ -4224,6 +4356,9 @@ class Enemy:
self.jump_cd_timer = 0.0
self.slam_cd = 0.0
self.slam_primed = False
+ self.charge_state = "idle"
+ self.charge_timer = 0.0
+ self.attack_cd = 0.0
self.turn_cooldown = 0.0
self.last_x = self.rect.x
@@ -4255,6 +4390,19 @@ class Enemy:
x = self.rect.left - lookahead
return x >= p.left
+ def support_below(self, platforms, max_dist):
+ """Return the nearest platform strictly below the feet within max_dist (or None)."""
+ probe_y = self.rect.bottom + 1
+ best = None
+ best_top = None
+ for p in platforms:
+ if p.top >= probe_y and p.left < self.rect.centerx < p.right:
+ if p.top - probe_y <= max_dist:
+ if best is None or p.top < best_top:
+ best = p
+ best_top = p.top
+ return best
+
def _probe_ground_now(self, platforms):
"""Refresh on_ground + ground_platform every frame (stable)."""
@@ -4327,6 +4475,99 @@ class Enemy:
return best
+ # --- behavior dispatch (config-driven AI) ---
+
+ def _behavior_tick(self, dt):
+ """Per-behavior cooldown timers."""
+ if self.behavior == "shockwave":
+ if self.slam_cd > 0:
+ self.slam_cd -= dt
+ elif self.behavior == "charger":
+ if self.attack_cd > 0:
+ self.attack_cd -= dt
+
+ def _behavior_update(self, dt, player, distance, dx, stunned):
+ """Choose/advance an attack per behavior. Runs before gravity/move."""
+ if self.behavior == "shockwave":
+ self._shockwave_update(player, distance, stunned)
+ elif self.behavior == "charger":
+ self._charger_update(dt, player, distance, dx, stunned)
+
+ def _shockwave_update(self, player, distance, stunned):
+ # Slam when the player is close (anti-stay) OR far (anti-cheese).
+ if (not stunned) and (not player.shade):
+ dist = abs(distance)
+ should_slam = (dist <= self.slam_trigger_close) or (dist >= self.slam_trigger_far)
+ if self.on_ground and self.slam_cd <= 0 and should_slam and (not self.slam_primed):
+ self.vel_y = self.slam_jump_vel
+ self.on_ground = False
+ self.ground_platform = None
+ self.slam_primed = True
+ self.slam_cd = self.slam_cooldown_base
+
+ def _charger_update(self, dt, player, distance, dx, stunned):
+ # State machine: idle -> retreat -> charge, or idle -> dash.
+ if stunned:
+ self.charge_state = "idle"
+ self.charge_timer = 0.0
+ return
+
+ if self.charge_state == "idle":
+ if not self.on_ground or self.attack_cd > 0 or player.shade:
+ return
+ dist = abs(distance)
+ player_above = player.rect.centery < self.rect.centery - self.charge_above_y
+
+ if dist <= self.retreat_dist or player_above:
+ # Leap away from the player, then charge back on landing.
+ self.charge_dir = -1 if dx > 0 else 1
+ self.direction = self.charge_dir
+ self.vel_y = self.retreat_jump_vel
+ self.on_ground = False
+ self.ground_platform = None
+ self.charge_state = "retreating"
+ elif self.dash_min_dist <= dist <= self.dash_max_dist:
+ # Alternate attack: dash straight at the player, no retreat first.
+ self.charge_dir = 1 if dx >= 0 else -1
+ self.direction = self.charge_dir
+ self.charge_state = "dashing"
+ self.charge_timer = self.dash_duration
+ self.attack_cd = self.attack_cooldown
+
+ elif self.charge_state == "retreating":
+ # Keep facing away until we land; landing flips us to charging.
+ self.direction = self.charge_dir
+
+ elif self.charge_state in ("charging", "dashing"):
+ self.charge_timer -= dt
+ if self.charge_timer <= 0:
+ self.charge_state = "idle"
+ self.attack_cd = self.attack_cooldown
+
+ def _behavior_on_land(self, player, shockwaves_list):
+ if self.behavior == "shockwave":
+ if self.slam_primed:
+ self.slam_primed = False
+ spawn_boss_shockwave(self, shockwaves_list)
+ elif self.behavior == "charger":
+ if self.charge_state == "retreating":
+ self.charge_state = "charging"
+ self.charge_timer = self.charge_duration
+ dx = player.rect.centerx - self.rect.centerx
+ self.charge_dir = 1 if dx >= 0 else -1
+ self.direction = self.charge_dir
+
+ def _behavior_vel_x(self, desired_speed):
+ """Return an override horizontal velocity, or None to use chase speed."""
+ if self.behavior == "charger":
+ if self.charge_state == "retreating":
+ return self.direction * self.speed * self.retreat_speed_mult
+ if self.charge_state == "charging":
+ return self.charge_dir * self.speed * self.charge_speed_mult
+ if self.charge_state == "dashing":
+ return self.charge_dir * self.speed * self.dash_speed_mult
+ return None
+
def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave]):
if self.jump_cd_timer > 0:
self.jump_cd_timer -= dt
@@ -4358,9 +4599,8 @@ class Enemy:
- # Boss slam cooldown tick
- if self.type_id == "boss" and self.slam_cd > 0:
- self.slam_cd -= dt
+ # Behavior cooldown tick (slam for shockwave, attack for charger)
+ self._behavior_tick(dt)
dx = player.rect.centerx - self.rect.centerx
dy = player.rect.centery - self.rect.centery
@@ -4407,16 +4647,8 @@ class Enemy:
stunned = True
- # Trigger boss slam: close OR far
- if (not stunned) and self.type_id == "boss" and (not player.shade):
- dist = abs(distance)
- should_slam = (dist <= self.slam_trigger_close) or (dist >= self.slam_trigger_far)
- if self.on_ground and self.slam_cd <= 0 and should_slam and (not self.slam_primed):
- self.vel_y = self.slam_jump_vel
- self.on_ground = False
- self.ground_platform = None
- self.slam_primed = True
- self.slam_cd = self.slam_cooldown_base
+ # Behavior attack decision (slam for shockwave, retreat-charge/dash for charger)
+ self._behavior_update(dt, player, distance, dx, stunned)
# gravity + vertical move
self.vel_y += 0.5
@@ -4555,10 +4787,9 @@ class Enemy:
self.direction = -1
- # if boss just landed after primed slam -> spawn shockwaves
- if self.type_id == "boss" and self.on_ground and (not was_on_ground) and self.slam_primed:
- self.slam_primed = False
- spawn_boss_shockwave(self, shockwaves_list)
+ # behavior on landing (shockwave slam impact / charger retreat->charge)
+ if self.on_ground and (not was_on_ground):
+ self._behavior_on_land(player, shockwaves_list)
if stunned:
self.vel_x = 0.0
@@ -4584,7 +4815,8 @@ class Enemy:
desired_speed *= TETHER_FEAR_SPEED_MULT
# --- PATROL / CHASE INTENT ---
- if chasing:
+ charg_acting = (self.behavior == "charger" and self.charge_state != "idle")
+ if chasing and not charg_acting:
cdx = player.rect.centerx - self.rect.centerx
if cdx < -CHASE_DEADZONE:
self.direction = -1
@@ -4594,7 +4826,11 @@ class Enemy:
# aggro just broke near an edge: keep walking off naturally
self.direction = self.stepoff_dir
- self.vel_x = self.direction * desired_speed
+ override_vx = self._behavior_vel_x(desired_speed)
+ if override_vx is not None:
+ self.vel_x = override_vx
+ else:
+ self.vel_x = self.direction * desired_speed
# --- CHASE LEDGE DROP ---
@@ -4614,7 +4850,7 @@ class Enemy:
)
# Jump small gaps if a safe platform exists on the other side.
- if at_edge and (not player_below) and self.jump_cd_timer <= 0:
+ if at_edge and (not player_below) and self.jump_cd_timer <= 0 and (not charg_acting):
jump_target = self.platform_ahead_for_jump(platforms)
if jump_target is not None:
self.vel_y = self.jump_strength
@@ -4623,15 +4859,22 @@ class Enemy:
self.jump_cd_timer = self.jump_cooldown
if player_below and at_edge:
- # cleanly step off + fall
- # ignore THIS platform for a short time so we don't "snap" back into it
- self.drop_ignore_platform = gp
- self.drop_ignore_timer = max(self.drop_ignore_timer, 0.35)
+ # Only step off if there's actually a landing platform below;
+ # don't chase the player off into the void.
+ if self.support_below(platforms, 500) is not None:
+ # cleanly step off + fall
+ # ignore THIS platform for a short time so we don't "snap" back into it
+ self.drop_ignore_platform = gp
+ self.drop_ignore_timer = max(self.drop_ignore_timer, 0.35)
- self.on_ground = False
- self.ground_platform = None
- self.vel_y = max(self.vel_y, 2.0)
- self.turn_cooldown = max(self.turn_cooldown, 0.15)
+ self.on_ground = False
+ self.ground_platform = None
+ self.vel_y = max(self.vel_y, 2.0)
+ self.turn_cooldown = max(self.turn_cooldown, 0.15)
+ else:
+ # No landing below -> turn around at the edge instead.
+ self.direction *= -1
+ self.turn_cooldown = max(self.turn_cooldown, 0.2)
# --- apply horizontal movement ---
@@ -4657,7 +4900,15 @@ class Enemy:
if blocked_by_wall and self.on_ground:
- if chasing:
+ if charg_acting:
+ # Charger slammed into a wall -> end the charge/dash.
+ if self.charge_state in ("charging", "dashing"):
+ self.charge_state = "idle"
+ self.charge_timer = 0.0
+ self.attack_cd = self.attack_cooldown
+ self.rect.x = old_x
+ self.vel_x = 0.0
+ elif chasing:
# chasing: stop and don't vibrate into the wall
self.rect.x = old_x
self.vel_x = 0.0
@@ -4672,9 +4923,25 @@ class Enemy:
self.vel_x = self.direction * desired_speed
+ # Room horizontal bounds safety net: never let an enemy leave the room.
+ lo = getattr(ROOM, "min_x", None)
+ hi = getattr(ROOM, "max_x", None)
+ if lo is not None and hi is not None:
+ if self.rect.left < lo:
+ self.rect.left = lo
+ self.direction = 1
+ if self.vel_x < 0:
+ self.vel_x = abs(self.vel_x)
+ elif self.rect.right > hi:
+ self.rect.right = hi
+ self.direction = -1
+ if self.vel_x > 0:
+ self.vel_x = -abs(self.vel_x)
- # regular enemy jump helper
- if self.on_ground and chasing and self.jump_cd_timer <= 0:
+
+
+ # 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):
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
@@ -4694,9 +4961,9 @@ class Enemy:
def draw(self, surface, cx, cy):
- if self.type_id == "boss":
+ if self.drop_profile == "boss":
color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR
- elif self.type_id == "miniboss":
+ elif self.drop_profile == "miniboss":
color = MINIBOSS_HIT_COLOR if self.hit_timer > 0 else MINIBOSS_COLOR
else:
color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR
@@ -4756,9 +5023,11 @@ def spawn_enemies_from_room(room: Room):
if not isinstance(ent, dict):
continue
t = str(ent.get("type", "enemy"))
- if t == "boss" and is_boss_defeated(room.id):
+ e_cfg = ENEMY_TYPES.get(t, ENEMY_TYPES.get("enemy", {}))
+ e_prof = str(e_cfg.get("drop_profile", "normal")) if isinstance(e_cfg, dict) else "normal"
+ if e_prof == "boss" and is_boss_defeated(room.id):
continue
- if t == "miniboss" and room.id in DEFEATED_MINIBOSSES:
+ if e_prof == "miniboss" and room.id in DEFEATED_MINIBOSSES:
continue
pos = ent.get("pos", None)
if not (isinstance(pos, (list, tuple)) and len(pos) == 2):
@@ -4772,7 +5041,8 @@ def spawn_enemies_from_room(room: Room):
w, h = cfg.get("rect", [40, 60])
x0 = int(pos[0] - (w / 2))
y0 = int(pos[1] - (h / 2))
- enemy = Enemy(x0, y0, t)
+ behavior_override = ent.get("behavior", None)
+ enemy = Enemy(x0, y0, t, behavior=behavior_override)
enemy.layer = int(ent.get("layer", 0))
out.append(enemy)
return out
@@ -5013,13 +5283,27 @@ def trigger_wall_blocking(rect) -> bool:
return present
+def enemy_trigger_wall_blocking(rect) -> bool:
+ """Trigger walls that block enemies.
+
+ Arena walls (open_on_miniboss_dead) always contain enemies while their
+ miniboss is alive — the player's lock_when_pass gate is ignored for them,
+ so a miniboss can't roam out of its arena before the player locks it in.
+ All other walls follow the same rules as for the player.
+ """
+ meta = platform_meta.get(id(rect), {})
+ if meta.get("open_on_miniboss_dead"):
+ return miniboss_alive_in_room()
+ return trigger_wall_blocking(rect)
+
+
# Lock-on-pass trigger walls (per-session, keyed by rect id).
LOCKED_TRIGGER_WALLS: set[int] = set()
TRIGGER_WALL_TOUCH: dict[int, bool] = {}
def miniboss_alive_in_room() -> bool:
- return any(getattr(en, "type_id", "") == "miniboss" and en.health > 0 for en in enemies)
+ return any(getattr(en, "drop_profile", "") == "miniboss" and en.health > 0 for en in enemies)
def push_rect_out_of_rects(rect, rects):
@@ -5058,8 +5342,35 @@ def reset_trigger_wall_locks():
prev_moving_pos: dict[int, tuple[int, int]] = {}
+def moving_platform_blocked(rect: pygame.Rect, new_x: int, new_y: int) -> bool:
+ """True if a moving platform would clip into the player or an enemy.
+
+ A player genuinely riding on top of the platform (grounded, feet on its top,
+ overlapping horizontally) is carried along and does NOT block. Everything
+ else in the platform's way — a player standing underneath it, or any enemy —
+ stops the platform until the path is clear again."""
+ candidate = pygame.Rect(new_x, new_y, rect.width, rect.height)
+ if candidate.colliderect(player.rect):
+ riding = (
+ player.on_ground
+ and 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 not riding:
+ return True
+ for en in enemies:
+ if candidate.colliderect(en.rect):
+ return True
+ return False
+
+
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
+ from the same spot once the path is clear again."""
for rect in platforms_moving:
mdata = platform_meta.get(id(rect))
if not mdata:
@@ -5089,12 +5400,16 @@ def update_moving_platforms(dt: float):
remaining -= to_end
t = 0.0
i = (i + 1) % n
- mdata["i"] = i
- mdata["t"] = t
x0, y0 = path[i]
x1, y1 = path[(i + 1) % n]
- rect.x = int(round(x0 + (x1 - x0) * t))
- rect.y = int(round(y0 + (y1 - y0) * t))
+ new_x = int(round(x0 + (x1 - x0) * t))
+ new_y = int(round(y0 + (y1 - y0) * t))
+ if moving_platform_blocked(rect, new_x, new_y):
+ continue # wait until the way is clear (keeps current i/t)
+ mdata["i"] = i
+ mdata["t"] = t
+ rect.x = new_x
+ rect.y = new_y
def draw_layerable(surface, cx, cy):
@@ -5392,6 +5707,8 @@ def draw_settings_menu(surface, selected_idx: int, waiting_action: str | None):
txt = font.render(line, True, col)
surface.blit(txt, (x + 10, start_y + (i - start_idx) * line_h))
+ mouse_note = font.render("Attack: Left Click Ranged: Right Click", True, (170, 170, 170))
+ surface.blit(mouse_note, (x + 10, y + panel_h - 42))
hint = font.render("Up/Down = select Enter = rebind Esc = back", True, (170, 170, 170))
surface.blit(hint, (x + 10, y + panel_h - 24))
@@ -5663,6 +5980,7 @@ def apply_room(room: Room, set_player_spawn=True):
ROOM = room
+ player.mist_active = ROOM.mist
KILL_Y = ROOM.kill_y
platforms_ground = ROOM.platforms_ground
platforms_floating = ROOM.platforms_floating
@@ -5716,7 +6034,7 @@ def apply_room(room: Room, set_player_spawn=True):
push_rect_out_of_rects(player.rect, ROOM.platforms)
def boss_alive_in_room() -> bool:
- return any(getattr(en, "type_id", "") == "boss" and en.health > 0 for en in enemies)
+ return any(getattr(en, "drop_profile", "") == "boss" and en.health > 0 for en in enemies)
def reset_room_combat_state():
@@ -6230,11 +6548,17 @@ def start_refight(boss_id: str, variant: str = "normal") -> bool:
apply_room(room, set_player_spawn=True)
if variant == "hard":
for en in enemies:
- if getattr(en, "type_id", "") == "boss":
+ if getattr(en, "drop_profile", "") == "boss":
en.speed *= 2.0
en.air_speed *= 2.0
- en.shockwave_speed *= 2.0
- en.slam_cooldown_base = max(0.25, en.slam_cooldown_base / 2.0)
+ if getattr(en, "behavior", "") == "charger":
+ en.charge_speed_mult *= 1.5
+ en.dash_speed_mult *= 1.5
+ en.retreat_speed_mult *= 1.5
+ en.attack_cooldown = max(0.6, en.attack_cooldown * 0.75)
+ else:
+ en.shockwave_speed *= 2.0
+ en.slam_cooldown_base = max(0.25, en.slam_cooldown_base / 2.0)
return True
@@ -7341,6 +7665,14 @@ 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
@@ -7433,9 +7765,11 @@ while running:
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):
@@ -7507,6 +7841,8 @@ while running:
screen.blit(VIGNETTE_LIGHT, (0, 0))
screen.blit(VIGNETTE_LIGHT_EDGE, (0, 0))
+ draw_mist_overlay(screen)
+
# HUD
draw_shatterstep_icon(screen, player)
draw_tether_icon(screen, player)
@@ -7624,6 +7960,16 @@ while running:
collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] + blocking_triggers
+ # Enemies collide with ground/floating platforms, doors and trigger walls —
+ # but NOT moving platforms. Moving platforms are player traversal; a platform
+ # descending into an enemy shoves it off edges / through the floor.
+ # Arena walls (open_on_miniboss_dead) always contain enemies.
+ moving_ids = {id(r) for r in platforms_moving}
+ enemy_blocking_triggers = [t for t in platforms_trigger if enemy_trigger_wall_blocking(t)]
+ 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
+
# Shade doors are solid for the world, but the player can pass through them
# freely while in shade form — and for a short grace period after switching
# forms, so they aren't instantly ejected from a door they're standing in.
@@ -7774,7 +8120,7 @@ while running:
# Update enemies
for en in enemies:
- en.update(dt, collision_platforms, ground_platforms, player, shockwaves)
+ en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves)
# Enemies that touch a hazard zone die instantly, dropping nothing.
for en in enemies:
@@ -7790,12 +8136,17 @@ while running:
atk_boxes = player.get_attack_hitboxes()
- if atk_boxes and not player.damage_applied:
+ if player.attacking:
dmg = 2 if player.shade else 1
for en in enemies:
+ if en.health <= 0:
+ continue
+ eid = id(en)
+ if eid in player.swing_hit_ids:
+ continue
if any(box.colliderect(en.rect) for box in atk_boxes):
+ player.swing_hit_ids.add(eid)
en.take_damage(dmg)
- player.damage_applied = True
# Dash interactions
if player.is_dashing():
@@ -7813,9 +8164,9 @@ while running:
else:
# brief invuln on hit + stun
player.invincibility_timer = max(player.invincibility_timer, DASH_LIGHT_INVULN)
- if getattr(en, "type_id", "") == "boss":
+ if getattr(en, "drop_profile", "") == "boss":
en.apply_stun(DASH_STUN_BOSS)
- elif getattr(en, "type_id", "") == "miniboss":
+ elif getattr(en, "drop_profile", "") == "miniboss":
en.apply_stun(DASH_STUN_MINIBOSS)
else:
en.apply_stun(DASH_STUN_NORMAL)
@@ -7894,12 +8245,13 @@ while running:
continue # hazard deaths drop nothing and give no quest credit
loot_drops.extend(spawn_drops_from_enemy(en))
etype = getattr(en, "type_id", "")
- if etype == "boss":
+ eprof = getattr(en, "drop_profile", "")
+ if eprof == "boss":
mark_boss_defeated(ROOM.id)
bid = boss_id_for_room(ROOM.id)
if bid and ACTIVE_REFIGHT is None:
token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid))
- elif etype == "miniboss":
+ elif eprof == "miniboss":
DEFEATED_MINIBOSSES.add(ROOM.id)
# Quest kill tracking
@@ -7912,15 +8264,15 @@ while running:
base_x = en.rect.centerx - 8
base_y = en.rect.top - 20
- if getattr(en, "type_id", "") == "enemy":
+ if getattr(en, "drop_profile", "") == "normal":
spawn_coins("light", roll_normal_light(), base_x, base_y)
if player.shade:
spawn_coins("dark", roll_normal_dark(), base_x, base_y)
- elif getattr(en, "type_id", "") == "miniboss":
+ elif getattr(en, "drop_profile", "") == "miniboss":
spawn_coins("light", roll_miniboss_light(), base_x, base_y)
if player.shade:
spawn_coins("dark", roll_miniboss_dark(), base_x, base_y)
- elif getattr(en, "type_id", "") == "boss":
+ elif getattr(en, "drop_profile", "") == "boss":
boss_light = roll_boss_light()
spawn_coins("light", boss_light, base_x, base_y)
if player.shade:
@@ -8109,7 +8461,7 @@ while running:
)
# Boss health bar (if boss exists)
- boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None)
+ boss = next((en for en in enemies if getattr(en, "drop_profile", "") == "boss"), None)
if boss is not None:
bar_w = 360
bar_h = 18
@@ -8133,6 +8485,8 @@ while running:
screen.blit(VIGNETTE_LIGHT, (0, 0))
screen.blit(VIGNETTE_LIGHT_EDGE, (0, 0))
+ draw_mist_overlay(screen)
+
draw_shatterstep_icon(screen, player)
draw_tether_icon(screen, player)
draw_reality_break_icon(screen, player)
diff --git a/light_and_dark/saves/new rooms 2.json b/light_and_dark/saves/new rooms 2.json
index 2da7780..6d32eb9 100644
--- a/light_and_dark/saves/new rooms 2.json
+++ b/light_and_dark/saves/new rooms 2.json
@@ -1,15 +1,15 @@
{
"version": 1,
- "saved_at": "2026-09-30T11:51:08",
+ "saved_at": "2026-10-01T10:39:22",
"player": {
"room_id": "room_04.json",
- "x": 590,
+ "x": 509,
"y": 240,
"health": 10,
"charge": 100.0,
"shade": false,
- "coins_light": 39,
- "coins_dark": 4,
+ "coins_light": 46,
+ "coins_dark": 6,
"butterfly_jars": 2,
"has_ranged": false,
"opened_chests": [
@@ -17,14 +17,48 @@
],
"equipment": {
"helmet": null,
- "chest": null,
+ "chest": {
+ "id": "vitalweave_chest",
+ "name": "Vitalweave Chestplate",
+ "set": "vitalweave",
+ "slot": "chest",
+ "base_stats": {},
+ "rarity_overrides": {
+ "tempered": {
+ "extra_stats": {
+ "heal_speed_pct": 0.1
+ }
+ },
+ "attuned": {
+ "extra_stats": {
+ "heal_speed_pct": 0.15
+ }
+ },
+ "awakened": {
+ "extra_stats": {
+ "heal_speed_pct": 0.15,
+ "health": 5
+ }
+ }
+ },
+ "rarity": "attuned",
+ "tier": 2,
+ "stats": {
+ "heal_speed_pct": 0.15
+ },
+ "abilities": []
+ },
"pants": null,
"necklace": null
}
},
"inventory": [],
"quests": {
- "active": {},
+ "active": {
+ "first_kills_shade": {
+ "progress": 0
+ }
+ },
"completed_one_time": [],
"ever_completed": [
"quest_first_kills"