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`);