Compare commits

...
4 Commits
Author SHA1 Message Date
Lena d56540785f flyer boss, town
flyer boss adjustments, town addition
2026-10-05 15:04:20 +02:00
Lena 73cfd870f6 flying boss
boss addtion, small editor changes
2026-10-02 10:56:51 +02:00
Lena c7558a3947 attack changes
dissolve platforms, mouse for attack, directional attack (i think), hold for attack
2026-10-02 08:12:53 +02:00
Lena bf4e8de590 mist/attack
mist, charge empty lock, attack changes, floating platform changes
2026-10-01 10:49:35 +02:00
23 changed files with 2602 additions and 346 deletions
+7 -3
View File
@@ -3,13 +3,13 @@
## Project
"Light & Dark" — a 2D light/shade platformer prototype (Python 3.10 + pygame).
- Main game: `light_and_dark/main.py` (single large file, ~8000 lines, no tests)
- Main game: `light_and_dark/main.py` (single large file, ~9500 lines, no tests)
- Level editor: `light_and_dark/level_editor_v43.html` (standalone HTML/JS, no build step)
- Levels: `light_and_dark/data/world/rooms/room_0N.json`
- Data/config: `light_and_dark/data/config/*.json`, `data/items/*.json`, `data/objects/*.json`, `data/enemies/*.json`
## Standard session start
1. `git status` + `git log --oneline -5` to see uncommitted/untracked work (rooms 4-6 are untracked/WIP).
1. `git status` + `git log --oneline -5` to see uncommitted/untracked work (currently `room_08`/`room_09`/`room_boss_fly_refight` and the `Boss_fly_token.png` sprite are untracked/WIP).
2. Read the specific file(s) the request touches before editing; follow existing style.
3. After edits, always verify (see below).
@@ -25,7 +25,11 @@
## Key systems (in main.py unless noted)
- **Saves**: lantern-only. `save_game` / `apply_save_data`. Quest progress, broken doors, defeated bosses persist ONLY on lantern interaction.
- **Trigger walls** (`trigger_wall_blocking`): meta keys `quest`, `quest_complete`, `quest_turned_in`, `lock_when_pass` (appears once walked through), `open_on_miniboss_dead`.
- **Trigger walls** (`trigger_wall_blocking`): meta keys `quest`, `quest_complete`, `quest_turned_in`, `lock_when_pass` (appears once walked through), `open_on_miniboss_dead`, `open_on_boss_dead` (released once the room's boss is slain; also hides/inerts moving platforms while the boss lives).
- **Exits**: `"requires_boss_dead": true` hides the exit and disables it while the room's boss is alive (used for boss doors and refight rooms). The editor round-trips this flag (checkbox in the Exit properties).
- **Bosses** (`BOSS_DEFS_DEFAULT`): `boss_id -> {name, room, refight_room, token_name, token_sprite}`. `boss_01` = Gloom Warden (legacy `room_boss_01`, not in the current build); `boss_02` = **Sky Terror** (flying, `room_08`, refight `room_boss_fly_refight`). A boss drops its token in its world room (not during a refight); tokens feed the Boss Diary (`boss_diary`) and the Combat Master upgrades them for the Hard fight. Token art is per-boss via `boss_token_sprite(boss_id)`.
- **Boss aggro**: once the player first enters a boss's `detection_range` in Light form, `Enemy.boss_engaged` latches and the boss chases forever (ignores range and Shade).
- **Flying boss** (`behavior: "flyer"`, config `enemy_types.json` → `boss_flyer.flyer`): hovers/bobs, slam-dives (shockwave `poison=False`), summons expiring minions, AOE rings, homing shots. Casts `_flyer_max_casts()` skills per slam-cycle — normal 1 (2 at `phase2_hp_pct`), hard 2 (3 at `hard_phase3_hp_pct`); `self.flyer_hard` is set by `start_refight(..., "hard")`.
- **Ability doors** (`AbilityDoor`): `kind` = `"ranged"` (shot open, persists in `broken_doors`) or `"shade"` (solid in light, passable in shade + a `shade_door_grace` window after form switch; ejected to nearest edge after grace).
- **Quests** (`QUEST_DEFS_DEFAULT`): objective `"shade": True` counts kills only in shade form. Rewards via `guaranteed_item` (one-time) or `pool` (repeatable). Items by `item_id` (e.g. `necklace_placeholder` = "Plain Necklace").
- **Minibosses**: stay defeated per room (`DEFEATED_MINIBOSSES`) until a lantern save in a different room, then respawn.
+24 -12
View File
@@ -37,6 +37,10 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop
| Stats overlay | H |
| Pause / Settings | Esc |
**Mouse**: **Left button** = melee attack, **Right button** = ranged shot.
**Hold-to-recast**: Melee (E / left mouse) and Ranged (Q / right mouse) can be **held** — the attack auto-recasts as soon as its cooldown is ready, and never faster than the cooldown.
---
## The two forms
@@ -77,7 +81,7 @@ When you die you lose your **butterfly jars for good** and drop your coins. A **
|---|---|---|---|
| **Melee** | E | 1 damage, costs 1 Charge | **2 damage**, costs 1 Charge |
| **Dash** | Tab | Costs 10 Charge; **stuns** enemies hit | Costs 1 HP; deals **4 damage** to enemies hit |
| **Ranged** | Q | Costs 5 Charge, 0.45s cooldown, 0.5 damage; **breaks green ability walls** | Same, but 1.0 damage |
| **Ranged** | Q | Costs 5 Charge, 0.63s cooldown, 0.5 damage; **breaks green ability walls** | Same, but 1.0 damage |
| **Shatterstep** | R | Spawns a **platform under your feet** while airborne (extra jumps) | On landing, creates a damaging **shockwave AOE** below you |
| **Tether** | X | **Binds** the enemy (3s) and **fears** it (runs away) | **Insta-kills** enemies below a HP threshold (normal ⅓, miniboss 20%, boss 5%); otherwise **slows** them |
| **Reality Break** | C | Doubles drop & coin rates and glows enemies | **Instantly kills all enemies** but halves drop & coin rates |
@@ -88,7 +92,8 @@ When you die you lose your **butterfly jars for good** and drop your coins. A **
## Combat notes
- Enemy contact damage only applies in **Light** form.
- Boss **shockwave attacks poison** you for 4 seconds: the health bar turns green and you lose 1 HP per second.
- The **Gloom Warden's** ground shockwaves **poison** you for 4 seconds (the health bar turns green, −1 HP/s). **Sky Terror's** slam does **not** poison.
- Once a boss first spots you in Light form it tracks you permanently — even in Shade form and from any distance.
- Attacks and hits use invincibility frames and knockback; your melee hitbox covers ~30% into your body so close/overlapping hits always connect.
---
@@ -118,7 +123,9 @@ The shade-focused sets (**Duskwarrior, Umbraflow, Abyss Cleaver, Adaptive Travel
---
## NPCs (Town 1: Gloomreach)
## NPCs
> The town room isn't in the current build (removed in the room overhaul); these NPC types still exist and can be placed with the editor.
- **Vendor** — sells basic armor pieces.
- **Necklace Vendor** — sells necklaces.
@@ -130,22 +137,27 @@ The shade-focused sets (**Duskwarrior, Umbraflow, Abyss Cleaver, Adaptive Travel
## Rooms & Progression
> The room set was overhauled; the old reward/town rooms are gone. Notable rooms:
- `room_01` — tutorial. Contains a **quest object** (pink): accept the **"First Kills"** quest (kill 4 normal enemies → 5–15 light coins, repeatable).
- `room_02` — first enemies.
- `room_07` — hub, branching to the boss, reward room, and town.
- `room_boss_01` — **Gloom Warden** fight. Defeating him drops his **boss token**.
- `room_reward_01` — reward chest + the **Ranged** ability, locked behind green ability walls.
- `room_town_gloomreach` — Town 1 (NPCs above).
- `room_boss_refight_01` — diary refight room (same arena, no exits until the boss dies or you die).
- `room_04` — **miniboss** arena; the walls seal until the miniboss is slain.
- `room_05` — **charger boss** arena (seals until slain).
- `room_07` — hub, branching to the boss arena and back.
- `room_08` — **Sky Terror**, the flying boss. Entering locks you in: the exit back to `room_07` and the right-hand door to `room_09` only appear once he's dead.
- `room_09` — the area past Sky Terror's right door.
- `room_boss_fly_refight` — Sky Terror **diary refight** arena.
---
## Boss Diary & Tokens
1. Beat the world boss → collect his **token** (teal pickup).
2. Bring it to the diary in town and **slot** it (or keep it in your inventory).
3. Slotting unlocks **Fight (Normal)**. Upgrading the token at the Combat Master unlocks **Fight (Hard)** — the boss moves and shoots twice as fast.
4. Dying in a refight returns you to the diary. Removing a token from the diary sends it back to your inventory, disabling the fight until you re-slot it.
1. Beat a world boss → collect its **token** (a distinct pickup per boss).
2. **Slot** it in the Boss Diary (or keep it in your inventory).
3. Slotting unlocks **Fight (Normal)**. Upgrading the token at the **Combat Master** unlocks **Fight (Hard)**.
4. Dying in a refight ends it; removing a token from the diary sends it back to your inventory.
Bosses: **Gloom Warden** (`boss_01`, legacy) and **Sky Terror** (`boss_02`) — Sky Terror lives in `room_08` (51 HP) and refights in `room_boss_fly_refight`; on Hard he opens with **two** skills per attack cycle and escalates to **all three** below 20% HP.
---
+3 -1
View File
@@ -10,6 +10,7 @@ Proves the core loop is fun before scaling to the full vision.
- **Vendor** (smith/trainer) gives direct linear power upgrades. Upgrades one selected spell; changing selection costs coins.
- **Gems / rings** give alternate behaviors / sidegrades.
- **Ranged identity**: keep the bolt, add upgrades (no charge-up / homing rework).
- **Bosses**: after the player first enters a boss's detection range (Light form), it tracks them permanently — ignoring range and Shade. The flying boss (**Sky Terror**, `boss_02`) casts a limited number of skills per slam-cycle: normal 1 (2 at ≤33% HP), Hard 2 (all 3 at ≤20% HP). Bosses drop a per-boss token that feeds the Boss Diary.
- **Fairy (in slice)**: guide + ambient light source (bonus flow regen near player). Scout mode, teleport, fairy holes deferred to regions 3+.
- **Ascended armor**: cut for now. Awakened is good enough at the start; less balancing needed.
@@ -28,6 +29,7 @@ Proves the core loop is fun before scaling to the full vision.
|---|---|---|
| ~~Death penalty (drop coins, reclaim at death spot; fairy marks it; lose on 2nd death)~~ | M | **DONE (2026-09-29)**: corpse at death spot holds 100% of coins; a repeat death before reclaiming keeps only 50% and the old corpse vanishes. Butterfly jars are lost for good on death. |
| ~~Fairy companion (guide + ambient light source -> bonus flow regen)~~ | M | **DONE (2026-09-29)**: fairy follows behind the player's head; flips with facing. |
| ~~Flying boss + boss tokens~~ | M | **DONE (2026-10-05)**: **Sky Terror** (`boss_02`) in `room_08` — 51 HP, poison-free slam, skill-cycle phases (2 at ≤33%). Drops a per-boss token feeding the Boss Diary; Hard refight (`room_boss_fly_refight`) starts at 2 skills/cycle and hits all 3 at ≤20% HP. |
| More quests (repeatable + one-time; system exists) | S | Just content. |
| Lore dialogue for fairy / NPCs | S | Needs written copy. |
@@ -43,7 +45,7 @@ Proves the core loop is fun before scaling to the full vision.
|---|---|---|
| Shade-gated interactions (doors, an NPC, an area) | S-M | Room JSON + existing toggle. |
| New enemy behaviors (patrol, dodge-and-leap, burrow-shooter) | M | Enemy system supports it. |
| Enemy line-of-sight detection (radius -> raycast) | S-M | Replace/augment euclidean aggro (`main.py:4329`) with a `Rect.clipline` raycast against solid platforms so walls block vision. Per-type `"detection": radius/los` config in `enemy_types.json` + editor toggle. Edge cases: skip the enemy's own support platform, sample player center+feet for tolerance, apply LOS to boss slam `trigger_close/far`. |
| Enemy line-of-sight detection (radius -> raycast) | S-M | Replace/augment the euclidean aggro in `Enemy.update` with a `Rect.clipline` raycast against solid platforms so walls block vision. Per-type `"detection": radius/los` config in `enemy_types.json` + editor toggle. Edge cases: skip the enemy's own support platform, sample player center+feet for tolerance, apply LOS to boss slam `trigger_close/far`. |
| ~~Quick recharge pickup~~ | S | **DONE (2026-09-29)**: butterfly jars — collect up to 3, press G to instantly refill Charge. Lost on death; respawn at their points after saving at a lantern in a different room or reloading. |
| Ability tweaks: ranged upgrades, ground-slam knockback/iframe, light-step buffs | M | Picks up remaining notes ideas. |
| Region-2 boss + ability pacing | M | Re-align pacing (Ranged already exists). |
+3 -1
View File
@@ -22,7 +22,9 @@
"miniboss": [170, 40, 210],
"miniboss_hit": [220, 140, 255],
"boss": [240, 220, 80],
"boss_hit": [255, 245, 160]
"boss_hit": [255, 245, 160],
"boss_flyer": [150, 90, 220],
"boss_flyer_hit": [210, 170, 255]
},
"healing": {
+1 -1
View File
@@ -6,7 +6,7 @@
"fps": 60
},
"colors": {
"bg": [30, 30, 30],
"bg": [58, 58, 64],
"player_light": [200, 200, 200],
"player_dark": [60, 60, 60],
"platform": [80, 80, 80],
+2
View File
@@ -6,6 +6,7 @@
"jump_shade": -9.0,
"gravity_light": 0.5,
"gravity_shade": 0.4,
"jump_release_gravity_mult": 3.0,
"shade_door_grace": 0.6
},
@@ -13,6 +14,7 @@
"base_max_charge": 100.0,
"charge_drain_rate": 40.0,
"charge_refill_rate": 10.0,
"charge_empty_lock": 1.5,
"base_max_health": 10,
"invincibility_duration": 1.0
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"charge_cost": 5.0,
"damage_mult": 0.5,
"cooldown": 0.45,
"cooldown": 0.63,
"projectile": {
"speed": 650.0,
"lifetime": 1.2,
+64 -2
View File
@@ -25,7 +25,69 @@
"air_speed": 1.4,
"detection_range": 500,
"contact_damage": 3,
"drop_profile": "boss"
}
"drop_profile": "boss",
"behavior": "shockwave",
"charger": {
"retreat_dist": 150,
"retreat_jump_vel": -14.0,
"retreat_speed_mult": 1.2,
"charge_speed_mult": 4.0,
"charge_duration": 1.1,
"dash_min_dist": 160,
"dash_max_dist": 420,
"dash_speed_mult": 4.2,
"dash_duration": 0.7,
"attack_cooldown": 1.8,
"above_y": 40
}
},
"boss_flyer": {
"rect": [120, 90],
"health": 51,
"speed": 3.0,
"air_speed": 3.0,
"detection_range": 620,
"contact_damage": 3,
"drop_profile": "boss",
"behavior": "flyer",
"flyer": {
"flight_height": 280,
"bob_amplitude": 20,
"bob_speed": 2.2,
"hover_speed": 2.6,
"startup_delay": 3.0,
"phase2_hp_pct": 0.33,
"hard_phase3_hp_pct": 0.20,
"slam_cd": 9.0,
"slam_dive_vel": 16.0,
"ground_time": 2.0,
"summon_cd": 8.0,
"summon_max": 3,
"summon_type": "boss_minion",
"summon_life": 6.0,
"summon_heal": 4,
"summon_damage": 3,
"aoe_cd": 9.0,
"aoe_radius": 300,
"aoe_damage": 1,
"aoe_duration": 0.9,
"shot_cd": 5.0,
"shot_damage": 5,
"shot_speed": 300,
"shot_lifetime": 2.2,
"shot_homing": 1.8
}
},
"boss_minion": {
"rect": [34, 50],
"health": 4,
"speed": 2.4,
"air_speed": 2.2,
"detection_range": 320,
"contact_damage": 1,
"drop_profile": "normal"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

+8 -12
View File
@@ -33,14 +33,6 @@
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 0,
"w": 1580,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 540,
"y": 140,
@@ -159,13 +151,15 @@
"x": 1480,
"y": 360,
"type": "enemy",
"layer": 0
"layer": 0,
"behavior": ""
},
{
"x": 340,
"y": 580,
"type": "enemy",
"layer": 0
"layer": 0,
"behavior": ""
}
],
"npcs": [],
@@ -193,10 +187,12 @@
240
],
"hint": "",
"layer": 0
"layer": 0,
"requires_boss_dead": false
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900
"room_h": 900,
"mist": false
}
+15 -32
View File
@@ -49,22 +49,6 @@
"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,
@@ -81,19 +65,11 @@
"group": "ground",
"layer": 0
},
{
"x": 1260,
"y": 180,
"w": 260,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1500,
"y": 200,
"y": 100,
"w": 20,
"h": 440,
"h": 540,
"group": "ground",
"layer": 0
},
@@ -203,13 +179,15 @@
"x": 1160,
"y": 320,
"type": "enemy",
"layer": 0
"layer": 0,
"behavior": ""
},
{
"x": 1160,
"y": 580,
"type": "enemy",
"layer": 0
"layer": 0,
"behavior": ""
}
],
"npcs": [],
@@ -219,7 +197,9 @@
"y": 320,
"type": "save_point",
"radius": 90,
"object_id": "lamp"
"object_id": "lamp",
"layer": 0,
"quest_id": ""
}
],
"exits": [
@@ -235,7 +215,8 @@
680
],
"hint": "",
"layer": 0
"layer": 0,
"requires_boss_dead": false
},
{
"x": 20,
@@ -249,10 +230,12 @@
220
],
"hint": "",
"layer": 0
"layer": 0,
"requires_boss_dead": false
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900
"room_h": 900,
"mist": false
}
+54 -9
View File
@@ -132,7 +132,7 @@
{
"x": 240,
"y": 280,
"w": 180,
"w": 160,
"h": 20,
"group": "moving",
"layer": 0,
@@ -213,16 +213,56 @@
"y": 620,
"w": 60,
"h": 20,
"group": "ground",
"layer": 0
"group": "moving",
"layer": 0,
"path": [
[
720,
620
],
[
720,
540
]
]
},
{
"x": 820,
"y": 540,
"w": 60,
"h": 20,
"group": "ground",
"layer": 0
"group": "dissolve",
"layer": 0,
"path": [
[
820,
540
],
[
820,
600
]
],
"stand_time": 2,
"dissolve_time": 2
},
{
"x": 1040,
"y": 580,
"w": 100,
"h": 20,
"group": "moving",
"layer": 0,
"path": [
[
1040,
580
],
[
1040,
480
]
]
}
],
"enemies": [
@@ -230,13 +270,15 @@
"x": 760,
"y": 200,
"type": "enemy",
"layer": 0
"layer": 0,
"behavior": ""
},
{
"x": 880,
"y": 640,
"type": "miniboss",
"layer": 0
"layer": 0,
"behavior": ""
}
],
"npcs": [],
@@ -246,7 +288,9 @@
"y": 240,
"type": "save_point",
"radius": 90,
"object_id": "lamp"
"object_id": "lamp",
"layer": 0,
"quest_id": ""
}
],
"exits": [
@@ -304,5 +348,6 @@
}
],
"room_w": 1600,
"room_h": 900
"room_h": 900,
"mist": false
}
+72 -49
View File
@@ -16,15 +16,15 @@
120
],
"room5_bottom_right": [
1400,
440
1420,
780
]
},
"platforms": [
{
"x": 0,
"x": 20,
"y": 180,
"w": 240,
"w": 220,
"h": 20,
"group": "ground",
"layer": 0
@@ -33,15 +33,7 @@
"x": 0,
"y": 0,
"w": 20,
"h": 180,
"group": "ground",
"layer": 0
},
{
"x": 20,
"y": 0,
"w": 1520,
"h": 20,
"h": 840,
"group": "ground",
"layer": 0
},
@@ -62,60 +54,88 @@
"layer": 0
},
{
"x": 860,
"y": 500,
"w": 680,
"x": 0,
"y": 840,
"w": 1520,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1520,
"y": 380,
"y": 680,
"w": 20,
"h": 120,
"h": 180,
"group": "ground",
"layer": 0
},
{
"x": 1320,
"y": 360,
"w": 220,
"x": 1380,
"y": 700,
"w": 20,
"h": 140,
"group": "trigger",
"layer": 0,
"name": "open_when_boss_slain",
"open_on_boss_dead": true
},
{
"x": 1380,
"y": 680,
"w": 140,
"h": 20,
"group": "trigger",
"layer": 0,
"name": "open_when_boss_slain",
"open_on_boss_dead": true
},
{
"x": 1380,
"y": 0,
"w": 20,
"h": 680,
"group": "ground",
"layer": 0
},
{
"x": 1520,
"y": 20,
"w": 20,
"h": 340,
"group": "ground",
"layer": 0
"x": 1040,
"y": 740,
"w": 160,
"h": 20,
"group": "moving",
"layer": 0,
"path": [
[
1040,
740
],
[
1040,
200
]
],
"open_on_boss_dead": true
}
],
"enemies": [
{
"x": 520,
"y": 140,
"type": "enemy",
"layer": 0
},
{
"x": 1000,
"y": 460,
"type": "enemy",
"layer": 0
"x": 620,
"y": 720,
"type": "boss",
"layer": 0,
"behavior": "shockwave"
}
],
"npcs": [],
"props": [
{
"x": 1400,
"y": 360,
"x": 860,
"y": 180,
"type": "save_point",
"radius": 90,
"object_id": "lamp"
"object_id": "lamp",
"layer": 0,
"quest_id": ""
}
],
"exits": [
@@ -124,31 +144,34 @@
"y": 80,
"w": 80,
"h": 100,
"to": "room_06",
"to": "room_04",
"spawn_id": "room4_bottom_right",
"spawn": [
1480,
640
],
"hint": "",
"layer": 0
"layer": 0,
"requires_boss_dead": false
},
{
"x": 1440,
"y": 400,
"y": 740,
"w": 80,
"h": 100,
"to": "",
"spawn_id": "",
"to": "room_07",
"spawn_id": "room7_top_left",
"spawn": [
1440,
400
180,
460
],
"hint": "",
"layer": 0
"layer": 0,
"requires_boss_dead": false
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900
"room_h": 900,
"mist": false
}
+18 -8
View File
@@ -22,42 +22,48 @@
"y": 300,
"w": 660,
"h": 20,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 420,
"y": 180,
"w": 240,
"h": 20,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 640,
"y": 200,
"w": 20,
"h": 100,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 420,
"y": 0,
"w": 20,
"h": 180,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 0,
"y": 0,
"w": 420,
"h": 20,
"group": "ground"
"group": "ground",
"layer": 0
},
{
"x": 0,
"y": 20,
"w": 20,
"h": 280,
"group": "ground"
"group": "ground",
"layer": 0
}
],
"enemies": [],
@@ -69,6 +75,7 @@
"type": "quest_giver",
"radius": 180,
"object_id": "quest_point",
"layer": 0,
"quest_id": "first_kills_shade"
}
],
@@ -83,10 +90,13 @@
"spawn": [
120,
620
]
],
"hint": "",
"layer": 0
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900
"room_h": 900,
"mist": true
}
@@ -0,0 +1,118 @@
{
"name": "room_07",
"display_name": "room_07",
"meta": {
"id": "room_07",
"name": "room_07"
},
"bounds": {},
"player_spawn": [
220,
440
],
"entry_spawns": {
"room7_top_left": [
180,
460
],
"room7_bottom_right": [
1040,
460
]
},
"platforms": [
{
"x": 60,
"y": 200,
"w": 20,
"h": 340,
"group": "ground",
"layer": 0
},
{
"x": 80,
"y": 520,
"w": 1080,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 1160,
"y": 320,
"w": 20,
"h": 220,
"group": "ground",
"layer": 0
}
],
"enemies": [],
"npcs": [],
"props": [
{
"x": 440,
"y": 494,
"type": "reward_chest",
"radius": 180,
"object_id": null,
"layer": 0,
"quest_id": "",
"coins": [
30,
10
]
},
{
"x": 820,
"y": 500,
"type": "ability_pickup",
"radius": 180,
"object_id": null,
"layer": 0,
"quest_id": ""
},
{
"x": 640,
"y": 520,
"type": "save_point",
"radius": 90,
"object_id": "lamp",
"layer": 0,
"quest_id": ""
}
],
"exits": [
{
"x": 80,
"y": 420,
"w": 80,
"h": 100,
"to": "room_05",
"spawn_id": "room5_bottom_right",
"spawn": [
1420,
780
],
"hint": "",
"layer": 0
},
{
"x": 1080,
"y": 420,
"w": 80,
"h": 100,
"to": "room_08",
"spawn_id": "room8_bottom_left",
"spawn": [
300,
400
],
"hint": "",
"layer": 0
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900,
"mist": false
}
@@ -0,0 +1,96 @@
{
"name": "room_08",
"display_name": "room_08",
"meta": {
"id": "room_08",
"name": "room_08"
},
"bounds": {},
"player_spawn": [
340,
300
],
"entry_spawns": {
"room8_bottom_left": [
300,
400
],
"room8_bottom_right": [
1400,
400
]
},
"platforms": [
{
"x": 200,
"y": 460,
"w": 1340,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 180,
"y": 120,
"w": 20,
"h": 360,
"group": "ground",
"layer": 0
},
{
"x": 1520,
"y": 100,
"w": 20,
"h": 360,
"group": "ground",
"layer": 0
}
],
"enemies": [
{
"x": 820,
"y": 220,
"type": "boss_flyer",
"layer": 0,
"behavior": "flyer"
}
],
"npcs": [],
"props": [],
"exits": [
{
"x": 200,
"y": 360,
"w": 80,
"h": 100,
"to": "room_07",
"spawn_id": "room7_bottom_right",
"spawn": [
1040,
460
],
"hint": "",
"layer": 0,
"requires_boss_dead": true
},
{
"x": 1440,
"y": 360,
"w": 80,
"h": 100,
"to": "room_09",
"spawn_id": "room9_top_left",
"spawn": [
220,
300
],
"hint": "",
"layer": 0,
"requires_boss_dead": true
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900,
"mist": false
}
@@ -0,0 +1,169 @@
{
"name": "room_09",
"display_name": "room_09",
"meta": {
"id": "room_09",
"name": "room_09"
},
"bounds": {},
"player_spawn": [
220,
260
],
"entry_spawns": {
"room9_top_left": [
220,
300
]
},
"platforms": [
{
"x": 100,
"y": 360,
"w": 300,
"h": 20,
"group": "ground",
"layer": 0
},
{
"x": 80,
"y": 220,
"w": 20,
"h": 440,
"group": "ground",
"layer": 0
},
{
"x": 460,
"y": 440,
"w": 140,
"h": 20,
"group": "ground"
},
{
"x": 620,
"y": 500,
"w": 120,
"h": 20,
"group": "ground"
},
{
"x": 800,
"y": 560,
"w": 80,
"h": 20,
"group": "ground"
},
{
"x": 80,
"y": 660,
"w": 2699,
"h": 20,
"group": "ground"
},
{
"x": 1580,
"y": 540,
"w": 240,
"h": 20,
"group": "ground"
},
{
"x": 1980,
"y": 540,
"w": 100,
"h": 20,
"group": "ground"
},
{
"x": 2260,
"y": 660,
"w": 140,
"h": 20,
"group": "moving",
"path": [
[
2260,
660
],
[
2260,
340
]
]
},
{
"x": 2460,
"y": 300,
"w": 320,
"h": 20,
"group": "ground"
}
],
"enemies": [],
"npcs": [
{
"x": 1296,
"y": 606,
"type": "necklace_vendor",
"layer": 1
},
{
"x": 2012,
"y": 486,
"type": "combat_master"
}
],
"props": [
{
"x": 800,
"y": 460,
"type": "player",
"radius": 180,
"object_id": "player"
},
{
"x": 1240,
"y": 660,
"type": "house",
"radius": 180,
"object_id": "house"
},
{
"x": 1660,
"y": 540,
"type": "house",
"radius": 180,
"object_id": "house"
},
{
"x": 1754,
"y": 490,
"type": "diary",
"radius": 180,
"object_id": "diary",
"layer": 1
}
],
"exits": [
{
"x": 100,
"y": 260,
"w": 80,
"h": 100,
"to": "room_08",
"spawn_id": "room8_bottom_right",
"spawn": [
1400,
400
],
"hint": "",
"layer": 0,
"requires_boss_dead": false
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900,
"mist": false
}
@@ -0,0 +1,66 @@
{
"name": "boss_fly_refight",
"display_name": "boss_fly_refight",
"meta": {
"id": "boss_fly_refight",
"name": "boss_fly_refight"
},
"bounds": {},
"player_spawn": [
280,
400
],
"entry_spawns": {},
"platforms": [
{
"x": 200,
"y": 460,
"w": 1340,
"h": 20,
"group": "ground"
},
{
"x": 180,
"y": 120,
"w": 20,
"h": 360,
"group": "ground"
},
{
"x": 1540,
"y": 120,
"w": 20,
"h": 360,
"group": "ground"
}
],
"enemies": [
{
"x": 820,
"y": 220,
"type": "boss_flyer",
"layer": 0,
"behavior": "flyer"
}
],
"npcs": [],
"props": [],
"exits": [
{
"x": 1460,
"y": 360,
"w": 80,
"h": 100,
"to": "",
"spawn_id": "",
"spawn": [
1460,
360
]
}
],
"ability_doors": [],
"room_w": 1600,
"room_h": 900,
"mist": false
}
+145 -21
View File
@@ -88,9 +88,10 @@
<div class="row">
<button id="downloadActiveBtn" class="good">Download Active</button>
<button id="downloadCheckedBtn">Download Checked</button>
<button id="downloadAllBtn">Download Modified</button>
</div>
<div class="muted" style="margin-top:6px;">
Downloads are per-room. Checked download will save one file per checked room.
Downloads are per-room. Checked download saves one file per checked room; Download Modified saves every room you've changed.
</div>
</div>
@@ -113,6 +114,7 @@
<option value="hazard">Hazard (red - damage)</option>
<option value="moving">Moving (path)</option>
<option value="trigger">Trigger (name)</option>
<option value="dissolve">Dissolve (timed)</option>
</select>
</div>
<div class="muted" style="margin-top:8px;">
@@ -174,7 +176,7 @@
</div> <div class="row" style="margin-bottom:8px;">
<label class="muted" style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="showLinksChk"/>
Draw link lines (auto-enables Compare)
Draw link lines
</label>
</div>
@@ -278,7 +280,27 @@
const DEFAULT_ENEMY_TYPES = {
enemy: { rect: [40, 60], health: 3, speed: 2.2, air_speed: 2.0, detection_range: 240, contact_damage: 1, drop_profile: "normal" },
miniboss: { rect: [70, 95], health: 18, speed: 1.8, air_speed: 1.6, detection_range: 360, contact_damage: 2, drop_profile: "miniboss" },
boss: { rect: [110, 120], health: 10, speed: 1.6, air_speed: 1.4, detection_range: 500, contact_damage: 3, drop_profile: "boss" },
boss: {
rect: [110, 120], health: 10, speed: 1.6, air_speed: 1.4, detection_range: 500, contact_damage: 3, drop_profile: "boss", behavior: "shockwave",
charger: {
retreat_dist: 150, retreat_jump_vel: -14.0, retreat_speed_mult: 1.2, charge_speed_mult: 4.0,
charge_duration: 1.1, dash_min_dist: 160, dash_max_dist: 420, dash_speed_mult: 4.2,
dash_duration: 0.7, attack_cooldown: 1.8, above_y: 40
}
},
boss_flyer: {
rect: [120, 90], health: 51, speed: 3.0, air_speed: 3.0, detection_range: 620, contact_damage: 3, drop_profile: "boss", behavior: "flyer",
flyer: {
flight_height: 280, bob_amplitude: 20, bob_speed: 2.2, hover_speed: 2.6,
startup_delay: 3.0,
phase2_hp_pct: 0.33, hard_phase3_hp_pct: 0.20,
slam_cd: 9.0, slam_dive_vel: 16.0, ground_time: 2.0,
summon_cd: 8.0, summon_max: 3, summon_type: "boss_minion", summon_life: 6.0, summon_heal: 4, summon_damage: 3,
aoe_cd: 9.0, aoe_radius: 300, aoe_damage: 1, aoe_duration: 0.9,
shot_cd: 5.0, shot_damage: 5, shot_speed: 300, shot_lifetime: 2.2, shot_homing: 1.8
}
},
boss_minion: { rect: [34, 50], health: 4, speed: 2.4, air_speed: 2.2, detection_range: 320, contact_damage: 1, drop_profile: "normal" },
};
function seedDefaultObjectCatalog(){
@@ -353,7 +375,10 @@
if(rect.quest_turned_in) out.quest_turned_in = String(rect.quest_turned_in);
if(rect.lock_when_pass) out.lock_when_pass = true;
if(rect.open_on_miniboss_dead) out.open_on_miniboss_dead = true;
if(rect.open_on_boss_dead) out.open_on_boss_dead = true;
if(Array.isArray(rect.respawn)) out.respawn = deepClone(rect.respawn);
if(rect.stand_time !== undefined) out.stand_time = +rect.stand_time;
if(rect.dissolve_time !== undefined) out.dissolve_time = +rect.dissolve_time;
return out;
}
return null;
@@ -435,9 +460,9 @@
for(const e of src.enemies){
if(e && typeof e === "object"){
if(Array.isArray(e.pos)){
r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy", layer: +(e.layer||0) });
r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy", layer: +(e.layer||0), behavior: e.behavior || "" });
} else if(typeof e.x === "number" || typeof e.y === "number"){
r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy", layer: +(e.layer||0) });
r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy", layer: +(e.layer||0), behavior: e.behavior || "" });
}
}
}
@@ -487,7 +512,8 @@
spawn_id: ex.spawn_id || "",
spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null,
hint: ex.hint || "",
layer: +(ex.layer||0)
layer: +(ex.layer||0),
requires_boss_dead: !!ex.requires_boss_dead
});
} else {
// editor-native exit
@@ -497,7 +523,8 @@
spawn_id: ex.spawn_id || "",
spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null,
hint: ex.hint || "",
layer: +(ex.layer||0)
layer: +(ex.layer||0),
requires_boss_dead: !!ex.requires_boss_dead
});
}
}
@@ -524,7 +551,7 @@
// ----- keep other top-level fields (future-proof) -----
r._extras = {};
if(src && typeof src === "object"){
const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","npcs","exits","ability_doors","entry_spawns","props","name","display_name","room_w","room_h"]);
const keep = new Set(["meta","player_spawn","bounds","platforms","enemies","npcs","exits","ability_doors","entry_spawns","props","name","display_name","room_w","room_h","mist"]);
for(const k of Object.keys(src)){
if(!keep.has(k)) r._extras[k] = deepClone(src[k]);
}
@@ -533,6 +560,7 @@
// some editor fields for convenience
if(!r.room_w) r.room_w = src.room_w || 1600;
if(!r.room_h) r.room_h = src.room_h || 900;
r.mist = !!(src && src.mist);
return r;
}
@@ -573,6 +601,7 @@
newRoomBtn: document.getElementById("newRoomBtn"),
downloadActiveBtn: document.getElementById("downloadActiveBtn"),
downloadCheckedBtn: document.getElementById("downloadCheckedBtn"),
downloadAllBtn: document.getElementById("downloadAllBtn"),
activeRoomLabel: document.getElementById("activeRoomLabel"),
status: document.getElementById("status"),
@@ -1438,6 +1467,8 @@ Cancel = Choose whether to remove without saving`);
save_point: { ring: "rgba(56,189,248,0.30)", fill: "rgba(56,189,248,0.9)", label: "Lantern" },
butterfly_jar: { ring: "rgba(168,85,247,0.22)", fill: "rgba(168,85,247,0.9)", label: "Jar" },
diary: { ring: "rgba(150,110,70,0.25)", fill: "rgba(150,110,70,0.95)", label: "Diary" },
reward_chest: { ring: "rgba(180,120,60,0.30)", fill: "rgba(190,125,70,0.95)", label: "Chest" },
ability_pickup:{ ring: "rgba(200,230,255,0.30)", fill: "rgba(200,230,255,0.95)", label: "Ability" },
};
const ptype = p.type || "light_source";
const st = PROP_STYLES[ptype] || { ring: "rgba(148,163,184,0.25)", fill: "rgba(148,163,184,0.9)", label: ptype };
@@ -1543,6 +1574,7 @@ Cancel = Choose whether to remove without saving`);
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 if(p && (p.group === "dissolve")) layerable.push({ z: +(p.layer||0), cat: 1, kind: "dissolve", 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 });
@@ -1645,6 +1677,18 @@ Cancel = Choose whether to remove without saving`);
ctx.stroke();
ctx.setLineDash([]);
}
} else if(it.kind === "dissolve"){
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(96,165,250,0.15)";
ctx.fillRect(sx,sy, sx2-sx, sy2-sy);
ctx.strokeStyle = "rgba(150,210,255,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("Dissolve", sx+4*dpr, sy-4*dpr);
} else if(it.kind === "door"){
const d = it.d;
const [sx,sy] = worldToScreen(d.x, d.y, canvas);
@@ -2121,7 +2165,15 @@ Cancel = Choose whether to remove without saving`);
const roomWrap = sel ? state.rooms.get(sel.roomId) : getActiveRoom();
if(!roomWrap) return "<div class='muted'>No room loaded.</div>";
const room = roomWrap.data;
if(!sel) return "<div class='muted'>Select something to edit properties.</div>";
if(!sel){
return `
<div><b>Room Settings</b></div>
<div class="hr"></div>
<div style="margin-top:8px;">
<label><input id="roommist" type="checkbox" ${room.mist?"checked":""}/> Mist (purple haze - shade form costs no charge)</label>
</div>
`;
}
if(sel.kind==="platform"){
const p=room.platforms[sel.index];
@@ -2144,6 +2196,7 @@ Cancel = Choose whether to remove without saving`);
<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>
<option value="dissolve" ${p.group==="dissolve"?"selected":""}>Dissolve (timed)</option>
</select>
</div>
${(p.group||"ground") === "trigger" ? `
@@ -2168,6 +2221,9 @@ Cancel = Choose whether to remove without saving`);
</div>
<div style="margin-top:8px;">
<label><input id="ptminibossdead" type="checkbox" ${p.open_on_miniboss_dead?"checked":""}/> Present until the room's miniboss is slain</label>
</div>
<div style="margin-top:8px;">
<label><input id="ptbossdead" type="checkbox" ${p.open_on_boss_dead?"checked":""}/> Present until the room's boss is slain</label>
</div>` : ""}
${(p.group||"ground") === "hazard" ? `
<div style="margin-top:8px;">
@@ -2186,7 +2242,16 @@ Cancel = Choose whether to remove without saving`);
<div style="margin-top:8px;">
<div class="mini">speed (px/s)</div>
<input id="ptspeed" type="number" value="${p.speed||120}"/>
</div>
<div style="margin-top:8px;">
<label><input id="pmbossdead" type="checkbox" ${p.open_on_boss_dead?"checked":""}/> Hidden until the room's boss is slain</label>
</div>` : ""}
${(p.group||"ground") === "dissolve" ? `
<div class="grid2" style="margin-top:8px;">
<div><div class="mini">stand time (s)</div><input id="ptstand" type="number" step="0.1" min="0.1" value="${p.stand_time!==undefined?p.stand_time:1.0}"/></div>
<div><div class="mini">dissolve time (s)</div><input id="ptdissolve" type="number" step="0.1" min="0.1" value="${p.dissolve_time!==undefined?p.dissolve_time:2.0}"/></div>
</div>
<div class="muted" style="margin-top:6px;">While the player stands on it, after the stand time it becomes translucent (no hitbox) for the dissolve time, then reforms.</div>` : ""}
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
</div>
@@ -2210,6 +2275,9 @@ Cancel = Choose whether to remove without saving`);
<div><div class="mini">to</div><input id="exto" type="text" value="${ex.to||""}" placeholder="room_02"/></div>
<div><div class="mini">spawn_id</div><input id="exsid" type="text" value="${ex.spawn_id||""}" placeholder="from_room_01"/></div>
</div>
<div style="margin-top:8px;">
<label><input id="exbossdead" type="checkbox" ${ex.requires_boss_dead?"checked":""}/> Appears/active only after the room's boss is slain</label>
</div>
<div class="muted" style="margin-top:8px;">Tip: use the Linking panel to set these safely.</div>
<div class="row" style="margin-top:8px;">
<button id="delSel" class="danger">Delete</button>
@@ -2244,6 +2312,11 @@ Cancel = Choose whether to remove without saving`);
if(sel.kind==="enemy"){
const e=room.enemies[sel.index];
if(!e) return "<div class='muted'>Selection missing.</div>";
const eType = e.type || "enemy";
const eDef = state.enemyTypes && state.enemyTypes[eType] ? state.enemyTypes[eType] : null;
const eProf = (eDef && eDef.drop_profile) ? eDef.drop_profile : "normal";
const beh = e.behavior || ((eDef && eDef.behavior) ? eDef.behavior : "chaser");
const bossBehavior = (eProf === "boss");
return `
<div><b>Enemy Spawn</b> #${sel.index}</div>
<div class="hr"></div>
@@ -2255,6 +2328,17 @@ Cancel = Choose whether to remove without saving`);
<div class="mini">type</div>
<input id="ent" type="text" value="${e.type||"enemy"}"/>
</div>
${bossBehavior ? `
<div style="margin-top:8px;">
<div class="mini">boss AI</div>
<select id="enbeh">
<option value="" ${beh===""?"selected":""}>default (from enemy type)</option>
<option value="shockwave" ${beh==="shockwave"?"selected":""}>shockwave (slam + shockwave)</option>
<option value="charger" ${beh==="charger"?"selected":""}>charger (retreat-charge + dash)</option>
<option value="flyer" ${beh==="flyer"?"selected":""}>flyer (air boss: slam, minions, aoe, homing shot)</option>
</select>
</div>
` : ""}
<div style="margin-top:8px;">
<div class="mini">layer (lower = back)</div>
<input id="enlayer" type="number" value="${e.layer||0}"/>
@@ -2371,14 +2455,18 @@ Cancel = Choose whether to remove without saving`);
function num(v){ const n = Number(v); return Number.isFinite(n) ? n : 0; }
if(!sel) return;
const pushAnd = (fn)=>{
pushHistory(roomWrap.id);
fn();
refreshUI();
};
if(!sel){
const rm = q("roommist");
if(rm) rm.addEventListener("change", ()=>pushAnd(()=>{ room.mist = rm.checked; }));
return;
}
if(sel.kind==="platform"){
const p = room.platforms[sel.index]; if(!p) return;
["px","py","pw","ph"].forEach(id=>{
@@ -2394,6 +2482,10 @@ Cancel = Choose whether to remove without saving`);
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 === "dissolve"){
if(p.stand_time === undefined) p.stand_time = 1.0;
if(p.dissolve_time === undefined) p.dissolve_time = 2.0;
}
if(p.group !== "trigger" && p.name !== undefined && p.name !== "") delete p.name;
}));
const ptname=q("ptname"); if(ptname) ptname.addEventListener("change", ()=>pushAnd(()=>{
@@ -2418,7 +2510,15 @@ Cancel = Choose whether to remove without saving`);
const ptminibossdead=q("ptminibossdead"); if(ptminibossdead) ptminibossdead.addEventListener("change", ()=>pushAnd(()=>{
if(ptminibossdead.checked){ p.open_on_miniboss_dead = true; } else { delete p.open_on_miniboss_dead; }
}));
const ptbossdead=q("ptbossdead"); if(ptbossdead) ptbossdead.addEventListener("change", ()=>pushAnd(()=>{
if(ptbossdead.checked){ p.open_on_boss_dead = true; } else { delete p.open_on_boss_dead; }
}));
const ptspeed=q("ptspeed"); if(ptspeed) ptspeed.addEventListener("change", ()=>pushAnd(()=>{ p.speed = Math.max(1, num(ptspeed.value)); }));
const pmbossdead=q("pmbossdead"); if(pmbossdead) pmbossdead.addEventListener("change", ()=>pushAnd(()=>{
if(pmbossdead.checked){ p.open_on_boss_dead = true; } else { delete p.open_on_boss_dead; }
}));
const ptstand=q("ptstand"); if(ptstand) ptstand.addEventListener("change", ()=>pushAnd(()=>{ p.stand_time = Math.max(0.1, num(ptstand.value)); }));
const ptdissolve=q("ptdissolve"); if(ptdissolve) ptdissolve.addEventListener("change", ()=>pushAnd(()=>{ p.dissolve_time = Math.max(0.1, num(ptdissolve.value)); }));
const hzrespawn=q("hzrespawn");
if(hzrespawn){
// preselect current respawn if it matches an entry spawn
@@ -2488,6 +2588,9 @@ Cancel = Choose whether to remove without saving`);
const exto=q("exto"); if(exto) exto.addEventListener("change", ()=>pushAnd(()=>{ ex.to = exto.value.trim(); }));
const exsid=q("exsid"); if(exsid) exsid.addEventListener("change", ()=>pushAnd(()=>{ ex.spawn_id = exsid.value.trim(); }));
const exlayer=q("exlayer"); if(exlayer) exlayer.addEventListener("change", ()=>pushAnd(()=>{ ex.layer = Math.round(num(exlayer.value)); }));
const exbossdead=q("exbossdead"); if(exbossdead) exbossdead.addEventListener("change", ()=>pushAnd(()=>{
if(exbossdead.checked){ ex.requires_boss_dead = true; } else { delete ex.requires_boss_dead; }
}));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.exits.splice(sel.index,1);
state.selection=null;
@@ -2511,11 +2614,15 @@ Cancel = Choose whether to remove without saving`);
}));
} else if(sel.kind==="enemy"){
const e = room.enemies[sel.index]; if(!e) return;
const enx=q("enx"), eny=q("eny"), ent=q("ent"), enlayer=q("enlayer");
const enx=q("enx"), eny=q("eny"), ent=q("ent"), enlayer=q("enlayer"), enbeh=q("enbeh");
if(enx) enx.addEventListener("change", ()=>pushAnd(()=>{ e.x=num(enx.value); }));
if(eny) eny.addEventListener("change", ()=>pushAnd(()=>{ e.y=num(eny.value); }));
if(ent) ent.addEventListener("change", ()=>pushAnd(()=>{ e.type=ent.value.trim()||"enemy"; }));
if(enlayer) enlayer.addEventListener("change", ()=>pushAnd(()=>{ e.layer = Math.round(num(enlayer.value)); }));
if(enbeh) enbeh.addEventListener("change", ()=>pushAnd(()=>{
const v = enbeh.value;
if(v) e.behavior = v; else delete e.behavior;
}));
const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{
room.enemies.splice(sel.index,1);
state.selection=null;
@@ -3312,19 +3419,24 @@ Cancel = Choose whether to remove without saving`);
for(const p of (d.platforms || [])){
const group = p.group || "ground";
if(!platforms[group]) platforms[group] = [];
if(group === "moving" || group === "trigger" || group === "hazard"){
if(group === "moving" || group === "trigger" || group === "hazard" || group === "dissolve"){
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(group === "dissolve"){
if(p.stand_time !== undefined) obj.stand_time = p.stand_time;
if(p.dissolve_time !== undefined) obj.dissolve_time = p.dissolve_time;
}
if(p.name) obj.name = p.name;
if(p.quest) obj.quest = p.quest;
if(p.quest_complete) obj.quest_complete = p.quest_complete;
if(p.quest_turned_in) obj.quest_turned_in = p.quest_turned_in;
if(p.lock_when_pass) obj.lock_when_pass = true;
if(p.open_on_miniboss_dead) obj.open_on_miniboss_dead = true;
if(p.open_on_boss_dead) obj.open_on_boss_dead = true;
if(Array.isArray(p.respawn)) obj.respawn = p.respawn;
platforms[group].push(obj);
} else {
@@ -3338,6 +3450,7 @@ Cancel = Choose whether to remove without saving`);
const enemies = (d.enemies || []).map(e => {
const out = { type: e.type || "enemy", pos: [e.x, e.y] };
if(e.layer) out.layer = e.layer;
if(e.behavior) out.behavior = e.behavior;
return out;
});
@@ -3348,7 +3461,7 @@ Cancel = Choose whether to remove without saving`);
return out;
});
// exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint} (+ layer when set)
// exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint} (+ layer / requires_boss_dead when set)
const exits = (d.exits || []).map(ex => {
const out = {
rect: [ex.x, ex.y, ex.w, ex.h],
@@ -3358,6 +3471,7 @@ Cancel = Choose whether to remove without saving`);
};
if(ex.spawn_id) out.spawn_id = ex.spawn_id;
if(ex.layer) out.layer = ex.layer;
if(ex.requires_boss_dead) out.requires_boss_dead = true;
return out;
});
@@ -3400,6 +3514,7 @@ Cancel = Choose whether to remove without saving`);
// include entry spawns (new feature) for your spawn linking
if(d.entry_spawns && typeof d.entry_spawns === "object") out.entry_spawns = d.entry_spawns;
if(props.length) out.props = props;
if(d.mist) out.mist = true;
// carry over any extra keys that were in the original
if(d._extras && typeof d._extras === "object"){
@@ -3691,14 +3806,9 @@ Cancel = Choose whether to remove without saving`);
refreshUI();
});
els.showLinksChk.checked = false; // always start unchecked (no auto Compare)
els.showLinksChk.addEventListener("change", ()=>{
if(els.showLinksChk.checked){
if(!state.compareEnabled){
state.compareEnabled = true;
const other = [...state.rooms.keys()].find(k => k !== state.activeId);
state.compareId = other || state.activeId;
}
}
// Lines only draw while Compare is on too (see drawLinkLinesForCanvas).
// overlay updates in render loop
refreshUI();
});
@@ -3757,6 +3867,20 @@ els.compareRoomSelect.addEventListener("change", ()=>{
setStatus(`Downloaded ${i} checked room(s).`);
});
els.downloadAllBtn.addEventListener("click", async ()=>{
const rooms = [...state.rooms.values()].filter(r=>r.dirty);
if(!rooms.length){ setStatus("No modified rooms."); return; }
let i=0;
for(const r of rooms){
downloadText(r.filename || (r.data.name + ".json"), serializeRoom(r));
r.dirty = false;
i++;
await new Promise(res=>setTimeout(res, 250));
}
refreshRoomsList();
setStatus(`Downloaded ${i} modified room(s).`);
});
// canvas pointer handlers
// middle-mouse pan (and prevent browser autoscroll / sidebar scrolling)
function startMousePan(e, canvasKey){
+1501 -175
View File
File diff suppressed because it is too large Load Diff
+235 -19
View File
@@ -1,30 +1,244 @@
{
"version": 1,
"saved_at": "2026-09-30T11:51:08",
"saved_at": "2026-10-05T09:57:55",
"player": {
"room_id": "room_04.json",
"x": 590,
"y": 240,
"room_id": "room_07.json",
"x": 680,
"y": 520,
"health": 10,
"charge": 100.0,
"charge": 120.0,
"shade": false,
"coins_light": 39,
"coins_dark": 4,
"butterfly_jars": 2,
"has_ranged": false,
"coins_light": 40,
"coins_dark": 20,
"butterfly_jars": 0,
"has_ranged": true,
"opened_chests": [
"room_03.json@90,194"
"room_03.json@90,194",
"room_07.json@440,494"
],
"equipment": {
"helmet": null,
"chest": null,
"pants": null,
"helmet": {
"id": "duskwarrior_helm",
"name": "Duskwarrior Helmet",
"set": "duskwarrior",
"slot": "helmet",
"base_stats": {},
"rarity_overrides": {
"tempered": {
"extra_stats": {
"charge": 10
}
},
"attuned": {
"extra_stats": {
"charge": 10,
"health": 5
}
},
"awakened": {
"extra_stats": {
"charge": 15,
"health": 5,
"charge_regen_pct": 0.1
}
}
},
"rarity": "tempered",
"tier": 1,
"stats": {
"charge": 10.0
},
"abilities": []
},
"chest": {
"id": "vitalweave_chest",
"name": "Vitalweave Chestplate",
"set": "vitalweave",
"slot": "chest",
"base_stats": {},
"rarity_overrides": {
"tempered": {
"extra_stats": {
"heal_speed_pct": 0.1
}
},
"attuned": {
"extra_stats": {
"heal_speed_pct": 0.15
}
},
"awakened": {
"extra_stats": {
"heal_speed_pct": 0.15,
"health": 5
}
}
},
"rarity": "attuned",
"tier": 2,
"stats": {
"heal_speed_pct": 0.15
},
"abilities": []
},
"pants": {
"id": "gravemark_greaves",
"name": "Gravemark Greaves",
"set": "gravemark",
"slot": "pants",
"base_stats": {},
"rarity_overrides": {
"tempered": {
"extra_stats": {
"charge": 10
}
},
"attuned": {
"extra_stats": {
"charge": 10,
"health": 5
}
},
"awakened": {
"extra_stats": {
"charge": 20,
"health": 5
}
}
},
"rarity": "tempered",
"tier": 1,
"stats": {
"charge": 10.0
},
"abilities": []
},
"necklace": null
}
},
"inventory": [],
"inventory": [
{
"id": "dawnrunner_helm",
"name": "Dawnrunner Helmet",
"set": "dawnrunner",
"slot": "helmet",
"base_stats": {},
"rarity_overrides": {
"tempered": {
"extra_stats": {
"move_speed_light_pct": 0.1
}
},
"attuned": {
"extra_stats": {
"move_speed_light_pct": 0.1,
"heal_speed_pct": 0.1
}
},
"awakened": {
"extra_stats": {
"move_speed_light_pct": 0.15,
"heal_speed_pct": 0.2
}
}
},
"rarity": "attuned",
"tier": 2,
"stats": {
"move_speed_light_pct": 0.1,
"heal_speed_pct": 0.1
},
"abilities": []
},
{
"id": "paladin_helm",
"name": "Paladin Helmet",
"set": "paladin",
"slot": "helmet",
"base_stats": {},
"rarity_overrides": {
"tempered": {
"extra_stats": {
"health": 5
}
},
"attuned": {
"extra_stats": {
"health": 10,
"negate_damage_chance": 0.05
}
},
"awakened": {
"extra_stats": {
"health": 15,
"negate_damage_chance": 0.1,
"knockback_resistance": 0.25
}
}
},
"rarity": "tempered",
"tier": 1,
"stats": {
"health": 5.0
},
"abilities": []
},
{
"id": "dawnrunner_chest",
"name": "Dawnrunner Chestplate",
"set": "dawnrunner",
"slot": "chest",
"base_stats": {},
"rarity_overrides": {
"tempered": {
"extra_stats": {
"charge": 10
}
},
"attuned": {
"extra_stats": {
"charge": 15,
"move_speed_light_pct": 0.05
}
},
"awakened": {
"extra_stats": {
"charge": 15,
"move_speed_light_pct": 0.05,
"heal_plus": 1
}
}
},
"rarity": "attuned",
"tier": 2,
"stats": {
"charge": 15.0,
"move_speed_light_pct": 0.05
},
"abilities": []
},
{
"id": "necklace_flow",
"name": "Charm of Flow",
"slot": "necklace",
"item_type": "necklace",
"base_stats": {
"charge": 10
},
"rarity": "",
"tier": 0,
"abilities": [],
"stats": {
"charge": 10
}
}
],
"quests": {
"active": {},
"active": {
"first_kills_shade": {
"progress": 1
}
},
"completed_one_time": [],
"ever_completed": [
"quest_first_kills"
@@ -34,11 +248,13 @@
"boss_diary": {
"slotted": {}
},
"defeated_bosses": [],
"defeated_bosses": [
"room_05.json"
],
"broken_doors": [],
"lantern_respawn": {
"room_id": "room_04.json",
"x": 560,
"y": 240
"room_id": "room_07.json",
"x": 640,
"y": 520
}
}