mist/attack

mist, charge empty lock, attack changes, floating platform changes
This commit is contained in:
2026-10-01 10:49:35 +02:00
parent ab35845572
commit bf4e8de590
10 changed files with 688 additions and 146 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"fps": 60
},
"colors": {
"bg": [30, 30, 30],
"bg": [58, 58, 64],
"player_light": [200, 200, 200],
"player_dark": [60, 60, 60],
"platform": [80, 80, 80],
+2
View File
@@ -6,6 +6,7 @@
"jump_shade": -9.0,
"gravity_light": 0.5,
"gravity_shade": 0.4,
"jump_release_gravity_mult": 3.0,
"shade_door_grace": 0.6
},
@@ -13,6 +14,7 @@
"base_max_charge": 100.0,
"charge_drain_rate": 40.0,
"charge_refill_rate": 10.0,
"charge_empty_lock": 1.5,
"base_max_health": 10,
"invincibility_duration": 1.0
},
+16 -2
View File
@@ -25,7 +25,21 @@
"air_speed": 1.4,
"detection_range": 500,
"contact_damage": 3,
"drop_profile": "boss"
}
"drop_profile": "boss",
"behavior": "shockwave",
"charger": {
"retreat_dist": 150,
"retreat_jump_vel": -14.0,
"retreat_speed_mult": 1.2,
"charge_speed_mult": 4.0,
"charge_duration": 1.1,
"dash_min_dist": 160,
"dash_max_dist": 420,
"dash_speed_mult": 4.2,
"dash_duration": 0.7,
"attack_cooldown": 1.8,
"above_y": 40
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

+52 -9
View File
@@ -132,7 +132,7 @@
{
"x": 240,
"y": 280,
"w": 180,
"w": 160,
"h": 20,
"group": "moving",
"layer": 0,
@@ -213,16 +213,54 @@
"y": 620,
"w": 60,
"h": 20,
"group": "ground",
"layer": 0
"group": "moving",
"layer": 0,
"path": [
[
720,
620
],
[
720,
540
]
]
},
{
"x": 820,
"y": 540,
"w": 60,
"h": 20,
"group": "ground",
"layer": 0
"group": "moving",
"layer": 0,
"path": [
[
820,
540
],
[
820,
600
]
]
},
{
"x": 1040,
"y": 580,
"w": 100,
"h": 20,
"group": "moving",
"layer": 0,
"path": [
[
1040,
580
],
[
1040,
480
]
]
}
],
"enemies": [
@@ -230,13 +268,15 @@
"x": 760,
"y": 200,
"type": "enemy",
"layer": 0
"layer": 0,
"behavior": ""
},
{
"x": 880,
"y": 640,
"type": "miniboss",
"layer": 0
"layer": 0,
"behavior": ""
}
],
"npcs": [],
@@ -246,7 +286,9 @@
"y": 240,
"type": "save_point",
"radius": 90,
"object_id": "lamp"
"object_id": "lamp",
"layer": 0,
"quest_id": ""
}
],
"exits": [
@@ -304,5 +346,6 @@
}
],
"room_w": 1600,
"room_h": 900
"room_h": 900,
"mist": false
}
+36 -8
View File
@@ -92,6 +92,30 @@
"h": 340,
"group": "ground",
"layer": 0
},
{
"x": 1180,
"y": 440,
"w": 1,
"h": 1,
"group": "ground"
},
{
"x": 1180,
"y": 420,
"w": 60,
"h": 20,
"group": "moving",
"path": [
[
1180,
420
],
[
1180,
360
]
]
}
],
"enemies": [
@@ -99,13 +123,15 @@
"x": 520,
"y": 140,
"type": "enemy",
"layer": 0
"layer": 0,
"behavior": ""
},
{
"x": 1000,
"y": 460,
"type": "enemy",
"layer": 0
"layer": 0,
"behavior": ""
}
],
"npcs": [],
@@ -115,7 +141,9 @@
"y": 360,
"type": "save_point",
"radius": 90,
"object_id": "lamp"
"object_id": "lamp",
"layer": 0,
"quest_id": ""
}
],
"exits": [
@@ -124,7 +152,7 @@
"y": 80,
"w": 80,
"h": 100,
"to": "room_06",
"to": "room_04",
"spawn_id": "room4_bottom_right",
"spawn": [
1480,
@@ -138,11 +166,11 @@
"y": 400,
"w": 80,
"h": 100,
"to": "",
"spawn_id": "",
"to": "room_06",
"spawn_id": "room6_top_right",
"spawn": [
1440,
400
520,
240
],
"hint": "",
"layer": 0
+18 -8
View File
@@ -22,42 +22,48 @@
"y": 300,
"w": 660,
"h": 20,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 420,
"y": 180,
"w": 240,
"h": 20,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 640,
"y": 200,
"w": 20,
"h": 100,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 420,
"y": 0,
"w": 20,
"h": 180,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 0,
"y": 0,
"w": 420,
"h": 20,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 0,
"y": 20,
"w": 20,
"h": 280,
"group": "ground"
"group": "ground",
"layer": 0
}
],
"enemies": [],
@@ -69,6 +75,7 @@
"type": "quest_giver",
"radius": 180,
"object_id": "quest_point",
"layer": 0,
"quest_id": "first_kills_shade"
}
],
@@ -83,10 +90,13 @@
"spawn": [
120,
620
]
],
"hint": "",
"layer": 0
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900
"room_h": 900,
"mist": true
}
+66 -9
View File
@@ -88,9 +88,10 @@
<div class="row">
<button id="downloadActiveBtn" class="good">Download Active</button>
<button id="downloadCheckedBtn">Download Checked</button>
<button id="downloadAllBtn">Download All</button>
</div>
<div class="muted" style="margin-top:6px;">
Downloads are per-room. Checked download will save one file per checked room.
Downloads are per-room. Checked download saves one file per checked room; Download All saves every loaded room.
</div>
</div>
@@ -278,7 +279,14 @@
const DEFAULT_ENEMY_TYPES = {
enemy: { rect: [40, 60], health: 3, speed: 2.2, air_speed: 2.0, detection_range: 240, contact_damage: 1, drop_profile: "normal" },
miniboss: { rect: [70, 95], health: 18, speed: 1.8, air_speed: 1.6, detection_range: 360, contact_damage: 2, drop_profile: "miniboss" },
boss: { rect: [110, 120], health: 10, speed: 1.6, air_speed: 1.4, detection_range: 500, contact_damage: 3, drop_profile: "boss" },
boss: {
rect: [110, 120], health: 10, speed: 1.6, air_speed: 1.4, detection_range: 500, contact_damage: 3, drop_profile: "boss", behavior: "shockwave",
charger: {
retreat_dist: 150, retreat_jump_vel: -14.0, retreat_speed_mult: 1.2, charge_speed_mult: 4.0,
charge_duration: 1.1, dash_min_dist: 160, dash_max_dist: 420, dash_speed_mult: 4.2,
dash_duration: 0.7, attack_cooldown: 1.8, above_y: 40
}
},
};
function seedDefaultObjectCatalog(){
@@ -435,9 +443,9 @@
for(const e of src.enemies){
if(e && typeof e === "object"){
if(Array.isArray(e.pos)){
r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy", layer: +(e.layer||0) });
r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy", layer: +(e.layer||0), behavior: e.behavior || "" });
} else if(typeof e.x === "number" || typeof e.y === "number"){
r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy", layer: +(e.layer||0) });
r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy", layer: +(e.layer||0), behavior: e.behavior || "" });
}
}
}
@@ -524,7 +532,7 @@
// ----- keep other top-level fields (future-proof) -----
r._extras = {};
if(src && typeof src === "object"){
const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","npcs","exits","ability_doors","entry_spawns","props","name","display_name","room_w","room_h"]);
const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","npcs","exits","ability_doors","entry_spawns","props","name","display_name","room_w","room_h","mist"]);
for(const k of Object.keys(src)){
if(!keep.has(k)) r._extras[k] = deepClone(src[k]);
}
@@ -533,6 +541,7 @@
// some editor fields for convenience
if(!r.room_w) r.room_w = src.room_w || 1600;
if(!r.room_h) r.room_h = src.room_h || 900;
r.mist = !!(src && src.mist);
return r;
}
@@ -573,6 +582,7 @@
newRoomBtn: document.getElementById("newRoomBtn"),
downloadActiveBtn: document.getElementById("downloadActiveBtn"),
downloadCheckedBtn: document.getElementById("downloadCheckedBtn"),
downloadAllBtn: document.getElementById("downloadAllBtn"),
activeRoomLabel: document.getElementById("activeRoomLabel"),
status: document.getElementById("status"),
@@ -2121,7 +2131,15 @@ Cancel = Choose whether to remove without saving`);
const roomWrap = sel ? state.rooms.get(sel.roomId) : getActiveRoom();
if(!roomWrap) return "<div class='muted'>No room loaded.</div>";
const room = roomWrap.data;
if(!sel) return "<div class='muted'>Select something to edit properties.</div>";
if(!sel){
return `
<div><b>Room Settings</b></div>
<div class="hr"></div>
<div style="margin-top:8px;">
<label><input id="roommist" type="checkbox" ${room.mist?"checked":""}/> Mist (purple haze - shade form costs no charge)</label>
</div>
`;
}
if(sel.kind==="platform"){
const p=room.platforms[sel.index];
@@ -2244,6 +2262,11 @@ Cancel = Choose whether to remove without saving`);
if(sel.kind==="enemy"){
const e=room.enemies[sel.index];
if(!e) return "<div class='muted'>Selection missing.</div>";
const eType = e.type || "enemy";
const eDef = state.enemyTypes && state.enemyTypes[eType] ? state.enemyTypes[eType] : null;
const eProf = (eDef && eDef.drop_profile) ? eDef.drop_profile : "normal";
const beh = e.behavior || ((eDef && eDef.behavior) ? eDef.behavior : "chaser");
const bossBehavior = (eProf === "boss");
return `
<div><b>Enemy Spawn</b> #${sel.index}</div>
<div class="hr"></div>
@@ -2255,6 +2278,16 @@ Cancel = Choose whether to remove without saving`);
<div class="mini">type</div>
<input id="ent" type="text" value="${e.type||"enemy"}"/>
</div>
${bossBehavior ? `
<div style="margin-top:8px;">
<div class="mini">boss AI</div>
<select id="enbeh">
<option value="" ${beh===""?"selected":""}>default (from enemy type)</option>
<option value="shockwave" ${beh==="shockwave"?"selected":""}>shockwave (slam + shockwave)</option>
<option value="charger" ${beh==="charger"?"selected":""}>charger (retreat-charge + dash)</option>
</select>
</div>
` : ""}
<div style="margin-top:8px;">
<div class="mini">layer (lower = back)</div>
<input id="enlayer" type="number" value="${e.layer||0}"/>
@@ -2371,14 +2404,18 @@ Cancel = Choose whether to remove without saving`);
function num(v){ const n = Number(v); return Number.isFinite(n) ? n : 0; }
if(!sel) return;
const pushAnd = (fn)=>{
pushHistory(roomWrap.id);
fn();
refreshUI();
};
if(!sel){
const rm = q("roommist");
if(rm) rm.addEventListener("change", ()=>pushAnd(()=>{ room.mist = rm.checked; }));
return;
}
if(sel.kind==="platform"){
const p = room.platforms[sel.index]; if(!p) return;
["px","py","pw","ph"].forEach(id=>{
@@ -2511,11 +2548,15 @@ Cancel = Choose whether to remove without saving`);
}));
} else if(sel.kind==="enemy"){
const e = room.enemies[sel.index]; if(!e) return;
const enx=q("enx"), eny=q("eny"), ent=q("ent"), enlayer=q("enlayer");
const enx=q("enx"), eny=q("eny"), ent=q("ent"), enlayer=q("enlayer"), enbeh=q("enbeh");
if(enx) enx.addEventListener("change", ()=>pushAnd(()=>{ e.x=num(enx.value); }));
if(eny) eny.addEventListener("change", ()=>pushAnd(()=>{ e.y=num(eny.value); }));
if(ent) ent.addEventListener("change", ()=>pushAnd(()=>{ e.type=ent.value.trim()||"enemy"; }));
if(enlayer) enlayer.addEventListener("change", ()=>pushAnd(()=>{ e.layer = Math.round(num(enlayer.value)); }));
if(enbeh) enbeh.addEventListener("change", ()=>pushAnd(()=>{
const v = enbeh.value;
if(v) e.behavior = v; else delete e.behavior;
}));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.enemies.splice(sel.index,1);
state.selection=null;
@@ -3338,6 +3379,7 @@ Cancel = Choose whether to remove without saving`);
const enemies = (d.enemies || []).map(e => {
const out = { type: e.type || "enemy", pos: [e.x, e.y] };
if(e.layer) out.layer = e.layer;
if(e.behavior) out.behavior = e.behavior;
return out;
});
@@ -3400,6 +3442,7 @@ Cancel = Choose whether to remove without saving`);
// include entry spawns (new feature) for your spawn linking
if(d.entry_spawns && typeof d.entry_spawns === "object") out.entry_spawns = d.entry_spawns;
if(props.length) out.props = props;
if(d.mist) out.mist = true;
// carry over any extra keys that were in the original
if(d._extras && typeof d._extras === "object"){
@@ -3757,6 +3800,20 @@ els.compareRoomSelect.addEventListener("change", ()=>{
setStatus(`Downloaded ${i} checked room(s).`);
});
els.downloadAllBtn.addEventListener("click", async ()=>{
const rooms = [...state.rooms.values()];
if(!rooms.length){ setStatus("No rooms loaded."); return; }
let i=0;
for(const r of rooms){
downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
r.dirty = false;
i++;
await new Promise(res=>setTimeout(res, 250));
}
refreshRoomsList();
setStatus(`Downloaded ${i} room(s).`);
});
// canvas pointer handlers
// middle-mouse pan (and prevent browser autoscroll / sidebar scrolling)
function startMousePan(e, canvasKey){
+441 -87
View File
@@ -438,6 +438,7 @@ JUMP_LIGHT = float(_phys(["player", "jump_light"], -12.0))
JUMP_SHADE = float(_phys(["player", "jump_shade"], -9.0))
GRAVITY_LIGHT = float(_phys(["player", "gravity_light"], 0.5))
GRAVITY_SHADE = float(_phys(["player", "gravity_shade"], 0.4))
JUMP_RELEASE_GRAVITY_MULT = float(_phys(["player", "jump_release_gravity_mult"], 3.0))
SHADE_DOOR_GRACE = float(_phys(["player", "shade_door_grace"], 0.6))
# Sprite offsets (tune to align art with hitbox)
@@ -450,6 +451,7 @@ PLAYER_HITBOX_H = int(_cfg(["player", "hitbox_h"], 72))
BASE_MAX_CHARGE = float(_phys(["resources", "base_max_charge"], 100.0))
CHARGE_DRAIN_RATE = float(_phys(["resources", "charge_drain_rate"], 40.0))
CHARGE_REFILL_RATE = float(_phys(["resources", "charge_refill_rate"], 10.0))
CHARGE_EMPTY_LOCK = float(_phys(["resources", "charge_empty_lock"], 1.5))
BASE_MAX_HEALTH = int(_phys(["resources", "base_max_health"], 10))
INVINCIBILITY_DURATION = float(_phys(["resources", "invincibility_duration"], 1.0))
@@ -558,6 +560,37 @@ VIGNETTE_LIGHT_EDGE = build_vignette_surface(WIDTH, HEIGHT, (0, 0, 0), edge_alph
VIGNETTE_SHADE = build_vignette_surface(WIDTH, HEIGHT, (90, 30, 130), edge_alpha=120, radius_scale=1.0, base_alpha=85, min_alpha=0.05)
VIGNETTE_SHADE_EDGE = build_vignette_surface(WIDTH, HEIGHT, (90, 30, 130), edge_alpha=120, radius_scale=1.0, base_alpha=0, min_alpha=0.0)
# --- Mist overlay (per-room environmental haze; tiles seamlessly) ---
MIST_TILE_SIZE = 320
def _build_mist_tile(size: int = MIST_TILE_SIZE) -> pygame.Surface:
surf = pygame.Surface((size, size), pygame.SRCALPHA)
rng = random.Random(1337)
surf.fill((80, 25, 130, 6))
for _ in range(14):
cx = rng.randint(0, size)
cy = rng.randint(0, size)
r = rng.randint(50, 130)
alpha = rng.randint(8, 20)
color = (96, 30, 150, alpha)
for ox in (-size, 0, size):
for oy in (-size, 0, size):
pygame.draw.circle(surf, color, (cx + ox, cy + oy), r)
return surf
MIST_TILE = _build_mist_tile()
def draw_mist_overlay(target: pygame.Surface):
if MIST_TILE is None or not getattr(ROOM, "mist", False):
return
size = MIST_TILE_SIZE
t = pygame.time.get_ticks() / 1000.0
dx = int((t * 10.0) % size)
dy = int((t * 6.0) % size)
for tx in range(-1, WIDTH // size + 2):
for ty in range(-1, HEIGHT // size + 2):
target.blit(MIST_TILE, (tx * size - dx, ty * size - dy))
VIGNETTE_CLEAR_RADIUS = 150
_VIGNETTE_HOLE_CACHE: dict[tuple[int, int], pygame.Surface] = {}
@@ -820,6 +853,20 @@ ENEMY_TYPES_DEFAULT = {
"detection_range": 520,
"contact_damage": 3,
"drop_profile": "boss",
"behavior": "shockwave",
"charger": {
"retreat_dist": 150,
"retreat_speed_mult": 1.2,
"retreat_jump_vel": -14.0,
"charge_speed_mult": 4.0,
"charge_duration": 1.1,
"dash_min_dist": 160,
"dash_max_dist": 420,
"dash_speed_mult": 4.2,
"dash_duration": 0.7,
"attack_cooldown": 1.8,
"above_retreat": True
}
},
}
@@ -1135,6 +1182,14 @@ class Room:
self.ground_platforms = self.platforms_ground[:]
self.platforms = self.platforms_ground + self.platforms_floating + self.platforms_moving
all_platform_rects = self.platforms_ground + self.platforms_floating + self.platforms_moving + self.platforms_hazard + self.platforms_trigger
if all_platform_rects:
self.min_x = min(r.left for r in all_platform_rects)
self.max_x = max(r.right for r in all_platform_rects)
else:
self.min_x = 0
self.max_x = 2000
self.enemies = room_json.get("enemies", []) if isinstance(room_json, dict) else []
if not isinstance(self.enemies, list):
self.enemies = []
@@ -1157,6 +1212,8 @@ class Room:
self.coin_drops_cfg = room_json.get("coin_drops", None) if isinstance(room_json, dict) else None
self.mist = bool(room_json.get("mist", False)) if isinstance(room_json, dict) else False
self.ability_doors = room_json.get("ability_doors", []) if isinstance(room_json, dict) else []
if not isinstance(self.ability_doors, list):
self.ability_doors = []
@@ -1275,6 +1332,7 @@ TETHER_SPRITE_LIGHT_PATH = ABILITY_SPRITES_DIR / "Tether_light_ability.png"
TETHER_SPRITE_DARK_PATH = ABILITY_SPRITES_DIR / "Tether_dark_ability.png"
TETHER_EDGE_LIGHT_PATH = ABILITY_SPRITES_DIR / "Tether_light_ability_edge.png"
TETHER_EDGE_DARK_PATH = ABILITY_SPRITES_DIR / "Tether_dark_ability_edge.png"
ATTACK_SPRITE_PATH = ABILITY_SPRITES_DIR / "basic_attack.png"
def _load_image_or_none(p: Path):
try:
@@ -1295,6 +1353,7 @@ TETHER_SPRITE_LIGHT_RAW = _load_image_or_none(TETHER_SPRITE_LIGHT_PATH)
TETHER_SPRITE_DARK_RAW = _load_image_or_none(TETHER_SPRITE_DARK_PATH)
TETHER_EDGE_LIGHT_RAW = _load_image_or_none(TETHER_EDGE_LIGHT_PATH)
TETHER_EDGE_DARK_RAW = _load_image_or_none(TETHER_EDGE_DARK_PATH)
ATTACK_SPRITE_RAW = _load_image_or_none(ATTACK_SPRITE_PATH)
def _load_first_existing(names: list[Path]):
@@ -1418,17 +1477,20 @@ class Shockwave:
class Projectile:
def __init__(self, x, y, direction: int, damage: float):
def __init__(self, x, y, dx: float, dy: float, damage: float):
w, h = PROJECTILE_SIZE
self.rect = pygame.Rect(int(x), int(y), int(w), int(h))
self.dir = 1 if direction >= 0 else -1
self.dir = 1 if dx >= 0 else -1
self.dir_x = float(dx)
self.dir_y = float(dy)
self.speed = float(PROJECTILE_SPEED)
self.t = float(PROJECTILE_LIFETIME)
self.damage = float(damage)
def update(self, dt: float, platforms: list[pygame.Rect], enemies: list, doors=None):
# move
self.rect.x += int(self.dir * self.speed * dt)
self.rect.x += int(self.dir_x * self.speed * dt)
self.rect.y += int(self.dir_y * self.speed * dt)
self.t -= dt
if self.t <= 0:
@@ -3288,6 +3350,7 @@ class Player:
self.vel_y = 0.0
self.on_ground = False
self.ground_platform = None
self.mist_active = False
self.shade = False
self.shade_door_grace = 0.0 # grace period to pass shade doors after a form switch
@@ -3346,7 +3409,9 @@ class Player:
self.attack_duration = 0.2
self.attack_cd = 0.0
self.facing_right = True
self.damage_applied = False
self.attack_dir_x = 1.0
self.attack_dir_y = 0.0
self.swing_hit_ids = set()
self.dash_cd = 0.0
self.dash_timer = 0.0
self.dash_dir = 1
@@ -3357,6 +3422,7 @@ class Player:
self.jump_held_last = False
# --- charge regen lockout (prevents instant refill after attacking) ---
self.charge_regen_lock = 0.0
self.charge_empty_lock = 0.0
self.charge_drain_mult = 1.0 # set per-frame (e.g. 2x when shade-clipping an enemy)
self.poison_timer = 0.0
self.poison_acc = 0.0
@@ -3595,7 +3661,7 @@ class Player:
else:
if self.charge < DASH_LIGHT_CHARGE_COST:
return False
self.charge -= DASH_LIGHT_CHARGE_COST
self._spend_charge(DASH_LIGHT_CHARGE_COST)
self.charge_regen_lock = max(self.charge_regen_lock, 0.35)
self.dash_timer = DASH_DURATION
@@ -3633,18 +3699,41 @@ class Player:
self.shade = not self.shade
self.shade_door_grace = SHADE_DOOR_GRACE
def set_attack_dir(self, camera_x: float, camera_y: float):
"""Aim the next melee/ranged attack at the mouse cursor (world space)."""
mx, my = pygame.mouse.get_pos()
dx = (mx + camera_x) - self.rect.centerx
dy = (my + camera_y) - self.rect.centery
length = math.hypot(dx, dy)
if length < 1.0:
dx, dy = 1.0, 0.0
length = 1.0
self.attack_dir_x = dx / length
self.attack_dir_y = dy / length
def _spend_charge(self, amount: float):
"""Drain charge; hitting exactly 0 starts a regen pause."""
self.charge -= amount
if self.charge <= 0.0:
self.charge = 0.0
self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK)
def attack(self):
if self.healing:
return
if self.charge >= 1 and not self.attacking and self.attack_cd <= 0.0:
self.attacking = True
self.attack_timer = self.effective_attack_duration()
self.charge -= 1
self.damage_applied = False
self._spend_charge(1)
self.swing_hit_ids.clear()
# lock regen for a bit (tweak this number to taste)
self.charge_regen_lock = max(self.charge_regen_lock, 0.35)
# face the aim direction so the sprite follows the mouse
if abs(self.attack_dir_x) >= 0.01:
self.facing_right = self.attack_dir_x > 0
def try_ranged_attack(self, projectiles_list: list, enemies_list: list, platforms_list: list):
if not self.has_ranged:
return
@@ -3656,18 +3745,17 @@ class Player:
return
# ranged costs more than melee
self.charge -= RANGED_CHARGE_COST
self._spend_charge(RANGED_CHARGE_COST)
# 50% of melee attack damage (supports 0.5 now because enemy health is float)
melee_dmg = 2.0 if self.shade else 1.0
dmg = melee_dmg * RANGED_DAMAGE_MULT
# spawn from roughly chest height, slightly in front
px = self.rect.centerx + (16 if self.facing_right else -16)
py = self.rect.centery - 6
# spawn from roughly chest height, offset toward the aim direction
px = self.rect.centerx + int(self.attack_dir_x * 16)
py = self.rect.centery - 6 + int(self.attack_dir_y * 10)
direction = 1 if self.facing_right else -1
projectiles_list.append(Projectile(px, py, direction, dmg))
projectiles_list.append(Projectile(px, py, self.attack_dir_x, self.attack_dir_y, dmg))
# cooldown + small regen lock so it doesn't instantly refill
self.ranged_cd = RANGED_COOLDOWN
@@ -3681,37 +3769,31 @@ class Player:
return boxes[0] if boxes else None
def get_attack_hitboxes(self):
"""Wide-at-player, narrow-outward 'inverse cone' melee hitbox.
The near segment overlaps ~30% into the player's body so enemies you're
hugging (common in shade) still get hit; the far tip keeps its height so
it reliably connects at max reach.
"""
"""Melee hitbox built from a strip of small boxes along the aim
direction, so attacks can fire in any direction (mouse-aimed)."""
if not self.attacking:
return []
body_overlap = int(self.rect.width * 0.3) # ~30% into the body
half_w = self.rect.width * 0.5
# Near (wide) segment
w1 = 28 # wider close to player
h1 = int(self.rect.height * 0.65)
y1 = self.rect.centery - h1 // 2
# reach from just inside the body out to near width + far tip
start = half_w - body_overlap
end = half_w + 28 + 18
n = 6
step = (end - start) / n
# Far (tip) segment: extends reach, slightly narrower but same height
w2 = 18
h2 = h1
y2 = self.rect.centery - h2 // 2
# square cells keep the hitbox thickness consistent (~18px) in any
# aim direction; axis-aligned squares never get thinner than 18px
cross = 18
if self.facing_right:
x1 = self.rect.right - body_overlap
x2 = self.rect.right + w1
else:
x1 = self.rect.left - w1
x2 = self.rect.left - w1 - w2
near = pygame.Rect(int(x1), int(y1), int(w1 + body_overlap), int(h1))
far = pygame.Rect(int(x2), int(y2), int(w2), int(h2))
return [near, far]
boxes = []
for i in range(n):
t = start + (i + 0.5) * step
cx = self.rect.centerx + self.attack_dir_x * t
cy = self.rect.centery + self.attack_dir_y * t
boxes.append(pygame.Rect(int(cx - cross // 2), int(cy - cross // 2), cross, cross))
return boxes
def take_damage(self, amount) -> bool:
@@ -3778,6 +3860,9 @@ class Player:
self.attacking = False
self.attack_timer = 0.0
self.attack_cd = 0.0
self.swing_hit_ids.clear()
self.attack_dir_x = 1.0
self.attack_dir_y = 0.0
self.poison_timer = 0.0
self.poison_acc = 0.0
self.invincibility_timer = 0.0
@@ -3785,6 +3870,8 @@ class Player:
self.health = self.max_health
self.charge = self.max_charge
self.charge_regen_lock = 0.0
self.charge_empty_lock = 0.0
def consume_butterfly_jar(self) -> bool:
"""Consume one butterfly jar to instantly refill charge. Returns True if consumed."""
@@ -3793,6 +3880,7 @@ class Player:
self.butterfly_jars -= 1
self.charge = self.max_charge
self.charge_regen_lock = 0.0
self.charge_empty_lock = 0.0
return True
def _start_heal_if_possible(self, keys):
@@ -3820,7 +3908,10 @@ class Player:
return
drain = self.heal_drain_rate() * dt
prev_charge = self.charge
self.charge = max(0.0, self.charge - drain)
if prev_charge > 0.0 and self.charge <= 0.0:
self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK)
self.heal_progress += dt
dur = self.effective_heal_duration()
@@ -3852,6 +3943,8 @@ class Player:
if self.charge_regen_lock > 0:
self.charge_regen_lock = max(0.0, self.charge_regen_lock - dt)
if self.charge_empty_lock > 0:
self.charge_empty_lock = max(0.0, self.charge_empty_lock - dt)
if self.dash_cd > 0.0:
self.dash_cd = max(0.0, self.dash_cd - dt)
@@ -3879,6 +3972,7 @@ class Player:
if self.reality_break_flash > 0.0:
self.reality_break_flash = max(0.0, self.reality_break_flash - dt)
if not self.attacking:
if key_pressed(keys, "move_left"):
self.facing_right = False
elif key_pressed(keys, "move_right"):
@@ -3931,6 +4025,8 @@ class Player:
gravity = GRAVITY_SHADE if self.shade else GRAVITY_LIGHT
if self.vel_y < 0 and (not key_pressed(keys, "jump")):
gravity *= JUMP_RELEASE_GRAVITY_MULT
self.vel_y += gravity
dy = self.vel_y
@@ -3997,14 +4093,18 @@ class Player:
self._update_heal(keys, dt)
if self.shade:
prev_charge = self.charge
if not self.mist_active:
self.charge -= CHARGE_DRAIN_RATE * getattr(self, "charge_drain_mult", 1.0) * dt
if self.charge <= 0:
self.charge = 0
if prev_charge > 0:
self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK)
self.shade = False
self.shade_door_grace = SHADE_DOOR_GRACE
else:
# no regen while healing, attacking, or during post-attack lockout
if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0):
# no regen while healing, attacking, or during post-attack/empty lockout
if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0) and (self.charge_empty_lock <= 0.0):
regen = CHARGE_REFILL_RATE * (1.0 + self.bonus_charge_regen_pct) * self.charge_regen_mult
self.charge = min(self.max_charge, self.charge + regen * dt)
@@ -4087,6 +4187,15 @@ class Player:
self._draw_fairy(surface, cx, cy, draw_x, draw_y, img.get_width(), img.get_height())
surface.blit(img, (draw_x, draw_y))
if self.attacking and ATTACK_SPRITE_RAW is not None:
ang = math.degrees(math.atan2(-self.attack_dir_y, self.attack_dir_x))
atk_img = pygame.transform.rotate(ATTACK_SPRITE_RAW, ang)
off = 40.0
sx = self.rect.centerx + self.attack_dir_x * off
sy = self.rect.centery + self.attack_dir_y * off
surface.blit(atk_img, (int(sx - atk_img.get_width() / 2 - cx),
int(sy - atk_img.get_height() / 2 - cy)))
# Debug: show player hitbox
pygame.draw.rect(
surface,
@@ -4098,7 +4207,7 @@ class Player:
if self.attacking:
for atk in self.get_attack_hitboxes():
pygame.draw.rect(surface, (255, 0, 0),
pygame.Rect(atk.x - cx, atk.y - cy, atk.width, atk.height))
pygame.Rect(atk.x - cx, atk.y - cy, atk.width, atk.height), 1)
@@ -4106,7 +4215,7 @@ class Player:
# Enemy (configured via JSON) + Boss slam
# ----------------------------
class Enemy:
def __init__(self, x, y, type_id: str):
def __init__(self, x, y, type_id: str, behavior: str | None = None):
self.type_id = type_id
cfg = ENEMY_TYPES.get(type_id, ENEMY_TYPES.get("enemy", {}))
if not isinstance(cfg, dict):
@@ -4149,6 +4258,9 @@ class Enemy:
self.contact_damage = int(cfg.get("contact_damage", 1))
self.drop_profile = str(cfg.get("drop_profile", "normal"))
# Behavior dispatch (chaser = default patrol/chase; shockwave = slam boss; charger = retreat-charge boss)
self.behavior = str(behavior or cfg.get("behavior", "chaser"))
# Boss slam/shockwave tuning (supports per-enemy overrides)
self.slam_cooldown_base = float(BOSS_SLAM_COOLDOWN)
self.slam_trigger_close = float(BOSS_TRIGGER_CLOSE)
@@ -4180,6 +4292,26 @@ class Enemy:
self.bound_light = True
self.reality_break_glow = False
# Charger behavior state (retreat-jump, then charge; or direct dash)
self.charge_cfg = charge_cfg = (cfg.get("charger", {}) if isinstance(cfg, dict) else {})
if not isinstance(charge_cfg, dict):
charge_cfg = {}
self.retreat_dist = float(charge_cfg.get("retreat_dist", 150))
self.retreat_jump_vel = float(charge_cfg.get("retreat_jump_vel", -14.0))
self.retreat_speed_mult = float(charge_cfg.get("retreat_speed_mult", 1.2))
self.charge_speed_mult = float(charge_cfg.get("charge_speed_mult", 4.0))
self.charge_duration = float(charge_cfg.get("charge_duration", 1.1))
self.dash_min_dist = float(charge_cfg.get("dash_min_dist", 160))
self.dash_max_dist = float(charge_cfg.get("dash_max_dist", 420))
self.dash_speed_mult = float(charge_cfg.get("dash_speed_mult", 4.2))
self.dash_duration = float(charge_cfg.get("dash_duration", 0.7))
self.attack_cooldown = float(charge_cfg.get("attack_cooldown", 1.8))
self.charge_above_y = float(charge_cfg.get("above_y", 40))
self.charge_state = "idle" # idle | retreating | charging | dashing
self.charge_dir = 1
self.charge_timer = 0.0
self.attack_cd = 0.0
# --- anti-jiggle helpers ---
self.turn_cooldown = 0.0 # prevents rapid flip-flop at edges/walls
self.last_x = self.rect.x
@@ -4224,6 +4356,9 @@ class Enemy:
self.jump_cd_timer = 0.0
self.slam_cd = 0.0
self.slam_primed = False
self.charge_state = "idle"
self.charge_timer = 0.0
self.attack_cd = 0.0
self.turn_cooldown = 0.0
self.last_x = self.rect.x
@@ -4255,6 +4390,19 @@ class Enemy:
x = self.rect.left - lookahead
return x >= p.left
def support_below(self, platforms, max_dist):
"""Return the nearest platform strictly below the feet within max_dist (or None)."""
probe_y = self.rect.bottom + 1
best = None
best_top = None
for p in platforms:
if p.top >= probe_y and p.left < self.rect.centerx < p.right:
if p.top - probe_y <= max_dist:
if best is None or p.top < best_top:
best = p
best_top = p.top
return best
def _probe_ground_now(self, platforms):
"""Refresh on_ground + ground_platform every frame (stable)."""
@@ -4327,6 +4475,99 @@ class Enemy:
return best
# --- behavior dispatch (config-driven AI) ---
def _behavior_tick(self, dt):
"""Per-behavior cooldown timers."""
if self.behavior == "shockwave":
if self.slam_cd > 0:
self.slam_cd -= dt
elif self.behavior == "charger":
if self.attack_cd > 0:
self.attack_cd -= dt
def _behavior_update(self, dt, player, distance, dx, stunned):
"""Choose/advance an attack per behavior. Runs before gravity/move."""
if self.behavior == "shockwave":
self._shockwave_update(player, distance, stunned)
elif self.behavior == "charger":
self._charger_update(dt, player, distance, dx, stunned)
def _shockwave_update(self, player, distance, stunned):
# Slam when the player is close (anti-stay) OR far (anti-cheese).
if (not stunned) and (not player.shade):
dist = abs(distance)
should_slam = (dist <= self.slam_trigger_close) or (dist >= self.slam_trigger_far)
if self.on_ground and self.slam_cd <= 0 and should_slam and (not self.slam_primed):
self.vel_y = self.slam_jump_vel
self.on_ground = False
self.ground_platform = None
self.slam_primed = True
self.slam_cd = self.slam_cooldown_base
def _charger_update(self, dt, player, distance, dx, stunned):
# State machine: idle -> retreat -> charge, or idle -> dash.
if stunned:
self.charge_state = "idle"
self.charge_timer = 0.0
return
if self.charge_state == "idle":
if not self.on_ground or self.attack_cd > 0 or player.shade:
return
dist = abs(distance)
player_above = player.rect.centery < self.rect.centery - self.charge_above_y
if dist <= self.retreat_dist or player_above:
# Leap away from the player, then charge back on landing.
self.charge_dir = -1 if dx > 0 else 1
self.direction = self.charge_dir
self.vel_y = self.retreat_jump_vel
self.on_ground = False
self.ground_platform = None
self.charge_state = "retreating"
elif self.dash_min_dist <= dist <= self.dash_max_dist:
# Alternate attack: dash straight at the player, no retreat first.
self.charge_dir = 1 if dx >= 0 else -1
self.direction = self.charge_dir
self.charge_state = "dashing"
self.charge_timer = self.dash_duration
self.attack_cd = self.attack_cooldown
elif self.charge_state == "retreating":
# Keep facing away until we land; landing flips us to charging.
self.direction = self.charge_dir
elif self.charge_state in ("charging", "dashing"):
self.charge_timer -= dt
if self.charge_timer <= 0:
self.charge_state = "idle"
self.attack_cd = self.attack_cooldown
def _behavior_on_land(self, player, shockwaves_list):
if self.behavior == "shockwave":
if self.slam_primed:
self.slam_primed = False
spawn_boss_shockwave(self, shockwaves_list)
elif self.behavior == "charger":
if self.charge_state == "retreating":
self.charge_state = "charging"
self.charge_timer = self.charge_duration
dx = player.rect.centerx - self.rect.centerx
self.charge_dir = 1 if dx >= 0 else -1
self.direction = self.charge_dir
def _behavior_vel_x(self, desired_speed):
"""Return an override horizontal velocity, or None to use chase speed."""
if self.behavior == "charger":
if self.charge_state == "retreating":
return self.direction * self.speed * self.retreat_speed_mult
if self.charge_state == "charging":
return self.charge_dir * self.speed * self.charge_speed_mult
if self.charge_state == "dashing":
return self.charge_dir * self.speed * self.dash_speed_mult
return None
def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave]):
if self.jump_cd_timer > 0:
self.jump_cd_timer -= dt
@@ -4358,9 +4599,8 @@ class Enemy:
# Boss slam cooldown tick
if self.type_id == "boss" and self.slam_cd > 0:
self.slam_cd -= dt
# Behavior cooldown tick (slam for shockwave, attack for charger)
self._behavior_tick(dt)
dx = player.rect.centerx - self.rect.centerx
dy = player.rect.centery - self.rect.centery
@@ -4407,16 +4647,8 @@ class Enemy:
stunned = True
# Trigger boss slam: close OR far
if (not stunned) and self.type_id == "boss" and (not player.shade):
dist = abs(distance)
should_slam = (dist <= self.slam_trigger_close) or (dist >= self.slam_trigger_far)
if self.on_ground and self.slam_cd <= 0 and should_slam and (not self.slam_primed):
self.vel_y = self.slam_jump_vel
self.on_ground = False
self.ground_platform = None
self.slam_primed = True
self.slam_cd = self.slam_cooldown_base
# Behavior attack decision (slam for shockwave, retreat-charge/dash for charger)
self._behavior_update(dt, player, distance, dx, stunned)
# gravity + vertical move
self.vel_y += 0.5
@@ -4555,10 +4787,9 @@ class Enemy:
self.direction = -1
# if boss just landed after primed slam -> spawn shockwaves
if self.type_id == "boss" and self.on_ground and (not was_on_ground) and self.slam_primed:
self.slam_primed = False
spawn_boss_shockwave(self, shockwaves_list)
# behavior on landing (shockwave slam impact / charger retreat->charge)
if self.on_ground and (not was_on_ground):
self._behavior_on_land(player, shockwaves_list)
if stunned:
self.vel_x = 0.0
@@ -4584,7 +4815,8 @@ class Enemy:
desired_speed *= TETHER_FEAR_SPEED_MULT
# --- PATROL / CHASE INTENT ---
if chasing:
charg_acting = (self.behavior == "charger" and self.charge_state != "idle")
if chasing and not charg_acting:
cdx = player.rect.centerx - self.rect.centerx
if cdx < -CHASE_DEADZONE:
self.direction = -1
@@ -4594,6 +4826,10 @@ class Enemy:
# aggro just broke near an edge: keep walking off naturally
self.direction = self.stepoff_dir
override_vx = self._behavior_vel_x(desired_speed)
if override_vx is not None:
self.vel_x = override_vx
else:
self.vel_x = self.direction * desired_speed
@@ -4614,7 +4850,7 @@ class Enemy:
)
# Jump small gaps if a safe platform exists on the other side.
if at_edge and (not player_below) and self.jump_cd_timer <= 0:
if at_edge and (not player_below) and self.jump_cd_timer <= 0 and (not charg_acting):
jump_target = self.platform_ahead_for_jump(platforms)
if jump_target is not None:
self.vel_y = self.jump_strength
@@ -4623,6 +4859,9 @@ class Enemy:
self.jump_cd_timer = self.jump_cooldown
if player_below and at_edge:
# Only step off if there's actually a landing platform below;
# don't chase the player off into the void.
if self.support_below(platforms, 500) is not None:
# cleanly step off + fall
# ignore THIS platform for a short time so we don't "snap" back into it
self.drop_ignore_platform = gp
@@ -4632,6 +4871,10 @@ class Enemy:
self.ground_platform = None
self.vel_y = max(self.vel_y, 2.0)
self.turn_cooldown = max(self.turn_cooldown, 0.15)
else:
# No landing below -> turn around at the edge instead.
self.direction *= -1
self.turn_cooldown = max(self.turn_cooldown, 0.2)
# --- apply horizontal movement ---
@@ -4657,7 +4900,15 @@ class Enemy:
if blocked_by_wall and self.on_ground:
if chasing:
if charg_acting:
# Charger slammed into a wall -> end the charge/dash.
if self.charge_state in ("charging", "dashing"):
self.charge_state = "idle"
self.charge_timer = 0.0
self.attack_cd = self.attack_cooldown
self.rect.x = old_x
self.vel_x = 0.0
elif chasing:
# chasing: stop and don't vibrate into the wall
self.rect.x = old_x
self.vel_x = 0.0
@@ -4672,9 +4923,25 @@ class Enemy:
self.vel_x = self.direction * desired_speed
# Room horizontal bounds safety net: never let an enemy leave the room.
lo = getattr(ROOM, "min_x", None)
hi = getattr(ROOM, "max_x", None)
if lo is not None and hi is not None:
if self.rect.left < lo:
self.rect.left = lo
self.direction = 1
if self.vel_x < 0:
self.vel_x = abs(self.vel_x)
elif self.rect.right > hi:
self.rect.right = hi
self.direction = -1
if self.vel_x > 0:
self.vel_x = -abs(self.vel_x)
# regular enemy jump helper
if self.on_ground and chasing and self.jump_cd_timer <= 0:
# regular enemy jump helper (charger moves via charge states instead)
if self.on_ground and chasing and self.jump_cd_timer <= 0 and (not charg_acting):
player_above = player.rect.centery < self.rect.centery - self.jump_when_player_above
if player_above and (blocked_by_wall or self.reachable_platform_ahead(platforms)):
self.vel_y = self.jump_strength
@@ -4694,9 +4961,9 @@ class Enemy:
def draw(self, surface, cx, cy):
if self.type_id == "boss":
if self.drop_profile == "boss":
color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR
elif self.type_id == "miniboss":
elif self.drop_profile == "miniboss":
color = MINIBOSS_HIT_COLOR if self.hit_timer > 0 else MINIBOSS_COLOR
else:
color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR
@@ -4756,9 +5023,11 @@ def spawn_enemies_from_room(room: Room):
if not isinstance(ent, dict):
continue
t = str(ent.get("type", "enemy"))
if t == "boss" and is_boss_defeated(room.id):
e_cfg = ENEMY_TYPES.get(t, ENEMY_TYPES.get("enemy", {}))
e_prof = str(e_cfg.get("drop_profile", "normal")) if isinstance(e_cfg, dict) else "normal"
if e_prof == "boss" and is_boss_defeated(room.id):
continue
if t == "miniboss" and room.id in DEFEATED_MINIBOSSES:
if e_prof == "miniboss" and room.id in DEFEATED_MINIBOSSES:
continue
pos = ent.get("pos", None)
if not (isinstance(pos, (list, tuple)) and len(pos) == 2):
@@ -4772,7 +5041,8 @@ def spawn_enemies_from_room(room: Room):
w, h = cfg.get("rect", [40, 60])
x0 = int(pos[0] - (w / 2))
y0 = int(pos[1] - (h / 2))
enemy = Enemy(x0, y0, t)
behavior_override = ent.get("behavior", None)
enemy = Enemy(x0, y0, t, behavior=behavior_override)
enemy.layer = int(ent.get("layer", 0))
out.append(enemy)
return out
@@ -5013,13 +5283,27 @@ def trigger_wall_blocking(rect) -> bool:
return present
def enemy_trigger_wall_blocking(rect) -> bool:
"""Trigger walls that block enemies.
Arena walls (open_on_miniboss_dead) always contain enemies while their
miniboss is alive — the player's lock_when_pass gate is ignored for them,
so a miniboss can't roam out of its arena before the player locks it in.
All other walls follow the same rules as for the player.
"""
meta = platform_meta.get(id(rect), {})
if meta.get("open_on_miniboss_dead"):
return miniboss_alive_in_room()
return trigger_wall_blocking(rect)
# Lock-on-pass trigger walls (per-session, keyed by rect id).
LOCKED_TRIGGER_WALLS: set[int] = set()
TRIGGER_WALL_TOUCH: dict[int, bool] = {}
def miniboss_alive_in_room() -> bool:
return any(getattr(en, "type_id", "") == "miniboss" and en.health > 0 for en in enemies)
return any(getattr(en, "drop_profile", "") == "miniboss" and en.health > 0 for en in enemies)
def push_rect_out_of_rects(rect, rects):
@@ -5058,8 +5342,35 @@ def reset_trigger_wall_locks():
prev_moving_pos: dict[int, tuple[int, int]] = {}
def moving_platform_blocked(rect: pygame.Rect, new_x: int, new_y: int) -> bool:
"""True if a moving platform would clip into the player or an enemy.
A player genuinely riding on top of the platform (grounded, feet on its top,
overlapping horizontally) is carried along and does NOT block. Everything
else in the platform's way — a player standing underneath it, or any enemy —
stops the platform until the path is clear again."""
candidate = pygame.Rect(new_x, new_y, rect.width, rect.height)
if candidate.colliderect(player.rect):
riding = (
player.on_ground
and player.rect.bottom >= rect.top - 2
and player.rect.bottom <= rect.top + 5
and player.rect.right > rect.left
and player.rect.left < rect.right
)
if not riding:
return True
for en in enemies:
if candidate.colliderect(en.rect):
return True
return False
def update_moving_platforms(dt: float):
"""Advance moving platforms along their waypoint paths (top-left follows the path)."""
"""Advance moving platforms along their waypoint paths (top-left follows the path).
A platform stops while the player or an enemy is in its way, and resumes
from the same spot once the path is clear again."""
for rect in platforms_moving:
mdata = platform_meta.get(id(rect))
if not mdata:
@@ -5089,12 +5400,16 @@ def update_moving_platforms(dt: float):
remaining -= to_end
t = 0.0
i = (i + 1) % n
mdata["i"] = i
mdata["t"] = t
x0, y0 = path[i]
x1, y1 = path[(i + 1) % n]
rect.x = int(round(x0 + (x1 - x0) * t))
rect.y = int(round(y0 + (y1 - y0) * t))
new_x = int(round(x0 + (x1 - x0) * t))
new_y = int(round(y0 + (y1 - y0) * t))
if moving_platform_blocked(rect, new_x, new_y):
continue # wait until the way is clear (keeps current i/t)
mdata["i"] = i
mdata["t"] = t
rect.x = new_x
rect.y = new_y
def draw_layerable(surface, cx, cy):
@@ -5392,6 +5707,8 @@ def draw_settings_menu(surface, selected_idx: int, waiting_action: str | None):
txt = font.render(line, True, col)
surface.blit(txt, (x + 10, start_y + (i - start_idx) * line_h))
mouse_note = font.render("Attack: Left Click Ranged: Right Click", True, (170, 170, 170))
surface.blit(mouse_note, (x + 10, y + panel_h - 42))
hint = font.render("Up/Down = select Enter = rebind Esc = back", True, (170, 170, 170))
surface.blit(hint, (x + 10, y + panel_h - 24))
@@ -5663,6 +5980,7 @@ def apply_room(room: Room, set_player_spawn=True):
ROOM = room
player.mist_active = ROOM.mist
KILL_Y = ROOM.kill_y
platforms_ground = ROOM.platforms_ground
platforms_floating = ROOM.platforms_floating
@@ -5716,7 +6034,7 @@ def apply_room(room: Room, set_player_spawn=True):
push_rect_out_of_rects(player.rect, ROOM.platforms)
def boss_alive_in_room() -> bool:
return any(getattr(en, "type_id", "") == "boss" and en.health > 0 for en in enemies)
return any(getattr(en, "drop_profile", "") == "boss" and en.health > 0 for en in enemies)
def reset_room_combat_state():
@@ -6230,9 +6548,15 @@ def start_refight(boss_id: str, variant: str = "normal") -> bool:
apply_room(room, set_player_spawn=True)
if variant == "hard":
for en in enemies:
if getattr(en, "type_id", "") == "boss":
if getattr(en, "drop_profile", "") == "boss":
en.speed *= 2.0
en.air_speed *= 2.0
if getattr(en, "behavior", "") == "charger":
en.charge_speed_mult *= 1.5
en.dash_speed_mult *= 1.5
en.retreat_speed_mult *= 1.5
en.attack_cooldown = max(0.6, en.attack_cooldown * 0.75)
else:
en.shockwave_speed *= 2.0
en.slam_cooldown_base = max(0.25, en.slam_cooldown_base / 2.0)
return True
@@ -7341,6 +7665,14 @@ while running:
running = False
break
if (not paused) and (not inv_ui.visible) and (not trade_ui.visible):
if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
player.set_attack_dir(camera_x, camera_y)
player.attack()
elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 3:
player.set_attack_dir(camera_x, camera_y)
player.try_ranged_attack(projectiles, enemies, platforms)
if e.type == pygame.KEYDOWN:
was_trade_visible = trade_ui.visible
was_inv_visible = inv_ui.visible
@@ -7433,9 +7765,11 @@ while running:
player.toggle_shade()
if key_is("attack", e.key):
player.set_attack_dir(camera_x, camera_y)
player.attack()
if key_is("ranged", e.key):
player.set_attack_dir(camera_x, camera_y)
player.try_ranged_attack(projectiles, enemies, platforms)
if key_is("dash", e.key):
@@ -7507,6 +7841,8 @@ while running:
screen.blit(VIGNETTE_LIGHT, (0, 0))
screen.blit(VIGNETTE_LIGHT_EDGE, (0, 0))
draw_mist_overlay(screen)
# HUD
draw_shatterstep_icon(screen, player)
draw_tether_icon(screen, player)
@@ -7624,6 +7960,16 @@ while running:
collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] + blocking_triggers
# Enemies collide with ground/floating platforms, doors and trigger walls —
# but NOT moving platforms. Moving platforms are player traversal; a platform
# descending into an enemy shoves it off edges / through the floor.
# Arena walls (open_on_miniboss_dead) always contain enemies.
moving_ids = {id(r) for r in platforms_moving}
enemy_blocking_triggers = [t for t in platforms_trigger if enemy_trigger_wall_blocking(t)]
enemy_collision_platforms = [r for r in platforms if id(r) not in moving_ids]
enemy_collision_platforms += [d.rect for d in ability_doors if not d.open]
enemy_collision_platforms += enemy_blocking_triggers
# Shade doors are solid for the world, but the player can pass through them
# freely while in shade form — and for a short grace period after switching
# forms, so they aren't instantly ejected from a door they're standing in.
@@ -7774,7 +8120,7 @@ while running:
# Update enemies
for en in enemies:
en.update(dt, collision_platforms, ground_platforms, player, shockwaves)
en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves)
# Enemies that touch a hazard zone die instantly, dropping nothing.
for en in enemies:
@@ -7790,12 +8136,17 @@ while running:
atk_boxes = player.get_attack_hitboxes()
if atk_boxes and not player.damage_applied:
if player.attacking:
dmg = 2 if player.shade else 1
for en in enemies:
if en.health <= 0:
continue
eid = id(en)
if eid in player.swing_hit_ids:
continue
if any(box.colliderect(en.rect) for box in atk_boxes):
player.swing_hit_ids.add(eid)
en.take_damage(dmg)
player.damage_applied = True
# Dash interactions
if player.is_dashing():
@@ -7813,9 +8164,9 @@ while running:
else:
# brief invuln on hit + stun
player.invincibility_timer = max(player.invincibility_timer, DASH_LIGHT_INVULN)
if getattr(en, "type_id", "") == "boss":
if getattr(en, "drop_profile", "") == "boss":
en.apply_stun(DASH_STUN_BOSS)
elif getattr(en, "type_id", "") == "miniboss":
elif getattr(en, "drop_profile", "") == "miniboss":
en.apply_stun(DASH_STUN_MINIBOSS)
else:
en.apply_stun(DASH_STUN_NORMAL)
@@ -7894,12 +8245,13 @@ while running:
continue # hazard deaths drop nothing and give no quest credit
loot_drops.extend(spawn_drops_from_enemy(en))
etype = getattr(en, "type_id", "")
if etype == "boss":
eprof = getattr(en, "drop_profile", "")
if eprof == "boss":
mark_boss_defeated(ROOM.id)
bid = boss_id_for_room(ROOM.id)
if bid and ACTIVE_REFIGHT is None:
token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid))
elif etype == "miniboss":
elif eprof == "miniboss":
DEFEATED_MINIBOSSES.add(ROOM.id)
# Quest kill tracking
@@ -7912,15 +8264,15 @@ while running:
base_x = en.rect.centerx - 8
base_y = en.rect.top - 20
if getattr(en, "type_id", "") == "enemy":
if getattr(en, "drop_profile", "") == "normal":
spawn_coins("light", roll_normal_light(), base_x, base_y)
if player.shade:
spawn_coins("dark", roll_normal_dark(), base_x, base_y)
elif getattr(en, "type_id", "") == "miniboss":
elif getattr(en, "drop_profile", "") == "miniboss":
spawn_coins("light", roll_miniboss_light(), base_x, base_y)
if player.shade:
spawn_coins("dark", roll_miniboss_dark(), base_x, base_y)
elif getattr(en, "type_id", "") == "boss":
elif getattr(en, "drop_profile", "") == "boss":
boss_light = roll_boss_light()
spawn_coins("light", boss_light, base_x, base_y)
if player.shade:
@@ -8109,7 +8461,7 @@ while running:
)
# Boss health bar (if boss exists)
boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None)
boss = next((en for en in enemies if getattr(en, "drop_profile", "") == "boss"), None)
if boss is not None:
bar_w = 360
bar_h = 18
@@ -8133,6 +8485,8 @@ while running:
screen.blit(VIGNETTE_LIGHT, (0, 0))
screen.blit(VIGNETTE_LIGHT_EDGE, (0, 0))
draw_mist_overlay(screen)
draw_shatterstep_icon(screen, player)
draw_tether_icon(screen, player)
draw_reality_break_icon(screen, player)
+40 -6
View File
@@ -1,15 +1,15 @@
{
"version": 1,
"saved_at": "2026-09-30T11:51:08",
"saved_at": "2026-10-01T10:39:22",
"player": {
"room_id": "room_04.json",
"x": 590,
"x": 509,
"y": 240,
"health": 10,
"charge": 100.0,
"shade": false,
"coins_light": 39,
"coins_dark": 4,
"coins_light": 46,
"coins_dark": 6,
"butterfly_jars": 2,
"has_ranged": false,
"opened_chests": [
@@ -17,14 +17,48 @@
],
"equipment": {
"helmet": null,
"chest": null,
"chest": {
"id": "vitalweave_chest",
"name": "Vitalweave Chestplate",
"set": "vitalweave",
"slot": "chest",
"base_stats": {},
"rarity_overrides": {
"tempered": {
"extra_stats": {
"heal_speed_pct": 0.1
}
},
"attuned": {
"extra_stats": {
"heal_speed_pct": 0.15
}
},
"awakened": {
"extra_stats": {
"heal_speed_pct": 0.15,
"health": 5
}
}
},
"rarity": "attuned",
"tier": 2,
"stats": {
"heal_speed_pct": 0.15
},
"abilities": []
},
"pants": null,
"necklace": null
}
},
"inventory": [],
"quests": {
"active": {},
"active": {
"first_kills_shade": {
"progress": 0
}
},
"completed_one_time": [],
"ever_completed": [
"quest_first_kills"