level editor overhaul

more objects, NPCs, better toolbar and snapping
This commit is contained in:
2026-09-29 12:13:56 +02:00
parent db8aa53d3f
commit f676e59008
8 changed files with 1062 additions and 576 deletions
+42 -5
View File
@@ -2,7 +2,7 @@
A 2D platformer built with **Pygame**. You switch between two forms — **Light** and **Shade** — each with different movement, combat, and ability behavior, all driven by a single shared resource: **Charge (Flow)**.
> Note: this is a prototype. All progress (quests, boss tokens, broken walls, defeated bosses) is **in-memory** and resets when you restart the game — intentional, for fast testing.
> Note: this is a prototype. Some progress (quests, boss tokens, broken walls, defeated bosses) is **in-memory** and resets when you restart — intentional, for fast testing. Saves (via pause menu or lanterns) persist player state, coins, equipment, inventory, and your respawn point.
---
@@ -12,7 +12,7 @@ A 2D platformer built with **Pygame**. You switch between two forms — **Light*
python main.py
```
Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drops) lives in `data/`. Rooms are edited with the included browser tool: `level_editor_v43.html`.
Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drops) lives in `data/`. Rooms are edited with the included browser tool: **`level_editor_v43.html`** — see [Level Editor](#level-editor) below.
---
@@ -31,7 +31,8 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop
| Tether | X |
| Reality Break | C |
| Toggle Light/Shade | Left Shift |
| Interact / Talk | F |
| Interact / Talk / Save | F |
| Use Butterfly Jar | G |
| Inventory | I |
| Stats overlay | H |
| Pause / Settings | Esc |
@@ -44,7 +45,29 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop
**Shade** — slower but tanky: **immune to enemy contact damage**, deals double melee damage, and can use shade-specific gear bonuses. But being in Shade **drains Charge** (and Shade is forced off when Charge hits 0). Shade also drains Charge **twice as fast while clipping inside an enemy**.
**Charge (Flow)** — base max 100, refills at 20/s in Light, drains at 40/s in Shade. Standing near a **lantern/light source** doubles Charge regeneration. Attacks, dash (Light), ranged shots, and healing all cost Charge.
**Charge (Flow)** — base max 100, refills at **10/s** in Light (halved to make light sources matter), drains at **40/s** in Shade. Standing near a **butterfly** light source doubles Charge regeneration. Attacks, dash (Light), ranged shots, and healing all cost Charge.
---
## Saving & lanterns
**Lanterns are save points.** Walk up to a lantern and press **F** to save — a "Press F - Save at lantern" prompt appears when you're in range. Your respawn point is set to that lantern, so dying (or reloading the save) puts you back at your latest lantern. Saving via a lantern in a *different room* than where you last died also respawns butterfly jars in the world.
You can also save anytime from the pause menu (**Esc → Save Game**).
---
## Butterfly jars
Butterfly-in-a-jar pickups are scattered through the rooms (you can hold up to **3**). Press **G** to consume one and instantly refill your Charge bar to max. Collected jars are shown below your coin counters (empty slots are greyed out).
Jars are lost when you die. They respawn at their pickup points after you save at a lantern in a different room, or after reloading the game.
---
## Death & recovery
When you die you lose your **butterfly jars for good** and drop your coins. A **faded player model (corpse)** marks the spot — touch it to reclaim all lost coins. Dying again *without* reclaiming keeps only **50%** of the coins in a new corpse at the new death spot (the old corpse vanishes), so the rest is lost. Falling out of the world (void deaths) leaves no recoverable corpse.
---
@@ -129,5 +152,19 @@ The shade-focused sets (**Duskwarrior, Umbraflow, Abyss Cleaver, Adaptive Travel
## HUD & Stats
- Top-right: health bar (turns **green while poisoned**), Charge bar, coin counts.
- Butterfly-jar slots below the coin counters (shown once you own at least one; remaining slots are greyed out).
- Ability icons with cooldown indicators (top-left).
- **H** toggles a detailed overlay: exact HP/Charge, move speeds (both forms), heal duration, attack rate, Charge regen, the lantern buff, and any stat bonuses from gear.
- **H** toggles a detailed overlay: exact HP/Charge, move speeds (both forms), heal duration, attack rate, Charge regen, the lantern/light buff, and any stat bonuses from gear.
---
## Level Editor
Open `level_editor_v43.html` in a browser to edit rooms. Highlights:
- **Icon toolbar** on the right edge of the canvas (Select, Platform, Enemy, NPC, Player, Entry, Exit, Ability Door, Object). Toggle **grid snap** in the left sidebar.
- **Objects catalog** (built-in, always available; `data/objects/objects.json` can override/add): `flow_butterfly` (light source), `lamp` (save point), `flow_butterfly_jar` (collectible), `house`, `diary`, and `player` (a size reference that renders nothing in-game).
- **NPC support**: place/select/move NPCs (vendor, upgrader, necklace_vendor, combat_master) and standalone **house props**.
- **Layers**: every object (platforms, houses, NPCs, enemies, props, ability doors, exits) has a **layer** number. Lower = further back, higher = further forward; lower is set in the Properties panel. The editor and game both draw back-to-front by `(layer, category)`, and the select tool is layer-aware.
- **Link lines**: enable **Draw link lines** (with Compare view) to see dashed lines from a room's exits to the matching doors in the other room.
- Load the game's enemy types (`data/enemies/enemy_types.json`) to preview hitboxes and aggro ranges; enemy/miniboss/boss are also built in.
+5 -4
View File
@@ -5,7 +5,7 @@ Proves the core loop is fun before scaling to the full vision.
## Design decisions (locked)
- **Death penalty**: on death, drop light/dark coins at the death spot; reclaim them or lose them permanently on a second death. The fairy hovers over the spot as the marker.
- **Death penalty**: on death, coins drop into a **corpse** (faded player model) at the death spot; touch it to reclaim them. Dying again without reclaiming keeps only 50% (a new corpse appears, the old one vanishes). Butterfly jars are lost for good on death. Void deaths (falling out of the world) leave no corpse.
- **Skill upgrades**:
- **Vendor** (smith/trainer) gives direct linear power upgrades. Upgrades one selected spell; changing selection costs coins.
- **Gems / rings** give alternate behaviors / sidegrades.
@@ -21,12 +21,13 @@ Proves the core loop is fun before scaling to the full vision.
| ~~Save/persistence system~~ | L | **DONE (2026-09-28)**: JSON save to `saves/`, save via pause menu. Stores player state, room, coins, equipment, inventory, quests, tokens, boss diary, defeated bosses, broken doors. |
| Charge -> Flow rename | S | Code + UI + configs. |
| ~~Main menu~~ | M | **DONE (2026-09-28)**: Select Game (greyed with no saves), New Game (name entry), Settings (rebinding), Quit. Wipe-all-saves button (bottom right) with confirmation dialog. |
| ~~Lantern save points~~ | M | **DONE (2026-09-29)**: Lanterns are explicit save points — walk up and press F to save; you respawn at your last lantern. Visible radius removed. |
### M1 — Region 1 complete loop
| Item | Effort | Notes |
|---|---|---|
| Death penalty (drop coins, reclaim at death spot; fairy marks it; lose on 2nd death) | M | Save system exists now; drop-position data needed. Ties into fairy. |
| Fairy companion (guide + ambient light source -> bonus flow regen) | M | Wakes player at start, tutorial dialogue. |
| ~~Death penalty (drop coins, reclaim at death spot; fairy marks it; lose on 2nd death)~~ | M | **DONE (2026-09-29)**: corpse at death spot holds 100% of coins; a repeat death before reclaiming keeps only 50% and the old corpse vanishes. Butterfly jars are lost for good on death. |
| ~~Fairy companion (guide + ambient light source -> bonus flow regen)~~ | M | **DONE (2026-09-29)**: fairy follows behind the player's head; flips with facing. |
| More quests (repeatable + one-time; system exists) | S | Just content. |
| Lore dialogue for fairy / NPCs | S | Needs written copy. |
@@ -42,7 +43,7 @@ Proves the core loop is fun before scaling to the full vision.
|---|---|---|
| Shade-gated interactions (doors, an NPC, an area) | S-M | Room JSON + existing toggle. |
| New enemy behaviors (patrol, dodge-and-leap, burrow-shooter) | M | Enemy system supports it. |
| Quick recharge pickup | S | Mid-battle refill. |
| ~~Quick recharge pickup~~ | S | **DONE (2026-09-29)**: butterfly jars — collect up to 3, press G to instantly refill Charge. Lost on death; respawn at their points after saving at a lantern in a different room or reloading. |
| Ability tweaks: ranged upgrades, ground-slam knockback/iframe, light-step buffs | M | Picks up remaining notes ideas. |
| Region-2 boss + ability pacing | M | Re-align pacing (Ranged already exists). |
+9
View File
@@ -10,6 +10,15 @@
},
"flow_butterfly_jar": {
"type": "butterfly_jar"
},
"house": {
"type": "house"
},
"diary": {
"type": "diary"
},
"player": {
"type": "player"
}
}
}
+34 -32
View File
@@ -153,13 +153,6 @@
"h": 20,
"group": "ground"
},
{
"x": 1940,
"y": 2720,
"w": 360,
"h": 20,
"group": "ground"
},
{
"x": 2660,
"y": 880,
@@ -278,12 +271,18 @@
"w": 320,
"h": 40,
"group": "ground"
},
{
"x": 2100,
"y": 2720,
"w": 60,
"h": 20
}
],
"enemies": [
{
"x": 2080,
"y": 360,
"y": 380,
"type": "enemy"
},
{
@@ -352,45 +351,42 @@
"type": "miniboss"
}
],
"npcs": [],
"props": [
{
"x": 200,
"y": 320,
"type": "save_point",
"pos": [
200,
320
],
"radius": 110
"radius": 110,
"object_id": null
},
{
"x": 820,
"y": 320,
"type": "butterfly_jar",
"pos": [
420,
320
]
"radius": 180,
"object_id": null
},
{
"x": 500,
"y": 320,
"type": "light_source",
"pos": [
500,
320
],
"radius": 180
"radius": 180,
"object_id": null
},
{
"x": 900,
"y": 2780,
"type": "light_source",
"pos": [
900,
2780
],
"radius": 180
"radius": 180,
"object_id": null
},
{
"x": 2600,
"y": 1860,
"type": "light_source",
"pos": [
2600,
1860
],
"radius": 180
"radius": 180,
"object_id": null
}
],
"exits": [
@@ -459,6 +455,12 @@
"y": 2180,
"w": 40,
"h": 600
},
{
"x": 1940,
"y": 2720,
"w": 360,
"h": 20
}
],
"room_w": 1600,
@@ -21,58 +21,6 @@
]
},
"enemies": [],
"exits": [
{
"rect": [
10,
300,
80,
60
],
"to": "room_07",
"spawn": [
-1000,
2720
],
"hint": "Back"
}
],
"ability_doors": [],
"entry_spawns": {
"entry_1": [
120,
300
],
"entry_diary": [
180,
300
]
},
"props": [
{
"type": "save_point",
"pos": [
120,
360
],
"radius": 110
},
{
"type": "light_source",
"pos": [
900,
360
],
"radius": 180
},
{
"type": "diary",
"pos": [
180,
310
]
}
],
"npcs": [
{
"type": "vendor",
@@ -98,10 +46,98 @@
{
"type": "combat_master",
"pos": [
400,
479,
306
]
}
],
"trade_cost": 0
"exits": [
{
"rect": [
10,
300,
80,
60
],
"to": "room_07",
"spawn": [
-1000,
2720
],
"hint": "Back"
}
],
"ability_doors": [],
"entry_spawns": {
"entry_1": [
120,
300
],
"entry_diary": [
300,
300
]
},
"props": [
{
"type": "house",
"pos": [
720,
360
],
"radius": 180,
"object_id": "house"
},
{
"type": "save_point",
"pos": [
1300,
360
],
"radius": 110
},
{
"type": "light_source",
"pos": [
1360,
360
],
"radius": 180
},
{
"type": "diary",
"pos": [
239,
310
],
"radius": 180
},
{
"type": "butterfly_jar",
"pos": [
1480,
360
],
"radius": 180,
"object_id": "flow_butterfly_jar"
},
{
"type": "butterfly_jar",
"pos": [
1520,
360
],
"radius": 180,
"object_id": "flow_butterfly_jar"
},
{
"type": "butterfly_jar",
"pos": [
1560,
360
],
"radius": 180,
"object_id": "flow_butterfly_jar"
}
]
}
File diff suppressed because it is too large Load Diff
+200 -138
View File
@@ -868,15 +868,27 @@ ROOM_DEFAULT = {
}
def rects_from_list(list_of_rects) -> list[pygame.Rect]:
def rects_from_list(list_of_rects, layer_map: dict[int, int] | None = None) -> list[pygame.Rect]:
out = []
if not isinstance(list_of_rects, list):
return out
for r in list_of_rects:
if isinstance(r, (list, tuple)) and len(r) == 4:
out.append(pygame.Rect(int(r[0]), int(r[1]), int(r[2]), int(r[3])))
if isinstance(r, (list, tuple)) and len(r) >= 4:
rect = pygame.Rect(int(r[0]), int(r[1]), int(r[2]), int(r[3]))
out.append(rect)
if layer_map is not None and len(r) >= 5:
try:
layer_map[id(rect)] = int(r[4])
except (TypeError, ValueError):
pass
elif isinstance(r, dict):
out.append(pygame.Rect(int(r.get("x", 0)), int(r.get("y", 0)), int(r.get("w", 0)), int(r.get("h", 0))))
rect = pygame.Rect(int(r.get("x", 0)), int(r.get("y", 0)), int(r.get("w", 0)), int(r.get("h", 0)))
out.append(rect)
if layer_map is not None:
try:
layer_map[id(rect)] = int(r.get("layer", 0))
except (TypeError, ValueError):
pass
return out
@@ -985,13 +997,14 @@ class Room:
self.kill_y = int((room_json.get("bounds", {}) or {}).get("kill_y", 1400))
self.platform_layer: dict[int, int] = {}
platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {}
if isinstance(platforms, list):
self.platforms_ground = rects_from_list(platforms)
self.platforms_ground = rects_from_list(platforms, self.platform_layer)
self.platforms_floating = []
elif isinstance(platforms, dict):
self.platforms_ground = rects_from_list(platforms.get("ground", []))
self.platforms_floating = rects_from_list(platforms.get("floating", []))
self.platforms_ground = rects_from_list(platforms.get("ground", []), self.platform_layer)
self.platforms_floating = rects_from_list(platforms.get("floating", []), self.platform_layer)
else:
self.platforms_ground = []
self.platforms_floating = []
@@ -1036,7 +1049,13 @@ class Room:
rect = list(d)
if rect is None:
continue
normalized_ability_doors.append({"rect": rect})
nd = {"rect": rect}
if isinstance(d, dict):
try:
nd["layer"] = int(d.get("layer", 0))
except (TypeError, ValueError):
nd["layer"] = 0
normalized_ability_doors.append(nd)
self.ability_doors = normalized_ability_doors
self.exit_triggers: list[dict] = []
@@ -1057,6 +1076,7 @@ class Room:
"hint": str(ex.get("hint", "")),
"requires_boss_dead": bool(ex.get("requires_boss_dead", False)),
"requires_ranged": bool(ex.get("requires_ranged", False)),
"layer": int(ex.get("layer", 0)),
})
@@ -1553,7 +1573,7 @@ class LightSource:
class SavePoint:
"""Lantern save point: auto-saves and becomes the respawn point when touched."""
"""Lantern save point: pressing interact nearby saves the game and sets the respawn point."""
def __init__(self, x: int, y: int, radius: int = 90):
self.x = int(x)
self.y = int(y)
@@ -1568,13 +1588,6 @@ class SavePoint:
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:
@@ -1808,6 +1821,7 @@ def spawn_ability_doors(room) -> list[AbilityDoor]:
rect = pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))
door = AbilityDoor(rect, room_id=room.id)
door.open = is_door_broken(room.id, rect)
door.layer = int(d.get("layer", 0))
out.append(door)
return out
@@ -1862,13 +1876,14 @@ class AbilityPickup:
class NPC:
def __init__(self, x, y, npc_type: str = "vendor", trade_cost: int | None = None,
offers=None, greet_light: str | None = None, greet_shade: str | None = None,
greet_hint: str | None = None):
greet_hint: str | None = None, layer: int = 0):
self.type = npc_type
self.trade_cost = int(trade_cost) if trade_cost is not None else None
self.offers = offers if isinstance(offers, list) else []
self.greet_light = greet_light
self.greet_shade = greet_shade
self.greet_hint = greet_hint
self.layer = int(layer)
self.rect = pygame.Rect(int(x), int(y), 36, 54)
def draw(self, surface, cx, cy):
@@ -4597,7 +4612,9 @@ def spawn_enemies_from_room(room: Room):
w, h = cfg.get("rect", [40, 60])
x0 = int(pos[0] - (w / 2))
y0 = int(pos[1] - (h / 2))
out.append(Enemy(x0, y0, t))
enemy = Enemy(x0, y0, t)
enemy.layer = int(ent.get("layer", 0))
out.append(enemy)
return out
@@ -4630,7 +4647,8 @@ def spawn_npcs_from_room(room: Room):
greet_hint = _npc(["defaults", t, "greet_hint"], None)
out.append(NPC(pos[0], pos[1], t, trade_cost=trade_cost, offers=offers,
greet_light=greet_light, greet_shade=greet_shade, greet_hint=greet_hint))
greet_light=greet_light, greet_shade=greet_shade, greet_hint=greet_hint,
layer=int(ent.get("layer", 0))))
return out
@@ -4759,12 +4777,12 @@ def draw_speech_bubble(surface, text: str, center_x: int, bottom_y: int, hint_te
surface.blit(hint, (hint_x, hint_y))
def draw_npc_house(surface, npc: NPC, cx, cy):
# Simple backdrop house behind the NPC
def draw_house_shape(surface, cx, cy, center_x: int, bottom_y: int):
"""Draw the procedural house with its bottom-center anchored at (center_x, bottom_y)."""
house_w = 140
house_h = 90
base_x = int(npc.rect.centerx - house_w // 2 - cx)
base_y = int(npc.rect.bottom - house_h + 6 - cy)
base_x = int(center_x - house_w // 2 - cx)
base_y = int(bottom_y - house_h - cy)
# wall
pygame.draw.rect(surface, (180, 170, 150), (base_x, base_y + 30, house_w, house_h - 30))
@@ -4787,6 +4805,84 @@ def draw_npc_house(surface, npc: NPC, cx, cy):
pygame.draw.polygon(surface, (210, 170, 110), roof_pts)
pygame.draw.polygon(surface, (135, 100, 65), roof_pts, 2)
class HouseObject:
"""A standalone house prop placed in a room (bottom-center anchored)."""
def __init__(self, x: int, y: int, layer: int = 0):
self.x = int(x)
self.y = int(y)
self.layer = int(layer)
self.rect = pygame.Rect(int(x) - 70, int(y) - 90, 140, 90)
def draw(self, surface, cx, cy):
draw_house_shape(surface, cx, cy, self.x, self.y)
def draw_layerable(surface, cx, cy):
"""Draw all static room objects back-to-front by (layer, category).
Lower layer = further back. Ties use category order so default rooms keep a
sensible look: props/houses (0) < platforms (1) < ability doors (2) < exits (3)
< enemies (4) < NPCs (5). Any object's layer can be raised/lowered to push it
into the foreground or background.
"""
items = []
# props / houses
for ls in light_sources:
items.append((getattr(ls, "layer", 0), 0, "light", ls))
for sp in save_points:
items.append((getattr(sp, "layer", 0), 0, "savepoint", sp))
for bj in butterfly_jars:
items.append((getattr(bj, "layer", 0), 0, "jar", bj))
for h in houses:
items.append((getattr(h, "layer", 0), 0, "house", h))
for qg in quest_givers:
items.append((getattr(qg, "layer", 0), 0, "questgiver", qg))
if diary_obj is not None:
items.append((getattr(diary_obj, "layer", 0), 0, "diary", diary_obj))
if reward_chest is not None:
items.append((getattr(reward_chest, "layer", 0), 0, "rewardchest", reward_chest))
if reward_ability is not None:
items.append((getattr(reward_ability, "layer", 0), 0, "rewardability", reward_ability))
# platforms
for p in platforms:
items.append((platform_layer.get(id(p), 0), 1, "platform", p))
# ability doors
for d in ability_doors:
items.append((getattr(d, "layer", 0), 2, "door", d))
# exits (respect locked-exit hiding)
for ex in ROOM.exit_triggers:
if ex.get("requires_boss_dead", False) and boss_alive_in_room():
continue
if ex.get("requires_ranged", False) and not player.has_ranged:
continue
items.append((ex.get("layer", 0), 3, "exit", ex))
# enemies
for en in enemies:
items.append((getattr(en, "layer", 0), 4, "enemy", en))
# npcs
for n in npcs:
items.append((getattr(n, "layer", 0), 5, "npc", n))
items.sort(key=lambda it: (it[0], it[1]))
for _z, _cat, kind, obj in items:
if kind in ("light", "savepoint", "jar", "house", "questgiver", "diary", "rewardchest", "rewardability", "door", "enemy", "npc"):
obj.draw(surface, cx, cy)
elif kind == "platform":
pygame.draw.rect(
surface,
PLATFORM_COLOR,
pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height),
)
elif kind == "exit":
rr = obj["rect"]
pygame.draw.rect(
surface,
(140, 220, 140),
pygame.Rect(rr.x - cx, rr.y - cy, rr.width, rr.height),
2,
)
def draw_ability_icon_bg(surface, x: int, y: int, size: int, shade: bool):
pad = 4
w = size + pad * 2
@@ -5020,6 +5116,7 @@ platforms_ground = ROOM.platforms_ground
platforms_floating = ROOM.platforms_floating
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
platform_layer = ROOM.platform_layer
# ----------------------------
@@ -5046,6 +5143,7 @@ tether_shots: list[TetherShot] = []
light_sources: list[LightSource] = []
save_points: list[SavePoint] = []
butterfly_jars: list[ButterflyJar] = []
houses: list[HouseObject] = []
dust_puffs: list[DustPuff] = []
class QuestGiver:
"""Interactable environment object that hands out a quest."""
@@ -5119,6 +5217,7 @@ def spawn_room_props(room: Room, player_obj: "Player"):
lights: list[LightSource] = []
save_points: list[SavePoint] = []
butterfly_jars: list[ButterflyJar] = []
houses: list[HouseObject] = []
quest_givers: list[QuestGiver] = []
diary_obj = None
@@ -5145,27 +5244,40 @@ def spawn_room_props(room: Room, player_obj: "Player"):
else:
coins = REWARD_CHEST_COINS
reward_chest_obj = RewardChest(x, y, opened=player_obj.reward_chest_opened, coins=coins)
reward_chest_obj.layer = int(p.get("layer", 0))
elif p_type == "ability_pickup" and reward_ability_obj is None:
ability_id = str(p.get("ability", "ranged"))
if ability_id == "ranged" and player_obj.has_ranged:
continue
reward_ability_obj = AbilityPickup(x, y, ability_id=ability_id)
reward_ability_obj.layer = int(p.get("layer", 0))
elif p_type == "light_source":
radius = int(p.get("radius", 180))
lights.append(LightSource(x, y, radius))
ls = LightSource(x, y, radius)
ls.layer = int(p.get("layer", 0))
lights.append(ls)
elif p_type == "save_point":
radius = int(p.get("radius", 90))
save_points.append(SavePoint(x, y, radius))
sp = SavePoint(x, y, radius)
sp.layer = int(p.get("layer", 0))
save_points.append(sp)
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))
bj = ButterflyJar(x, y, key=key)
bj.layer = int(p.get("layer", 0))
butterfly_jars.append(bj)
elif p_type == "quest_giver":
quest_givers.append(QuestGiver(x, y, str(p.get("quest_id", ""))))
qg = QuestGiver(x, y, str(p.get("quest_id", "")))
qg.layer = int(p.get("layer", 0))
quest_givers.append(qg)
elif p_type == "diary":
diary_obj = DiaryObject(x, y)
diary_obj.layer = int(p.get("layer", 0))
elif p_type == "house":
houses.append(HouseObject(x, y, layer=int(p.get("layer", 0))))
return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, quest_givers, diary_obj
return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, houses, quest_givers, diary_obj
def rebuild_room_butterfly_jars():
@@ -5186,7 +5298,7 @@ def rebuild_room_butterfly_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)
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, 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"]
@@ -5231,9 +5343,10 @@ def grant_five_tempered() -> dict | None:
def apply_room(room: Room, set_player_spawn=True):
"""Updates room-dependent lists + respawns enemies. Keeps inventory/equipment."""
global ROOM, KILL_Y, platforms_ground, platforms_floating, ground_platforms, platforms
global platform_layer
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global shatter_platforms, shatter_aoes, tether_shots
global light_sources, save_points, butterfly_jars
global light_sources, save_points, butterfly_jars, houses
global dust_puffs
global dust_puffs
global reward_chest, reward_ability, quest_givers, diary_obj
@@ -5248,6 +5361,7 @@ def apply_room(room: Room, set_player_spawn=True):
platforms_floating = ROOM.platforms_floating
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
platform_layer = ROOM.platform_layer
if is_town_room(ROOM):
town_title.trigger(ROOM.id, town_display_name(ROOM))
@@ -5267,6 +5381,7 @@ def apply_room(room: Room, set_player_spawn=True):
light_sources = []
save_points = []
butterfly_jars = []
houses = []
dust_puffs = []
dust_puffs = []
ROOM_DROP_MULT = 1.0
@@ -5278,7 +5393,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, save_points, butterfly_jars, quest_givers, diary_obj = spawn_room_props(ROOM, player)
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player)
if set_player_spawn:
player.start_x = int(ROOM.player_spawn[0])
@@ -5845,6 +5960,37 @@ def get_near_diary(player_obj, diary_obj) -> bool:
return math.hypot(dx, dy) <= DiaryObject.INTERACT_RANGE
def get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -> SavePoint | None:
"""Return the nearest lantern the player can interact with, or None."""
best = None
best_d = None
px, py = player_obj.rect.midbottom
for sp in save_point_list:
r = range_px if range_px is not None else sp.radius
if r <= 0:
continue
dx = px - sp.x
dy = py - sp.y
d2 = dx * dx + dy * dy
if d2 <= (r * r):
if best is None or d2 < best_d:
best = sp
best_d = d2
return best
def save_at_lantern(sp: SavePoint):
"""Set the lantern as the respawn point and save the game."""
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
LANTERN_RESPAWN_ROOM = ROOM.id
LANTERN_RESPAWN_POS = (sp.x, sp.y)
_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)
def _ellipsize_text(text: str, fnt, max_w: int) -> str:
if fnt.size(text)[0] <= max_w:
return text
@@ -6783,7 +6929,6 @@ 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
@@ -6895,7 +7040,10 @@ while running:
pickup_feed.push_center(f"Debug: +5 {inst.get('name', 'Item')} (tempered)", duration=3.0)
if key_is("interact", e.key) and not inv_ui.visible and not was_trade_visible and not diary_ui.visible:
if get_near_diary(player, diary_obj):
near_lantern = get_near_lantern(player, save_points)
if near_lantern:
save_at_lantern(near_lantern)
elif get_near_diary(player, diary_obj):
diary_ui.toggle()
else:
near_npc = get_near_npc(player, npcs)
@@ -6961,16 +7109,9 @@ while running:
# draw last frame + pause overlay
screen.fill(BG)
# NPC backdrop houses (behind platforms)
for npc in npcs:
if npc.type == "vendor":
draw_npc_house(screen, npc, camera_x, camera_y)
# Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs)
draw_layerable(screen, camera_x, camera_y)
for p in platforms:
pygame.draw.rect(
screen, PLATFORM_COLOR,
pygame.Rect(p.x - camera_x, p.y - camera_y, p.width, p.height)
)
for sp in shatter_platforms:
sp.draw(screen, camera_x, camera_y)
for aoe in shatter_aoes:
@@ -6978,20 +7119,6 @@ while running:
for dp in dust_puffs:
dp.draw(screen, camera_x, camera_y)
for ex in ROOM.exit_triggers:
if ex.get("requires_boss_dead", False) and boss_alive_in_room():
continue
if ex.get("requires_ranged", False) and not player.has_ranged:
continue
rr = ex["rect"]
pygame.draw.rect(
screen,
(140, 220, 140),
pygame.Rect(rr.x - camera_x, rr.y - camera_y, rr.width, rr.height),
2,
)
for drop in loot_drops:
drop.draw(screen, camera_x, camera_y)
for c in coin_drops:
@@ -7004,25 +7131,10 @@ while running:
pr.draw(screen, camera_x, camera_y)
for ts in tether_shots:
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:
npc.draw(screen, camera_x, camera_y)
for en in enemies:
en.draw(screen, camera_x, camera_y)
if reward_chest:
reward_chest.draw(screen, camera_x, camera_y)
if reward_ability:
reward_ability.draw(screen, camera_x, camera_y)
if VIGNETTE_LIGHT and VIGNETTE_SHADE:
if player.shade:
@@ -7108,7 +7220,8 @@ while running:
player.charge_regen_mult = 2.0
break
# Lantern save point: touching a lantern sets the respawn point + auto-saves.
# Lantern save point: being near a lantern enables the "Press F to save" prompt;
# the actual save + respawn point update happens when the player presses interact.
near_save_point = False
for sp in save_points:
px, py = player.rect.midbottom
@@ -7116,16 +7229,7 @@ while running:
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]
@@ -7403,9 +7507,9 @@ while running:
f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})",
duration=2.5,
)
butterfly_jars.remove(bj)
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)
@@ -7434,23 +7538,9 @@ while running:
# ----------------------------
screen.fill(BG)
# NPC backdrop houses (behind platforms)
for npc in npcs:
if npc.type == "vendor":
draw_npc_house(screen, npc, camera_x, camera_y)
# Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs)
draw_layerable(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)
for p in platforms:
pygame.draw.rect(
screen, PLATFORM_COLOR,
pygame.Rect(p.x - camera_x, p.y - camera_y, p.width, p.height)
)
for sp in shatter_platforms:
sp.draw(screen, camera_x, camera_y)
for aoe in shatter_aoes:
@@ -7458,26 +7548,6 @@ while running:
for dp in dust_puffs:
dp.draw(screen, camera_x, camera_y)
# ability doors (ranged-locked)
for d in ability_doors:
d.draw(screen, camera_x, camera_y)
# exits / doorways
for ex in ROOM.exit_triggers:
# hide locked exits
if ex.get("requires_boss_dead", False) and boss_alive_in_room():
continue
if ex.get("requires_ranged", False) and not player.has_ranged:
continue
rr = ex["rect"]
pygame.draw.rect(
screen,
(140, 220, 140),
pygame.Rect(rr.x - camera_x, rr.y - camera_y, rr.width, rr.height),
2,
)
# loot
for drop in loot_drops:
drop.draw(screen, camera_x, camera_y)
@@ -7490,21 +7560,6 @@ while running:
for t in token_drops:
t.draw(screen, camera_x, camera_y)
# reward room props
if reward_chest:
reward_chest.draw(screen, camera_x, camera_y)
if reward_ability:
reward_ability.draw(screen, camera_x, camera_y)
# quest giver objects
for qg in quest_givers:
qg.draw(screen, camera_x, camera_y)
# diary object (boss token diary)
if diary_obj:
diary_obj.draw(screen, camera_x, camera_y)
# shockwaves
for sw in shockwaves:
sw.draw(screen, camera_x, camera_y)
@@ -7516,14 +7571,10 @@ while running:
ts.draw(screen, camera_x, camera_y)
# entities
for npc in npcs:
npc.draw(screen, camera_x, camera_y)
# entities (NPCs/enemies are drawn by draw_layerable for layer support)
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)
# NPC greeting bubble (shown near the player)
greet_npc = get_near_npc(player, npcs)
@@ -7576,6 +7627,17 @@ while running:
int(diary_obj.rect.top - camera_y),
)
# Lantern save hint bubble (shown near the player)
if not inv_ui.visible and not trade_ui.visible and not diary_ui.visible:
near_lantern = get_near_lantern(player, save_points)
if near_lantern:
draw_speech_bubble(
screen,
"Press F - Save at lantern",
int(near_lantern.rect.centerx - camera_x),
int(near_lantern.rect.top - camera_y),
)
# Boss health bar (if boss exists)
boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None)
if boss is not None:
+5 -4
View File
@@ -1,16 +1,16 @@
{
"version": 1,
"saved_at": "2026-09-29T09:39:49",
"saved_at": "2026-09-29T12:12:10",
"player": {
"room_id": "room_town_gloomreach.json",
"x": 230,
"x": 1315,
"y": 360,
"health": 10,
"charge": 125.0,
"shade": false,
"coins_light": 0,
"coins_dark": 0,
"butterfly_jars": 0,
"butterfly_jars": 3,
"has_ranged": true,
"reward_chest_opened": true,
"equipment": {
@@ -212,13 +212,14 @@
],
"broken_doors": [
"room_07.json:-980,2180,80,600",
"room_07.json:1940,2720,360,20",
"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,
"x": 1300,
"y": 360
}
}