level editor overhaul
more objects, NPCs, better toolbar and snapping
This commit is contained in:
@@ -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)**.
|
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
|
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 |
|
| Tether | X |
|
||||||
| Reality Break | C |
|
| Reality Break | C |
|
||||||
| Toggle Light/Shade | Left Shift |
|
| Toggle Light/Shade | Left Shift |
|
||||||
| Interact / Talk | F |
|
| Interact / Talk / Save | F |
|
||||||
|
| Use Butterfly Jar | G |
|
||||||
| Inventory | I |
|
| Inventory | I |
|
||||||
| Stats overlay | H |
|
| Stats overlay | H |
|
||||||
| Pause / Settings | Esc |
|
| 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**.
|
**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
|
## HUD & Stats
|
||||||
|
|
||||||
- Top-right: health bar (turns **green while poisoned**), Charge bar, coin counts.
|
- 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).
|
- 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,7 +5,7 @@ Proves the core loop is fun before scaling to the full vision.
|
|||||||
|
|
||||||
## Design decisions (locked)
|
## 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**:
|
- **Skill upgrades**:
|
||||||
- **Vendor** (smith/trainer) gives direct linear power upgrades. Upgrades one selected spell; changing selection costs coins.
|
- **Vendor** (smith/trainer) gives direct linear power upgrades. Upgrades one selected spell; changing selection costs coins.
|
||||||
- **Gems / rings** give alternate behaviors / sidegrades.
|
- **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. |
|
| ~~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. |
|
| 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. |
|
| ~~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
|
### M1 — Region 1 complete loop
|
||||||
| Item | Effort | Notes |
|
| 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. |
|
| ~~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 | Wakes player at start, tutorial dialogue. |
|
| ~~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. |
|
| More quests (repeatable + one-time; system exists) | S | Just content. |
|
||||||
| Lore dialogue for fairy / NPCs | S | Needs written copy. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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). |
|
| Region-2 boss + ability pacing | M | Re-align pacing (Ranged already exists). |
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
},
|
},
|
||||||
"flow_butterfly_jar": {
|
"flow_butterfly_jar": {
|
||||||
"type": "butterfly_jar"
|
"type": "butterfly_jar"
|
||||||
|
},
|
||||||
|
"house": {
|
||||||
|
"type": "house"
|
||||||
|
},
|
||||||
|
"diary": {
|
||||||
|
"type": "diary"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"type": "player"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,13 +153,6 @@
|
|||||||
"h": 20,
|
"h": 20,
|
||||||
"group": "ground"
|
"group": "ground"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"x": 1940,
|
|
||||||
"y": 2720,
|
|
||||||
"w": 360,
|
|
||||||
"h": 20,
|
|
||||||
"group": "ground"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"x": 2660,
|
"x": 2660,
|
||||||
"y": 880,
|
"y": 880,
|
||||||
@@ -278,12 +271,18 @@
|
|||||||
"w": 320,
|
"w": 320,
|
||||||
"h": 40,
|
"h": 40,
|
||||||
"group": "ground"
|
"group": "ground"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2100,
|
||||||
|
"y": 2720,
|
||||||
|
"w": 60,
|
||||||
|
"h": 20
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"enemies": [
|
"enemies": [
|
||||||
{
|
{
|
||||||
"x": 2080,
|
"x": 2080,
|
||||||
"y": 360,
|
"y": 380,
|
||||||
"type": "enemy"
|
"type": "enemy"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -352,45 +351,42 @@
|
|||||||
"type": "miniboss"
|
"type": "miniboss"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"npcs": [],
|
||||||
"props": [
|
"props": [
|
||||||
{
|
{
|
||||||
|
"x": 200,
|
||||||
|
"y": 320,
|
||||||
"type": "save_point",
|
"type": "save_point",
|
||||||
"pos": [
|
"radius": 110,
|
||||||
200,
|
"object_id": null
|
||||||
320
|
|
||||||
],
|
|
||||||
"radius": 110
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"x": 820,
|
||||||
|
"y": 320,
|
||||||
"type": "butterfly_jar",
|
"type": "butterfly_jar",
|
||||||
"pos": [
|
"radius": 180,
|
||||||
420,
|
"object_id": null
|
||||||
320
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"x": 500,
|
||||||
|
"y": 320,
|
||||||
"type": "light_source",
|
"type": "light_source",
|
||||||
"pos": [
|
"radius": 180,
|
||||||
500,
|
"object_id": null
|
||||||
320
|
|
||||||
],
|
|
||||||
"radius": 180
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"x": 900,
|
||||||
|
"y": 2780,
|
||||||
"type": "light_source",
|
"type": "light_source",
|
||||||
"pos": [
|
"radius": 180,
|
||||||
900,
|
"object_id": null
|
||||||
2780
|
|
||||||
],
|
|
||||||
"radius": 180
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"x": 2600,
|
||||||
|
"y": 1860,
|
||||||
"type": "light_source",
|
"type": "light_source",
|
||||||
"pos": [
|
"radius": 180,
|
||||||
2600,
|
"object_id": null
|
||||||
1860
|
|
||||||
],
|
|
||||||
"radius": 180
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"exits": [
|
"exits": [
|
||||||
@@ -459,6 +455,12 @@
|
|||||||
"y": 2180,
|
"y": 2180,
|
||||||
"w": 40,
|
"w": 40,
|
||||||
"h": 600
|
"h": 600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1940,
|
||||||
|
"y": 2720,
|
||||||
|
"w": 360,
|
||||||
|
"h": 20
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"room_w": 1600,
|
"room_w": 1600,
|
||||||
|
|||||||
@@ -21,58 +21,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enemies": [],
|
"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": [
|
"npcs": [
|
||||||
{
|
{
|
||||||
"type": "vendor",
|
"type": "vendor",
|
||||||
@@ -98,10 +46,98 @@
|
|||||||
{
|
{
|
||||||
"type": "combat_master",
|
"type": "combat_master",
|
||||||
"pos": [
|
"pos": [
|
||||||
400,
|
479,
|
||||||
306
|
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
@@ -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 = []
|
out = []
|
||||||
if not isinstance(list_of_rects, list):
|
if not isinstance(list_of_rects, list):
|
||||||
return out
|
return out
|
||||||
for r in list_of_rects:
|
for r in list_of_rects:
|
||||||
if isinstance(r, (list, tuple)) and len(r) == 4:
|
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])))
|
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):
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -985,13 +997,14 @@ class Room:
|
|||||||
|
|
||||||
self.kill_y = int((room_json.get("bounds", {}) or {}).get("kill_y", 1400))
|
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 {}
|
platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {}
|
||||||
if isinstance(platforms, list):
|
if isinstance(platforms, list):
|
||||||
self.platforms_ground = rects_from_list(platforms)
|
self.platforms_ground = rects_from_list(platforms, self.platform_layer)
|
||||||
self.platforms_floating = []
|
self.platforms_floating = []
|
||||||
elif isinstance(platforms, dict):
|
elif isinstance(platforms, dict):
|
||||||
self.platforms_ground = rects_from_list(platforms.get("ground", []))
|
self.platforms_ground = rects_from_list(platforms.get("ground", []), self.platform_layer)
|
||||||
self.platforms_floating = rects_from_list(platforms.get("floating", []))
|
self.platforms_floating = rects_from_list(platforms.get("floating", []), self.platform_layer)
|
||||||
else:
|
else:
|
||||||
self.platforms_ground = []
|
self.platforms_ground = []
|
||||||
self.platforms_floating = []
|
self.platforms_floating = []
|
||||||
@@ -1036,7 +1049,13 @@ class Room:
|
|||||||
rect = list(d)
|
rect = list(d)
|
||||||
if rect is None:
|
if rect is None:
|
||||||
continue
|
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.ability_doors = normalized_ability_doors
|
||||||
|
|
||||||
self.exit_triggers: list[dict] = []
|
self.exit_triggers: list[dict] = []
|
||||||
@@ -1057,6 +1076,7 @@ class Room:
|
|||||||
"hint": str(ex.get("hint", "")),
|
"hint": str(ex.get("hint", "")),
|
||||||
"requires_boss_dead": bool(ex.get("requires_boss_dead", False)),
|
"requires_boss_dead": bool(ex.get("requires_boss_dead", False)),
|
||||||
"requires_ranged": bool(ex.get("requires_ranged", False)),
|
"requires_ranged": bool(ex.get("requires_ranged", False)),
|
||||||
|
"layer": int(ex.get("layer", 0)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1553,7 +1573,7 @@ class LightSource:
|
|||||||
|
|
||||||
|
|
||||||
class SavePoint:
|
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):
|
def __init__(self, x: int, y: int, radius: int = 90):
|
||||||
self.x = int(x)
|
self.x = int(x)
|
||||||
self.y = int(y)
|
self.y = int(y)
|
||||||
@@ -1568,13 +1588,6 @@ class SavePoint:
|
|||||||
|
|
||||||
def draw(self, surface, cx, cy):
|
def draw(self, surface, cx, cy):
|
||||||
self.t += 0.03
|
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:
|
if self.img is not None:
|
||||||
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy))
|
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy))
|
||||||
else:
|
else:
|
||||||
@@ -1808,6 +1821,7 @@ def spawn_ability_doors(room) -> list[AbilityDoor]:
|
|||||||
rect = pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))
|
rect = pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))
|
||||||
door = AbilityDoor(rect, room_id=room.id)
|
door = AbilityDoor(rect, room_id=room.id)
|
||||||
door.open = is_door_broken(room.id, rect)
|
door.open = is_door_broken(room.id, rect)
|
||||||
|
door.layer = int(d.get("layer", 0))
|
||||||
out.append(door)
|
out.append(door)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -1862,13 +1876,14 @@ class AbilityPickup:
|
|||||||
class NPC:
|
class NPC:
|
||||||
def __init__(self, x, y, npc_type: str = "vendor", trade_cost: int | None = None,
|
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,
|
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.type = npc_type
|
||||||
self.trade_cost = int(trade_cost) if trade_cost is not None else None
|
self.trade_cost = int(trade_cost) if trade_cost is not None else None
|
||||||
self.offers = offers if isinstance(offers, list) else []
|
self.offers = offers if isinstance(offers, list) else []
|
||||||
self.greet_light = greet_light
|
self.greet_light = greet_light
|
||||||
self.greet_shade = greet_shade
|
self.greet_shade = greet_shade
|
||||||
self.greet_hint = greet_hint
|
self.greet_hint = greet_hint
|
||||||
|
self.layer = int(layer)
|
||||||
self.rect = pygame.Rect(int(x), int(y), 36, 54)
|
self.rect = pygame.Rect(int(x), int(y), 36, 54)
|
||||||
|
|
||||||
def draw(self, surface, cx, cy):
|
def draw(self, surface, cx, cy):
|
||||||
@@ -4597,7 +4612,9 @@ def spawn_enemies_from_room(room: Room):
|
|||||||
w, h = cfg.get("rect", [40, 60])
|
w, h = cfg.get("rect", [40, 60])
|
||||||
x0 = int(pos[0] - (w / 2))
|
x0 = int(pos[0] - (w / 2))
|
||||||
y0 = int(pos[1] - (h / 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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -4630,7 +4647,8 @@ def spawn_npcs_from_room(room: Room):
|
|||||||
greet_hint = _npc(["defaults", t, "greet_hint"], None)
|
greet_hint = _npc(["defaults", t, "greet_hint"], None)
|
||||||
|
|
||||||
out.append(NPC(pos[0], pos[1], t, trade_cost=trade_cost, offers=offers,
|
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
|
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))
|
surface.blit(hint, (hint_x, hint_y))
|
||||||
|
|
||||||
|
|
||||||
def draw_npc_house(surface, npc: NPC, cx, cy):
|
def draw_house_shape(surface, cx, cy, center_x: int, bottom_y: int):
|
||||||
# Simple backdrop house behind the NPC
|
"""Draw the procedural house with its bottom-center anchored at (center_x, bottom_y)."""
|
||||||
house_w = 140
|
house_w = 140
|
||||||
house_h = 90
|
house_h = 90
|
||||||
base_x = int(npc.rect.centerx - house_w // 2 - cx)
|
base_x = int(center_x - house_w // 2 - cx)
|
||||||
base_y = int(npc.rect.bottom - house_h + 6 - cy)
|
base_y = int(bottom_y - house_h - cy)
|
||||||
|
|
||||||
# wall
|
# wall
|
||||||
pygame.draw.rect(surface, (180, 170, 150), (base_x, base_y + 30, house_w, house_h - 30))
|
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, (210, 170, 110), roof_pts)
|
||||||
pygame.draw.polygon(surface, (135, 100, 65), roof_pts, 2)
|
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):
|
def draw_ability_icon_bg(surface, x: int, y: int, size: int, shade: bool):
|
||||||
pad = 4
|
pad = 4
|
||||||
w = size + pad * 2
|
w = size + pad * 2
|
||||||
@@ -5020,6 +5116,7 @@ platforms_ground = ROOM.platforms_ground
|
|||||||
platforms_floating = ROOM.platforms_floating
|
platforms_floating = ROOM.platforms_floating
|
||||||
ground_platforms = ROOM.ground_platforms
|
ground_platforms = ROOM.ground_platforms
|
||||||
platforms = ROOM.platforms
|
platforms = ROOM.platforms
|
||||||
|
platform_layer = ROOM.platform_layer
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -5046,6 +5143,7 @@ tether_shots: list[TetherShot] = []
|
|||||||
light_sources: list[LightSource] = []
|
light_sources: list[LightSource] = []
|
||||||
save_points: list[SavePoint] = []
|
save_points: list[SavePoint] = []
|
||||||
butterfly_jars: list[ButterflyJar] = []
|
butterfly_jars: list[ButterflyJar] = []
|
||||||
|
houses: list[HouseObject] = []
|
||||||
dust_puffs: list[DustPuff] = []
|
dust_puffs: list[DustPuff] = []
|
||||||
class QuestGiver:
|
class QuestGiver:
|
||||||
"""Interactable environment object that hands out a quest."""
|
"""Interactable environment object that hands out a quest."""
|
||||||
@@ -5119,6 +5217,7 @@ def spawn_room_props(room: Room, player_obj: "Player"):
|
|||||||
lights: list[LightSource] = []
|
lights: list[LightSource] = []
|
||||||
save_points: list[SavePoint] = []
|
save_points: list[SavePoint] = []
|
||||||
butterfly_jars: list[ButterflyJar] = []
|
butterfly_jars: list[ButterflyJar] = []
|
||||||
|
houses: list[HouseObject] = []
|
||||||
quest_givers: list[QuestGiver] = []
|
quest_givers: list[QuestGiver] = []
|
||||||
diary_obj = None
|
diary_obj = None
|
||||||
|
|
||||||
@@ -5145,27 +5244,40 @@ def spawn_room_props(room: Room, player_obj: "Player"):
|
|||||||
else:
|
else:
|
||||||
coins = REWARD_CHEST_COINS
|
coins = REWARD_CHEST_COINS
|
||||||
reward_chest_obj = RewardChest(x, y, opened=player_obj.reward_chest_opened, coins=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:
|
elif p_type == "ability_pickup" and reward_ability_obj is None:
|
||||||
ability_id = str(p.get("ability", "ranged"))
|
ability_id = str(p.get("ability", "ranged"))
|
||||||
if ability_id == "ranged" and player_obj.has_ranged:
|
if ability_id == "ranged" and player_obj.has_ranged:
|
||||||
continue
|
continue
|
||||||
reward_ability_obj = AbilityPickup(x, y, ability_id=ability_id)
|
reward_ability_obj = AbilityPickup(x, y, ability_id=ability_id)
|
||||||
|
reward_ability_obj.layer = int(p.get("layer", 0))
|
||||||
elif p_type == "light_source":
|
elif p_type == "light_source":
|
||||||
radius = int(p.get("radius", 180))
|
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":
|
elif p_type == "save_point":
|
||||||
radius = int(p.get("radius", 90))
|
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":
|
elif p_type == "butterfly_jar":
|
||||||
key = jar_key(room.id, x, y)
|
key = jar_key(room.id, x, y)
|
||||||
if key not in COLLECTED_JARS:
|
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":
|
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":
|
elif p_type == "diary":
|
||||||
diary_obj = DiaryObject(x, y)
|
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():
|
def rebuild_room_butterfly_jars():
|
||||||
@@ -5186,7 +5298,7 @@ def rebuild_room_butterfly_jars():
|
|||||||
butterfly_jars.append(ButterflyJar(x, y, key=key))
|
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:
|
def grant_full_set(set_id: str, rarity: str, equip: bool = True) -> bool:
|
||||||
slots = ["helmet", "chest", "pants"]
|
slots = ["helmet", "chest", "pants"]
|
||||||
@@ -5231,9 +5343,10 @@ def grant_five_tempered() -> dict | None:
|
|||||||
def apply_room(room: Room, set_player_spawn=True):
|
def apply_room(room: Room, set_player_spawn=True):
|
||||||
"""Updates room-dependent lists + respawns enemies. Keeps inventory/equipment."""
|
"""Updates room-dependent lists + respawns enemies. Keeps inventory/equipment."""
|
||||||
global ROOM, KILL_Y, platforms_ground, platforms_floating, ground_platforms, platforms
|
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 enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
|
||||||
global shatter_platforms, shatter_aoes, tether_shots
|
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 dust_puffs
|
global dust_puffs
|
||||||
global reward_chest, reward_ability, quest_givers, diary_obj
|
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
|
platforms_floating = ROOM.platforms_floating
|
||||||
ground_platforms = ROOM.ground_platforms
|
ground_platforms = ROOM.ground_platforms
|
||||||
platforms = ROOM.platforms
|
platforms = ROOM.platforms
|
||||||
|
platform_layer = ROOM.platform_layer
|
||||||
|
|
||||||
if is_town_room(ROOM):
|
if is_town_room(ROOM):
|
||||||
town_title.trigger(ROOM.id, town_display_name(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 = []
|
light_sources = []
|
||||||
save_points = []
|
save_points = []
|
||||||
butterfly_jars = []
|
butterfly_jars = []
|
||||||
|
houses = []
|
||||||
dust_puffs = []
|
dust_puffs = []
|
||||||
dust_puffs = []
|
dust_puffs = []
|
||||||
ROOM_DROP_MULT = 1.0
|
ROOM_DROP_MULT = 1.0
|
||||||
@@ -5278,7 +5393,7 @@ def apply_room(room: Room, set_player_spawn=True):
|
|||||||
reward_chest = None
|
reward_chest = None
|
||||||
reward_ability = None
|
reward_ability = None
|
||||||
diary_obj = 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:
|
if set_player_spawn:
|
||||||
player.start_x = int(ROOM.player_spawn[0])
|
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
|
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:
|
def _ellipsize_text(text: str, fnt, max_w: int) -> str:
|
||||||
if fnt.size(text)[0] <= max_w:
|
if fnt.size(text)[0] <= max_w:
|
||||||
return text
|
return text
|
||||||
@@ -6783,7 +6929,6 @@ pause_menu_index = 0
|
|||||||
settings_index = 0
|
settings_index = 0
|
||||||
settings_wait_action: str | None = None
|
settings_wait_action: str | None = None
|
||||||
pause_use_mouse = False
|
pause_use_mouse = False
|
||||||
prev_near_save_point = False
|
|
||||||
while running:
|
while running:
|
||||||
dt = clock.tick(60) / 1000.0
|
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)
|
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 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()
|
diary_ui.toggle()
|
||||||
else:
|
else:
|
||||||
near_npc = get_near_npc(player, npcs)
|
near_npc = get_near_npc(player, npcs)
|
||||||
@@ -6961,16 +7109,9 @@ while running:
|
|||||||
# draw last frame + pause overlay
|
# draw last frame + pause overlay
|
||||||
screen.fill(BG)
|
screen.fill(BG)
|
||||||
|
|
||||||
# NPC backdrop houses (behind platforms)
|
# Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs)
|
||||||
for npc in npcs:
|
draw_layerable(screen, camera_x, camera_y)
|
||||||
if npc.type == "vendor":
|
|
||||||
draw_npc_house(screen, npc, 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:
|
for sp in shatter_platforms:
|
||||||
sp.draw(screen, camera_x, camera_y)
|
sp.draw(screen, camera_x, camera_y)
|
||||||
for aoe in shatter_aoes:
|
for aoe in shatter_aoes:
|
||||||
@@ -6978,20 +7119,6 @@ while running:
|
|||||||
for dp in dust_puffs:
|
for dp in dust_puffs:
|
||||||
dp.draw(screen, camera_x, camera_y)
|
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:
|
for drop in loot_drops:
|
||||||
drop.draw(screen, camera_x, camera_y)
|
drop.draw(screen, camera_x, camera_y)
|
||||||
for c in coin_drops:
|
for c in coin_drops:
|
||||||
@@ -7004,25 +7131,10 @@ while running:
|
|||||||
pr.draw(screen, camera_x, camera_y)
|
pr.draw(screen, camera_x, camera_y)
|
||||||
for ts in tether_shots:
|
for ts in tether_shots:
|
||||||
ts.draw(screen, camera_x, camera_y)
|
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:
|
if corpse is not None and corpse.room_id == ROOM.id:
|
||||||
corpse.draw(screen, camera_x, camera_y)
|
corpse.draw(screen, camera_x, camera_y)
|
||||||
|
|
||||||
player.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 VIGNETTE_LIGHT and VIGNETTE_SHADE:
|
||||||
if player.shade:
|
if player.shade:
|
||||||
@@ -7108,7 +7220,8 @@ while running:
|
|||||||
player.charge_regen_mult = 2.0
|
player.charge_regen_mult = 2.0
|
||||||
break
|
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
|
near_save_point = False
|
||||||
for sp in save_points:
|
for sp in save_points:
|
||||||
px, py = player.rect.midbottom
|
px, py = player.rect.midbottom
|
||||||
@@ -7116,16 +7229,7 @@ while running:
|
|||||||
dy = py - sp.y
|
dy = py - sp.y
|
||||||
if (dx * dx + dy * dy) <= (sp.radius * sp.radius):
|
if (dx * dx + dy * dy) <= (sp.radius * sp.radius):
|
||||||
near_save_point = True
|
near_save_point = True
|
||||||
LANTERN_RESPAWN_ROOM = ROOM.id
|
|
||||||
LANTERN_RESPAWN_POS = (sp.x, sp.y)
|
|
||||||
break
|
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]
|
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]
|
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})",
|
f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})",
|
||||||
duration=2.5,
|
duration=2.5,
|
||||||
)
|
)
|
||||||
|
butterfly_jars.remove(bj)
|
||||||
else:
|
else:
|
||||||
pickup_feed.push_center("You can't carry any more butterfly jars!", duration=2.0)
|
pickup_feed.push_center("You can't carry any more butterfly jars!", duration=2.0)
|
||||||
butterfly_jars.remove(bj)
|
|
||||||
for t in token_drops[:]:
|
for t in token_drops[:]:
|
||||||
if player.rect.colliderect(t.rect):
|
if player.rect.colliderect(t.rect):
|
||||||
token = make_boss_token(t.boss_id)
|
token = make_boss_token(t.boss_id)
|
||||||
@@ -7434,23 +7538,9 @@ while running:
|
|||||||
# ----------------------------
|
# ----------------------------
|
||||||
screen.fill(BG)
|
screen.fill(BG)
|
||||||
|
|
||||||
# NPC backdrop houses (behind platforms)
|
# Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs)
|
||||||
for npc in npcs:
|
draw_layerable(screen, camera_x, camera_y)
|
||||||
if npc.type == "vendor":
|
|
||||||
draw_npc_house(screen, npc, 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:
|
for sp in shatter_platforms:
|
||||||
sp.draw(screen, camera_x, camera_y)
|
sp.draw(screen, camera_x, camera_y)
|
||||||
for aoe in shatter_aoes:
|
for aoe in shatter_aoes:
|
||||||
@@ -7458,26 +7548,6 @@ while running:
|
|||||||
for dp in dust_puffs:
|
for dp in dust_puffs:
|
||||||
dp.draw(screen, camera_x, camera_y)
|
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
|
# loot
|
||||||
for drop in loot_drops:
|
for drop in loot_drops:
|
||||||
drop.draw(screen, camera_x, camera_y)
|
drop.draw(screen, camera_x, camera_y)
|
||||||
@@ -7490,21 +7560,6 @@ while running:
|
|||||||
for t in token_drops:
|
for t in token_drops:
|
||||||
t.draw(screen, camera_x, camera_y)
|
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
|
# shockwaves
|
||||||
for sw in shockwaves:
|
for sw in shockwaves:
|
||||||
sw.draw(screen, camera_x, camera_y)
|
sw.draw(screen, camera_x, camera_y)
|
||||||
@@ -7516,14 +7571,10 @@ while running:
|
|||||||
ts.draw(screen, camera_x, camera_y)
|
ts.draw(screen, camera_x, camera_y)
|
||||||
|
|
||||||
|
|
||||||
# entities
|
# entities (NPCs/enemies are drawn by draw_layerable for layer support)
|
||||||
for npc in npcs:
|
|
||||||
npc.draw(screen, camera_x, camera_y)
|
|
||||||
if corpse is not None and corpse.room_id == ROOM.id:
|
if corpse is not None and corpse.room_id == ROOM.id:
|
||||||
corpse.draw(screen, camera_x, camera_y)
|
corpse.draw(screen, camera_x, camera_y)
|
||||||
player.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)
|
# NPC greeting bubble (shown near the player)
|
||||||
greet_npc = get_near_npc(player, npcs)
|
greet_npc = get_near_npc(player, npcs)
|
||||||
@@ -7576,6 +7627,17 @@ while running:
|
|||||||
int(diary_obj.rect.top - camera_y),
|
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 health bar (if boss exists)
|
||||||
boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None)
|
boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None)
|
||||||
if boss is not None:
|
if boss is not None:
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"saved_at": "2026-09-29T09:39:49",
|
"saved_at": "2026-09-29T12:12:10",
|
||||||
"player": {
|
"player": {
|
||||||
"room_id": "room_town_gloomreach.json",
|
"room_id": "room_town_gloomreach.json",
|
||||||
"x": 230,
|
"x": 1315,
|
||||||
"y": 360,
|
"y": 360,
|
||||||
"health": 10,
|
"health": 10,
|
||||||
"charge": 125.0,
|
"charge": 125.0,
|
||||||
"shade": false,
|
"shade": false,
|
||||||
"coins_light": 0,
|
"coins_light": 0,
|
||||||
"coins_dark": 0,
|
"coins_dark": 0,
|
||||||
"butterfly_jars": 0,
|
"butterfly_jars": 3,
|
||||||
"has_ranged": true,
|
"has_ranged": true,
|
||||||
"reward_chest_opened": true,
|
"reward_chest_opened": true,
|
||||||
"equipment": {
|
"equipment": {
|
||||||
@@ -212,13 +212,14 @@
|
|||||||
],
|
],
|
||||||
"broken_doors": [
|
"broken_doors": [
|
||||||
"room_07.json:-980,2180,80,600",
|
"room_07.json:-980,2180,80,600",
|
||||||
|
"room_07.json:1940,2720,360,20",
|
||||||
"room_07.json:4100,2180,40,600",
|
"room_07.json:4100,2180,40,600",
|
||||||
"room_reward_01.json:1580,60,40,300",
|
"room_reward_01.json:1580,60,40,300",
|
||||||
"room_reward_01.json:1700,60,60,300"
|
"room_reward_01.json:1700,60,60,300"
|
||||||
],
|
],
|
||||||
"lantern_respawn": {
|
"lantern_respawn": {
|
||||||
"room_id": "room_town_gloomreach.json",
|
"room_id": "room_town_gloomreach.json",
|
||||||
"x": 120,
|
"x": 1300,
|
||||||
"y": 360
|
"y": 360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user