diff --git a/light_and_dark/data/config/physics.json b/light_and_dark/data/config/physics.json
index 9ac3978..d782a6b 100644
--- a/light_and_dark/data/config/physics.json
+++ b/light_and_dark/data/config/physics.json
@@ -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
},
diff --git a/light_and_dark/data/config/ui_layout.json b/light_and_dark/data/config/ui_layout.json
index db42e8e..73369d8 100644
--- a/light_and_dark/data/config/ui_layout.json
+++ b/light_and_dark/data/config/ui_layout.json
@@ -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 },
diff --git a/light_and_dark/data/objects/objects.json b/light_and_dark/data/objects/objects.json
index f33b265..4699114 100644
--- a/light_and_dark/data/objects/objects.json
+++ b/light_and_dark/data/objects/objects.json
@@ -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"
}
}
-}
+}
\ No newline at end of file
diff --git a/light_and_dark/data/sprites/objects/flow_butterfly1.png b/light_and_dark/data/sprites/objects/flow_butterfly1.png
new file mode 100644
index 0000000..24482f9
Binary files /dev/null and b/light_and_dark/data/sprites/objects/flow_butterfly1.png differ
diff --git a/light_and_dark/data/sprites/objects/flow_butterfly_jar1.png b/light_and_dark/data/sprites/objects/flow_butterfly_jar1.png
new file mode 100644
index 0000000..024cf2a
Binary files /dev/null and b/light_and_dark/data/sprites/objects/flow_butterfly_jar1.png differ
diff --git a/light_and_dark/data/world/rooms/room_01.json b/light_and_dark/data/world/rooms/room_01.json
index 9f0f420..f3e0a0f 100644
--- a/light_and_dark/data/world/rooms/room_01.json
+++ b/light_and_dark/data/world/rooms/room_01.json
@@ -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",
diff --git a/light_and_dark/data/world/rooms/room_02.json b/light_and_dark/data/world/rooms/room_02.json
index 7e86d0e..8d2cbc7 100644
--- a/light_and_dark/data/world/rooms/room_02.json
+++ b/light_and_dark/data/world/rooms/room_02.json
@@ -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,
diff --git a/light_and_dark/data/world/rooms/room_07.json b/light_and_dark/data/world/rooms/room_07.json
index ea01baa..73af087 100644
--- a/light_and_dark/data/world/rooms/room_07.json
+++ b/light_and_dark/data/world/rooms/room_07.json
@@ -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,
diff --git a/light_and_dark/data/world/rooms/room_boss_01.json b/light_and_dark/data/world/rooms/room_boss_01.json
index bf10a25..1ca42cf 100644
--- a/light_and_dark/data/world/rooms/room_boss_01.json
+++ b/light_and_dark/data/world/rooms/room_boss_01.json
@@ -62,6 +62,16 @@
}
],
"ability_doors": [],
+ "props": [
+ {
+ "type": "light_source",
+ "pos": [
+ -100,
+ 360
+ ],
+ "radius": 180
+ }
+ ],
"entry_spawns": {
"entry_2": [
-360,
diff --git a/light_and_dark/data/world/rooms/room_reward_01.json b/light_and_dark/data/world/rooms/room_reward_01.json
index 29f69cd..3fd684b 100644
--- a/light_and_dark/data/world/rooms/room_reward_01.json
+++ b/light_and_dark/data/world/rooms/room_reward_01.json
@@ -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": [
diff --git a/light_and_dark/data/world/rooms/room_town_gloomreach.json b/light_and_dark/data/world/rooms/room_town_gloomreach.json
index 65813a8..36bd958 100644
--- a/light_and_dark/data/world/rooms/room_town_gloomreach.json
+++ b/light_and_dark/data/world/rooms/room_town_gloomreach.json
@@ -49,6 +49,22 @@
]
},
"props": [
+ {
+ "type": "save_point",
+ "pos": [
+ 120,
+ 360
+ ],
+ "radius": 110
+ },
+ {
+ "type": "light_source",
+ "pos": [
+ 900,
+ 360
+ ],
+ "radius": 180
+ },
{
"type": "diary",
"pos": [
diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html
index 945152d..686fdf2 100644
--- a/light_and_dark/level_editor_v43.html
+++ b/light_and_dark/level_editor_v43.html
@@ -110,7 +110,7 @@
Load objects.json to get default properties (lamp ranges, future objects).
+ Load objects.json to get default properties. Built-ins are always available: flow_butterfly (light source), lamp (save point), flow_butterfly_jar (collectible).
Selection missing.
";
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 `
-
radius
@@ -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)){
diff --git a/light_and_dark/main.py b/light_and_dark/main.py
index 993bb1b..baabbe1 100644
--- a/light_and_dark/main.py
+++ b/light_and_dark/main.py
@@ -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)
diff --git a/light_and_dark/saves/save 1.json b/light_and_dark/saves/save 1.json
index 1ceeb72..5daa3ba 100644
--- a/light_and_dark/saves/save 1.json
+++ b/light_and_dark/saves/save 1.json
@@ -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
+ }
}
\ No newline at end of file