diff --git a/pygame test/__pycache__/pygame_test.cpython-312.pyc b/pygame test/__pycache__/pygame_test.cpython-312.pyc new file mode 100644 index 0000000..1d72295 Binary files /dev/null and b/pygame test/__pycache__/pygame_test.cpython-312.pyc differ diff --git a/pygame test/data/world/rooms/room_02.json b/pygame test/data/world/rooms/room_02.json index 05f820a..7e86d0e 100644 --- a/pygame test/data/world/rooms/room_02.json +++ b/pygame test/data/world/rooms/room_02.json @@ -150,7 +150,7 @@ "type": "enemy", "pos": [ 480, - -580 + -530 ] }, { diff --git a/pygame test/data/world/rooms/room_town_gloomreach.json b/pygame test/data/world/rooms/room_town_gloomreach.json index f3ae7e3..731a677 100644 --- a/pygame test/data/world/rooms/room_town_gloomreach.json +++ b/pygame test/data/world/rooms/room_town_gloomreach.json @@ -29,10 +29,10 @@ 80, 60 ], - "to": "room_reward_01", + "to": "room_07", "spawn": [ - 1700, - 260 + -1000, + 2720 ], "hint": "Back" } diff --git a/pygame test/pygame_test.py b/pygame test/pygame_test.py index 8c4c308..5124f8d 100644 --- a/pygame test/pygame_test.py +++ b/pygame test/pygame_test.py @@ -220,6 +220,27 @@ def load_json_safe(filename: str, roots: list[Path], default: dict) -> dict: print(f"[JSON load failed] {p} -> {e} (using defaults)") return deepcopy(default) + +# ---------------------------- +# Broken ability doors (in-memory, resets on restart) +# ---------------------------- +BROKEN_DOORS: set[str] = set() + + +def broken_door_key(room_id: str, rect) -> str: + return f"{room_id}:{int(rect[0])},{int(rect[1])},{int(rect[2])},{int(rect[3])}" + + +def is_door_broken(room_id: str, rect) -> bool: + return broken_door_key(room_id, rect) in BROKEN_DOORS + + +def mark_door_broken(room_id: str, rect): + if not room_id: + return + BROKEN_DOORS.add(broken_door_key(room_id, rect)) + + # ---------------------------- # Game config (numbers & colors) # ---------------------------- @@ -1153,6 +1174,7 @@ class Projectile: continue if self.rect.colliderect(d.rect): d.open = True + mark_door_broken(getattr(d, "room_id", None), d.rect) self.t = 0 return @@ -1532,9 +1554,10 @@ class CoinPickup: class AbilityDoor: - def __init__(self, rect: pygame.Rect): + def __init__(self, rect: pygame.Rect, room_id: str | None = None): self.rect = rect self.open = False + self.room_id = room_id def draw(self, surface, cx, cy): if self.open: @@ -1544,6 +1567,18 @@ class AbilityDoor: pygame.draw.rect(surface, (40, 80, 40), r, 2) +def spawn_ability_doors(room) -> list[AbilityDoor]: + out = [] + for d in room.ability_doors: + if not isinstance(d, dict): + continue + rect = pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0]))) + door = AbilityDoor(rect, room_id=room.id) + door.open = is_door_broken(room.id, rect) + out.append(door) + return out + + class RewardChest: def __init__(self, x, y, opened=False, coins=(0, 0)): self.rect = pygame.Rect(int(x), int(y), 40, 26) @@ -4594,8 +4629,7 @@ pickup_feed = PickupFeed(font, max_lines=int(_ui(["pickup_feed", "max_lines"], 6 enemies = spawn_enemies_from_room(ROOM) npcs = spawn_npcs_from_room(ROOM) -ability_doors = [AbilityDoor(pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))) - for d in ROOM.ability_doors if isinstance(d, dict)] +ability_doors = spawn_ability_doors(ROOM) loot_drops: list[LootPickup] = [] shockwaves: list[Shockwave] = [] coin_drops: list[CoinPickup] = [] @@ -4706,6 +4740,9 @@ def apply_room(room: Room, set_player_spawn=True): ground_platforms = ROOM.ground_platforms platforms = ROOM.platforms + if is_town_room(ROOM): + town_title.trigger(ROOM.id, town_display_name(ROOM)) + loot_drops = [] shockwaves = [] coin_drops = [] @@ -4720,8 +4757,7 @@ def apply_room(room: Room, set_player_spawn=True): ROOM_COIN_MULT = 1.0 enemies = spawn_enemies_from_room(ROOM) npcs = spawn_npcs_from_room(ROOM) - ability_doors = [AbilityDoor(pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))) - for d in ROOM.ability_doors if isinstance(d, dict)] + ability_doors = spawn_ability_doors(ROOM) reward_chest = None reward_ability = None @@ -4797,6 +4833,368 @@ def mark_boss_defeated(room_id: str): DEFEATED_BOSSES.add(room_id) +# ---------------------------- +# Quest system +# ---------------------------- +def draw_token_icon(surface, x: int, y: int, size: int = 16): + """Placeholder quest-token icon: a red triangle.""" + cx = x + size // 2 + pygame.draw.polygon(surface, (220, 45, 45), [(cx, y), (x, y + size), (x + size, y + size)]) + pygame.draw.polygon(surface, (120, 10, 10), [(cx, y), (x, y + size), (x + size, y + size)], 1) + + +def _resolve_reward_base(entry: dict): + """Return a base item dict from a reward entry: {item_id} or {slot}.""" + if not isinstance(entry, dict): + return None + item_id = entry.get("item_id") + if item_id: + base = safe_get_item(str(item_id)) + if base: + return base + slot = entry.get("slot") + if slot: + pool = [it for it in ITEM_BY_ID.values() if isinstance(it, dict) and it.get("slot") == slot] + if pool: + return random.choice(pool) + return None + + +def _build_reward_item(entry: dict, rarity_fallback: str = "tempered"): + base = _resolve_reward_base(entry) + if base is None: + return None + rarity = str(entry.get("rarity", rarity_fallback)) + return build_item_instance_any(base, rarity if rarity in RARITIES else None) + + +class Quest: + def __init__(self, qdef: dict): + self.id = str(qdef.get("id", "")) + self.name = str(qdef.get("name", self.id)) + self.description = str(qdef.get("description", "")) + self.qtype = "repeatable" if str(qdef.get("type", "")) == "repeatable" else "one_time" + self.giver = str(qdef.get("giver", "")) + self.objective = dict(qdef.get("objective", {})) if isinstance(qdef.get("objective"), dict) else {} + self.reward = dict(qdef.get("reward", {})) if isinstance(qdef.get("reward"), dict) else {} + + @property + def is_repeatable(self) -> bool: + return self.qtype == "repeatable" + + def guaranteed_item(self) -> dict | None: + """One-time quests: a specific guaranteed reward item.""" + g = self.reward.get("guaranteed_item") + return _build_reward_item(g) if isinstance(g, dict) else None + + def pool_entries(self) -> list[dict]: + pool = self.reward.get("pool") + return [e for e in pool if isinstance(e, dict)] if isinstance(pool, list) else [] + + def roll_pool_item(self) -> dict | None: + """Repeatable quests: weighted random item from the loot pool.""" + weighted = [] + for e in self.pool_entries(): + item = _build_reward_item(e) + if item is not None: + weighted.append((item, float(e.get("weight", 1.0)))) + if not weighted: + return None + total = sum(w for _, w in weighted) + if total <= 0: + return weighted[0][0] + r = random.random() * total + acc = 0.0 + for item, w in weighted: + acc += w + if r < acc: + return item + return weighted[-1][0] + + def redeem_pool_item(self, pool_index: int) -> dict | None: + """Choose a specific item from the repeatable pool by index (for token redemption).""" + entries = self.pool_entries() + if pool_index < 0 or pool_index >= len(entries): + return None + return _build_reward_item(entries[pool_index]) + + def token_count(self) -> int: + return max(0, int(self.reward.get("token_count", 1 if self.is_repeatable else 0))) + + +class QuestManager: + def __init__(self): + self.quests: dict[str, Quest] = {} + self.active: dict[str, dict] = {} # quest_id -> {"progress": int} + self.completed_one_time: set[str] = set() + self.tokens = 0 + + def register(self, qdef: dict) -> Quest | None: + q = Quest(qdef) + if q.id: + self.quests[q.id] = q + return q + return None + + def register_all(self, qdefs): + for qd in qdefs: + if isinstance(qd, dict): + self.register(qd) + + def get(self, quest_id: str) -> Quest | None: + return self.quests.get(quest_id) + + def quests_from(self, giver_id: str) -> list[Quest]: + return [q for q in self.quests.values() if q.giver == giver_id] + + def can_accept(self, quest_id: str) -> bool: + q = self.quests.get(quest_id) + if q is None: + return False + if q.is_repeatable: + return quest_id not in self.active + return quest_id not in self.completed_one_time and quest_id not in self.active + + def accept(self, quest_id: str) -> bool: + if not self.can_accept(quest_id): + return False + self.active[quest_id] = {"progress": 0} + return True + + def progress(self, quest_id: str, amount: int = 1) -> bool: + st = self.active.get(quest_id) + if st is None: + return False + st["progress"] = int(st.get("progress", 0)) + max(0, int(amount)) + return True + + def _target_count(self, q: Quest) -> int: + return max(1, int(q.objective.get("count", 1))) + + def is_complete(self, quest_id: str) -> bool: + q = self.quests.get(quest_id) + st = self.active.get(quest_id) + if q is None or st is None: + return False + return int(st.get("progress", 0)) >= self._target_count(q) + + def _complete(self, quest_id: str): + q = self.quests[quest_id] + self.active.pop(quest_id, None) + if q.is_repeatable: + self.tokens += q.token_count() + else: + self.completed_one_time.add(quest_id) + + def turn_in(self, quest_id: str) -> dict | None: + """Called by the quest giver: completes the quest and returns the reward item.""" + q = self.quests.get(quest_id) + if q is None or not self.is_complete(quest_id): + return None + self._complete(quest_id) + return q.roll_pool_item() if q.is_repeatable else q.guaranteed_item() + + def add_tokens(self, amount: int): + self.tokens += max(0, int(amount)) + + def spend_tokens(self, amount: int) -> bool: + amount = max(0, int(amount)) + if self.tokens < amount: + return False + self.tokens -= amount + return True + + def redeem(self, quest_id: str, pool_index: int, cost: int = 1) -> dict | None: + """Spend tokens to pick a specific item from a repeatable quest's pool.""" + q = self.quests.get(quest_id) + if q is None or not q.is_repeatable: + return None + item = q.redeem_pool_item(pool_index) + if item is None or not self.spend_tokens(cost): + return None + return item + + +QUEST_DEFS_DEFAULT = [ + { + "id": "quest_forest_cleanup", + "name": "Forest Cleanup", + "description": "Clear 5 enemies from the forest.", + "type": "repeatable", + "giver": "npc:hunter", + "objective": {"kind": "kill", "target": "enemy", "count": 5}, + "reward": { + "token_count": 1, + "pool": [ + {"slot": "helmet", "rarity": "tempered", "weight": 1.0}, + {"slot": "chest", "rarity": "tempered", "weight": 1.0}, + {"slot": "pants", "rarity": "tempered", "weight": 1.0}, + ], + }, + }, + { + "id": "quest_first_slayer", + "name": "The First Slayer", + "description": "Defeat the miniboss of the lower depths.", + "type": "one_time", + "giver": "npc:combat_master", + "objective": {"kind": "kill", "target": "miniboss", "count": 1}, + "reward": { + "guaranteed_item": {"slot": "chest", "rarity": "attuned"}, + }, + }, +] + +quest_manager = QuestManager() +quest_manager.register_all(QUEST_DEFS_DEFAULT) + + +# ---------------------------- +# Boss trophy collection / re-fight / upgrades +# ---------------------------- +BOSS_DEFS_DEFAULT = { + "boss_01": { + "name": "Gloom Warden", + "room": "room_boss_01.json", + "trophy_name": "Warden's Heart", + }, +} + + +class BossTrophy: + def __init__(self, boss_id: str, name: str, tier: int = 0): + self.boss_id = str(boss_id) + self.name = str(name) + self.tier = int(tier) # 0 = base, higher = upgraded (harder) difficulty + + def upgrade(self) -> int: + self.tier += 1 + return self.tier + + +class BossCollection: + def __init__(self): + self.trophies: dict[str, BossTrophy] = {} + self.defeated: set[str] = set() + + def add_trophy(self, boss_id: str, name: str = "") -> BossTrophy: + t = self.trophies.get(boss_id) + if t is None: + t = BossTrophy(boss_id, name or boss_id) + self.trophies[boss_id] = t + self.defeated.add(boss_id) + return t + + def has_trophy(self, boss_id: str) -> bool: + return boss_id in self.trophies + + def get(self, boss_id: str) -> BossTrophy | None: + return self.trophies.get(boss_id) + + def upgrade(self, boss_id: str) -> int | None: + """Combat Master: upgrade a trophy so a harder version can be fought.""" + t = self.trophies.get(boss_id) + return t.upgrade() if t is not None else None + + def refight(self, boss_id: str) -> bool: + """Re-enable this boss for a re-fight. Returns True if it was defeated.""" + if boss_id not in self.defeated: + return False + self.defeated.discard(boss_id) + return True + + +def boss_room_id(boss_id: str) -> str | None: + bd = BOSS_DEFS_DEFAULT.get(boss_id) + return str(bd.get("room")) if isinstance(bd, dict) and bd.get("room") else None + + +def clear_boss_defeated(room_id: str): + """Allow a boss room to be re-fought by clearing its runtime defeated flag.""" + DEFEATED_BOSSES.discard(room_id) + + +def refight_boss(boss_id: str) -> bool: + """Re-fight a collected boss: clears its defeated flag so it respawns.""" + if not boss_collection.refight(boss_id): + return False + room = boss_room_id(boss_id) + if room: + clear_boss_defeated(room) + return True + + +boss_collection = BossCollection() + + +# ---------------------------- +# Town title banner +# ---------------------------- +TOWN_NPC_TYPES = ("vendor", "upgrader", "necklace_vendor", "combat_master") + +TOWN_DISPLAY_NAMES = { + "room_town_gloomreach.json": "Gloomreach", +} + + +def is_town_room(room) -> bool: + """A room counts as a town if marked in meta or it contains town NPCs.""" + meta = getattr(room, "meta", {}) or {} + if meta.get("is_town") or meta.get("town"): + return True + for n in getattr(room, "npcs", []) or []: + if isinstance(n, dict) and n.get("type") in TOWN_NPC_TYPES: + return True + return False + + +def town_display_name(room) -> str: + name = TOWN_DISPLAY_NAMES.get(getattr(room, "id", "")) + if name: + return name + meta = getattr(room, "meta", {}) or {} + return str(meta.get("display_name") or meta.get("name") or getattr(room, "id", "")) + + +class TownTitleBanner: + def __init__(self, font_obj: pygame.font.Font): + self.font = font_obj + self.title_font = pygame.font.SysFont(None, 64) + self.text = "" + self.timer = 0.0 + self.duration = 3.0 + self.seen: set[str] = set() + + def trigger(self, town_id: str, town_name: str): + if town_id in self.seen: + return + self.seen.add(town_id) + self.text = town_name + self.timer = self.duration + + def update(self, dt: float): + if self.timer > 0: + self.timer = max(0.0, self.timer - dt) + + @property + def active(self) -> bool: + return self.timer > 0 + + def draw(self, surface: pygame.Surface): + if not self.active or not self.text: + return + alpha = 255 + if self.timer < 0.5: + alpha = int(255 * (self.timer / 0.5)) + title_surf = self.title_font.render(self.text, True, (240, 235, 210)).convert_alpha() + title_surf.set_alpha(alpha) + x = (WIDTH - title_surf.get_width()) // 2 + y = int(HEIGHT * 0.28) # upper half of the screen + surface.blit(title_surf, (x, y)) + + +town_title = TownTitleBanner(font) + # ---------------------------- # Main loop @@ -4812,6 +5210,7 @@ while running: dt = clock.tick(60) / 1000.0 if not paused: pickup_feed.update(dt) + town_title.update(dt) for e in pygame.event.get(): @@ -5148,8 +5547,7 @@ while running: tether_shots = [] player.shatterstep_landing_pending = False player.shatterstep_landing_damage = 0.0 - ability_doors = [AbilityDoor(pygame.Rect(*map(int, d.get("rect", [0, 0, 0, 0])))) - for d in ROOM.ability_doors if isinstance(d, dict)] + ability_doors = spawn_ability_doors(ROOM) # Update enemies for en in enemies: @@ -5487,6 +5885,7 @@ while running: inv_ui.draw(screen) trade_ui.draw(screen) pickup_feed.draw(screen) + town_title.draw(screen) pygame.display.flip()