diff --git a/pygame test/README.md b/pygame test/README.md new file mode 100644 index 0000000..3481400 --- /dev/null +++ b/pygame test/README.md @@ -0,0 +1,133 @@ +# Light & Dark — Prototype + +A 2D platformer built with **Pygame**. You switch between two forms — **Light** and **Shade** — each with different movement, combat, and ability behavior, all driven by a single shared resource: **Charge (Flow)**. + +> Note: this is a prototype. All progress (quests, boss tokens, broken walls, defeated bosses) is **in-memory** and resets when you restart the game — intentional, for fast testing. + +--- + +## Running the game + +``` +python pygame_test.py +``` + +Requires Python 3.10+ and `pygame`. Game data (rooms, items, sets, enemies, drops) lives in `data/`. Rooms are edited with the included browser tool: `level_editor_v43.html`. + +--- + +## Controls + +| Action | Key | +|---|---| +| Move | A / D | +| Jump | W | +| Move down / drop | S | +| Heal | Space (hold) | +| Melee attack | E | +| Ranged shot | Q | +| Dash | Tab | +| Shatterstep | R | +| Tether | X | +| Reality Break | C | +| Toggle Light/Shade | Left Shift | +| Interact / Talk | F | +| Inventory | I | +| Stats overlay | H | +| Pause / Settings | Esc | + +--- + +## The two forms + +**Light** — faster movement, higher jump, and charge **refills** over time. You take contact damage from enemies. + +**Shade** — slower but tanky: **immune to enemy contact damage**, deals double melee damage, and can use shade-specific gear bonuses. But being in Shade **drains Charge** (and Shade is forced off when Charge hits 0). Shade also drains Charge **twice as fast while clipping inside an enemy**. + +**Charge (Flow)** — base max 100, refills at 20/s in Light, drains at 40/s in Shade. Standing near a **lantern/light source** doubles Charge regeneration. Attacks, dash (Light), ranged shots, and healing all cost Charge. + +--- + +## Abilities (by form) + +| Ability | Key | Light | Shade | +|---|---|---|---| +| **Melee** | E | 1 damage, costs 1 Charge | **2 damage**, costs 1 Charge | +| **Dash** | Tab | Costs 10 Charge; **stuns** enemies hit | Costs 1 HP; deals **4 damage** to enemies hit | +| **Ranged** | Q | Costs 5 Charge, 0.45s cooldown, 0.5 damage; **breaks green ability walls** | Same, but 1.0 damage | +| **Shatterstep** | R | Spawns a **platform under your feet** while airborne (extra jumps) | On landing, creates a damaging **shockwave AOE** below you | +| **Tether** | X | **Binds** the enemy (3s) and **fears** it (runs away) | **Insta-kills** enemies below a HP threshold (normal ⅓, miniboss 20%, boss 5%); otherwise **slows** them | +| **Reality Break** | C | Doubles drop & coin rates and glows enemies | **Instantly kills all enemies** but halves drop & coin rates | +| **Heal** | Space | Spend Charge to heal over ~2s (slower while healing) | Unavailable | + +--- + +## Combat notes + +- Enemy contact damage only applies in **Light** form. +- Boss **shockwave attacks poison** you for 4 seconds: the health bar turns green and you lose 1 HP per second. +- Attacks and hits use invincibility frames and knockback; your melee hitbox covers ~30% into your body so close/overlapping hits always connect. + +--- + +## Equipment & Armor Sets + +Slots: **helmet, chest, pants, necklace**. Pieces roll stats (move speed, heal, charge, health, etc.). + +**Rarities:** `tempered` (tier 1) → `attuned` (tier 2) → `awakened` (tier 3, unlocks ability slots). The **Upgrader** NPC turns 5 tempered copies of an item into 1 attuned copy. + +**Sets** (3 pieces of the same set, attuned or better): + +| Set | Summary | +|---|---| +| Dawnrunner | +HP; awakened adds Light move speed | +| Gravemark | +max Charge; awakened adds Charge regen | +| Vitalweave | +HP; awakened adds +heal | +| Duskwarrior | +Shade damage; awakened restores Charge on Shade hits | +| Twister | Removes Shade speed penalty; awakened gives move speed after switching forms | +| Paladin | Chance to negate incoming damage; awakened adds +HP | +| Luminal Growth | +Charge regen while standing still in Light; awakened also negates damage | +| Abyss Cleaver | Chance to heal on kill; awakened also gives move speed on kill | +| Umbraflow | Reduces Shade Charge drain | +| Adaptive Traveller | Move speed after switching forms; awakened restores Charge on switch | + +The shade-focused sets (**Duskwarrior, Umbraflow, Abyss Cleaver, Adaptive Traveller**) are intentionally **excluded** from early boss and town-shop loot, so you can always complete the remaining sets. + +--- + +## NPCs (Town 1: Gloomreach) + +- **Vendor** — sells basic armor pieces. +- **Necklace Vendor** — sells necklaces. +- **Upgrader** — attunes 5× tempered → attuned. +- **Combat Master** — upgrades your boss token to unlock the **Hard** boss variant. +- **Boss Diary** — an interactable book: view your collected boss tokens in a grid, slot/unslot them, and start refights. + +--- + +## Rooms & Progression + +- `room_01` — tutorial. Contains a **quest object** (pink): accept the **"First Kills"** quest (kill 4 normal enemies → 5–15 light coins, repeatable). +- `room_02` — first enemies. +- `room_07` — hub, branching to the boss, reward room, and town. +- `room_boss_01` — **Gloom Warden** fight. Defeating him drops his **boss token**. +- `room_reward_01` — reward chest + the **Ranged** ability, locked behind green ability walls. +- `room_town_gloomreach` — Town 1 (NPCs above). +- `room_boss_refight_01` — diary refight room (same arena, no exits until the boss dies or you die). + +--- + +## Boss Diary & Tokens + +1. Beat the world boss → collect his **token** (teal pickup). +2. Bring it to the diary in town and **slot** it (or keep it in your inventory). +3. Slotting unlocks **Fight (Normal)**. Upgrading the token at the Combat Master unlocks **Fight (Hard)** — the boss moves and shoots twice as fast. +4. Dying in a refight returns you to the diary. Removing a token from the diary sends it back to your inventory, disabling the fight until you re-slot it. + +--- + +## HUD & Stats + +- Top-right: health bar (turns **green while poisoned**), Charge bar, coin counts. +- Ability icons with cooldown indicators (top-left). +- **H** toggles a detailed overlay: exact HP/Charge, move speeds (both forms), heal duration, attack rate, Charge regen, the lantern buff, and any stat bonuses from gear. \ No newline at end of file diff --git a/pygame test/__pycache__/pygame_test.cpython-312.pyc b/pygame test/__pycache__/pygame_test.cpython-312.pyc index 1d72295..d4d36f8 100644 Binary files a/pygame test/__pycache__/pygame_test.cpython-312.pyc and b/pygame test/__pycache__/pygame_test.cpython-312.pyc differ diff --git a/pygame test/data/sprites/abilities/Ranged_projectile.png b/pygame test/data/sprites/abilities/Ranged_projectile.png new file mode 100644 index 0000000..3f66599 Binary files /dev/null and b/pygame test/data/sprites/abilities/Ranged_projectile.png differ diff --git a/pygame test/data/sprites/tokens/Boss1_token.png b/pygame test/data/sprites/tokens/Boss1_token.png new file mode 100644 index 0000000..7098e52 Binary files /dev/null and b/pygame test/data/sprites/tokens/Boss1_token.png differ diff --git a/pygame test/data/world/rooms/room_01.json b/pygame test/data/world/rooms/room_01.json index e29d63a..9f0f420 100644 --- a/pygame test/data/world/rooms/room_01.json +++ b/pygame test/data/world/rooms/room_01.json @@ -130,6 +130,14 @@ 560 ], "radius": 180 + }, + { + "type": "quest_giver", + "quest_id": "quest_first_kills", + "pos": [ + 300, + 600 + ] } ] } \ No newline at end of file diff --git a/pygame test/data/world/rooms/room_boss_refight_01.json b/pygame test/data/world/rooms/room_boss_refight_01.json new file mode 100644 index 0000000..75e2524 --- /dev/null +++ b/pygame test/data/world/rooms/room_boss_refight_01.json @@ -0,0 +1,48 @@ +{ + "meta": { + "id": "room_boss_refight_01", + "name": "room_boss_refight_01" + }, + "player_spawn": [ + -240, + 300 + ], + "bounds": { + "kill_y": 1400 + }, + "platforms": { + "ground": [ + [ + -500, + 360, + 1500, + 240 + ] + ] + }, + "enemies": [ + { + "type": "boss", + "pos": [ + 280, + 200 + ] + } + ], + "exits": [ + { + "rect": [ + -460, + 300, + 80, + 60 + ], + "to": "room_town_gloomreach", + "spawn_id": "entry_diary", + "hint": "Leave", + "requires_boss_dead": true + } + ], + "ability_doors": [], + "entry_spawns": {} +} \ No newline at end of file diff --git a/pygame test/data/world/rooms/room_town_gloomreach.json b/pygame test/data/world/rooms/room_town_gloomreach.json index 731a677..65813a8 100644 --- a/pygame test/data/world/rooms/room_town_gloomreach.json +++ b/pygame test/data/world/rooms/room_town_gloomreach.json @@ -42,8 +42,21 @@ "entry_1": [ 120, 300 + ], + "entry_diary": [ + 180, + 300 ] }, + "props": [ + { + "type": "diary", + "pos": [ + 180, + 310 + ] + } + ], "npcs": [ { "type": "vendor", @@ -65,6 +78,14 @@ 1020, 306 ] + }, + { + "type": "combat_master", + "pos": [ + 400, + 306 + ], + "trade_cost": 0 } ] } \ No newline at end of file diff --git a/pygame test/pygame_test.py b/pygame test/pygame_test.py index 5124f8d..f600ee0 100644 --- a/pygame test/pygame_test.py +++ b/pygame test/pygame_test.py @@ -17,6 +17,8 @@ SHOCKWAVE_SPEED = 600.0 # px/sec SHOCKWAVE_LIFETIME = 2.0 # seconds SHOCKWAVE_HEIGHT = 20 # hitbox height SHOCKWAVE_DAMAGE = 2 +BOSS_POISON_DURATION = 4.0 # boss attacks poison the player for this many seconds +ATTACK_COOLDOWN_BASE = 0.35 # recovery between melee swings; attack speed reduces it # --- Ranged ability / projectile settings --- RANGED_CHARGE_COST = 5.0 # costs more than melee (melee costs 1) @@ -106,6 +108,7 @@ BINDINGS_DEFAULT = { "toggle_shade": pygame.K_LSHIFT, "inventory": pygame.K_i, "interact": pygame.K_f, + "stats_overlay": pygame.K_h, "pause": pygame.K_ESCAPE, "ui_up": pygame.K_UP, "ui_down": pygame.K_DOWN, @@ -164,6 +167,7 @@ SETTINGS_ACTIONS = [ ("Toggle Shade", "toggle_shade"), ("Inventory", "inventory"), ("Interact/Trade", "interact"), + ("Stats Overlay", "stats_overlay"), ("Pause", "pause"), ("Debug 1", "debug_1"), ("Debug 2", "debug_2"), @@ -688,6 +692,52 @@ def safe_get_item(item_id: str): return ITEM_BY_ID.get(item_id) +# Stats that only benefit the shade form (kept out of early boss / town loot). +SHADE_STAT_KEYS = { + "move_speed_shade_pct", + "shade_double_damage_chance", + "jump_height_shade_pct", + "shade_damage_flat", + "shade_charge_drain_reduction_pct", + "negate_shade_speed_penalty", +} + + +def item_has_shade_bonus(base_item) -> bool: + """True if an item (base stats or any rarity override) has a shade-only bonus.""" + if not isinstance(base_item, dict): + return False + def _has(stat_dict): + if not isinstance(stat_dict, dict): + return False + return any(stat_dict.get(k) for k in SHADE_STAT_KEYS) + if _has(base_item.get("base_stats")): + return True + for rdata in (base_item.get("rarity_overrides") or {}).values(): + if isinstance(rdata, dict) and _has(rdata.get("extra_stats")): + return True + return False + + +def item_set_has_shade_bonus(set_id: str) -> bool: + """True if ANY piece of the given set carries a shade-only bonus.""" + for it in ITEM_BY_ID.values(): + if isinstance(it, dict) and it.get("set") == set_id: + if item_has_shade_bonus(it): + return True + return False + + +def item_is_shade_restricted(base_item) -> bool: + """Restrict an item if it OR any other piece of its set has a shade-only bonus.""" + if not isinstance(base_item, dict): + return False + if item_has_shade_bonus(base_item): + return True + set_id = base_item.get("set") + return bool(set_id) and item_set_has_shade_bonus(set_id) + + def merge_stat_value(dst: dict, key: str, value): """Numeric stats add, boolean stats OR, everything else overwrites.""" if isinstance(value, bool): @@ -1071,6 +1121,31 @@ TETHER_SPRITE_DARK_RAW = _load_image_or_none(TETHER_SPRITE_DARK_PATH) TETHER_EDGE_LIGHT_RAW = _load_image_or_none(TETHER_EDGE_LIGHT_PATH) TETHER_EDGE_DARK_RAW = _load_image_or_none(TETHER_EDGE_DARK_PATH) + +def _load_first_existing(names: list[Path]): + for p in names: + if p.exists(): + img = _load_image_or_none(p) + if img is not None: + return img + return None + + +# Ranged projectile + boss token sprites (fallback to placeholder shapes if absent) +RANGED_PROJECTILE_SPRITE_RAW = _load_first_existing([ + ABILITY_SPRITES_DIR / "Ranged_projectile.png", + ABILITY_SPRITES_DIR / "ranged_projectile.png", + ABILITY_SPRITES_DIR / "ranged_bolt.png", +]) +TOKENS_DIR = DATA_ROOT / "sprites" / "tokens" +BOSS_TOKEN_SPRITE_RAW = _load_first_existing([ + TOKENS_DIR / "Boss1_token.png", + TOKENS_DIR / "boss_token.png", + OBJECTS_DIR / "Boss1_token.png", + OBJECTS_DIR / "boss_token.png", + OBJECTS_DIR / "Boss_Token.png", +]) + def _scale_icon(img: pygame.Surface | None, size: int) -> pygame.Surface | None: if img is None: return None @@ -1196,6 +1271,17 @@ class Projectile: return self.t > 0 def draw(self, surface, cx, cy): + if RANGED_PROJECTILE_SPRITE_RAW is not None: + img = RANGED_PROJECTILE_SPRITE_RAW + target = 22 + scale = target / max(1, img.get_width(), img.get_height()) + w = max(1, int(img.get_width() * scale)) + h = max(1, int(img.get_height() * scale)) + img = pygame.transform.scale(img, (w, h)) + if self.dir > 0: + img = pygame.transform.flip(img, True, False) + surface.blit(img, (self.rect.centerx - w // 2 - cx, self.rect.centery - h // 2 - cy)) + return # simple readable bolt pygame.draw.rect( surface, @@ -1681,7 +1767,9 @@ class TradeUI: self._try_trade() def _random_item(self, slot: str, rarity: str) -> dict | None: - pool = [it for it in ARMOR_DB if isinstance(it, dict) and it.get("slot") == slot] + # Town shops only sell light/general gear (no shade-only bonuses). + pool = [it for it in ARMOR_DB if isinstance(it, dict) and it.get("slot") == slot + and not item_is_shade_restricted(it)] if not pool: return None base = random.choice(pool) @@ -1689,6 +1777,21 @@ class TradeUI: return build_item_instance(base, use_rarity) def _build_options(self, npc: NPC | None) -> list[dict]: + # Combat Master: offer to upgrade each carried boss token + if npc is not None and npc.type == "combat_master": + options = [] + for it in self.inv.items: + if isinstance(it, dict) and it.get("item_type") == "boss_token": + tier = int(it.get("tier", 0)) + if tier >= 1: + continue + options.append({ + "type": "upgrade_token", + "boss_id": it.get("boss_id"), + "label": f"Upgrade {it.get('name', 'Token')} (Hard)", + }) + return options + offers = npc.offers if npc and npc.offers else [] if not offers: npc_type = npc.type if npc else "vendor" @@ -1792,6 +1895,27 @@ class TradeUI: self.cursor = max(0, min(self.cursor, len(self.options) - 1)) else: self.cursor = 0 + elif choice_type == "upgrade_token": + boss_id = choice.get("boss_id") + idx = None + for i, it in enumerate(self.inv.items): + if (isinstance(it, dict) and it.get("item_type") == "boss_token" + and it.get("boss_id") == boss_id and int(it.get("tier", 0)) == 0): + idx = i + break + if idx is None: + pickup_feed.push_plain("Token not found.") + return + token = dict(self.inv.items.pop(idx)) + token["tier"] = 1 + token["name"] = make_boss_token(boss_id, 1)["name"] + self.inv.add(token) + pickup_feed.push_plain(f"Upgraded to {token['name']}!") + self.options = self._build_options(self.active_npc) + if self.options: + self.cursor = max(0, min(self.cursor, len(self.options) - 1)) + else: + self.cursor = 0 elif choice_type == "sell_specific": item_id = choice.get("item_id") if not item_id: @@ -1889,6 +2013,18 @@ def spawn_drops_from_enemy(enemy) -> list[LootPickup]: elif (not pool) and default_pool == "necklace": pool = list(NECKLACE_BY_ID.keys()) + # Boss (first boss) drops only light/general gear; the miniboss keeps everything. + restrict_shade = (enemy.drop_profile == "boss") + + def _shade_ok(item_id): + base = safe_get_item(item_id) + return base is not None and not item_is_shade_restricted(base) + + def pick_from(source): + if restrict_shade: + source = [i for i in source if _shade_ok(i)] + return random.choice(source) if source else None + # ---- drop placement helper (prevents overlap) ---- spawned = 0 @@ -1919,17 +2055,13 @@ def spawn_drops_from_enemy(enemy) -> list[LootPickup]: pool_kind = g.get("item_pool", None) if not item_id: if pool_kind == "necklace": - if not NECKLACE_BY_ID: - continue - item_id = random.choice(list(NECKLACE_BY_ID.keys())) + item_id = pick_from(list(NECKLACE_BY_ID.keys())) elif pool_kind == "armor": - if not ARMOR_BY_ID: - continue - item_id = random.choice(list(ARMOR_BY_ID.keys())) + item_id = pick_from(list(ARMOR_BY_ID.keys())) else: - if not pool: - continue - item_id = random.choice(pool) + item_id = pick_from(pool) + if not item_id: + continue base = safe_get_item(item_id) if not base: print("DROP DEBUG: missing item ->", item_id) @@ -1953,7 +2085,9 @@ def spawn_drops_from_enemy(enemy) -> list[LootPickup]: if not pool: continue - item_id = random.choice(pool) + item_id = pick_from(pool) + if not item_id: + continue base = safe_get_item(item_id) if not base: print("DROP DEBUG: missing item ->", item_id) @@ -2444,6 +2578,33 @@ class InventoryUI: eq_y += self.line_h + int(_ui(["inventory_ui", "equipped", "line_gap"], 4)) + # --- Quests (below equipped) --- + quests_x = eq_x + quests_y = eq_y + int(_ui(["inventory_ui", "quests", "gap"], 8)) + quest_w = self.w - (quests_x - self.x) - self.pad + self._blit(surface, "Quests:", quests_x, quests_y, (220, 220, 220)) + quests_y += self.line_h + + active_ids = list(quest_manager.active.keys()) + max_q = int(_ui(["inventory_ui", "quests", "max_lines"], 4)) + shown = 0 + for qid in active_ids[:max_q]: + q = quest_manager.get(qid) + if q is None: + continue + st = quest_manager.active[qid] + prog = int(st.get("progress", 0)) + complete = prog >= q.target_count() + color = (150, 220, 150) if complete else (230, 230, 230) + label = self._ellipsize(f"{q.name}: {prog}/{q.target_count()}", max(0, quest_w)) + self._blit(surface, label, quests_x, quests_y, color) + quests_y += self.line_h + shown += 1 + if not active_ids: + self._blit(surface, "(no active quests)", quests_x, quests_y, (160, 160, 160)) + elif len(active_ids) > shown: + self._blit(surface, f"+{len(active_ids) - shown} more", quests_x, quests_y, (160, 160, 160)) + # --- Detail areas --- stats_h = int(_ui(["inventory_ui", "details", "stats_h"], 90)) hint_h = int(_ui(["inventory_ui", "details", "hint_h"], 24)) @@ -2852,6 +3013,7 @@ class Player: self.attacking = False self.attack_timer = 0.0 self.attack_duration = 0.2 + self.attack_cd = 0.0 self.facing_right = True self.damage_applied = False self.dash_cd = 0.0 @@ -2864,6 +3026,9 @@ class Player: self.jump_held_last = False # --- charge regen lockout (prevents instant refill after attacking) --- self.charge_regen_lock = 0.0 + self.charge_drain_mult = 1.0 # set per-frame (e.g. 2x when shade-clipping an enemy) + self.poison_timer = 0.0 + self.poison_acc = 0.0 self.bonus_move_speed_light_pct = 0.0 self.bonus_move_speed_shade_pct = 0.0 @@ -2920,9 +3085,12 @@ class Player: return HEAL_COST_FLAT / dur if dur > 0 else 999999.0 def effective_attack_duration(self) -> float: - # More attack_speed_pct => shorter duration - # clamp so it never becomes 0 or negative - return max(0.05, self.attack_duration / (1.0 + self.bonus_attack_speed_pct)) + # The swing itself is always the same length. Attack speed increases rate, not swing speed. + return float(self.attack_duration) + + def effective_attack_cooldown(self) -> float: + # Recovery between swings: attack_speed_pct reduces it -> faster attack rate. + return ATTACK_COOLDOWN_BASE / (1.0 + self.bonus_attack_speed_pct) def effective_heal_duration(self) -> float: @@ -3135,7 +3303,7 @@ class Player: def attack(self): if self.healing: return - if self.charge >= 1 and not self.attacking: + if self.charge >= 1 and not self.attacking and self.attack_cd <= 0.0: self.attacking = True self.attack_timer = self.effective_attack_duration() self.charge -= 1 @@ -3180,28 +3348,35 @@ class Player: return boxes[0] if boxes else None def get_attack_hitboxes(self): - """Wide-at-player, narrow-outward 'inverse cone' melee hitbox.""" + """Wide-at-player, narrow-outward 'inverse cone' melee hitbox. + + The near segment overlaps ~30% into the player's body so enemies you're + hugging (common in shade) still get hit; the far tip keeps its height so + it reliably connects at max reach. + """ if not self.attacking: return [] + body_overlap = int(self.rect.width * 0.3) # ~30% into the body + # Near (wide) segment w1 = 28 # wider close to player h1 = int(self.rect.height * 0.65) y1 = self.rect.centery - h1 // 2 - # Far (narrow) segment (+50% reach, but slimmer) - w2 = int(round(w1 * 0.5)) # +50% reach - h2 = int(round(h1 * 0.55)) # noticeably narrower + # Far (tip) segment: extends reach, slightly narrower but same height + w2 = 18 + h2 = h1 y2 = self.rect.centery - h2 // 2 if self.facing_right: - x1 = self.rect.right + x1 = self.rect.right - body_overlap x2 = self.rect.right + w1 else: x1 = self.rect.left - w1 x2 = self.rect.left - w1 - w2 - near = pygame.Rect(int(x1), int(y1), int(w1), int(h1)) + near = pygame.Rect(int(x1), int(y1), int(w1 + body_overlap), int(h1)) far = pygame.Rect(int(x2), int(y2), int(w2), int(h2)) return [near, far] @@ -3229,6 +3404,9 @@ class Player: return True + def apply_poison(self, duration: float): + self.poison_timer = max(self.poison_timer, float(duration)) + def apply_knockback(self, source_rect: pygame.Rect, strength_px_per_frame: float = 10.0, duration: float = 0.18, @@ -3266,6 +3444,9 @@ class Player: self.shade = False self.attacking = False self.attack_timer = 0.0 + self.attack_cd = 0.0 + self.poison_timer = 0.0 + self.poison_acc = 0.0 self.invincibility_timer = 0.0 self._cancel_heal() @@ -3466,7 +3647,7 @@ class Player: self._update_heal(keys, dt) if self.shade: - self.charge -= CHARGE_DRAIN_RATE * dt + self.charge -= CHARGE_DRAIN_RATE * getattr(self, "charge_drain_mult", 1.0) * dt if self.charge <= 0: self.charge = 0 self.shade = False @@ -3477,10 +3658,23 @@ class Player: self.charge = min(self.max_charge, self.charge + regen * dt) + if self.attack_cd > 0: + self.attack_cd = max(0.0, self.attack_cd - dt) + if self.attacking: self.attack_timer -= dt if self.attack_timer <= 0: self.attacking = False + self.attack_cd = self.effective_attack_cooldown() + + # Poison: 1 HP per second while active + if self.poison_timer > 0.0: + self.poison_timer = max(0.0, self.poison_timer - dt) + self.poison_acc += dt + if self.poison_acc >= 1.0: + ticks = int(self.poison_acc) + self.poison_acc -= float(ticks) + self.health = max(0, self.health - ticks) self._clamp_resources() @@ -4633,16 +4827,85 @@ ability_doors = spawn_ability_doors(ROOM) loot_drops: list[LootPickup] = [] shockwaves: list[Shockwave] = [] coin_drops: list[CoinPickup] = [] +token_drops: list = [] projectiles: list[Projectile] = [] shatter_platforms: list[ShatterPlatform] = [] shatter_aoes: list[ShatterAOE] = [] tether_shots: list[TetherShot] = [] light_sources: list[LightSource] = [] dust_puffs: list[DustPuff] = [] +class QuestGiver: + """Interactable environment object that hands out a quest.""" + INTERACT_RANGE = 70 + + def __init__(self, x: int, y: int, quest_id: str): + self.rect = pygame.Rect(int(x), int(y), 34, 40) + self.quest_id = str(quest_id) + self.bob_t = random.random() * 10.0 + + def draw(self, surface, cx, cy): + bob_y = int(math.sin(self.bob_t) * 2) + self.bob_t += 0.05 + x = int(self.rect.x - cx) + y = int(self.rect.y - cy + bob_y) + pygame.draw.rect(surface, (255, 130, 180), (x, y, self.rect.width, self.rect.height)) + pygame.draw.rect(surface, (160, 40, 90), (x, y, self.rect.width, self.rect.height), 2) + + +def get_near_quest_giver(player_obj, qg_list: list, range_px: int = QuestGiver.INTERACT_RANGE) -> QuestGiver | None: + best = None + best_d = None + for qg in qg_list: + dx = player_obj.rect.centerx - qg.rect.centerx + dy = player_obj.rect.centery - qg.rect.centery + d = math.hypot(dx, dy) + if d <= range_px: + if best is None or d < best_d: + best = qg + best_d = d + return best + + +def interact_quest_giver(qg: QuestGiver): + """Player pressed F near a quest object. Accept / show progress / turn in.""" + qid = qg.quest_id + q = quest_manager.get(qid) + if q is None: + pickup_feed.push_plain("Quest not found.") + return + + if quest_manager.is_complete(qid): + result = quest_manager.turn_in(qid) + if result is None: + pickup_feed.push_plain(f"{q.name} is not ready to turn in yet.") + return + parts = [f"{q.name} complete!"] + if result.get("coins"): + player.coins_light += int(result["coins"]) + parts.append(f"+{result['coins']} light coins") + if result.get("tokens"): + parts.append(f"+{result['tokens']} token") + if result.get("item"): + inventory.add(result["item"]) + parts.append(f"+{result['item'].get('name', 'Item')}") + pickup_feed.push_center(" ".join(parts), duration=4.0) + return + + if quest_manager.accept(qid): + pickup_feed.push_plain(f"Quest accepted: {q.name} ({q.description})") + return + + st = quest_manager.active.get(qid) + prog = int(st.get("progress", 0)) if st else 0 + pickup_feed.push_plain(f"{q.name}: {prog}/{q.target_count()}") + + def spawn_room_props(room: Room, player_obj: "Player"): reward_chest_obj = None reward_ability_obj = None lights: list[LightSource] = [] + quest_givers: list[QuestGiver] = [] + diary_obj = None props = room.props if not props and room.id == REWARD_ROOM_ID: @@ -4675,11 +4938,15 @@ def spawn_room_props(room: Room, player_obj: "Player"): elif p_type == "light_source": radius = int(p.get("radius", 180)) lights.append(LightSource(x, y, radius)) + elif p_type == "quest_giver": + quest_givers.append(QuestGiver(x, y, str(p.get("quest_id", "")))) + elif p_type == "diary": + diary_obj = DiaryObject(x, y) - return reward_chest_obj, reward_ability_obj, lights + return reward_chest_obj, reward_ability_obj, lights, quest_givers, diary_obj -reward_chest, reward_ability, light_sources = spawn_room_props(ROOM, player) +reward_chest, reward_ability, light_sources, quest_givers, diary_obj = spawn_room_props(ROOM, player) def grant_full_set(set_id: str, rarity: str, equip: bool = True) -> bool: slots = ["helmet", "chest", "pants"] @@ -4729,7 +4996,9 @@ def apply_room(room: Room, set_player_spawn=True): global light_sources global dust_puffs global dust_puffs - global reward_chest, reward_ability + global reward_chest, reward_ability, quest_givers, diary_obj + global token_drops + global ACTIVE_REFIGHT global ROOM_DROP_MULT, ROOM_COIN_MULT @@ -4743,9 +5012,14 @@ def apply_room(room: Room, set_player_spawn=True): if is_town_room(ROOM): town_title.trigger(ROOM.id, town_display_name(ROOM)) + # Leaving a diary refight room ends the refight. + if ACTIVE_REFIGHT is not None and ROOM.id != ACTIVE_REFIGHT.get("room_id"): + ACTIVE_REFIGHT = None + loot_drops = [] shockwaves = [] coin_drops = [] + token_drops = [] projectiles = [] shatter_platforms = [] shatter_aoes = [] @@ -4761,7 +5035,8 @@ def apply_room(room: Room, set_player_spawn=True): reward_chest = None reward_ability = None - reward_chest, reward_ability, light_sources = spawn_room_props(ROOM, player) + diary_obj = None + reward_chest, reward_ability, light_sources, quest_givers, diary_obj = spawn_room_props(ROOM, player) if set_player_spawn: player.start_x = int(ROOM.player_spawn[0]) @@ -4919,7 +5194,17 @@ class Quest: 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))) + return max(0, int(self.reward.get("token_count", 0))) + + def coin_range(self) -> tuple[int, int] | None: + """Optional [min, max] light-coin reward for a quest.""" + cr = self.reward.get("coin_range") + if isinstance(cr, (list, tuple)) and len(cr) == 2: + return (int(cr[0]), int(cr[1])) + return None + + def target_count(self) -> int: + return max(1, int(self.objective.get("count", 1))) class QuestManager: @@ -4986,13 +5271,40 @@ class QuestManager: else: self.completed_one_time.add(quest_id) + def progress_kill(self, enemy_type: str) -> list[str]: + """Progress every active kill-quest matching this enemy type. + Returns quest ids that just became complete.""" + completed = [] + for qid in list(self.active.keys()): + q = self.quests.get(qid) + if q is None: + continue + obj = q.objective + if obj.get("kind") != "kill" or str(obj.get("target", "")) != enemy_type: + continue + was = self.is_complete(qid) + self.progress(qid, 1) + if not was and self.is_complete(qid): + completed.append(qid) + return completed + def turn_in(self, quest_id: str) -> dict | None: - """Called by the quest giver: completes the quest and returns the reward item.""" + """Completes an active quest. Returns a reward result: + {"item": dict|None, "coins": int, "tokens": int}.""" 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() + result = {"item": None, "coins": 0, "tokens": 0} + if q.is_repeatable: + result["tokens"] = q.token_count() + result["item"] = q.roll_pool_item() + else: + result["item"] = q.guaranteed_item() + cr = q.coin_range() + if cr: + result["coins"] = random.randint(cr[0], cr[1]) + return result def add_tokens(self, amount: int): self.tokens += max(0, int(amount)) @@ -5043,6 +5355,17 @@ QUEST_DEFS_DEFAULT = [ "guaranteed_item": {"slot": "chest", "rarity": "attuned"}, }, }, + { + "id": "quest_first_kills", + "name": "First Kills", + "description": "Kill 4 normal enemies.", + "type": "repeatable", + "giver": "object:quest_board", + "objective": {"kind": "kill", "target": "enemy", "count": 4}, + "reward": { + "coin_range": [5, 15], + }, + }, ] quest_manager = QuestManager() @@ -5056,75 +5379,371 @@ BOSS_DEFS_DEFAULT = { "boss_01": { "name": "Gloom Warden", "room": "room_boss_01.json", - "trophy_name": "Warden's Heart", + "refight_room": "room_boss_refight_01.json", + "token_name": "Gloom Warden's Token", }, } -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 boss_refight_room_id(boss_id: str) -> str | None: + bd = BOSS_DEFS_DEFAULT.get(boss_id) + return str(bd.get("refight_room")) if isinstance(bd, dict) and bd.get("refight_room") else None + + +def boss_id_for_room(room_id: str) -> str | None: + for bid, bd in BOSS_DEFS_DEFAULT.items(): + if isinstance(bd, dict) and str(bd.get("room")) == room_id: + return bid + return None + + +def make_boss_token(boss_id: str, tier: int = 0) -> dict: + bd = BOSS_DEFS_DEFAULT.get(boss_id, {}) + name = str(bd.get("token_name") or f"{bd.get('name', boss_id)}'s Token") + if int(tier) >= 1: + name = f"{name} (Hard)" + return {"item_type": "boss_token", "boss_id": str(boss_id), "name": name, "tier": int(tier)} + + +def find_carried_boss_token(inv, boss_id: str, tier: int | None = None): + for it in inv.items: + if not isinstance(it, dict): + continue + if it.get("item_type") == "boss_token" and it.get("boss_id") == boss_id: + if tier is None or int(it.get("tier", 0)) == tier: + return it + return None + + +class BossDiary: + """Tracks which boss tokens are slotted into the diary (usable for fights).""" + def __init__(self): + self.slotted: dict[str, int] = {} # boss_id -> token tier + + def tier(self, boss_id: str) -> int: + return self.slotted.get(boss_id, -1) # -1 = not slotted + + def has(self, boss_id: str) -> bool: + return boss_id in self.slotted + + def slot(self, boss_id: str, tier: int): + self.slotted[str(boss_id)] = int(tier) + + def unslot(self, boss_id: str) -> int | None: + return self.slotted.pop(str(boss_id), None) + + +boss_diary = BossDiary() + +# Active diary refight (cleared when leaving the refight room) +ACTIVE_REFIGHT: dict | None = None # {"boss_id": str, "variant": str, "room_id": str} + + 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): +def start_refight(boss_id: str, variant: str = "normal") -> bool: + """Begin a diary refight: teleport into the boss's refight room.""" + global ACTIVE_REFIGHT + room_id = boss_refight_room_id(boss_id) + if not room_id: return False - room = boss_room_id(boss_id) - if room: - clear_boss_defeated(room) + ACTIVE_REFIGHT = {"boss_id": boss_id, "variant": variant, "room_id": room_id} + DEFEATED_BOSSES.discard(room_id) # refight room must always spawn its boss + room = room_manager.load_room(room_id) + apply_room(room, set_player_spawn=True) + if variant == "hard": + for en in enemies: + if getattr(en, "type_id", "") == "boss": + en.speed *= 2.0 + en.air_speed *= 2.0 + en.shockwave_speed *= 2.0 + en.slam_cooldown_base = max(0.25, en.slam_cooldown_base / 2.0) return True -boss_collection = BossCollection() +class TokenPickup: + """Drops from a world boss fight; grants its boss token when collected.""" + def __init__(self, x, y, boss_id): + self.boss_id = str(boss_id) + self.rect = pygame.Rect(int(x), int(y), 16, 16) + self.vx = random.uniform(-40, 40) + self.vy = random.uniform(-120, -40) + self.on_ground = False + self.bob_t = random.random() * 10.0 + + def update(self, dt, platforms): + self.bob_t += dt * LOOT_BOB_SPEED + if self.on_ground: + self.vx *= LOOT_FRICTION + if abs(self.vx) < 6: + self.vx = 0.0 + self.rect.x += int(self.vx * dt) + return + self.vy = min(LOOT_TERMINAL_VEL, self.vy + LOOT_GRAVITY * dt) + self.rect.x += int(self.vx * dt) + self.rect.y += int(self.vy * dt) + for p in platforms: + if self.rect.colliderect(p): + if self.vy > 0 and self.rect.bottom >= p.top: + self.rect.bottom = p.top + self.vy = -self.vy * LOOT_BOUNCE + if abs(self.vy) < 30: + self.vy = 0.0 + self.on_ground = True + elif self.vy < 0 and self.rect.top <= p.bottom: + self.rect.top = p.bottom + self.vy = 0.0 + + def draw(self, surface, cx, cy): + bob_y = int(math.sin(self.bob_t) * LOOT_BOB_AMP) if self.on_ground else 0 + x = int(self.rect.x - cx) + y = int(self.rect.y - cy + bob_y) + if BOSS_TOKEN_SPRITE_RAW is not None: + img = pygame.transform.scale(BOSS_TOKEN_SPRITE_RAW, (self.rect.width, self.rect.height)) + surface.blit(img, (x, y)) + return + pygame.draw.circle(surface, (60, 200, 190), (x + 8, y + 8), 7) + pygame.draw.circle(surface, (10, 110, 105), (x + 8, y + 8), 7, 2) + + +class DiaryObject: + """Interactable book in town: opens the boss-token diary menu.""" + INTERACT_RANGE = 70 + + def __init__(self, x: int, y: int): + self.rect = pygame.Rect(int(x), int(y), 40, 50) + self.bob_t = random.random() * 10.0 + + def draw(self, surface, cx, cy): + bob_y = int(math.sin(self.bob_t) * 2) + self.bob_t += 0.05 + x = int(self.rect.x - cx) + y = int(self.rect.y - cy + bob_y) + pygame.draw.rect(surface, (150, 110, 70), (x, y, self.rect.width, self.rect.height)) + pygame.draw.rect(surface, (90, 60, 30), (x, y, self.rect.width, self.rect.height), 2) + pygame.draw.line(surface, (90, 60, 30), + (x + self.rect.width // 2, y), + (x + self.rect.width // 2, y + self.rect.height), 2) + + +def get_near_diary(player_obj, diary_obj) -> bool: + if diary_obj is None: + return False + dx = player_obj.rect.centerx - diary_obj.rect.centerx + dy = player_obj.rect.centery - diary_obj.rect.centery + return math.hypot(dx, dy) <= DiaryObject.INTERACT_RANGE + + +def _ellipsize_text(text: str, fnt, max_w: int) -> str: + if fnt.size(text)[0] <= max_w: + return text + while text and fnt.size(text + "...")[0] > max_w: + text = text[:-1] + return text + "..." + + +class DiaryUI: + def __init__(self, inventory, player, font_obj: pygame.font.Font): + self.inv = inventory + self.player = player + self.font = font_obj + self.small_font = pygame.font.SysFont(None, 16) + self.visible = False + self.boss_ids: list[str] = [] + self.grid_cursor = 0 + self.action_open = False + self.actions: list[dict] = [] + self.action_cursor = 0 + self.cols = 4 + + def _rebuild(self): + self.boss_ids = list(BOSS_DEFS_DEFAULT.keys()) + + def toggle(self): + self.visible = not self.visible + self.grid_cursor = 0 + self.action_open = False + self.actions = [] + self.action_cursor = 0 + if self.visible: + self._rebuild() + + def _boss_actions(self, boss_id: str) -> list[dict]: + bd = BOSS_DEFS_DEFAULT.get(boss_id, {}) + name = bd.get("name", boss_id) + tier = boss_diary.tier(boss_id) + carried = find_carried_boss_token(self.inv, boss_id) + actions = [] + if tier >= 0: + actions.append({"action": "fight", "boss_id": boss_id, "variant": "normal", + "label": f"Fight {name} (Normal)"}) + if tier >= 1: + actions.append({"action": "fight", "boss_id": boss_id, "variant": "hard", + "label": f"Fight {name} (Hard)"}) + actions.append({"action": "unslot", "boss_id": boss_id, + "label": f"Remove {bd.get('token_name', name + ' Token')} from diary"}) + elif carried is not None: + actions.append({"action": "slot", "boss_id": boss_id, "tier": int(carried.get("tier", 0)), + "label": f"Use {carried.get('name', 'Token')} (add to diary)"}) + else: + actions.append({"action": "none", "boss_id": boss_id, "label": f"{name}: no token"}) + return actions + + def handle_keydown(self, key): + if not self.visible: + return + if key in (key_for("ui_cancel"), key_for("interact"), key_for("inventory")): + if self.action_open: + self.action_open = False + self.actions = [] + self.action_cursor = 0 + else: + self.visible = False + return + + if self.action_open: + if not self.actions: + return + if key == key_for("ui_up"): + self.action_cursor = (self.action_cursor - 1) % len(self.actions) + elif key == key_for("ui_down"): + self.action_cursor = (self.action_cursor + 1) % len(self.actions) + elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + self._do_action(self.actions[self.action_cursor]) + return + + n = len(self.boss_ids) + if n == 0: + return + row = self.grid_cursor // self.cols + col = self.grid_cursor % self.cols + if key == key_for("ui_left"): + col = (col - 1) % self.cols + elif key == key_for("ui_right"): + col = (col + 1) % self.cols + elif key == key_for("ui_up"): + row = max(0, row - 1) + elif key == key_for("ui_down"): + max_row = max(0, (n - 1) // self.cols) + row = min(max_row, row + 1) + elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): + self.actions = self._boss_actions(self.boss_ids[self.grid_cursor]) + self.action_cursor = 0 + self.action_open = True + return + self.grid_cursor = min(n - 1, row * self.cols + col) + + def _do_action(self, choice: dict): + action = choice.get("action") + boss_id = choice.get("boss_id") + if action == "slot": + it = find_carried_boss_token(self.inv, boss_id) + if it is None: + pickup_feed.push_plain("Token not found.") + return + self.inv.items.remove(it) + boss_diary.slot(boss_id, int(choice.get("tier", 0))) + pickup_feed.push_plain(f"{it.get('name')} added to diary.") + elif action == "unslot": + tier = boss_diary.unslot(boss_id) + if tier is None: + return + token = make_boss_token(boss_id, tier) + self.inv.add(token) + pickup_feed.push_plain(f"{token['name']} returned to inventory.") + elif action == "fight": + self.visible = False + start_refight(boss_id, choice.get("variant", "normal")) + return + self.actions = self._boss_actions(boss_id) + self.action_cursor = 0 + + def draw(self, surface: pygame.Surface): + if not self.visible: + return + n = len(self.boss_ids) + if n == 0: + return + + cell_w = 120 + cell_h = 96 + gap = 10 + pad = 14 + cols = self.cols + rows = (n + cols - 1) // cols + panel_w = pad * 2 + cols * cell_w + (cols - 1) * gap + header_h = 36 + action_h = 8 + len(self.actions) * 20 if self.action_open else 0 + panel_h = header_h + rows * cell_h + (rows - 1) * gap + pad + action_h + x = (WIDTH - panel_w) // 2 + y = (HEIGHT - panel_h) // 2 + + panel = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA) + panel.fill((12, 12, 20, 235)) + surface.blit(panel, (x, y)) + pygame.draw.rect(surface, (100, 100, 130), (x, y, panel_w, panel_h), 2) + surface.blit(self.font.render("Boss Diary", True, (235, 230, 215)), (x + pad, y + 10)) + + for i, boss_id in enumerate(self.boss_ids): + bd = BOSS_DEFS_DEFAULT.get(boss_id, {}) + cx = x + pad + (i % cols) * (cell_w + gap) + cy = y + header_h + (i // cols) * (cell_h + gap) + cell = pygame.Rect(cx, cy, cell_w, cell_h) + pygame.draw.rect(surface, (35, 35, 50), cell) + is_sel = (i == self.grid_cursor) + border = (230, 200, 120) if is_sel else (90, 90, 110) + pygame.draw.rect(surface, border, cell, 2 if is_sel else 1) + + tier = boss_diary.tier(boss_id) + carried = find_carried_boss_token(self.inv, boss_id) + + icon_x = cell.centerx - 16 + icon_y = cell.y + 8 + has_token = (tier >= 0 or carried is not None) + if BOSS_TOKEN_SPRITE_RAW is not None and has_token: + icon = BOSS_TOKEN_SPRITE_RAW + if tier < 0: + icon = icon.copy() + icon.set_alpha(120) + surface.blit(icon, (icon_x, icon_y)) + if tier >= 1: + pygame.draw.rect(surface, (200, 50, 50), (icon_x, icon_y, 32, 5)) + htag = self.small_font.render("H", True, (255, 255, 255)) + surface.blit(htag, (icon_x + 13, icon_y - 1)) + else: + pygame.draw.rect(surface, (60, 60, 75), (icon_x, icon_y, 32, 32), 2) + + status = "Ready" if tier >= 0 else ("Carried" if carried is not None else "No token") + status_col = (150, 220, 150) if tier >= 0 else ((200, 200, 160) if carried else (150, 150, 150)) + st = self.small_font.render(status, True, status_col) + surface.blit(st, (cell.centerx - st.get_width() // 2, cell.y + cell_h - 38)) + + label = _ellipsize_text(str(bd.get("name", boss_id)), self.small_font, cell_w - 12) + label_surf = self.small_font.render(label, True, (220, 220, 220)) + surface.blit(label_surf, (cell.centerx - label_surf.get_width() // 2, cell.y + cell_h - 20)) + + if self.action_open and self.actions: + ay = y + panel_h - action_h + 4 + for j, a in enumerate(self.actions): + if j == self.action_cursor: + pygame.draw.rect(surface, (70, 70, 95), (x + 8, ay - 1, panel_w - 16, 18)) + col = (255, 225, 130) if j == self.action_cursor else (230, 230, 230) + if a.get("action") == "none": + col = (150, 150, 150) + label = _ellipsize_text(str(a.get("label", "")), self.small_font, panel_w - 28) + surface.blit(self.small_font.render(label, True, col), (x + 14, ay)) + ay += 20 + + +diary_ui = DiaryUI(inventory, player, font) # ---------------------------- @@ -5196,11 +5815,62 @@ class TownTitleBanner: town_title = TownTitleBanner(font) +def draw_stats_overlay(surface): + if not stats_overlay_visible: + return + light_speed = PLAYER_SPEED * (1.0 + player.bonus_move_speed_pct + player.bonus_move_speed_light_pct) + shade_speed = PLAYER_SHADE_SPEED * (1.0 + player.bonus_move_speed_pct + player.bonus_move_speed_shade_pct) + heal_dur = player.effective_heal_duration() + cycle = player.effective_attack_duration() + player.effective_attack_cooldown() + atk_rate = (1.0 / cycle) if cycle > 0 else 0.0 + regen = CHARGE_REFILL_RATE * (1.0 + player.bonus_charge_regen_pct) * player.charge_regen_mult + lines = [ + f"Health: {player.health} / {player.max_health}", + f"Charge: {player.charge:.1f} / {player.max_charge:.1f}", + f"Light Move Speed: {light_speed:.1f}", + f"Shade Move Speed: {shade_speed:.1f}", + f"Heal Duration: {heal_dur:.2f}s", + f"Attack Rate: {atk_rate:.1f}/s", + f"Charge Regen: {regen:.1f}/s", + ] + bonus = [] + if player.charge_regen_mult > 1.0: + bonus.append(f"Lantern Buff: {player.charge_regen_mult:.0f}x Charge Regen") + if player.bonus_attack_speed_pct: + bonus.append(f"Attack Speed: +{player.bonus_attack_speed_pct:.0%}") + if player.bonus_shade_double_damage_chance: + bonus.append(f"Shade Double Dmg: {player.bonus_shade_double_damage_chance:.0%}") + if player.bonus_negate_damage_chance: + bonus.append(f"Damage Negate: {player.bonus_negate_damage_chance:.0%}") + if player.bonus_shade_damage_flat: + bonus.append(f"Shade Dmg Bonus: +{player.bonus_shade_damage_flat:.0f}") + if player.bonus_jump_height_shade_pct: + bonus.append(f"Shade Jump: +{player.bonus_jump_height_shade_pct:.0%}") + if player.bonus_heal_plus: + bonus.append(f"Heal Plus: +{player.bonus_heal_plus}") + if player.bonus_max_health or player.bonus_max_charge: + bonus.append(f"Bonus HP/Charge: +{player.bonus_max_health}/+{player.bonus_max_charge:.0f}") + lines += bonus + panel_w = 280 + panel_h = 16 + len(lines) * 18 + 10 + x = WIDTH - panel_w - 12 + y = 8 + panel = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA) + panel.fill((15, 15, 20, 210)) + surface.blit(panel, (x, y)) + pygame.draw.rect(surface, (120, 120, 140), (x, y, panel_w, panel_h), 2) + ty = y + 8 + for line in lines: + surface.blit(font.render(line, True, (230, 230, 230)), (x + 10, ty)) + ty += 18 + + # ---------------------------- # Main loop # ---------------------------- running = True paused = False +stats_overlay_visible = False pause_menu = "main" # main | settings pause_menu_index = 0 settings_index = 0 @@ -5245,6 +5915,7 @@ while running: if e.type == pygame.KEYDOWN: was_trade_visible = trade_ui.visible was_inv_visible = inv_ui.visible + was_diary_visible = diary_ui.visible if paused and pause_menu == "settings": if settings_wait_action: @@ -5279,16 +5950,20 @@ while running: paused = False continue - if key_is("ui_cancel", e.key) and (was_inv_visible or was_trade_visible): + if key_is("ui_cancel", e.key) and (was_inv_visible or was_trade_visible or was_diary_visible): inv_ui.visible = False trade_ui.visible = False trade_ui.active_npc = None + diary_ui.visible = False continue - if key_is("inventory", e.key) and not trade_ui.visible: + if key_is("inventory", e.key) and not trade_ui.visible and not diary_ui.visible: inv_ui.toggle() + if key_is("stats_overlay", e.key): + stats_overlay_visible = not stats_overlay_visible inv_ui.handle_keydown(e.key) trade_ui.handle_keydown(e.key) + diary_ui.handle_keydown(e.key) # Debug gear: only in room_01 if ROOM.id == "room_01.json": @@ -5303,16 +5978,23 @@ while running: if inst: pickup_feed.push_center(f"Debug: +5 {inst.get('name', 'Item')} (tempered)", duration=3.0) - if key_is("interact", e.key) and not inv_ui.visible and not was_trade_visible: - near_npc = get_near_npc(player, npcs) - if near_npc and not player.shade: - trade_ui.toggle(near_npc) + if key_is("interact", e.key) and not inv_ui.visible and not was_trade_visible and not diary_ui.visible: + if get_near_diary(player, diary_obj): + diary_ui.toggle() + else: + near_npc = get_near_npc(player, npcs) + if near_npc and not player.shade: + trade_ui.toggle(near_npc) + else: + near_qg = get_near_quest_giver(player, quest_givers) + if near_qg: + interact_quest_giver(near_qg) if key_is("pause", e.key): if paused: paused = False else: - if not inv_ui.visible and not trade_ui.visible: + if not inv_ui.visible and not trade_ui.visible and not diary_ui.visible: paused = True pause_menu = "main" pause_menu_index = 0 @@ -5392,6 +6074,8 @@ while running: drop.draw(screen, camera_x, camera_y) for c in coin_drops: c.draw(screen, camera_x, camera_y) + for t in token_drops: + t.draw(screen, camera_x, camera_y) for sw in shockwaves: sw.draw(screen, camera_x, camera_y) for pr in projectiles: @@ -5426,7 +6110,8 @@ while running: draw_reality_break_icon(screen, player) pygame.draw.rect(screen, HEALTH_BG, (WIDTH - 220, 20, 200, 20)) hp_ratio = (player.health / player.max_health) if player.max_health > 0 else 0 - pygame.draw.rect(screen, HEALTH_FILL, (WIDTH - 220, 20, int(200 * hp_ratio), 20)) + hp_color = (60, 200, 80) if player.poison_timer > 0 else HEALTH_FILL + pygame.draw.rect(screen, hp_color, (WIDTH - 220, 20, int(200 * hp_ratio), 20)) pygame.draw.rect(screen, CHARGE_BG, (WIDTH - 220, 45, 200, 20)) ch_ratio = (player.charge / player.max_charge) if player.max_charge > 0 else 0 @@ -5488,6 +6173,13 @@ while running: break collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] 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). + if player.shade: + player.charge_drain_mult = 2.0 if any(player.rect.colliderect(en.rect) for en in enemies) else 1.0 + else: + player.charge_drain_mult = 1.0 + player.update(keys, player_collision_platforms, dt) if player.is_shatterstep_active() and (not player.shade) and (not player.on_ground): @@ -5526,6 +6218,9 @@ while running: trade_ui.visible = False trade_ui.active_npc = None + if diary_ui.visible and not get_near_diary(player, diary_obj): + diary_ui.visible = False + room_manager.update(dt) if try_room_transition(keys): camera_x = float(player.rect.centerx - WIDTH // 2) @@ -5534,20 +6229,32 @@ while running: # Respawn on death/void if player.rect.top > KILL_Y or player.health <= 0: - player.respawn(int(ROOM.player_spawn[0]), int(ROOM.player_spawn[1])) - enemies = spawn_enemies_from_room(ROOM) - npcs = spawn_npcs_from_room(ROOM) - loot_drops = [] - shockwaves = [] - coin_drops = [] - projectiles = [] - shatter_platforms = [] - shatter_aoes = [] - dust_puffs = [] - tether_shots = [] - player.shatterstep_landing_pending = False - player.shatterstep_landing_damage = 0.0 - ability_doors = spawn_ability_doors(ROOM) + if ACTIVE_REFIGHT is not None: + # Died in a diary refight -> return to the diary in town. + ACTIVE_REFIGHT = None + room = room_manager.load_room("room_town_gloomreach.json") + diary_spawn = room.entry_spawns.get("entry_diary") or room.player_spawn + room.player_spawn = [int(diary_spawn[0]), int(diary_spawn[1])] + player.respawn(int(diary_spawn[0]), int(diary_spawn[1])) + apply_room(room, set_player_spawn=True) + player.shatterstep_landing_pending = False + player.shatterstep_landing_damage = 0.0 + else: + player.respawn(int(ROOM.player_spawn[0]), int(ROOM.player_spawn[1])) + enemies = spawn_enemies_from_room(ROOM) + npcs = spawn_npcs_from_room(ROOM) + loot_drops = [] + shockwaves = [] + coin_drops = [] + token_drops = [] + projectiles = [] + shatter_platforms = [] + shatter_aoes = [] + dust_puffs = [] + tether_shots = [] + player.shatterstep_landing_pending = False + player.shatterstep_landing_damage = 0.0 + ability_doors = spawn_ability_doors(ROOM) # Update enemies for en in enemies: @@ -5631,6 +6338,7 @@ while running: did_hit = player.take_damage(sw.damage) if did_hit: player.apply_knockback(sw.rect, strength_px_per_frame=14.0, duration=0.22, pop_up_vel=-4.5) + player.apply_poison(BOSS_POISON_DURATION) sw.t = 0 # one-hit @@ -5642,6 +6350,9 @@ while running: for c in coin_drops: c.update(dt, collision_platforms) + for t in token_drops: + t.update(dt, collision_platforms) + if reward_ability: reward_ability.update(dt) @@ -5653,8 +6364,18 @@ while running: alive.append(en) else: loot_drops.extend(spawn_drops_from_enemy(en)) - if getattr(en, "type_id", "") == "boss": + etype = getattr(en, "type_id", "") + if etype == "boss": mark_boss_defeated(ROOM.id) + bid = boss_id_for_room(ROOM.id) + if bid and ACTIVE_REFIGHT is None: + token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid)) + + # Quest kill tracking + for qid in quest_manager.progress_kill(etype): + q = quest_manager.get(qid) + if q: + pickup_feed.push_plain(f"Quest fulfilled: {q.name} - return to the quest object!") # --- coin drops --- base_x = en.rect.centerx - 8 @@ -5711,6 +6432,12 @@ while running: else: player.coins_dark += 1 coin_drops.remove(c) + for t in token_drops[:]: + if player.rect.colliderect(t.rect): + token = make_boss_token(t.boss_id) + inventory.add(token) + pickup_feed.push_plain(f"Collected {token['name']}!") + token_drops.remove(t) @@ -5771,12 +6498,24 @@ while running: for c in coin_drops: c.draw(screen, camera_x, camera_y) + # boss tokens + for t in token_drops: + t.draw(screen, camera_x, camera_y) + # reward room props if reward_chest: reward_chest.draw(screen, camera_x, camera_y) if reward_ability: reward_ability.draw(screen, camera_x, camera_y) + # quest giver objects + for qg in quest_givers: + qg.draw(screen, camera_x, camera_y) + + # diary object (boss token diary) + if diary_obj: + diary_obj.draw(screen, camera_x, camera_y) + # shockwaves for sw in shockwaves: @@ -5818,6 +6557,35 @@ while running: hint_text ) + # Quest object hint bubble (shown near the player) + near_qg = get_near_quest_giver(player, quest_givers) + if near_qg: + q = quest_manager.get(near_qg.quest_id) + label = "Press F" + if q is not None: + if quest_manager.is_complete(near_qg.quest_id): + label = "Press F - claim reward" + elif near_qg.quest_id in quest_manager.active: + st = quest_manager.active[near_qg.quest_id] + label = f"Press F - {q.name} ({int(st.get('progress', 0))}/{q.target_count()})" + else: + label = f"Press F - {q.name}" + draw_speech_bubble( + screen, + label, + int(near_qg.rect.centerx - camera_x), + int(near_qg.rect.top - camera_y), + ) + + # Diary hint bubble (shown near the player) + if get_near_diary(player, diary_obj) and not diary_ui.visible: + draw_speech_bubble( + screen, + "Press F - Boss Diary", + int(diary_obj.rect.centerx - camera_x), + int(diary_obj.rect.top - camera_y), + ) + # Boss health bar (if boss exists) boss = next((en for en in enemies if getattr(en, "type_id", "") == "boss"), None) if boss is not None: @@ -5850,7 +6618,8 @@ while running: # Player health pygame.draw.rect(screen, HEALTH_BG, (WIDTH - 220, 20, 200, 20)) hp_ratio = (player.health / player.max_health) if player.max_health > 0 else 0 - pygame.draw.rect(screen, HEALTH_FILL, (WIDTH - 220, 20, int(200 * hp_ratio), 20)) + hp_color = (60, 200, 80) if player.poison_timer > 0 else HEALTH_FILL + pygame.draw.rect(screen, hp_color, (WIDTH - 220, 20, int(200 * hp_ratio), 20)) # Player charge pygame.draw.rect(screen, CHARGE_BG, (WIDTH - 220, 45, 200, 20)) @@ -5884,8 +6653,10 @@ while running: inv_ui.draw(screen) trade_ui.draw(screen) + diary_ui.draw(screen) pickup_feed.draw(screen) town_title.draw(screen) + draw_stats_overlay(screen) pygame.display.flip()