From bf4e8de590ef92255ce4cf97b9a6158529a5a698 Mon Sep 17 00:00:00 2001 From: Halica Date: Thu, 1 Oct 2026 10:49:35 +0200 Subject: [PATCH] mist/attack mist, charge empty lock, attack changes, floating platform changes --- light_and_dark/data/config/game_config.json | 2 +- light_and_dark/data/config/physics.json | 2 + light_and_dark/data/enemies/enemy_types.json | 18 +- .../data/sprites/abilities/basic_attack.png | Bin 0 -> 362 bytes light_and_dark/data/world/rooms/room_04.json | 61 +- light_and_dark/data/world/rooms/room_05.json | 44 +- light_and_dark/data/world/rooms/room_06.json | 26 +- light_and_dark/level_editor_v43.html | 75 ++- light_and_dark/main.py | 560 ++++++++++++++---- light_and_dark/saves/new rooms 2.json | 46 +- 10 files changed, 688 insertions(+), 146 deletions(-) create mode 100644 light_and_dark/data/sprites/abilities/basic_attack.png 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 0000000000000000000000000000000000000000..80ede8f1601381ec895162580390ed2d8f881580 GIT binary patch literal 362 zcmV-w0hRuVP)}LW~IN0ayEfNzd>d z2W>yw)O`>UjYFM$EGdX8`bmhnf{Ce7qoiO#Z9iL$h2J40sl+&65v&wPf>KL?fB?RK zjg%M&xQQ#~i3~9T-Lz!6+R2S+K2iH5f4(Z?Qsc9bV+#F6LKAC0?rvJ_l`Omsk2-#9 z$T4spoB;>T->+t)aj2_(J0JuH+f9q*7ytI;J_(j0aRWEar;^%)K>&BQ^Sk8n3e270 zZm5{?8IfG$L%^hmTr(a59J*<-tJ#Ps-!k)80(>`B96C|{_5GCsVuE%lxv7dBMQB1} z&_2`^bcg*_ipHRQsC*{u3o!NBd +
- 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 ` +
Room Settings
+
+
+ +
+ `; + } if(sel.kind==="platform"){ const p=room.platforms[sel.index]; @@ -2244,6 +2262,11 @@ Cancel = Choose whether to remove without saving`); if(sel.kind==="enemy"){ const e=room.enemies[sel.index]; if(!e) 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 `
Enemy Spawn #${sel.index}
@@ -2255,6 +2278,16 @@ Cancel = Choose whether to remove without saving`);
type
+ ${bossBehavior ? ` +
+
boss AI
+ +
+ ` : ""}
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"