death + charge regen

death penalty and corpse, lantern to butterfly, butterfly jar, added the new stuff to level editor
This commit is contained in:
2026-09-28 21:01:30 +02:00
parent 17d4e9cdf3
commit 2a86881108
14 changed files with 723 additions and 61 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"resources": {
"base_max_charge": 100.0,
"charge_drain_rate": 40.0,
"charge_refill_rate": 20.0,
"charge_refill_rate": 10.0,
"base_max_health": 10,
"invincibility_duration": 1.0
},
@@ -7,6 +7,11 @@
"group_spacing": 90
},
"butterfly_jars": {
"icon_size": 16,
"icon_gap": 4
},
"inventory_ui": {
"panel": { "x": 20, "y": 20, "w": 560, "h": 545, "pad": 10 },
"list": { "w": 360, "line_h": 18, "header_h": 40, "pad_y": 6, "pad_right": 14 },
+9 -2
View File
@@ -1,8 +1,15 @@
{
"objects": {
"lamp": {
"flow_butterfly": {
"type": "light_source",
"charge_regen_range": 180
},
"lamp": {
"type": "save_point",
"charge_regen_range": 90
},
"flow_butterfly_jar": {
"type": "butterfly_jar"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

@@ -131,6 +131,21 @@
],
"radius": 180
},
{
"type": "save_point",
"pos": [
-60,
560
],
"radius": 110
},
{
"type": "butterfly_jar",
"pos": [
320,
560
]
},
{
"type": "quest_giver",
"quest_id": "quest_first_kills",
@@ -192,6 +192,39 @@
}
],
"ability_doors": [],
"props": [
{
"type": "save_point",
"pos": [
-300,
360
],
"radius": 110
},
{
"type": "butterfly_jar",
"pos": [
800,
360
]
},
{
"type": "light_source",
"pos": [
300,
360
],
"radius": 180
},
{
"type": "light_source",
"pos": [
1100,
360
],
"radius": 180
}
],
"entry_spawns": {
"entry_1": [
-420,
+41 -1
View File
@@ -352,7 +352,47 @@
"type": "miniboss"
}
],
"props": [],
"props": [
{
"type": "save_point",
"pos": [
200,
320
],
"radius": 110
},
{
"type": "butterfly_jar",
"pos": [
420,
320
]
},
{
"type": "light_source",
"pos": [
500,
320
],
"radius": 180
},
{
"type": "light_source",
"pos": [
900,
2780
],
"radius": 180
},
{
"type": "light_source",
"pos": [
2600,
1860
],
"radius": 180
}
],
"exits": [
{
"x": -1160,
@@ -62,6 +62,16 @@
}
],
"ability_doors": [],
"props": [
{
"type": "light_source",
"pos": [
-100,
360
],
"radius": 180
}
],
"entry_spawns": {
"entry_2": [
-360,
@@ -96,6 +96,22 @@
]
},
"props": [
{
"type": "save_point",
"pos": [
140,
360
],
"radius": 110
},
{
"type": "light_source",
"pos": [
1100,
360
],
"radius": 180
},
{
"type": "reward_chest",
"pos": [
@@ -49,6 +49,22 @@
]
},
"props": [
{
"type": "save_point",
"pos": [
120,
360
],
"radius": 110
},
{
"type": "light_source",
"pos": [
900,
360
],
"radius": 180
},
{
"type": "diary",
"pos": [
+75 -17
View File
@@ -110,7 +110,7 @@
</label>
<div class="hr"></div>
<h3 style="margin-top:10px;">Objects Catalog</h3>
<div class="muted">Load objects.json to get default properties (lamp ranges, future objects).</div>
<div class="muted">Load objects.json to get default properties. Built-ins are always available: <b>flow_butterfly</b> (light source), <b>lamp</b> (save point), <b>flow_butterfly_jar</b> (collectible).</div>
<div class="row" style="margin-top:8px;">
<input id="objectsInput" type="file" accept=".json,application/json" />
</div>
@@ -188,6 +188,26 @@
const nowId = ()=>Math.random().toString(16).slice(2) + Date.now().toString(16);
const objectCatalog = { byType: {}, byId: {} };
// Built-in object catalog so the new assets (butterfly light, lantern save point,
// butterfly-in-jar collectible) are always placeable even before loading objects.json.
const DEFAULT_OBJECT_CATALOG = {
flow_butterfly: { type: "light_source", charge_regen_range: 180 },
lamp: { type: "save_point", charge_regen_range: 90 },
flow_butterfly_jar: { type: "butterfly_jar" },
};
// Type fallbacks used when no object def provides a radius.
const TYPE_DEFAULT_RADII = { light_source: 180, save_point: 90 };
function seedDefaultObjectCatalog(){
objectCatalog.byType = {};
objectCatalog.byId = {};
for(const [id, def] of Object.entries(DEFAULT_OBJECT_CATALOG)){
objectCatalog.byId[id] = def;
if(def.type) objectCatalog.byType[def.type] = def;
}
}
seedDefaultObjectCatalog();
const snap = (v)=> Math.round(v / state.gridSize) * state.gridSize;
const snapPt = (x,y)=> [snap(x), snap(y)];
@@ -215,8 +235,9 @@
function getDefaultRadiusForType(type, objectId){
const def = getObjectDefById(objectId) || getObjectDefByType(type);
const val = def && Number.isFinite(Number(def.charge_regen_range)) ? Number(def.charge_regen_range) : 180;
return val > 0 ? val : 180;
const val = def && Number.isFinite(Number(def.charge_regen_range)) ? Number(def.charge_regen_range) : 0;
if(val > 0) return val;
return TYPE_DEFAULT_RADII[type] || 180;
}
function roomTemplate(name="room_new"){
@@ -1332,22 +1353,48 @@ Cancel = Choose whether to remove without saving`);
ctx.fillText(typeId, sx+10*dpr, sy+4*dpr);
}
// props (light sources)
// props (light sources, save points, collectibles, etc.)
const PROP_STYLES = {
light_source: { ring: "rgba(250,204,21,0.25)", fill: "rgba(250,204,21,0.85)", label: "Butterfly" },
save_point: { ring: "rgba(56,189,248,0.30)", fill: "rgba(56,189,248,0.9)", label: "Lantern" },
butterfly_jar: { ring: "rgba(168,85,247,0.22)", fill: "rgba(168,85,247,0.9)", label: "Jar" },
};
for(let i=0;i<(room.props||[]).length;i++){
const p = room.props[i];
if(p.type !== "light_source") continue;
if(!p) continue;
const ptype = p.type || "light_source";
const st = PROP_STYLES[ptype] || { ring: "rgba(148,163,184,0.25)", fill: "rgba(148,163,184,0.9)", label: ptype };
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const r = Math.max(20, +p.radius || getDefaultRadiusForType(p.type || "light_source", p.object_id || p.objectId || null));
const v = getViewForCanvas(canvas);
const rPx = r * (v.zoom || 1) * dpr;
ctx.strokeStyle = "rgba(250,204,21,0.25)";
ctx.lineWidth = 2*dpr;
ctx.beginPath(); ctx.arc(sx, sy, rPx, 0, Math.PI*2); ctx.stroke();
ctx.fillStyle = "rgba(250,204,21,0.85)";
ctx.beginPath(); ctx.arc(sx, sy, 6*dpr, 0, Math.PI*2); ctx.fill();
const r = Math.max(20, +p.radius || getDefaultRadiusForType(ptype, p.object_id || p.objectId || null));
// radius ring (only for types that actually use it in-game)
if(ptype === "light_source" || ptype === "save_point"){
const rPx = r * (v.zoom || 1) * dpr;
ctx.strokeStyle = st.ring;
ctx.lineWidth = 2*dpr;
ctx.beginPath(); ctx.arc(sx, sy, rPx, 0, Math.PI*2); ctx.stroke();
}
// marker
ctx.fillStyle = st.fill;
if(ptype === "save_point"){
ctx.beginPath();
ctx.moveTo(sx, sy-7*dpr);
ctx.lineTo(sx+7*dpr, sy);
ctx.lineTo(sx, sy+7*dpr);
ctx.lineTo(sx-7*dpr, sy);
ctx.closePath();
ctx.fill();
} else if(ptype === "butterfly_jar"){
ctx.fillRect(sx-6*dpr, sy-6*dpr, 12*dpr, 12*dpr);
} else {
ctx.beginPath(); ctx.arc(sx, sy, 6*dpr, 0, Math.PI*2); ctx.fill();
}
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText("Lamp", sx+10*dpr, sy+4*dpr);
ctx.fillText(st.label, sx+10*dpr, sy+4*dpr);
}
// exits
@@ -1791,8 +1838,10 @@ Cancel = Choose whether to remove without saving`);
const p=room.props && room.props[sel.index];
if(!p) return "<div class='muted'>Selection missing.</div>";
const prDefault = getDefaultRadiusForType(p.type || "light_source", p.object_id || p.objectId || null);
const objId = p.object_id || p.objectId || "";
const hint = ({light_source:"Butterfly (charge regen 2x)", save_point:"Lantern (save point)", butterfly_jar:"Butterfly-in-jar (collectible)"})[p.type||"light_source"] || "";
return `
<div><b>Prop</b> #${sel.index}</div>
<div><b>Prop</b> #${sel.index}${hint ? ` <span class="mini">· ${hint}</span>` : ""}</div>
<div class="hr"></div>
<div class="grid2">
<div><div class="mini">x</div><input id="prx" type="number" value="${p.x}"/></div>
@@ -1802,6 +1851,10 @@ Cancel = Choose whether to remove without saving`);
<div class="mini">type</div>
<input id="prtype" type="text" value="${p.type||"light_source"}"/>
</div>
<div style="margin-top:8px;">
<div class="mini">object id</div>
<input id="probjid" type="text" value="${objId}" placeholder="(e.g. flow_butterfly, lamp, flow_butterfly_jar)"/>
</div>
<div style="margin-top:8px;">
<div class="mini">radius</div>
<input id="prrad" type="number" value="${p.radius||prDefault}"/>
@@ -1921,9 +1974,13 @@ Cancel = Choose whether to remove without saving`);
}));
} else if(sel.kind==="prop"){
const p = room.props && room.props[sel.index]; if(!p) return;
const prx=q("prx"), pry=q("pry"), prtype=q("prtype"), prrad=q("prrad");
const prx=q("prx"), pry=q("pry"), prtype=q("prtype"), probjid=q("probjid"), prrad=q("prrad");
if(prx) prx.addEventListener("change", ()=>pushAnd(()=>{ p.x=num(prx.value); }));
if(pry) pry.addEventListener("change", ()=>pushAnd(()=>{ p.y=num(pry.value); }));
if(probjid) probjid.addEventListener("change", ()=>pushAnd(()=>{
const v = probjid.value.trim();
if(v){ p.object_id = v; } else { delete p.object_id; }
}));
if(prtype) prtype.addEventListener("change", ()=>pushAnd(()=>{
p.type=prtype.value.trim()||"light_source";
if(!(Number.isFinite(Number(p.radius)) && Number(p.radius) > 0)){
@@ -2834,8 +2891,9 @@ Cancel = Choose whether to remove without saving`);
}
function loadObjectCatalog(data, filename){
objectCatalog.byType = {};
objectCatalog.byId = {};
// Keep the built-in defaults so the core assets are always available; a loaded
// objects.json overrides individual definitions (same id) and adds new ones.
seedDefaultObjectCatalog();
if(data && typeof data === "object" && data.objects && typeof data.objects === "object"){
for(const [id, def] of Object.entries(data.objects)){
+410 -26
View File
@@ -106,6 +106,7 @@ BINDINGS_DEFAULT = {
"shatterstep": pygame.K_r,
"tether": pygame.K_x,
"reality_break": pygame.K_c,
"use_butterfly_jar": pygame.K_g,
"toggle_shade": pygame.K_LSHIFT,
"inventory": pygame.K_i,
"interact": pygame.K_f,
@@ -165,6 +166,7 @@ SETTINGS_ACTIONS = [
("Shatterstep", "shatterstep"),
("Tether", "tether"),
("Reality Break", "reality_break"),
("Use Butterfly Jar", "use_butterfly_jar"),
("Toggle Shade", "toggle_shade"),
("Inventory", "inventory"),
("Interact/Trade", "interact"),
@@ -246,6 +248,30 @@ def mark_door_broken(room_id: str, rect):
BROKEN_DOORS.add(broken_door_key(room_id, rect))
# ----------------------------
# Collected butterfly jars (persisted in saves)
# ----------------------------
COLLECTED_JARS: set[str] = set()
BUTTERFLY_JARS_MAX = 3
def jar_key(room_id: str, x: int, y: int) -> str:
return f"{room_id}:{int(x)},{int(y)}"
# ----------------------------
# Lantern save-point respawn (persisted in saves)
# ----------------------------
LANTERN_RESPAWN_ROOM: str | None = None
LANTERN_RESPAWN_POS: tuple[int, int] | None = None
# ----------------------------
# Death penalty state (session-only, not persisted)
# ----------------------------
corpse = None # Corpse | None
JAR_DEATH_ROOM: str | None = None
# ----------------------------
# Game config (numbers & colors)
# ----------------------------
@@ -418,7 +444,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"], 20.0))
CHARGE_REFILL_RATE = float(_phys(["resources", "charge_refill_rate"], 10.0))
BASE_MAX_HEALTH = int(_phys(["resources", "base_max_health"], 10))
INVINCIBILITY_DURATION = float(_phys(["resources", "invincibility_duration"], 1.0))
@@ -474,6 +500,10 @@ PLAYER_IDLE_BOB_SPEED = float(_anim(["player_idle", "bob_speed"], PLAYER_IDLE_BO
COIN_UI_SIZE = int(_ui(["coins_hud", "icon_size"], 14))
COIN_UI_GAP = int(_ui(["coins_hud", "icon_gap"], 6))
# --- Butterfly jar HUD ---
BUTTERFLY_JAR_UI_SIZE = int(_ui(["butterfly_jars", "icon_size"], 16))
BUTTERFLY_JAR_UI_GAP = int(_ui(["butterfly_jars", "icon_gap"], 4))
pygame.init()
# --- Window setup ---
@@ -1173,6 +1203,11 @@ TETHER_EDGE_DARK = _scale_image(TETHER_EDGE_DARK_RAW, TETHER_SPRITE_DRAW_SIZE)
LAMP_PATH = OBJECTS_DIR / "lamp_1.png"
LAMP_RAW = _load_image_or_none(LAMP_PATH)
BUTTERFLY_PATH = OBJECTS_DIR / "flow_butterfly1.png"
BUTTERFLY_RAW = _load_image_or_none(BUTTERFLY_PATH)
BUTTERFLY_JAR_PATH = OBJECTS_DIR / "flow_butterfly_jar1.png"
BUTTERFLY_JAR_RAW = _load_image_or_none(BUTTERFLY_JAR_PATH)
# ----------------------------
@@ -1481,12 +1516,12 @@ class LightSource:
self.y = int(y)
self.radius = int(radius)
self.t = random.random() * 10.0
if LAMP_RAW is not None:
self.img = LAMP_RAW
if BUTTERFLY_RAW is not None:
self.img = BUTTERFLY_RAW
self.rect = self.img.get_rect(midbottom=(self.x, self.y))
else:
self.img = None
self.rect = pygame.Rect(self.x - 8, self.y - 16, 16, 16)
self.rect = pygame.Rect(self.x - 16, self.y - 32, 32, 32)
def draw(self, surface, cx, cy):
# subtle pulse area
@@ -1499,9 +1534,108 @@ class LightSource:
surface.blit(overlay, (self.x - r - cx, self.y - r - cy))
if self.img is not None:
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy))
bob = int(math.sin(self.t * 1.6) * 3)
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy - 6 + bob))
else:
pygame.draw.rect(surface, (240, 230, 120), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height))
class SavePoint:
"""Lantern save point: auto-saves and becomes the respawn point when touched."""
def __init__(self, x: int, y: int, radius: int = 90):
self.x = int(x)
self.y = int(y)
self.radius = int(radius)
self.t = random.random() * 10.0
if LAMP_RAW is not None:
self.img = LAMP_RAW
self.rect = self.img.get_rect(midbottom=(self.x, self.y))
else:
self.img = None
self.rect = pygame.Rect(self.x - 16, self.y - 37, 32, 74)
def draw(self, surface, cx, cy):
self.t += 0.03
pulse = 0.10 + 0.05 * (0.5 + 0.5 * math.sin(self.t))
r = self.radius
overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA)
alpha = int(255 * pulse)
pygame.draw.circle(overlay, (150, 220, 255, alpha), (r, r), r, 2)
surface.blit(overlay, (self.x - r - cx, self.y - r - cy))
if self.img is not None:
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy))
else:
pygame.draw.rect(surface, (150, 200, 230), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height))
class ButterflyJar:
"""Collectible butterfly-in-a-jar. The player can hold up to BUTTERFLY_JARS_MAX."""
def __init__(self, x: int, y: int, key: str = ""):
self.x = int(x)
self.y = int(y)
self.key = key
self.bob_t = random.random() * 10.0
if BUTTERFLY_JAR_RAW is not None:
self.img = BUTTERFLY_JAR_RAW
self.rect = self.img.get_rect(midbottom=(self.x, self.y))
else:
self.img = None
self.rect = pygame.Rect(self.x - 16, self.y - 32, 32, 32)
def update(self, dt: float):
self.bob_t += dt
def draw(self, surface, cx, cy):
bob_y = int(math.sin(self.bob_t * 2.0) * 3)
if self.img is not None:
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy + bob_y))
else:
pygame.draw.rect(surface, (200, 150, 230), (self.rect.x - cx, self.rect.y - cy + bob_y, self.rect.width, self.rect.height))
class Corpse:
"""A faded player model left where the player died, holding the coins they lost."""
def __init__(self, x: int, y: int, room_id: str, coins_light: int, coins_dark: int):
self.room_id = str(room_id)
self.x = int(x)
self.y = int(y)
self.coins_light = int(coins_light)
self.coins_dark = int(coins_dark)
self.t = random.random() * 10.0
self.rect = pygame.Rect(int(x), int(y), PLAYER_HITBOX_W, PLAYER_HITBOX_H)
self.rect.midbottom = (int(x), int(y))
self.img = self._build_faded_sprite()
def _build_faded_sprite(self):
if PLAYER_SPRITE_RAW is None:
return None
scale = PLAYER_SPRITE_SCALE
w = max(1, int(PLAYER_SPRITE_RAW.get_width() * scale))
h = max(1, int(PLAYER_SPRITE_RAW.get_height() * scale))
try:
img = pygame.transform.smoothscale(PLAYER_SPRITE_RAW, (w, h))
except Exception:
return None
img.set_alpha(130)
return img
def draw(self, surface, cx, cy):
self.t += 0.02
bob = int(math.sin(self.t) * 2)
if self.img is not None:
draw_x = self.rect.x - cx - (self.img.get_width() - self.rect.width) // 2 + PLAYER_SPRITE_OFFSET_X
draw_y = self.rect.y - cy - (self.img.get_height() - self.rect.height) + PLAYER_SPRITE_OFFSET_Y + bob
surface.blit(self.img, (draw_x, draw_y))
else:
pygame.draw.rect(
surface,
PLAYER_LIGHT,
pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height),
2,
)
def spawn_boss_shockwave(boss, shockwaves_list: list[Shockwave]):
y = boss.rect.bottom - boss.shockwave_height
x = boss.rect.centerx
@@ -2148,10 +2282,22 @@ class InventoryUI:
self.slot_icons = load_slot_icons()
self.slot_icon_cache = {} # (slot, rarity) -> Surface
self.token_icon = None # lazily cached scaled boss-token icon
self.icon_size = int(_ui(["inventory_ui", "icons", "size"], 16))
self.icon_gap = int(_ui(["inventory_ui", "icons", "gap"], 6))
def _get_token_icon(self) -> pygame.Surface | None:
if self.token_icon is None:
if BOSS_TOKEN_SPRITE_RAW is not None:
try:
self.token_icon = pygame.transform.smoothscale(
BOSS_TOKEN_SPRITE_RAW, (self.icon_size, self.icon_size))
except Exception:
self.token_icon = None
return self.token_icon
def toggle(self):
self.visible = not self.visible
if self.visible:
@@ -2520,8 +2666,12 @@ class InventoryUI:
name_fit = self._ellipsize(name, max(0, avail_for_name))
# --- slot icon (tinted by rarity) ---
icon = self._get_slot_icon(slot, rarity)
# --- slot icon (tinted by rarity), or token icon for boss tokens ---
icon = None
if it.get("item_type") == "boss_token":
icon = self._get_token_icon()
if icon is None:
icon = self._get_slot_icon(slot, rarity)
icon_y = y + (self.line_h - self.icon_size) // 2
if icon:
surface.blit(icon, (x0, icon_y))
@@ -2968,6 +3118,7 @@ class Player:
self.coins_light = 0
self.coins_dark = 0
self.butterfly_jars = 0
# --- abilities ---
self.has_ranged = False
@@ -3454,6 +3605,15 @@ class Player:
self.health = self.max_health
self.charge = self.max_charge
def consume_butterfly_jar(self) -> bool:
"""Consume one butterfly jar to instantly refill charge. Returns True if consumed."""
if self.butterfly_jars <= 0:
return False
self.butterfly_jars -= 1
self.charge = self.max_charge
self.charge_regen_lock = 0.0
return True
def _start_heal_if_possible(self, keys):
if self.shade:
return
@@ -4503,12 +4663,18 @@ def draw_pause_controls(surface, x, y):
f"Hold {key_name(key_for('heal'))} = heal",
f"{key_name(key_for('inventory'))} = inventory",
f"{key_name(key_for('interact'))} = interact / trade",
f"{key_name(key_for('use_butterfly_jar'))} = butterfly jar",
f"{key_name(key_for('pause'))} = pause",
"Click Close Game to exit",
]
line_h = 18
# Keep the whole list on screen: nudge upward if the requested y would overflow.
bottom = HEIGHT - 8
if y + len(lines) * line_h > bottom:
y = max(8, bottom - len(lines) * line_h)
for s in lines:
surface.blit(font.render(s, True, (220, 220, 220)), (x, y))
y += 18
y += line_h
def get_pause_close_button_rect() -> pygame.Rect:
@@ -4834,6 +5000,8 @@ shatter_platforms: list[ShatterPlatform] = []
shatter_aoes: list[ShatterAOE] = []
tether_shots: list[TetherShot] = []
light_sources: list[LightSource] = []
save_points: list[SavePoint] = []
butterfly_jars: list[ButterflyJar] = []
dust_puffs: list[DustPuff] = []
class QuestGiver:
"""Interactable environment object that hands out a quest."""
@@ -4905,6 +5073,8 @@ def spawn_room_props(room: Room, player_obj: "Player"):
reward_chest_obj = None
reward_ability_obj = None
lights: list[LightSource] = []
save_points: list[SavePoint] = []
butterfly_jars: list[ButterflyJar] = []
quest_givers: list[QuestGiver] = []
diary_obj = None
@@ -4939,15 +5109,40 @@ def spawn_room_props(room: Room, player_obj: "Player"):
elif p_type == "light_source":
radius = int(p.get("radius", 180))
lights.append(LightSource(x, y, radius))
elif p_type == "save_point":
radius = int(p.get("radius", 90))
save_points.append(SavePoint(x, y, radius))
elif p_type == "butterfly_jar":
key = jar_key(room.id, x, y)
if key not in COLLECTED_JARS:
butterfly_jars.append(ButterflyJar(x, y, key=key))
elif p_type == "quest_giver":
quest_givers.append(QuestGiver(x, y, str(p.get("quest_id", ""))))
elif p_type == "diary":
diary_obj = DiaryObject(x, y)
return reward_chest_obj, reward_ability_obj, lights, quest_givers, diary_obj
return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, quest_givers, diary_obj
reward_chest, reward_ability, light_sources, quest_givers, diary_obj = spawn_room_props(ROOM, player)
def rebuild_room_butterfly_jars():
"""Rebuild the butterfly jar list for the current room (after jars respawn)."""
global butterfly_jars
butterfly_jars = []
for p in ROOM.props:
if not isinstance(p, dict):
continue
if str(p.get("type", "")) != "butterfly_jar":
continue
pos = p.get("pos", None)
if not (isinstance(pos, (list, tuple)) and len(pos) == 2):
continue
x, y = int(pos[0]), int(pos[1])
key = jar_key(ROOM.id, x, y)
if key not in COLLECTED_JARS:
butterfly_jars.append(ButterflyJar(x, y, key=key))
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, quest_givers, diary_obj = spawn_room_props(ROOM, player)
def grant_full_set(set_id: str, rarity: str, equip: bool = True) -> bool:
slots = ["helmet", "chest", "pants"]
@@ -4994,7 +5189,7 @@ def apply_room(room: Room, set_player_spawn=True):
global ROOM, KILL_Y, platforms_ground, platforms_floating, ground_platforms, platforms
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global shatter_platforms, shatter_aoes, tether_shots
global light_sources
global light_sources, save_points, butterfly_jars
global dust_puffs
global dust_puffs
global reward_chest, reward_ability, quest_givers, diary_obj
@@ -5026,6 +5221,8 @@ def apply_room(room: Room, set_player_spawn=True):
shatter_aoes = []
tether_shots = []
light_sources = []
save_points = []
butterfly_jars = []
dust_puffs = []
dust_puffs = []
ROOM_DROP_MULT = 1.0
@@ -5037,7 +5234,7 @@ def apply_room(room: Room, set_player_spawn=True):
reward_chest = None
reward_ability = None
diary_obj = None
reward_chest, reward_ability, light_sources, quest_givers, diary_obj = spawn_room_props(ROOM, player)
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, quest_givers, diary_obj = spawn_room_props(ROOM, player)
if set_player_spawn:
player.start_x = int(ROOM.player_spawn[0])
@@ -5049,6 +5246,62 @@ def apply_room(room: Room, set_player_spawn=True):
def boss_alive_in_room() -> bool:
return any(getattr(en, "type_id", "") == "boss" and en.health > 0 for en in enemies)
def reset_room_combat_state():
"""Rebuild room enemies/NPCs/doors and clear transient combat objects."""
global enemies, npcs, loot_drops, shockwaves, coin_drops, token_drops, projectiles
global shatter_platforms, shatter_aoes, dust_puffs, tether_shots, ability_doors
enemies = spawn_enemies_from_room(ROOM)
npcs = spawn_npcs_from_room(ROOM)
loot_drops = []
shockwaves = []
coin_drops = []
token_drops = []
projectiles = []
shatter_platforms = []
shatter_aoes = []
dust_puffs = []
tether_shots = []
ability_doors = spawn_ability_doors(ROOM)
def apply_death_penalty():
"""On death: lose butterfly jars for good and drop coins into a corpse.
A corpse holds 100% of carried coins on the first death, or 50% on a repeat
death (the other 50% is lost forever and the old corpse vanishes). Void deaths
(falling out of the world) leave no recoverable corpse.
"""
global corpse, JAR_DEATH_ROOM
player.butterfly_jars = 0
JAR_DEATH_ROOM = ROOM.id
is_void_death = player.rect.top > KILL_Y
carried_l = player.coins_light
carried_d = player.coins_dark
if corpse is not None:
lost_l = int(carried_l * 0.5)
lost_d = int(carried_d * 0.5)
else:
lost_l = carried_l
lost_d = carried_d
if not is_void_death:
corpse = Corpse(player.rect.centerx, player.rect.bottom, ROOM.id, lost_l, lost_d)
player.coins_light = 0
player.coins_dark = 0
def respawn_jars_if_elsewhere() -> bool:
"""Respawn collected butterfly jars when saving at a lantern in a different room
than the one the player died in. Returns True if jars were respawned."""
global JAR_DEATH_ROOM
if JAR_DEATH_ROOM is not None and ROOM.id != JAR_DEATH_ROOM:
COLLECTED_JARS.clear()
JAR_DEATH_ROOM = None
rebuild_room_butterfly_jars()
return True
return False
def try_room_transition(keys):
if room_manager.transition_cooldown > 0:
return False
@@ -5918,6 +6171,7 @@ def save_game(name: str = "auto") -> str:
"shade": player.shade,
"coins_light": player.coins_light,
"coins_dark": player.coins_dark,
"butterfly_jars": player.butterfly_jars,
"has_ranged": player.has_ranged,
"reward_chest_opened": player.reward_chest_opened,
"equipment": {s: it for s, it in player.equipment.items()},
@@ -5931,6 +6185,10 @@ def save_game(name: str = "auto") -> str:
"boss_diary": {"slotted": boss_diary.slotted},
"defeated_bosses": sorted(DEFEATED_BOSSES),
"broken_doors": sorted(BROKEN_DOORS),
"lantern_respawn": (
{"room_id": LANTERN_RESPAWN_ROOM, "x": LANTERN_RESPAWN_POS[0], "y": LANTERN_RESPAWN_POS[1]}
if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS else None
),
}
path = save_path(name)
with path.open("w", encoding="utf-8") as f:
@@ -5940,16 +6198,22 @@ def save_game(name: str = "auto") -> str:
def apply_save_data(data: dict):
"""Apply a save's state to the current live game objects (player/inventory/quests/room)."""
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
global JAR_DEATH_ROOM, corpse
pdata = data.get("player", {}) or {}
# persistent player fields
player.coins_light = int(pdata.get("coins_light", 0))
player.coins_dark = int(pdata.get("coins_dark", 0))
player.butterfly_jars = int(pdata.get("butterfly_jars", 0))
player.has_ranged = bool(pdata.get("has_ranged", False))
player.reward_chest_opened = bool(pdata.get("reward_chest_opened", False))
eq = pdata.get("equipment") or {}
player.equipment = {s: (dict(it) if it else None) for s, it in eq.items()}
player.equipment = {s: None for s in ("helmet", "chest", "pants", "necklace")}
for s, it in eq.items():
if s in player.equipment:
player.equipment[s] = dict(it) if it else None
player.recalc_bonuses()
player._clamp_resources()
@@ -5967,10 +6231,27 @@ def apply_save_data(data: dict):
DEFEATED_BOSSES.update(data.get("defeated_bosses") or [])
BROKEN_DOORS.clear()
BROKEN_DOORS.update(data.get("broken_doors") or [])
# Butterfly jars respawn at their points on reload; the corpse is session-only.
COLLECTED_JARS.clear()
JAR_DEATH_ROOM = None
corpse = None
lrespawn = data.get("lantern_respawn") or {}
if lrespawn.get("room_id"):
LANTERN_RESPAWN_ROOM = normalize_room_id(str(lrespawn["room_id"]))
LANTERN_RESPAWN_POS = (int(lrespawn["x"]), int(lrespawn["y"]))
else:
LANTERN_RESPAWN_ROOM = None
LANTERN_RESPAWN_POS = None
# Reloading a save spawns the player at their latest lantern save point.
room_id = normalize_room_id(str(pdata.get("room_id") or "room_01.json"))
if LANTERN_RESPAWN_ROOM:
room_id = LANTERN_RESPAWN_ROOM
room = room_manager.load_room(room_id)
px, py = pdata.get("x"), pdata.get("y")
if LANTERN_RESPAWN_POS:
px, py = LANTERN_RESPAWN_POS
if isinstance(px, (int, float)) and isinstance(py, (int, float)):
room.player_spawn = [int(px), int(py)]
apply_room(room, set_player_spawn=True)
@@ -5996,6 +6277,7 @@ def fresh_save_data() -> dict:
"shade": False,
"coins_light": 0,
"coins_dark": 0,
"butterfly_jars": 0,
"has_ranged": False,
"reward_chest_opened": False,
"equipment": {},
@@ -6005,6 +6287,7 @@ def fresh_save_data() -> dict:
"boss_diary": {"slotted": {}},
"defeated_bosses": [],
"broken_doors": [],
"lantern_respawn": None,
}
@@ -6417,6 +6700,29 @@ def _menu_draw(surface):
draw_main_menu(surface, menu_index, menu_use_mouse, len(list_saves()) > 0)
def draw_butterfly_jar_slots(surface, x, y):
"""Draw the butterfly-jar collectible slots below the coin counters.
Only shown once at least one jar has been collected. Always renders all
BUTTERFLY_JARS_MAX slots; collected ones are full-color, the rest greyed out.
"""
if player.butterfly_jars <= 0:
return
size = BUTTERFLY_JAR_UI_SIZE
for i in range(BUTTERFLY_JARS_MAX):
ix = x + i * (size + BUTTERFLY_JAR_UI_GAP)
if BUTTERFLY_JAR_RAW is not None:
icon = pygame.transform.scale(BUTTERFLY_JAR_RAW, (size, size))
surface.blit(icon, (ix, y))
if i >= player.butterfly_jars:
dim = pygame.Surface((size, size), pygame.SRCALPHA)
dim.fill((45, 45, 55, 205))
surface.blit(dim, (ix, y))
else:
col = (210, 170, 230) if i < player.butterfly_jars else (95, 95, 105)
pygame.draw.rect(surface, col, (ix, y, size, size))
# ----------------------------
# Main loop
# ----------------------------
@@ -6428,6 +6734,7 @@ pause_menu_index = 0
settings_index = 0
settings_wait_action: str | None = None
pause_use_mouse = False
prev_near_save_point = False
while running:
dt = clock.tick(60) / 1000.0
@@ -6593,6 +6900,12 @@ while running:
en.reality_break_glow = True
player.reality_break_flash = REALITY_BREAK_FLASH_DURATION
if key_is("use_butterfly_jar", e.key):
if player.consume_butterfly_jar():
pickup_feed.push_center("Butterfly jar consumed - Charge restored!", duration=2.0)
else:
pickup_feed.push_center("No butterfly jars left!", duration=1.5)
keys = pygame.key.get_pressed()
if paused:
@@ -6644,6 +6957,12 @@ while running:
ts.draw(screen, camera_x, camera_y)
for ls in light_sources:
ls.draw(screen, camera_x, camera_y)
for sp in save_points:
sp.draw(screen, camera_x, camera_y)
for bj in butterfly_jars:
bj.draw(screen, camera_x, camera_y)
if corpse is not None and corpse.room_id == ROOM.id:
corpse.draw(screen, camera_x, camera_y)
player.draw(screen, camera_x, camera_y)
for npc in npcs:
@@ -6694,6 +7013,11 @@ while running:
font.render(str(player.coins_dark), True, (240, 240, 240)),
(coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1)
)
draw_butterfly_jar_slots(
screen,
WIDTH - int(_ui(["coins_hud", "x_start_offset"], 220)),
coin_y + COIN_UI_SIZE + COIN_UI_GAP + 4,
)
overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 150))
@@ -6734,6 +7058,26 @@ while running:
if (dx * dx + dy * dy) <= (ls.radius * ls.radius):
player.charge_regen_mult = 2.0
break
# Lantern save point: touching a lantern sets the respawn point + auto-saves.
near_save_point = False
for sp in save_points:
px, py = player.rect.midbottom
dx = px - sp.x
dy = py - sp.y
if (dx * dx + dy * dy) <= (sp.radius * sp.radius):
near_save_point = True
LANTERN_RESPAWN_ROOM = ROOM.id
LANTERN_RESPAWN_POS = (sp.x, sp.y)
break
if near_save_point and not prev_near_save_point:
_do_save_game()
pickup_feed.push_center("Lantern - game saved", duration=2.0)
# Butterfly jars lost on death respawn after saving at a lantern in a different room.
if respawn_jars_if_elsewhere():
pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5)
prev_near_save_point = near_save_point
collision_platforms = platforms + [d.rect for d in ability_doors if not d.open]
player_collision_platforms = collision_platforms + [p.rect for p in shatter_platforms if p.alive]
@@ -6792,6 +7136,7 @@ while running:
# Respawn on death/void
if player.rect.top > KILL_Y or player.health <= 0:
apply_death_penalty()
if ACTIVE_REFIGHT is not None:
# Died in a diary refight -> return to the diary in town.
ACTIVE_REFIGHT = None
@@ -6803,21 +7148,22 @@ while running:
player.shatterstep_landing_pending = False
player.shatterstep_landing_damage = 0.0
else:
player.respawn(int(ROOM.player_spawn[0]), int(ROOM.player_spawn[1]))
enemies = spawn_enemies_from_room(ROOM)
npcs = spawn_npcs_from_room(ROOM)
loot_drops = []
shockwaves = []
coin_drops = []
token_drops = []
projectiles = []
shatter_platforms = []
shatter_aoes = []
dust_puffs = []
tether_shots = []
# Respawn at the latest lantern save point if one has been touched.
if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS:
lx, ly = LANTERN_RESPAWN_POS
if LANTERN_RESPAWN_ROOM != ROOM.id:
room = room_manager.load_room(LANTERN_RESPAWN_ROOM)
room.player_spawn = [int(lx), int(ly)]
apply_room(room, set_player_spawn=True)
player.respawn(int(lx), int(ly))
else:
player.respawn(int(lx), int(ly))
reset_room_combat_state()
else:
player.respawn(int(ROOM.player_spawn[0]), int(ROOM.player_spawn[1]))
reset_room_combat_state()
player.shatterstep_landing_pending = False
player.shatterstep_landing_damage = 0.0
ability_doors = spawn_ability_doors(ROOM)
# Update enemies
for en in enemies:
@@ -6916,6 +7262,9 @@ while running:
for t in token_drops:
t.update(dt, collision_platforms)
for bj in butterfly_jars:
bj.update(dt)
if reward_ability:
reward_ability.update(dt)
@@ -6995,6 +7344,19 @@ while running:
else:
player.coins_dark += 1
coin_drops.remove(c)
for bj in butterfly_jars[:]:
if player.rect.colliderect(bj.rect):
if player.butterfly_jars < BUTTERFLY_JARS_MAX:
player.butterfly_jars += 1
if bj.key:
COLLECTED_JARS.add(bj.key)
pickup_feed.push_center(
f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})",
duration=2.5,
)
else:
pickup_feed.push_center("You can't carry any more butterfly jars!", duration=2.0)
butterfly_jars.remove(bj)
for t in token_drops[:]:
if player.rect.colliderect(t.rect):
token = make_boss_token(t.boss_id)
@@ -7002,6 +7364,16 @@ while running:
pickup_feed.push_plain(f"Collected {token['name']}!")
token_drops.remove(t)
# Corpse: touching it grants back the coins lost on death.
if corpse is not None and corpse.room_id == ROOM.id:
if player.rect.colliderect(corpse.rect):
recovered = corpse.coins_light + corpse.coins_dark
player.coins_light += corpse.coins_light
player.coins_dark += corpse.coins_dark
if recovered > 0:
pickup_feed.push_center(f"Recovered {recovered} coins from your corpse!", duration=2.5)
corpse = None
# Camera follow
@@ -7020,6 +7392,10 @@ while running:
for ls in light_sources:
ls.draw(screen, camera_x, camera_y)
for sp in save_points:
sp.draw(screen, camera_x, camera_y)
for bj in butterfly_jars:
bj.draw(screen, camera_x, camera_y)
for p in platforms:
pygame.draw.rect(
@@ -7094,6 +7470,8 @@ while running:
# entities
for npc in npcs:
npc.draw(screen, camera_x, camera_y)
if corpse is not None and corpse.room_id == ROOM.id:
corpse.draw(screen, camera_x, camera_y)
player.draw(screen, camera_x, camera_y)
for en in enemies:
en.draw(screen, camera_x, camera_y)
@@ -7212,6 +7590,12 @@ while running:
(coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1)
)
draw_butterfly_jar_slots(
screen,
WIDTH - int(_ui(["coins_hud", "x_start_offset"], 220)),
coin_y + COIN_UI_SIZE + COIN_UI_GAP + 4,
)
inv_ui.draw(screen)
+92 -14
View File
@@ -1,28 +1,106 @@
{
"version": 1,
"saved_at": "2026-09-28T09:46:05",
"saved_at": "2026-09-28T10:53:55",
"player": {
"room_id": "room_02.json",
"x": -45,
"room_id": "room_town_gloomreach.json",
"x": 120,
"y": 360,
"health": 10,
"charge": 100.0,
"health": 9,
"charge": 64.71,
"shade": false,
"coins_light": 23,
"coins_dark": 2,
"has_ranged": false,
"reward_chest_opened": false,
"equipment": {}
"coins_light": 84,
"coins_dark": 41,
"butterfly_jars": 2,
"has_ranged": true,
"reward_chest_opened": true,
"equipment": {
"helmet": null,
"chest": null,
"pants": null,
"necklace": null
}
},
"inventory": [],
"inventory": [
{
"id": "necklace_placeholder",
"name": "Plain Necklace",
"slot": "necklace",
"item_type": "necklace",
"base_stats": {
"health": 2
},
"rarity": "",
"tier": 0,
"abilities": [],
"stats": {
"health": 2
}
},
{
"id": "dawnrunner_chest",
"name": "Dawnrunner Chestplate",
"set": "dawnrunner",
"slot": "chest",
"base_stats": {},
"rarity_overrides": {
"tempered": {
"extra_stats": {
"charge": 10
}
},
"attuned": {
"extra_stats": {
"charge": 15,
"move_speed_light_pct": 0.05
}
},
"awakened": {
"extra_stats": {
"charge": 15,
"move_speed_light_pct": 0.05,
"heal_plus": 1
}
}
},
"rarity": "attuned",
"tier": 2,
"stats": {
"charge": 15.0,
"move_speed_light_pct": 0.05
},
"abilities": []
},
{
"item_type": "boss_token",
"boss_id": "boss_01",
"name": "Gloom Warden's Token",
"tier": 0
}
],
"quests": {
"active": {},
"active": {
"quest_first_kills": {
"progress": 12
}
},
"completed_one_time": [],
"tokens": 0
},
"boss_diary": {
"slotted": {}
},
"defeated_bosses": [],
"broken_doors": []
"defeated_bosses": [
"room_boss_01.json"
],
"broken_doors": [
"room_07.json:-980,2180,80,600",
"room_07.json:4100,2180,40,600",
"room_reward_01.json:1580,60,40,300",
"room_reward_01.json:1700,60,60,300"
],
"lantern_respawn": {
"room_id": "room_town_gloomreach.json",
"x": 120,
"y": 360
}
}