diff --git a/light_and_dark/README.md b/light_and_dark/README.md index a9cad94..5ddcc8c 100644 --- a/light_and_dark/README.md +++ b/light_and_dark/README.md @@ -2,7 +2,7 @@ A 2D platformer built with **Pygame**. You switch between two forms — **Light** and **Shade** — each with different movement, combat, and ability behavior, all driven by a single shared resource: **Charge (Flow)**. -> Note: this is a prototype. All progress (quests, boss tokens, broken walls, defeated bosses) is **in-memory** and resets when you restart the game — intentional, for fast testing. +> Note: this is a prototype. Some progress (quests, boss tokens, broken walls, defeated bosses) is **in-memory** and resets when you restart — intentional, for fast testing. Saves (via pause menu or lanterns) persist player state, coins, equipment, inventory, and your respawn point. --- @@ -12,7 +12,7 @@ A 2D platformer built with **Pygame**. You switch between two forms — **Light* python main.py ``` -Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drops) lives in `data/`. Rooms are edited with the included browser tool: `level_editor_v43.html`. +Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drops) lives in `data/`. Rooms are edited with the included browser tool: **`level_editor_v43.html`** — see [Level Editor](#level-editor) below. --- @@ -31,7 +31,8 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop | Tether | X | | Reality Break | C | | Toggle Light/Shade | Left Shift | -| Interact / Talk | F | +| Interact / Talk / Save | F | +| Use Butterfly Jar | G | | Inventory | I | | Stats overlay | H | | Pause / Settings | Esc | @@ -44,7 +45,29 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop **Shade** — slower but tanky: **immune to enemy contact damage**, deals double melee damage, and can use shade-specific gear bonuses. But being in Shade **drains Charge** (and Shade is forced off when Charge hits 0). Shade also drains Charge **twice as fast while clipping inside an enemy**. -**Charge (Flow)** — base max 100, refills at 20/s in Light, drains at 40/s in Shade. Standing near a **lantern/light source** doubles Charge regeneration. Attacks, dash (Light), ranged shots, and healing all cost Charge. +**Charge (Flow)** — base max 100, refills at **10/s** in Light (halved to make light sources matter), drains at **40/s** in Shade. Standing near a **butterfly** light source doubles Charge regeneration. Attacks, dash (Light), ranged shots, and healing all cost Charge. + +--- + +## Saving & lanterns + +**Lanterns are save points.** Walk up to a lantern and press **F** to save — a "Press F - Save at lantern" prompt appears when you're in range. Your respawn point is set to that lantern, so dying (or reloading the save) puts you back at your latest lantern. Saving via a lantern in a *different room* than where you last died also respawns butterfly jars in the world. + +You can also save anytime from the pause menu (**Esc → Save Game**). + +--- + +## Butterfly jars + +Butterfly-in-a-jar pickups are scattered through the rooms (you can hold up to **3**). Press **G** to consume one and instantly refill your Charge bar to max. Collected jars are shown below your coin counters (empty slots are greyed out). + +Jars are lost when you die. They respawn at their pickup points after you save at a lantern in a different room, or after reloading the game. + +--- + +## Death & recovery + +When you die you lose your **butterfly jars for good** and drop your coins. A **faded player model (corpse)** marks the spot — touch it to reclaim all lost coins. Dying again *without* reclaiming keeps only **50%** of the coins in a new corpse at the new death spot (the old corpse vanishes), so the rest is lost. Falling out of the world (void deaths) leaves no recoverable corpse. --- @@ -129,5 +152,19 @@ The shade-focused sets (**Duskwarrior, Umbraflow, Abyss Cleaver, Adaptive Travel ## HUD & Stats - Top-right: health bar (turns **green while poisoned**), Charge bar, coin counts. +- Butterfly-jar slots below the coin counters (shown once you own at least one; remaining slots are greyed out). - Ability icons with cooldown indicators (top-left). -- **H** toggles a detailed overlay: exact HP/Charge, move speeds (both forms), heal duration, attack rate, Charge regen, the lantern buff, and any stat bonuses from gear. \ No newline at end of file +- **H** toggles a detailed overlay: exact HP/Charge, move speeds (both forms), heal duration, attack rate, Charge regen, the lantern/light buff, and any stat bonuses from gear. + +--- + +## Level Editor + +Open `level_editor_v43.html` in a browser to edit rooms. Highlights: + +- **Icon toolbar** on the right edge of the canvas (Select, Platform, Enemy, NPC, Player, Entry, Exit, Ability Door, Object). Toggle **grid snap** in the left sidebar. +- **Objects catalog** (built-in, always available; `data/objects/objects.json` can override/add): `flow_butterfly` (light source), `lamp` (save point), `flow_butterfly_jar` (collectible), `house`, `diary`, and `player` (a size reference that renders nothing in-game). +- **NPC support**: place/select/move NPCs (vendor, upgrader, necklace_vendor, combat_master) and standalone **house props**. +- **Layers**: every object (platforms, houses, NPCs, enemies, props, ability doors, exits) has a **layer** number. Lower = further back, higher = further forward; lower is set in the Properties panel. The editor and game both draw back-to-front by `(layer, category)`, and the select tool is layer-aware. +- **Link lines**: enable **Draw link lines** (with Compare view) to see dashed lines from a room's exits to the matching doors in the other room. +- Load the game's enemy types (`data/enemies/enemy_types.json`) to preview hitboxes and aggro ranges; enemy/miniboss/boss are also built in. \ No newline at end of file diff --git a/light_and_dark/ROADMAP.md b/light_and_dark/ROADMAP.md index 29e10c7..3af2991 100644 --- a/light_and_dark/ROADMAP.md +++ b/light_and_dark/ROADMAP.md @@ -5,7 +5,7 @@ Proves the core loop is fun before scaling to the full vision. ## Design decisions (locked) -- **Death penalty**: on death, drop light/dark coins at the death spot; reclaim them or lose them permanently on a second death. The fairy hovers over the spot as the marker. +- **Death penalty**: on death, coins drop into a **corpse** (faded player model) at the death spot; touch it to reclaim them. Dying again without reclaiming keeps only 50% (a new corpse appears, the old one vanishes). Butterfly jars are lost for good on death. Void deaths (falling out of the world) leave no corpse. - **Skill upgrades**: - **Vendor** (smith/trainer) gives direct linear power upgrades. Upgrades one selected spell; changing selection costs coins. - **Gems / rings** give alternate behaviors / sidegrades. @@ -21,12 +21,13 @@ Proves the core loop is fun before scaling to the full vision. | ~~Save/persistence system~~ | L | **DONE (2026-09-28)**: JSON save to `saves/`, save via pause menu. Stores player state, room, coins, equipment, inventory, quests, tokens, boss diary, defeated bosses, broken doors. | | Charge -> Flow rename | S | Code + UI + configs. | | ~~Main menu~~ | M | **DONE (2026-09-28)**: Select Game (greyed with no saves), New Game (name entry), Settings (rebinding), Quit. Wipe-all-saves button (bottom right) with confirmation dialog. | +| ~~Lantern save points~~ | M | **DONE (2026-09-29)**: Lanterns are explicit save points — walk up and press F to save; you respawn at your last lantern. Visible radius removed. | ### M1 — Region 1 complete loop | Item | Effort | Notes | |---|---|---| -| Death penalty (drop coins, reclaim at death spot; fairy marks it; lose on 2nd death) | M | Save system exists now; drop-position data needed. Ties into fairy. | -| Fairy companion (guide + ambient light source -> bonus flow regen) | M | Wakes player at start, tutorial dialogue. | +| ~~Death penalty (drop coins, reclaim at death spot; fairy marks it; lose on 2nd death)~~ | M | **DONE (2026-09-29)**: corpse at death spot holds 100% of coins; a repeat death before reclaiming keeps only 50% and the old corpse vanishes. Butterfly jars are lost for good on death. | +| ~~Fairy companion (guide + ambient light source -> bonus flow regen)~~ | M | **DONE (2026-09-29)**: fairy follows behind the player's head; flips with facing. | | More quests (repeatable + one-time; system exists) | S | Just content. | | Lore dialogue for fairy / NPCs | S | Needs written copy. | @@ -42,7 +43,7 @@ Proves the core loop is fun before scaling to the full vision. |---|---|---| | Shade-gated interactions (doors, an NPC, an area) | S-M | Room JSON + existing toggle. | | New enemy behaviors (patrol, dodge-and-leap, burrow-shooter) | M | Enemy system supports it. | -| Quick recharge pickup | S | Mid-battle refill. | +| ~~Quick recharge pickup~~ | S | **DONE (2026-09-29)**: butterfly jars — collect up to 3, press G to instantly refill Charge. Lost on death; respawn at their points after saving at a lantern in a different room or reloading. | | Ability tweaks: ranged upgrades, ground-slam knockback/iframe, light-step buffs | M | Picks up remaining notes ideas. | | Region-2 boss + ability pacing | M | Re-align pacing (Ranged already exists). | diff --git a/light_and_dark/data/objects/objects.json b/light_and_dark/data/objects/objects.json index 4699114..ab2224c 100644 --- a/light_and_dark/data/objects/objects.json +++ b/light_and_dark/data/objects/objects.json @@ -10,6 +10,15 @@ }, "flow_butterfly_jar": { "type": "butterfly_jar" + }, + "house": { + "type": "house" + }, + "diary": { + "type": "diary" + }, + "player": { + "type": "player" } } } \ No newline at end of file diff --git a/light_and_dark/data/world/rooms/room_07.json b/light_and_dark/data/world/rooms/room_07.json index 73af087..52c3fdc 100644 --- a/light_and_dark/data/world/rooms/room_07.json +++ b/light_and_dark/data/world/rooms/room_07.json @@ -153,13 +153,6 @@ "h": 20, "group": "ground" }, - { - "x": 1940, - "y": 2720, - "w": 360, - "h": 20, - "group": "ground" - }, { "x": 2660, "y": 880, @@ -278,12 +271,18 @@ "w": 320, "h": 40, "group": "ground" + }, + { + "x": 2100, + "y": 2720, + "w": 60, + "h": 20 } ], "enemies": [ { "x": 2080, - "y": 360, + "y": 380, "type": "enemy" }, { @@ -352,45 +351,42 @@ "type": "miniboss" } ], + "npcs": [], "props": [ { + "x": 200, + "y": 320, "type": "save_point", - "pos": [ - 200, - 320 - ], - "radius": 110 + "radius": 110, + "object_id": null }, { + "x": 820, + "y": 320, "type": "butterfly_jar", - "pos": [ - 420, - 320 - ] + "radius": 180, + "object_id": null }, { + "x": 500, + "y": 320, "type": "light_source", - "pos": [ - 500, - 320 - ], - "radius": 180 + "radius": 180, + "object_id": null }, { + "x": 900, + "y": 2780, "type": "light_source", - "pos": [ - 900, - 2780 - ], - "radius": 180 + "radius": 180, + "object_id": null }, { + "x": 2600, + "y": 1860, "type": "light_source", - "pos": [ - 2600, - 1860 - ], - "radius": 180 + "radius": 180, + "object_id": null } ], "exits": [ @@ -459,6 +455,12 @@ "y": 2180, "w": 40, "h": 600 + }, + { + "x": 1940, + "y": 2720, + "w": 360, + "h": 20 } ], "room_w": 1600, diff --git a/light_and_dark/data/world/rooms/room_town_gloomreach.json b/light_and_dark/data/world/rooms/room_town_gloomreach.json index 36bd958..1abff1b 100644 --- a/light_and_dark/data/world/rooms/room_town_gloomreach.json +++ b/light_and_dark/data/world/rooms/room_town_gloomreach.json @@ -21,58 +21,6 @@ ] }, "enemies": [], - "exits": [ - { - "rect": [ - 10, - 300, - 80, - 60 - ], - "to": "room_07", - "spawn": [ - -1000, - 2720 - ], - "hint": "Back" - } - ], - "ability_doors": [], - "entry_spawns": { - "entry_1": [ - 120, - 300 - ], - "entry_diary": [ - 180, - 300 - ] - }, - "props": [ - { - "type": "save_point", - "pos": [ - 120, - 360 - ], - "radius": 110 - }, - { - "type": "light_source", - "pos": [ - 900, - 360 - ], - "radius": 180 - }, - { - "type": "diary", - "pos": [ - 180, - 310 - ] - } - ], "npcs": [ { "type": "vendor", @@ -98,10 +46,98 @@ { "type": "combat_master", "pos": [ - 400, + 479, 306 + ] + } + ], + "exits": [ + { + "rect": [ + 10, + 300, + 80, + 60 ], - "trade_cost": 0 + "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" } ] } \ No newline at end of file diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html index 686fdf2..e99f605 100644 --- a/light_and_dark/level_editor_v43.html +++ b/light_and_dark/level_editor_v43.html @@ -33,10 +33,28 @@ .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%;} + /* Floating tool palette pinned to the right edge of the canvas area */ + #toolbar{ + position:absolute; right:10px; top:70px; z-index:60; + display:flex; flex-direction:column; gap:6px; padding:8px; + background:rgba(17,24,39,0.92); border:1px solid var(--border); border-radius:12px; + box-shadow:0 4px 18px rgba(0,0,0,0.45); + } + .toolIconBtn{ + width:36px; height:36px; padding:0; margin:0; + display:flex; align-items:center; justify-content:center; + border-radius:8px; border:1px solid var(--border); background:var(--panel2); + cursor:pointer; color:var(--muted); transition:color .12s, border-color .12s, background .12s; + } + .toolIconBtn:hover{ color:var(--fg); border-color:#334155; } + .toolIconBtn svg{ width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:2; stroke-linejoin:round; stroke-linecap:round; } + .toolIconBtn.active, .toolIconBtn.primary, .toolIconBtn.warn{ + color:#fff; border-color:rgba(96,165,250,0.8); + background:rgba(96,165,250,0.22); box-shadow:0 0 0 1px rgba(96,165,250,0.25) inset; + } .topbar{display:flex; gap:10px; align-items:center; padding:10px 12px; border-bottom:1px solid var(--border); background:rgba(255,255,255,0.02);} /* IMPORTANT: force the grid to actually take height inside the flex column */ .canvasRow{position:relative; flex:1; min-height:0; height:100%; display:grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; gap:1px; background:var(--border);} - #linkOverlay{position:absolute; left:0; top:0; width:100%; height:100%; pointer-events:none; z-index:50;} .canvasWrap{position:relative; background:#05070a; min-height:0;} canvas{width:100%; height:100%; display:block;} .overlayLabel{ @@ -78,15 +96,17 @@

Tools

-
- - - - - - - - +
+ Tools are on the icon toolbar at the right edge of the canvas. +
+
+ +
+
+ Layers: every object (platforms, houses, NPCs, enemies, props, doors, exits) has a layer 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.
Shortcuts: Ctrl+Z undo, @@ -97,7 +117,7 @@

Enemy Types

-
Load an enemy types JSON (keyed by enemy id). Used for placement dropdown, hitbox preview and aggro range preview.
+
Load an enemy types JSON (keyed by enemy id). Built-ins (enemy/miniboss/boss) are always available; importing overrides them.
@@ -109,8 +129,19 @@ Draw enemy aggro ranges
+

NPC Types

+
Pick which NPC the NPC Spawn tool places. You can also change an NPC's type in Properties after placing it.
+
+ +
+

Objects Catalog

-
Load objects.json to get default properties. Built-ins are always available: flow_butterfly (light source), lamp (save point), flow_butterfly_jar (collectible).
+
Load objects.json to get default properties. Built-ins are always available: flow_butterfly (light), lamp (save point), flow_butterfly_jar (collectible), house, diary, player (size reference).
@@ -175,7 +206,37 @@
Compare room
- +
+ + +
+ + + + + + + + +
@@ -194,10 +255,21 @@ flow_butterfly: { type: "light_source", charge_regen_range: 180 }, lamp: { type: "save_point", charge_regen_range: 90 }, flow_butterfly_jar: { type: "butterfly_jar" }, + house: { type: "house" }, + diary: { type: "diary" }, + player: { type: "player" }, }; // Type fallbacks used when no object def provides a radius. const TYPE_DEFAULT_RADII = { light_source: 180, save_point: 90 }; + // Built-in enemy types (mirrors data/enemies/enemy_types.json) so the editor works + // immediately; importing an enemy types JSON overrides these. + 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" }, + }; + function seedDefaultObjectCatalog(){ objectCatalog.byType = {}; objectCatalog.byId = {}; @@ -208,7 +280,7 @@ } seedDefaultObjectCatalog(); - const snap = (v)=> Math.round(v / state.gridSize) * state.gridSize; + const snap = (v)=> state.snapEnabled ? Math.round(v / state.gridSize) * state.gridSize : Math.round(v); const snapPt = (x,y)=> [snap(x), snap(y)]; function downloadText(filename, text){ @@ -249,6 +321,7 @@ entry_spawns: {}, // {spawn_id: [x,y]} platforms: [], enemies: [], + npcs: [], exits: [], ability_doors: [], props: [] @@ -295,14 +368,14 @@ 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 }); + 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 }); } } // 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" }); + 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) }); } } } @@ -311,9 +384,9 @@ 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" }); + 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" }); + 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 }); } } } else if(src.platforms && typeof src.platforms === "object"){ @@ -323,7 +396,7 @@ 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 }); + 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 }); } } } @@ -335,9 +408,23 @@ 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" }); + r.enemies.push({ x:+e.pos[0]||0, y:+e.pos[1]||0, type: e.type || "enemy", layer: +(e.layer||0) }); } 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" }); + r.enemies.push({ x:+e.x||0, y:+e.y||0, type: e.type || "enemy", layer: +(e.layer||0) }); + } + } + } + } + + // ----- npcs ----- + r.npcs = []; + if(Array.isArray(src.npcs)){ + for(const n of src.npcs){ + if(n && typeof n === "object"){ + if(Array.isArray(n.pos)){ + r.npcs.push({ x:+n.pos[0]||0, y:+n.pos[1]||0, type: n.type || "vendor", layer: +(n.layer||0) }); + } else if(typeof n.x === "number" || typeof n.y === "number"){ + r.npcs.push({ x:+n.x||0, y:+n.y||0, type: n.type || "vendor", layer: +(n.layer||0) }); } } } @@ -357,7 +444,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 }); + r.props.push({ x, y, type, radius, object_id, layer: +(p.layer||0) }); } } @@ -372,7 +459,8 @@ to: ex.to || "", spawn_id: ex.spawn_id || "", spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null, - hint: ex.hint || "" + hint: ex.hint || "", + layer: +(ex.layer||0) }); } else { // editor-native exit @@ -381,7 +469,8 @@ to: ex.to || "", spawn_id: ex.spawn_id || "", spawn: Array.isArray(ex.spawn) ? [ +ex.spawn[0]||0, +ex.spawn[1]||0 ] : null, - hint: ex.hint || "" + hint: ex.hint || "", + layer: +(ex.layer||0) }); } } @@ -393,12 +482,12 @@ for(const d of src.ability_doors){ if(!d) continue; if(Array.isArray(d) && d.length >= 4){ - r.ability_doors.push({ x:+d[0]||0, y:+d[1]||0, w:+d[2]||0, h:+d[3]||0 }); + r.ability_doors.push({ x:+d[0]||0, y:+d[1]||0, w:+d[2]||0, h:+d[3]||0, layer: d.length>=5 ? (+d[4]||0) : 0 }); } else if(typeof d === "object"){ if(Array.isArray(d.rect)){ - r.ability_doors.push({ x:+d.rect[0]||0, y:+d.rect[1]||0, w:+d.rect[2]||0, h:+d.rect[3]||0 }); + r.ability_doors.push({ x:+d.rect[0]||0, y:+d.rect[1]||0, w:+d.rect[2]||0, h:+d.rect[3]||0, layer: +(d.layer||0) }); } else { - r.ability_doors.push({ x:+d.x||0, y:+d.y||0, w:+d.w||0, h:+d.h||0 }); + r.ability_doors.push({ x:+d.x||0, y:+d.y||0, w:+d.w||0, h:+d.h||0, layer: +(d.layer||0) }); } } } @@ -407,7 +496,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","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"]); for(const k of Object.keys(src)){ if(!keep.has(k)) r._extras[k] = deepClone(src[k]); } @@ -431,6 +520,7 @@ tool: "select", platformGroup: "ground", gridSize: 20, + snapEnabled: true, selection: null, // {kind, index, roomId} dragging: null, @@ -440,9 +530,10 @@ clipboard: null, - enemyTypes: null, - enemyTypeKeys: [], + enemyTypes: deepClone(DEFAULT_ENEMY_TYPES), + enemyTypeKeys: Object.keys(DEFAULT_ENEMY_TYPES), enemyPlaceType: "enemy", + npcPlaceType: "vendor", showAggro: false, objectPlaceId: null }; @@ -468,15 +559,16 @@ compareRoomSelect: document.getElementById("compareRoomSelect"), labelA: document.getElementById("labelA"), labelB: document.getElementById("labelB"), - linkOverlay: document.getElementById("linkOverlay"), showLinksChk: document.getElementById("showLinksChk"), props: document.getElementById("props"), canvasA: document.getElementById("canvasA"), canvasB: document.getElementById("canvasB"), platformGroup: document.getElementById("platformGroup"), + snapChk: document.getElementById("snapChk"), enemyTypesInput: document.getElementById("enemyTypesInput"), enemyTypeSelect: document.getElementById("enemyTypeSelect"), + npcTypeSelect: document.getElementById("npcTypeSelect"), showAggroChk: document.getElementById("showAggroChk"), objectsInput: document.getElementById("objectsInput"), objectsStatus: document.getElementById("objectsStatus"), @@ -1015,6 +1107,16 @@ Cancel = Choose whether to remove without saving`); return; } + if(state.tool === "npc_spawn"){ + pushHistory(roomWrap.id); + const [sx1,sy1]=snapPt(wx,wy); + room.npcs = room.npcs || []; + room.npcs.push({x: sx1, y: sy1, type: (state.npcPlaceType || "vendor")}); + state.selection = {kind:"npc", index:room.npcs.length-1, roomId: roomWrap.id}; + refreshUI(); + return; + } + if(state.tool === "player_spawn"){ pushHistory(roomWrap.id); room.player_spawn = snapPt(wx,wy); @@ -1137,6 +1239,10 @@ Cancel = Choose whether to remove without saving`); minX = Math.min(minX, e.x-50); minY = Math.min(minY, e.y-50); maxX = Math.max(maxX, e.x+50); maxY = Math.max(maxY, e.y+50); } + for(const n of (room.npcs||[])){ + minX = Math.min(minX, n.x-60); minY = Math.min(minY, n.y-60); + maxX = Math.max(maxX, n.x+60); maxY = Math.max(maxY, n.y+60); + } const pad = 60; minX -= pad; minY -= pad; maxX += pad; maxY += pad; @@ -1194,6 +1300,160 @@ Cancel = Choose whether to remove without saving`); return [x,y]; } + // Replicates the game's procedural vendor house backdrop (drawn in world units). + function drawHouseShape(ctx, centerX, bottomY, dpr, canvas){ + const house_w = 140, house_h = 90; + const bx = centerX - house_w/2; + const by = bottomY - house_h; + + const wallA = worldToScreen(bx, by + 30, canvas); + const wallB = worldToScreen(bx + house_w, by + house_h, canvas); + const roofA = worldToScreen(bx - 6, by + 34, canvas); + const roofB = worldToScreen(bx + house_w/2, by - 8, canvas); + const roofC = worldToScreen(bx + house_w + 6, by + 34, canvas); + const doorA = worldToScreen(bx + house_w/2 - 13, by + house_h - 34, canvas); + const doorB = worldToScreen(bx + house_w/2 + 13, by + house_h, canvas); + + // wall + ctx.fillStyle = "rgba(180,170,150,0.80)"; + ctx.fillRect(wallA[0], wallA[1], wallB[0]-wallA[0], wallB[1]-wallA[1]); + ctx.strokeStyle = "rgba(120,110,95,0.9)"; + ctx.lineWidth = 1.5*dpr; + ctx.strokeRect(wallA[0], wallA[1], wallB[0]-wallA[0], wallB[1]-wallA[1]); + + // door + ctx.fillStyle = "rgba(150,170,195,0.85)"; + ctx.fillRect(doorA[0], doorA[1], doorB[0]-doorA[0], doorB[1]-doorA[1]); + ctx.strokeStyle = "rgba(95,110,130,0.9)"; + ctx.strokeRect(doorA[0], doorA[1], doorB[0]-doorA[0], doorB[1]-doorA[1]); + + // roof + ctx.beginPath(); + ctx.moveTo(roofA[0], roofA[1]); + ctx.lineTo(roofB[0], roofB[1]); + ctx.lineTo(roofC[0], roofC[1]); + ctx.closePath(); + ctx.fillStyle = "rgba(210,170,110,0.9)"; + ctx.fill(); + ctx.strokeStyle = "rgba(135,100,65,0.9)"; + ctx.stroke(); + } + + // Replicates the game's DiaryObject: a 40x50 book with a center spine (top-left anchored). + function drawBookShape(ctx, x, y, dpr, canvas){ + const a = worldToScreen(x, y, canvas); + const b = worldToScreen(x + 40, y + 50, canvas); + const w = b[0]-a[0], h = b[1]-a[1]; + ctx.fillStyle = "rgba(150,110,70,0.9)"; + ctx.fillRect(a[0], a[1], w, h); + ctx.strokeStyle = "rgba(90,60,30,0.9)"; + ctx.lineWidth = 2*dpr; + ctx.strokeRect(a[0], a[1], w, h); + ctx.beginPath(); + ctx.moveTo(a[0]+w/2, a[1]); + ctx.lineTo(a[0]+w/2, a[1]+h); + ctx.stroke(); + } + + // Clickable body for props with a real in-world shape (null = small point marker). + function getPropHitRect(p){ + if(!p) return null; + const t = p.type || "light_source"; + if(t === "house") return { x: p.x - 70, y: p.y - 90, w: 140, h: 90 }; // bottom-center + if(t === "diary") return { x: p.x, y: p.y, w: 40, h: 50 }; // top-left book + if(t === "player") return { x: p.x, y: p.y, w: 48, h: 72 }; // top-left hitbox + return null; + } + + // Corner/edge handle detection for resizing (platforms, doors, exits). + function getResizeEdge(r, wx, wy, tol2){ + const x0=r.x, x1=r.x+r.w, y0=r.y, y1=r.y+r.h; + const xm=(x0+x1)/2, ym=(y0+y1)/2; + const handles = [ + [x0,y0,'nw'], [xm,y0,'n'], [x1,y0,'ne'], + [x1,ym,'e'], [x1,y1,'se'], [xm,y1,'s'], + [x0,y1,'sw'], [x0,ym,'w'] + ]; + const order = ['nw','ne','se','sw','n','e','s','w']; + for(const tag of order){ + const h = handles.find(h=>h[2]===tag); + const dx=wx-h[0], dy=wy-h[1]; + if(dx*dx+dy*dy <= tol2) return tag; + } + return null; + } + + // Draws a single prop (house/diary/player get their real shape; others get a marker). + function drawPropMarker(ctx, p, dpr, canvas){ + const PROP_STYLES = { + light_source: { ring: "rgba(250,204,21,0.25)", fill: "rgba(250,204,21,0.85)", label: "Butterfly" }, + save_point: { ring: "rgba(56,189,248,0.30)", fill: "rgba(56,189,248,0.9)", label: "Lantern" }, + butterfly_jar: { ring: "rgba(168,85,247,0.22)", fill: "rgba(168,85,247,0.9)", label: "Jar" }, + diary: { ring: "rgba(150,110,70,0.25)", fill: "rgba(150,110,70,0.95)", label: "Diary" }, + }; + const ptype = p.type || "light_source"; + const st = PROP_STYLES[ptype] || { ring: "rgba(148,163,184,0.25)", fill: "rgba(148,163,184,0.9)", label: ptype }; + const [sx,sy] = worldToScreen(p.x, p.y, canvas); + const v = getViewForCanvas(canvas); + const r = Math.max(20, +p.radius || getDefaultRadiusForType(ptype, p.object_id || p.objectId || null)); + + if(ptype === "house"){ + drawHouseShape(ctx, p.x, p.y, dpr, canvas); + ctx.fillStyle = "rgba(229,231,235,0.9)"; + ctx.font = `${11*dpr}px system-ui`; + ctx.fillText("House", sx+10*dpr, sy+4*dpr); + return; + } + if(ptype === "diary"){ + drawBookShape(ctx, p.x, p.y, dpr, canvas); + ctx.fillStyle = "rgba(229,231,235,0.9)"; + ctx.font = `${11*dpr}px system-ui`; + ctx.fillText("Diary", sx+10*dpr, sy-4*dpr); + return; + } + if(ptype === "player"){ + const a = worldToScreen(p.x, p.y, canvas); + const b = worldToScreen(p.x+48, p.y+72, canvas); + ctx.fillStyle = "rgba(200,200,220,0.15)"; + ctx.fillRect(a[0], a[1], b[0]-a[0], b[1]-a[1]); + ctx.strokeStyle = "rgba(200,200,220,0.8)"; + ctx.lineWidth = 2*dpr; + ctx.strokeRect(a[0], a[1], b[0]-a[0], b[1]-a[1]); + ctx.fillStyle = "rgba(229,231,235,0.9)"; + ctx.font = `${11*dpr}px system-ui`; + ctx.fillText("Player", a[0], a[1]-4*dpr); + return; + } + + // radius ring (only for types that actually use it in-game) + if(ptype === "light_source" || ptype === "save_point"){ + const rPx = r * (v.zoom || 1) * dpr; + ctx.strokeStyle = st.ring; + ctx.lineWidth = 2*dpr; + ctx.beginPath(); ctx.arc(sx, sy, rPx, 0, Math.PI*2); ctx.stroke(); + } + // marker + ctx.fillStyle = st.fill; + if(ptype === "save_point"){ + ctx.beginPath(); + ctx.moveTo(sx, sy-7*dpr); + ctx.lineTo(sx+7*dpr, sy); + ctx.lineTo(sx, sy+7*dpr); + ctx.lineTo(sx-7*dpr, sy); + ctx.closePath(); + ctx.fill(); + } else if(ptype === "butterfly_jar"){ + ctx.fillRect(sx-6*dpr, sy-6*dpr, 12*dpr, 12*dpr); + } else { + ctx.beginPath(); ctx.arc(sx, sy, 6*dpr, 0, Math.PI*2); ctx.fill(); + } + ctx.fillStyle = "rgba(229,231,235,0.9)"; + ctx.font = `${11*dpr}px system-ui`; + ctx.fillText(st.label, sx+10*dpr, sy+4*dpr); + } + + // House backdrops are explicit house props now; this shape helper is used for them. + function drawRoom(ctx, canvas, roomWrap, labelEl, isCompare=false){ const dpr = resizeCanvasToDisplaySize(canvas); ctx.setTransform(1,0,0,1,0,0); @@ -1220,28 +1480,107 @@ Cancel = Choose whether to remove without saving`); ctx.lineWidth = 2*dpr; ctx.strokeRect(x0,y0, x1-x0, y1-y0); - // platforms - for(let i=0;i (a.z - b.z) || (a.cat - b.cat)); + for(const it of layerable){ + if(it.kind === "prop"){ + drawPropMarker(ctx, it.p, dpr, canvas); + } else if(it.kind === "platform"){ + 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.25)"; + ctx.fillRect(sx,sy, sx2-sx, sy2-sy); + ctx.strokeStyle = "rgba(96,165,250,0.75)"; + ctx.lineWidth = 2*dpr; + ctx.strokeRect(sx,sy, sx2-sx, sy2-sy); + } else if(it.kind === "door"){ + const d = it.d; + const [sx,sy] = worldToScreen(d.x, d.y, canvas); + const [sx2,sy2] = worldToScreen(d.x+d.w, d.y+d.h, canvas); + ctx.fillStyle = "rgba(16,185,129,0.18)"; + ctx.fillRect(sx,sy, sx2-sx, sy2-sy); + ctx.strokeStyle = "rgba(16,185,129,0.75)"; + ctx.lineWidth = 2*dpr; + ctx.strokeRect(sx,sy, sx2-sx, sy2-sy); + } else if(it.kind === "exit"){ + const ex = it.ex; + const [sx,sy] = worldToScreen(ex.x, ex.y, canvas); + const [sx2,sy2] = worldToScreen(ex.x+ex.w, ex.y+ex.h, canvas); + ctx.fillStyle = "rgba(167,139,250,0.18)"; + ctx.fillRect(sx,sy, sx2-sx, sy2-sy); + ctx.strokeStyle = "rgba(167,139,250,0.75)"; + ctx.lineWidth = 2*dpr; + ctx.strokeRect(sx,sy, sx2-sx, sy2-sy); + const label = `${ex.to || "?"}${ex.spawn_id ? " → " + ex.spawn_id : ""}`; + ctx.fillStyle = "rgba(229,231,235,0.9)"; + ctx.font = `${12*dpr}px system-ui`; + ctx.fillText(label, sx+6*dpr, sy-6*dpr); + } else if(it.kind === "enemy"){ + const e = it.e; + const typeId = (e.type || "enemy"); + const def = state.enemyTypes && state.enemyTypes[typeId] ? state.enemyTypes[typeId] : null; + const w = def && Array.isArray(def.rect) ? (+def.rect[0]||0) : 0; + const h = def && Array.isArray(def.rect) ? (+def.rect[1]||0) : 0; + if(w>0 && h>0){ + const hx = e.x - w/2, hy = e.y - h/2; + const [sx1,sy1] = worldToScreen(hx, hy, canvas); + const [sx2,sy2] = worldToScreen(hx+w, hy+h, canvas); + ctx.fillStyle = "rgba(239,68,68,0.12)"; + ctx.fillRect(sx1,sy1, sx2-sx1, sy2-sy1); + ctx.strokeStyle = "rgba(239,68,68,0.35)"; + ctx.lineWidth = 1.5*dpr; + ctx.strokeRect(sx1,sy1, sx2-sx1, sy2-sy1); + } + if(state.showAggro && def && typeof def.detection_range === "number"){ + const r = +def.detection_range || 0; + if(r>0){ + const [sx,sy] = worldToScreen(e.x, e.y, canvas); + ctx.strokeStyle = "rgba(239,68,68,0.18)"; + ctx.lineWidth = 2*dpr; + ctx.setLineDash([6*dpr, 6*dpr]); + ctx.beginPath(); + ctx.arc(sx,sy, r*state[canvas===els.canvasA?"viewA":"viewB"].zoom*dpr, 0, Math.PI*2); + ctx.stroke(); + ctx.setLineDash([]); + } + } + const [sx,sy] = worldToScreen(e.x, e.y, canvas); + ctx.fillStyle = "rgba(239,68,68,0.95)"; + ctx.fillRect(sx-5*dpr, sy-5*dpr, 10*dpr, 10*dpr); + ctx.fillStyle = "rgba(229,231,235,0.9)"; + ctx.font = `${11*dpr}px system-ui`; + ctx.fillText(typeId, sx+10*dpr, sy+4*dpr); + } else if(it.kind === "npc"){ + const n = it.n; + const ntype = n.type || "vendor"; + const st = NPC_STYLES[ntype] || { body: "rgba(150,150,160,0.95)", border: "rgba(110,110,120,0.9)" }; + const [sx1,sy1] = worldToScreen(n.x, n.y, canvas); + const [sx2,sy2] = worldToScreen(n.x+36, n.y+54, canvas); + ctx.fillStyle = st.body; + ctx.fillRect(sx1, sy1, sx2-sx1, sy2-sy1); + ctx.strokeStyle = st.border; + ctx.lineWidth = 2*dpr; + ctx.strokeRect(sx1, sy1, sx2-sx1, sy2-sy1); + ctx.fillStyle = "rgba(229,231,235,0.9)"; + ctx.font = `${11*dpr}px system-ui`; + ctx.fillText(ntype, sx1 + 10*dpr, sy1 - 4*dpr); + } } // grid overlay (always covers the full canvas) @@ -1308,111 +1647,9 @@ Cancel = Choose whether to remove without saving`); ctx.fillText(k, sx+10*dpr, sy+4*dpr); } - // enemy spawns - for(let i=0;i0 && h>0){ - const hx = e.x - w/2; - const hy = e.y - h/2; - const [sx1,sy1] = worldToScreen(hx, hy, canvas); - const [sx2,sy2] = worldToScreen(hx+w, hy+h, canvas); - ctx.fillStyle = "rgba(239,68,68,0.12)"; - ctx.fillRect(sx1,sy1, sx2-sx1, sy2-sy1); - ctx.strokeStyle = "rgba(239,68,68,0.35)"; - ctx.lineWidth = 1.5*dpr; - ctx.strokeRect(sx1,sy1, sx2-sx1, sy2-sy1); - } - - // Optional aggro range preview - if(state.showAggro && def && typeof def.detection_range === "number"){ - const r = +def.detection_range || 0; - if(r>0){ - const [sx,sy] = worldToScreen(e.x, e.y, canvas); - ctx.strokeStyle = "rgba(239,68,68,0.18)"; - ctx.lineWidth = 2*dpr; - ctx.setLineDash([6*dpr, 6*dpr]); - ctx.beginPath(); - ctx.arc(sx,sy, r*state[canvas===els.canvasA?"viewA":"viewB"].zoom*dpr, 0, Math.PI*2); - ctx.stroke(); - ctx.setLineDash([]); - } - } - - // marker + label - const [sx,sy] = worldToScreen(e.x, e.y, canvas); - ctx.fillStyle = "rgba(239,68,68,0.95)"; - ctx.fillRect(sx-5*dpr, sy-5*dpr, 10*dpr, 10*dpr); - ctx.fillStyle = "rgba(229,231,235,0.9)"; - ctx.font = `${11*dpr}px system-ui`; - ctx.fillText(typeId, sx+10*dpr, sy+4*dpr); - } - - // props (light sources, save points, collectibles, etc.) - const PROP_STYLES = { - light_source: { ring: "rgba(250,204,21,0.25)", fill: "rgba(250,204,21,0.85)", label: "Butterfly" }, - save_point: { ring: "rgba(56,189,248,0.30)", fill: "rgba(56,189,248,0.9)", label: "Lantern" }, - butterfly_jar: { ring: "rgba(168,85,247,0.22)", fill: "rgba(168,85,247,0.9)", label: "Jar" }, - }; - for(let i=0;i<(room.props||[]).length;i++){ - const p = room.props[i]; - if(!p) continue; - const ptype = p.type || "light_source"; - const st = PROP_STYLES[ptype] || { ring: "rgba(148,163,184,0.25)", fill: "rgba(148,163,184,0.9)", label: ptype }; - const [sx,sy] = worldToScreen(p.x, p.y, canvas); - const v = getViewForCanvas(canvas); - const r = Math.max(20, +p.radius || getDefaultRadiusForType(ptype, p.object_id || p.objectId || null)); - - // radius ring (only for types that actually use it in-game) - if(ptype === "light_source" || ptype === "save_point"){ - const rPx = r * (v.zoom || 1) * dpr; - ctx.strokeStyle = st.ring; - ctx.lineWidth = 2*dpr; - ctx.beginPath(); ctx.arc(sx, sy, rPx, 0, Math.PI*2); ctx.stroke(); - } - - // marker - ctx.fillStyle = st.fill; - if(ptype === "save_point"){ - ctx.beginPath(); - ctx.moveTo(sx, sy-7*dpr); - ctx.lineTo(sx+7*dpr, sy); - ctx.lineTo(sx, sy+7*dpr); - ctx.lineTo(sx-7*dpr, sy); - ctx.closePath(); - ctx.fill(); - } else if(ptype === "butterfly_jar"){ - ctx.fillRect(sx-6*dpr, sy-6*dpr, 12*dpr, 12*dpr); - } else { - ctx.beginPath(); ctx.arc(sx, sy, 6*dpr, 0, Math.PI*2); ctx.fill(); - } - - ctx.fillStyle = "rgba(229,231,235,0.9)"; - ctx.font = `${11*dpr}px system-ui`; - ctx.fillText(st.label, sx+10*dpr, sy+4*dpr); - } - - // exits - for(let i=0;icompare-room link lines on the given canvas, converting + // both endpoints into this canvas's local device coordinates (canvas clips the rest). + function drawLinkLinesForCanvas(ctx, canvas, isCompare){ + if(!els.showLinksChk || !els.showLinksChk.checked) return; + if(!state.compareEnabled) return; + const srcWrap = getActiveRoom(); + const dstWrap = getCompareRoom(); + if(!srcWrap || !dstWrap || srcWrap === dstWrap) return; + + const dpr = window.devicePixelRatio || 1; + const aRect = els.canvasA.getBoundingClientRect(); + const bRect = els.canvasB.getBoundingClientRect(); + const thisRect = (canvas === els.canvasA) ? aRect : bRect; + const thisLeft = thisRect.left * dpr, thisTop = thisRect.top * dpr; + + const exits = srcWrap.data.exits || []; + for(let i=0;i= 2){ + pos = ex.spawn; + } + } + if(!pos) continue; + + // viewport device coords of both endpoints + const [sx,sy] = worldToScreen(ex.x + ex.w/2, ex.y + ex.h/2, els.canvasA); + const [tx,ty] = worldToScreen(pos[0], pos[1], els.canvasB); + const p1x = aRect.left*dpr + sx, p1y = aRect.top*dpr + sy; + const p2x = bRect.left*dpr + tx, p2y = bRect.top*dpr + ty; + + // to this canvas's local device coords + const lx1 = p1x - thisLeft, ly1 = p1y - thisTop; + const lx2 = p2x - thisLeft, ly2 = p2y - thisTop; + + ctx.save(); + ctx.strokeStyle = "rgba(167,139,250,0.9)"; + ctx.lineWidth = 2*dpr; + ctx.setLineDash([6*dpr, 6*dpr]); + ctx.beginPath(); + ctx.moveTo(lx1, ly1); + ctx.lineTo(lx2, ly2); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } } function render(){ @@ -1536,7 +1858,6 @@ Cancel = Choose whether to remove without saving`); if(state.compareEnabled){ drawRoom(ctxB, els.canvasB, getCompareRoom(), els.labelB, true); } - updateLinkOverlay(); requestAnimationFrame(render); } @@ -1548,177 +1869,13 @@ Cancel = Choose whether to remove without saving`); - // ---------- Link Overlay (SVG across both canvases) ---------- - function clearLinkOverlay(){ - if(!els.linkOverlay) return; - els.linkOverlay.innerHTML = ""; - } - function updateLinkOverlay(){ - if(!els.linkOverlay || !els.showLinksChk) return; - if(!els.showLinksChk.checked){ clearLinkOverlay(); return; } - if(!state.compareEnabled){ clearLinkOverlay(); return; } - - const srcWrap = getActiveRoom(); - const dstWrap = getCompareRoom(); - if(!srcWrap || !dstWrap){ clearLinkOverlay(); return; } - - const rowRect = els.canvasRow.getBoundingClientRect(); - const aRect = els.canvasA.getBoundingClientRect(); - const bRect = els.canvasB.getBoundingClientRect(); - - const w = Math.max(1, Math.round(rowRect.width)); - const h = Math.max(1, Math.round(rowRect.height)); - els.linkOverlay.setAttribute("viewBox", `0 0 ${w} ${h}`); - els.linkOverlay.setAttribute("width", w); - els.linkOverlay.setAttribute("height", h); - - // clear and redraw - els.linkOverlay.innerHTML = ""; - - // draw only links from active room exits that point to the compare room - const exits = (srcWrap.data.exits || []); - for(let i=0;i screen in A - const sxw = ex.x + ex.w/2; - const syw = ex.y + ex.h/2; - const [sx,sy] = worldToScreen(sxw, syw, els.canvasA); - - // target spawn -> screen in B - const [tx,ty] = worldToScreen(pos[0], pos[1], els.canvasB); - - const x1 = (aRect.left - rowRect.left) + sx; - const y1 = (aRect.top - rowRect.top) + sy; - const x2 = (bRect.left - rowRect.left) + tx; - const y2 = (bRect.top - rowRect.top) + ty; - - const line = document.createElementNS("http://www.w3.org/2000/svg","line"); - line.setAttribute("x1", x1); - line.setAttribute("y1", y1); - line.setAttribute("x2", x2); - line.setAttribute("y2", y2); - line.setAttribute("stroke", "rgba(167,139,250,0.85)"); - line.setAttribute("stroke-width", "2"); - line.setAttribute("stroke-dasharray", "6 6"); - els.linkOverlay.appendChild(line); - } - } // ---------- Hit Testing ---------- function hitTest(roomWrap, wx, wy, canvas){ if(!roomWrap) return null; const room = roomWrap.data; - // exits first (so door selection is easy) - const v0 = canvas ? getViewForCanvas(canvas) : {zoom:1}; - const tolExit = (8 / (v0.zoom||1)); // ~8px in world-units - const tol2Exit = tolExit*tolExit; - for(let i=room.exits.length-1;i>=0;i--){ - const ex = room.exits[i]; - if(wx>=ex.x && wx<=ex.x+ex.w && wy>=ex.y && wy<=ex.y+ex.h){ - // handle detection (corners/edges) for exits - const x0=ex.x, x1=ex.x+ex.w, y0=ex.y, y1=ex.y+ex.h; - const xm=(x0+x1)/2, ym=(y0+y1)/2; - const handles = [ - [x0,y0,'nw'], [xm,y0,'n'], [x1,y0,'ne'], - [x1,ym,'e'], [x1,y1,'se'], [xm,y1,'s'], - [x0,y1,'sw'], [x0,ym,'w'] - ]; - const order = ['nw','ne','se','sw','n','e','s','w']; - let edge = null; - for(const tag of order){ - const h = handles.find(h=>h[2]===tag); - const dx=wx-h[0], dy=wy-h[1]; - const d2=dx*dx+dy*dy; - if(d2<=tol2Exit){ edge = tag; break; } - } - return {kind:"exit", index:i, roomId: roomWrap.id, edge}; - } - } - - // ability doors - for(let i=(room.ability_doors||[]).length-1;i>=0;i--){ - const d = room.ability_doors[i]; - if(wx>=d.x && wx<=d.x+d.w && wy>=d.y && wy<=d.y+d.h){ - const x0=d.x, x1=d.x+d.w, y0=d.y, y1=d.y+d.h; - const xm=(x0+x1)/2, ym=(y0+y1)/2; - const handles = [ - [x0,y0,'nw'], [xm,y0,'n'], [x1,y0,'ne'], - [x1,ym,'e'], [x1,y1,'se'], [xm,y1,'s'], - [x0,y1,'sw'], [x0,ym,'w'] - ]; - const order = ['nw','ne','se','sw','n','e','s','w']; - let edge = null; - for(const tag of order){ - const h = handles.find(h=>h[2]===tag); - const dx=wx-h[0], dy=wy-h[1]; - const d2=dx*dx+dy*dy; - if(d2<=tol2Exit){ edge = tag; break; } - } - return {kind:"ability_door", index:i, roomId: roomWrap.id, edge}; - } - } - - // props (light sources) - for(let i=(room.props||[]).length-1;i>=0;i--){ - const p = room.props[i]; - if(!p) continue; - const dx = wx - p.x; - const dy = wy - p.y; - if((dx*dx + dy*dy) <= 100){ - return {kind:"prop", index:i, roomId: roomWrap.id}; - } - } - - // platforms (with edge detection for resize) - const v = canvas ? getViewForCanvas(canvas) : {zoom:1}; - const tol = (8 / (v.zoom||1)); // ~8px in world-units - for(let i=room.platforms.length-1;i>=0;i--){ - const p=room.platforms[i]; - if(wx>=p.x && wx<=p.x+p.w && wy>=p.y && wy<=p.y+p.h){ - // handle detection (corners/edges) - const x0=p.x, x1=p.x+p.w, y0=p.y, y1=p.y+p.h; - const xm=(x0+x1)/2, ym=(y0+y1)/2; - const tol2 = tol*tol; - const handles = [ - [x0,y0,'nw'], [xm,y0,'n'], [x1,y0,'ne'], - [x1,ym,'e'], [x1,y1,'se'], [xm,y1,'s'], - [x0,y1,'sw'], [x0,ym,'w'] - ]; - let edge = null; - // prefer corners first - const cornerOrder = ['nw','ne','se','sw','n','e','s','w']; - let best = null; - for(const tag of cornerOrder){ - const h = handles.find(h=>h[2]===tag); - const dx=wx-h[0], dy=wy-h[1]; - const d2=dx*dx+dy*dy; - if(d2<=tol2){ best = {tag, d2}; break; } - } - if(best) edge = best.tag; - return {kind:"platform", index:i, roomId: roomWrap.id, edge}; - } - } - - // enemies - for(let i=room.enemies.length-1;i>=0;i--){ - const e=room.enemies[i]; - if(Math.abs(wx-e.x)<10 && Math.abs(wy-e.y)<10){ - return {kind:"enemy", index:i, roomId: roomWrap.id}; - } - } + // exits, ability doors and enemies are resolved in the layerable pass below (layer-aware) // player spawn { const [px,py]=room.player_spawn; @@ -1734,6 +1891,74 @@ Cancel = Choose whether to remove without saving`); return {kind:"entry_spawn", key:k, roomId: roomWrap.id}; } } + + // layerable objects (props, platforms, doors, exits, enemies, NPCs): pick the + // TOPMOST by layer, so an object in a higher layer wins over one behind it. + const vL = canvas ? getViewForCanvas(canvas) : {zoom:1}; + const tolL = (8 / (vL.zoom||1)); + const tol2L = tolL*tolL; + let best = null; // {key, kind, index, edge?} + const consider = (key, kind, index, edge)=>{ + if(!best || key > best.key){ + best = {key, kind, index, roomId: roomWrap.id, edge}; + } + }; + // props (category 0): house/diary/player use their shape, others a point marker + for(let i=(room.props||[]).length-1;i>=0;i--){ + const p = room.props[i]; + if(!p) continue; + const hr = getPropHitRect(p); + if(hr){ + if(wx>=hr.x && wx<=hr.x+hr.w && wy>=hr.y && wy<=hr.y+hr.h){ + consider((p.layer||0)*10 + 0, "prop", i, null); + } + } else { + const dx = wx - p.x, dy = wy - p.y; + if((dx*dx + dy*dy) <= 100){ + consider((p.layer||0)*10 + 0, "prop", i, null); + } + } + } + // platforms (category 1) with edge detection for resize + for(let i=room.platforms.length-1;i>=0;i--){ + const p=room.platforms[i]; + if(wx>=p.x && wx<=p.x+p.w && wy>=p.y && wy<=p.y+p.h){ + const edge = getResizeEdge(p, wx, wy, tol2L); + consider((p.layer||0)*10 + 1, "platform", i, edge); + } + } + // ability doors (category 2) with edge detection + for(let i=(room.ability_doors||[]).length-1;i>=0;i--){ + const d=room.ability_doors[i]; + if(d && wx>=d.x && wx<=d.x+d.w && wy>=d.y && wy<=d.y+d.h){ + const edge = getResizeEdge(d, wx, wy, tol2L); + consider((d.layer||0)*10 + 2, "ability_door", i, edge); + } + } + // exits (category 3) with edge detection + for(let i=(room.exits||[]).length-1;i>=0;i--){ + const ex=room.exits[i]; + if(ex && wx>=ex.x && wx<=ex.x+ex.w && wy>=ex.y && wy<=ex.y+ex.h){ + const edge = getResizeEdge(ex, wx, wy, tol2L); + consider((ex.layer||0)*10 + 3, "exit", i, edge); + } + } + // enemies (category 4) + for(let i=(room.enemies||[]).length-1;i>=0;i--){ + const e=room.enemies[i]; + if(e && Math.abs(wx-e.x)<10 && Math.abs(wy-e.y)<10){ + consider((e.layer||0)*10 + 4, "enemy", i, null); + } + } + // npcs (category 5) + for(let i=(room.npcs||[]).length-1;i>=0;i--){ + const n=room.npcs[i]; + if(!n) continue; + if(wx>=n.x && wx<=n.x+36 && wy>=n.y && wy<=n.y+54){ + consider((n.layer||0)*10 + 5, "npc", i, null); + } + } + if(best) return best; return null; } @@ -1741,8 +1966,10 @@ Cancel = Choose whether to remove without saving`); function setTool(tool){ state.tool = tool; document.querySelectorAll(".toolBtn").forEach(b=>{ - b.classList.toggle("primary", b.dataset.tool===tool && tool!=="select"); - b.classList.toggle("warn", b.dataset.tool===tool && tool==="select"); + const isActive = b.dataset.tool === tool; + b.classList.toggle("primary", isActive && tool!=="select"); + b.classList.toggle("warn", isActive && tool==="select"); + b.classList.toggle("active", isActive); }); setStatus(`Tool: ${tool}`); } @@ -1769,6 +1996,7 @@ Cancel = Choose whether to remove without saving`);
y
w
h
+
layer (lower = back)
@@ -1786,6 +2014,7 @@ Cancel = Choose whether to remove without saving`);
y
w
h
+
layer (lower = back)
@@ -1809,6 +2038,7 @@ Cancel = Choose whether to remove without saving`);
y
w
h
+
layer (lower = back)
@@ -1829,6 +2059,33 @@ Cancel = Choose whether to remove without saving`);
type
+
+
layer (lower = back)
+ +
+
+ +
+ `; + } + if(sel.kind==="npc"){ + const n=room.npcs && room.npcs[sel.index]; + if(!n) return "
Selection missing.
"; + return ` +
NPC #${sel.index}
+
+
+
x
+
y
+
+
+
type
+ +
+
+
layer (lower = back)
+ +
@@ -1839,7 +2096,7 @@ Cancel = Choose whether to remove without saving`); if(!p) return "
Selection missing.
"; const prDefault = getDefaultRadiusForType(p.type || "light_source", p.object_id || p.objectId || null); const objId = p.object_id || p.objectId || ""; - const hint = ({light_source:"Butterfly (charge regen 2x)", save_point:"Lantern (save point)", butterfly_jar:"Butterfly-in-jar (collectible)"})[p.type||"light_source"] || ""; + 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"] || ""; return `
Prop #${sel.index}${hint ? ` · ${hint}` : ""}
@@ -1859,6 +2116,10 @@ Cancel = Choose whether to remove without saving`);
radius
+
+
layer (lower = back; houses use it)
+ +
@@ -1926,6 +2187,7 @@ Cancel = Choose whether to remove without saving`); if(id==="ph") p.h = Math.max(1,num(el.value)); })); }); + const pl=q("pl"); if(pl) pl.addEventListener("change", ()=>pushAnd(()=>{ p.layer = Math.round(num(pl.value)); })); const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{ room.platforms.splice(sel.index,1); state.selection=null; @@ -1943,6 +2205,7 @@ 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 del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{ room.exits.splice(sel.index,1); state.selection=null; @@ -1958,25 +2221,39 @@ Cancel = Choose whether to remove without saving`); if(id==="adh") d.h = Math.max(1,num(el.value)); })); }); + const adlayer=q("adlayer"); if(adlayer) adlayer.addEventListener("change", ()=>pushAnd(()=>{ d.layer = Math.round(num(adlayer.value)); })); const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{ room.ability_doors.splice(sel.index,1); state.selection=null; })); } else if(sel.kind==="enemy"){ const e = room.enemies[sel.index]; if(!e) return; - const enx=q("enx"), eny=q("eny"), ent=q("ent"); + const enx=q("enx"), eny=q("eny"), ent=q("ent"), enlayer=q("enlayer"); 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)); })); const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{ room.enemies.splice(sel.index,1); state.selection=null; })); + } else if(sel.kind==="npc"){ + const n = room.npcs && room.npcs[sel.index]; if(!n) return; + const npx=q("npx"), npy=q("npy"), npt=q("npt"), npl=q("npl"); + if(npx) npx.addEventListener("change", ()=>pushAnd(()=>{ n.x=num(npx.value); })); + if(npy) npy.addEventListener("change", ()=>pushAnd(()=>{ n.y=num(npy.value); })); + if(npt) npt.addEventListener("change", ()=>pushAnd(()=>{ n.type=npt.value.trim()||"vendor"; })); + if(npl) npl.addEventListener("change", ()=>pushAnd(()=>{ n.layer = Math.round(num(npl.value)); })); + const del=q("delSel"); if(del) del.addEventListener("click", ()=>pushAnd(()=>{ + room.npcs.splice(sel.index,1); + state.selection=null; + })); } 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"); + const prx=q("prx"), pry=q("pry"), prtype=q("prtype"), probjid=q("probjid"), prrad=q("prrad"), prlayer=q("prlayer"); 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(probjid) probjid.addEventListener("change", ()=>pushAnd(()=>{ const v = probjid.value.trim(); if(v){ p.object_id = v; } else { delete p.object_id; } @@ -2145,6 +2422,16 @@ Cancel = Choose whether to remove without saving`); return; } + if(state.tool === "npc_spawn"){ + pushHistory(roomWrap.id); + const [sx1,sy1]=snapPt(wx,wy); + room.npcs = room.npcs || []; + room.npcs.push({x: sx1, y: sy1, type: (state.npcPlaceType || "vendor")}); + state.selection = {kind:"npc", index:room.npcs.length-1, roomId: roomWrap.id}; + refreshUI(); + return; + } + if(state.tool === "player_spawn"){ pushHistory(roomWrap.id); room.player_spawn = snapPt(wx,wy); @@ -2276,6 +2563,8 @@ Cancel = Choose whether to remove without saving`); const d=room.ability_doors && room.ability_doors[sel.index]; if(d) base.ad = {x:d.x,y:d.y}; } else if(sel.kind==="enemy"){ const e=room.enemies[sel.index]; if(e) base.e = {x:e.x,y:e.y}; + } else if(sel.kind==="npc"){ + const n=room.npcs && room.npcs[sel.index]; if(n) base.n = {x:n.x,y:n.y}; } else if(sel.kind==="prop"){ const p=room.props && room.props[sel.index]; if(p) base.pr = {x:p.x,y:p.y}; } else if(sel.kind==="player_spawn"){ @@ -2315,6 +2604,12 @@ Cancel = Choose whether to remove without saving`); e.x = snap(state.dragging.base.e.x + offX); e.y = snap(state.dragging.base.e.y + offY); } + } else if(sel.kind==="npc"){ + const n=room.npcs && room.npcs[sel.index]; + if(n && state.dragging.base.n){ + n.x = snap(state.dragging.base.n.x + offX); + n.y = snap(state.dragging.base.n.y + offY); + } } else if(sel.kind==="prop"){ const p=room.props && room.props[sel.index]; if(p && state.dragging.base.pr){ @@ -2562,6 +2857,7 @@ Cancel = Choose whether to remove without saving`); if(sel.kind==="exit") return {kind:"exit", data: deepClone(room.exits[sel.index])}; if(sel.kind==="ability_door") return {kind:"ability_door", data: deepClone(room.ability_doors[sel.index])}; if(sel.kind==="enemy") return {kind:"enemy", data: deepClone(room.enemies[sel.index])}; + if(sel.kind==="npc") return {kind:"npc", data: deepClone(room.npcs[sel.index])}; if(sel.kind==="prop") return {kind:"prop", data: deepClone(room.props[sel.index])}; if(sel.kind==="entry_spawn"){ const pos = room.entry_spawns[sel.key]; @@ -2588,6 +2884,7 @@ Cancel = Choose whether to remove without saving`); else if(sel.kind==="exit") room.exits.splice(sel.index,1); else if(sel.kind==="ability_door") room.ability_doors.splice(sel.index,1); else if(sel.kind==="enemy") room.enemies.splice(sel.index,1); + else if(sel.kind==="npc") room.npcs.splice(sel.index,1); else if(sel.kind==="prop") room.props.splice(sel.index,1); else if(sel.kind==="entry_spawn") delete room.entry_spawns[sel.key]; state.selection=null; @@ -2618,6 +2915,11 @@ Cancel = Choose whether to remove without saving`); c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset); room.enemies.push(c.data); state.selection = {kind:"enemy", index: room.enemies.length-1, roomId: roomWrap.id}; + } else if(c.kind==="npc"){ + c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset); + room.npcs = room.npcs || []; + room.npcs.push(c.data); + state.selection = {kind:"npc", index: room.npcs.length-1, roomId: roomWrap.id}; } else if(c.kind==="prop"){ c.data.x = snap(c.data.x + offset); c.data.y = snap(c.data.y + offset); (room.props || (room.props = [])).push(c.data); @@ -2649,6 +2951,7 @@ Cancel = Choose whether to remove without saving`); else if(sel.kind==="exit") room.exits.splice(sel.index,1); else if(sel.kind==="ability_door") room.ability_doors.splice(sel.index,1); else if(sel.kind==="enemy") room.enemies.splice(sel.index,1); + else if(sel.kind==="npc") room.npcs.splice(sel.index,1); else if(sel.kind==="prop") room.props.splice(sel.index,1); else if(sel.kind==="entry_spawn") delete room.entry_spawns[sel.key]; else if(sel.kind==="player_spawn") room.player_spawn = [100,300]; @@ -2697,18 +3000,31 @@ Cancel = Choose whether to remove without saving`); const fmt = d._format || "editor_v1"; if(fmt === "game_v1"){ - // rebuild grouped platforms + // rebuild grouped platforms (layer appended as 5th element when non-zero) const platforms = {}; for(const p of (d.platforms || [])){ const group = p.group || "ground"; if(!platforms[group]) platforms[group] = []; - platforms[group].push([p.x, p.y, p.w, p.h]); + 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]} - const enemies = (d.enemies || []).map(e => ({ type: e.type || "enemy", pos: [e.x, e.y] })); + // enemies back to {type, pos:[x,y]} (+ layer when set) + const enemies = (d.enemies || []).map(e => { + const out = { type: e.type || "enemy", pos: [e.x, e.y] }; + if(e.layer) out.layer = e.layer; + return out; + }); - // exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint} + // npcs back to {type, pos:[x,y]} (+ layer when set) + const npcs = (d.npcs || []).map(n => { + const out = { type: n.type || "vendor", pos: [n.x, n.y] }; + if(n.layer) out.layer = n.layer; + return out; + }); + + // exits back to {rect:[x,y,w,h], to, spawn_id?, spawn:[x,y], hint} (+ layer when set) const exits = (d.exits || []).map(ex => { const out = { rect: [ex.x, ex.y, ex.w, ex.h], @@ -2717,12 +3033,16 @@ Cancel = Choose whether to remove without saving`); hint: ex.hint || "" }; if(ex.spawn_id) out.spawn_id = ex.spawn_id; + if(ex.layer) out.layer = ex.layer; return out; }); - const ability_doors = (d.ability_doors || []).map(ad => ({ - rect: [ad.x, ad.y, ad.w, ad.h] - })); + // ability doors back to {rect:[x,y,w,h]} (+ layer when set) + const ability_doors = (d.ability_doors || []).map(ad => { + const out = { rect: [ad.x, ad.y, ad.w, ad.h] }; + if(ad.layer) out.layer = ad.layer; + return out; + }); const props = (d.props || []).map(p => { const out = { @@ -2731,6 +3051,7 @@ Cancel = Choose whether to remove without saving`); radius: p.radius || 180 }; if(p.object_id) out.object_id = p.object_id; + if(p.layer) out.layer = p.layer; return out; }); @@ -2744,6 +3065,7 @@ Cancel = Choose whether to remove without saving`); bounds: d.bounds && typeof d.bounds === "object" ? d.bounds : {}, platforms, enemies, + npcs, exits, ability_doors }; @@ -2949,6 +3271,14 @@ Cancel = Choose whether to remove without saving`); }); } + if(els.npcTypeSelect){ + els.npcTypeSelect.value = state.npcPlaceType || "vendor"; + els.npcTypeSelect.addEventListener("change", ()=>{ + state.npcPlaceType = els.npcTypeSelect.value || "vendor"; + setStatus(`NPC placement type: ${state.npcPlaceType}`); + }); + } + function refreshObjectSelect(){ if(!els.objectSelect) return; const sel = els.objectSelect; @@ -2987,6 +3317,14 @@ Cancel = Choose whether to remove without saving`); }); } + if(els.snapChk){ + els.snapChk.checked = !!state.snapEnabled; + els.snapChk.addEventListener("change", ()=>{ + state.snapEnabled = !!els.snapChk.checked; + setStatus(state.snapEnabled ? "Grid snap ON" : "Grid snap OFF (free movement)"); + }); + } + if(els.linkTargetRoom){ diff --git a/light_and_dark/main.py b/light_and_dark/main.py index c7b5fd8..8b41923 100644 --- a/light_and_dark/main.py +++ b/light_and_dark/main.py @@ -868,15 +868,27 @@ ROOM_DEFAULT = { } -def rects_from_list(list_of_rects) -> list[pygame.Rect]: +def rects_from_list(list_of_rects, layer_map: dict[int, int] | None = None) -> list[pygame.Rect]: out = [] if not isinstance(list_of_rects, list): return out for r in list_of_rects: - if isinstance(r, (list, tuple)) and len(r) == 4: - out.append(pygame.Rect(int(r[0]), int(r[1]), int(r[2]), int(r[3]))) + if isinstance(r, (list, tuple)) and len(r) >= 4: + rect = pygame.Rect(int(r[0]), int(r[1]), int(r[2]), int(r[3])) + out.append(rect) + if layer_map is not None and len(r) >= 5: + try: + layer_map[id(rect)] = int(r[4]) + except (TypeError, ValueError): + pass elif isinstance(r, dict): - out.append(pygame.Rect(int(r.get("x", 0)), int(r.get("y", 0)), int(r.get("w", 0)), int(r.get("h", 0)))) + rect = pygame.Rect(int(r.get("x", 0)), int(r.get("y", 0)), int(r.get("w", 0)), int(r.get("h", 0))) + out.append(rect) + if layer_map is not None: + try: + layer_map[id(rect)] = int(r.get("layer", 0)) + except (TypeError, ValueError): + pass return out @@ -985,13 +997,14 @@ class Room: self.kill_y = int((room_json.get("bounds", {}) or {}).get("kill_y", 1400)) + self.platform_layer: dict[int, int] = {} platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {} if isinstance(platforms, list): - self.platforms_ground = rects_from_list(platforms) + self.platforms_ground = rects_from_list(platforms, self.platform_layer) self.platforms_floating = [] elif isinstance(platforms, dict): - self.platforms_ground = rects_from_list(platforms.get("ground", [])) - self.platforms_floating = rects_from_list(platforms.get("floating", [])) + self.platforms_ground = rects_from_list(platforms.get("ground", []), self.platform_layer) + self.platforms_floating = rects_from_list(platforms.get("floating", []), self.platform_layer) else: self.platforms_ground = [] self.platforms_floating = [] @@ -1036,7 +1049,13 @@ class Room: rect = list(d) if rect is None: continue - normalized_ability_doors.append({"rect": rect}) + nd = {"rect": rect} + if isinstance(d, dict): + try: + nd["layer"] = int(d.get("layer", 0)) + except (TypeError, ValueError): + nd["layer"] = 0 + normalized_ability_doors.append(nd) self.ability_doors = normalized_ability_doors self.exit_triggers: list[dict] = [] @@ -1057,6 +1076,7 @@ class Room: "hint": str(ex.get("hint", "")), "requires_boss_dead": bool(ex.get("requires_boss_dead", False)), "requires_ranged": bool(ex.get("requires_ranged", False)), + "layer": int(ex.get("layer", 0)), }) @@ -1553,7 +1573,7 @@ class LightSource: class SavePoint: - """Lantern save point: auto-saves and becomes the respawn point when touched.""" + """Lantern save point: pressing interact nearby saves the game and sets the respawn point.""" def __init__(self, x: int, y: int, radius: int = 90): self.x = int(x) self.y = int(y) @@ -1568,13 +1588,6 @@ class SavePoint: def draw(self, surface, cx, cy): self.t += 0.03 - pulse = 0.10 + 0.05 * (0.5 + 0.5 * math.sin(self.t)) - r = self.radius - overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) - alpha = int(255 * pulse) - pygame.draw.circle(overlay, (150, 220, 255, alpha), (r, r), r, 2) - surface.blit(overlay, (self.x - r - cx, self.y - r - cy)) - if self.img is not None: surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy)) else: @@ -1808,6 +1821,7 @@ def spawn_ability_doors(room) -> list[AbilityDoor]: rect = pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0]))) door = AbilityDoor(rect, room_id=room.id) door.open = is_door_broken(room.id, rect) + door.layer = int(d.get("layer", 0)) out.append(door) return out @@ -1862,13 +1876,14 @@ class AbilityPickup: class NPC: def __init__(self, x, y, npc_type: str = "vendor", trade_cost: int | None = None, offers=None, greet_light: str | None = None, greet_shade: str | None = None, - greet_hint: str | None = None): + greet_hint: str | None = None, layer: int = 0): self.type = npc_type self.trade_cost = int(trade_cost) if trade_cost is not None else None self.offers = offers if isinstance(offers, list) else [] self.greet_light = greet_light self.greet_shade = greet_shade self.greet_hint = greet_hint + self.layer = int(layer) self.rect = pygame.Rect(int(x), int(y), 36, 54) def draw(self, surface, cx, cy): @@ -4597,7 +4612,9 @@ def spawn_enemies_from_room(room: Room): w, h = cfg.get("rect", [40, 60]) x0 = int(pos[0] - (w / 2)) y0 = int(pos[1] - (h / 2)) - out.append(Enemy(x0, y0, t)) + enemy = Enemy(x0, y0, t) + enemy.layer = int(ent.get("layer", 0)) + out.append(enemy) return out @@ -4630,7 +4647,8 @@ def spawn_npcs_from_room(room: Room): greet_hint = _npc(["defaults", t, "greet_hint"], None) out.append(NPC(pos[0], pos[1], t, trade_cost=trade_cost, offers=offers, - greet_light=greet_light, greet_shade=greet_shade, greet_hint=greet_hint)) + greet_light=greet_light, greet_shade=greet_shade, greet_hint=greet_hint, + layer=int(ent.get("layer", 0)))) return out @@ -4759,12 +4777,12 @@ def draw_speech_bubble(surface, text: str, center_x: int, bottom_y: int, hint_te surface.blit(hint, (hint_x, hint_y)) -def draw_npc_house(surface, npc: NPC, cx, cy): - # Simple backdrop house behind the NPC +def draw_house_shape(surface, cx, cy, center_x: int, bottom_y: int): + """Draw the procedural house with its bottom-center anchored at (center_x, bottom_y).""" house_w = 140 house_h = 90 - base_x = int(npc.rect.centerx - house_w // 2 - cx) - base_y = int(npc.rect.bottom - house_h + 6 - cy) + base_x = int(center_x - house_w // 2 - cx) + base_y = int(bottom_y - house_h - cy) # wall pygame.draw.rect(surface, (180, 170, 150), (base_x, base_y + 30, house_w, house_h - 30)) @@ -4787,6 +4805,84 @@ def draw_npc_house(surface, npc: NPC, cx, cy): pygame.draw.polygon(surface, (210, 170, 110), roof_pts) pygame.draw.polygon(surface, (135, 100, 65), roof_pts, 2) + +class HouseObject: + """A standalone house prop placed in a room (bottom-center anchored).""" + def __init__(self, x: int, y: int, layer: int = 0): + self.x = int(x) + self.y = int(y) + self.layer = int(layer) + self.rect = pygame.Rect(int(x) - 70, int(y) - 90, 140, 90) + + def draw(self, surface, cx, cy): + draw_house_shape(surface, cx, cy, self.x, self.y) + + +def draw_layerable(surface, cx, cy): + """Draw all static room objects back-to-front by (layer, category). + + Lower layer = further back. Ties use category order so default rooms keep a + sensible look: props/houses (0) < platforms (1) < ability doors (2) < exits (3) + < enemies (4) < NPCs (5). Any object's layer can be raised/lowered to push it + into the foreground or background. + """ + items = [] + # props / houses + for ls in light_sources: + items.append((getattr(ls, "layer", 0), 0, "light", ls)) + for sp in save_points: + items.append((getattr(sp, "layer", 0), 0, "savepoint", sp)) + for bj in butterfly_jars: + items.append((getattr(bj, "layer", 0), 0, "jar", bj)) + for h in houses: + items.append((getattr(h, "layer", 0), 0, "house", h)) + for qg in quest_givers: + items.append((getattr(qg, "layer", 0), 0, "questgiver", qg)) + if diary_obj is not None: + items.append((getattr(diary_obj, "layer", 0), 0, "diary", diary_obj)) + if reward_chest is not None: + items.append((getattr(reward_chest, "layer", 0), 0, "rewardchest", reward_chest)) + if reward_ability is not None: + items.append((getattr(reward_ability, "layer", 0), 0, "rewardability", reward_ability)) + # platforms + for p in platforms: + items.append((platform_layer.get(id(p), 0), 1, "platform", p)) + # ability doors + for d in ability_doors: + items.append((getattr(d, "layer", 0), 2, "door", d)) + # exits (respect locked-exit hiding) + for ex in ROOM.exit_triggers: + if ex.get("requires_boss_dead", False) and boss_alive_in_room(): + continue + if ex.get("requires_ranged", False) and not player.has_ranged: + continue + items.append((ex.get("layer", 0), 3, "exit", ex)) + # enemies + for en in enemies: + items.append((getattr(en, "layer", 0), 4, "enemy", en)) + # npcs + for n in npcs: + items.append((getattr(n, "layer", 0), 5, "npc", n)) + + items.sort(key=lambda it: (it[0], it[1])) + for _z, _cat, kind, obj in items: + if kind in ("light", "savepoint", "jar", "house", "questgiver", "diary", "rewardchest", "rewardability", "door", "enemy", "npc"): + obj.draw(surface, cx, cy) + elif kind == "platform": + pygame.draw.rect( + surface, + PLATFORM_COLOR, + pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height), + ) + elif kind == "exit": + rr = obj["rect"] + pygame.draw.rect( + surface, + (140, 220, 140), + pygame.Rect(rr.x - cx, rr.y - cy, rr.width, rr.height), + 2, + ) + def draw_ability_icon_bg(surface, x: int, y: int, size: int, shade: bool): pad = 4 w = size + pad * 2 @@ -5020,6 +5116,7 @@ platforms_ground = ROOM.platforms_ground platforms_floating = ROOM.platforms_floating ground_platforms = ROOM.ground_platforms platforms = ROOM.platforms +platform_layer = ROOM.platform_layer # ---------------------------- @@ -5046,6 +5143,7 @@ tether_shots: list[TetherShot] = [] light_sources: list[LightSource] = [] save_points: list[SavePoint] = [] butterfly_jars: list[ButterflyJar] = [] +houses: list[HouseObject] = [] dust_puffs: list[DustPuff] = [] class QuestGiver: """Interactable environment object that hands out a quest.""" @@ -5119,6 +5217,7 @@ def spawn_room_props(room: Room, player_obj: "Player"): lights: list[LightSource] = [] save_points: list[SavePoint] = [] butterfly_jars: list[ButterflyJar] = [] + houses: list[HouseObject] = [] quest_givers: list[QuestGiver] = [] diary_obj = None @@ -5145,27 +5244,40 @@ def spawn_room_props(room: Room, player_obj: "Player"): else: coins = REWARD_CHEST_COINS reward_chest_obj = RewardChest(x, y, opened=player_obj.reward_chest_opened, coins=coins) + reward_chest_obj.layer = int(p.get("layer", 0)) elif p_type == "ability_pickup" and reward_ability_obj is None: ability_id = str(p.get("ability", "ranged")) if ability_id == "ranged" and player_obj.has_ranged: continue reward_ability_obj = AbilityPickup(x, y, ability_id=ability_id) + reward_ability_obj.layer = int(p.get("layer", 0)) elif p_type == "light_source": radius = int(p.get("radius", 180)) - lights.append(LightSource(x, y, radius)) + ls = LightSource(x, y, radius) + ls.layer = int(p.get("layer", 0)) + lights.append(ls) elif p_type == "save_point": radius = int(p.get("radius", 90)) - save_points.append(SavePoint(x, y, radius)) + sp = SavePoint(x, y, radius) + sp.layer = int(p.get("layer", 0)) + save_points.append(sp) elif p_type == "butterfly_jar": key = jar_key(room.id, x, y) if key not in COLLECTED_JARS: - butterfly_jars.append(ButterflyJar(x, y, key=key)) + bj = ButterflyJar(x, y, key=key) + bj.layer = int(p.get("layer", 0)) + butterfly_jars.append(bj) elif p_type == "quest_giver": - quest_givers.append(QuestGiver(x, y, str(p.get("quest_id", "")))) + qg = QuestGiver(x, y, str(p.get("quest_id", ""))) + qg.layer = int(p.get("layer", 0)) + quest_givers.append(qg) elif p_type == "diary": diary_obj = DiaryObject(x, y) + diary_obj.layer = int(p.get("layer", 0)) + elif p_type == "house": + houses.append(HouseObject(x, y, layer=int(p.get("layer", 0)))) - return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, quest_givers, diary_obj + return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, houses, quest_givers, diary_obj def rebuild_room_butterfly_jars(): @@ -5186,7 +5298,7 @@ def rebuild_room_butterfly_jars(): butterfly_jars.append(ButterflyJar(x, y, key=key)) -reward_chest, reward_ability, light_sources, save_points, butterfly_jars, quest_givers, diary_obj = spawn_room_props(ROOM, player) +reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player) def grant_full_set(set_id: str, rarity: str, equip: bool = True) -> bool: slots = ["helmet", "chest", "pants"] @@ -5231,9 +5343,10 @@ def grant_five_tempered() -> dict | None: def apply_room(room: Room, set_player_spawn=True): """Updates room-dependent lists + respawns enemies. Keeps inventory/equipment.""" global ROOM, KILL_Y, platforms_ground, platforms_floating, ground_platforms, platforms + global platform_layer global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors global shatter_platforms, shatter_aoes, tether_shots - global light_sources, save_points, butterfly_jars + global light_sources, save_points, butterfly_jars, houses global dust_puffs global dust_puffs global reward_chest, reward_ability, quest_givers, diary_obj @@ -5248,6 +5361,7 @@ def apply_room(room: Room, set_player_spawn=True): platforms_floating = ROOM.platforms_floating ground_platforms = ROOM.ground_platforms platforms = ROOM.platforms + platform_layer = ROOM.platform_layer if is_town_room(ROOM): town_title.trigger(ROOM.id, town_display_name(ROOM)) @@ -5267,6 +5381,7 @@ def apply_room(room: Room, set_player_spawn=True): light_sources = [] save_points = [] butterfly_jars = [] + houses = [] dust_puffs = [] dust_puffs = [] ROOM_DROP_MULT = 1.0 @@ -5278,7 +5393,7 @@ def apply_room(room: Room, set_player_spawn=True): reward_chest = None reward_ability = None diary_obj = None - reward_chest, reward_ability, light_sources, save_points, butterfly_jars, quest_givers, diary_obj = spawn_room_props(ROOM, player) + reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player) if set_player_spawn: player.start_x = int(ROOM.player_spawn[0]) @@ -5845,6 +5960,37 @@ def get_near_diary(player_obj, diary_obj) -> bool: return math.hypot(dx, dy) <= DiaryObject.INTERACT_RANGE +def get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -> SavePoint | None: + """Return the nearest lantern the player can interact with, or None.""" + best = None + best_d = None + px, py = player_obj.rect.midbottom + for sp in save_point_list: + r = range_px if range_px is not None else sp.radius + if r <= 0: + continue + dx = px - sp.x + dy = py - sp.y + d2 = dx * dx + dy * dy + if d2 <= (r * r): + if best is None or d2 < best_d: + best = sp + best_d = d2 + return best + + +def save_at_lantern(sp: SavePoint): + """Set the lantern as the respawn point and save the game.""" + global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS + LANTERN_RESPAWN_ROOM = ROOM.id + LANTERN_RESPAWN_POS = (sp.x, sp.y) + _do_save_game() + pickup_feed.push_center("Lantern - game saved", duration=2.0) + # Butterfly jars lost on death respawn after saving at a lantern in a different room. + if respawn_jars_if_elsewhere(): + pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5) + + def _ellipsize_text(text: str, fnt, max_w: int) -> str: if fnt.size(text)[0] <= max_w: return text @@ -6783,7 +6929,6 @@ pause_menu_index = 0 settings_index = 0 settings_wait_action: str | None = None pause_use_mouse = False -prev_near_save_point = False while running: dt = clock.tick(60) / 1000.0 @@ -6895,7 +7040,10 @@ while running: pickup_feed.push_center(f"Debug: +5 {inst.get('name', 'Item')} (tempered)", duration=3.0) if key_is("interact", e.key) and not inv_ui.visible and not was_trade_visible and not diary_ui.visible: - if get_near_diary(player, diary_obj): + near_lantern = get_near_lantern(player, save_points) + if near_lantern: + save_at_lantern(near_lantern) + elif get_near_diary(player, diary_obj): diary_ui.toggle() else: near_npc = get_near_npc(player, npcs) @@ -6961,16 +7109,9 @@ while running: # draw last frame + pause overlay screen.fill(BG) - # NPC backdrop houses (behind platforms) - for npc in npcs: - if npc.type == "vendor": - draw_npc_house(screen, npc, camera_x, camera_y) + # Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs) + draw_layerable(screen, camera_x, camera_y) - for p in platforms: - pygame.draw.rect( - screen, PLATFORM_COLOR, - pygame.Rect(p.x - camera_x, p.y - camera_y, p.width, p.height) - ) for sp in shatter_platforms: sp.draw(screen, camera_x, camera_y) for aoe in shatter_aoes: @@ -6978,20 +7119,6 @@ while running: for dp in dust_puffs: dp.draw(screen, camera_x, camera_y) - for ex in ROOM.exit_triggers: - if ex.get("requires_boss_dead", False) and boss_alive_in_room(): - continue - if ex.get("requires_ranged", False) and not player.has_ranged: - continue - - rr = ex["rect"] - pygame.draw.rect( - screen, - (140, 220, 140), - pygame.Rect(rr.x - camera_x, rr.y - camera_y, rr.width, rr.height), - 2, - ) - for drop in loot_drops: drop.draw(screen, camera_x, camera_y) for c in coin_drops: @@ -7004,25 +7131,10 @@ while running: pr.draw(screen, camera_x, camera_y) for ts in tether_shots: ts.draw(screen, camera_x, camera_y) - for ls in light_sources: - ls.draw(screen, camera_x, camera_y) - for sp in save_points: - sp.draw(screen, camera_x, camera_y) - for bj in butterfly_jars: - bj.draw(screen, camera_x, camera_y) if corpse is not None and corpse.room_id == ROOM.id: corpse.draw(screen, camera_x, camera_y) player.draw(screen, camera_x, camera_y) - for npc in npcs: - npc.draw(screen, camera_x, camera_y) - for en in enemies: - en.draw(screen, camera_x, camera_y) - - if reward_chest: - reward_chest.draw(screen, camera_x, camera_y) - if reward_ability: - reward_ability.draw(screen, camera_x, camera_y) if VIGNETTE_LIGHT and VIGNETTE_SHADE: if player.shade: @@ -7108,7 +7220,8 @@ while running: player.charge_regen_mult = 2.0 break - # Lantern save point: touching a lantern sets the respawn point + auto-saves. + # Lantern save point: being near a lantern enables the "Press F to save" prompt; + # the actual save + respawn point update happens when the player presses interact. near_save_point = False for sp in save_points: px, py = player.rect.midbottom @@ -7116,16 +7229,7 @@ while running: dy = py - sp.y if (dx * dx + dy * dy) <= (sp.radius * sp.radius): near_save_point = True - LANTERN_RESPAWN_ROOM = ROOM.id - LANTERN_RESPAWN_POS = (sp.x, sp.y) break - if near_save_point and not prev_near_save_point: - _do_save_game() - pickup_feed.push_center("Lantern - game saved", duration=2.0) - # Butterfly jars lost on death respawn after saving at a lantern in a different room. - if respawn_jars_if_elsewhere(): - pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5) - prev_near_save_point = near_save_point collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] player_collision_platforms = collision_platforms + [p.rect for p in shatter_platforms if p.alive] @@ -7403,9 +7507,9 @@ while running: f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})", duration=2.5, ) + butterfly_jars.remove(bj) else: pickup_feed.push_center("You can't carry any more butterfly jars!", duration=2.0) - butterfly_jars.remove(bj) for t in token_drops[:]: if player.rect.colliderect(t.rect): token = make_boss_token(t.boss_id) @@ -7434,23 +7538,9 @@ while running: # ---------------------------- screen.fill(BG) - # NPC backdrop houses (behind platforms) - for npc in npcs: - if npc.type == "vendor": - draw_npc_house(screen, npc, camera_x, camera_y) + # Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs) + draw_layerable(screen, camera_x, camera_y) - for ls in light_sources: - ls.draw(screen, camera_x, camera_y) - for sp in save_points: - sp.draw(screen, camera_x, camera_y) - for bj in butterfly_jars: - bj.draw(screen, camera_x, camera_y) - - for p in platforms: - pygame.draw.rect( - screen, PLATFORM_COLOR, - pygame.Rect(p.x - camera_x, p.y - camera_y, p.width, p.height) - ) for sp in shatter_platforms: sp.draw(screen, camera_x, camera_y) for aoe in shatter_aoes: @@ -7458,26 +7548,6 @@ while running: for dp in dust_puffs: dp.draw(screen, camera_x, camera_y) - # ability doors (ranged-locked) - for d in ability_doors: - d.draw(screen, camera_x, camera_y) - - # exits / doorways - for ex in ROOM.exit_triggers: - # hide locked exits - if ex.get("requires_boss_dead", False) and boss_alive_in_room(): - continue - if ex.get("requires_ranged", False) and not player.has_ranged: - continue - - rr = ex["rect"] - pygame.draw.rect( - screen, - (140, 220, 140), - pygame.Rect(rr.x - camera_x, rr.y - camera_y, rr.width, rr.height), - 2, - ) - # loot for drop in loot_drops: drop.draw(screen, camera_x, camera_y) @@ -7490,21 +7560,6 @@ while running: for t in token_drops: t.draw(screen, camera_x, camera_y) - # reward room props - if reward_chest: - reward_chest.draw(screen, camera_x, camera_y) - if reward_ability: - reward_ability.draw(screen, camera_x, camera_y) - - # quest giver objects - for qg in quest_givers: - qg.draw(screen, camera_x, camera_y) - - # diary object (boss token diary) - if diary_obj: - diary_obj.draw(screen, camera_x, camera_y) - - # shockwaves for sw in shockwaves: sw.draw(screen, camera_x, camera_y) @@ -7516,14 +7571,10 @@ while running: ts.draw(screen, camera_x, camera_y) - # entities - for npc in npcs: - npc.draw(screen, camera_x, camera_y) + # entities (NPCs/enemies are drawn by draw_layerable for layer support) if corpse is not None and corpse.room_id == ROOM.id: corpse.draw(screen, camera_x, camera_y) player.draw(screen, camera_x, camera_y) - for en in enemies: - en.draw(screen, camera_x, camera_y) # NPC greeting bubble (shown near the player) greet_npc = get_near_npc(player, npcs) @@ -7576,6 +7627,17 @@ while running: int(diary_obj.rect.top - camera_y), ) + # Lantern save hint bubble (shown near the player) + if not inv_ui.visible and not trade_ui.visible and not diary_ui.visible: + near_lantern = get_near_lantern(player, save_points) + if near_lantern: + draw_speech_bubble( + screen, + "Press F - Save at lantern", + int(near_lantern.rect.centerx - camera_x), + int(near_lantern.rect.top - camera_y), + ) + # Boss health bar (if boss exists) boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None) if boss is not None: diff --git a/light_and_dark/saves/save 1.json b/light_and_dark/saves/save 1.json index 095e293..cb732a4 100644 --- a/light_and_dark/saves/save 1.json +++ b/light_and_dark/saves/save 1.json @@ -1,16 +1,16 @@ { "version": 1, - "saved_at": "2026-09-29T09:39:49", + "saved_at": "2026-09-29T12:12:10", "player": { "room_id": "room_town_gloomreach.json", - "x": 230, + "x": 1315, "y": 360, "health": 10, "charge": 125.0, "shade": false, "coins_light": 0, "coins_dark": 0, - "butterfly_jars": 0, + "butterfly_jars": 3, "has_ranged": true, "reward_chest_opened": true, "equipment": { @@ -212,13 +212,14 @@ ], "broken_doors": [ "room_07.json:-980,2180,80,600", + "room_07.json:1940,2720,360,20", "room_07.json:4100,2180,40,600", "room_reward_01.json:1580,60,40,300", "room_reward_01.json:1700,60,60,300" ], "lantern_respawn": { "room_id": "room_town_gloomreach.json", - "x": 120, + "x": 1300, "y": 360 } } \ No newline at end of file