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 `
Prop #${sel.index}${hint ? ` · ${hint}` : ""}
@@ -2390,10 +2393,19 @@ Cancel = Choose whether to remove without saving`);
type
- ${isQuest ? ` + ${(isQuest || isFairySpot) ? `
quest id (name)
- + +
` : ""} + ${isFairySpot ? ` +
+
dialogue id (from data/config/fairy.json)
+ +
+
+
auto trigger
+
` : ""}
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