flyer boss, town

flyer boss adjustments, town addition
This commit is contained in:
2026-10-05 15:04:20 +02:00
parent 73cfd870f6
commit d56540785f
14 changed files with 491 additions and 123 deletions
+7 -3
View File
@@ -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.
+19 -11
View File
@@ -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.
---
+3 -1
View File
@@ -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). |
+6 -3
View File
@@ -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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

+8 -12
View File
@@ -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
}
+15 -32
View File
@@ -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
}
+5 -3
View File
@@ -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": [],
+24 -2
View File
@@ -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": [],
@@ -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
}
@@ -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
}
+17 -6
View File
@@ -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`);
<div><div class="mini">to</div><input id="exto" type="text" value="${ex.to||""}" placeholder="room_02"/></div>
<div><div class="mini">spawn_id</div><input id="exsid" type="text" value="${ex.spawn_id||""}" placeholder="from_room_01"/></div>
</div>
<div style="margin-top:8px;">
<label><input id="exbossdead" type="checkbox" ${ex.requires_boss_dead?"checked":""}/> Appears/active only after the room's boss is slain</label>
</div>
<div class="muted" style="margin-top:8px;">Tip: use the Linking panel to set these safely.</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
@@ -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;
});
+149 -47
View File
@@ -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 ``<boss_id>_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)
+3 -3
View File
@@ -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"