diff --git a/light_and_dark/ROADMAP.md b/light_and_dark/ROADMAP.md index 3af2991..ff3cc8a 100644 --- a/light_and_dark/ROADMAP.md +++ b/light_and_dark/ROADMAP.md @@ -43,6 +43,7 @@ Proves the core loop is fun before scaling to the full vision. |---|---|---| | Shade-gated interactions (doors, an NPC, an area) | S-M | Room JSON + existing toggle. | | New enemy behaviors (patrol, dodge-and-leap, burrow-shooter) | M | Enemy system supports it. | +| Enemy line-of-sight detection (radius -> raycast) | S-M | Replace/augment euclidean aggro (`main.py:4329`) with a `Rect.clipline` raycast against solid platforms so walls block vision. Per-type `"detection": radius/los` config in `enemy_types.json` + editor toggle. Edge cases: skip the enemy's own support platform, sample player center+feet for tolerance, apply LOS to boss slam `trigger_close/far`. | | ~~Quick recharge pickup~~ | S | **DONE (2026-09-29)**: butterfly jars — collect up to 3, press G to instantly refill Charge. Lost on death; respawn at their points after saving at a lantern in a different room or reloading. | | Ability tweaks: ranged upgrades, ground-slam knockback/iframe, light-step buffs | M | Picks up remaining notes ideas. | | Region-2 boss + ability pacing | M | Re-align pacing (Ranged already exists). | diff --git a/light_and_dark/data/world/rooms/room_03.json b/light_and_dark/data/world/rooms/room_03.json index 51f8eec..39d26a2 100644 --- a/light_and_dark/data/world/rooms/room_03.json +++ b/light_and_dark/data/world/rooms/room_03.json @@ -14,6 +14,10 @@ "room3_top_right": [ 1480, 220 + ], + "room3_bottom_left": [ + 140, + 800 ] }, "platforms": [ @@ -88,29 +92,149 @@ "y": 460, "w": 20, "h": 160, - "group": "trigger" + "group": "trigger", + "layer": 0 + }, + { + "x": 1100, + "y": 620, + "w": 320, + "h": 20, + "group": "ground" + }, + { + "x": 800, + "y": 320, + "w": 160, + "h": 20, + "group": "ground" + }, + { + "x": 1580, + "y": 320, + "w": 20, + "h": 580, + "group": "ground" + }, + { + "x": 1220, + "y": 760, + "w": 360, + "h": 20, + "group": "ground" + }, + { + "x": 20, + "y": 880, + "w": 1560, + "h": 20, + "group": "ground" + }, + { + "x": 20, + "y": 640, + "w": 20, + "h": 240, + "group": "ground" + }, + { + "x": 160, + "y": 640, + "w": 20, + "h": 240, + "group": "trigger", + "name": "first_quest_complete" + }, + { + "x": 580, + "y": 640, + "w": 20, + "h": 120, + "group": "ground" + }, + { + "x": 480, + "y": 740, + "w": 100, + "h": 20, + "group": "ground" + }, + { + "x": 400, + "y": 400, + "w": 100, + "h": 20, + "group": "ground" + }, + { + "x": 220, + "y": 400, + "w": 80, + "h": 20, + "group": "moving", + "path": [ + [ + 220, + 400 + ], + [ + 220, + 260 + ] + ] + }, + { + "x": 40, + "y": 220, + "w": 140, + "h": 20, + "group": "ground" + }, + { + "x": 1520, + "y": 640, + "w": 60, + "h": 20, + "group": "ground" } ], - "enemies": [], - "npcs": [], - "props": [ + "enemies": [ { - "x": 1300, - "y": 220, - "type": "player", - "radius": 180, - "object_id": "player", - "layer": 0, - "quest_id": "" + "x": 600, + "y": 840, + "type": "enemy" }, { "x": 960, - "y": 540, + "y": 840, + "type": "enemy" + }, + { + "x": 200, + "y": 580, + "type": "enemy" + }, + { + "x": 520, + "y": 700, + "type": "enemy" + }, + { + "x": 1520, + "y": 840, + "type": "enemy" + } + ], + "npcs": [], + "props": [ + { + "x": 960, + "y": 580, "type": "quest_giver", "radius": 180, "object_id": "quest_point", "layer": 0, - "quest_id": "first_kills" + "quest_id": "quest_first_kills" } ], "exits": [ @@ -127,6 +251,18 @@ ], "hint": "", "layer": 0 + }, + { + "x": 40, + "y": 780, + "w": 80, + "h": 100, + "to": "", + "spawn_id": "", + "spawn": [ + 120, + 780 + ] } ], "ability_doors": [], diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html index b8e235a..f999175 100644 --- a/light_and_dark/level_editor_v43.html +++ b/light_and_dark/level_editor_v43.html @@ -348,6 +348,8 @@ if(Array.isArray(rect.path)) out.path = deepClone(rect.path); if(rect.speed !== undefined) out.speed = +rect.speed; if(rect.name) out.name = String(rect.name); + if(rect.quest) out.quest = String(rect.quest); + if(rect.quest_complete) out.quest_complete = String(rect.quest_complete); if(Array.isArray(rect.respawn)) out.respawn = deepClone(rect.respawn); return out; } @@ -466,7 +468,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 || "" }); + 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 }); } } @@ -2133,6 +2135,14 @@ Cancel = Choose whether to remove without saving`);
name
+
+
+
unlock when quest accepted (id)
+ +
+
+
unlock when quest complete (id)
+
` : ""} ${(p.group||"ground") === "hazard" ? `
@@ -2358,6 +2368,14 @@ Cancel = Choose whether to remove without saving`); const v = ptname.value.trim(); if(v){ p.name = v; } else { delete p.name; } })); + const ptquest=q("ptquest"); if(ptquest) ptquest.addEventListener("change", ()=>pushAnd(()=>{ + const v = ptquest.value.trim(); + if(v){ p.quest = v; } else { delete p.quest; } + })); + const ptqcomplete=q("ptqcomplete"); if(ptqcomplete) ptqcomplete.addEventListener("change", ()=>pushAnd(()=>{ + const v = ptqcomplete.value.trim(); + if(v){ p.quest_complete = v; } else { delete p.quest_complete; } + })); const ptspeed=q("ptspeed"); if(ptspeed) ptspeed.addEventListener("change", ()=>pushAnd(()=>{ p.speed = Math.max(1, num(ptspeed.value)); })); const hzrespawn=q("hzrespawn"); if(hzrespawn){ @@ -3259,6 +3277,8 @@ Cancel = Choose whether to remove without saving`); if(p.speed !== undefined) obj.speed = p.speed; } if(p.name) obj.name = p.name; + if(p.quest) obj.quest = p.quest; + if(p.quest_complete) obj.quest_complete = p.quest_complete; if(Array.isArray(p.respawn)) obj.respawn = p.respawn; platforms[group].push(obj); } else { @@ -3311,6 +3331,7 @@ 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(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 125f27f..609e197 100644 --- a/light_and_dark/main.py +++ b/light_and_dark/main.py @@ -937,6 +937,10 @@ def normalize_room_json(room_json: dict) -> dict: obj["speed"] = p["speed"] if "name" in p: obj["name"] = p["name"] + if "quest" in p: + obj["quest"] = p["quest"] + if "quest_complete" in p: + obj["quest_complete"] = p["quest_complete"] if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2: obj["respawn"] = p["respawn"] grouped.setdefault(group, []).append(obj) @@ -1068,7 +1072,12 @@ class Room: if isinstance(tp, dict): rect = pygame.Rect(int(tp.get("x", 0)), int(tp.get("y", 0)), int(tp.get("w", 0)), int(tp.get("h", 0))) self.platform_layer[id(rect)] = int(tp.get("layer", 0)) - self.platform_meta[id(rect)] = {"name": str(tp.get("name", ""))} + meta = {"name": str(tp.get("name", ""))} + if tp.get("quest"): + meta["quest"] = str(tp.get("quest")) + if tp.get("quest_complete"): + meta["quest_complete"] = str(tp.get("quest_complete")) + self.platform_meta[id(rect)] = meta self.platforms_trigger.append(rect) elif isinstance(tp, (list, tuple)) and len(tp) >= 4: rect = pygame.Rect(int(tp[0]), int(tp[1]), int(tp[2]), int(tp[3])) @@ -1931,11 +1940,12 @@ class RewardChest: def open(self, player): if self.opened: - return + return False light_amt, dark_amt = self.coins player.coins_light += int(light_amt) player.coins_dark += int(dark_amt) self.opened = True + return True def draw(self, surface, cx, cy): x = int(self.rect.x - cx) @@ -3248,6 +3258,7 @@ class Player: self.rect = pygame.Rect(int(x), int(y), PLAYER_HITBOX_W, PLAYER_HITBOX_H) self.vel_y = 0.0 self.on_ground = False + self.ground_platform = None self.shade = False self.charge = float(BASE_MAX_CHARGE) @@ -3261,7 +3272,7 @@ class Player: # --- abilities --- self.has_ranged = False self.ranged_cd = 0.0 - self.reward_chest_opened = False + self.opened_chests: set[str] = set() self.has_shatterstep = True self.shatterstep_timer = 0.0 self.shatterstep_cd = 0.0 @@ -3889,9 +3900,11 @@ class Player: self.vel_y += gravity dy = self.vel_y - # Horizontal collision + # Horizontal collision (ignore the platform the player is standing on) self.rect.x += int(dx) for p in platforms: + if p is self.ground_platform: + continue if self.rect.colliderect(p): if dx > 0: self.rect.right = p.left @@ -3901,12 +3914,14 @@ class Player: # Vertical collision self.rect.y += int(dy) self.on_ground = False + self.ground_platform = None for p in platforms: if self.rect.colliderect(p): if dy > 0: self.rect.bottom = p.top self.vel_y = 0 self.on_ground = True + self.ground_platform = p elif dy < 0: self.rect.top = p.bottom self.vel_y = 0 @@ -4922,6 +4937,25 @@ class HouseObject: draw_house_shape(surface, cx, cy, self.x, self.y) +def trigger_wall_blocking(rect) -> bool: + """A trigger wall blocks while its unlock condition is NOT met. + - ``quest``: wall stays until the quest is accepted (becomes active). + - ``quest_complete``: wall stays until the quest objective is done + (is_complete, or the quest has been completed at least once). + Walls with no condition are inert placeholders (never block).""" + meta = platform_meta.get(id(rect), {}) + qid = meta.get("quest") + if qid: + return qid not in quest_manager.active + qc = meta.get("quest_complete") + if qc: + return not (quest_manager.is_complete(qc) or qc in quest_manager.ever_completed) + return False + + +prev_moving_pos: dict[int, tuple[int, int]] = {} + + def update_moving_platforms(dt: float): """Advance moving platforms along their waypoint paths (top-left follows the path).""" for rect in platforms_moving: @@ -4996,9 +5030,10 @@ def draw_layerable(surface, cx, cy): # hazard zones (red) layer alongside platforms for p in platforms_hazard: items.append((platform_layer.get(id(p), 0), 1, "hazard", p)) - # trigger placeholders (yellow, non-solid, no behavior yet) + # trigger walls: only drawn while blocking (quest not yet accepted) for p in platforms_trigger: - items.append((platform_layer.get(id(p), 0), 1, "trigger", p)) + if trigger_wall_blocking(p): + items.append((platform_layer.get(id(p), 0), 1, "trigger", p)) # ability doors for d in ability_doors: items.append((getattr(d, "layer", 0), 2, "door", d)) @@ -5409,7 +5444,9 @@ def spawn_room_props(room: Room, player_obj: "Player"): coins = (int(coins[0]), int(coins[1])) else: coins = REWARD_CHEST_COINS - reward_chest_obj = RewardChest(x, y, opened=player_obj.reward_chest_opened, coins=coins) + cid = str(p.get("object_id") or "") or f"{room.id}@{int(x)},{int(y)}" + reward_chest_obj = RewardChest(x, y, opened=cid in player_obj.opened_chests, coins=coins) + reward_chest_obj.id = cid reward_chest_obj.layer = int(p.get("layer", 0)) elif p_type == "ability_pickup" and reward_ability_obj is None: ability_id = str(p.get("ability", "ranged")) @@ -5801,6 +5838,7 @@ class QuestManager: self.quests: dict[str, Quest] = {} self.active: dict[str, dict] = {} # quest_id -> {"progress": int} self.completed_one_time: set[str] = set() + self.ever_completed: set[str] = set() # completed at least once (incl. repeatable) self.tokens = 0 def register(self, qdef: dict) -> Quest | None: @@ -5855,6 +5893,7 @@ class QuestManager: def _complete(self, quest_id: str): q = self.quests[quest_id] self.active.pop(quest_id, None) + self.ever_completed.add(quest_id) if q.is_repeatable: self.tokens += q.token_count() else: @@ -6543,13 +6582,14 @@ def save_game(name: str = "auto") -> str: "coins_dark": player.coins_dark, "butterfly_jars": player.butterfly_jars, "has_ranged": player.has_ranged, - "reward_chest_opened": player.reward_chest_opened, + "opened_chests": sorted(player.opened_chests), "equipment": {s: it for s, it in player.equipment.items()}, }, "inventory": inventory.items, "quests": { "active": quest_manager.active, "completed_one_time": sorted(quest_manager.completed_one_time), + "ever_completed": sorted(quest_manager.ever_completed), "tokens": quest_manager.tokens, }, "boss_diary": {"slotted": boss_diary.slotted}, @@ -6577,7 +6617,10 @@ def apply_save_data(data: dict): player.coins_dark = int(pdata.get("coins_dark", 0)) player.butterfly_jars = int(pdata.get("butterfly_jars", 0)) player.has_ranged = bool(pdata.get("has_ranged", False)) - player.reward_chest_opened = bool(pdata.get("reward_chest_opened", False)) + 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"): + player.opened_chests.add(f"{REWARD_ROOM_ID}@{REWARD_CHEST_POS[0]},{REWARD_CHEST_POS[1]}") eq = pdata.get("equipment") or {} player.equipment = {s: None for s in ("helmet", "chest", "pants", "necklace")} @@ -6592,6 +6635,7 @@ def apply_save_data(data: dict): qdata = data.get("quests") or {} quest_manager.active = {k: dict(v) for k, v in (qdata.get("active") or {}).items()} quest_manager.completed_one_time = set(qdata.get("completed_one_time") or []) + quest_manager.ever_completed = set(qdata.get("ever_completed") or []) quest_manager.tokens = int(qdata.get("tokens", 0)) bdata = data.get("boss_diary") or {} @@ -6649,11 +6693,11 @@ def fresh_save_data() -> dict: "coins_dark": 0, "butterfly_jars": 0, "has_ranged": False, - "reward_chest_opened": False, + "opened_chests": [], "equipment": {}, }, "inventory": [], - "quests": {"active": {}, "completed_one_time": [], "tokens": 0}, + "quests": {"active": {}, "completed_one_time": [], "ever_completed": [], "tokens": 0}, "boss_diary": {"slotted": {}}, "defeated_bosses": [], "broken_doors": [], @@ -7406,10 +7450,35 @@ while running: near_save_point = True break - # Move moving platforms before collision is computed so the player collides correctly. - update_moving_platforms(dt) + # Determine the moving platform the player is currently standing on (before it moves). + support_moving = None + if player.on_ground: + for rect in platforms_moving: + if (player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and + player.rect.right > rect.left and player.rect.left < rect.right): + support_moving = rect + break - collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] + # Move moving platforms; carry the player along with their support platform BEFORE + # collision so they stay on it (no slipping off / bobbing when moving down). + for rect in platforms_moving: + prev_moving_pos[id(rect)] = (rect.x, rect.y) + update_moving_platforms(dt) + if support_moving is not None: + px, py = prev_moving_pos.get(id(support_moving)) + if px is not None: + dx = support_moving.x - px + dy = support_moving.y - py + if dx or dy: + player.rect.x += dx + player.rect.y += dy + player.vel_y = 0.0 + player.ground_platform = support_moving + + # Trigger walls: solid only while their unlock quest is not yet active. + blocking_triggers = [t for t in platforms_trigger if trigger_wall_blocking(t)] + + collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] + blocking_triggers player_collision_platforms = collision_platforms + [p.rect for p in shatter_platforms if p.alive] # Shade + clipping an enemy drains charge twice as fast (shade still avoids damage). @@ -7420,6 +7489,17 @@ while running: player.update(keys, player_collision_platforms, dt) + # Glue the player to any moving platform they're standing on, so upward movement + # doesn't leave a gap that makes on_ground flicker (bobbing / eventual fall-off). + for rect in platforms_moving: + if (player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and + player.rect.right > rect.left and player.rect.left < rect.right): + player.rect.bottom = rect.top + player.vel_y = 0.0 + player.on_ground = True + player.ground_platform = rect + break + # Hazard zones: touching one deals 1 damage and teleports the player to its linked respawn # (defaults to the room's player spawn). A lethal hit falls through to the normal death respawn. for hz in platforms_hazard: @@ -7664,8 +7744,8 @@ while running: # Pickup loot (only when inventory closed) if not inv_ui.visible and not trade_ui.visible: if reward_chest and player.rect.colliderect(reward_chest.rect): - reward_chest.open(player) - player.reward_chest_opened = True + if reward_chest.open(player): + player.opened_chests.add(reward_chest.id) if reward_ability and player.rect.colliderect(reward_ability.rect): if reward_ability.ability_id == "ranged":