fairy stuff

fairy quest and attack mode
This commit is contained in:
2026-10-05 18:19:49 +02:00
parent d56540785f
commit 002d1e83bf
17 changed files with 1435 additions and 526 deletions
+1
View File
@@ -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 |
+22
View File
@@ -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.
+89
View File
@@ -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!"
}
}
}
+11
View File
@@ -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
}
}
+6
View File
@@ -22,6 +22,12 @@
},
"quest_point": {
"type": "quest_giver"
},
"fairy_spot": {
"type": "fairy_spot"
},
"fairy_dust": {
"type": "fairy_dust"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

+31 -1
View File
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
+101 -18
View File
@@ -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": [],
@@ -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
}
@@ -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,
+26 -4
View File
@@ -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 "<div class='muted'>Selection missing.</div>";
const prDefault = getDefaultRadiusForType(p.type || "light_source", p.object_id || p.objectId || null);
const objId = p.object_id || p.objectId || "";
const hint = ({light_source:"Butterfly (charge regen 2x)", save_point:"Lantern (save point - press F to save)", butterfly_jar:"Butterfly-in-jar (collectible)", diary:"Boss diary (interact)", house:"Standalone house", player:"Player size reference (no in-game effect)", 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 `
<div><b>Prop</b> #${sel.index}${hint ? ` <span class="mini">· ${hint}</span>` : ""}</div>
<div class="hr"></div>
@@ -2390,10 +2393,19 @@ Cancel = Choose whether to remove without saving`);
<div class="mini">type</div>
<input id="prtype" type="text" value="${p.type||"light_source"}"/>
</div>
${isQuest ? `
${(isQuest || isFairySpot) ? `
<div style="margin-top:8px;">
<div class="mini">quest id (name)</div>
<input id="prqid" type="text" value="${p.quest_id||""}" placeholder="e.g. quest_save_the_well"/>
<input id="prqid" type="text" value="${p.quest_id||""}" placeholder="e.g. quest_fairy_dust"/>
</div>` : ""}
${isFairySpot ? `
<div style="margin-top:8px;">
<div class="mini">dialogue id (from data/config/fairy.json)</div>
<input id="prdialogue" type="text" value="${p.dialogue_id||""}" placeholder="e.g. spot_greeting"/>
</div>
<div style="margin-top:8px;">
<div class="mini">auto trigger</div>
<input id="prauto" type="checkbox" ${p.auto ? "checked" : ""}/>
</div>` : ""}
<div style="margin-top:8px;">
<div class="mini">object id</div>
@@ -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;
});
+1038 -232
View File
File diff suppressed because it is too large Load Diff
-260
View File
@@ -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
}
}