mist/attack
mist, charge empty lock, attack changes, floating platform changes
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user