room overhaul

more editor changes, new rooms
This commit is contained in:
2026-09-29 18:55:29 +02:00
parent f676e59008
commit a9e999b49e
14 changed files with 1110 additions and 1288 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop
## 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.
**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. Saving **fully restores your health and Charge** (and clears poison), and sets your respawn point 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**).
+3
View File
@@ -19,6 +19,9 @@
},
"player": {
"type": "player"
},
"quest_point": {
"type": "quest_giver"
}
}
}
+183 -139
View File
@@ -1,158 +1,202 @@
{
"name": "room_01",
"display_name": "room_01",
"meta": {
"id": "room_01",
"name": "Room 01 - Tutorial Steps"
"name": "room_01"
},
"bounds": {},
"player_spawn": [
-260,
500
100,
180
],
"bounds": {
"kill_y": 2000
},
"platforms": {
"ground": [
[
-500,
640,
2060,
460
],
[
-500,
560,
520,
80
],
[
1560,
220,
120,
880
],
[
-1100,
-520,
600,
1620
],
[
1680,
-100,
920,
1200
],
[
-500,
-520,
940,
840
],
[
440,
-520,
2160,
420
]
],
"floating": [
[
800,
220,
320,
40
],
[
880,
520,
240,
40
],
[
500,
400,
320,
40
],
[
440,
280,
200,
40
],
[
1220,
220,
340,
40
]
"entry_spawns": {
"room1_right": [
1040,
680
]
},
"enemies": [],
"exits": [
"platforms": [
{
"rect": [
-420,
480,
90,
80
],
"to": "room_reward_01",
"spawn": [
120,
300
],
"hint": "Reward"
"x": 20,
"y": 260,
"w": 1000,
"h": 20,
"group": "ground",
"layer": 0
},
{
"rect": [
1560,
100,
120,
120
],
"x": 0,
"y": 0,
"w": 20,
"h": 780,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 0,
"w": 1580,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 540,
"y": 140,
"w": 160,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 900,
"y": 400,
"w": 700,
"h": 100,
"group": "ground",
"layer": 0
},
{
"x": 1580,
"y": 20,
"w": 20,
"h": 380,
"group": "ground",
"layer": 0
},
{
"x": 1120,
"y": 280,
"w": 40,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 840,
"y": 460,
"w": 60,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 600,
"y": 400,
"w": 140,
"h": 100,
"group": "ground",
"layer": 0
},
{
"x": 380,
"y": 400,
"w": 140,
"h": 100,
"group": "ground",
"layer": 0
},
{
"x": 160,
"y": 400,
"w": 140,
"h": 100,
"group": "ground",
"layer": 0
},
{
"x": 160,
"y": 620,
"w": 760,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 160,
"y": 500,
"w": 20,
"h": 120,
"group": "ground",
"layer": 0
},
{
"x": 900,
"y": 500,
"w": 20,
"h": 120,
"group": "ground",
"layer": 0
},
{
"x": 800,
"y": 520,
"w": 60,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 760,
"w": 1140,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1140,
"y": 500,
"w": 20,
"h": 260,
"group": "ground",
"layer": 0
}
],
"enemies": [
{
"x": 1480,
"y": 360,
"type": "enemy",
"layer": 0
},
{
"x": 340,
"y": 580,
"type": "enemy",
"layer": 0
}
],
"npcs": [],
"props": [
{
"x": 700,
"y": 760,
"type": "butterfly_jar",
"radius": 180,
"object_id": "flow_butterfly_jar",
"layer": 0,
"quest_id": ""
}
],
"exits": [
{
"x": 1060,
"y": 660,
"w": 80,
"h": 100,
"to": "room_02",
"spawn_id": "room2_left",
"spawn": [
-420,
300
120,
240
],
"hint": "",
"spawn_id": "entry_1"
"layer": 0
}
],
"ability_doors": [],
"entry_spawns": {},
"props": [
{
"type": "light_source",
"pos": [
0,
560
],
"radius": 180
},
{
"type": "save_point",
"pos": [
-60,
560
],
"radius": 110
},
{
"type": "butterfly_jar",
"pos": [
320,
560
]
},
{
"type": "quest_giver",
"quest_id": "quest_first_kills",
"pos": [
300,
600
]
}
]
"room_w": 1600,
"room_h": 900
}
+227 -207
View File
@@ -1,238 +1,258 @@
{
"name": "room_02",
"display_name": "room_02",
"meta": {
"id": "room_02",
"name": "Room 02 - First Enemies"
"name": "room_02"
},
"bounds": {},
"player_spawn": [
-320,
300
820,
440
],
"bounds": {
"kill_y": 2000
},
"platforms": {
"ground": [
[
-500,
360,
2440,
660
],
[
-760,
-580,
260,
1600
],
[
1940,
-580,
240,
1600
],
[
-760,
-820,
2940,
240
]
"entry_spawns": {
"room2_bottom_left": [
120,
460
],
"floating": [
[
260,
260,
140,
18
],
[
520,
220,
140,
18
],
[
760,
180,
140,
18
],
[
940,
120,
170,
18
],
[
1200,
160,
170,
18
],
[
1460,
100,
170,
18
],
[
1200,
-20,
280,
40
],
[
40,
-140,
1100,
60
],
[
-500,
-140,
460,
60
],
[
-500,
-260,
260,
40
],
[
-500,
-380,
160,
40
],
[
-320,
-500,
1380,
40
],
[
1140,
-440,
340,
40
],
[
1560,
-380,
380,
40
]
"room2_top_left": [
120,
240
],
"hazard_respawn_1": [
840,
440
]
},
"platforms": [
{
"x": 0,
"y": 320,
"w": 1040,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 0,
"y": 120,
"w": 20,
"h": 200,
"group": "ground",
"layer": 0
},
{
"x": 420,
"y": 220,
"w": 220,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 120,
"w": 1240,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1240,
"y": 140,
"w": 20,
"h": 60,
"group": "ground",
"layer": 0
},
{
"x": 1060,
"y": 360,
"w": 180,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1260,
"y": 320,
"w": 140,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1260,
"y": 180,
"w": 260,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1500,
"y": 200,
"w": 20,
"h": 440,
"group": "ground",
"layer": 0
},
{
"x": 860,
"y": 620,
"w": 640,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 800,
"y": 500,
"w": 60,
"h": 140,
"group": "ground",
"layer": 0
},
{
"x": 1400,
"y": 500,
"w": 100,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1380,
"y": 380,
"w": 40,
"h": 40,
"group": "ground",
"layer": 0
},
{
"x": 380,
"y": 620,
"w": 420,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 260,
"y": 580,
"w": 120,
"h": 60,
"group": "ground",
"layer": 0
},
{
"x": 640,
"y": 500,
"w": 80,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 480,
"y": 500,
"w": 80,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 540,
"w": 220,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 0,
"y": 340,
"w": 20,
"h": 220,
"group": "ground",
"layer": 0
},
{
"x": 240,
"y": 560,
"w": 20,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 380,
"y": 600,
"w": 420,
"h": 20,
"group": "hazard",
"layer": 0,
"respawn": [
840,
440
]
}
],
"enemies": [
{
"x": 1160,
"y": 320,
"type": "enemy",
"pos": [
240,
-200
]
"layer": 0
},
{
"x": 1160,
"y": 580,
"type": "enemy",
"pos": [
620,
140
]
},
"layer": 0
}
],
"npcs": [],
"props": [
{
"type": "enemy",
"pos": [
860,
-200
]
},
{
"type": "enemy",
"pos": [
480,
-530
]
},
{
"type": "enemy",
"pos": [
1740,
280
]
"x": 720,
"y": 320,
"type": "save_point",
"radius": 90,
"object_id": "lamp"
}
],
"exits": [
{
"rect": [
-500,
300,
80,
60
],
"x": 20,
"y": 220,
"w": 80,
"h": 100,
"to": "room_01",
"spawn_id": "room1_right",
"spawn": [
1560,
140
1040,
680
],
"hint": "Back"
"hint": "",
"layer": 0
},
{
"rect": [
1860,
-500,
80,
120
],
"to": "room_07",
"x": 20,
"y": 440,
"w": 80,
"h": 100,
"to": "room_03",
"spawn_id": "room3_top_right",
"spawn": [
180,
240
1480,
220
],
"hint": ""
"hint": "",
"layer": 0
}
],
"ability_doors": [],
"props": [
{
"type": "save_point",
"pos": [
-300,
360
],
"radius": 110
},
{
"type": "butterfly_jar",
"pos": [
800,
360
]
},
{
"type": "light_source",
"pos": [
300,
360
],
"radius": 180
},
{
"type": "light_source",
"pos": [
1100,
360
],
"radius": 180
}
],
"entry_spawns": {
"entry_1": [
-420,
300
],
"entry_3": [
1840,
-440
]
}
"room_w": 1600,
"room_h": 900
}
@@ -0,0 +1,135 @@
{
"name": "room_03",
"display_name": "room_03",
"meta": {
"id": "room_03",
"name": "room_03"
},
"bounds": {},
"player_spawn": [
1460,
220
],
"entry_spawns": {
"room3_top_right": [
1480,
220
]
},
"platforms": [
{
"x": 1080,
"y": 300,
"w": 520,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1580,
"y": 60,
"w": 20,
"h": 240,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 40,
"w": 1580,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1080,
"y": 320,
"w": 20,
"h": 140,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 620,
"w": 1080,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 60,
"w": 20,
"h": 560,
"group": "ground",
"layer": 0
},
{
"x": 620,
"y": 520,
"w": 80,
"h": 20,
"group": "moving",
"layer": 0,
"path": [
[
620,
520
],
[
620,
340
]
]
},
{
"x": 1080,
"y": 460,
"w": 20,
"h": 160,
"group": "trigger"
}
],
"enemies": [],
"npcs": [],
"props": [
{
"x": 1300,
"y": 220,
"type": "player",
"radius": 180,
"object_id": "player",
"layer": 0,
"quest_id": ""
},
{
"x": 960,
"y": 540,
"type": "quest_giver",
"radius": 180,
"object_id": "quest_point",
"layer": 0,
"quest_id": "first_kills"
}
],
"exits": [
{
"x": 1500,
"y": 200,
"w": 80,
"h": 100,
"to": "room_02",
"spawn_id": "room2_bottom_left",
"spawn": [
120,
460
],
"hint": "",
"layer": 0
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900
}
@@ -1,468 +0,0 @@
{
"name": "room_07",
"display_name": "room_07",
"meta": {
"id": "room_07",
"name": "room_07"
},
"bounds": {
"kill_y": 3200
},
"player_spawn": [
200,
220
],
"entry_spawns": {
"entry_1": [
180,
240
],
"entry_2": [
4200,
2720
],
"entry_3": [
-320,
2720
]
},
"platforms": [
{
"x": 60,
"y": 320,
"w": 1460,
"h": 40,
"group": "ground"
},
{
"x": -1160,
"y": 2780,
"w": 5500,
"h": 120,
"group": "ground"
},
{
"x": -1160,
"y": -300,
"w": 1220,
"h": 2480,
"group": "ground"
},
{
"x": -1280,
"y": -300,
"w": 120,
"h": 3200,
"group": "ground"
},
{
"x": 2300,
"y": 1860,
"w": 2040,
"h": 320,
"group": "ground"
},
{
"x": 4340,
"y": -300,
"w": 100,
"h": 3200,
"group": "ground"
},
{
"x": 60,
"y": -300,
"w": 4280,
"h": 80,
"group": "ground"
},
{
"x": 60,
"y": 1860,
"w": 1600,
"h": 320,
"group": "ground"
},
{
"x": 1600,
"y": 440,
"w": 1060,
"h": 40,
"group": "ground"
},
{
"x": 2780,
"y": 320,
"w": 580,
"h": 40,
"group": "ground"
},
{
"x": 3280,
"y": 800,
"w": 1060,
"h": 60,
"group": "ground"
},
{
"x": 1660,
"y": 1920,
"w": 360,
"h": 20,
"group": "ground"
},
{
"x": 1940,
"y": 2040,
"w": 360,
"h": 20,
"group": "ground"
},
{
"x": 1660,
"y": 2160,
"w": 360,
"h": 20,
"group": "ground"
},
{
"x": 1940,
"y": 2280,
"w": 360,
"h": 20,
"group": "ground"
},
{
"x": 1660,
"y": 2400,
"w": 360,
"h": 20,
"group": "ground"
},
{
"x": 1940,
"y": 2520,
"w": 360,
"h": 20,
"group": "ground"
},
{
"x": 1660,
"y": 2640,
"w": 360,
"h": 20,
"group": "ground"
},
{
"x": 2660,
"y": 880,
"w": 520,
"h": 40,
"group": "ground"
},
{
"x": 2140,
"y": 880,
"w": 380,
"h": 40,
"group": "ground"
},
{
"x": 1940,
"y": 880,
"w": 120,
"h": 40,
"group": "ground"
},
{
"x": 1680,
"y": 880,
"w": 100,
"h": 40,
"group": "ground"
},
{
"x": 1440,
"y": 880,
"w": 80,
"h": 40,
"group": "ground"
},
{
"x": 1220,
"y": 880,
"w": 80,
"h": 40,
"group": "ground"
},
{
"x": 940,
"y": 880,
"w": 180,
"h": 40,
"group": "ground"
},
{
"x": 660,
"y": 820,
"w": 180,
"h": 40,
"group": "ground"
},
{
"x": 300,
"y": 740,
"w": 260,
"h": 60,
"group": "ground"
},
{
"x": 260,
"y": 640,
"w": 40,
"h": 1100,
"group": "ground"
},
{
"x": 300,
"y": 1260,
"w": 4040,
"h": 40,
"group": "ground"
},
{
"x": 3240,
"y": 980,
"w": 60,
"h": 40,
"group": "ground"
},
{
"x": 3100,
"y": 1060,
"w": 60,
"h": 40,
"group": "ground"
},
{
"x": 3240,
"y": 1140,
"w": 60,
"h": 40,
"group": "ground"
},
{
"x": 3380,
"y": 440,
"w": 300,
"h": 40,
"group": "ground"
},
{
"x": 3700,
"y": 560,
"w": 300,
"h": 40,
"group": "ground"
},
{
"x": 4020,
"y": 680,
"w": 320,
"h": 40,
"group": "ground"
},
{
"x": 2100,
"y": 2720,
"w": 60,
"h": 20
}
],
"enemies": [
{
"x": 2080,
"y": 380,
"type": "enemy"
},
{
"x": 280,
"y": 2680,
"type": "miniboss"
},
{
"x": 520,
"y": 2700,
"type": "enemy"
},
{
"x": 680,
"y": 2700,
"type": "enemy"
},
{
"x": 980,
"y": 2700,
"type": "enemy"
},
{
"x": 840,
"y": 2700,
"type": "enemy"
},
{
"x": 820,
"y": 1160,
"type": "enemy"
},
{
"x": 1040,
"y": 1160,
"type": "enemy"
},
{
"x": 1340,
"y": 1160,
"type": "enemy"
},
{
"x": 1700,
"y": 1160,
"type": "enemy"
},
{
"x": 2140,
"y": 1160,
"type": "enemy"
},
{
"x": 2480,
"y": 1160,
"type": "enemy"
},
{
"x": 2700,
"y": 1160,
"type": "enemy"
},
{
"x": 2740,
"y": 1720,
"type": "miniboss"
}
],
"npcs": [],
"props": [
{
"x": 200,
"y": 320,
"type": "save_point",
"radius": 110,
"object_id": null
},
{
"x": 820,
"y": 320,
"type": "butterfly_jar",
"radius": 180,
"object_id": null
},
{
"x": 500,
"y": 320,
"type": "light_source",
"radius": 180,
"object_id": null
},
{
"x": 900,
"y": 2780,
"type": "light_source",
"radius": 180,
"object_id": null
},
{
"x": 2600,
"y": 1860,
"type": "light_source",
"radius": 180,
"object_id": null
}
],
"exits": [
{
"x": -1160,
"y": 2640,
"w": 100,
"h": 140,
"to": "room_town_gloomreach",
"spawn_id": "",
"spawn": [
120,
300
],
"hint": ""
},
{
"x": -420,
"y": 2720,
"w": 80,
"h": 60,
"to": "room_boss_01",
"spawn_id": "entry_2",
"spawn": [
-360,
300
],
"hint": ""
},
{
"x": 4240,
"y": 2700,
"w": 100,
"h": 80,
"to": "room_reward_01",
"spawn_id": "",
"spawn": [
1760,
300
],
"hint": ""
},
{
"x": 60,
"y": 180,
"w": 100,
"h": 140,
"to": "room_02",
"spawn_id": "",
"spawn": [
1840,
-440
],
"hint": ""
}
],
"ability_doors": [
{
"x": -980,
"y": 2180,
"w": 80,
"h": 600
},
{
"x": 4100,
"y": 2180,
"w": 40,
"h": 600
},
{
"x": 1940,
"y": 2720,
"w": 360,
"h": 20
}
],
"room_w": 1600,
"room_h": 900
}
@@ -1,81 +0,0 @@
{
"meta": {
"id": "room_boss_01",
"name": "room_boss_01"
},
"player_spawn": [
-240,
300
],
"bounds": {
"kill_y": 1400
},
"platforms": {
"ground": [
[
-500,
360,
1500,
240
]
]
},
"enemies": [
{
"type": "boss",
"pos": [
280,
200
]
}
],
"exits": [
{
"rect": [
-460,
300,
80,
60
],
"to": "room_07",
"spawn": [
-320,
2720
],
"hint": "Retreat",
"spawn_id": "entry_3"
},
{
"rect": [
880,
260,
120,
100
],
"to": "room_reward_01",
"spawn": [
100,
300
],
"hint": "Reward",
"spawn_id": "entry_2"
}
],
"ability_doors": [],
"props": [
{
"type": "light_source",
"pos": [
-100,
360
],
"radius": 180
}
],
"entry_spawns": {
"entry_2": [
-360,
300
]
}
}
@@ -1,48 +0,0 @@
{
"meta": {
"id": "room_boss_refight_01",
"name": "room_boss_refight_01"
},
"player_spawn": [
-240,
300
],
"bounds": {
"kill_y": 1400
},
"platforms": {
"ground": [
[
-500,
360,
1500,
240
]
]
},
"enemies": [
{
"type": "boss",
"pos": [
280,
200
]
}
],
"exits": [
{
"rect": [
-460,
300,
80,
60
],
"to": "room_town_gloomreach",
"spawn_id": "entry_diary",
"hint": "Leave",
"requires_boss_dead": true
}
],
"ability_doors": [],
"entry_spawns": {}
}
@@ -1,132 +0,0 @@
{
"meta": {
"id": "room_reward_01",
"name": "room_reward_01"
},
"player_spawn": [
140,
300
],
"bounds": {
"kill_y": 2000
},
"platforms": {
"ground": [
[
-500,
360,
2400,
240
]
],
"floating": [
[
650,
260,
220,
18
],
[
1100,
260,
220,
18
]
]
},
"enemies": [],
"exits": [
{
"rect": [
0,
300,
80,
60
],
"to": "room_boss_01",
"spawn_id": "entry_2",
"spawn": [
110,
260
],
"hint": "Back"
},
{
"rect": [
1840,
300,
60,
60
],
"to": "room_07",
"spawn_id": "entry_2",
"spawn": [
4200,
2720
],
"hint": "Forward"
}
],
"ability_doors": [
{
"rect": [
1700,
60,
60,
300
]
},
{
"rect": [
1580,
60,
40,
300
]
}
],
"entry_spawns": {
"entry_2": [
100,
300
],
"entry_1": [
1800,
300
]
},
"props": [
{
"type": "save_point",
"pos": [
140,
360
],
"radius": 110
},
{
"type": "light_source",
"pos": [
1100,
360
],
"radius": 180
},
{
"type": "reward_chest",
"pos": [
520,
330
],
"radius": 180
},
{
"type": "ability_pickup",
"pos": [
820,
210
],
"radius": 180
}
]
}
@@ -1,143 +0,0 @@
{
"meta": {
"id": "room_town_gloomreach",
"name": "room_town_gloomreach"
},
"player_spawn": [
80,
260
],
"bounds": {
"kill_y": 2000
},
"platforms": {
"ground": [
[
-500,
360,
2400,
240
]
]
},
"enemies": [],
"npcs": [
{
"type": "vendor",
"pos": [
700,
306
]
},
{
"type": "upgrader",
"pos": [
860,
306
]
},
{
"type": "necklace_vendor",
"pos": [
1020,
306
]
},
{
"type": "combat_master",
"pos": [
479,
306
]
}
],
"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"
}
]
}
+306 -47
View File
@@ -9,9 +9,9 @@
--bg:#0b0f14; --panel:#111827; --panel2:#0f172a; --fg:#e5e7eb; --muted:#9ca3af;
--accent:#60a5fa; --accent2:#34d399; --warn:#f59e0b; --danger:#ef4444; --border:#1f2937;
}
html,body{height:100%; margin:0; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;}
.wrap{display:grid; grid-template-columns: 360px 1fr; height:100vh; min-height:100vh;}
.left{background:var(--panel); border-right:1px solid var(--border); padding:12px; overflow:auto;}
html,body{height:100%; margin:0; overflow:hidden; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;}
.wrap{display:grid; grid-template-columns: 360px 1fr; height:100vh; min-height:0; overflow:hidden;}
.left{background:var(--panel); border-right:1px solid var(--border); padding:12px; overflow-y:auto; height:100vh; box-sizing:border-box;}
.section{background:rgba(255,255,255,0.03); border:1px solid var(--border); border-radius:12px; padding:10px; margin-bottom:12px;}
.section h3{margin:0 0 8px 0; font-size:14px; color:var(--fg); display:flex; align-items:center; gap:8px;}
.row{display:flex; gap:8px; flex-wrap:wrap; align-items:center;}
@@ -32,7 +32,7 @@
.badge{font-size:11px; padding:2px 6px; border-radius:999px; border:1px solid var(--border); color:var(--muted);}
.badge.dirty{border-color:rgba(245,158,11,0.6); color:#fbbf24;}
.kbd{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; padding:2px 6px; border:1px solid var(--border); border-radius:6px; color:var(--muted);}
.right{position:relative; display:flex; flex-direction:column; min-width:0; height:100%;}
.right{position:relative; display:flex; flex-direction:column; min-width:0; height:100%; overflow:hidden;}
/* Floating tool palette pinned to the right edge of the canvas area */
#toolbar{
position:absolute; right:10px; top:70px; z-index:60;
@@ -105,6 +105,16 @@
Grid snap (20px) on placement / drag
</label>
</div>
<div class="row" style="margin-top:8px;">
<label class="muted" style="flex:1;">New platform type:</label>
<select id="platformGroup" style="flex:1; min-width:120px;">
<option value="ground">Ground</option>
<option value="floating">Floating</option>
<option value="hazard">Hazard (red - damage)</option>
<option value="moving">Moving (path)</option>
<option value="trigger">Trigger (name)</option>
</select>
</div>
<div class="muted" style="margin-top:8px;">
<b>Layers:</b> every object (platforms, houses, NPCs, enemies, props, doors, exits) has a <b>layer</b> in its Properties — lower = further back, higher = further forward. The canvas and game draw back-to-front by (layer, category), and the select tool respects layers.
</div>
@@ -258,6 +268,7 @@
house: { type: "house" },
diary: { type: "diary" },
player: { type: "player" },
quest_point: { type: "quest_giver" },
};
// Type fallbacks used when no object def provides a radius.
const TYPE_DEFAULT_RADII = { light_source: 180, save_point: 90 };
@@ -328,7 +339,22 @@
};
}
function normalizeRoom(json, fallbackName, filename){
function normPlatformEntry(rect, group){
if(Array.isArray(rect)){
return { x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group, layer: rect.length>=5 ? (+rect[4]||0) : 0 };
}
if(rect && typeof rect === "object"){
const out = { x:+rect.x||0, y:+rect.y||0, w:+rect.w||0, h:+rect.h||0, group: rect.group || group || "ground", layer: +(rect.layer||0) };
if(Array.isArray(rect.path)) out.path = deepClone(rect.path);
if(rect.speed !== undefined) out.speed = +rect.speed;
if(rect.name) out.name = String(rect.name);
if(Array.isArray(rect.respawn)) out.respawn = deepClone(rect.respawn);
return out;
}
return null;
}
function normalizeRoom(json, fallbackName, filename){
// Supports both:
// A) editor-native rooms (name, platforms:[{x,y,w,h}], exits:[{x,y,w,h}...])
// B) your game rooms (meta, platforms:{ground:[[x,y,w,h],...], ...}, exits:[{rect:[...]}], enemies:[{pos:[x,y]}])
@@ -367,27 +393,23 @@
const arr = platObj[group];
if(!Array.isArray(arr)) continue;
for(const rect of arr){
if(!Array.isArray(rect) || rect.length < 4) continue;
r.platforms.push({ x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group, layer: rect.length>=5 ? (+rect[4]||0) : 0 });
const np = normPlatformEntry(rect, group);
if(np) r.platforms.push(np);
}
}
// if nothing came through, try also accepting editor-native arrays inside game rooms
if(Array.isArray(src.platforms)){
for(const p of src.platforms){
if(p && typeof p === "object"){
r.platforms.push({ x:+p.x||0, y:+p.y||0, w:+p.w||0, h:+p.h||0, group: p.group || "ground", layer: +(p.layer||0) });
}
const np = normPlatformEntry(p, p && p.group ? p.group : "ground");
if(np) r.platforms.push(np);
}
}
} else {
// editor-native
if(Array.isArray(src.platforms)){
for(const p of src.platforms){
if(p && typeof p === "object"){
r.platforms.push({ x:+p.x||0, y:+p.y||0, w:+p.w||0, h:+p.h||0, group: p.group || "ground", layer: +(p.layer||0) });
} else if(Array.isArray(p) && p.length>=4){
r.platforms.push({ x:+p[0]||0, y:+p[1]||0, w:+p[2]||0, h:+p[3]||0, group:"ground", layer: p.length>=5 ? (+p[4]||0) : 0 });
}
const np = normPlatformEntry(p, p && p.group ? p.group : "ground");
if(np) r.platforms.push(np);
}
} else if(src.platforms && typeof src.platforms === "object"){
// allow grouped platforms even in editor rooms
@@ -395,8 +417,8 @@
const arr = src.platforms[group];
if(!Array.isArray(arr)) continue;
for(const rect of arr){
if(!Array.isArray(rect) || rect.length < 4) continue;
r.platforms.push({ x:+rect[0]||0, y:+rect[1]||0, w:+rect[2]||0, h:+rect[3]||0, group, layer: rect.length>=5 ? (+rect[4]||0) : 0 });
const np = normPlatformEntry(rect, group);
if(np) r.platforms.push(np);
}
}
}
@@ -444,7 +466,7 @@
const type = p.type || p.kind || "light_source";
const object_id = p.object_id || p.objectId || null;
const radius = +p.radius || getDefaultRadiusForType(type, object_id);
r.props.push({ x, y, type, radius, object_id, layer: +(p.layer||0) });
r.props.push({ x, y, type, radius, object_id, layer: +(p.layer||0), quest_id: p.quest_id || p.questId || "" });
}
}
@@ -535,7 +557,8 @@
enemyPlaceType: "enemy",
npcPlaceType: "vendor",
showAggro: false,
objectPlaceId: null
objectPlaceId: null,
waitingPathPt: null
};
const els = {
@@ -957,6 +980,24 @@ Cancel = Choose whether to remove without saving`);
const room = roomWrap.data;
// Adding a path point to a selected moving platform?
if(state.waitingPathPt){
if(ev.button === 0){
ev.preventDefault();
const wp = state.waitingPathPt;
const wrap = state.rooms.get(wp.roomId);
const plat = wrap && wrap.data.platforms && wrap.data.platforms[wp.index];
if(plat){
if(!Array.isArray(plat.path)) plat.path = [[plat.x, plat.y]];
plat.path.push(snapPt(wx, wy));
}
state.waitingPathPt = null;
refreshUI();
setStatus("Path point added.");
return;
}
}
if(ev.button === 1){
// middle mouse pan handled by mousedown
return;
@@ -1075,7 +1116,8 @@ Cancel = Choose whether to remove without saving`);
const [sx1,sy1]=snapPt(wx,wy);
// Start as a 1x1 rect at the anchor; resizing can go in any direction.
const p = {x: sx1, y: sy1, w: 1, h: 1};
const p = {x: sx1, y: sy1, w: 1, h: 1, group: state.platformGroup || "ground"};
if(p.group === "moving") p.path = [[sx1, sy1]];
room.platforms.push(p);
state.selection = {kind:"platform", index: room.platforms.length-1, roomId: roomWrap.id};
@@ -1491,7 +1533,12 @@ Cancel = Choose whether to remove without saving`);
};
const layerable = [];
for(const p of (room.props || [])) if(p) layerable.push({ z: +(p.layer||0), cat: 0, kind: "prop", p });
for(const p of (room.platforms || [])) layerable.push({ z: +(p.layer||0), cat: 1, kind: "platform", p });
for(const p of (room.platforms || [])){
if(p && (p.group === "moving")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "moving", p });
else if(p && (p.group === "trigger")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "trigger", p });
else if(p && (p.group === "hazard")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "hazard", p });
else layerable.push({ z: +(p.layer||0), cat: 1, kind: "platform", p });
}
for(const d of (room.ability_doors || [])) if(d) layerable.push({ z: +(d.layer||0), cat: 2, kind: "door", d });
for(const ex of (room.exits || [])) if(ex) layerable.push({ z: +(ex.layer||0), cat: 3, kind: "exit", ex });
for(const e of (room.enemies || [])) if(e) layerable.push({ z: +(e.layer||0), cat: 4, kind: "enemy", e });
@@ -1509,6 +1556,89 @@ Cancel = Choose whether to remove without saving`);
ctx.strokeStyle = "rgba(96,165,250,0.75)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
} else if(it.kind === "moving"){
const p = it.p;
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
ctx.fillStyle = "rgba(80,180,220,0.30)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(150,220,255,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
// draw the movement path (dashed line + waypoint markers)
const path = p.path || [];
if(path.length >= 2){
ctx.strokeStyle = "rgba(150,220,255,0.5)";
ctx.lineWidth = 1.5*dpr;
ctx.setLineDash([4*dpr, 4*dpr]);
ctx.beginPath();
path.forEach((pt, pi)=>{
const [xx,yy] = worldToScreen(pt[0], pt[1], canvas);
if(pi===0) ctx.moveTo(xx,yy); else ctx.lineTo(xx,yy);
});
// loop back to start
const [exx,eyy] = worldToScreen(path[0][0], path[0][1], canvas);
ctx.lineTo(exx, eyy);
ctx.stroke();
ctx.setLineDash([]);
for(const pt of path){
const [xx,yy] = worldToScreen(pt[0], pt[1], canvas);
ctx.fillStyle = "rgba(150,220,255,0.9)";
ctx.beginPath(); ctx.arc(xx, yy, 3*dpr, 0, Math.PI*2); ctx.fill();
}
// ghost outline of the platform at its final waypoint
const lastPt = path[path.length-1];
if(lastPt){
const [gx,gy] = worldToScreen(lastPt[0], lastPt[1], canvas);
const gw = sx2 - sx, gh = sy2 - sy;
ctx.strokeStyle = "rgba(150,220,255,0.45)";
ctx.lineWidth = 2*dpr;
ctx.setLineDash([5*dpr, 4*dpr]);
ctx.strokeRect(gx, gy, gw, gh);
ctx.setLineDash([]);
}
}
} else if(it.kind === "trigger"){
const p = it.p;
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
ctx.fillStyle = "rgba(220,190,60,0.30)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(255,230,120,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
const label = p.name ? ("Trigger: " + p.name) : "Trigger";
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText(label, sx+4*dpr, sy-4*dpr);
} else if(it.kind === "hazard"){
const p = it.p;
const [sx,sy] = worldToScreen(p.x, p.y, canvas);
const [sx2,sy2] = worldToScreen(p.x+p.w, p.y+p.h, canvas);
ctx.fillStyle = "rgba(239,68,68,0.35)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(239,68,68,0.9)";
ctx.lineWidth = 2*dpr;
ctx.strokeRect(sx,sy, sx2-sx, sy2-sy);
ctx.fillStyle = "rgba(229,231,235,0.9)";
ctx.font = `${11*dpr}px system-ui`;
ctx.fillText("Hazard", sx+4*dpr, sy-4*dpr);
// linked respawn marker
if(Array.isArray(p.respawn) && p.respawn.length>=2){
const [rx,ry] = worldToScreen(p.respawn[0], p.respawn[1], canvas);
ctx.strokeStyle = "rgba(52,211,153,0.9)";
ctx.lineWidth = 2*dpr;
ctx.beginPath(); ctx.arc(rx, ry, 6*dpr, 0, Math.PI*2); ctx.stroke();
ctx.fillStyle = "rgba(52,211,153,0.9)";
ctx.beginPath(); ctx.arc(rx, ry, 3*dpr, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = "rgba(52,211,153,0.5)";
ctx.setLineDash([3*dpr,3*dpr]);
ctx.beginPath();
ctx.moveTo((sx+sx2)/2, (sy+sy2)/2);
ctx.lineTo(rx, ry);
ctx.stroke();
ctx.setLineDash([]);
}
} else if(it.kind === "door"){
const d = it.d;
const [sx,sy] = worldToScreen(d.x, d.y, canvas);
@@ -1807,26 +1937,17 @@ Cancel = Choose whether to remove without saving`);
const ex = exits[i];
if(!ex || ex.to !== dstWrap.data.name) continue;
// End the line at the corresponding DOOR in the compare room (the exit that
// leads back to this room), so it lands accurately on the doorway.
// End the line at the ENTRY point in the compare room (spawn / entry spawn),
// i.e. exit -> entry, not exit -> exit.
const sid = ex.spawn_id || null;
let pos = null;
for(const dex of (dstWrap.data.exits || [])){
if(dex && dex.to === srcWrap.data.name){
pos = [dex.x + dex.w/2, dex.y + dex.h/2];
break;
}
if(sid === "__player_spawn__"){
pos = dstWrap.data.player_spawn || null;
} else if(sid){
pos = (dstWrap.data.entry_spawns && dstWrap.data.entry_spawns[sid]) ? dstWrap.data.entry_spawns[sid] : null;
}
// Fallback: the exit's spawn / entry spawn in the compare room.
if(!pos){
const sid = ex.spawn_id || null;
if(sid === "__player_spawn__"){
pos = dstWrap.data.player_spawn || null;
} else if(sid){
pos = (dstWrap.data.entry_spawns && dstWrap.data.entry_spawns[sid]) ? dstWrap.data.entry_spawns[sid] : null;
}
if(!pos && Array.isArray(ex.spawn) && ex.spawn.length >= 2){
pos = ex.spawn;
}
if(!pos && Array.isArray(ex.spawn) && ex.spawn.length >= 2){
pos = ex.spawn;
}
if(!pos) continue;
@@ -1998,6 +2119,39 @@ Cancel = Choose whether to remove without saving`);
<div><div class="mini">h</div><input id="ph" type="number" value="${p.h}"/></div>
<div><div class="mini">layer (lower = back)</div><input id="pl" type="number" value="${p.layer||0}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type</div>
<select id="pg">
<option value="ground" ${p.group==="ground"||!p.group?"selected":""}>Ground</option>
<option value="floating" ${p.group==="floating"?"selected":""}>Floating</option>
<option value="hazard" ${p.group==="hazard"?"selected":""}>Hazard (red - damage)</option>
<option value="moving" ${p.group==="moving"?"selected":""}>Moving (path)</option>
<option value="trigger" ${p.group==="trigger"?"selected":""}>Trigger (name)</option>
</select>
</div>
${(p.group||"ground") === "trigger" ? `
<div style="margin-top:8px;">
<div class="mini">name</div>
<input id="ptname" type="text" value="${p.name||""}" placeholder="e.g. opens_bridge"/>
</div>` : ""}
${(p.group||"ground") === "hazard" ? `
<div style="margin-top:8px;">
<div class="mini">respawn point (hazard teleports here)</div>
<select id="hzrespawn">
<option value="">(none - room default)</option>
${Object.keys(room.entry_spawns||{}).map(k=>`<option value="${k}">Entry: ${k}</option>`).join("")}
</select>
</div>` : ""}
${(p.group||"ground") === "moving" ? `
<div style="margin-top:8px;">
<div class="mini">path (waypoints - click Add, then the canvas)</div>
<button id="addPathPt" class="primary" style="width:100%;">Add path point</button>
<div id="pathList" style="margin-top:6px; max-height:120px; overflow:auto;"></div>
</div>
<div style="margin-top:8px;">
<div class="mini">speed (px/s)</div>
<input id="ptspeed" type="number" value="${p.speed||120}"/>
</div>` : ""}
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
@@ -2079,9 +2233,10 @@ Cancel = Choose whether to remove without saving`);
<div><div class="mini">y</div><input id="npy" type="number" value="${n.y}"/></div>
</div>
<div style="margin-top:8px;">
<div class="mini">type</div>
<div class="mini">type / name</div>
<input id="npt" type="text" value="${n.type||"vendor"}"/>
</div>
<div class="muted" style="margin-top:6px;">Any name works (e.g. quest_giver_gwen). vendor / upgrader / necklace_vendor / combat_master have game behavior; custom names are inert until coded.</div>
<div style="margin-top:8px;">
<div class="mini">layer (lower = back)</div>
<input id="npl" type="number" value="${n.layer||0}"/>
@@ -2096,7 +2251,8 @@ Cancel = Choose whether to remove without saving`);
if(!p) return "<div class='muted'>Selection missing.</div>";
const prDefault = getDefaultRadiusForType(p.type || "light_source", p.object_id || p.objectId || null);
const objId = p.object_id || p.objectId || "";
const hint = ({light_source:"Butterfly (charge regen 2x)", save_point:"Lantern (save point - press F to save)", butterfly_jar:"Butterfly-in-jar (collectible)", diary:"Boss diary (interact)", house:"Standalone house", player:"Player size reference (no in-game effect)"})[p.type||"light_source"] || "";
const hint = ({light_source:"Butterfly (charge regen 2x)", save_point:"Lantern (save point - press F to save)", butterfly_jar:"Butterfly-in-jar (collectible)", diary:"Boss diary (interact)", house:"Standalone house", player:"Player size reference (no in-game effect)", quest_giver:"Quest point (name it below)"})[p.type||"light_source"] || "";
const isQuest = (p.type||"") === "quest_giver";
return `
<div><b>Prop</b> #${sel.index}${hint ? ` <span class="mini">· ${hint}</span>` : ""}</div>
<div class="hr"></div>
@@ -2108,6 +2264,11 @@ Cancel = Choose whether to remove without saving`);
<div class="mini">type</div>
<input id="prtype" type="text" value="${p.type||"light_source"}"/>
</div>
${isQuest ? `
<div style="margin-top:8px;">
<div class="mini">quest id (name)</div>
<input id="prqid" type="text" value="${p.quest_id||""}" placeholder="e.g. quest_save_the_well"/>
</div>` : ""}
<div style="margin-top:8px;">
<div class="mini">object id</div>
<input id="probjid" type="text" value="${objId}" placeholder="(e.g. flow_butterfly, lamp, flow_butterfly_jar)"/>
@@ -2188,6 +2349,67 @@ Cancel = Choose whether to remove without saving`);
}));
});
const pl=q("pl"); if(pl) pl.addEventListener("change", ()=>pushAnd(()=>{ p.layer = Math.round(num(pl.value)); }));
const pg=q("pg"); if(pg) pg.addEventListener("change", ()=>pushAnd(()=>{
p.group = pg.value || "ground";
if(p.group === "moving" && !Array.isArray(p.path)) p.path = [[p.x, p.y]];
if(p.group !== "trigger" && p.name !== undefined && p.name !== "") delete p.name;
}));
const ptname=q("ptname"); if(ptname) ptname.addEventListener("change", ()=>pushAnd(()=>{
const v = ptname.value.trim();
if(v){ p.name = v; } else { delete p.name; }
}));
const ptspeed=q("ptspeed"); if(ptspeed) ptspeed.addEventListener("change", ()=>pushAnd(()=>{ p.speed = Math.max(1, num(ptspeed.value)); }));
const hzrespawn=q("hzrespawn");
if(hzrespawn){
// preselect current respawn if it matches an entry spawn
let cur = "";
if(Array.isArray(p.respawn)){
const es = room.entry_spawns || {};
for(const k of Object.keys(es)){
const pos = es[k];
if(Array.isArray(pos) && pos.length>=2 && Math.round(pos[0])===Math.round(p.respawn[0]) && Math.round(pos[1])===Math.round(p.respawn[1])){
cur = k; break;
}
}
}
hzrespawn.value = cur;
hzrespawn.addEventListener("change", ()=>pushAnd(()=>{
const v = hzrespawn.value;
if(v === ""){ delete p.respawn; }
else {
const pos = (room.entry_spawns||{})[v];
if(Array.isArray(pos) && pos.length>=2) p.respawn = [Math.round(pos[0]), Math.round(pos[1])];
}
}));
}
const addPathPt=q("addPathPt"); if(addPathPt) addPathPt.addEventListener("click", ()=>{
state.waitingPathPt = { roomId: roomWrap.id, index: sel.index };
setStatus("Click on the canvas to add a path point to this moving platform.");
refreshProps();
});
const pathList=q("pathList");
if(pathList && Array.isArray(p.path)){
pathList.innerHTML = "";
p.path.forEach((pt, pi)=>{
const row = document.createElement("div");
row.style.cssText = "display:flex; align-items:center; gap:6px; margin-bottom:4px;";
row.innerHTML = `<span style="flex:1; font-size:12px;">${pi+1}. (${Math.round(pt[0])}, ${Math.round(pt[1])})</span>`;
const delBtn = document.createElement("button");
delBtn.textContent = "x";
delBtn.style.cssText = "padding:2px 8px;";
delBtn.addEventListener("click", ()=>pushAnd(()=>{
p.path.splice(pi,1);
}));
row.appendChild(delBtn);
pathList.appendChild(row);
});
if(state.waitingPathPt && state.waitingPathPt.index === sel.index){
const hint = document.createElement("div");
hint.className = "muted";
hint.textContent = "Click the canvas to add point " + (p.path.length + 1);
pathList.appendChild(hint);
}
}
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.platforms.splice(sel.index,1);
state.selection=null;
@@ -2250,10 +2472,14 @@ 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"), probjid=q("probjid"), prrad=q("prrad"), prlayer=q("prlayer");
const prx=q("prx"), pry=q("pry"), prtype=q("prtype"), probjid=q("probjid"), prrad=q("prrad"), prlayer=q("prlayer"), prqid=q("prqid");
if(prx) prx.addEventListener("change", ()=>pushAnd(()=>{ p.x=num(prx.value); }));
if(pry) pry.addEventListener("change", ()=>pushAnd(()=>{ p.y=num(pry.value); }));
if(prlayer) prlayer.addEventListener("change", ()=>pushAnd(()=>{ p.layer = Math.round(num(prlayer.value)); }));
if(prqid) prqid.addEventListener("change", ()=>pushAnd(()=>{
const v = prqid.value.trim();
if(v){ p.quest_id = v; } else { delete p.quest_id; }
}));
if(probjid) probjid.addEventListener("change", ()=>pushAnd(()=>{
const v = probjid.value.trim();
if(v){ p.object_id = v; } else { delete p.object_id; }
@@ -2309,6 +2535,24 @@ Cancel = Choose whether to remove without saving`);
const room = roomWrap.data;
// Adding a path point to a selected moving platform?
if(state.waitingPathPt){
if(ev.button === 0){
ev.preventDefault();
const wp = state.waitingPathPt;
const wrap = state.rooms.get(wp.roomId);
const plat = wrap && wrap.data.platforms && wrap.data.platforms[wp.index];
if(plat){
if(!Array.isArray(plat.path)) plat.path = [[plat.x, plat.y]];
plat.path.push(snapPt(wx, wy));
}
state.waitingPathPt = null;
refreshUI();
setStatus("Path point added.");
return;
}
}
if(ev.button === 1){
// middle mouse pan is handled by the mousedown listener (better cross-browser)
return;
@@ -2390,7 +2634,8 @@ Cancel = Choose whether to remove without saving`);
const [sx1,sy1]=snapPt(wx,wy);
// Start as a 1x1 rect at the anchor; resizing can go in any direction.
const p = {x: sx1, y: sy1, w: 1, h: 1};
const p = {x: sx1, y: sy1, w: 1, h: 1, group: state.platformGroup || "ground"};
if(p.group === "moving") p.path = [[sx1, sy1]];
room.platforms.push(p);
state.selection = {kind:"platform", index: room.platforms.length-1, roomId: roomWrap.id};
@@ -3000,14 +3245,27 @@ Cancel = Choose whether to remove without saving`);
const fmt = d._format || "editor_v1";
if(fmt === "game_v1"){
// rebuild grouped platforms (layer appended as 5th element when non-zero)
// rebuild grouped platforms (layer appended as 5th element when non-zero;
// moving/trigger platforms serialize as objects to keep their path/name/speed)
const platforms = {};
for(const p of (d.platforms || [])){
const group = p.group || "ground";
if(!platforms[group]) platforms[group] = [];
const rect = [p.x, p.y, p.w, p.h];
if(p.layer) rect.push(p.layer);
platforms[group].push(rect);
if(group === "moving" || group === "trigger" || group === "hazard"){
const obj = { x: p.x, y: p.y, w: p.w, h: p.h };
if(p.layer) obj.layer = p.layer;
if(group === "moving"){
if(Array.isArray(p.path)) obj.path = p.path;
if(p.speed !== undefined) obj.speed = p.speed;
}
if(p.name) obj.name = p.name;
if(Array.isArray(p.respawn)) obj.respawn = p.respawn;
platforms[group].push(obj);
} else {
const rect = [p.x, p.y, p.w, p.h];
if(p.layer) rect.push(p.layer);
platforms[group].push(rect);
}
}
// enemies back to {type, pos:[x,y]} (+ layer when set)
@@ -3052,6 +3310,7 @@ Cancel = Choose whether to remove without saving`);
};
if(p.object_id) out.object_id = p.object_id;
if(p.layer) out.layer = p.layer;
if(p.quest_id) out.quest_id = p.quest_id;
return out;
});
+213 -19
View File
@@ -21,6 +21,10 @@ SHOCKWAVE_DAMAGE = 2
BOSS_POISON_DURATION = 4.0 # boss attacks poison the player for this many seconds
ATTACK_COOLDOWN_BASE = 0.35 # recovery between melee swings; attack speed reduces it
# Horizontal deadzone: enemies only turn toward the player when the player is this far
# to one side, so a player standing directly above/below no longer makes them wiggle.
CHASE_DEADZONE = 6
# --- Ranged ability / projectile settings ---
RANGED_CHARGE_COST = 5.0 # costs more than melee (melee costs 1)
RANGED_DAMAGE_MULT = 0.5 # 50% of melee damage
@@ -915,13 +919,32 @@ def normalize_room_json(room_json: dict) -> dict:
if isinstance(platforms, list):
grouped = {"ground": [], "floating": []}
for p in platforms:
if isinstance(p, (list, tuple)) and len(p) == 4:
grouped["ground"].append([p[0], p[1], p[2], p[3]])
if isinstance(p, (list, tuple)) and len(p) >= 4:
arr = [p[0], p[1], p[2], p[3]]
if len(p) >= 5:
arr.append(p[4])
grouped["ground"].append(arr)
elif isinstance(p, dict):
rect = [p.get("x", 0), p.get("y", 0), p.get("w", 0), p.get("h", 0)]
group = p.get("group", "ground")
grouped.setdefault(group, [])
grouped[group].append(rect)
if group in ("moving", "trigger", "hazard"):
obj = {"x": p.get("x", 0), "y": p.get("y", 0), "w": p.get("w", 0), "h": p.get("h", 0)}
if "layer" in p and p.get("layer"):
obj["layer"] = p["layer"]
if group == "moving":
if isinstance(p.get("path"), list):
obj["path"] = p["path"]
if "speed" in p:
obj["speed"] = p["speed"]
if "name" in p:
obj["name"] = p["name"]
if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2:
obj["respawn"] = p["respawn"]
grouped.setdefault(group, []).append(obj)
else:
rect = [p.get("x", 0), p.get("y", 0), p.get("w", 0), p.get("h", 0)]
if p.get("layer"):
rect.append(p["layer"])
grouped.setdefault(group, []).append(rect)
out["platforms"] = grouped
elif not isinstance(platforms, dict):
out["platforms"] = {"ground": [], "floating": []}
@@ -954,6 +977,20 @@ def normalize_room_json(room_json: dict) -> dict:
norm_npcs.append(n)
out["npcs"] = norm_npcs
# props -> ensure pos:[x,y]
props = out.get("props", [])
if isinstance(props, list):
norm_props = []
for p in props:
if not isinstance(p, dict):
continue
if not (isinstance(p.get("pos"), (list, tuple)) and len(p["pos"]) == 2):
if "x" in p and "y" in p:
p = dict(p)
p["pos"] = [p.get("x", 0), p.get("y", 0)]
norm_props.append(p)
out["props"] = norm_props
# exits -> ensure rect:[x,y,w,h]
exits = out.get("exits", [])
if isinstance(exits, list):
@@ -998,18 +1035,78 @@ class Room:
self.kill_y = int((room_json.get("bounds", {}) or {}).get("kill_y", 1400))
self.platform_layer: dict[int, int] = {}
self.platform_meta: dict[int, dict] = {} # id(rect) -> {"name"|"path"|"speed", ...}
platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {}
if isinstance(platforms, list):
self.platforms_ground = rects_from_list(platforms, self.platform_layer)
self.platforms_floating = []
self.platforms_hazard = []
self.platforms_trigger = []
self.platforms_moving = []
elif isinstance(platforms, dict):
self.platforms_ground = rects_from_list(platforms.get("ground", []), self.platform_layer)
self.platforms_floating = rects_from_list(platforms.get("floating", []), self.platform_layer)
self.platforms_hazard = []
for hp in platforms.get("hazard", []):
if isinstance(hp, dict):
rect = pygame.Rect(int(hp.get("x", 0)), int(hp.get("y", 0)), int(hp.get("w", 0)), int(hp.get("h", 0)))
self.platform_layer[id(rect)] = int(hp.get("layer", 0))
respawn = hp.get("respawn")
if isinstance(respawn, (list, tuple)) and len(respawn) >= 2:
self.platform_meta[id(rect)] = {"respawn": [int(respawn[0]), int(respawn[1])]}
self.platforms_hazard.append(rect)
elif isinstance(hp, (list, tuple)) and len(hp) >= 4:
rect = pygame.Rect(int(hp[0]), int(hp[1]), int(hp[2]), int(hp[3]))
if len(hp) >= 5:
try:
self.platform_layer[id(rect)] = int(hp[4])
except (TypeError, ValueError):
pass
self.platforms_hazard.append(rect)
self.platforms_trigger = []
for tp in platforms.get("trigger", []):
if isinstance(tp, dict):
rect = pygame.Rect(int(tp.get("x", 0)), int(tp.get("y", 0)), int(tp.get("w", 0)), int(tp.get("h", 0)))
self.platform_layer[id(rect)] = int(tp.get("layer", 0))
self.platform_meta[id(rect)] = {"name": str(tp.get("name", ""))}
self.platforms_trigger.append(rect)
elif isinstance(tp, (list, tuple)) and len(tp) >= 4:
rect = pygame.Rect(int(tp[0]), int(tp[1]), int(tp[2]), int(tp[3]))
if len(tp) >= 5:
try:
self.platform_layer[id(rect)] = int(tp[4])
except (TypeError, ValueError):
pass
self.platforms_trigger.append(rect)
self.platforms_moving = []
for mp in platforms.get("moving", []):
if isinstance(mp, dict):
rect = pygame.Rect(int(mp.get("x", 0)), int(mp.get("y", 0)), int(mp.get("w", 0)), int(mp.get("h", 0)))
self.platform_layer[id(rect)] = int(mp.get("layer", 0))
path = mp.get("path") or []
if isinstance(path, list) and all(isinstance(w, (list, tuple)) and len(w) >= 2 for w in path):
path = [[int(w[0]), int(w[1])] for w in path]
else:
path = [[rect.x, rect.y]]
speed = float(mp.get("speed", 120))
self.platform_meta[id(rect)] = {"path": path, "speed": speed, "i": 0, "t": 0.0}
self.platforms_moving.append(rect)
elif isinstance(mp, (list, tuple)) and len(mp) >= 4:
rect = pygame.Rect(int(mp[0]), int(mp[1]), int(mp[2]), int(mp[3]))
if len(mp) >= 5:
try:
self.platform_layer[id(rect)] = int(mp[4])
except (TypeError, ValueError):
pass
self.platforms_moving.append(rect)
else:
self.platforms_ground = []
self.platforms_floating = []
self.platforms_hazard = []
self.platforms_trigger = []
self.platforms_moving = []
self.ground_platforms = self.platforms_ground[:]
self.platforms = self.platforms_ground + self.platforms_floating
self.platforms = self.platforms_ground + self.platforms_floating + self.platforms_moving
self.enemies = room_json.get("enemies", []) if isinstance(room_json, dict) else []
if not isinstance(self.enemies, list):
@@ -4222,7 +4319,10 @@ class Enemy:
self.direction = -1 if dx > 0 else 1
if chasing:
self.direction = 1 if dx > 0 else -1
if dx < -CHASE_DEADZONE:
self.direction = -1
elif dx > CHASE_DEADZONE:
self.direction = 1
self.last_chase_dir = self.direction # remember chase intent
# aggro just broke (shade OR distance)
@@ -4431,7 +4531,11 @@ class Enemy:
# --- PATROL / CHASE INTENT ---
if chasing:
self.direction = 1 if player.rect.centerx > self.rect.centerx else -1
cdx = player.rect.centerx - self.rect.centerx
if cdx < -CHASE_DEADZONE:
self.direction = -1
elif cdx > CHASE_DEADZONE:
self.direction = 1
elif self.stepoff_timer > 0.0:
# aggro just broke near an edge: keep walking off naturally
self.direction = self.stepoff_dir
@@ -4818,6 +4922,45 @@ class HouseObject:
draw_house_shape(surface, cx, cy, self.x, self.y)
def update_moving_platforms(dt: float):
"""Advance moving platforms along their waypoint paths (top-left follows the path)."""
for rect in platforms_moving:
mdata = platform_meta.get(id(rect))
if not mdata:
continue
path = mdata.get("path") or []
if len(path) < 2:
continue
n = len(path)
speed = float(mdata.get("speed", 120))
i = mdata.get("i", 0)
t = mdata.get("t", 0.0)
remaining = speed * dt
while remaining > 0:
x0, y0 = path[i]
x1, y1 = path[(i + 1) % n]
dx = x1 - x0
dy = y1 - y0
seg_len = math.hypot(dx, dy)
if seg_len <= 0:
i = (i + 1) % n
continue
to_end = (1.0 - t) * seg_len
if remaining < to_end:
t += remaining / seg_len
remaining = 0.0
else:
remaining -= to_end
t = 0.0
i = (i + 1) % n
mdata["i"] = i
mdata["t"] = t
x0, y0 = path[i]
x1, y1 = path[(i + 1) % n]
rect.x = int(round(x0 + (x1 - x0) * t))
rect.y = int(round(y0 + (y1 - y0) * t))
def draw_layerable(surface, cx, cy):
"""Draw all static room objects back-to-front by (layer, category).
@@ -4847,6 +4990,15 @@ def draw_layerable(surface, cx, cy):
# platforms
for p in platforms:
items.append((platform_layer.get(id(p), 0), 1, "platform", p))
# moving platforms (solid, follow a path) layer alongside platforms
for p in platforms_moving:
items.append((platform_layer.get(id(p), 0), 1, "moving", p))
# hazard zones (red) layer alongside platforms
for p in platforms_hazard:
items.append((platform_layer.get(id(p), 0), 1, "hazard", p))
# trigger placeholders (yellow, non-solid, no behavior yet)
for p in platforms_trigger:
items.append((platform_layer.get(id(p), 0), 1, "trigger", p))
# ability doors
for d in ability_doors:
items.append((getattr(d, "layer", 0), 2, "door", d))
@@ -4874,6 +5026,18 @@ def draw_layerable(surface, cx, cy):
PLATFORM_COLOR,
pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height),
)
elif kind == "moving":
mr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
pygame.draw.rect(surface, (80, 180, 220), mr)
pygame.draw.rect(surface, (180, 230, 255), mr, 2)
elif kind == "hazard":
hz = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
pygame.draw.rect(surface, (200, 40, 40), hz)
pygame.draw.rect(surface, (255, 120, 120), hz, 2)
elif kind == "trigger":
tr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
pygame.draw.rect(surface, (220, 190, 60), tr)
pygame.draw.rect(surface, (255, 230, 120), tr, 2)
elif kind == "exit":
rr = obj["rect"]
pygame.draw.rect(
@@ -5114,6 +5278,10 @@ ROOM = room_manager.current_room
KILL_Y = ROOM.kill_y
platforms_ground = ROOM.platforms_ground
platforms_floating = ROOM.platforms_floating
platforms_hazard = ROOM.platforms_hazard
platforms_trigger = ROOM.platforms_trigger
platforms_moving = ROOM.platforms_moving
platform_meta = ROOM.platform_meta
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
platform_layer = ROOM.platform_layer
@@ -5155,10 +5323,8 @@ class QuestGiver:
self.bob_t = random.random() * 10.0
def draw(self, surface, cx, cy):
bob_y = int(math.sin(self.bob_t) * 2)
self.bob_t += 0.05
x = int(self.rect.x - cx)
y = int(self.rect.y - cy + bob_y)
y = int(self.rect.y - cy)
pygame.draw.rect(surface, (255, 130, 180), (x, y, self.rect.width, self.rect.height))
pygame.draw.rect(surface, (160, 40, 90), (x, y, self.rect.width, self.rect.height), 2)
@@ -5342,7 +5508,8 @@ 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 ROOM, KILL_Y, platforms_ground, platforms_floating, platforms_hazard, ground_platforms, platforms
global platforms_trigger, platforms_moving, platform_meta
global platform_layer
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global shatter_platforms, shatter_aoes, tether_shots
@@ -5359,6 +5526,10 @@ def apply_room(room: Room, set_player_spawn=True):
KILL_Y = ROOM.kill_y
platforms_ground = ROOM.platforms_ground
platforms_floating = ROOM.platforms_floating
platforms_hazard = ROOM.platforms_hazard
platforms_trigger = ROOM.platforms_trigger
platforms_moving = ROOM.platforms_moving
platform_meta = ROOM.platform_meta
ground_platforms = ROOM.ground_platforms
platforms = ROOM.platforms
platform_layer = ROOM.platform_layer
@@ -5941,10 +6112,8 @@ class DiaryObject:
self.bob_t = random.random() * 10.0
def draw(self, surface, cx, cy):
bob_y = int(math.sin(self.bob_t) * 2)
self.bob_t += 0.05
x = int(self.rect.x - cx)
y = int(self.rect.y - cy + bob_y)
y = int(self.rect.y - cy)
pygame.draw.rect(surface, (150, 110, 70), (x, y, self.rect.width, self.rect.height))
pygame.draw.rect(surface, (90, 60, 30), (x, y, self.rect.width, self.rect.height), 2)
pygame.draw.line(surface, (90, 60, 30),
@@ -5980,13 +6149,19 @@ def get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -
def save_at_lantern(sp: SavePoint):
"""Set the lantern as the respawn point and save the game."""
"""Set the lantern as the respawn point, restore HP/Charge, and save the game."""
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
LANTERN_RESPAWN_ROOM = ROOM.id
LANTERN_RESPAWN_POS = (sp.x, sp.y)
# Resting at a lantern fully restores the player.
player._cancel_heal()
player.health = player.max_health
player.charge = player.max_charge
player.poison_timer = 0.0
player.poison_acc = 0.0
player._clamp_resources()
_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.
pickup_feed.push_center("Lantern - game saved, fully restored", duration=2.0)
if respawn_jars_if_elsewhere():
pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5)
@@ -7231,6 +7406,9 @@ while running:
near_save_point = True
break
# Move moving platforms before collision is computed so the player collides correctly.
update_moving_platforms(dt)
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]
@@ -7242,6 +7420,22 @@ while running:
player.update(keys, player_collision_platforms, dt)
# Hazard zones: touching one deals 1 damage and teleports the player to its linked respawn
# (defaults to the room's player spawn). A lethal hit falls through to the normal death respawn.
for hz in platforms_hazard:
if player.rect.colliderect(hz):
player.take_damage(1)
mdata = platform_meta.get(id(hz), {})
respawn = mdata.get("respawn")
if respawn:
spx, spy = respawn
else:
spx, spy = ROOM.player_spawn
player.rect.midbottom = (int(spx), int(spy))
player.vel_y = 0.0
player.on_ground = False
break
if player.is_shatterstep_active() and (not player.shade) and (not player.on_ground):
if (not player.shatterstep_platform_spawned) and player.shatterstep_platform_cd <= 0.0:
jump_v0 = abs(JUMP_LIGHT)
@@ -7301,7 +7495,7 @@ while running:
player.shatterstep_landing_pending = False
player.shatterstep_landing_damage = 0.0
else:
# Respawn at the latest lantern save point if one has been touched.
# Normal death respawn: latest lantern save point, else the room spawn.
if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS:
lx, ly = LANTERN_RESPAWN_POS
if LANTERN_RESPAWN_ROOM != ROOM.id:
+39
View File
@@ -0,0 +1,39 @@
{
"version": 1,
"saved_at": "2026-09-29T18:54:05",
"player": {
"room_id": "room_02.json",
"x": 774,
"y": 320,
"health": 10,
"charge": 100.0,
"shade": false,
"coins_light": 4,
"coins_dark": 0,
"butterfly_jars": 1,
"has_ranged": false,
"reward_chest_opened": false,
"equipment": {
"helmet": null,
"chest": null,
"pants": null,
"necklace": null
}
},
"inventory": [],
"quests": {
"active": {},
"completed_one_time": [],
"tokens": 0
},
"boss_diary": {
"slotted": {}
},
"defeated_bosses": [],
"broken_doors": [],
"lantern_respawn": {
"room_id": "room_02.json",
"x": 720,
"y": 320
}
}
+3 -3
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"saved_at": "2026-09-29T12:12:10",
"saved_at": "2026-09-29T12:14:42",
"player": {
"room_id": "room_town_gloomreach.json",
"x": 1315,
"x": 1250,
"y": 360,
"health": 10,
"health": 12,
"charge": 125.0,
"shade": false,
"coins_light": 0,