diff --git a/light_and_dark/README.md b/light_and_dark/README.md
index 324b9be..2ba5f00 100644
--- a/light_and_dark/README.md
+++ b/light_and_dark/README.md
@@ -30,6 +30,7 @@ Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drop
| Shatterstep | R |
| Tether | X |
| Reality Break | C |
+| Fairy Mode (once unlocked) | Z |
| Toggle Light/Shade | Left Shift |
| Interact / Talk / Save | F |
| Use Butterfly Jar | G |
diff --git a/light_and_dark/ROADMAP.md b/light_and_dark/ROADMAP.md
index 56b4172..34dcdec 100644
--- a/light_and_dark/ROADMAP.md
+++ b/light_and_dark/ROADMAP.md
@@ -51,6 +51,7 @@ Proves the core loop is fun before scaling to the full vision.
| Region-2 boss + ability pacing | M | Re-align pacing (Ranged already exists). |
### Stretch / core (post-slice, in order)
+- Map system (current-room minimap -> world graph -> lantern fast travel) — see below
- Fast travel (rune stones)
- Fairy scout + bond teleport + fairy holes
- Regions 3-5 per pacing notes
@@ -59,6 +60,27 @@ Proves the core loop is fun before scaling to the full vision.
### Cut
- Ascended armor (removed).
+## Map system (deferred — brainstorm, not started)
+
+Feels huge; split into two independent features rather than one.
+
+1. **Current-room minimap** — cheap and self-contained: scale `room_bounds` + platform rects + a player arrow into a corner/overlay panel. No new data, no persistence. Good first slice.
+2. **World map / region graph** — nodes = rooms, edges = `exits[]` (derivable automatically from room JSON rather than hand-authored).
+
+Design notes
+- **Layout**: add `meta.map_x` / `meta.map_y` (+ optional `meta.region`) per room and place nodes in the editor; fall back to auto-layout from exit topology if absent.
+- **Discovery**: persist `visited_rooms` and `discovered_links` alongside the existing lantern-only save data. Undiscovered = "?" / fog.
+- **Theme hook**: map clear in Light, fogged/faded in Shade, or fog dispelled by lanterns — reinforces the light/shade identity (keep it cosmetic).
+- **Fairy as cartographer**: she fills in rooms and comments on new ones; reuses her room-aware voice lines and mirrors the Boss Diary UI pattern.
+- **Fast travel**: discovered lanterns become warp nodes; overlaps with the deferred "rune stones" / "fairy bond teleport" items.
+- **Edge cases**: one-way exits, boss-refight rooms, the missing `room_town_gloomreach.json`, exits with empty `to` (ignore), multiple entry spawns = one node.
+
+Phasing
+1. Current-room minimap overlay.
+2. `visited_rooms` + world map from auto-derived exit links (auto-positioned).
+3. Manual `map_x/map_y` layout in the editor.
+4. Lantern fast travel + fairy-bond teleport.
+
## Dependencies
- Save is the keystone: death penalty, main menu, and any progress feature hang off it.
diff --git a/light_and_dark/data/config/fairy.json b/light_and_dark/data/config/fairy.json
new file mode 100644
index 0000000..105c39e
--- /dev/null
+++ b/light_and_dark/data/config/fairy.json
@@ -0,0 +1,89 @@
+{
+ "talk_range": 80,
+ "bubble_duration": 3.5,
+ "idle_interval": 45.0,
+ "idle_lines": ["idle_1", "idle_2", "idle_3"],
+ "dust_lines": ["dust_1", "dust_2", "dust_3"],
+ "attack_lines": ["attack_1", "attack_2", "attack_3"],
+ "auto": {
+ "room_01.json": { "on_enter": "welcome" }
+ },
+ "lines": {
+ "welcome": {
+ "light": "Wake up, sleepyhead! We have a long way to go.",
+ "shade": "Ooh, you found your dark side already?"
+ },
+ "idle_1": {
+ "light": "The butterflies always know the way.",
+ "shade": "Even in the dark, I can still see you."
+ },
+ "idle_2": {
+ "light": "Rest at a lantern and it will remember this place.",
+ "shade": "Careful - the gloom hides sharp things."
+ },
+ "idle_3": {
+ "light": "I will stay close. I always do.",
+ "shade": "You are not alone down here. I am right here."
+ },
+ "spot_greeting": {
+ "light": "Isn't this place pretty?",
+ "shade": "I can barely make you out in here."
+ },
+ "dust_1": {
+ "light": "Fairy dust! I can feel its sparkle.",
+ "shade": "Ooh, shiny. Even the dark likes this."
+ },
+ "dust_2": {
+ "light": "Another pinch of dust - keep going!",
+ "shade": "The dark tastes sweeter with this."
+ },
+ "dust_3": {
+ "light": "This dust hums when I hold it.",
+ "shade": "Keep collecting; I want all of it."
+ },
+ "quest_intro": {
+ "light": "I scattered my fairy dust across these rooms. Bring me 5 and I will fight beside you!",
+ "shade": "Find my 5 fairy dust. I will lend you my sting."
+ },
+ "quest_reward": {
+ "light": "You found them all! Now I can fight - press the Fairy Mode key!",
+ "shade": "All 5! My sting is yours. Press the Fairy Mode key."
+ },
+ "quest_return": {
+ "light": "That's all 5! Return to me at the fairy spot - I have something to show you!",
+ "shade": "All 5 gathered. Come back to my spot - I am ready to change."
+ },
+ "quest_unlock": {
+ "light": "Watch this - hold still!",
+ "shade": "Observe. I am about to change."
+ },
+ "quest_done": {
+ "light": "I am ready whenever you are!",
+ "shade": "Say the word and I strike."
+ },
+ "combat_on": {
+ "light": "To battle! I will pick off the stragglers.",
+ "shade": "Time to hunt. I like this form of yours."
+ },
+ "combat_off": {
+ "light": "Resting my wings now.",
+ "shade": "Fine, back to lurking."
+ },
+ "combat_empty": {
+ "light": "I am out of charge - I need to rest!",
+ "shade": "Empty. Let me recharge."
+ },
+ "attack_1": {
+ "light": "Huah!",
+ "shade": "Huah!"
+ },
+ "attack_2": {
+ "light": "Take this!",
+ "shade": "Take this!"
+ },
+ "attack_3": {
+ "light": "Hyah!",
+ "shade": "Hyah!"
+ }
+ }
+}
diff --git a/light_and_dark/data/config/ranged.json b/light_and_dark/data/config/ranged.json
index 6af6978..0d2c4bc 100644
--- a/light_and_dark/data/config/ranged.json
+++ b/light_and_dark/data/config/ranged.json
@@ -6,5 +6,16 @@
"speed": 650.0,
"lifetime": 1.2,
"size": [14, 6]
+ },
+ "fairy": {
+ "charge_cost": 5.0,
+ "cooldown": 0.7,
+ "range": 560.0,
+ "voice_every": 3,
+ "damage_mult": 0.5,
+ "speed": 520.0,
+ "lifetime": 1.4,
+ "size": [16, 16],
+ "spread_deg": 6.0
}
}
diff --git a/light_and_dark/data/objects/objects.json b/light_and_dark/data/objects/objects.json
index ddbb1d5..181d49a 100644
--- a/light_and_dark/data/objects/objects.json
+++ b/light_and_dark/data/objects/objects.json
@@ -22,6 +22,12 @@
},
"quest_point": {
"type": "quest_giver"
+ },
+ "fairy_spot": {
+ "type": "fairy_spot"
+ },
+ "fairy_dust": {
+ "type": "fairy_dust"
}
}
}
\ No newline at end of file
diff --git a/light_and_dark/data/sprites/abilities/fairy_projectile.png b/light_and_dark/data/sprites/abilities/fairy_projectile.png
new file mode 100644
index 0000000..7073eb6
Binary files /dev/null and b/light_and_dark/data/sprites/abilities/fairy_projectile.png differ
diff --git a/light_and_dark/data/sprites/objects/fairy_dust.png b/light_and_dark/data/sprites/objects/fairy_dust.png
new file mode 100644
index 0000000..ae1bdff
Binary files /dev/null and b/light_and_dark/data/sprites/objects/fairy_dust.png differ
diff --git a/light_and_dark/data/world/rooms/room_01.json b/light_and_dark/data/world/rooms/room_01.json
index 3c4462f..cd4c3b2 100644
--- a/light_and_dark/data/world/rooms/room_01.json
+++ b/light_and_dark/data/world/rooms/room_01.json
@@ -144,6 +144,23 @@
"h": 260,
"group": "ground",
"layer": 0
+ },
+ {
+ "x": 20,
+ "y": 760,
+ "w": 140,
+ "h": 20,
+ "group": "moving",
+ "path": [
+ [
+ 20,
+ 760
+ ],
+ [
+ 20,
+ 400
+ ]
+ ]
}
],
"enemies": [
@@ -171,7 +188,20 @@
"radius": 180,
"object_id": "flow_butterfly_jar",
"layer": 0,
- "quest_id": ""
+ "quest_id": "",
+ "dialogue_id": "",
+ "auto": false
+ },
+ {
+ "x": 600,
+ "y": 140,
+ "type": "fairy_dust",
+ "radius": 40,
+ "object_id": "fairy_dust",
+ "layer": 0,
+ "quest_id": "",
+ "dialogue_id": "",
+ "auto": false
}
],
"exits": [
diff --git a/light_and_dark/data/world/rooms/room_03.json b/light_and_dark/data/world/rooms/room_03.json
index 46d6dc2..68a977f 100644
--- a/light_and_dark/data/world/rooms/room_03.json
+++ b/light_and_dark/data/world/rooms/room_03.json
@@ -277,6 +277,15 @@
"object_id": "flow_butterfly",
"layer": 0,
"quest_id": ""
+ },
+ {
+ "x": 440,
+ "y": 400,
+ "type": "fairy_dust",
+ "radius": 40,
+ "object_id": "fairy_dust",
+ "layer": 0,
+ "quest_id": ""
}
],
"exits": [
diff --git a/light_and_dark/data/world/rooms/room_05.json b/light_and_dark/data/world/rooms/room_05.json
index 284b2ac..72ae412 100644
--- a/light_and_dark/data/world/rooms/room_05.json
+++ b/light_and_dark/data/world/rooms/room_05.json
@@ -136,6 +136,15 @@
"object_id": "lamp",
"layer": 0,
"quest_id": ""
+ },
+ {
+ "x": 740,
+ "y": 180,
+ "type": "fairy_dust",
+ "radius": 40,
+ "object_id": "fairy_dust",
+ "layer": 0,
+ "quest_id": ""
}
],
"exits": [
diff --git a/light_and_dark/data/world/rooms/room_07.json b/light_and_dark/data/world/rooms/room_07.json
index 8cdc410..1ff17a4 100644
--- a/light_and_dark/data/world/rooms/room_07.json
+++ b/light_and_dark/data/world/rooms/room_07.json
@@ -79,6 +79,15 @@
"object_id": "lamp",
"layer": 0,
"quest_id": ""
+ },
+ {
+ "x": 1100,
+ "y": 520,
+ "type": "fairy_dust",
+ "radius": 40,
+ "object_id": "fairy_dust",
+ "layer": 0,
+ "quest_id": ""
}
],
"exits": [
diff --git a/light_and_dark/data/world/rooms/room_09.json b/light_and_dark/data/world/rooms/room_09.json
index 2e951d8..276c650 100644
--- a/light_and_dark/data/world/rooms/room_09.json
+++ b/light_and_dark/data/world/rooms/room_09.json
@@ -14,6 +14,14 @@
"room9_top_left": [
220,
300
+ ],
+ "room9_top_right": [
+ 2660,
+ 240
+ ],
+ "room9_bottom_right": [
+ 2660,
+ 600
]
},
"platforms": [
@@ -38,42 +46,48 @@
"y": 440,
"w": 140,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 620,
"y": 500,
"w": 120,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 800,
"y": 560,
"w": 80,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 80,
"y": 660,
"w": 2699,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 1580,
"y": 540,
"w": 240,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 1980,
"y": 540,
"w": 100,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
},
{
"x": 2260,
@@ -81,6 +95,7 @@
"w": 140,
"h": 20,
"group": "moving",
+ "layer": 0,
"path": [
[
2260,
@@ -97,7 +112,24 @@
"y": 300,
"w": 320,
"h": 20,
- "group": "ground"
+ "group": "ground",
+ "layer": 0
+ },
+ {
+ "x": 2780,
+ "y": 120,
+ "w": 20,
+ "h": 200,
+ "group": "ground",
+ "layer": 0
+ },
+ {
+ "x": 2780,
+ "y": 480,
+ "w": 20,
+ "h": 200,
+ "group": "ground",
+ "layer": 0
}
],
"enemies": [],
@@ -111,30 +143,32 @@
{
"x": 2012,
"y": 486,
- "type": "combat_master"
+ "type": "combat_master",
+ "layer": 0
}
],
"props": [
- {
- "x": 800,
- "y": 460,
- "type": "player",
- "radius": 180,
- "object_id": "player"
- },
{
"x": 1240,
"y": 660,
"type": "house",
"radius": 180,
- "object_id": "house"
+ "object_id": "house",
+ "layer": 0,
+ "quest_id": "",
+ "dialogue_id": "",
+ "auto": false
},
{
"x": 1660,
"y": 540,
"type": "house",
"radius": 180,
- "object_id": "house"
+ "object_id": "house",
+ "layer": 0,
+ "quest_id": "",
+ "dialogue_id": "",
+ "auto": false
},
{
"x": 1754,
@@ -142,7 +176,26 @@
"type": "diary",
"radius": 180,
"object_id": "diary",
- "layer": 1
+ "layer": 1,
+ "quest_id": "",
+ "dialogue_id": "",
+ "auto": false
+ },
+ {
+ "x": 1460,
+ "y": 660,
+ "type": "save_point",
+ "radius": 90,
+ "object_id": "lamp"
+ },
+ {
+ "x": 840,
+ "y": 560,
+ "type": "fairy_dust",
+ "radius": 40,
+ "object_id": "fairy_dust",
+ "layer": 0,
+ "quest_id": ""
}
],
"exits": [
@@ -160,6 +213,36 @@
"hint": "",
"layer": 0,
"requires_boss_dead": false
+ },
+ {
+ "x": 2700,
+ "y": 560,
+ "w": 80,
+ "h": 100,
+ "to": "",
+ "spawn_id": "",
+ "spawn": [
+ 2700,
+ 560
+ ],
+ "hint": "",
+ "layer": 0,
+ "requires_boss_dead": false
+ },
+ {
+ "x": 2700,
+ "y": 200,
+ "w": 80,
+ "h": 100,
+ "to": "room_10",
+ "spawn_id": "room10_top_left",
+ "spawn": [
+ 140,
+ 280
+ ],
+ "hint": "",
+ "layer": 0,
+ "requires_boss_dead": false
}
],
"ability_doors": [],
diff --git a/light_and_dark/data/world/rooms/room_10.json b/light_and_dark/data/world/rooms/room_10.json
new file mode 100644
index 0000000..54e4b8a
--- /dev/null
+++ b/light_and_dark/data/world/rooms/room_10.json
@@ -0,0 +1,71 @@
+{
+ "name": "room_10",
+ "display_name": "room_10",
+ "meta": {
+ "id": "room_10",
+ "name": "room_10"
+ },
+ "bounds": {},
+ "player_spawn": [
+ 160,
+ 260
+ ],
+ "entry_spawns": {
+ "room10_top_left": [
+ 140,
+ 280
+ ]
+ },
+ "platforms": [
+ {
+ "x": 20,
+ "y": 340,
+ "w": 440,
+ "h": 20,
+ "group": "ground",
+ "layer": 0
+ },
+ {
+ "x": 20,
+ "y": 120,
+ "w": 20,
+ "h": 220,
+ "group": "ground",
+ "layer": 0
+ }
+ ],
+ "enemies": [],
+ "npcs": [],
+ "props": [
+ {
+ "x": 340,
+ "y": 300,
+ "type": "fairy_spot",
+ "radius": 180,
+ "object_id": "fairy_spot",
+ "dialogue_id": "spot_greeting",
+ "quest_id": "quest_fairy_dust"
+ }
+ ],
+ "exits": [
+ {
+ "x": 40,
+ "y": 240,
+ "w": 80,
+ "h": 100,
+ "to": "room_09",
+ "spawn_id": "room9_top_right",
+ "spawn": [
+ 2660,
+ 240
+ ],
+ "hint": "",
+ "layer": 0,
+ "requires_boss_dead": false
+ }
+ ],
+ "ability_doors": [],
+ "room_w": 1600,
+ "room_h": 900,
+ "mist": false
+}
\ No newline at end of file
diff --git a/light_and_dark/data/world/rooms/room_boss_fly_refight.json b/light_and_dark/data/world/rooms/room_boss_fly_refight.json
index 0fa9eee..978321a 100644
--- a/light_and_dark/data/world/rooms/room_boss_fly_refight.json
+++ b/light_and_dark/data/world/rooms/room_boss_fly_refight.json
@@ -47,17 +47,18 @@
"props": [],
"exits": [
{
- "x": 1460,
- "y": 360,
- "w": 80,
- "h": 100,
- "to": "",
- "spawn_id": "",
- "spawn": [
- 1460,
- 360
- ]
- }
+ "x": 1460,
+ "y": 360,
+ "w": 80,
+ "h": 100,
+ "to": "room_09.json",
+ "spawn_id": "",
+ "spawn": [
+ 1754,
+ 540
+ ],
+ "requires_boss_dead": true
+ }
],
"ability_doors": [],
"room_w": 1600,
diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html
index 5fef4fb..b3997e8 100644
--- a/light_and_dark/level_editor_v43.html
+++ b/light_and_dark/level_editor_v43.html
@@ -271,6 +271,8 @@
diary: { type: "diary" },
player: { type: "player" },
quest_point: { type: "quest_giver" },
+ fairy_spot: { type: "fairy_spot" },
+ fairy_dust: { type: "fairy_dust" },
};
// Type fallbacks used when no object def provides a radius.
const TYPE_DEFAULT_RADII = { light_source: 180, save_point: 90 };
@@ -496,7 +498,7 @@
const type = p.type || p.kind || "light_source";
const object_id = p.object_id || p.objectId || null;
const radius = +p.radius || getDefaultRadiusForType(type, object_id);
- r.props.push({ x, y, type, radius, object_id, layer: +(p.layer||0), quest_id: p.quest_id || p.questId || "", coins: Array.isArray(p.coins) ? deepClone(p.coins) : undefined });
+ r.props.push({ x, y, type, radius, object_id, layer: +(p.layer||0), quest_id: p.quest_id || p.questId || "", dialogue_id: p.dialogue_id || p.dialogueId || "", auto: !!p.auto, coins: Array.isArray(p.coins) ? deepClone(p.coins) : undefined });
}
}
@@ -2377,8 +2379,9 @@ 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 - press F to save)", butterfly_jar:"Butterfly-in-jar (collectible)", diary:"Boss diary (interact)", house:"Standalone house", player:"Player size reference (no in-game effect)", quest_giver:"Quest point (name it below)"})[p.type||"light_source"] || "";
+ const hint = ({light_source:"Butterfly (charge regen 2x)", save_point:"Lantern (save point - press F to save)", butterfly_jar:"Butterfly-in-jar (collectible)", diary:"Boss diary (interact)", house:"Standalone house", player:"Player size reference (no in-game effect)", quest_giver:"Quest point (name it below)", fairy_spot:"Fairy spot (press F to hear the fairy; set quest id for a quest)", fairy_dust:"Fairy dust (collect with F)"})[p.type||"light_source"] || "";
const isQuest = (p.type||"") === "quest_giver";
+ const isFairySpot = (p.type||"") === "fairy_spot";
return `
object id
@@ -2648,6 +2660,14 @@ Cancel = Choose whether to remove without saving`);
const v = prqid.value.trim();
if(v){ p.quest_id = v; } else { delete p.quest_id; }
}));
+ const prdialogue=q("prdialogue"), prauto=q("prauto");
+ if(prdialogue) prdialogue.addEventListener("change", ()=>pushAnd(()=>{
+ const v = prdialogue.value.trim();
+ if(v){ p.dialogue_id = v; } else { delete p.dialogue_id; }
+ }));
+ if(prauto) prauto.addEventListener("change", ()=>pushAnd(()=>{
+ if(prauto.checked){ p.auto = true; } else { delete p.auto; }
+ }));
if(probjid) probjid.addEventListener("change", ()=>pushAnd(()=>{
const v = probjid.value.trim();
if(v){ p.object_id = v; } else { delete p.object_id; }
@@ -3492,6 +3512,8 @@ Cancel = Choose whether to remove without saving`);
if(p.object_id) out.object_id = p.object_id;
if(p.layer) out.layer = p.layer;
if(p.quest_id) out.quest_id = p.quest_id;
+ if(p.dialogue_id) out.dialogue_id = p.dialogue_id;
+ if(p.auto) out.auto = true;
if(Array.isArray(p.coins)) out.coins = p.coins;
return out;
});
diff --git a/light_and_dark/main.py b/light_and_dark/main.py
index cc9dbe7..d68b528 100644
--- a/light_and_dark/main.py
+++ b/light_and_dark/main.py
@@ -122,6 +122,7 @@ BINDINGS_DEFAULT = {
"shatterstep": pygame.K_r,
"tether": pygame.K_x,
"reality_break": pygame.K_c,
+ "fairy_mode": pygame.K_z,
"use_butterfly_jar": pygame.K_g,
"toggle_shade": pygame.K_LSHIFT,
"inventory": pygame.K_i,
@@ -182,6 +183,7 @@ SETTINGS_ACTIONS = [
("Shatterstep", "shatterstep"),
("Tether", "tether"),
("Reality Break", "reality_break"),
+ ("Fairy Mode", "fairy_mode"),
("Use Butterfly Jar", "use_butterfly_jar"),
("Toggle Shade", "toggle_shade"),
("Inventory", "inventory"),
@@ -270,6 +272,9 @@ def mark_door_broken(room_id: str, rect):
COLLECTED_JARS: set[str] = set()
BUTTERFLY_JARS_MAX = 3
+# Fairy Dust collectibles (persisted in saves; feed the Fairy Dust quest)
+FAIRY_DUST_COLLECTED: set[str] = set()
+
def jar_key(room_id: str, x: int, y: int) -> str:
return f"{room_id}:{int(x)},{int(y)}"
@@ -318,6 +323,7 @@ UI_CFG = load_json_safe("ui_layout.json", [CONFIG_DIR, DATA_ROOT], {})
RANGED_CFG = load_json_safe("ranged.json", [CONFIG_DIR, DATA_ROOT], {})
REWARD_CFG = load_json_safe("reward_room.json", [CONFIG_DIR, DATA_ROOT], {})
NPC_CFG = load_json_safe("npc.json", [CONFIG_DIR, DATA_ROOT], {})
+FAIRY_CFG = load_json_safe("fairy.json", [CONFIG_DIR, DATA_ROOT], {})
ANIM_CFG = load_json_safe("animation.json", [CONFIG_DIR, DATA_ROOT], {})
COIN_DROPS_CFG = load_json_safe("coin_drops.json", [CONFIG_DIR, DATA_ROOT], {})
@@ -378,6 +384,14 @@ def _npc(path, default=None):
cur = cur[key]
return cur
+def _fairy(path, default=None):
+ cur = FAIRY_CFG
+ for key in path:
+ if not isinstance(cur, dict) or key not in cur:
+ return default
+ cur = cur[key]
+ return cur
+
def _anim(path, default=None):
cur = ANIM_CFG
for key in path:
@@ -455,6 +469,10 @@ GRAVITY_LIGHT = float(_phys(["player", "gravity_light"], 0.5))
GRAVITY_SHADE = float(_phys(["player", "gravity_shade"], 0.4))
JUMP_RELEASE_GRAVITY_MULT = float(_phys(["player", "jump_release_gravity_mult"], 3.0))
SHADE_DOOR_GRACE = float(_phys(["player", "shade_door_grace"], 0.6))
+# Down-slash pogo: upward velocity after stomping an enemy, and how long the
+# player keeps jump-hold gravity + contact-damage immunity afterwards.
+DOWN_SLASH_BOUNCE = float(_phys(["player", "down_slash_bounce"], -9.0))
+POGO_GRACE = float(_phys(["player", "pogo_grace"], 0.35))
# Sprite offsets (tune to align art with hitbox)
PLAYER_SPRITE_OFFSET_X = int(_cfg(["player", "sprite_offset_x"], 0))
@@ -502,6 +520,18 @@ PROJECTILE_SPEED = float(_ranged(["projectile", "speed"], PROJECTILE_SPEED))
PROJECTILE_LIFETIME = float(_ranged(["projectile", "lifetime"], PROJECTILE_LIFETIME))
PROJECTILE_SIZE = tuple(_ranged(["projectile", "size"], list(PROJECTILE_SIZE)))
+# --- Fairy combat (granted by the Fairy Dust quest) ---
+FAIRY_SHOT_CHARGE_COST = float(_ranged(["fairy", "charge_cost"], RANGED_CHARGE_COST))
+FAIRY_SHOT_COOLDOWN = float(_ranged(["fairy", "cooldown"], 0.7))
+FAIRY_SHOT_RANGE = float(_ranged(["fairy", "range"], 560.0))
+FAIRY_VOICE_EVERY = max(1, int(_ranged(["fairy", "voice_every"], 3)))
+FAIRY_DAMAGE_MULT = float(_ranged(["fairy", "damage_mult"], RANGED_DAMAGE_MULT))
+FAIRY_PROJECTILE_SPEED = float(_ranged(["fairy", "speed"], 520.0))
+FAIRY_PROJECTILE_LIFETIME = float(_ranged(["fairy", "lifetime"], 1.4))
+FAIRY_PROJECTILE_SIZE = tuple(_ranged(["fairy", "size"], [16, 16]))
+FAIRY_SPREAD_DEG = float(_ranged(["fairy", "spread_deg"], 6.0))
+FAIRY_DUST_MAX = 5
+
# --- Reward room settings (from reward_room.json) ---
REWARD_ROOM_ID = str(_reward(["room_id"], REWARD_ROOM_ID))
REWARD_CHEST_POS = tuple(_reward(["chest_pos"], list(REWARD_CHEST_POS)))
@@ -1490,6 +1520,10 @@ RANGED_PROJECTILE_SPRITE_RAW = _load_first_existing([
ABILITY_SPRITES_DIR / "ranged_projectile.png",
ABILITY_SPRITES_DIR / "ranged_bolt.png",
])
+FAIRY_PROJECTILE_SPRITE_RAW = _load_first_existing([
+ ABILITY_SPRITES_DIR / "fairy_projectile.png",
+ ABILITY_SPRITES_DIR / "Fairy_projectile.png",
+])
TOKENS_DIR = DATA_ROOT / "sprites" / "tokens"
BOSS_TOKEN_SPRITE_RAW = _load_first_existing([
TOKENS_DIR / "Boss1_token.png",
@@ -1561,6 +1595,9 @@ BUTTERFLY_RAW = _load_image_or_none(BUTTERFLY_PATH)
BUTTERFLY_JAR_PATH = OBJECTS_DIR / "flow_butterfly_jar1.png"
BUTTERFLY_JAR_RAW = _load_image_or_none(BUTTERFLY_JAR_PATH)
+FAIRY_DUST_PATH = OBJECTS_DIR / "fairy_dust.png"
+FAIRY_DUST_RAW = _load_image_or_none(FAIRY_DUST_PATH)
+
# --- Fairy companion (hovers behind the player's head) ---
FAIRY_PATH = DATA_ROOT / "sprites" / "npcs" / "fairy1.png"
FAIRY_RAW = _load_image_or_none(FAIRY_PATH)
@@ -1706,6 +1743,32 @@ class Projectile:
)
+class FairyProjectile(Projectile):
+ """Fairy combat shot. Same collision/hit rules as the player's ranged bolt,
+ so it can also miss moving targets."""
+ def __init__(self, x, y, dx: float, dy: float, damage: float):
+ super().__init__(x, y, dx, dy, damage)
+ w, h = FAIRY_PROJECTILE_SIZE
+ self.rect = pygame.Rect(int(x), int(y), int(w), int(h))
+ self.speed = float(FAIRY_PROJECTILE_SPEED)
+ self.t = float(FAIRY_PROJECTILE_LIFETIME)
+
+ def draw(self, surface, cx, cy):
+ img_raw = FAIRY_PROJECTILE_SPRITE_RAW
+ if img_raw is None:
+ super().draw(surface, cx, cy)
+ return
+ target = 26
+ scale = target / max(1, img_raw.get_width(), img_raw.get_height())
+ w = max(1, int(img_raw.get_width() * scale))
+ h = max(1, int(img_raw.get_height() * scale))
+ img = pygame.transform.scale(img_raw, (w, h))
+ angle = -math.degrees(math.atan2(self.dir_y, self.dir_x))
+ img = pygame.transform.rotate(img, angle)
+ surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx,
+ self.rect.centery - img.get_height() // 2 - cy))
+
+
class BossProjectile:
"""Homing projectile fired by the flying boss. Slightly curves toward the
player each frame. Uses boss_ranged.png (faces right; flipped when fired
@@ -2086,6 +2149,40 @@ class ButterflyJar:
pygame.draw.rect(surface, (200, 150, 230), (self.rect.x - cx, self.rect.y - cy + bob_y, self.rect.width, self.rect.height))
+class FairyDust:
+ """Collectible fairy dust; picked up with the interact key."""
+ INTERACT_RANGE = 70
+
+ def __init__(self, x: int, y: int, key: str = ""):
+ self.x = int(x)
+ self.y = int(y)
+ self.key = key
+ self.bob_t = random.random() * 10.0
+ if FAIRY_DUST_RAW is not None:
+ target = 26
+ scale = target / max(1, FAIRY_DUST_RAW.get_width(), FAIRY_DUST_RAW.get_height())
+ w = max(1, int(FAIRY_DUST_RAW.get_width() * scale))
+ h = max(1, int(FAIRY_DUST_RAW.get_height() * scale))
+ self.img = pygame.transform.smoothscale(FAIRY_DUST_RAW, (w, h))
+ self.rect = self.img.get_rect(midbottom=(self.x, self.y))
+ else:
+ self.img = None
+ self.rect = pygame.Rect(self.x - 12, self.y - 24, 24, 24)
+
+ def update(self, dt: float):
+ self.bob_t += dt
+
+ def draw(self, surface, cx, cy):
+ bob_y = int(math.sin(self.bob_t * 2.0) * 3)
+ if self.img is not None:
+ surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy + bob_y))
+ else:
+ pygame.draw.circle(surface, (240, 230, 160),
+ (int(self.rect.centerx - cx), int(self.rect.centery - cy + bob_y)), 8)
+ pygame.draw.circle(surface, (180, 160, 90),
+ (int(self.rect.centerx - cx), int(self.rect.centery - cy + bob_y)), 8, 2)
+
+
class Corpse:
"""A faded player model left where the player died, holding the coins they lost."""
def __init__(self, x: int, y: int, room_id: str, coins_light: int, coins_dark: int):
@@ -2432,10 +2529,30 @@ class TradeUI:
use_rarity = rarity if rarity in RARITIES else "tempered"
return build_item_instance(base, use_rarity)
+ def _combat_master_quest_option(self) -> dict | None:
+ """The Combat Master's token-collection quest, as a menu option."""
+ qid = "quest_combat_master_tokens"
+ q = quest_manager.get(qid)
+ if q is None:
+ return None
+ if qid in quest_manager.active:
+ prog = int(quest_manager.active[qid].get("progress", 0))
+ if quest_manager.is_complete(qid):
+ return {"type": "quest_turnin", "quest_id": qid,
+ "label": f"Turn in: {q.name}"}
+ return {"type": "quest_status", "quest_id": qid,
+ "label": f"{q.name}: {prog}/{q.target_count()} boss tokens"}
+ if qid in quest_manager.completed_one_time or qid in quest_manager.ever_completed:
+ return None
+ return {"type": "quest_accept", "quest_id": qid, "label": f"Take quest: {q.name}"}
+
def _build_options(self, npc: NPC | None) -> list[dict]:
- # Combat Master: offer to upgrade each carried boss token
+ # Combat Master: his quest first, then upgrade each carried boss token.
if npc is not None and npc.type == "combat_master":
options = []
+ qopt = self._combat_master_quest_option()
+ if qopt:
+ options.append(qopt)
for it in self.inv.items:
if isinstance(it, dict) and it.get("item_type") == "boss_token":
tier = int(it.get("tier", 0))
@@ -2500,24 +2617,64 @@ class TradeUI:
return options
def _try_trade(self):
- cost = NPC_TRADE_COST
- if self.active_npc and self.active_npc.trade_cost is not None:
- cost = int(self.active_npc.trade_cost)
-
- if self.player.coins_light < cost:
- pickup_feed.push_plain("Not enough light coins")
- return
if not self.options:
pickup_feed.push_plain("No offers available")
return
choice = self.options[self.cursor]
choice_type = str(choice.get("type", "sell_item"))
+
+ # --- Quest options never cost coins ---
+ if choice_type == "quest_status":
+ return
+ if choice_type == "quest_accept":
+ qid = choice.get("quest_id")
+ q = quest_manager.get(qid)
+ if q is None:
+ return
+ if quest_manager.accept(qid):
+ if (str(q.objective.get("kind", "")) == "collect"
+ and str(q.objective.get("target", "")) == "boss_token"):
+ held = quest_manager.boss_token_count_held()
+ quest_manager.active[qid]["progress"] = min(q.target_count(), held)
+ pickup_feed.push_plain(f"Quest accepted: {q.name}")
+ else:
+ pickup_feed.push_plain("Cannot take that quest right now.")
+ self.options = self._build_options(self.active_npc)
+ self.cursor = max(0, min(self.cursor, len(self.options) - 1)) if self.options else 0
+ return
+ if choice_type == "quest_turnin":
+ qid = choice.get("quest_id")
+ q = quest_manager.get(qid)
+ result = quest_manager.turn_in(qid)
+ if result is None:
+ pickup_feed.push_plain("Quest is not ready to turn in.")
+ return
+ name = q.name if q else "Quest"
+ parts = [f"{name} complete!"]
+ if result.get("coins"):
+ self.player.coins_light += int(result["coins"])
+ parts.append(f"+{result['coins']} light coins")
+ if result.get("tokens"):
+ quest_manager.add_tokens(int(result["tokens"]))
+ parts.append(f"+{result['tokens']} token")
+ if result.get("item"):
+ self.inv.add(result["item"])
+ parts.append(f"+{result['item'].get('name', 'Item')}")
+ pickup_feed.push_center(" ".join(parts), duration=4.0)
+ self.options = self._build_options(self.active_npc)
+ self.cursor = max(0, min(self.cursor, len(self.options) - 1)) if self.options else 0
+ return
+
+ # --- Paired trades (may cost light coins) ---
+ cost = NPC_TRADE_COST
+ if self.active_npc and self.active_npc.trade_cost is not None:
+ cost = int(self.active_npc.trade_cost)
choice_cost = choice.get("cost")
if isinstance(choice_cost, (int, float)):
cost = int(choice_cost)
- if self.player.coins_light < cost:
- pickup_feed.push_plain("Not enough light coins")
- return
+ if self.player.coins_light < cost:
+ pickup_feed.push_plain("Not enough light coins")
+ return
if choice_type == "upgrade_attune":
item_id = choice.get("item_id")
required = int(choice.get("required_count", 5))
@@ -2763,6 +2920,19 @@ def spawn_drops_from_enemy(enemy) -> list[LootPickup]:
# ----------------------------
# Inventory
# ----------------------------
+QUEST_ITEM_TYPES = ("boss_token",)
+
+
+def is_quest_item(it) -> bool:
+ """Quest objects (boss tokens today; fairy dust is shown as a virtual entry)."""
+ return isinstance(it, dict) and str(it.get("item_type", "")) in QUEST_ITEM_TYPES
+
+
+def is_equipment_item(it) -> bool:
+ """Anything that is not a quest object is treated as Equipment."""
+ return isinstance(it, dict) and not is_quest_item(it)
+
+
class Inventory:
def __init__(self):
self.items: list[dict] = []
@@ -2782,13 +2952,14 @@ class InventoryUI:
self.font = font_obj
self.visible = False
- self.focus = "inv" # "inv" or "equip"
+ self.tab = "equipment" # "equipment" or "quest"
+ self.tabs = ("equipment", "quest")
self.cursor = 0
self.scroll = 0
+ # Ring slot is planned; add "ring" here once implemented.
self.equip_slots = ["helmet", "chest", "pants", "necklace"]
- self.equip_idx = 0
self.x = int(_ui(["inventory_ui", "panel", "x"], 20))
self.y = int(_ui(["inventory_ui", "panel", "y"], 20))
@@ -2821,15 +2992,64 @@ class InventoryUI:
self.token_icons[key] = icon
return self.token_icons[key]
+ def _get_dust_icon(self) -> pygame.Surface | None:
+ if getattr(self, "_dust_icon_cache", "missing") != "missing":
+ return self._dust_icon_cache
+ icon = None
+ if FAIRY_DUST_RAW is not None:
+ try:
+ icon = pygame.transform.smoothscale(FAIRY_DUST_RAW, (self.icon_size, self.icon_size))
+ except Exception:
+ icon = None
+ self._dust_icon_cache = icon
+ return icon
def toggle(self):
self.visible = not self.visible
if self.visible:
- self.focus = "inv"
+ self.tab = "equipment"
+ self.cursor = 0
+ self.scroll = 0
self._clamp_cursor()
+ # ---------- tab rows ----------
+ def _bag_equipment_rows(self) -> list[dict]:
+ return [{"kind": "bag", "item": it} for it in self.inv.items if is_equipment_item(it)]
+
+ def _equipped_rows(self) -> list[dict]:
+ return [{"kind": "equipped", "slot": s, "item": self.player.equipment.get(s)}
+ for s in self.equip_slots]
+
+ def _quest_rows(self) -> list[dict]:
+ rows: list[dict] = []
+ for it in self.inv.items:
+ if is_quest_item(it):
+ rows.append({"kind": "quest", "item": it})
+ show_dust = (
+ len(FAIRY_DUST_COLLECTED) > 0
+ or "quest_fairy_dust" in quest_manager.active
+ or "quest_fairy_dust" in quest_manager.ever_completed
+ )
+ if show_dust:
+ rows.append({"kind": "dust", "count": len(FAIRY_DUST_COLLECTED)})
+ return rows
+
+ def _rows(self) -> list[dict]:
+ """Selectable rows: bag equipment first, then the equipped slots (which
+ are only shown in the side panel, but remain selectable to unequip)."""
+ if self.tab == "equipment":
+ return self._bag_equipment_rows() + self._equipped_rows()
+ return self._quest_rows()
+
+ def _display_rows(self) -> list[dict]:
+ """Rows actually rendered in the left list (equipped slots live only in
+ the side panel)."""
+ if self.tab == "equipment":
+ return self._bag_equipment_rows()
+ return self._quest_rows()
+
def _clamp_cursor(self):
- n = len(self.inv.items)
+ n = len(self._rows())
if n == 0:
self.cursor = 0
self.scroll = 0
@@ -2841,14 +3061,22 @@ class InventoryUI:
max_scroll = max(0, n - self._last_list_max_lines)
self.scroll = max(0, min(self.scroll, max_scroll))
-
-
def _ensure_visible(self, max_lines: int):
+ n = len(self._display_rows())
+ if n == 0:
+ self.scroll = 0
+ return
if self.cursor < self.scroll:
self.scroll = self.cursor
if self.cursor >= self.scroll + max_lines:
self.scroll = self.cursor - max_lines + 1
- self.scroll = max(0, min(self.scroll, max(0, len(self.inv.items) - max_lines)))
+ self.scroll = max(0, min(self.scroll, max(0, n - max_lines)))
+
+ def _switch_tab(self, step: int):
+ i = self.tabs.index(self.tab)
+ self.tab = self.tabs[(i + step) % len(self.tabs)]
+ self.cursor = 0
+ self.scroll = 0
def handle_keydown(self, key):
if not self.visible:
@@ -2858,59 +3086,54 @@ class InventoryUI:
self.toggle()
return
- if key == key_for("ui_right"):
- self.focus = "equip"
- return
if key == key_for("ui_left"):
- self.focus = "inv"
+ self._switch_tab(-1)
+ return
+ if key == key_for("ui_right"):
+ self._switch_tab(1)
return
- if self.focus == "inv":
- if key == key_for("ui_up"):
- self.cursor -= 1
- self._clamp_cursor()
- self._ensure_visible(self._last_list_max_lines)
- elif key == key_for("ui_down"):
- self.cursor += 1
- self._clamp_cursor()
- self._ensure_visible(self._last_list_max_lines)
- elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
- self._equip_selected_swap()
- else:
- if key == key_for("ui_up"):
- self.equip_idx = (self.equip_idx - 1) % len(self.equip_slots)
- elif key == key_for("ui_down"):
- self.equip_idx = (self.equip_idx + 1) % len(self.equip_slots)
- elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
- self._unequip_selected()
+ if key == key_for("ui_up"):
+ self.cursor -= 1
+ self._clamp_cursor()
+ self._ensure_visible(self._last_list_max_lines)
+ elif key == key_for("ui_down"):
+ self.cursor += 1
+ self._clamp_cursor()
+ self._ensure_visible(self._last_list_max_lines)
+ elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
+ self._activate_selected()
- def _equip_selected_swap(self):
- if len(self.inv.items) == 0:
+ def _activate_selected(self):
+ rows = self._rows()
+ if not rows:
return
+ row = rows[min(self.cursor, len(rows) - 1)]
+ kind = row.get("kind")
+ if kind == "equipped":
+ self._unequip_slot(row["slot"])
+ elif kind == "bag":
+ self._equip_item(row["item"])
+ elif kind == "quest":
+ pickup_feed.push_plain("Quest objects are used from the Boss Diary.")
+ elif kind == "dust":
+ pickup_feed.push_plain("Fairy Dust - return it to the fairy.")
- idx = self.cursor
- new_item = self.inv.items[idx]
+ def _equip_item(self, new_item: dict):
slot = new_item.get("slot")
if slot not in self.player.equipment:
return
-
- old_item = self.player.equipment.get(slot)
-
- # equip new (swap)
- self.player.equip_swap(new_item)
-
- # remove selected from inventory
- self.inv.items.pop(idx)
-
- # insert old at same index (true swap)
+ old_item = self.player.equip_swap(new_item)
+ for i, it in enumerate(self.inv.items):
+ if it is new_item:
+ self.inv.items.pop(i)
+ break
if old_item is not None:
- self.inv.items.insert(idx, old_item)
-
+ self.inv.items.append(old_item)
self._clamp_cursor()
- def _unequip_selected(self):
- """Move the item in the selected equipment slot back to the inventory."""
- slot = self.equip_slots[self.equip_idx]
+ def _unequip_slot(self, slot: str):
+ """Move the item in the given equipment slot back to the inventory."""
item = self.player.equipment.get(slot)
if item is None:
return
@@ -3123,6 +3346,123 @@ class InventoryUI:
return lines
+ def _draw_quests_column(self, surface, list_top):
+ quests_x = self.x + self.list_w + self.pad
+ quests_y = list_top
+ quest_w = self.w - (quests_x - self.x) - self.pad
+ self._blit(surface, "Quests:", quests_x, quests_y, (220, 220, 220))
+ quests_y += self.line_h
+
+ active_ids = list(quest_manager.active.keys())
+ max_q = int(_ui(["inventory_ui", "quests", "max_lines"], 6))
+ shown = 0
+ for qid in active_ids[:max_q]:
+ q = quest_manager.get(qid)
+ if q is None:
+ continue
+ st = quest_manager.active[qid]
+ prog = int(st.get("progress", 0))
+ complete = prog >= q.target_count()
+ color = (150, 220, 150) if complete else (230, 230, 230)
+ label = self._ellipsize(f"{q.name}: {prog}/{q.target_count()}", max(0, quest_w))
+ self._blit(surface, label, quests_x, quests_y, color)
+ quests_y += self.line_h
+ shown += 1
+ if not active_ids:
+ self._blit(surface, "(no active quests)", quests_x, quests_y, (160, 160, 160))
+ elif len(active_ids) > shown:
+ self._blit(surface, f"+{len(active_ids) - shown} more", quests_x, quests_y, (160, 160, 160))
+
+ def _draw_equipped_column(self, surface, list_top, sel_row, sel_kind):
+ eq_x = self.x + self.list_w + self.pad
+ eq_y = list_top
+ self._blit(surface, "Equipped:", eq_x, eq_y - 18, (220, 220, 220))
+ line_gap = int(_ui(["inventory_ui", "equipped", "line_gap"], 4))
+ for slot in self.equip_slots:
+ it = self.player.equipment.get(slot)
+ is_sel = bool(sel_kind == "equipped" and sel_row and sel_row.get("slot") == slot)
+ rarity = it.get("rarity", "") if it else ""
+ icon = self._get_slot_icon(slot, rarity)
+
+ if is_sel:
+ hl = pygame.Rect(eq_x - 6, eq_y - 2, self.w - (eq_x - self.x) - self.pad, self.line_h + 2)
+ pygame.draw.rect(surface, (60, 60, 60), hl)
+
+ if icon:
+ icon_y = eq_y + (self.line_h - self.icon_size) // 2
+ surface.blit(icon, (eq_x, icon_y))
+
+ text_x = eq_x + self.icon_size + self.icon_gap
+ avail = (self.x + self.w - self.pad) - text_x
+ if not it:
+ self._blit(surface, self._ellipsize(f"{slot}: (empty)", max(0, avail)),
+ text_x, eq_y, (170, 170, 170))
+ else:
+ name = it.get("name", "?")
+ tag = f"[{rarity}]" if rarity else ""
+ tag_w = self.font.size(tag)[0] if tag else 0
+ left = self._ellipsize(f"{slot}: {name} ", max(0, avail - tag_w))
+ self._blit(surface, left, text_x, eq_y, (230, 230, 230))
+ if tag:
+ self._blit(surface, tag, text_x + self.font.size(left)[0], eq_y, self._rarity_color(rarity))
+
+ eq_y += self.line_h + line_gap
+
+ def _draw_row(self, surface, row, x0, y, list_w):
+ kind = row.get("kind")
+ icon = None
+ name = ""
+ rarity = ""
+ right = ""
+ dim = False
+
+ if kind == "equipped":
+ slot = row["slot"]
+ it = row.get("item")
+ if it:
+ rarity = it.get("rarity", "")
+ icon = self._get_slot_icon(slot, rarity)
+ name = f"{slot}: {it.get('name', '?')}"
+ else:
+ dim = True
+ name = f"{slot}: (empty)"
+ right = "equipped"
+ elif kind == "bag":
+ it = row["item"]
+ slot = it.get("slot", "?")
+ rarity = it.get("rarity", "")
+ icon = self._get_slot_icon(slot, rarity)
+ name = it.get("name", it.get("id", ">"))
+ right = f"({slot})"
+ elif kind == "quest":
+ it = row["item"]
+ icon = self._get_token_icon(it.get("boss_id")) if it.get("item_type") == "boss_token" else None
+ name = it.get("name", it.get("id", ">"))
+ right = "Boss Token"
+ elif kind == "dust":
+ icon = self._get_dust_icon()
+ name = "Fairy Dust"
+ right = f"x{row.get('count', 0)}/{FAIRY_DUST_MAX}"
+
+ if icon:
+ icon_y = y + (self.line_h - self.icon_size) // 2
+ surface.blit(icon, (x0, icon_y))
+
+ text_x = x0 + self.icon_size + self.icon_gap
+ pad_right = int(_ui(["inventory_ui", "list", "pad_right"], 14))
+ max_w = list_w - pad_right
+ rarity_text = f"[{rarity}]" if rarity else ""
+ suffix = f" {rarity_text} {right}".rstrip()
+ avail = max_w - (self.icon_size + self.icon_gap) - self.font.size(suffix)[0]
+ name_fit = self._ellipsize(name, max(0, avail))
+ self._blit(surface, name_fit, text_x, y, (170, 170, 170) if dim else (230, 230, 230))
+ sx = text_x + self.font.size(name_fit)[0]
+ if rarity_text:
+ self._blit(surface, " " + rarity_text, sx, y, self._rarity_color(rarity))
+ sx += self.font.size(" " + rarity_text)[0]
+ if right:
+ self._blit(surface, " " + right, sx, y, (150, 150, 150))
+
def draw(self, surface):
if not self.visible:
return
@@ -3141,6 +3481,19 @@ class InventoryUI:
self._blit(surface, self._ellipsize(title, self.w - self.pad * 2),
self.x + self.pad, self.y + self.pad, (240, 240, 240))
+ # --- Tabs: Equipment | Quest Objects ---
+ tab_y = self.y + self.pad + 20
+ tab_h = 18
+ tab_w = (self.w - self.pad * 2) // len(self.tabs)
+ tab_labels = {"equipment": "Equipment", "quest": "Quest Objects"}
+ for i, tname in enumerate(self.tabs):
+ tr = pygame.Rect(self.x + self.pad + i * tab_w, tab_y, tab_w, tab_h)
+ active = (tname == self.tab)
+ pygame.draw.rect(surface, (70, 70, 95) if active else (48, 48, 48), tr)
+ pygame.draw.rect(surface, (165, 165, 190) if active else (95, 95, 95), tr, 1)
+ self._blit(surface, tab_labels.get(tname, tname), tr.x + 6, tr.y + 1,
+ (255, 242, 200) if active else (170, 170, 170))
+
# --- Detail areas (reserve bottom space FIRST) ---
stats_h = int(_ui(["inventory_ui", "details", "stats_h"], 90))
hint_h = int(_ui(["inventory_ui", "details", "hint_h"], 24))
@@ -3149,9 +3502,8 @@ class InventoryUI:
hint_y = self.y + self.h - self.pad - hint_h
stats_y = hint_y - gap - stats_h
- # --- List region (everything between header and stats) ---
- header_h = int(_ui(["inventory_ui", "list", "header_h"], 40))
- list_top = self.y + header_h
+ # --- List region (everything between the tab bar and stats) ---
+ list_top = tab_y + tab_h + 6
list_h = (stats_y - gap) - list_top
list_h = max(40, list_h) # safety
@@ -3159,144 +3511,46 @@ class InventoryUI:
list_pad_y = int(_ui(["inventory_ui", "list", "pad_y"], 6))
usable_h = max(1, list_h - list_pad_y * 2)
+ sel_rows = self._rows()
+ display_rows = self._display_rows()
list_max_lines = max(1, usable_h // self.line_h)
self._ensure_visible(list_max_lines)
self._last_list_max_lines = list_max_lines
-
list_rect = pygame.Rect(self.x + self.pad, list_top, self.list_w, list_h)
pygame.draw.rect(surface, (45, 45, 45), list_rect)
pygame.draw.rect(surface, (110, 110, 110), list_rect, 1)
start = self.scroll
- end = min(len(self.inv.items), start + list_max_lines)
+ end = min(len(display_rows), start + list_max_lines)
y = list_top + list_pad_y
# --- clip everything to the list box ---
old_clip = surface.get_clip()
surface.set_clip(list_rect)
-
- if len(self.inv.items) == 0:
- self._blit(surface, "(empty)", self.x + self.pad + 6, y, (160, 160, 160))
+ if not display_rows:
+ empty_msg = "(empty)" if self.tab == "equipment" else "(no quest objects yet)"
+ self._blit(surface, empty_msg, self.x + self.pad + 6, y, (160, 160, 160))
else:
- for idx in range(start, end):
- it = self.inv.items[idx]
- name = it.get("name", it.get("id", ">"))
- rarity = it.get("rarity", "?")
- slot = it.get("slot", "?")
-
- if self.focus == "inv" and idx == self.cursor:
+ for ridx in range(start, end):
+ row = display_rows[ridx]
+ if ridx == self.cursor:
hl = pygame.Rect(self.x + 4, y - 2, self.list_w - 8, self.line_h)
pygame.draw.rect(surface, (60, 60, 60), hl)
-
- rarity_text = f"[{rarity}]" if rarity else ""
- right_text = f" ({slot})"
-
- x0 = self.x + self.pad + 6
- pad_right = int(_ui(["inventory_ui", "list", "pad_right"], 14)) # keep away from scrollbar
- max_w = self.list_w - pad_right
-
- prefix = f"{idx + 1:02d} "
- suffix = f" {rarity_text}{right_text}"
-
- icon_space = self.icon_size + self.icon_gap
- avail_for_name = max_w - icon_space - self.font.size(suffix)[0]
-
- name_fit = self._ellipsize(name, max(0, avail_for_name))
-
- # --- slot icon (tinted by rarity), or token icon for boss tokens ---
- icon = None
- if it.get("item_type") == "boss_token":
- icon = self._get_token_icon(it.get("boss_id"))
- if icon is None:
- icon = self._get_slot_icon(slot, rarity)
- icon_y = y + (self.line_h - self.icon_size) // 2
- if icon:
- surface.blit(icon, (x0, icon_y))
-
-
- text_x = x0 + self.icon_size + self.icon_gap
-
- # --- item name ---
- self._blit(surface, name_fit + " ", text_x, y, (230, 230, 230))
- x1 = text_x + self.font.size(name_fit + " ")[0]
-
- x0 = self.x + self.pad + 6
- if rarity_text:
- self._blit(surface, rarity_text, x1, y, self._rarity_color(rarity))
- x2 = x1 + (self.font.size(rarity_text)[0] if rarity_text else 0)
- self._blit(surface, right_text, x2, y, (230, 230, 230))
-
+ self._draw_row(surface, row, self.x + self.pad + 6, y, self.list_w)
y += self.line_h
surface.set_clip(old_clip)
- # Equipped column
- eq_x = self.x + self.list_w + self.pad
- eq_y = list_top
- self._blit(surface, "Equipped:", eq_x, eq_y - 18, (220, 220, 220))
-
- for i, slot in enumerate(self.equip_slots):
- it = self.player.equipment.get(slot)
- is_sel = (self.focus == "equip" and i == self.equip_idx)
-
- rarity = it.get("rarity", "") if it else ""
- icon = self._get_slot_icon(slot, rarity)
-
- if is_sel:
- hl = pygame.Rect(eq_x - 6, eq_y - 2, self.w - (eq_x - self.x) - self.pad, self.line_h + 2)
- pygame.draw.rect(surface, (60, 60, 60), hl)
-
- # draw icon
- if icon:
- icon_y = eq_y + (self.line_h - self.icon_size) // 2
- surface.blit(icon, (eq_x, icon_y))
-
- text_x = eq_x + (self.icon_size + self.icon_gap)
- avail = (self.x + self.w - self.pad) - text_x
-
- if not it:
- self._blit(surface, self._ellipsize(f"{slot}: (empty)", max(0, avail)),
- text_x, eq_y, (170, 170, 170))
- else:
- name = it.get("name", "?")
- tag = f"[{rarity}]" if rarity else ""
- tag_w = self.font.size(tag)[0] if tag else 0
- left = self._ellipsize(f"{slot}: {name} ", max(0, avail - tag_w))
- self._blit(surface, left, text_x, eq_y, (230, 230, 230))
- if tag:
- self._blit(surface, tag, text_x + self.font.size(left)[0], eq_y, self._rarity_color(rarity))
-
-
- eq_y += self.line_h + int(_ui(["inventory_ui", "equipped", "line_gap"], 4))
-
- # --- Quests (below equipped) ---
- quests_x = eq_x
- quests_y = eq_y + int(_ui(["inventory_ui", "quests", "gap"], 8))
- quest_w = self.w - (quests_x - self.x) - self.pad
- self._blit(surface, "Quests:", quests_x, quests_y, (220, 220, 220))
- quests_y += self.line_h
-
- active_ids = list(quest_manager.active.keys())
- max_q = int(_ui(["inventory_ui", "quests", "max_lines"], 4))
- shown = 0
- for qid in active_ids[:max_q]:
- q = quest_manager.get(qid)
- if q is None:
- continue
- st = quest_manager.active[qid]
- prog = int(st.get("progress", 0))
- complete = prog >= q.target_count()
- color = (150, 220, 150) if complete else (230, 230, 230)
- label = self._ellipsize(f"{q.name}: {prog}/{q.target_count()}", max(0, quest_w))
- self._blit(surface, label, quests_x, quests_y, color)
- quests_y += self.line_h
- shown += 1
- if not active_ids:
- self._blit(surface, "(no active quests)", quests_x, quests_y, (160, 160, 160))
- elif len(active_ids) > shown:
- self._blit(surface, f"+{len(active_ids) - shown} more", quests_x, quests_y, (160, 160, 160))
+ # Side column: equipped gear on the Equipment tab, active quests on the
+ # Quest Objects tab.
+ sel_row = sel_rows[min(self.cursor, len(sel_rows) - 1)] if sel_rows else None
+ sel_kind = sel_row.get("kind") if sel_row else None
+ if self.tab == "equipment":
+ self._draw_equipped_column(surface, list_top, sel_row, sel_kind)
+ else:
+ self._draw_quests_column(surface, list_top)
# --- Detail areas ---
stats_h = int(_ui(["inventory_ui", "details", "stats_h"], 90))
@@ -3321,7 +3575,7 @@ class InventoryUI:
self._blit(
surface,
self._ellipsize(
- "UP/DOWN move ENTER equip / unequip LEFT/RIGHT switch panel",
+ "UP/DOWN move LEFT/RIGHT switch tab ENTER equip / unequip",
self.w - self.pad * 2 - 6,
),
self.x + self.pad + 6,
@@ -3342,16 +3596,29 @@ class InventoryUI:
- selected_item = None
- if self.focus == "inv":
- selected_item = self.inv.items[self.cursor] if len(self.inv.items) > 0 else None
- selected_label = "Selected (Inventory):"
- else:
- slot = self.equip_slots[self.equip_idx]
- selected_item = self.player.equipment.get(slot)
- selected_label = f"Selected (Equipped: {slot}):"
+ sel_rows = self._rows()
+ selected_row = sel_rows[min(self.cursor, len(sel_rows) - 1)] if sel_rows else None
+ kind = selected_row.get("kind") if selected_row else None
+ selected_item = selected_row.get("item") if (selected_row and kind in ("equipped", "bag")) else None
- self._blit(surface, selected_label, self.x + self.pad + 6, stats_y + 6, (220, 220, 220))
+ self._blit(surface, "Selected:", self.x + self.pad + 6, stats_y + 6, (220, 220, 220))
+
+ # Quest objects and fairy dust show a description rather than a stat sheet.
+ if kind == "quest":
+ it = selected_row["item"]
+ qname = it.get("name", it.get("id", ">"))
+ bid = it.get("boss_id")
+ boss_name = str(BOSS_DEFS_DEFAULT.get(bid, {}).get("name", bid)) if bid else ""
+ self._blit(surface, self._ellipsize(qname, self.w - self.pad * 2 - 12),
+ self.x + self.pad + 6, stats_y + 24, (230, 230, 230))
+ self._blit(surface, f"Quest object - Boss Token ({boss_name})",
+ self.x + self.pad + 6, stats_y + 42, (190, 190, 210))
+ return
+ if kind == "dust":
+ self._blit(surface, "Fairy Dust", self.x + self.pad + 6, stats_y + 24, (230, 230, 230))
+ self._blit(surface, f"Quest object - collected {selected_row.get('count', 0)}/{FAIRY_DUST_MAX}",
+ self.x + self.pad + 6, stats_y + 42, (190, 190, 210))
+ return
if not selected_item:
@@ -3375,10 +3642,12 @@ class InventoryUI:
compare_item = None
selected_slot = selected_item.get("slot")
- # Only show comparisons when browsing inventory items.
- if self.focus == "inv":
+ # Only show comparisons when browsing bag items.
+ if kind == "bag":
if selected_slot in self.player.equipment:
compare_item = self.player.equipment.get(selected_slot)
+ if compare_item is selected_item:
+ compare_item = None
sel_stats = self._get_stats(selected_item)
cmp_stats = self._get_stats(compare_item) if compare_item else {}
@@ -3429,14 +3698,14 @@ class InventoryUI:
self._blit(surface, self._ellipsize(line, max(0, bonus_avail)), stats_x, y_bonus, (200, 200, 200))
y_bonus += 16
# --- Scroll hint + tiny scrollbar ---
- total = len(self.inv.items)
+ total = len(display_rows)
# Always show item count
- hint = f"{total} items"
+ hint = f"{total} item" + ("" if total == 1 else "s")
self._blit(surface, hint, self.x + self.pad + 6, list_top + list_h - 18, (160, 160, 160))
# Only show scrollbar if it’s actually needed
- if total > max_lines:
+ if total > list_max_lines:
# scrollbar track
track_x = self.x + self.pad + self.list_w - 8
track_y = list_top + list_pad_y
@@ -3444,7 +3713,7 @@ class InventoryUI:
pygame.draw.rect(surface, (40, 40, 40), (track_x, track_y, 4, track_h))
# thumb size based on how many lines are visible
- thumb_h = max(10, int(track_h * (max_lines / total)))
+ thumb_h = max(10, int(track_h * (list_max_lines / total)))
# thumb position based on cursor (so last item => thumb at bottom)
if total <= 1:
@@ -3543,10 +3812,7 @@ class PickupFeed:
return
x = int(_ui(["pickup_feed", "x"], 14))
- y0 = (HEIGHT // 2) + int(_ui(["pickup_feed", "y_offset_from_center"], -10))
-
-
- y = y0
+ y = int(HEIGHT * float(_ui(["pickup_feed", "y_frac"], 0.34)))
for it in self.lines:
alpha = self._alpha_for(it["t"])
if alpha <= 0:
@@ -3564,7 +3830,7 @@ class PickupFeed:
box_w = min(WIDTH - 80, w + pad_x * 2)
box_h = h + pad_y * 2
box_x = (WIDTH - box_w) // 2
- box_y = int((HEIGHT - box_h) * 0.35)
+ box_y = y
box = pygame.Surface((box_w, box_h), pygame.SRCALPHA)
box.fill((0, 0, 0, int(alpha * 0.8)))
@@ -3573,6 +3839,7 @@ class PickupFeed:
text_x = box_x + (box_w - w) // 2
text_y = box_y + pad_y
surface.blit(surf, (text_x, text_y))
+ y += box_h + self.line_gap
continue
if it.get("kind") == "plain":
@@ -3583,9 +3850,9 @@ class PickupFeed:
h = surf.get_height()
box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA)
box.fill((0, 0, 0, int(alpha * 0.65)))
- surface.blit(box, (x, y - self.pad_y))
- surface.blit(surf, (x + self.pad_x, y))
- y += h + self.line_gap
+ surface.blit(box, (x, y))
+ surface.blit(surf, (x + self.pad_x, y + self.pad_y))
+ y += h + self.pad_y * 2 + self.line_gap
continue
if it.get("kind") == "plain_rarity":
@@ -3600,12 +3867,12 @@ class PickupFeed:
h = max(surf_text.get_height(), surf_tag.get_height())
box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA)
box.fill((0, 0, 0, int(alpha * 0.65)))
- surface.blit(box, (x, y - self.pad_y))
+ surface.blit(box, (x, y))
px = x + self.pad_x
- surface.blit(surf_text, (px, y))
+ surface.blit(surf_text, (px, y + self.pad_y))
px += surf_text.get_width()
- surface.blit(surf_tag, (px, y))
- y += h + self.line_gap
+ surface.blit(surf_tag, (px, y + self.pad_y))
+ y += h + self.pad_y * 2 + self.line_gap
continue
name = it["name"]
@@ -3633,18 +3900,19 @@ class PickupFeed:
# background box per line
box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA)
box.fill((0, 0, 0, int(alpha * 0.65)))
- surface.blit(box, (x, y - self.pad_y))
+ surface.blit(box, (x, y))
# blit parts
+ ty = y + self.pad_y
px = x + self.pad_x
- surface.blit(surf_left, (px, y))
+ surface.blit(surf_left, (px, ty))
px += surf_left.get_width()
- surface.blit(surf_name, (px, y))
+ surface.blit(surf_name, (px, ty))
px += surf_name.get_width()
if surf_tag:
- surface.blit(surf_tag, (px, y))
+ surface.blit(surf_tag, (px, ty))
- y += h + self.line_gap
+ y += h + self.pad_y * 2 + self.line_gap
@@ -3671,10 +3939,16 @@ class Player:
self.coins_light = 0
self.coins_dark = 0
self.butterfly_jars = 0
+ self.butterfly_jar_seen = False # first jar per save shows the "use" key hint
# --- abilities ---
self.has_ranged = False
self.ranged_cd = 0.0
+ # Fairy combat mode (unlocked by the Fairy Dust quest)
+ self.has_fairy_combat = False
+ self.fairy_combat = False
+ self.fairy_shot_cd = 0.0
+ self.fairy_shot_count = 0
self.opened_chests: set[str] = set()
self.has_shatterstep = True
self.shatterstep_timer = 0.0
@@ -3742,6 +4016,7 @@ class Player:
self.invincibility_timer = 0.0
+ self.pogo_timer = 0.0 # down-slash bounce: jump-hold gravity + contact immunity
# --- knockback (combat feel) ---
self.knockback_vx = 0.0
@@ -3769,6 +4044,8 @@ class Player:
self.idle_anim_time = 0.0
self.idle_bob_t = 0.0
self.fairy_bob_t = random.random() * 10.0
+ self.fairy_screen_rect: pygame.Rect | None = None
+ self.fairy_anim: dict | None = None # unlock animation state
self.idle_hold_time = 0.0
self.ground_contact_timer = 0.0
self.is_idle = False
@@ -4009,6 +4286,14 @@ class Player:
self.shade = not self.shade
self.shade_door_grace = SHADE_DOOR_GRACE
+ def toggle_fairy_combat(self) -> bool:
+ """Toggle the fairy's combat mode. Returns the new state (False if locked)."""
+ if not self.has_fairy_combat:
+ return False
+ self.fairy_combat = not self.fairy_combat
+ self.fairy_shot_cd = 0.0
+ return self.fairy_combat
+
def set_attack_dir(self, camera_x: float, camera_y: float):
"""Aim the next melee/ranged attack at the mouse cursor (world space)."""
mx, my = pygame.mouse.get_pos()
@@ -4176,6 +4461,7 @@ class Player:
self.poison_timer = 0.0
self.poison_acc = 0.0
self.invincibility_timer = 0.0
+ self.pogo_timer = 0.0
self._cancel_heal()
self.health = self.max_health
@@ -4250,6 +4536,8 @@ class Player:
if self.invincibility_timer > 0:
self.invincibility_timer -= dt
+ if self.pogo_timer > 0:
+ self.pogo_timer = max(0.0, self.pogo_timer - dt)
if self.charge_regen_lock > 0:
self.charge_regen_lock = max(0.0, self.charge_regen_lock - dt)
@@ -4340,7 +4628,7 @@ class Player:
gravity = GRAVITY_SHADE if self.shade else GRAVITY_LIGHT
- if self.vel_y < 0 and (not key_pressed(keys, "jump")):
+ if self.vel_y < 0 and (not key_pressed(keys, "jump")) and self.pogo_timer <= 0:
gravity *= JUMP_RELEASE_GRAVITY_MULT
self.vel_y += gravity
dy = self.vel_y
@@ -4456,6 +4744,33 @@ class Player:
fx = px + (pw - fw) // 2 + off
fairy_cy = py + int(ph * 0.18)
bob = int(math.sin(self.fairy_bob_t * 2.2) * 3)
+
+ if self.fairy_anim:
+ # Unlock sequence: detach -> spin -> return to the default hover.
+ anim = self.fairy_anim
+ t01 = min(1.0, max(0.0, anim.get("t", 0.0) / max(0.001, anim.get("duration", 2.4))))
+ if t01 < 0.3:
+ detach = t01 / 0.3
+ elif t01 < 0.65:
+ detach = 1.0
+ else:
+ detach = max(0.0, (1.0 - t01) / 0.35)
+ dirx = 1.0 if self.facing_right else -1.0
+ base_cx = fx + fw / 2
+ base_cy = fairy_cy + bob
+ cx_f = base_cx + dirx * 26.0 * detach
+ cy_f = base_cy - 58.0 * detach
+ angle = 0.0
+ if 0.3 <= t01 <= 0.65:
+ angle = 720.0 * ((t01 - 0.3) / 0.35)
+ rotated = pygame.transform.rotate(img, angle)
+ rx = int(cx_f - rotated.get_width() / 2)
+ ry = int(cy_f - rotated.get_height() / 2)
+ self.fairy_screen_rect = pygame.Rect(rx, ry, rotated.get_width(), rotated.get_height())
+ surface.blit(rotated, (rx, ry))
+ return
+
+ self.fairy_screen_rect = pygame.Rect(fx, fairy_cy - fh // 2 + bob, fw, fh)
surface.blit(img, (fx, fairy_cy - fh // 2 + bob))
def draw(self, surface, cx, cy):
@@ -6030,6 +6345,10 @@ def trigger_wall_blocking(rect) -> bool:
is simply present until then).
Walls with no condition are inert placeholders (never block)."""
meta = platform_meta.get(id(rect), {})
+ # A miniboss gate that has already opened stays open even if the miniboss
+ # respawns later this session.
+ if meta.get("open_on_miniboss_dead") and trigger_wall_key(rect) in OPENED_MINIBOSS_WALLS:
+ return False
qid = meta.get("quest")
if meta.get("lock_when_pass"):
present = id(rect) in LOCKED_TRIGGER_WALLS
@@ -6048,7 +6367,12 @@ def trigger_wall_blocking(rect) -> bool:
return False # inert placeholder
if meta.get("open_on_miniboss_dead"):
- present = present and miniboss_alive_in_room()
+ if miniboss_alive_in_room():
+ present = present and True
+ else:
+ # Latch the gate open so it cannot close again if the miniboss respawns.
+ OPENED_MINIBOSS_WALLS.add(trigger_wall_key(rect))
+ present = False
if meta.get("open_on_boss_dead"):
present = present and boss_alive_in_room()
return present
@@ -6064,6 +6388,8 @@ def enemy_trigger_wall_blocking(rect) -> bool:
"""
meta = platform_meta.get(id(rect), {})
if meta.get("open_on_miniboss_dead"):
+ if trigger_wall_key(rect) in OPENED_MINIBOSS_WALLS:
+ return False
return miniboss_alive_in_room()
if meta.get("open_on_boss_dead"):
return boss_alive_in_room()
@@ -6074,6 +6400,15 @@ def enemy_trigger_wall_blocking(rect) -> bool:
LOCKED_TRIGGER_WALLS: set[int] = set()
TRIGGER_WALL_TOUCH: dict[int, bool] = {}
+# Trigger walls released by slaying the room's miniboss stay open for the rest
+# of the session, even if the miniboss later respawns (saving at a lantern in
+# another room). Keyed by room id + wall geometry so it survives room reloads.
+OPENED_MINIBOSS_WALLS: set[str] = set()
+
+
+def trigger_wall_key(rect) -> str:
+ return f"{ROOM.id}:{rect.x},{rect.y},{rect.width},{rect.height}"
+
def miniboss_alive_in_room() -> bool:
return any(getattr(en, "drop_profile", "") == "miniboss" and en.health > 0 for en in enemies)
@@ -6256,6 +6591,10 @@ def draw_layerable(surface, cx, cy):
items.append((getattr(h, "layer", 0), 0, "house", h))
for qg in quest_givers:
items.append((getattr(qg, "layer", 0), 0, "questgiver", qg))
+ for fs in fairy_spots:
+ items.append((getattr(fs, "layer", 0), 0, "fairyspot", fs))
+ for fd in fairy_dusts:
+ items.append((getattr(fd, "layer", 0), 0, "fairydust", fd))
if diary_obj is not None:
items.append((getattr(diary_obj, "layer", 0), 0, "diary", diary_obj))
if reward_chest is not None:
@@ -6303,7 +6642,7 @@ def draw_layerable(surface, cx, cy):
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"):
+ if kind in ("light", "savepoint", "jar", "house", "questgiver", "fairyspot", "fairydust", "diary", "rewardchest", "rewardability", "door", "enemy", "npc"):
obj.draw(surface, cx, cy)
elif kind == "platform":
pygame.draw.rect(
@@ -6602,6 +6941,9 @@ loot_drops: list[LootPickup] = []
shockwaves: list[Shockwave] = []
coin_drops: list[CoinPickup] = []
token_drops: list = []
+# Uncollected boss tokens persist across room changes and saves, keyed by room:
+# room_id -> [{"boss_id": str, "x": int, "y": int}]
+PENDING_TOKEN_DROPS: dict = {}
projectiles: list[Projectile] = []
boss_projectiles: list[BossProjectile] = []
boss_aoes: list[BossAoe] = []
@@ -6613,6 +6955,13 @@ save_points: list[SavePoint] = []
butterfly_jars: list[ButterflyJar] = []
houses: list[HouseObject] = []
dust_puffs: list[DustPuff] = []
+fairy_spots: list = []
+fairy_dusts: list = []
+fairy_speech_text: str | None = None
+fairy_speech_timer: float = 0.0
+fairy_idle_timer: float = float(_fairy(["idle_interval"], 45.0))
+fairy_shown_auto: set = set()
+fairy_fired_spots: set = set()
class QuestGiver:
"""Interactable environment object that hands out a quest."""
INTERACT_RANGE = 70
@@ -6677,6 +7026,272 @@ def interact_quest_giver(qg: QuestGiver):
pickup_feed.push_plain(f"{q.name}: {prog}/{q.target_count()}")
+class FairySpot:
+ """A spot where the player can press F to have the fairy comment."""
+ INTERACT_RANGE = 80
+
+ def __init__(self, x: int, y: int, dialogue_id: str, auto: bool = False, quest_id: str = ""):
+ self.rect = pygame.Rect(int(x), int(y), 28, 28)
+ self.dialogue_id = str(dialogue_id)
+ self.quest_id = str(quest_id)
+ self.auto = bool(auto)
+ self.bob_t = random.random() * 10.0
+
+ def draw(self, surface, cx, cy):
+ x = int(self.rect.centerx - cx)
+ y = int(self.rect.centery - cy + math.sin(self.bob_t * 2.0) * 3)
+ pygame.draw.circle(surface, (200, 180, 255), (x, y), 6, 1)
+ pygame.draw.circle(surface, (255, 255, 255), (x, y), 2)
+
+
+def fairy_line(dialogue_id: str, shade: bool) -> str | None:
+ """Resolve a dialogue id to text, picking the light/shade variant."""
+ entry = _fairy(["lines", dialogue_id], None)
+ if isinstance(entry, str):
+ return entry
+ if not isinstance(entry, dict):
+ return None
+ text = entry.get("shade") if shade else entry.get("light")
+ if text is None:
+ text = entry.get("light") if shade else entry.get("shade")
+ if text is None:
+ text = entry.get("text")
+ return str(text) if text is not None else None
+
+
+def fairy_say(dialogue_id: str, duration: float | None = None):
+ """Make the fairy show a single speech bubble for the given dialogue id."""
+ global fairy_speech_text, fairy_speech_timer
+ text = fairy_line(dialogue_id, getattr(player, "shade", False))
+ if not text:
+ return
+ fairy_speech_text = text
+ fairy_speech_timer = float(duration if duration is not None else _fairy(["bubble_duration"], 3.5))
+
+
+def fairy_say_text(text: str, duration: float | None = None):
+ """Speak an arbitrary (dynamic) line, e.g. one that includes a bound key."""
+ global fairy_speech_text, fairy_speech_timer
+ if not text:
+ return
+ fairy_speech_text = str(text)
+ fairy_speech_timer = float(duration if duration is not None else _fairy(["bubble_duration"], 3.5))
+
+
+def get_near_fairy_spot(player_obj, spot_list, range_px: int | None = None) -> "FairySpot | None":
+ if range_px is None:
+ range_px = int(_fairy(["talk_range"], FairySpot.INTERACT_RANGE))
+ best = None
+ best_d = None
+ for fs in spot_list:
+ dx = player_obj.rect.centerx - fs.rect.centerx
+ dy = player_obj.rect.centery - fs.rect.centery
+ d = math.hypot(dx, dy)
+ if d <= range_px and (best is None or d < best_d):
+ best = fs
+ best_d = d
+ return best
+
+
+def get_near_fairy_dust(player_obj, dust_list, range_px: int | None = None) -> "FairyDust | None":
+ if range_px is None:
+ range_px = FairyDust.INTERACT_RANGE
+ best = None
+ best_d = None
+ for fd in dust_list:
+ dx = player_obj.rect.centerx - fd.rect.centerx
+ dy = player_obj.rect.centery - fd.rect.centery
+ d = math.hypot(dx, dy)
+ if d <= range_px and (best is None or d < best_d):
+ best = fd
+ best_d = d
+ return best
+
+
+def collect_fairy_dust(fd: "FairyDust"):
+ """Pick up a fairy dust: records it, advances the quest, and voices a line."""
+ if fd.key:
+ FAIRY_DUST_COLLECTED.add(fd.key)
+ if fd in fairy_dusts:
+ fairy_dusts.remove(fd)
+ just_completed = False
+ if "quest_fairy_dust" in quest_manager.active:
+ was_complete = quest_manager.is_complete("quest_fairy_dust")
+ quest_manager.progress("quest_fairy_dust", 1)
+ just_completed = (not was_complete) and quest_manager.is_complete("quest_fairy_dust")
+ collected = len(FAIRY_DUST_COLLECTED)
+ pickup_feed.push_center(f"Fairy Dust collected ({collected}/{FAIRY_DUST_MAX})", duration=2.5)
+ if just_completed:
+ fairy_say("quest_return", duration=5.0)
+ else:
+ lines = _fairy(["dust_lines"], [])
+ if isinstance(lines, list) and lines:
+ fairy_say(random.choice(lines))
+
+
+def interact_fairy_quest(qid: str):
+ """Talk to the fairy about the Fairy Dust quest (accept / progress / redeem)."""
+ q = quest_manager.get(qid)
+ if q is None:
+ return
+ target = q.target_count()
+ if quest_manager.is_complete(qid):
+ quest_manager.turn_in(qid)
+ start_fairy_unlock_animation()
+ return
+ if qid in quest_manager.active:
+ prog = int(quest_manager.active[qid].get("progress", 0))
+ fairy_say("quest_intro")
+ pickup_feed.push_plain(f"Fairy Dust: {prog}/{target} collected.")
+ return
+ if qid in quest_manager.completed_one_time or qid in quest_manager.ever_completed:
+ fairy_say("quest_done")
+ return
+ # Accept the quest, crediting any dust already collected this run.
+ quest_manager.accept(qid)
+ if qid == "quest_fairy_dust" and qid in quest_manager.active:
+ quest_manager.active[qid]["progress"] = min(target, len(FAIRY_DUST_COLLECTED))
+ fairy_say("quest_intro")
+ # If already complete, redeem immediately on this press.
+ if quest_manager.is_complete(qid):
+ interact_fairy_quest(qid)
+
+
+def interact_fairy_spot(fs: FairySpot):
+ if getattr(fs, "quest_id", ""):
+ interact_fairy_quest(fs.quest_id)
+ return
+ fairy_say(fs.dialogue_id)
+
+
+def fairy_fire_auto_on_enter(room: Room):
+ """Fairy's self-initiated line when the player enters a configured room."""
+ entry = _fairy(["auto", room.id, "on_enter"], None)
+ if not isinstance(entry, str) or not entry:
+ return
+ key = f"{room.id}:{entry}"
+ if key in fairy_shown_auto:
+ return
+ fairy_shown_auto.add(key)
+ fairy_say(entry)
+
+
+def start_fairy_unlock_animation():
+ """Fairy detaches from the player, spins (unlocking combat), then returns."""
+ player.fairy_anim = {"t": 0.0, "duration": 2.4, "unlocked": False}
+ fairy_say("quest_unlock", duration=2.0)
+
+
+def update_fairy_animation(dt: float):
+ anim = player.fairy_anim
+ if not anim:
+ return
+ anim["t"] = anim.get("t", 0.0) + dt
+ duration = max(0.001, anim.get("duration", 2.4))
+ if (not anim.get("unlocked")) and anim["t"] >= duration * 0.5:
+ anim["unlocked"] = True
+ player.has_fairy_combat = True
+ player.fairy_combat = False
+ mode_key = key_name(key_for("fairy_mode")).upper()
+ fairy_say_text(f"You found them all! Press {mode_key} to fight!", duration=5.0)
+ pickup_feed.push_center(f"Fairy Combat unlocked - press {mode_key} to toggle Fairy Mode!", duration=5.0)
+ if anim["t"] >= duration:
+ player.fairy_anim = None
+
+
+def _segment_blocked(x1, y1, x2, y2, rects) -> bool:
+ """True if the segment (x1,y1)-(x2,y2) crosses any of the given rects."""
+ if not rects:
+ return False
+ for r in rects:
+ try:
+ if r.clipline((int(x1), int(y1)), (int(x2), int(y2))):
+ return True
+ except Exception:
+ continue
+ return False
+
+
+def update_fairy_combat(dt: float, enemy_list: list, projectile_list: list, solid_rects=None):
+ """Auto-fire the fairy at nearby enemies while combat mode is on.
+ Each shot deals the player's ranged damage and drains Charge; when Charge
+ runs out the fairy switches herself back to non-combat mode. Targets need
+ line of sight, so the fairy never shoots through walls/platforms."""
+ if solid_rects is None:
+ solid_rects = []
+ if not (player.has_fairy_combat and player.fairy_combat):
+ return
+ if player.charge < FAIRY_SHOT_CHARGE_COST:
+ player.fairy_combat = False
+ fairy_say("combat_empty")
+ return
+ player.fairy_shot_cd = max(0.0, player.fairy_shot_cd - dt)
+ if player.fairy_shot_cd > 0.0:
+ return
+
+ fr = player.fairy_screen_rect
+ if fr is not None:
+ fx = float(fr.centerx) + camera_x
+ fy = float(fr.centery) + camera_y
+ else:
+ fx = float(player.rect.centerx)
+ fy = float(player.rect.centery)
+
+ target = None
+ best_d2 = FAIRY_SHOT_RANGE * FAIRY_SHOT_RANGE
+ for en in enemy_list:
+ if getattr(en, "health", 0) <= 0:
+ continue
+ dx = en.rect.centerx - fx
+ dy = en.rect.centery - fy
+ d2 = dx * dx + dy * dy
+ if d2 <= best_d2 and not _segment_blocked(fx, fy, en.rect.centerx, en.rect.centery, solid_rects):
+ best_d2 = d2
+ target = en
+ if target is None:
+ return
+
+ # Aim at the target's center, leading its velocity a little so the shot
+ # lands more often; a small spread keeps it from being perfectly accurate.
+ tx, ty = target.rect.center
+ lead = math.hypot(tx - fx, ty - fy) / max(1.0, FAIRY_PROJECTILE_SPEED)
+ aim_x = tx + getattr(target, "vel_x", 0.0) * lead * 0.6 - fx
+ aim_y = ty + getattr(target, "vel_y", 0.0) * lead * 0.6 - fy
+ length = math.hypot(aim_x, aim_y)
+ if length < 1.0:
+ aim_x, aim_y, length = 1.0, 0.0, 1.0
+ aim_x /= length
+ aim_y /= length
+ spread = math.radians(random.uniform(-FAIRY_SPREAD_DEG, FAIRY_SPREAD_DEG))
+ ca, sa = math.cos(spread), math.sin(spread)
+ dir_x = aim_x * ca - aim_y * sa
+ dir_y = aim_x * sa + aim_y * ca
+
+ melee_dmg = 2.0 if player.shade else 1.0
+ projectile_list.append(FairyProjectile(fx, fy, dir_x, dir_y, melee_dmg * FAIRY_DAMAGE_MULT))
+ player._spend_charge(FAIRY_SHOT_CHARGE_COST)
+ player.charge_regen_lock = max(player.charge_regen_lock, 0.4)
+ player.fairy_shot_cd = FAIRY_SHOT_COOLDOWN
+ player.fairy_shot_count += 1
+ if player.fairy_shot_count % FAIRY_VOICE_EVERY == 0:
+ lines = _fairy(["attack_lines"], [])
+ if isinstance(lines, list) and lines:
+ fairy_say(random.choice(lines))
+ if player.charge < FAIRY_SHOT_CHARGE_COST:
+ player.fairy_combat = False
+ fairy_say("combat_empty")
+
+
+def fairy_dust_quest_active() -> bool:
+ """Fairy Dust only appears in the world once its quest is active. Uses a
+ globals() lookup because this is called during module import (before
+ quest_manager is defined) to seed the starting room."""
+ qm = globals().get("quest_manager")
+ if qm is None:
+ return False
+ return "quest_fairy_dust" in qm.active
+
+
def spawn_room_props(room: Room, player_obj: "Player"):
reward_chest_obj = None
reward_ability_obj = None
@@ -6685,6 +7300,8 @@ def spawn_room_props(room: Room, player_obj: "Player"):
butterfly_jars: list[ButterflyJar] = []
houses: list[HouseObject] = []
quest_givers: list[QuestGiver] = []
+ fairy_spots_list: list[FairySpot] = []
+ fairy_dusts_list: list[FairyDust] = []
diary_obj = None
props = room.props
@@ -6744,8 +7361,21 @@ def spawn_room_props(room: Room, player_obj: "Player"):
diary_obj.layer = int(p.get("layer", 0))
elif p_type == "house":
houses.append(HouseObject(x, y, layer=int(p.get("layer", 0))))
+ elif p_type == "fairy_spot":
+ fs = FairySpot(x, y, str(p.get("dialogue_id", p.get("dialogue", ""))),
+ auto=bool(p.get("auto", False)), quest_id=str(p.get("quest_id", "")))
+ fs.layer = int(p.get("layer", 0))
+ fairy_spots_list.append(fs)
+ elif p_type == "fairy_dust":
+ if fairy_dust_quest_active():
+ key = jar_key(room.id, x, y)
+ if key not in FAIRY_DUST_COLLECTED:
+ fd = FairyDust(x, y, key=key)
+ fd.layer = int(p.get("layer", 0))
+ fairy_dusts_list.append(fd)
- return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, houses, quest_givers, diary_obj
+ return (reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars,
+ houses, quest_givers, diary_obj, fairy_spots_list, fairy_dusts_list)
def rebuild_room_butterfly_jars():
@@ -6766,7 +7396,8 @@ def rebuild_room_butterfly_jars():
butterfly_jars.append(ButterflyJar(x, y, key=key))
-reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player)
+reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj, fairy_spots, fairy_dusts = spawn_room_props(ROOM, player)
+fairy_fire_auto_on_enter(ROOM)
def grant_full_set(set_id: str, rarity: str, equip: bool = True) -> bool:
slots = ["helmet", "chest", "pants"]
@@ -6817,7 +7448,7 @@ def apply_room(room: Room, set_player_spawn=True):
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
global boss_projectiles, boss_aoes
global shatter_platforms, shatter_aoes, tether_shots
- global light_sources, save_points, butterfly_jars, houses
+ global light_sources, save_points, butterfly_jars, houses, fairy_spots, fairy_dusts
global dust_puffs
global dust_puffs
global reward_chest, reward_ability, quest_givers, diary_obj
@@ -6851,7 +7482,7 @@ def apply_room(room: Room, set_player_spawn=True):
loot_drops = []
shockwaves = []
coin_drops = []
- token_drops = []
+ token_drops = restore_token_drops(ROOM.id)
projectiles = []
boss_projectiles = []
boss_aoes = []
@@ -6862,6 +7493,8 @@ def apply_room(room: Room, set_player_spawn=True):
save_points = []
butterfly_jars = []
houses = []
+ fairy_spots = []
+ fairy_dusts = []
dust_puffs = []
dust_puffs = []
ROOM_DROP_MULT = 1.0
@@ -6873,7 +7506,8 @@ 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, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player)
+ reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj, fairy_spots, fairy_dusts = spawn_room_props(ROOM, player)
+ fairy_fire_auto_on_enter(ROOM)
if set_player_spawn:
player.start_x = int(ROOM.player_spawn[0])
@@ -6899,7 +7533,7 @@ def reset_room_combat_state():
loot_drops = []
shockwaves = []
coin_drops = []
- token_drops = []
+ token_drops = restore_token_drops(ROOM.id)
projectiles = []
boss_projectiles = []
boss_aoes = []
@@ -6967,6 +7601,8 @@ def try_room_transition(keys):
if player.rect.colliderect(ex["rect"]):
target = ex.get("to", None)
+ if not target:
+ continue # placeholder exit (e.g. a refight door with no destination)
spawn = ex.get("spawn", None)
spawn_id = ex.get("spawn_id", None)
@@ -7218,6 +7854,30 @@ class QuestManager:
completed.append(qid)
return completed
+ def progress_collect(self, target: str, amount: int = 1) -> list[str]:
+ """Progress every active 'collect' quest matching this target.
+ Returns quest ids that just became complete."""
+ completed = []
+ for qid in list(self.active.keys()):
+ q = self.quests.get(qid)
+ if q is None:
+ continue
+ obj = q.objective
+ if obj.get("kind") != "collect" or str(obj.get("target", "")) != target:
+ continue
+ was = self.is_complete(qid)
+ self.progress(qid, amount)
+ if not was and self.is_complete(qid):
+ completed.append(qid)
+ return completed
+
+ def boss_token_count_held(self) -> int:
+ """Boss tokens the player currently owns (carried in the bag or slotted)."""
+ n = sum(1 for it in inventory.items
+ if isinstance(it, dict) and it.get("item_type") == "boss_token")
+ n += len(boss_diary.slotted)
+ return n
+
def turn_in(self, quest_id: str) -> dict | None:
"""Completes an active quest. Returns a reward result:
{"item": dict|None, "coins": int, "tokens": int}."""
@@ -7307,6 +7967,24 @@ QUEST_DEFS_DEFAULT = [
"guaranteed_item": {"item_id": "necklace_placeholder"},
},
},
+ {
+ "id": "quest_fairy_dust",
+ "name": "Fairy Dust",
+ "description": "Collect 5 Fairy Dust scattered across the rooms.",
+ "type": "one_time",
+ "giver": "object:fairy_spot",
+ "objective": {"kind": "collect", "target": "fairy_dust", "count": 5},
+ "reward": {},
+ },
+ {
+ "id": "quest_combat_master_tokens",
+ "name": "Trophy Hunter",
+ "description": "Bring the Combat Master 3 boss tokens.",
+ "type": "one_time",
+ "giver": "npc:combat_master",
+ "objective": {"kind": "collect", "target": "boss_token", "count": 3},
+ "reward": {},
+ },
]
quest_manager = QuestManager()
@@ -7472,6 +8150,12 @@ class TokenPickup:
pygame.draw.circle(surface, (10, 110, 105), (x + 8, y + 8), 7, 2)
+def restore_token_drops(room_id: str) -> list:
+ """Rebuild the uncollected boss-token pickups for a room."""
+ return [TokenPickup(d.get("x", 0), d.get("y", 0), d.get("boss_id", ""))
+ for d in PENDING_TOKEN_DROPS.get(room_id, [])]
+
+
class DiaryObject:
"""Interactable book in town: opens the boss-token diary menu."""
INTERACT_RANGE = 70
@@ -7576,7 +8260,15 @@ class DiaryUI:
self.cols = 4
def _rebuild(self):
- self.boss_ids = list(BOSS_DEFS_DEFAULT.keys())
+ # Only show tokens the player actually owns (slotted in the diary or
+ # carried in the inventory), in canonical boss order. This means the
+ # grid has no empty/uncollected slots, and owned tokens re-sort into
+ # the correct order as more are collected.
+ self.boss_ids = [
+ bid for bid in BOSS_DEFS_DEFAULT
+ if boss_diary.tier(bid) >= 0 or find_carried_boss_token(self.inv, bid) is not None
+ ]
+ self.grid_cursor = min(self.grid_cursor, len(self.boss_ids) - 1) if self.boss_ids else 0
def toggle(self):
self.visible = not self.visible
@@ -7663,6 +8355,7 @@ class DiaryUI:
self.inv.items.remove(it)
boss_diary.slot(boss_id, int(choice.get("tier", 0)))
pickup_feed.push_plain(f"{it.get('name')} added to diary.")
+ self._rebuild()
elif action == "unslot":
tier = boss_diary.unslot(boss_id)
if tier is None:
@@ -7670,6 +8363,7 @@ class DiaryUI:
token = make_boss_token(boss_id, tier)
self.inv.add(token)
pickup_feed.push_plain(f"{token['name']} returned to inventory.")
+ self._rebuild()
elif action == "fight":
self.visible = False
start_refight(boss_id, choice.get("variant", "normal"))
@@ -7682,6 +8376,16 @@ class DiaryUI:
return
n = len(self.boss_ids)
if n == 0:
+ panel_w, panel_h = 360, 96
+ x = (WIDTH - panel_w) // 2
+ y = (HEIGHT - panel_h) // 2
+ panel = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA)
+ panel.fill((12, 12, 20, 235))
+ surface.blit(panel, (x, y))
+ pygame.draw.rect(surface, (100, 100, 130), (x, y, panel_w, panel_h), 2)
+ surface.blit(self.font.render("Boss Diary", True, (235, 230, 215)), (x + 14, y + 10))
+ msg = self.small_font.render("No boss tokens collected yet.", True, (180, 180, 180))
+ surface.blit(msg, (x + 14, y + 52))
return
cell_w = 120
@@ -7932,7 +8636,9 @@ def save_game(name: str = "auto") -> str:
"coins_light": player.coins_light,
"coins_dark": player.coins_dark,
"butterfly_jars": player.butterfly_jars,
+ "butterfly_jar_seen": player.butterfly_jar_seen,
"has_ranged": player.has_ranged,
+ "has_fairy_combat": player.has_fairy_combat,
"opened_chests": sorted(player.opened_chests),
"equipment": {s: it for s, it in player.equipment.items()},
},
@@ -7946,6 +8652,9 @@ def save_game(name: str = "auto") -> str:
"boss_diary": {"slotted": boss_diary.slotted},
"defeated_bosses": sorted(DEFEATED_BOSSES),
"broken_doors": sorted(BROKEN_DOORS),
+ "pending_token_drops": PENDING_TOKEN_DROPS,
+ "opened_miniboss_walls": sorted(OPENED_MINIBOSS_WALLS),
+ "fairy_dust_collected": sorted(FAIRY_DUST_COLLECTED),
"lantern_respawn": (
{"room_id": LANTERN_RESPAWN_ROOM, "x": LANTERN_RESPAWN_POS[0], "y": LANTERN_RESPAWN_POS[1]}
if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS else None
@@ -7967,7 +8676,11 @@ def apply_save_data(data: dict):
player.coins_light = int(pdata.get("coins_light", 0))
player.coins_dark = int(pdata.get("coins_dark", 0))
player.butterfly_jars = int(pdata.get("butterfly_jars", 0))
+ player.butterfly_jar_seen = bool(pdata.get("butterfly_jar_seen", False))
player.has_ranged = bool(pdata.get("has_ranged", False))
+ player.has_fairy_combat = bool(pdata.get("has_fairy_combat", False))
+ player.fairy_combat = False
+ player.fairy_shot_cd = 0.0
player.opened_chests = set(str(c) for c in (pdata.get("opened_chests") or []))
# migrate the legacy single-flag save (only the reward-room default chest existed)
if pdata.get("reward_chest_opened"):
@@ -7996,9 +8709,17 @@ def apply_save_data(data: dict):
DEFEATED_BOSSES.update(data.get("defeated_bosses") or [])
BROKEN_DOORS.clear()
BROKEN_DOORS.update(data.get("broken_doors") or [])
+ PENDING_TOKEN_DROPS.clear()
+ for rid, drops in (data.get("pending_token_drops") or {}).items():
+ if isinstance(drops, list):
+ PENDING_TOKEN_DROPS[str(rid)] = [d for d in drops if isinstance(d, dict)]
# Butterfly jars and minibosses respawn on reload (session-only); corpse too.
COLLECTED_JARS.clear()
+ FAIRY_DUST_COLLECTED.clear()
+ FAIRY_DUST_COLLECTED.update(data.get("fairy_dust_collected") or [])
DEFEATED_MINIBOSSES.clear()
+ OPENED_MINIBOSS_WALLS.clear()
+ OPENED_MINIBOSS_WALLS.update(str(k) for k in (data.get("opened_miniboss_walls") or []))
JAR_DEATH_ROOM = None
corpse = None
@@ -8046,7 +8767,9 @@ def fresh_save_data() -> dict:
"coins_light": 0,
"coins_dark": 0,
"butterfly_jars": 0,
+ "butterfly_jar_seen": False,
"has_ranged": False,
+ "has_fairy_combat": False,
"opened_chests": [],
"equipment": {},
},
@@ -8055,6 +8778,9 @@ def fresh_save_data() -> dict:
"boss_diary": {"slotted": {}},
"defeated_bosses": [],
"broken_doors": [],
+ "pending_token_drops": {},
+ "opened_miniboss_walls": [],
+ "fairy_dust_collected": [],
"lantern_respawn": None,
}
@@ -8516,6 +9242,29 @@ while running:
if not paused:
pickup_feed.update(dt)
town_title.update(dt)
+ update_fairy_animation(dt)
+ if fairy_speech_timer > 0.0:
+ fairy_speech_timer = max(0.0, fairy_speech_timer - dt)
+ if fairy_speech_timer <= 0.0:
+ fairy_speech_text = None
+ else:
+ fairy_idle_timer -= dt
+ if fairy_idle_timer <= 0.0:
+ fairy_idle_timer = float(_fairy(["idle_interval"], 45.0))
+ if (not inv_ui.visible) and (not trade_ui.visible) and (not diary_ui.visible):
+ idle_lines = _fairy(["idle_lines"], [])
+ if isinstance(idle_lines, list) and idle_lines:
+ fairy_say(random.choice(idle_lines))
+ for fs in fairy_spots:
+ if not getattr(fs, "auto", False):
+ continue
+ key = f"{ROOM.id}:{fs.rect.centerx},{fs.rect.centery}:{fs.dialogue_id}"
+ if key in fairy_fired_spots:
+ continue
+ if get_near_fairy_spot(player, [fs]) is not None:
+ fairy_fired_spots.add(key)
+ fairy_say(fs.dialogue_id)
+ break
for e in pygame.event.get():
@@ -8627,6 +9376,14 @@ while running:
pass
else:
interact_quest_giver(near_qg)
+ else:
+ near_fd = get_near_fairy_dust(player, fairy_dusts)
+ if near_fd:
+ collect_fairy_dust(near_fd)
+ else:
+ near_fs = get_near_fairy_spot(player, fairy_spots)
+ if near_fs:
+ interact_fairy_spot(near_fs)
if key_is("pause", e.key):
if paused:
@@ -8656,6 +9413,12 @@ while running:
en.reality_break_glow = True
player.reality_break_flash = REALITY_BREAK_FLASH_DURATION
+ if key_is("fairy_mode", e.key) and player.has_fairy_combat:
+ if player.toggle_fairy_combat():
+ fairy_say("combat_on")
+ else:
+ fairy_say("combat_off")
+
if key_is("use_butterfly_jar", e.key):
if player.consume_butterfly_jar():
pickup_feed.push_center("Butterfly jar consumed - Charge restored!", duration=2.0)
@@ -9048,12 +9811,16 @@ while running:
if any(box.colliderect(en.rect) for box in atk_boxes):
player.swing_hit_ids.add(eid)
en.take_damage(dmg)
- # Downward strike bounce: hitting an enemy from above while
- # swinging down bounces the player up (stomp/pogo).
- if player.attack_dir_y > 0.3 and player.rect.bottom <= en.rect.top + 6:
- player.vel_y = min(player.vel_y, -8.0)
+ # Downward strike bounce (stomp/pogo): hitting from above while
+ # swinging down launches the player up. The grace window keeps
+ # jump-hold gravity off and blocks contact damage while escaping.
+ if player.attack_dir_y > 0.3 and player.rect.centery <= en.rect.centery:
+ player.vel_y = min(player.vel_y, DOWN_SLASH_BOUNCE)
+ if player.rect.bottom > en.rect.top:
+ player.rect.bottom = en.rect.top
player.on_ground = False
player.ground_platform = None
+ player.pogo_timer = max(player.pogo_timer, POGO_GRACE)
# Dash interactions
if player.is_dashing():
@@ -9079,14 +9846,17 @@ while running:
en.apply_stun(DASH_STUN_NORMAL)
- # Enemy contact damage (only when player is Light)
+ # Enemy contact damage (only when player is Light, and not during a pogo escape)
for en in enemies:
- if (not player.shade) and player.rect.colliderect(en.rect):
+ if (not player.shade) and player.pogo_timer <= 0 and player.rect.colliderect(en.rect):
did_hit = player.take_damage(en.contact_damage)
if did_hit:
player.apply_knockback(en.rect, strength_px_per_frame=12.0, duration=0.20, pop_up_vel=-3.5)
+ # Fairy combat mode: auto-fire at nearby enemies (drains Charge).
+ update_fairy_combat(dt, enemies, projectiles, collision_platforms)
+
# Update projectiles
for pr in projectiles:
pr.update(dt, collision_platforms, enemies, ability_doors)
@@ -9201,7 +9971,12 @@ while running:
mark_boss_defeated(ROOM.id)
bid = boss_id_for_room(ROOM.id)
if bid and ACTIVE_REFIGHT is None:
- token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid))
+ tx = en.rect.centerx - 8
+ ty = en.rect.top - 20
+ token_drops.append(TokenPickup(tx, ty, bid))
+ # Remember the drop so it survives leaving the room / reloading.
+ PENDING_TOKEN_DROPS.setdefault(ROOM.id, []).append(
+ {"boss_id": bid, "x": int(tx), "y": int(ty)})
# A defeated flying boss takes its summoned minions down with it.
for other in enemies:
if other is en:
@@ -9277,10 +10052,19 @@ while running:
player.butterfly_jars += 1
if bj.key:
COLLECTED_JARS.add(bj.key)
- pickup_feed.push_center(
- f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})",
- duration=2.5,
- )
+ if not player.butterfly_jar_seen:
+ player.butterfly_jar_seen = True
+ use_key = key_name(key_for("use_butterfly_jar")).upper()
+ pickup_feed.push_center(
+ f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})"
+ f" - press {use_key} to use",
+ duration=4.5,
+ )
+ else:
+ pickup_feed.push_center(
+ 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)
@@ -9289,7 +10073,19 @@ while running:
token = make_boss_token(t.boss_id)
inventory.add(token)
pickup_feed.push_plain(f"Collected {token['name']}!")
+ for qid in quest_manager.progress_collect("boss_token", 1):
+ q = quest_manager.get(qid)
+ if q:
+ pickup_feed.push_plain(f"Quest fulfilled: {q.name} - return to the quest object!")
token_drops.remove(t)
+ pend = PENDING_TOKEN_DROPS.get(ROOM.id)
+ if pend:
+ for i, d in enumerate(pend):
+ if d.get("boss_id") == t.boss_id:
+ pend.pop(i)
+ break
+ if not pend:
+ PENDING_TOKEN_DROPS.pop(ROOM.id, None)
# Corpse: touching it grants back the coins lost on death.
if corpse is not None and corpse.room_id == ROOM.id:
@@ -9356,6 +10152,16 @@ while running:
corpse.draw(screen, camera_x, camera_y)
player.draw(screen, camera_x, camera_y)
+ # Fairy companion bubble: active line, or a talk prompt when at a fairy spot.
+ fairy_rect = player.fairy_screen_rect
+ if fairy_rect is not None:
+ if fairy_speech_text:
+ draw_speech_bubble(screen, fairy_speech_text, fairy_rect.centerx, fairy_rect.top)
+ elif get_near_fairy_dust(player, fairy_dusts):
+ draw_speech_bubble(screen, "(Press F to collect)", fairy_rect.centerx, fairy_rect.top)
+ elif get_near_fairy_spot(player, fairy_spots):
+ draw_speech_bubble(screen, "(Press F to talk)", fairy_rect.centerx, fairy_rect.top)
+
# NPC greeting bubble (shown near the player)
greet_npc = get_near_npc(player, npcs)
if greet_npc:
diff --git a/light_and_dark/saves/new rooms 2.json b/light_and_dark/saves/new rooms 2.json
deleted file mode 100644
index 0478ed9..0000000
--- a/light_and_dark/saves/new rooms 2.json
+++ /dev/null
@@ -1,260 +0,0 @@
-{
- "version": 1,
- "saved_at": "2026-10-05T09:57:55",
- "player": {
- "room_id": "room_07.json",
- "x": 680,
- "y": 520,
- "health": 10,
- "charge": 120.0,
- "shade": false,
- "coins_light": 40,
- "coins_dark": 20,
- "butterfly_jars": 0,
- "has_ranged": true,
- "opened_chests": [
- "room_03.json@90,194",
- "room_07.json@440,494"
- ],
- "equipment": {
- "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": [
- {
- "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": {
- "first_kills_shade": {
- "progress": 1
- }
- },
- "completed_one_time": [],
- "ever_completed": [
- "quest_first_kills"
- ],
- "tokens": 0
- },
- "boss_diary": {
- "slotted": {}
- },
- "defeated_bosses": [
- "room_05.json"
- ],
- "broken_doors": [],
- "lantern_respawn": {
- "room_id": "room_07.json",
- "x": 640,
- "y": 520
- }
-}
\ No newline at end of file