smol changes
title screen town, boss drop for repeat, quest rewards
This commit is contained in:
Binary file not shown.
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user