smol changes

title screen town, boss drop for repeat, quest rewards
This commit is contained in:
2026-09-21 21:30:11 +02:00
parent f90800a3c7
commit 1ec6590389
4 changed files with 410 additions and 11 deletions
Binary file not shown.
+1 -1
View File
@@ -150,7 +150,7 @@
"type": "enemy",
"pos": [
480,
-580
-530
]
},
{
@@ -29,10 +29,10 @@
80,
60
],
"to": "room_reward_01",
"to": "room_07",
"spawn": [
1700,
260
-1000,
2720
],
"hint": "Back"
}
+406 -7
View File
@@ -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()