flying boss
boss addtion, small editor changes
This commit is contained in:
@@ -37,6 +37,10 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop
|
|||||||
| Stats overlay | H |
|
| Stats overlay | H |
|
||||||
| Pause / Settings | Esc |
|
| Pause / Settings | Esc |
|
||||||
|
|
||||||
|
**Mouse**: **Left button** = melee attack, **Right button** = ranged shot.
|
||||||
|
|
||||||
|
**Hold-to-recast**: Melee (E / left mouse) and Ranged (Q / right mouse) can be **held** — the attack auto-recasts as soon as its cooldown is ready, and never faster than the cooldown.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The two forms
|
## The two forms
|
||||||
@@ -77,7 +81,7 @@ When you die you lose your **butterfly jars for good** and drop your coins. A **
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Melee** | E | 1 damage, costs 1 Charge | **2 damage**, costs 1 Charge |
|
| **Melee** | E | 1 damage, costs 1 Charge | **2 damage**, costs 1 Charge |
|
||||||
| **Dash** | Tab | Costs 10 Charge; **stuns** enemies hit | Costs 1 HP; deals **4 damage** to enemies hit |
|
| **Dash** | Tab | Costs 10 Charge; **stuns** enemies hit | Costs 1 HP; deals **4 damage** to enemies hit |
|
||||||
| **Ranged** | Q | Costs 5 Charge, 0.45s cooldown, 0.5 damage; **breaks green ability walls** | Same, but 1.0 damage |
|
| **Ranged** | Q | Costs 5 Charge, 0.63s cooldown, 0.5 damage; **breaks green ability walls** | Same, but 1.0 damage |
|
||||||
| **Shatterstep** | R | Spawns a **platform under your feet** while airborne (extra jumps) | On landing, creates a damaging **shockwave AOE** below you |
|
| **Shatterstep** | R | Spawns a **platform under your feet** while airborne (extra jumps) | On landing, creates a damaging **shockwave AOE** below you |
|
||||||
| **Tether** | X | **Binds** the enemy (3s) and **fears** it (runs away) | **Insta-kills** enemies below a HP threshold (normal ⅓, miniboss 20%, boss 5%); otherwise **slows** them |
|
| **Tether** | X | **Binds** the enemy (3s) and **fears** it (runs away) | **Insta-kills** enemies below a HP threshold (normal ⅓, miniboss 20%, boss 5%); otherwise **slows** them |
|
||||||
| **Reality Break** | C | Doubles drop & coin rates and glows enemies | **Instantly kills all enemies** but halves drop & coin rates |
|
| **Reality Break** | C | Doubles drop & coin rates and glows enemies | **Instantly kills all enemies** but halves drop & coin rates |
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
"miniboss": [170, 40, 210],
|
"miniboss": [170, 40, 210],
|
||||||
"miniboss_hit": [220, 140, 255],
|
"miniboss_hit": [220, 140, 255],
|
||||||
"boss": [240, 220, 80],
|
"boss": [240, 220, 80],
|
||||||
"boss_hit": [255, 245, 160]
|
"boss_hit": [255, 245, 160],
|
||||||
|
"boss_flyer": [150, 90, 220],
|
||||||
|
"boss_flyer_hit": [210, 170, 255]
|
||||||
},
|
},
|
||||||
|
|
||||||
"healing": {
|
"healing": {
|
||||||
|
|||||||
@@ -40,6 +40,51 @@
|
|||||||
"attack_cooldown": 1.8,
|
"attack_cooldown": 1.8,
|
||||||
"above_y": 40
|
"above_y": 40
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"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",
|
||||||
|
"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,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"boss_minion": {
|
||||||
|
"rect": [34, 50],
|
||||||
|
"health": 4,
|
||||||
|
"speed": 2.4,
|
||||||
|
"air_speed": 2.2,
|
||||||
|
"detection_range": 320,
|
||||||
|
"contact_damage": 1,
|
||||||
|
"drop_profile": "normal"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 320 B |
@@ -14,6 +14,10 @@
|
|||||||
"room7_top_left": [
|
"room7_top_left": [
|
||||||
180,
|
180,
|
||||||
460
|
460
|
||||||
|
],
|
||||||
|
"room7_bottom_right": [
|
||||||
|
1040,
|
||||||
|
460
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"platforms": [
|
"platforms": [
|
||||||
@@ -28,10 +32,18 @@
|
|||||||
{
|
{
|
||||||
"x": 80,
|
"x": 80,
|
||||||
"y": 520,
|
"y": 520,
|
||||||
"w": 920,
|
"w": 1080,
|
||||||
"h": 20,
|
"h": 20,
|
||||||
"group": "ground",
|
"group": "ground",
|
||||||
"layer": 0
|
"layer": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1160,
|
||||||
|
"y": 320,
|
||||||
|
"w": 20,
|
||||||
|
"h": 220,
|
||||||
|
"group": "ground",
|
||||||
|
"layer": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"enemies": [],
|
"enemies": [],
|
||||||
@@ -64,7 +76,9 @@
|
|||||||
"y": 520,
|
"y": 520,
|
||||||
"type": "save_point",
|
"type": "save_point",
|
||||||
"radius": 90,
|
"radius": 90,
|
||||||
"object_id": "lamp"
|
"object_id": "lamp",
|
||||||
|
"layer": 0,
|
||||||
|
"quest_id": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"exits": [
|
"exits": [
|
||||||
@@ -81,6 +95,20 @@
|
|||||||
],
|
],
|
||||||
"hint": "",
|
"hint": "",
|
||||||
"layer": 0
|
"layer": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1080,
|
||||||
|
"y": 420,
|
||||||
|
"w": 80,
|
||||||
|
"h": 100,
|
||||||
|
"to": "room_08",
|
||||||
|
"spawn_id": "room8_bottom_left",
|
||||||
|
"spawn": [
|
||||||
|
300,
|
||||||
|
400
|
||||||
|
],
|
||||||
|
"hint": "",
|
||||||
|
"layer": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ability_doors": [],
|
"ability_doors": [],
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "room_08",
|
||||||
|
"display_name": "room_08",
|
||||||
|
"meta": {
|
||||||
|
"id": "room_08",
|
||||||
|
"name": "room_08"
|
||||||
|
},
|
||||||
|
"bounds": {},
|
||||||
|
"player_spawn": [
|
||||||
|
340,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"entry_spawns": {
|
||||||
|
"room8_bottom_left": [
|
||||||
|
300,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"platforms": [
|
||||||
|
{
|
||||||
|
"x": 200,
|
||||||
|
"y": 460,
|
||||||
|
"w": 1340,
|
||||||
|
"h": 20,
|
||||||
|
"group": "ground",
|
||||||
|
"layer": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 180,
|
||||||
|
"y": 120,
|
||||||
|
"w": 20,
|
||||||
|
"h": 360,
|
||||||
|
"group": "ground",
|
||||||
|
"layer": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1520,
|
||||||
|
"y": 100,
|
||||||
|
"w": 20,
|
||||||
|
"h": 360,
|
||||||
|
"group": "ground",
|
||||||
|
"layer": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"enemies": [
|
||||||
|
{
|
||||||
|
"x": 820,
|
||||||
|
"y": 220,
|
||||||
|
"type": "boss",
|
||||||
|
"layer": 0,
|
||||||
|
"behavior": "flyer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"npcs": [],
|
||||||
|
"props": [],
|
||||||
|
"exits": [
|
||||||
|
{
|
||||||
|
"x": 200,
|
||||||
|
"y": 360,
|
||||||
|
"w": 80,
|
||||||
|
"h": 100,
|
||||||
|
"to": "room_07",
|
||||||
|
"spawn_id": "room7_bottom_right",
|
||||||
|
"spawn": [
|
||||||
|
1040,
|
||||||
|
460
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ability_doors": [],
|
||||||
|
"room_w": 1600,
|
||||||
|
"room_h": 900,
|
||||||
|
"mist": false
|
||||||
|
}
|
||||||
@@ -88,10 +88,10 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="downloadActiveBtn" class="good">Download Active</button>
|
<button id="downloadActiveBtn" class="good">Download Active</button>
|
||||||
<button id="downloadCheckedBtn">Download Checked</button>
|
<button id="downloadCheckedBtn">Download Checked</button>
|
||||||
<button id="downloadAllBtn">Download All</button>
|
<button id="downloadAllBtn">Download Modified</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted" style="margin-top:6px;">
|
<div class="muted" style="margin-top:6px;">
|
||||||
Downloads are per-room. Checked download saves one file per checked room; Download All saves every loaded room.
|
Downloads are per-room. Checked download saves one file per checked room; Download Modified saves every room you've changed.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
</div> <div class="row" style="margin-bottom:8px;">
|
</div> <div class="row" style="margin-bottom:8px;">
|
||||||
<label class="muted" style="display:flex; align-items:center; gap:8px;">
|
<label class="muted" style="display:flex; align-items:center; gap:8px;">
|
||||||
<input type="checkbox" id="showLinksChk"/>
|
<input type="checkbox" id="showLinksChk"/>
|
||||||
Draw link lines (auto-enables Compare)
|
Draw link lines
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,6 +288,17 @@
|
|||||||
dash_duration: 0.7, attack_cooldown: 1.8, above_y: 40
|
dash_duration: 0.7, attack_cooldown: 1.8, above_y: 40
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
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",
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
boss_minion: { rect: [34, 50], health: 4, speed: 2.4, air_speed: 2.2, detection_range: 320, contact_damage: 1, drop_profile: "normal" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function seedDefaultObjectCatalog(){
|
function seedDefaultObjectCatalog(){
|
||||||
@@ -2317,6 +2328,7 @@ Cancel = Choose whether to remove without saving`);
|
|||||||
<option value="" ${beh===""?"selected":""}>default (from enemy type)</option>
|
<option value="" ${beh===""?"selected":""}>default (from enemy type)</option>
|
||||||
<option value="shockwave" ${beh==="shockwave"?"selected":""}>shockwave (slam + shockwave)</option>
|
<option value="shockwave" ${beh==="shockwave"?"selected":""}>shockwave (slam + shockwave)</option>
|
||||||
<option value="charger" ${beh==="charger"?"selected":""}>charger (retreat-charge + dash)</option>
|
<option value="charger" ${beh==="charger"?"selected":""}>charger (retreat-charge + dash)</option>
|
||||||
|
<option value="flyer" ${beh==="flyer"?"selected":""}>flyer (air boss: slam, minions, aoe, homing shot)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
@@ -3783,14 +3795,9 @@ Cancel = Choose whether to remove without saving`);
|
|||||||
refreshUI();
|
refreshUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
els.showLinksChk.checked = false; // always start unchecked (no auto Compare)
|
||||||
els.showLinksChk.addEventListener("change", ()=>{
|
els.showLinksChk.addEventListener("change", ()=>{
|
||||||
if(els.showLinksChk.checked){
|
// Lines only draw while Compare is on too (see drawLinkLinesForCanvas).
|
||||||
if(!state.compareEnabled){
|
|
||||||
state.compareEnabled = true;
|
|
||||||
const other = [...state.rooms.keys()].find(k => k !== state.activeId);
|
|
||||||
state.compareId = other || state.activeId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// overlay updates in render loop
|
// overlay updates in render loop
|
||||||
refreshUI();
|
refreshUI();
|
||||||
});
|
});
|
||||||
@@ -3850,8 +3857,8 @@ els.compareRoomSelect.addEventListener("change", ()=>{
|
|||||||
});
|
});
|
||||||
|
|
||||||
els.downloadAllBtn.addEventListener("click", async ()=>{
|
els.downloadAllBtn.addEventListener("click", async ()=>{
|
||||||
const rooms = [...state.rooms.values()];
|
const rooms = [...state.rooms.values()].filter(r=>r.dirty);
|
||||||
if(!rooms.length){ setStatus("No rooms loaded."); return; }
|
if(!rooms.length){ setStatus("No modified rooms."); return; }
|
||||||
let i=0;
|
let i=0;
|
||||||
for(const r of rooms){
|
for(const r of rooms){
|
||||||
downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
|
downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
|
||||||
@@ -3860,7 +3867,7 @@ els.compareRoomSelect.addEventListener("change", ()=>{
|
|||||||
await new Promise(res=>setTimeout(res, 250));
|
await new Promise(res=>setTimeout(res, 250));
|
||||||
}
|
}
|
||||||
refreshRoomsList();
|
refreshRoomsList();
|
||||||
setStatus(`Downloaded ${i} room(s).`);
|
setStatus(`Downloaded ${i} modified room(s).`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// canvas pointer handlers
|
// canvas pointer handlers
|
||||||
|
|||||||
+565
-3
@@ -433,6 +433,9 @@ MINIBOSS_HIT_COLOR = _color(["enemy", "miniboss_hit"])
|
|||||||
BOSS_COLOR = _color(["enemy", "boss"])
|
BOSS_COLOR = _color(["enemy", "boss"])
|
||||||
BOSS_HIT_COLOR = _color(["enemy", "boss_hit"])
|
BOSS_HIT_COLOR = _color(["enemy", "boss_hit"])
|
||||||
|
|
||||||
|
FLYER_BOSS_COLOR = _color(["enemy", "boss_flyer"], (150, 90, 220))
|
||||||
|
FLYER_BOSS_HIT_COLOR = _color(["enemy", "boss_flyer_hit"], (210, 170, 255))
|
||||||
|
|
||||||
PLAYER_HIT_COLOR = (255, 255, 0)
|
PLAYER_HIT_COLOR = (255, 255, 0)
|
||||||
|
|
||||||
HEAL_DARK = _color(["healing", "dark"])
|
HEAL_DARK = _color(["healing", "dark"])
|
||||||
@@ -907,6 +910,49 @@ ENEMY_TYPES_DEFAULT = {
|
|||||||
"above_retreat": True
|
"above_retreat": True
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
|
"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,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"boss_minion": {
|
||||||
|
"rect": [34, 50],
|
||||||
|
"health": 4,
|
||||||
|
"speed": 2.4,
|
||||||
|
"air_speed": 2.2,
|
||||||
|
"detection_range": 320,
|
||||||
|
"contact_damage": 1,
|
||||||
|
"drop_profile": "normal"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ENEMY_DROPS_DEFAULT = {
|
ENEMY_DROPS_DEFAULT = {
|
||||||
@@ -1453,6 +1499,15 @@ BOSS_TOKEN_SPRITE_RAW = _load_first_existing([
|
|||||||
OBJECTS_DIR / "Boss_Token.png",
|
OBJECTS_DIR / "Boss_Token.png",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# 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([
|
||||||
|
ENEMY_SKILLS_DIR / "boss_ranged.png",
|
||||||
|
ENEMY_SKILLS_DIR / "boss_ranged",
|
||||||
|
ENEMY_SKILLS_DIR / "Boss_ranged.png",
|
||||||
|
])
|
||||||
|
BOSS_PROJECTILE_RAW_LEFT = pygame.transform.flip(BOSS_PROJECTILE_RAW, True, False) if BOSS_PROJECTILE_RAW is not None else None
|
||||||
|
|
||||||
def _scale_icon(img: pygame.Surface | None, size: int) -> pygame.Surface | None:
|
def _scale_icon(img: pygame.Surface | None, size: int) -> pygame.Surface | None:
|
||||||
if img is None:
|
if img is None:
|
||||||
return None
|
return None
|
||||||
@@ -1628,6 +1683,123 @@ class Projectile:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BossProjectile:
|
||||||
|
"""Homing projectile fired by the flying boss. Slightly curves toward the
|
||||||
|
player each frame. Uses boss_ranged.png (faces right; flipped when fired
|
||||||
|
to the left)."""
|
||||||
|
def __init__(self, x, y, dx, dy, damage, speed, lifetime, homing, player):
|
||||||
|
self.rect = pygame.Rect(0, 0, 26, 26)
|
||||||
|
self.rect.center = (int(x), int(y))
|
||||||
|
self.dir_x = float(dx)
|
||||||
|
self.dir_y = float(dy)
|
||||||
|
d = math.hypot(self.dir_x, self.dir_y)
|
||||||
|
if d > 0.0001:
|
||||||
|
self.dir_x /= d
|
||||||
|
self.dir_y /= d
|
||||||
|
self.speed = float(speed)
|
||||||
|
self.t = float(lifetime)
|
||||||
|
self.damage = float(damage)
|
||||||
|
self.homing = float(homing)
|
||||||
|
self.player = player
|
||||||
|
|
||||||
|
def update(self, dt: float, platforms: list[pygame.Rect]):
|
||||||
|
# slight homing: steer the direction toward the player
|
||||||
|
px = self.player.rect.centerx - self.rect.centerx
|
||||||
|
py = self.player.rect.centery - self.rect.centery
|
||||||
|
plen = math.hypot(px, py)
|
||||||
|
if plen > 1.0:
|
||||||
|
tx = px / plen
|
||||||
|
ty = py / plen
|
||||||
|
k = min(1.0, self.homing * dt)
|
||||||
|
self.dir_x += (tx - self.dir_x) * k
|
||||||
|
self.dir_y += (ty - self.dir_y) * k
|
||||||
|
d = math.hypot(self.dir_x, self.dir_y)
|
||||||
|
if d > 0.0001:
|
||||||
|
self.dir_x /= d
|
||||||
|
self.dir_y /= d
|
||||||
|
|
||||||
|
self.rect.x += int(self.dir_x * self.speed * dt)
|
||||||
|
self.rect.y += int(self.dir_y * self.speed * dt)
|
||||||
|
self.t -= dt
|
||||||
|
|
||||||
|
for p in platforms:
|
||||||
|
if self.rect.colliderect(p):
|
||||||
|
self.t = 0
|
||||||
|
break
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alive(self) -> bool:
|
||||||
|
return self.t > 0
|
||||||
|
|
||||||
|
def draw(self, surface, cx, cy):
|
||||||
|
if BOSS_PROJECTILE_RAW is not None:
|
||||||
|
if self.dir_x >= 0:
|
||||||
|
img = BOSS_PROJECTILE_RAW
|
||||||
|
angle = -math.degrees(math.atan2(self.dir_y, self.dir_x))
|
||||||
|
else:
|
||||||
|
img = BOSS_PROJECTILE_RAW_LEFT
|
||||||
|
angle = math.degrees(math.atan2(self.dir_y, -self.dir_x))
|
||||||
|
img = pygame.transform.rotate(img, angle)
|
||||||
|
surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx,
|
||||||
|
self.rect.centery - img.get_height() // 2 - cy))
|
||||||
|
return
|
||||||
|
# fallback: glowing bolt
|
||||||
|
pygame.draw.rect(
|
||||||
|
surface,
|
||||||
|
(255, 120, 60),
|
||||||
|
pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height),
|
||||||
|
)
|
||||||
|
pygame.draw.rect(
|
||||||
|
surface,
|
||||||
|
(0, 0, 0),
|
||||||
|
pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BossAoe:
|
||||||
|
"""Huge expanding circular blast: low damage, high knockback, one hit."""
|
||||||
|
def __init__(self, x, y, max_radius, damage, duration,
|
||||||
|
knock_strength=16.0, knock_duration=0.35, pop_up=-6.0):
|
||||||
|
self.center = (int(x), int(y))
|
||||||
|
self.max_radius = int(max_radius)
|
||||||
|
self.radius = 0
|
||||||
|
self.damage = int(damage)
|
||||||
|
self.full_duration = max(0.05, float(duration))
|
||||||
|
self.t = float(duration)
|
||||||
|
self.hit = False
|
||||||
|
self.knock_strength = float(knock_strength)
|
||||||
|
self.knock_duration = float(knock_duration)
|
||||||
|
self.pop_up = float(pop_up)
|
||||||
|
|
||||||
|
def update(self, dt: float):
|
||||||
|
self.t -= dt
|
||||||
|
progress = 1.0 - max(0.0, self.t / self.full_duration)
|
||||||
|
self.radius = int(self.max_radius * progress)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alive(self) -> bool:
|
||||||
|
return self.t > 0
|
||||||
|
|
||||||
|
def hits(self, rect: pygame.Rect) -> bool:
|
||||||
|
if self.hit:
|
||||||
|
return False
|
||||||
|
cx, cy = self.center
|
||||||
|
nx = max(rect.left, min(cx, rect.right))
|
||||||
|
ny = max(rect.top, min(cy, rect.bottom))
|
||||||
|
return math.hypot(cx - nx, cy - ny) <= self.radius
|
||||||
|
|
||||||
|
def draw(self, surface, cx, cy):
|
||||||
|
progress = 1.0 - max(0.0, self.t / self.full_duration)
|
||||||
|
alpha = int(110 * (1.0 - progress * 0.65))
|
||||||
|
px = self.center[0] - cx
|
||||||
|
py = self.center[1] - cy
|
||||||
|
ring = pygame.Surface((self.radius * 2 + 6, self.radius * 2 + 6), pygame.SRCALPHA)
|
||||||
|
pygame.draw.circle(ring, (255, 120, 60, alpha), (self.radius + 3, self.radius + 3), self.radius, 4)
|
||||||
|
pygame.draw.circle(ring, (255, 210, 130, alpha), (self.radius + 3, self.radius + 3), self.radius, 1)
|
||||||
|
surface.blit(ring, (px - self.radius - 3, py - self.radius - 3))
|
||||||
|
|
||||||
|
|
||||||
class TetherShot:
|
class TetherShot:
|
||||||
def __init__(self, x, y, direction: int, player_ref: "Player"):
|
def __init__(self, x, y, direction: int, player_ref: "Player"):
|
||||||
w, h = TETHER_SIZE
|
w, h = TETHER_SIZE
|
||||||
@@ -4430,6 +4602,51 @@ class Enemy:
|
|||||||
self.charge_timer = 0.0
|
self.charge_timer = 0.0
|
||||||
self.attack_cd = 0.0
|
self.attack_cd = 0.0
|
||||||
|
|
||||||
|
# --- Flying boss ("flyer") behavior state ---
|
||||||
|
flyer_cfg = (cfg.get("flyer", {}) if isinstance(cfg, dict) else {})
|
||||||
|
if not isinstance(flyer_cfg, dict):
|
||||||
|
flyer_cfg = {}
|
||||||
|
self.flyer_flight_height = float(flyer_cfg.get("flight_height", 100))
|
||||||
|
self.flyer_bob_amplitude = float(flyer_cfg.get("bob_amplitude", 20))
|
||||||
|
self.flyer_bob_speed = float(flyer_cfg.get("bob_speed", 2.2))
|
||||||
|
self.flyer_hover_speed = float(flyer_cfg.get("hover_speed", 2.6))
|
||||||
|
self.flyer_slam_cooldown = float(flyer_cfg.get("slam_cd", 6.0))
|
||||||
|
self.flyer_dive_vel = float(flyer_cfg.get("slam_dive_vel", 16.0))
|
||||||
|
self.flyer_ground_time = float(flyer_cfg.get("ground_time", 2.0))
|
||||||
|
self.flyer_summon_cooldown = float(flyer_cfg.get("summon_cd", 8.0))
|
||||||
|
self.flyer_summon_max = int(flyer_cfg.get("summon_max", 3))
|
||||||
|
self.flyer_summon_type = str(flyer_cfg.get("summon_type", "boss_minion"))
|
||||||
|
self.flyer_summon_life = float(flyer_cfg.get("summon_life", 6.0))
|
||||||
|
self.flyer_summon_heal = float(flyer_cfg.get("summon_heal", 4))
|
||||||
|
self.flyer_summon_damage = float(flyer_cfg.get("summon_damage", 3))
|
||||||
|
self.flyer_aoe_cooldown = float(flyer_cfg.get("aoe_cd", 9.0))
|
||||||
|
self.flyer_aoe_radius = float(flyer_cfg.get("aoe_radius", 300))
|
||||||
|
self.flyer_aoe_damage = int(flyer_cfg.get("aoe_damage", 1))
|
||||||
|
self.flyer_aoe_duration = float(flyer_cfg.get("aoe_duration", 0.9))
|
||||||
|
self.flyer_shot_cooldown = float(flyer_cfg.get("shot_cd", 5.0))
|
||||||
|
self.flyer_shot_damage = float(flyer_cfg.get("shot_damage", 5))
|
||||||
|
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_state = "hover" # hover | diving | grounded
|
||||||
|
self.flyer_grounded_timer = 0.0
|
||||||
|
self.flyer_bob_t = random.random() * 10.0
|
||||||
|
self.flyer_last_ground_top = self.rect.bottom
|
||||||
|
self.flyer_slam_cd = 0.0
|
||||||
|
self.flyer_summon_cd = 0.0
|
||||||
|
self.flyer_aoe_cd = 0.0
|
||||||
|
self.flyer_shot_cd = 0.0
|
||||||
|
|
||||||
|
# --- summoned boss minion: expires after a short life, then heals the
|
||||||
|
# boss; killing it early damages the boss. Color fades toward white. ---
|
||||||
|
self.is_minion = (self.type_id == "boss_minion")
|
||||||
|
self.minion_timer = 0.0
|
||||||
|
self.minion_duration = 0.0
|
||||||
|
self.minion_owner = None
|
||||||
|
self.minion_heal = 0.0
|
||||||
|
self.minion_damage = 0.0
|
||||||
|
self.minion_expired = False
|
||||||
|
|
||||||
# --- anti-jiggle helpers ---
|
# --- anti-jiggle helpers ---
|
||||||
self.turn_cooldown = 0.0 # prevents rapid flip-flop at edges/walls
|
self.turn_cooldown = 0.0 # prevents rapid flip-flop at edges/walls
|
||||||
self.last_x = self.rect.x
|
self.last_x = self.rect.x
|
||||||
@@ -4478,6 +4695,15 @@ class Enemy:
|
|||||||
self.charge_timer = 0.0
|
self.charge_timer = 0.0
|
||||||
self.attack_cd = 0.0
|
self.attack_cd = 0.0
|
||||||
|
|
||||||
|
self.flyer_state = "hover"
|
||||||
|
self.flyer_grounded_timer = 0.0
|
||||||
|
self.flyer_bob_t = random.random() * 10.0
|
||||||
|
self.flyer_last_ground_top = self.rect.bottom
|
||||||
|
self.flyer_slam_cd = 0.0
|
||||||
|
self.flyer_summon_cd = 0.0
|
||||||
|
self.flyer_aoe_cd = 0.0
|
||||||
|
self.flyer_shot_cd = 0.0
|
||||||
|
|
||||||
self.turn_cooldown = 0.0
|
self.turn_cooldown = 0.0
|
||||||
self.last_x = self.rect.x
|
self.last_x = self.rect.x
|
||||||
self.stuck_timer = 0.0
|
self.stuck_timer = 0.0
|
||||||
@@ -4617,6 +4843,11 @@ class Enemy:
|
|||||||
elif self.behavior == "charger":
|
elif self.behavior == "charger":
|
||||||
if self.attack_cd > 0:
|
if self.attack_cd > 0:
|
||||||
self.attack_cd -= dt
|
self.attack_cd -= dt
|
||||||
|
elif self.behavior == "flyer":
|
||||||
|
for attr in ("flyer_slam_cd", "flyer_summon_cd", "flyer_aoe_cd", "flyer_shot_cd"):
|
||||||
|
v = getattr(self, attr)
|
||||||
|
if v > 0:
|
||||||
|
setattr(self, attr, max(0.0, v - dt))
|
||||||
|
|
||||||
def _behavior_update(self, dt, player, distance, dx, stunned):
|
def _behavior_update(self, dt, player, distance, dx, stunned):
|
||||||
"""Choose/advance an attack per behavior. Runs before gravity/move."""
|
"""Choose/advance an attack per behavior. Runs before gravity/move."""
|
||||||
@@ -4700,7 +4931,255 @@ class Enemy:
|
|||||||
return self.charge_dir * self.speed * self.dash_speed_mult
|
return self.charge_dir * self.speed * self.dash_speed_mult
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave]):
|
# --- Flying boss ("flyer") behavior ---
|
||||||
|
|
||||||
|
def _flyer_update(self, dt, platforms, player, shockwaves_list,
|
||||||
|
projectiles_list, enemies_list, aoes_list):
|
||||||
|
"""The flying boss hovers in the air (bobbing like wings), and uses its
|
||||||
|
aerial skills: slam when the player is below, ground minion summoning,
|
||||||
|
a huge low-damage/high-knockback AOE, and a slightly-homing shot."""
|
||||||
|
# own timer ticks (the flyer bypasses the shared ground-based update body)
|
||||||
|
if self.hit_timer > 0:
|
||||||
|
self.hit_timer -= dt
|
||||||
|
if self.bind_timer > 0.0:
|
||||||
|
self.bind_timer = max(0.0, self.bind_timer - dt)
|
||||||
|
if self.fear_timer > 0.0:
|
||||||
|
self.fear_timer = max(0.0, self.fear_timer - dt)
|
||||||
|
if self.slow_timer > 0.0:
|
||||||
|
self.slow_timer = max(0.0, self.slow_timer - dt)
|
||||||
|
if self.slow_timer == 0.0:
|
||||||
|
self.slow_mult = 1.0
|
||||||
|
self._behavior_tick(dt)
|
||||||
|
|
||||||
|
stunned = self.stun_timer > 0.0
|
||||||
|
if self.stun_timer > 0.0:
|
||||||
|
self.stun_timer = max(0.0, self.stun_timer - dt)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
feared = self.fear_timer > 0.0
|
||||||
|
if feared:
|
||||||
|
chasing = False
|
||||||
|
self.direction = -1 if dx > 0 else 1
|
||||||
|
elif chasing and abs(dx) > 8:
|
||||||
|
self.direction = 1 if dx > 0 else -1
|
||||||
|
|
||||||
|
speed = self.flyer_hover_speed
|
||||||
|
if self.slow_timer > 0.0:
|
||||||
|
speed *= self.slow_mult
|
||||||
|
if feared:
|
||||||
|
speed *= TETHER_FEAR_SPEED_MULT
|
||||||
|
|
||||||
|
if self.flyer_state == "diving":
|
||||||
|
self._flyer_update_diving(dt, platforms, speed, shockwaves_list)
|
||||||
|
elif self.flyer_state == "grounded":
|
||||||
|
ground_speed = self.speed * (self.slow_mult if self.slow_timer > 0.0 else 1.0)
|
||||||
|
if feared:
|
||||||
|
ground_speed *= TETHER_FEAR_SPEED_MULT
|
||||||
|
self._flyer_update_grounded(dt, platforms, player, ground_speed, feared)
|
||||||
|
else:
|
||||||
|
self._flyer_update_hover(dt, platforms, player, speed, chasing, feared,
|
||||||
|
stunned, projectiles_list, enemies_list, aoes_list)
|
||||||
|
|
||||||
|
if self.rect.top > (ROOM.kill_y + 400):
|
||||||
|
self.respawn()
|
||||||
|
return
|
||||||
|
self.was_chasing = chasing
|
||||||
|
|
||||||
|
def _flyer_update_hover(self, dt, platforms, player, speed, chasing, feared,
|
||||||
|
stunned, projectiles_list, enemies_list, aoes_list):
|
||||||
|
ground = self.support_below(platforms, 5000)
|
||||||
|
if ground is not None:
|
||||||
|
self.flyer_last_ground_top = ground.top
|
||||||
|
ground_top = getattr(self, "flyer_last_ground_top", self.rect.bottom)
|
||||||
|
|
||||||
|
self.flyer_bob_t += dt * self.flyer_bob_speed
|
||||||
|
bob = math.sin(self.flyer_bob_t) * self.flyer_bob_amplitude
|
||||||
|
target_y = ground_top - self.flyer_flight_height + bob
|
||||||
|
|
||||||
|
if feared:
|
||||||
|
self.vel_x = -self.direction * speed
|
||||||
|
elif chasing and not stunned:
|
||||||
|
self.vel_x = self.direction * speed
|
||||||
|
else:
|
||||||
|
self.vel_x *= 0.9
|
||||||
|
if abs(self.vel_x) < 0.5:
|
||||||
|
self.vel_x = 0.0
|
||||||
|
|
||||||
|
# ease vertically toward the hover target (stable, frame-rate-based like the rest)
|
||||||
|
diff = target_y - self.rect.y
|
||||||
|
self.rect.y += int(diff * min(1.0, 0.12))
|
||||||
|
self.rect.x += int(self.vel_x)
|
||||||
|
|
||||||
|
# room bounds safety net (the flying boss ignores platform walls; it
|
||||||
|
# hovers above the terrain and only needs to stay inside the room)
|
||||||
|
lo = getattr(ROOM, "min_x", None)
|
||||||
|
hi = getattr(ROOM, "max_x", None)
|
||||||
|
if lo is not None:
|
||||||
|
self.rect.left = max(self.rect.left, lo)
|
||||||
|
if hi is not None:
|
||||||
|
self.rect.right = min(self.rect.right, hi)
|
||||||
|
room_h = getattr(ROOM, "room_h", 900)
|
||||||
|
self.rect.y = max(20, min(self.rect.y, room_h - self.rect.height))
|
||||||
|
|
||||||
|
if chasing and (not stunned) and (not feared):
|
||||||
|
self._flyer_cast_skills(dt, player, projectiles_list, enemies_list, aoes_list, platforms)
|
||||||
|
|
||||||
|
def _flyer_update_diving(self, dt, platforms, speed, shockwaves_list):
|
||||||
|
# dive down until we hit a platform, then slam and stay grounded a moment.
|
||||||
|
self.vel_y = min(self.flyer_dive_vel, self.vel_y + 1.0)
|
||||||
|
self.rect.y += int(self.vel_y)
|
||||||
|
self.rect.x += int(self.direction * speed * dt)
|
||||||
|
for p in platforms:
|
||||||
|
if self.rect.colliderect(p):
|
||||||
|
self.rect.bottom = p.top
|
||||||
|
self.vel_y = 0.0
|
||||||
|
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)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _flyer_update_grounded(self, dt, platforms, player, speed, feared):
|
||||||
|
"""After a slam the boss stays grounded briefly: walks toward the player
|
||||||
|
and can deal contact damage, then lifts back into the air."""
|
||||||
|
self.flyer_grounded_timer -= dt
|
||||||
|
dx = player.rect.centerx - self.rect.centerx
|
||||||
|
if feared:
|
||||||
|
self.direction = -1 if dx > 0 else 1
|
||||||
|
elif abs(dx) > 8:
|
||||||
|
self.direction = 1 if dx > 0 else -1
|
||||||
|
|
||||||
|
# gravity + vertical move/landing
|
||||||
|
self.vel_y = min(24.0, self.vel_y + 0.5)
|
||||||
|
self.rect.y += int(self.vel_y)
|
||||||
|
landed = False
|
||||||
|
for p in platforms:
|
||||||
|
if self.rect.colliderect(p):
|
||||||
|
if self.vel_y > 0:
|
||||||
|
self.rect.bottom = p.top
|
||||||
|
self.vel_y = 0.0
|
||||||
|
landed = True
|
||||||
|
elif self.vel_y < 0:
|
||||||
|
self.rect.top = p.bottom
|
||||||
|
self.vel_y = 0.0
|
||||||
|
self.on_ground = landed
|
||||||
|
|
||||||
|
# horizontal move + wall collision
|
||||||
|
self.vel_x = self.direction * speed
|
||||||
|
self.rect.x += int(self.vel_x)
|
||||||
|
for p in platforms:
|
||||||
|
if self.rect.colliderect(p):
|
||||||
|
if self.vel_x > 0:
|
||||||
|
self.rect.right = p.left
|
||||||
|
elif self.vel_x < 0:
|
||||||
|
self.rect.left = p.right
|
||||||
|
self.vel_x = 0.0
|
||||||
|
break
|
||||||
|
|
||||||
|
# room bounds safety net
|
||||||
|
lo = getattr(ROOM, "min_x", None)
|
||||||
|
hi = getattr(ROOM, "max_x", None)
|
||||||
|
if lo is not None:
|
||||||
|
self.rect.left = max(self.rect.left, lo)
|
||||||
|
if hi is not None:
|
||||||
|
self.rect.right = min(self.rect.right, hi)
|
||||||
|
|
||||||
|
if self.flyer_grounded_timer <= 0.0:
|
||||||
|
self.flyer_state = "hover"
|
||||||
|
self.vel_y = 0.0
|
||||||
|
self.vel_x = 0.0
|
||||||
|
|
||||||
|
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
|
||||||
|
return
|
||||||
|
|
||||||
|
# Otherwise pick from the ranged kit (shuffle so it isn't a fixed order).
|
||||||
|
for skill in random.sample(("shot", "summon", "aoe"), 3):
|
||||||
|
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:
|
||||||
|
if self._flyer_cast_summon(enemies_list, platforms):
|
||||||
|
return
|
||||||
|
if skill == "aoe" and self.flyer_aoe_cd <= 0.0:
|
||||||
|
self._flyer_cast_aoe(aoes_list)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _flyer_cast_shot(self, player, projectiles_list):
|
||||||
|
sx = self.rect.centerx
|
||||||
|
sy = self.rect.centery
|
||||||
|
tx = player.rect.centerx - sx
|
||||||
|
ty = player.rect.centery - sy
|
||||||
|
length = math.hypot(tx, ty)
|
||||||
|
if length < 1.0:
|
||||||
|
tx, ty, length = 1.0, 0.0, 1.0
|
||||||
|
projectiles_list.append(BossProjectile(
|
||||||
|
sx, sy, tx / length, ty / length,
|
||||||
|
self.flyer_shot_damage, self.flyer_shot_speed,
|
||||||
|
self.flyer_shot_lifetime, self.flyer_shot_homing, player,
|
||||||
|
))
|
||||||
|
self.flyer_shot_cd = self.flyer_shot_cooldown
|
||||||
|
|
||||||
|
def _flyer_cast_summon(self, enemies_list, platforms) -> bool:
|
||||||
|
alive = sum(
|
||||||
|
1 for en in enemies_list
|
||||||
|
if getattr(en, "type_id", "") == self.flyer_summon_type and en.health > 0
|
||||||
|
)
|
||||||
|
if alive >= self.flyer_summon_max:
|
||||||
|
return False
|
||||||
|
ground = self.support_below(platforms, 5000)
|
||||||
|
if ground is not None:
|
||||||
|
gx = self.rect.centerx
|
||||||
|
gy = ground.top
|
||||||
|
else:
|
||||||
|
gx = self.rect.centerx
|
||||||
|
gy = int(getattr(ROOM, "room_h", 900)) - 40
|
||||||
|
s_cfg = ENEMY_TYPES.get(self.flyer_summon_type, ENEMY_TYPES.get("enemy", {}))
|
||||||
|
sw = int(s_cfg.get("rect", [40, 60])[0]) if isinstance(s_cfg, dict) else 40
|
||||||
|
sh = int(s_cfg.get("rect", [40, 60])[1]) if isinstance(s_cfg, dict) else 60
|
||||||
|
minion = Enemy(gx - sw // 2, gy - sh, self.flyer_summon_type)
|
||||||
|
minion.is_minion = True
|
||||||
|
minion.minion_owner = self
|
||||||
|
minion.minion_duration = self.flyer_summon_life
|
||||||
|
minion.minion_timer = self.flyer_summon_life
|
||||||
|
minion.minion_heal = self.flyer_summon_heal
|
||||||
|
minion.minion_damage = self.flyer_summon_damage
|
||||||
|
enemies_list.append(minion)
|
||||||
|
self.flyer_summon_cd = self.flyer_summon_cooldown
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _flyer_cast_aoe(self, aoes_list):
|
||||||
|
aoes_list.append(BossAoe(
|
||||||
|
self.rect.centerx, self.rect.centery,
|
||||||
|
self.flyer_aoe_radius, self.flyer_aoe_damage, self.flyer_aoe_duration,
|
||||||
|
))
|
||||||
|
self.flyer_aoe_cd = self.flyer_aoe_cooldown
|
||||||
|
|
||||||
|
def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave],
|
||||||
|
projectiles_list=None, enemies_list=None, aoes_list=None):
|
||||||
|
# Summoned minions expire after their short life: no drops, heals the boss.
|
||||||
|
if getattr(self, "is_minion", False) and self.minion_duration > 0:
|
||||||
|
self.minion_timer -= dt
|
||||||
|
if self.minion_timer <= 0.0:
|
||||||
|
self.minion_timer = 0.0
|
||||||
|
self.health = 0.0
|
||||||
|
self.hazard_kill = True
|
||||||
|
self.minion_expired = True
|
||||||
|
|
||||||
|
# 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 [])
|
||||||
|
return
|
||||||
|
|
||||||
if self.jump_cd_timer > 0:
|
if self.jump_cd_timer > 0:
|
||||||
self.jump_cd_timer -= dt
|
self.jump_cd_timer -= dt
|
||||||
|
|
||||||
@@ -5126,13 +5605,21 @@ class Enemy:
|
|||||||
|
|
||||||
|
|
||||||
def draw(self, surface, cx, cy):
|
def draw(self, surface, cx, cy):
|
||||||
if self.drop_profile == "boss":
|
if self.behavior == "flyer":
|
||||||
|
color = FLYER_BOSS_HIT_COLOR if self.hit_timer > 0 else FLYER_BOSS_COLOR
|
||||||
|
elif self.drop_profile == "boss":
|
||||||
color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR
|
color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR
|
||||||
elif self.drop_profile == "miniboss":
|
elif self.drop_profile == "miniboss":
|
||||||
color = MINIBOSS_HIT_COLOR if self.hit_timer > 0 else MINIBOSS_COLOR
|
color = MINIBOSS_HIT_COLOR if self.hit_timer > 0 else MINIBOSS_COLOR
|
||||||
else:
|
else:
|
||||||
color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR
|
color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR
|
||||||
|
|
||||||
|
# Summoned minions fade toward white as their life runs out.
|
||||||
|
if getattr(self, "is_minion", False) and self.minion_duration > 0:
|
||||||
|
frac = max(0.0, min(1.0, self.minion_timer / self.minion_duration))
|
||||||
|
light = 1.0 - frac
|
||||||
|
color = tuple(int(c + (255 - c) * light * 0.9) for c in color)
|
||||||
|
|
||||||
pygame.draw.rect(
|
pygame.draw.rect(
|
||||||
surface,
|
surface,
|
||||||
color,
|
color,
|
||||||
@@ -6026,6 +6513,8 @@ shockwaves: list[Shockwave] = []
|
|||||||
coin_drops: list[CoinPickup] = []
|
coin_drops: list[CoinPickup] = []
|
||||||
token_drops: list = []
|
token_drops: list = []
|
||||||
projectiles: list[Projectile] = []
|
projectiles: list[Projectile] = []
|
||||||
|
boss_projectiles: list[BossProjectile] = []
|
||||||
|
boss_aoes: list[BossAoe] = []
|
||||||
shatter_platforms: list[ShatterPlatform] = []
|
shatter_platforms: list[ShatterPlatform] = []
|
||||||
shatter_aoes: list[ShatterAOE] = []
|
shatter_aoes: list[ShatterAOE] = []
|
||||||
tether_shots: list[TetherShot] = []
|
tether_shots: list[TetherShot] = []
|
||||||
@@ -6236,6 +6725,7 @@ def apply_room(room: Room, set_player_spawn=True):
|
|||||||
global platforms_dissolve
|
global platforms_dissolve
|
||||||
global platform_layer
|
global platform_layer
|
||||||
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
|
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
|
||||||
|
global boss_projectiles, boss_aoes
|
||||||
global shatter_platforms, shatter_aoes, tether_shots
|
global shatter_platforms, shatter_aoes, tether_shots
|
||||||
global light_sources, save_points, butterfly_jars, houses
|
global light_sources, save_points, butterfly_jars, houses
|
||||||
global dust_puffs
|
global dust_puffs
|
||||||
@@ -6273,6 +6763,8 @@ def apply_room(room: Room, set_player_spawn=True):
|
|||||||
coin_drops = []
|
coin_drops = []
|
||||||
token_drops = []
|
token_drops = []
|
||||||
projectiles = []
|
projectiles = []
|
||||||
|
boss_projectiles = []
|
||||||
|
boss_aoes = []
|
||||||
shatter_platforms = []
|
shatter_platforms = []
|
||||||
shatter_aoes = []
|
shatter_aoes = []
|
||||||
tether_shots = []
|
tether_shots = []
|
||||||
@@ -6310,6 +6802,7 @@ def reset_room_combat_state():
|
|||||||
"""Rebuild room enemies/NPCs/doors and clear transient combat objects."""
|
"""Rebuild room enemies/NPCs/doors and clear transient combat objects."""
|
||||||
global enemies, npcs, loot_drops, shockwaves, coin_drops, token_drops, projectiles
|
global enemies, npcs, loot_drops, shockwaves, coin_drops, token_drops, projectiles
|
||||||
global shatter_platforms, shatter_aoes, dust_puffs, tether_shots, ability_doors
|
global shatter_platforms, shatter_aoes, dust_puffs, tether_shots, ability_doors
|
||||||
|
global boss_projectiles, boss_aoes
|
||||||
reset_trigger_wall_locks()
|
reset_trigger_wall_locks()
|
||||||
enemies = spawn_enemies_from_room(ROOM)
|
enemies = spawn_enemies_from_room(ROOM)
|
||||||
npcs = spawn_npcs_from_room(ROOM)
|
npcs = spawn_npcs_from_room(ROOM)
|
||||||
@@ -6318,6 +6811,8 @@ def reset_room_combat_state():
|
|||||||
coin_drops = []
|
coin_drops = []
|
||||||
token_drops = []
|
token_drops = []
|
||||||
projectiles = []
|
projectiles = []
|
||||||
|
boss_projectiles = []
|
||||||
|
boss_aoes = []
|
||||||
shatter_platforms = []
|
shatter_platforms = []
|
||||||
shatter_aoes = []
|
shatter_aoes = []
|
||||||
dust_puffs = []
|
dust_puffs = []
|
||||||
@@ -8113,8 +8608,12 @@ while running:
|
|||||||
t.draw(screen, camera_x, camera_y)
|
t.draw(screen, camera_x, camera_y)
|
||||||
for sw in shockwaves:
|
for sw in shockwaves:
|
||||||
sw.draw(screen, camera_x, camera_y)
|
sw.draw(screen, camera_x, camera_y)
|
||||||
|
for a in boss_aoes:
|
||||||
|
a.draw(screen, camera_x, camera_y)
|
||||||
for pr in projectiles:
|
for pr in projectiles:
|
||||||
pr.draw(screen, camera_x, camera_y)
|
pr.draw(screen, camera_x, camera_y)
|
||||||
|
for bp in boss_projectiles:
|
||||||
|
bp.draw(screen, camera_x, camera_y)
|
||||||
for ts in tether_shots:
|
for ts in tether_shots:
|
||||||
ts.draw(screen, camera_x, camera_y)
|
ts.draw(screen, camera_x, camera_y)
|
||||||
if corpse is not None and corpse.room_id == ROOM.id:
|
if corpse is not None and corpse.room_id == ROOM.id:
|
||||||
@@ -8420,7 +8919,8 @@ while running:
|
|||||||
|
|
||||||
# Update enemies
|
# Update enemies
|
||||||
for en in enemies:
|
for en in enemies:
|
||||||
en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves)
|
en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves,
|
||||||
|
projectiles, enemies, boss_aoes)
|
||||||
|
|
||||||
# Enemies that touch a hazard zone die instantly, dropping nothing.
|
# Enemies that touch a hazard zone die instantly, dropping nothing.
|
||||||
for en in enemies:
|
for en in enemies:
|
||||||
@@ -8447,6 +8947,12 @@ while running:
|
|||||||
if any(box.colliderect(en.rect) for box in atk_boxes):
|
if any(box.colliderect(en.rect) for box in atk_boxes):
|
||||||
player.swing_hit_ids.add(eid)
|
player.swing_hit_ids.add(eid)
|
||||||
en.take_damage(dmg)
|
en.take_damage(dmg)
|
||||||
|
# Downward strike bounce: hitting an enemy from above while
|
||||||
|
# swinging down bounces the player up (stomp/pogo).
|
||||||
|
if player.attack_dir_y > 0.3 and player.rect.bottom <= en.rect.top + 6:
|
||||||
|
player.vel_y = min(player.vel_y, -8.0)
|
||||||
|
player.on_ground = False
|
||||||
|
player.ground_platform = None
|
||||||
|
|
||||||
# Dash interactions
|
# Dash interactions
|
||||||
if player.is_dashing():
|
if player.is_dashing():
|
||||||
@@ -8485,6 +8991,11 @@ while running:
|
|||||||
pr.update(dt, collision_platforms, enemies, ability_doors)
|
pr.update(dt, collision_platforms, enemies, ability_doors)
|
||||||
projectiles = [pr for pr in projectiles if pr.alive]
|
projectiles = [pr for pr in projectiles if pr.alive]
|
||||||
|
|
||||||
|
# Update flying-boss projectiles (homing shots)
|
||||||
|
for bp in boss_projectiles:
|
||||||
|
bp.update(dt, collision_platforms)
|
||||||
|
boss_projectiles = [bp for bp in boss_projectiles if bp.alive]
|
||||||
|
|
||||||
# Update tether shots
|
# Update tether shots
|
||||||
for ts in tether_shots:
|
for ts in tether_shots:
|
||||||
ts.update(dt, collision_platforms, enemies)
|
ts.update(dt, collision_platforms, enemies)
|
||||||
@@ -8496,6 +9007,11 @@ while running:
|
|||||||
sw.update(dt, collision_platforms)
|
sw.update(dt, collision_platforms)
|
||||||
shockwaves = [sw for sw in shockwaves if sw.alive]
|
shockwaves = [sw for sw in shockwaves if sw.alive]
|
||||||
|
|
||||||
|
# Update flying-boss AOE rings
|
||||||
|
for a in boss_aoes:
|
||||||
|
a.update(dt)
|
||||||
|
boss_aoes = [a for a in boss_aoes if a.alive]
|
||||||
|
|
||||||
# Update shatter effects
|
# Update shatter effects
|
||||||
for sp in shatter_platforms:
|
for sp in shatter_platforms:
|
||||||
sp.update(dt)
|
sp.update(dt)
|
||||||
@@ -8516,6 +9032,27 @@ while running:
|
|||||||
player.apply_poison(BOSS_POISON_DURATION)
|
player.apply_poison(BOSS_POISON_DURATION)
|
||||||
sw.t = 0 # one-hit
|
sw.t = 0 # one-hit
|
||||||
|
|
||||||
|
# Flying-boss AOE rings hit the player (one hit per ring)
|
||||||
|
for a in boss_aoes:
|
||||||
|
if (not player.shade) and a.hits(player.rect):
|
||||||
|
did_hit = player.take_damage(a.damage)
|
||||||
|
if did_hit:
|
||||||
|
player.apply_knockback(
|
||||||
|
pygame.Rect(a.center[0] - 2, a.center[1] - 2, 4, 4),
|
||||||
|
strength_px_per_frame=a.knock_strength,
|
||||||
|
duration=a.knock_duration,
|
||||||
|
pop_up_vel=a.pop_up,
|
||||||
|
)
|
||||||
|
a.hit = True
|
||||||
|
|
||||||
|
# Flying-boss homing projectiles hit the player
|
||||||
|
for bp in boss_projectiles[:]:
|
||||||
|
if (not player.shade) and player.rect.colliderect(bp.rect):
|
||||||
|
did_hit = player.take_damage(bp.damage)
|
||||||
|
if did_hit:
|
||||||
|
player.apply_knockback(bp.rect, strength_px_per_frame=10.0, duration=0.18, pop_up_vel=-3.0)
|
||||||
|
boss_projectiles.remove(bp)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Update loot drops (gravity + landing)
|
# Update loot drops (gravity + landing)
|
||||||
@@ -8541,8 +9078,20 @@ while running:
|
|||||||
if en.health > 0:
|
if en.health > 0:
|
||||||
alive.append(en)
|
alive.append(en)
|
||||||
else:
|
else:
|
||||||
|
# Summoned minion expired on its own: heals the boss, no drops.
|
||||||
|
if getattr(en, "minion_expired", False):
|
||||||
|
owner = getattr(en, "minion_owner", None)
|
||||||
|
if owner is not None and owner.health > 0:
|
||||||
|
owner.health = min(owner.max_health, owner.health + getattr(en, "minion_heal", 0.0))
|
||||||
|
continue
|
||||||
if getattr(en, "hazard_kill", False):
|
if getattr(en, "hazard_kill", False):
|
||||||
continue # hazard deaths drop nothing and give no quest credit
|
continue # hazard deaths drop nothing and give no quest credit
|
||||||
|
# Player-killed minion: damages the boss, no drops.
|
||||||
|
if getattr(en, "type_id", "") == "boss_minion":
|
||||||
|
owner = getattr(en, "minion_owner", None)
|
||||||
|
if owner is not None and owner.health > 0:
|
||||||
|
owner.take_damage(getattr(en, "minion_damage", 0.0))
|
||||||
|
continue
|
||||||
loot_drops.extend(spawn_drops_from_enemy(en))
|
loot_drops.extend(spawn_drops_from_enemy(en))
|
||||||
etype = getattr(en, "type_id", "")
|
etype = getattr(en, "type_id", "")
|
||||||
eprof = getattr(en, "drop_profile", "")
|
eprof = getattr(en, "drop_profile", "")
|
||||||
@@ -8551,6 +9100,13 @@ while running:
|
|||||||
bid = boss_id_for_room(ROOM.id)
|
bid = boss_id_for_room(ROOM.id)
|
||||||
if bid and ACTIVE_REFIGHT is None:
|
if bid and ACTIVE_REFIGHT is None:
|
||||||
token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid))
|
token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid))
|
||||||
|
# A defeated flying boss takes its summoned minions down with it.
|
||||||
|
for other in enemies:
|
||||||
|
if other is en:
|
||||||
|
continue
|
||||||
|
if getattr(other, "type_id", "") == "boss_minion" and other.health > 0:
|
||||||
|
other.health = 0.0
|
||||||
|
other.hazard_kill = True
|
||||||
elif eprof == "miniboss":
|
elif eprof == "miniboss":
|
||||||
DEFEATED_MINIBOSSES.add(ROOM.id)
|
DEFEATED_MINIBOSSES.add(ROOM.id)
|
||||||
|
|
||||||
@@ -8680,9 +9236,15 @@ while running:
|
|||||||
for sw in shockwaves:
|
for sw in shockwaves:
|
||||||
sw.draw(screen, camera_x, camera_y)
|
sw.draw(screen, camera_x, camera_y)
|
||||||
|
|
||||||
|
# flying-boss AOE rings
|
||||||
|
for a in boss_aoes:
|
||||||
|
a.draw(screen, camera_x, camera_y)
|
||||||
|
|
||||||
# projectiles
|
# projectiles
|
||||||
for pr in projectiles:
|
for pr in projectiles:
|
||||||
pr.draw(screen, camera_x, camera_y)
|
pr.draw(screen, camera_x, camera_y)
|
||||||
|
for bp in boss_projectiles:
|
||||||
|
bp.draw(screen, camera_x, camera_y)
|
||||||
for ts in tether_shots:
|
for ts in tether_shots:
|
||||||
ts.draw(screen, camera_x, camera_y)
|
ts.draw(screen, camera_x, camera_y)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user