From 2a868811084e9caeeec8108472b4ea7e95f33e92 Mon Sep 17 00:00:00 2001 From: Halica Date: Mon, 28 Sep 2026 21:01:30 +0200 Subject: [PATCH] death + charge regen death penalty and corpse, lantern to butterfly, butterfly jar, added the new stuff to level editor --- light_and_dark/data/config/physics.json | 2 +- light_and_dark/data/config/ui_layout.json | 5 + light_and_dark/data/objects/objects.json | 11 +- .../data/sprites/objects/flow_butterfly1.png | Bin 0 -> 257 bytes .../sprites/objects/flow_butterfly_jar1.png | Bin 0 -> 385 bytes light_and_dark/data/world/rooms/room_01.json | 15 + light_and_dark/data/world/rooms/room_02.json | 33 ++ light_and_dark/data/world/rooms/room_07.json | 42 +- .../data/world/rooms/room_boss_01.json | 10 + .../data/world/rooms/room_reward_01.json | 16 + .../world/rooms/room_town_gloomreach.json | 16 + light_and_dark/level_editor_v43.html | 92 +++- light_and_dark/main.py | 436 ++++++++++++++++-- light_and_dark/saves/save 1.json | 106 ++++- 14 files changed, 723 insertions(+), 61 deletions(-) create mode 100644 light_and_dark/data/sprites/objects/flow_butterfly1.png create mode 100644 light_and_dark/data/sprites/objects/flow_butterfly_jar1.png 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 0000000000000000000000000000000000000000..24482f99a70315acd5ab54f39255555b1f8c13e8 GIT binary patch literal 257 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJ6P_-PArY-_r=I0)HsERXZ9O99 zbFqy5+?fL*XAaDiZP480y2Rs43g?J%U7*^r(8NX zwHkMErm8vltkN`)RbKS@nRZc+iC?~RF|X|L!a0v>7}RP)Zk@m0>k9M}gQu&X%Q~lo FCII7xV{QNd literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..024cf2a962ff8fb627c492d019e02baf358ee0d9 GIT binary patch literal 385 zcmV-{0e=38P) zTm<19$nxR|;4+w*g4X(c zx?nsRy#SaHRXuE}5}?LpM&<-nbsun2FM$mKydOo4y!Jp{66W~F0IDFaxBK@`fen!+ zail#3!N*W*C{~sirTI&h$lm>u9*thbK54K3+7z_&YKeW#O>qwsHnI>1$*V}UW)c|9 f

Objects Catalog

-
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).
@@ -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 "
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 ` -
Prop #${sel.index}
+
Prop #${sel.index}${hint ? ` ยท ${hint}` : ""}
x
@@ -1802,6 +1851,10 @@ Cancel = Choose whether to remove without saving`);
type
+
+
object id
+ +
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