diff --git a/AGENTS.md b/AGENTS.md index 6e83a46..b49e5b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,13 +3,13 @@ ## Project "Light & Dark" — a 2D light/shade platformer prototype (Python 3.10 + pygame). -- Main game: `light_and_dark/main.py` (single large file, ~8000 lines, no tests) +- Main game: `light_and_dark/main.py` (single large file, ~9500 lines, no tests) - Level editor: `light_and_dark/level_editor_v43.html` (standalone HTML/JS, no build step) - Levels: `light_and_dark/data/world/rooms/room_0N.json` - Data/config: `light_and_dark/data/config/*.json`, `data/items/*.json`, `data/objects/*.json`, `data/enemies/*.json` ## Standard session start -1. `git status` + `git log --oneline -5` to see uncommitted/untracked work (rooms 4-6 are untracked/WIP). +1. `git status` + `git log --oneline -5` to see uncommitted/untracked work (currently `room_08`/`room_09`/`room_boss_fly_refight` and the `Boss_fly_token.png` sprite are untracked/WIP). 2. Read the specific file(s) the request touches before editing; follow existing style. 3. After edits, always verify (see below). @@ -25,7 +25,11 @@ ## Key systems (in main.py unless noted) - **Saves**: lantern-only. `save_game` / `apply_save_data`. Quest progress, broken doors, defeated bosses persist ONLY on lantern interaction. -- **Trigger walls** (`trigger_wall_blocking`): meta keys `quest`, `quest_complete`, `quest_turned_in`, `lock_when_pass` (appears once walked through), `open_on_miniboss_dead`. +- **Trigger walls** (`trigger_wall_blocking`): meta keys `quest`, `quest_complete`, `quest_turned_in`, `lock_when_pass` (appears once walked through), `open_on_miniboss_dead`, `open_on_boss_dead` (released once the room's boss is slain; also hides/inerts moving platforms while the boss lives). +- **Exits**: `"requires_boss_dead": true` hides the exit and disables it while the room's boss is alive (used for boss doors and refight rooms). The editor round-trips this flag (checkbox in the Exit properties). +- **Bosses** (`BOSS_DEFS_DEFAULT`): `boss_id -> {name, room, refight_room, token_name, token_sprite}`. `boss_01` = Gloom Warden (legacy `room_boss_01`, not in the current build); `boss_02` = **Sky Terror** (flying, `room_08`, refight `room_boss_fly_refight`). A boss drops its token in its world room (not during a refight); tokens feed the Boss Diary (`boss_diary`) and the Combat Master upgrades them for the Hard fight. Token art is per-boss via `boss_token_sprite(boss_id)`. +- **Boss aggro**: once the player first enters a boss's `detection_range` in Light form, `Enemy.boss_engaged` latches and the boss chases forever (ignores range and Shade). +- **Flying boss** (`behavior: "flyer"`, config `enemy_types.json` → `boss_flyer.flyer`): hovers/bobs, slam-dives (shockwave `poison=False`), summons expiring minions, AOE rings, homing shots. Casts `_flyer_max_casts()` skills per slam-cycle — normal 1 (2 at `phase2_hp_pct`), hard 2 (3 at `hard_phase3_hp_pct`); `self.flyer_hard` is set by `start_refight(..., "hard")`. - **Ability doors** (`AbilityDoor`): `kind` = `"ranged"` (shot open, persists in `broken_doors`) or `"shade"` (solid in light, passable in shade + a `shade_door_grace` window after form switch; ejected to nearest edge after grace). - **Quests** (`QUEST_DEFS_DEFAULT`): objective `"shade": True` counts kills only in shade form. Rewards via `guaranteed_item` (one-time) or `pool` (repeatable). Items by `item_id` (e.g. `necklace_placeholder` = "Plain Necklace"). - **Minibosses**: stay defeated per room (`DEFEATED_MINIBOSSES`) until a lantern save in a different room, then respawn. diff --git a/light_and_dark/README.md b/light_and_dark/README.md index 1b723f3..324b9be 100644 --- a/light_and_dark/README.md +++ b/light_and_dark/README.md @@ -92,7 +92,8 @@ When you die you lose your **butterfly jars for good** and drop your coins. A ** ## Combat notes - Enemy contact damage only applies in **Light** form. -- Boss **shockwave attacks poison** you for 4 seconds: the health bar turns green and you lose 1 HP per second. +- The **Gloom Warden's** ground shockwaves **poison** you for 4 seconds (the health bar turns green, −1 HP/s). **Sky Terror's** slam does **not** poison. +- Once a boss first spots you in Light form it tracks you permanently — even in Shade form and from any distance. - Attacks and hits use invincibility frames and knockback; your melee hitbox covers ~30% into your body so close/overlapping hits always connect. --- @@ -122,7 +123,9 @@ The shade-focused sets (**Duskwarrior, Umbraflow, Abyss Cleaver, Adaptive Travel --- -## NPCs (Town 1: Gloomreach) +## NPCs + +> The town room isn't in the current build (removed in the room overhaul); these NPC types still exist and can be placed with the editor. - **Vendor** — sells basic armor pieces. - **Necklace Vendor** — sells necklaces. @@ -134,22 +137,27 @@ The shade-focused sets (**Duskwarrior, Umbraflow, Abyss Cleaver, Adaptive Travel ## Rooms & Progression +> The room set was overhauled; the old reward/town rooms are gone. Notable rooms: + - `room_01` — tutorial. Contains a **quest object** (pink): accept the **"First Kills"** quest (kill 4 normal enemies → 5–15 light coins, repeatable). - `room_02` — first enemies. -- `room_07` — hub, branching to the boss, reward room, and town. -- `room_boss_01` — **Gloom Warden** fight. Defeating him drops his **boss token**. -- `room_reward_01` — reward chest + the **Ranged** ability, locked behind green ability walls. -- `room_town_gloomreach` — Town 1 (NPCs above). -- `room_boss_refight_01` — diary refight room (same arena, no exits until the boss dies or you die). +- `room_04` — **miniboss** arena; the walls seal until the miniboss is slain. +- `room_05` — **charger boss** arena (seals until slain). +- `room_07` — hub, branching to the boss arena and back. +- `room_08` — **Sky Terror**, the flying boss. Entering locks you in: the exit back to `room_07` and the right-hand door to `room_09` only appear once he's dead. +- `room_09` — the area past Sky Terror's right door. +- `room_boss_fly_refight` — Sky Terror **diary refight** arena. --- ## Boss Diary & Tokens -1. Beat the world boss → collect his **token** (teal pickup). -2. Bring it to the diary in town and **slot** it (or keep it in your inventory). -3. Slotting unlocks **Fight (Normal)**. Upgrading the token at the Combat Master unlocks **Fight (Hard)** — the boss moves and shoots twice as fast. -4. Dying in a refight returns you to the diary. Removing a token from the diary sends it back to your inventory, disabling the fight until you re-slot it. +1. Beat a world boss → collect its **token** (a distinct pickup per boss). +2. **Slot** it in the Boss Diary (or keep it in your inventory). +3. Slotting unlocks **Fight (Normal)**. Upgrading the token at the **Combat Master** unlocks **Fight (Hard)**. +4. Dying in a refight ends it; removing a token from the diary sends it back to your inventory. + +Bosses: **Gloom Warden** (`boss_01`, legacy) and **Sky Terror** (`boss_02`) — Sky Terror lives in `room_08` (51 HP) and refights in `room_boss_fly_refight`; on Hard he opens with **two** skills per attack cycle and escalates to **all three** below 20% HP. --- diff --git a/light_and_dark/ROADMAP.md b/light_and_dark/ROADMAP.md index ff3cc8a..56b4172 100644 --- a/light_and_dark/ROADMAP.md +++ b/light_and_dark/ROADMAP.md @@ -10,6 +10,7 @@ Proves the core loop is fun before scaling to the full vision. - **Vendor** (smith/trainer) gives direct linear power upgrades. Upgrades one selected spell; changing selection costs coins. - **Gems / rings** give alternate behaviors / sidegrades. - **Ranged identity**: keep the bolt, add upgrades (no charge-up / homing rework). +- **Bosses**: after the player first enters a boss's detection range (Light form), it tracks them permanently — ignoring range and Shade. The flying boss (**Sky Terror**, `boss_02`) casts a limited number of skills per slam-cycle: normal 1 (2 at ≤33% HP), Hard 2 (all 3 at ≤20% HP). Bosses drop a per-boss token that feeds the Boss Diary. - **Fairy (in slice)**: guide + ambient light source (bonus flow regen near player). Scout mode, teleport, fairy holes deferred to regions 3+. - **Ascended armor**: cut for now. Awakened is good enough at the start; less balancing needed. @@ -28,6 +29,7 @@ Proves the core loop is fun before scaling to the full vision. |---|---|---| | ~~Death penalty (drop coins, reclaim at death spot; fairy marks it; lose on 2nd death)~~ | M | **DONE (2026-09-29)**: corpse at death spot holds 100% of coins; a repeat death before reclaiming keeps only 50% and the old corpse vanishes. Butterfly jars are lost for good on death. | | ~~Fairy companion (guide + ambient light source -> bonus flow regen)~~ | M | **DONE (2026-09-29)**: fairy follows behind the player's head; flips with facing. | +| ~~Flying boss + boss tokens~~ | M | **DONE (2026-10-05)**: **Sky Terror** (`boss_02`) in `room_08` — 51 HP, poison-free slam, skill-cycle phases (2 at ≤33%). Drops a per-boss token feeding the Boss Diary; Hard refight (`room_boss_fly_refight`) starts at 2 skills/cycle and hits all 3 at ≤20% HP. | | More quests (repeatable + one-time; system exists) | S | Just content. | | Lore dialogue for fairy / NPCs | S | Needs written copy. | @@ -43,7 +45,7 @@ Proves the core loop is fun before scaling to the full vision. |---|---|---| | Shade-gated interactions (doors, an NPC, an area) | S-M | Room JSON + existing toggle. | | New enemy behaviors (patrol, dodge-and-leap, burrow-shooter) | M | Enemy system supports it. | -| Enemy line-of-sight detection (radius -> raycast) | S-M | Replace/augment euclidean aggro (`main.py:4329`) with a `Rect.clipline` raycast against solid platforms so walls block vision. Per-type `"detection": radius/los` config in `enemy_types.json` + editor toggle. Edge cases: skip the enemy's own support platform, sample player center+feet for tolerance, apply LOS to boss slam `trigger_close/far`. | +| Enemy line-of-sight detection (radius -> raycast) | S-M | Replace/augment the euclidean aggro in `Enemy.update` with a `Rect.clipline` raycast against solid platforms so walls block vision. Per-type `"detection": radius/los` config in `enemy_types.json` + editor toggle. Edge cases: skip the enemy's own support platform, sample player center+feet for tolerance, apply LOS to boss slam `trigger_close/far`. | | ~~Quick recharge pickup~~ | S | **DONE (2026-09-29)**: butterfly jars — collect up to 3, press G to instantly refill Charge. Lost on death; respawn at their points after saving at a lantern in a different room or reloading. | | Ability tweaks: ranged upgrades, ground-slam knockback/iframe, light-step buffs | M | Picks up remaining notes ideas. | | Region-2 boss + ability pacing | M | Re-align pacing (Ranged already exists). | diff --git a/light_and_dark/data/enemies/enemy_types.json b/light_and_dark/data/enemies/enemy_types.json index 1e344a8..31484bd 100644 --- a/light_and_dark/data/enemies/enemy_types.json +++ b/light_and_dark/data/enemies/enemy_types.json @@ -44,7 +44,7 @@ "boss_flyer": { "rect": [120, 90], - "health": 26, + "health": 51, "speed": 3.0, "air_speed": 3.0, "detection_range": 620, @@ -52,11 +52,14 @@ "drop_profile": "boss", "behavior": "flyer", "flyer": { - "flight_height": 220, + "flight_height": 280, "bob_amplitude": 20, "bob_speed": 2.2, "hover_speed": 2.6, - "slam_cd": 6.0, + "startup_delay": 3.0, + "phase2_hp_pct": 0.33, + "hard_phase3_hp_pct": 0.20, + "slam_cd": 9.0, "slam_dive_vel": 16.0, "ground_time": 2.0, "summon_cd": 8.0, diff --git a/light_and_dark/data/sprites/tokens/Boss_fly_token.png b/light_and_dark/data/sprites/tokens/Boss_fly_token.png new file mode 100644 index 0000000..e76d42a Binary files /dev/null and b/light_and_dark/data/sprites/tokens/Boss_fly_token.png differ diff --git a/light_and_dark/data/world/rooms/room_01.json b/light_and_dark/data/world/rooms/room_01.json index 6d5e38e..3c4462f 100644 --- a/light_and_dark/data/world/rooms/room_01.json +++ b/light_and_dark/data/world/rooms/room_01.json @@ -33,14 +33,6 @@ "group": "ground", "layer": 0 }, - { - "x": 20, - "y": 0, - "w": 1580, - "h": 20, - "group": "ground", - "layer": 0 - }, { "x": 540, "y": 140, @@ -159,13 +151,15 @@ "x": 1480, "y": 360, "type": "enemy", - "layer": 0 + "layer": 0, + "behavior": "" }, { "x": 340, "y": 580, "type": "enemy", - "layer": 0 + "layer": 0, + "behavior": "" } ], "npcs": [], @@ -193,10 +187,12 @@ 240 ], "hint": "", - "layer": 0 + "layer": 0, + "requires_boss_dead": false } ], "ability_doors": [], "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_02.json b/light_and_dark/data/world/rooms/room_02.json index a4d830c..1ac3ba7 100644 --- a/light_and_dark/data/world/rooms/room_02.json +++ b/light_and_dark/data/world/rooms/room_02.json @@ -49,22 +49,6 @@ "group": "ground", "layer": 0 }, - { - "x": 20, - "y": 120, - "w": 1240, - "h": 20, - "group": "ground", - "layer": 0 - }, - { - "x": 1240, - "y": 140, - "w": 20, - "h": 60, - "group": "ground", - "layer": 0 - }, { "x": 1060, "y": 360, @@ -81,19 +65,11 @@ "group": "ground", "layer": 0 }, - { - "x": 1260, - "y": 180, - "w": 260, - "h": 20, - "group": "ground", - "layer": 0 - }, { "x": 1500, - "y": 200, + "y": 100, "w": 20, - "h": 440, + "h": 540, "group": "ground", "layer": 0 }, @@ -203,13 +179,15 @@ "x": 1160, "y": 320, "type": "enemy", - "layer": 0 + "layer": 0, + "behavior": "" }, { "x": 1160, "y": 580, "type": "enemy", - "layer": 0 + "layer": 0, + "behavior": "" } ], "npcs": [], @@ -219,7 +197,9 @@ "y": 320, "type": "save_point", "radius": 90, - "object_id": "lamp" + "object_id": "lamp", + "layer": 0, + "quest_id": "" } ], "exits": [ @@ -235,7 +215,8 @@ 680 ], "hint": "", - "layer": 0 + "layer": 0, + "requires_boss_dead": false }, { "x": 20, @@ -249,10 +230,12 @@ 220 ], "hint": "", - "layer": 0 + "layer": 0, + "requires_boss_dead": false } ], "ability_doors": [], "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 fa4bef4..284b2ac 100644 --- a/light_and_dark/data/world/rooms/room_05.json +++ b/light_and_dark/data/world/rooms/room_05.json @@ -123,7 +123,7 @@ "y": 720, "type": "boss", "layer": 0, - "behavior": "charger" + "behavior": "shockwave" } ], "npcs": [], @@ -151,7 +151,8 @@ 640 ], "hint": "", - "layer": 0 + "layer": 0, + "requires_boss_dead": false }, { "x": 1440, @@ -165,7 +166,8 @@ 460 ], "hint": "", - "layer": 0 + "layer": 0, + "requires_boss_dead": false } ], "ability_doors": [], diff --git a/light_and_dark/data/world/rooms/room_08.json b/light_and_dark/data/world/rooms/room_08.json index 2e414b4..008cd45 100644 --- a/light_and_dark/data/world/rooms/room_08.json +++ b/light_and_dark/data/world/rooms/room_08.json @@ -14,6 +14,10 @@ "room8_bottom_left": [ 300, 400 + ], + "room8_bottom_right": [ + 1400, + 400 ] }, "platforms": [ @@ -46,7 +50,7 @@ { "x": 820, "y": 220, - "type": "boss", + "type": "boss_flyer", "layer": 0, "behavior": "flyer" } @@ -64,7 +68,25 @@ "spawn": [ 1040, 460 - ] + ], + "hint": "", + "layer": 0, + "requires_boss_dead": true + }, + { + "x": 1440, + "y": 360, + "w": 80, + "h": 100, + "to": "room_09", + "spawn_id": "room9_top_left", + "spawn": [ + 220, + 300 + ], + "hint": "", + "layer": 0, + "requires_boss_dead": true } ], "ability_doors": [], diff --git a/light_and_dark/data/world/rooms/room_09.json b/light_and_dark/data/world/rooms/room_09.json new file mode 100644 index 0000000..2e951d8 --- /dev/null +++ b/light_and_dark/data/world/rooms/room_09.json @@ -0,0 +1,169 @@ +{ + "name": "room_09", + "display_name": "room_09", + "meta": { + "id": "room_09", + "name": "room_09" + }, + "bounds": {}, + "player_spawn": [ + 220, + 260 + ], + "entry_spawns": { + "room9_top_left": [ + 220, + 300 + ] + }, + "platforms": [ + { + "x": 100, + "y": 360, + "w": 300, + "h": 20, + "group": "ground", + "layer": 0 + }, + { + "x": 80, + "y": 220, + "w": 20, + "h": 440, + "group": "ground", + "layer": 0 + }, + { + "x": 460, + "y": 440, + "w": 140, + "h": 20, + "group": "ground" + }, + { + "x": 620, + "y": 500, + "w": 120, + "h": 20, + "group": "ground" + }, + { + "x": 800, + "y": 560, + "w": 80, + "h": 20, + "group": "ground" + }, + { + "x": 80, + "y": 660, + "w": 2699, + "h": 20, + "group": "ground" + }, + { + "x": 1580, + "y": 540, + "w": 240, + "h": 20, + "group": "ground" + }, + { + "x": 1980, + "y": 540, + "w": 100, + "h": 20, + "group": "ground" + }, + { + "x": 2260, + "y": 660, + "w": 140, + "h": 20, + "group": "moving", + "path": [ + [ + 2260, + 660 + ], + [ + 2260, + 340 + ] + ] + }, + { + "x": 2460, + "y": 300, + "w": 320, + "h": 20, + "group": "ground" + } + ], + "enemies": [], + "npcs": [ + { + "x": 1296, + "y": 606, + "type": "necklace_vendor", + "layer": 1 + }, + { + "x": 2012, + "y": 486, + "type": "combat_master" + } + ], + "props": [ + { + "x": 800, + "y": 460, + "type": "player", + "radius": 180, + "object_id": "player" + }, + { + "x": 1240, + "y": 660, + "type": "house", + "radius": 180, + "object_id": "house" + }, + { + "x": 1660, + "y": 540, + "type": "house", + "radius": 180, + "object_id": "house" + }, + { + "x": 1754, + "y": 490, + "type": "diary", + "radius": 180, + "object_id": "diary", + "layer": 1 + } + ], + "exits": [ + { + "x": 100, + "y": 260, + "w": 80, + "h": 100, + "to": "room_08", + "spawn_id": "room8_bottom_right", + "spawn": [ + 1400, + 400 + ], + "hint": "", + "layer": 0, + "requires_boss_dead": false + } + ], + "ability_doors": [], + "room_w": 1600, + "room_h": 900, + "mist": false +} \ No newline at end of file diff --git a/light_and_dark/data/world/rooms/room_boss_fly_refight.json b/light_and_dark/data/world/rooms/room_boss_fly_refight.json new file mode 100644 index 0000000..0fa9eee --- /dev/null +++ b/light_and_dark/data/world/rooms/room_boss_fly_refight.json @@ -0,0 +1,66 @@ +{ + "name": "boss_fly_refight", + "display_name": "boss_fly_refight", + "meta": { + "id": "boss_fly_refight", + "name": "boss_fly_refight" + }, + "bounds": {}, + "player_spawn": [ + 280, + 400 + ], + "entry_spawns": {}, + "platforms": [ + { + "x": 200, + "y": 460, + "w": 1340, + "h": 20, + "group": "ground" + }, + { + "x": 180, + "y": 120, + "w": 20, + "h": 360, + "group": "ground" + }, + { + "x": 1540, + "y": 120, + "w": 20, + "h": 360, + "group": "ground" + } + ], + "enemies": [ + { + "x": 820, + "y": 220, + "type": "boss_flyer", + "layer": 0, + "behavior": "flyer" + } + ], + "npcs": [], + "props": [], + "exits": [ + { + "x": 1460, + "y": 360, + "w": 80, + "h": 100, + "to": "", + "spawn_id": "", + "spawn": [ + 1460, + 360 + ] + } + ], + "ability_doors": [], + "room_w": 1600, + "room_h": 900, + "mist": false +} \ 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 3723d4f..5fef4fb 100644 --- a/light_and_dark/level_editor_v43.html +++ b/light_and_dark/level_editor_v43.html @@ -289,10 +289,12 @@ } }, boss_flyer: { - rect: [120, 90], health: 26, speed: 3.0, air_speed: 3.0, detection_range: 620, contact_damage: 3, drop_profile: "boss", behavior: "flyer", + rect: [120, 90], health: 51, speed: 3.0, air_speed: 3.0, detection_range: 620, contact_damage: 3, drop_profile: "boss", behavior: "flyer", flyer: { - flight_height: 220, bob_amplitude: 20, bob_speed: 2.2, hover_speed: 2.6, - slam_cd: 6.0, slam_dive_vel: 16.0, ground_time: 2.0, + flight_height: 280, bob_amplitude: 20, bob_speed: 2.2, hover_speed: 2.6, + startup_delay: 3.0, + phase2_hp_pct: 0.33, hard_phase3_hp_pct: 0.20, + slam_cd: 9.0, slam_dive_vel: 16.0, ground_time: 2.0, summon_cd: 8.0, summon_max: 3, summon_type: "boss_minion", summon_life: 6.0, summon_heal: 4, summon_damage: 3, aoe_cd: 9.0, aoe_radius: 300, aoe_damage: 1, aoe_duration: 0.9, shot_cd: 5.0, shot_damage: 5, shot_speed: 300, shot_lifetime: 2.2, shot_homing: 1.8 @@ -510,7 +512,8 @@ spawn_id: ex.spawn_id || "", spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null, hint: ex.hint || "", - layer: +(ex.layer||0) + layer: +(ex.layer||0), + requires_boss_dead: !!ex.requires_boss_dead }); } else { // editor-native exit @@ -520,7 +523,8 @@ spawn_id: ex.spawn_id || "", spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null, hint: ex.hint || "", - layer: +(ex.layer||0) + layer: +(ex.layer||0), + requires_boss_dead: !!ex.requires_boss_dead }); } } @@ -2271,6 +2275,9 @@ Cancel = Choose whether to remove without saving`);
to
spawn_id
+
+ +
Tip: use the Linking panel to set these safely.
@@ -2581,6 +2588,9 @@ Cancel = Choose whether to remove without saving`); const exto=q("exto"); if(exto) exto.addEventListener("change", ()=>pushAnd(()=>{ ex.to = exto.value.trim(); })); const exsid=q("exsid"); if(exsid) exsid.addEventListener("change", ()=>pushAnd(()=>{ ex.spawn_id = exsid.value.trim(); })); const exlayer=q("exlayer"); if(exlayer) exlayer.addEventListener("change", ()=>pushAnd(()=>{ ex.layer = Math.round(num(exlayer.value)); })); + const exbossdead=q("exbossdead"); if(exbossdead) exbossdead.addEventListener("change", ()=>pushAnd(()=>{ + if(exbossdead.checked){ ex.requires_boss_dead = true; } else { delete ex.requires_boss_dead; } + })); const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{ room.exits.splice(sel.index,1); state.selection=null; @@ -3451,7 +3461,7 @@ Cancel = Choose whether to remove without saving`); return out; }); - // exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint} (+ layer when set) + // exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint} (+ layer / requires_boss_dead when set) const exits = (d.exits || []).map(ex => { const out = { rect: [ex.x, ex.y, ex.w, ex.h], @@ -3461,6 +3471,7 @@ Cancel = Choose whether to remove without saving`); }; if(ex.spawn_id) out.spawn_id = ex.spawn_id; if(ex.layer) out.layer = ex.layer; + if(ex.requires_boss_dead) out.requires_boss_dead = true; return out; }); diff --git a/light_and_dark/main.py b/light_and_dark/main.py index ad08442..cc9dbe7 100644 --- a/light_and_dark/main.py +++ b/light_and_dark/main.py @@ -1499,6 +1499,28 @@ BOSS_TOKEN_SPRITE_RAW = _load_first_existing([ OBJECTS_DIR / "Boss_Token.png", ]) +BOSS_TOKEN_SPRITE_CACHE: dict[str, pygame.Surface | None] = {} + + +def boss_token_sprite(boss_id) -> pygame.Surface | None: + """Per-boss token sprite. Uses ``token_sprite`` from BOSS_DEFS_DEFAULT when + set, else ``_token.png``; falls back to the shared token sprite.""" + key = str(boss_id or "") + if key in BOSS_TOKEN_SPRITE_CACHE: + return BOSS_TOKEN_SPRITE_CACHE[key] + bd = BOSS_DEFS_DEFAULT.get(key, {}) if key else {} + candidates: list[Path] = [] + if isinstance(bd, dict) and bd.get("token_sprite"): + candidates.append(TOKENS_DIR / str(bd["token_sprite"])) + if key: + candidates.append(TOKENS_DIR / f"{key}_token.png") + img = _load_first_existing(candidates) if candidates else None + if img is None: + img = BOSS_TOKEN_SPRITE_RAW + BOSS_TOKEN_SPRITE_CACHE[key] = img + return img + + # Flying boss projectile sprite (faces RIGHT by default; flipped when fired left) ENEMY_SKILLS_DIR = DATA_ROOT / "sprites" / "enemy_skills" BOSS_PROJECTILE_RAW = _load_first_existing([ @@ -1569,12 +1591,13 @@ def roll_from_table(table: dict) -> str | None: class Shockwave: - def __init__(self, x, y, direction: int, speed: float, lifetime: float, height: int, damage: int): + def __init__(self, x, y, direction: int, speed: float, lifetime: float, height: int, damage: int, poison: bool = True): self.rect = pygame.Rect(int(x), int(y), 26, int(height)) self.dir = 1 if direction >= 0 else -1 self.speed = float(speed) self.t = float(lifetime) self.damage = int(damage) + self.poison = bool(poison) def update(self, dt: float, platforms: list[pygame.Rect]): self.rect.x += int(self.dir * self.speed * dt) @@ -2105,16 +2128,16 @@ class Corpse: ) -def spawn_boss_shockwave(boss, shockwaves_list: list[Shockwave]): +def spawn_boss_shockwave(boss, shockwaves_list: list[Shockwave], poison: bool = True): y = boss.rect.bottom - boss.shockwave_height x = boss.rect.centerx shockwaves_list.append(Shockwave( x, y, -1, - boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage + boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage, poison )) shockwaves_list.append(Shockwave( x, y, 1, - boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage + boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage, poison )) @@ -2780,20 +2803,23 @@ class InventoryUI: self.slot_icons = load_slot_icons() self.slot_icon_cache = {} # (slot, rarity) -> Surface - self.token_icon = None # lazily cached scaled boss-token icon + self.token_icons = {} # boss_id -> lazily cached scaled boss-token icon self.icon_size = int(_ui(["inventory_ui", "icons", "size"], 16)) self.icon_gap = int(_ui(["inventory_ui", "icons", "gap"], 6)) - def _get_token_icon(self) -> pygame.Surface | None: - if self.token_icon is None: - if BOSS_TOKEN_SPRITE_RAW is not None: + def _get_token_icon(self, boss_id=None) -> pygame.Surface | None: + key = str(boss_id or "") + if key not in self.token_icons: + raw = boss_token_sprite(boss_id) + icon = None + if raw is not None: try: - self.token_icon = pygame.transform.smoothscale( - BOSS_TOKEN_SPRITE_RAW, (self.icon_size, self.icon_size)) + icon = pygame.transform.smoothscale(raw, (self.icon_size, self.icon_size)) except Exception: - self.token_icon = None - return self.token_icon + icon = None + self.token_icons[key] = icon + return self.token_icons[key] def toggle(self): @@ -3111,7 +3137,8 @@ class InventoryUI: except Exception: pass - self._blit(surface, f"Inventory (I to close) — {room_name}", + title = f"Inventory (I to close) — {room_name}" + self._blit(surface, self._ellipsize(title, self.w - self.pad * 2), self.x + self.pad, self.y + self.pad, (240, 240, 240)) # --- Detail areas (reserve bottom space FIRST) --- @@ -3181,7 +3208,7 @@ class InventoryUI: # --- slot icon (tinted by rarity), or token icon for boss tokens --- icon = None if it.get("item_type") == "boss_token": - icon = self._get_token_icon() + icon = self._get_token_icon(it.get("boss_id")) if icon is None: icon = self._get_slot_icon(slot, rarity) icon_y = y + (self.line_h - self.icon_size) // 2 @@ -3227,13 +3254,16 @@ class InventoryUI: surface.blit(icon, (eq_x, icon_y)) text_x = eq_x + (self.icon_size + self.icon_gap) + avail = (self.x + self.w - self.pad) - text_x if not it: - self._blit(surface, f"{slot}: (empty)", text_x, eq_y, (170, 170, 170)) + self._blit(surface, self._ellipsize(f"{slot}: (empty)", max(0, avail)), + text_x, eq_y, (170, 170, 170)) else: name = it.get("name", "?") - left = f"{slot}: {name} " tag = f"[{rarity}]" if rarity else "" + tag_w = self.font.size(tag)[0] if tag else 0 + left = self._ellipsize(f"{slot}: {name} ", max(0, avail - tag_w)) self._blit(surface, left, text_x, eq_y, (230, 230, 230)) if tag: self._blit(surface, tag, text_x + self.font.size(left)[0], eq_y, self._rarity_color(rarity)) @@ -3290,7 +3320,10 @@ class InventoryUI: # Hint text (NOW hint_y exists) self._blit( surface, - "UP/DOWN move ENTER equip / unequip LEFT/RIGHT switch panel", + self._ellipsize( + "UP/DOWN move ENTER equip / unequip LEFT/RIGHT switch panel", + self.w - self.pad * 2 - 6, + ), self.x + self.pad + 6, hint_y + 4, (160, 160, 160), @@ -3328,12 +3361,15 @@ class InventoryUI: name = selected_item.get("name", selected_item.get("id", "")) rarity = selected_item.get("rarity", "?") - self._blit(surface, f"{name} ", self.x + self.pad + 6, stats_y + 24, (230, 230, 230)) - if rarity: - self._blit(surface, f"[{rarity}]", - self.x + self.pad + 6 + self.font.size(f"{name} ")[0], - stats_y + 24, - self._rarity_color(rarity)) + name_x = self.x + self.pad + 6 + stats_avail = (self.x + self.w - self.pad) - name_x + tag = f"[{rarity}]" if rarity else "" + tag_w = self.font.size(tag)[0] if tag else 0 + name_fit = self._ellipsize(f"{name} ", max(0, stats_avail - tag_w)) + self._blit(surface, name_fit, name_x, stats_y + 24, (230, 230, 230)) + if tag: + self._blit(surface, tag, name_x + self.font.size(name_fit)[0], + stats_y + 24, self._rarity_color(rarity)) compare_item = None @@ -3386,10 +3422,11 @@ class InventoryUI: if bonus_lines: y_bonus = y_stats if not had_stats else (y_stats + 2) max_y = stats_bottom + bonus_avail = (self.x + self.w - self.pad) - stats_x for line in bonus_lines[:2]: if y_bonus > max_y: break - self._blit(surface, line, stats_x, y_bonus, (200, 200, 200)) + self._blit(surface, self._ellipsize(line, max(0, bonus_avail)), stats_x, y_bonus, (200, 200, 200)) y_bonus += 16 # --- Scroll hint + tiny scrollbar --- total = len(self.inv.items) @@ -4628,6 +4665,9 @@ class Enemy: self.flyer_shot_speed = float(flyer_cfg.get("shot_speed", 300)) self.flyer_shot_lifetime = float(flyer_cfg.get("shot_lifetime", 2.2)) self.flyer_shot_homing = float(flyer_cfg.get("shot_homing", 1.8)) + self.flyer_startup_delay = float(flyer_cfg.get("startup_delay", 1.5)) + self.flyer_phase2_hp_pct = float(flyer_cfg.get("phase2_hp_pct", 0.30)) + self.flyer_hard_phase3_hp_pct = float(flyer_cfg.get("hard_phase3_hp_pct", 0.20)) self.flyer_state = "hover" # hover | diving | grounded self.flyer_grounded_timer = 0.0 self.flyer_bob_t = random.random() * 10.0 @@ -4636,6 +4676,9 @@ class Enemy: self.flyer_summon_cd = 0.0 self.flyer_aoe_cd = 0.0 self.flyer_shot_cd = 0.0 + self.flyer_startup_timer = self.flyer_startup_delay + self.flyer_cycle_casts_left = 0 + self.flyer_hard = False # --- summoned boss minion: expires after a short life, then heals the # boss; killing it early damages the boss. Color fades toward white. --- @@ -4657,6 +4700,10 @@ class Enemy: self.last_chase_dir = self.direction self.aggro_drop_timer = 0.0 + # Bosses pull aggro once (first light-form entry into their range) and + # then track the player permanently, ignoring range and shade form. + self.boss_engaged = False + # When aggro breaks near an edge: keep walking off naturally, then fall self.stepoff_timer = 0.0 self.stepoff_dir = self.direction @@ -4703,6 +4750,8 @@ class Enemy: self.flyer_summon_cd = 0.0 self.flyer_aoe_cd = 0.0 self.flyer_shot_cd = 0.0 + self.flyer_startup_timer = self.flyer_startup_delay + self.flyer_cycle_casts_left = 0 self.turn_cooldown = 0.0 self.last_x = self.rect.x @@ -4951,6 +5000,9 @@ class Enemy: self.slow_mult = 1.0 self._behavior_tick(dt) + if self.flyer_startup_timer > 0.0: + self.flyer_startup_timer = max(0.0, self.flyer_startup_timer - dt) + stunned = self.stun_timer > 0.0 if self.stun_timer > 0.0: self.stun_timer = max(0.0, self.stun_timer - dt) @@ -4958,7 +5010,12 @@ class Enemy: dx = player.rect.centerx - self.rect.centerx dy = player.rect.centery - self.rect.centery distance = math.hypot(dx, dy) - chasing = (not player.shade) and (distance <= self.detection_range) + if self.drop_profile == "boss": + if (not self.boss_engaged) and (not player.shade) and distance <= self.detection_range: + self.boss_engaged = True + chasing = self.boss_engaged + else: + chasing = (not player.shade) and (distance <= self.detection_range) feared = self.fear_timer > 0.0 if feared: @@ -5040,7 +5097,7 @@ class Enemy: self.flyer_state = "grounded" self.flyer_grounded_timer = self.flyer_ground_time self.flyer_last_ground_top = p.top - spawn_boss_shockwave(self, shockwaves_list) + spawn_boss_shockwave(self, shockwaves_list, poison=False) break def _flyer_update_grounded(self, dt, platforms, player, speed, feared): @@ -5093,25 +5150,49 @@ class Enemy: self.vel_y = 0.0 self.vel_x = 0.0 + def _flyer_max_casts(self) -> int: + """Number of ranged skills the boss may cast in the upcoming cycle. + Normal: 1, or 2 at/below phase2_hp_pct. + Hard: 2 (starts as if already in phase 2), or all 3 at/below hard_phase3_hp_pct.""" + frac = (self.health / self.max_health) if self.max_health > 0 else 0.0 + if getattr(self, "flyer_hard", False): + return 3 if frac <= self.flyer_hard_phase3_hp_pct else 2 + return 2 if frac <= self.flyer_phase2_hp_pct else 1 + def _flyer_cast_skills(self, dt, player, projectiles_list, enemies_list, aoes_list, platforms): - # Slam takes priority whenever the player is below the boss. - if self.flyer_slam_cd <= 0.0 and player.rect.centery > self.rect.centery + 20: - self.flyer_state = "diving" - self.vel_y = 6.0 - self.flyer_slam_cd = self.flyer_slam_cooldown + # Give the boss a calm intro instead of opening with a slam. + if self.flyer_startup_timer > 0.0: return - # Otherwise pick from the ranged kit (shuffle so it isn't a fixed order). + # Start a new attack cycle whenever the slam comes off cooldown. Each + # cycle the boss casts a limited number of ranged skills (see + # _flyer_max_casts); it never chains more than that. + if self.flyer_slam_cd <= 0.0: + self.flyer_cycle_casts_left = self._flyer_max_casts() + self.flyer_slam_cd = self.flyer_slam_cooldown + # Slam takes priority whenever the player is below the boss. + if player.rect.centery > self.rect.centery + 20: + self.flyer_state = "diving" + self.vel_y = 6.0 + return + + if self.flyer_cycle_casts_left <= 0: + return + + casts = 0 for skill in random.sample(("shot", "summon", "aoe"), 3): + if casts >= self.flyer_cycle_casts_left: + break if skill == "shot" and self.flyer_shot_cd <= 0.0: self._flyer_cast_shot(player, projectiles_list) - return - if skill == "summon" and self.flyer_summon_cd <= 0.0: + casts += 1 + elif skill == "summon" and self.flyer_summon_cd <= 0.0: if self._flyer_cast_summon(enemies_list, platforms): - return - if skill == "aoe" and self.flyer_aoe_cd <= 0.0: + casts += 1 + elif skill == "aoe" and self.flyer_aoe_cd <= 0.0: self._flyer_cast_aoe(aoes_list) - return + casts += 1 + self.flyer_cycle_casts_left -= casts def _flyer_cast_shot(self, player, projectiles_list): sx = self.rect.centerx @@ -5176,8 +5257,12 @@ class Enemy: # Flying boss: completely replaces the ground-based AI body. if self.behavior == "flyer": - self._flyer_update(dt, platforms, player, shockwaves_list, - projectiles_list or [], enemies_list or [], aoes_list or []) + self._flyer_update( + dt, platforms, player, shockwaves_list, + projectiles_list if projectiles_list is not None else [], + enemies_list if enemies_list is not None else [], + aoes_list if aoes_list is not None else [], + ) return if self.jump_cd_timer > 0: @@ -5216,7 +5301,12 @@ class Enemy: dx = player.rect.centerx - self.rect.centerx dy = player.rect.centery - self.rect.centery distance = math.hypot(dx, dy) - chasing = (not player.shade) and (distance <= self.detection_range) + if self.drop_profile == "boss": + if (not self.boss_engaged) and (not player.shade) and distance <= self.detection_range: + self.boss_engaged = True + chasing = self.boss_engaged + else: + chasing = (not player.shade) and (distance <= self.detection_range) feared = self.fear_timer > 0.0 if feared: @@ -7232,6 +7322,14 @@ BOSS_DEFS_DEFAULT = { "room": "room_boss_01.json", "refight_room": "room_boss_refight_01.json", "token_name": "Gloom Warden's Token", + "token_sprite": "Boss1_token.png", + }, + "boss_02": { + "name": "Sky Terror", + "room": "room_08.json", + "refight_room": "room_boss_fly_refight.json", + "token_name": "Sky Terror's Token", + "token_sprite": "Boss_fly_token.png", }, } @@ -7320,6 +7418,8 @@ def start_refight(boss_id: str, variant: str = "normal") -> bool: en.dash_speed_mult *= 1.5 en.retreat_speed_mult *= 1.5 en.attack_cooldown = max(0.6, en.attack_cooldown * 0.75) + elif getattr(en, "behavior", "") == "flyer": + en.flyer_hard = True else: en.shockwave_speed *= 2.0 en.slam_cooldown_base = max(0.25, en.slam_cooldown_base / 2.0) @@ -7363,8 +7463,9 @@ class TokenPickup: bob_y = int(math.sin(self.bob_t) * LOOT_BOB_AMP) if self.on_ground else 0 x = int(self.rect.x - cx) y = int(self.rect.y - cy + bob_y) - if BOSS_TOKEN_SPRITE_RAW is not None: - img = pygame.transform.scale(BOSS_TOKEN_SPRITE_RAW, (self.rect.width, self.rect.height)) + raw = boss_token_sprite(self.boss_id) + if raw is not None: + img = pygame.transform.scale(raw, (self.rect.width, self.rect.height)) surface.blit(img, (x, y)) return pygame.draw.circle(surface, (60, 200, 190), (x + 8, y + 8), 7) @@ -7618,10 +7719,10 @@ class DiaryUI: icon_x = cell.centerx - 16 icon_y = cell.y + 8 has_token = (tier >= 0 or carried is not None) - if BOSS_TOKEN_SPRITE_RAW is not None and has_token: - icon = BOSS_TOKEN_SPRITE_RAW + raw_token = boss_token_sprite(boss_id) + if raw_token is not None and has_token: + icon = pygame.transform.scale(raw_token, (32, 32)) if tier < 0: - icon = icon.copy() icon.set_alpha(120) surface.blit(icon, (icon_x, icon_y)) if tier >= 1: @@ -8920,7 +9021,7 @@ while running: # Update enemies for en in enemies: en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves, - projectiles, enemies, boss_aoes) + boss_projectiles, enemies, boss_aoes) # Enemies that touch a hazard zone die instantly, dropping nothing. for en in enemies: @@ -9029,7 +9130,8 @@ while running: did_hit = player.take_damage(sw.damage) if did_hit: player.apply_knockback(sw.rect, strength_px_per_frame=14.0, duration=0.22, pop_up_vel=-4.5) - player.apply_poison(BOSS_POISON_DURATION) + if sw.poison: + player.apply_poison(BOSS_POISON_DURATION) sw.t = 0 # one-hit # Flying-boss AOE rings hit the player (one hit per ring) diff --git a/light_and_dark/saves/new rooms 2.json b/light_and_dark/saves/new rooms 2.json index 5c77be7..0478ed9 100644 --- a/light_and_dark/saves/new rooms 2.json +++ b/light_and_dark/saves/new rooms 2.json @@ -1,9 +1,9 @@ { "version": 1, - "saved_at": "2026-10-01T14:40:38", + "saved_at": "2026-10-05T09:57:55", "player": { "room_id": "room_07.json", - "x": 620, + "x": 680, "y": 520, "health": 10, "charge": 120.0, @@ -11,7 +11,7 @@ "coins_light": 40, "coins_dark": 20, "butterfly_jars": 0, - "has_ranged": false, + "has_ranged": true, "opened_chests": [ "room_03.json@90,194", "room_07.json@440,494"