import os import json import random from pathlib import Path from datetime import datetime from copy import deepcopy import pygame import math # --- Boss Shockwave Settings --- BOSS_SLAM_COOLDOWN = 3.0 # seconds between slams BOSS_TRIGGER_CLOSE = 140 # px: slam if player is close BOSS_TRIGGER_FAR = 520 # px: slam if player is far (anti-cheese) BOSS_JUMP_VEL = -16.0 # slam jump strength 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) RANGED_DAMAGE_MULT = 0.5 # 50% of melee damage RANGED_COOLDOWN = 0.45 # seconds between shots (feel-tweak) PROJECTILE_SPEED = 650.0 # px/sec PROJECTILE_LIFETIME = 1.2 # seconds PROJECTILE_SIZE = (14, 6) # width, height # --- Loot physics --- LOOT_GRAVITY = 900.0 # px/sec^2 LOOT_TERMINAL_VEL = 900.0 # px/sec LOOT_BOUNCE = 0.0 # keep 0.0 for no bounce (clean) LOOT_FRICTION = 0.85 # x damp when on ground (if you later add x motion) LOOT_BOB_AMP = 3 # pixels LOOT_BOB_SPEED = 2.2 # radians/sec-ish feel # --- Reward room setup --- REWARD_ROOM_ID = "room_reward_01.json" REWARD_CHEST_POS = (520, 330) REWARD_ABILITY_POS = (820, 210) REWARD_CHEST_COINS = (30, 30) # light, dark # --- NPC settings --- NPC_TALK_RANGE = 110 # px NPC_TRADE_COST = 20 # --- Player idle animation --- PLAYER_IDLE_FRAME_TIME = 0.8 PLAYER_IDLE_START_DELAY = 0.15 PLAYER_IDLE_BOB_AMP = 2 PLAYER_IDLE_BOB_SPEED = 2.0 # --- Dash ability --- DASH_COOLDOWN = 2.0 DASH_DURATION = 0.18 DASH_SPEED = 16.0 DASH_LIGHT_CHARGE_COST = 10.0 DASH_SHADE_HEALTH_COST = 1 DASH_LIGHT_INVULN = 0.35 DASH_STUN_NORMAL = 2.0 DASH_STUN_MINIBOSS = 1.5 DASH_STUN_BOSS = 1.0 # --- Shatterstep ability --- SHATTERSTEP_DURATION = 5.0 SHATTERSTEP_COOLDOWN = 6.0 SHATTER_PLATFORM_LIFETIME = 3.0 SHATTER_PLATFORM_COOLDOWN = 0.5 SHATTER_PLATFORM_SIZE = (80, 10) SHATTER_AOE_SIZE = (140, 30) SHATTER_AOE_LIFETIME = 0.25 SHATTER_ICON_SIZE = 32 SHATTER_ICON_POS = (20, 20) # --- Tether ability --- TETHER_BIND_DURATION = 3.0 TETHER_COOLDOWN = 6.0 TETHER_SPEED = 900.0 TETHER_LIFETIME = 0.55 TETHER_SIZE = (20, 6) TETHER_FEAR_SPEED_MULT = 1.5 TETHER_ICON_SIZE = 32 TETHER_ICON_POS = (20, 60) TETHER_SPRITE_DRAW_SIZE = (32, 16) # --- Reality Break ability --- REALITY_BREAK_ICON_SIZE = 32 REALITY_BREAK_ICON_POS = (20, 100) REALITY_BREAK_FLASH_DURATION = 0.6 # ---------------------------- # Key bindings (runtime only) # ---------------------------- BINDINGS_DEFAULT = { "move_left": pygame.K_a, "move_right": pygame.K_d, "move_down": pygame.K_s, "jump": pygame.K_w, "heal": pygame.K_SPACE, "attack": pygame.K_e, "ranged": pygame.K_q, "dash": pygame.K_TAB, "shatterstep": pygame.K_r, "tether": pygame.K_x, "reality_break": pygame.K_c, "use_butterfly_jar": pygame.K_g, "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, "ui_left": pygame.K_LEFT, "ui_right": pygame.K_RIGHT, "ui_confirm": pygame.K_RETURN, "ui_confirm_alt": pygame.K_KP_ENTER, "ui_cancel": pygame.K_ESCAPE, "debug_1": pygame.K_F5, "debug_2": pygame.K_F6, "debug_3": pygame.K_F7, } BINDINGS = dict(BINDINGS_DEFAULT) def key_name(key_code: int) -> str: if key_code is None: return "-" try: return pygame.key.name(key_code) except Exception: return str(key_code) def key_for(action: str) -> int | None: return BINDINGS.get(action) def key_is(action: str, key: int) -> bool: return key_for(action) == key def key_pressed(keys, action: str) -> bool: k = key_for(action) return bool(k is not None and keys[k]) def scale_count(base: int, mult: float) -> int: if mult <= 0: return 0 scaled = float(base) * float(mult) whole = int(scaled) frac = scaled - whole if random.random() < frac: whole += 1 return max(0, whole) SETTINGS_ACTIONS = [ ("Move Left", "move_left"), ("Move Right", "move_right"), ("Move Down", "move_down"), ("Jump", "jump"), ("Heal", "heal"), ("Attack", "attack"), ("Ranged", "ranged"), ("Dash", "dash"), ("Shatterstep", "shatterstep"), ("Tether", "tether"), ("Reality Break", "reality_break"), ("Use Butterfly Jar", "use_butterfly_jar"), ("Toggle Shade", "toggle_shade"), ("Inventory", "inventory"), ("Interact/Trade", "interact"), ("Stats Overlay", "stats_overlay"), ("Pause", "pause"), ("Debug 1", "debug_1"), ("Debug 2", "debug_2"), ("Debug 3", "debug_3"), ] # ---------------------------- # Paths + JSON loading helpers # ---------------------------- BASE_DIR = Path(__file__).resolve().parent DATA_ROOT = BASE_DIR / "data" CONFIG_DIR = DATA_ROOT / "config" DROPS_DIR = DATA_ROOT / "drops" ITEMS_DIR = DATA_ROOT / "items" ENEMIES_DIR = DATA_ROOT / "enemies" LEVELS_DIR = DATA_ROOT / "levels" # legacy (still supported) ROOMS_DIR = DATA_ROOT / "world" / "rooms" UI_DIR = DATA_ROOT / "ui" UI_ICONS_SLOTS_DIR = UI_DIR / "icons" / "slots" OBJECTS_DIR = DATA_ROOT / "sprites" / "objects" def load_json(path: Path) -> dict: with path.open("r", encoding="utf-8") as f: return json.load(f) def find_file_anywhere(filename: str, roots: list[Path]) -> Path | None: """Try typical locations first, then fallback to rglob search inside the given roots.""" for r in roots: p = r / filename if p.exists(): return p for r in roots: if r.exists(): hits = list(r.rglob(filename)) if hits: return hits[0] return None def load_json_safe(filename: str, roots: list[Path], default: dict) -> dict: p = find_file_anywhere(filename, roots) if not p: print(f"[JSON missing] {filename} (using defaults)") return deepcopy(default) try: return load_json(p) except Exception as e: 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)) # ---------------------------- # Collected butterfly jars (persisted in saves) # ---------------------------- COLLECTED_JARS: set[str] = set() BUTTERFLY_JARS_MAX = 3 def jar_key(room_id: str, x: int, y: int) -> str: return f"{room_id}:{int(x)},{int(y)}" # ---------------------------- # Lantern save-point respawn (persisted in saves) # ---------------------------- LANTERN_RESPAWN_ROOM: str | None = None LANTERN_RESPAWN_POS: tuple[int, int] | None = None # ---------------------------- # Death penalty state (session-only, not persisted) # ---------------------------- corpse = None # Corpse | None JAR_DEATH_ROOM: str | None = None # ---------------------------- # Game config (numbers & colors) # ---------------------------- GAME_CONFIG_DEFAULT = { "window": {"width": 800, "height": 450, "caption": "Light_and_Dark Prototype", "fps": 60}, "colors": {}, "loot_colors": {}, "physics": {}, "resources": {}, "healing": {}, "boss_shockwave": {}, "assets": {"player_sprite": "Player_01.png", "player_sprite_shade": "Player_02.png"}, "debug": {"drop_debug_print": True} } GAME_CONFIG = load_json_safe("game_config.json", [CONFIG_DIR, DATA_ROOT], GAME_CONFIG_DEFAULT) # --- Colors config (separate file to declutter code) --- COLOR_CFG = load_json_safe("colors.json", [CONFIG_DIR, DATA_ROOT], {}) # --- Physics config (separate file to declutter code) --- PHYS_CFG = load_json_safe("physics.json", [CONFIG_DIR, DATA_ROOT], {}) # --- UI layout config (separate file to declutter UI numbers) --- UI_CFG = load_json_safe("ui_layout.json", [CONFIG_DIR, DATA_ROOT], {}) # --- Additional tuning configs --- RANGED_CFG = load_json_safe("ranged.json", [CONFIG_DIR, DATA_ROOT], {}) REWARD_CFG = load_json_safe("reward_room.json", [CONFIG_DIR, DATA_ROOT], {}) NPC_CFG = load_json_safe("npc.json", [CONFIG_DIR, DATA_ROOT], {}) ANIM_CFG = load_json_safe("animation.json", [CONFIG_DIR, DATA_ROOT], {}) COIN_DROPS_CFG = load_json_safe("coin_drops.json", [CONFIG_DIR, DATA_ROOT], {}) def _cfg(path, default=None): cur = GAME_CONFIG for key in path: if not isinstance(cur, dict) or key not in cur: return default cur = cur[key] return cur def _color(path, default=(255, 255, 255)): cur = COLOR_CFG for key in path: if not isinstance(cur, dict) or key not in cur: return default cur = cur[key] # Expect JSON arrays like [30, 30, 30] return tuple(int(x) for x in cur) def _phys(path, default=None): cur = PHYS_CFG for key in path: if not isinstance(cur, dict) or key not in cur: return default cur = cur[key] return cur def _ui(path, default=None): cur = UI_CFG for key in path: if not isinstance(cur, dict) or key not in cur: return default cur = cur[key] return cur def _ranged(path, default=None): cur = RANGED_CFG for key in path: if not isinstance(cur, dict) or key not in cur: return default cur = cur[key] return cur def _reward(path, default=None): cur = REWARD_CFG for key in path: if not isinstance(cur, dict) or key not in cur: return default cur = cur[key] return cur def _npc(path, default=None): cur = NPC_CFG for key in path: if not isinstance(cur, dict) or key not in cur: return default cur = cur[key] return cur def _anim(path, default=None): cur = ANIM_CFG for key in path: if not isinstance(cur, dict) or key not in cur: return default cur = cur[key] return cur def _coin_drop(path, default=None): def _get_from(cfg): cur = cfg for key in path: if not isinstance(cur, dict) or key not in cur: return None cur = cur[key] return cur room_cfg = None try: room_cfg = ROOM.coin_drops_cfg except Exception: room_cfg = None if isinstance(room_cfg, dict): room_val = _get_from(room_cfg) if room_val is not None: return room_val global_val = _get_from(COIN_DROPS_CFG) if global_val is not None: return global_val return default # --- Colors --- BG = _color(["bg"]) PLAYER_LIGHT = _color(["player", "light"]) PLAYER_DARK = _color(["player", "dark"]) PLATFORM_COLOR = _color(["platform"]) CHARGE_BG = _color(["bars", "charge_bg"]) CHARGE_FILL = _color(["bars", "charge_fill"]) HEALTH_BG = _color(["bars", "health_bg"]) HEALTH_FILL = _color(["bars", "health_fill"]) ENEMY_COLOR = _color(["enemy", "normal"]) ENEMY_HIT_COLOR = _color(["enemy", "normal_hit"]) MINIBOSS_COLOR = _color(["enemy", "miniboss"]) MINIBOSS_HIT_COLOR = _color(["enemy", "miniboss_hit"]) BOSS_COLOR = _color(["enemy", "boss"]) BOSS_HIT_COLOR = _color(["enemy", "boss_hit"]) PLAYER_HIT_COLOR = (255, 255, 0) HEAL_DARK = _color(["healing", "dark"]) HEAL_LIGHT = _color(["healing", "light"]) LOOT_COLORS = { k: tuple(v) for k, v in COLOR_CFG.get("loot", {}).items() } # --- Physics Settings --- PLAYER_SPEED = float(_phys(["player", "speed_light"], 5.0)) PLAYER_SHADE_SPEED = float(_phys(["player", "speed_shade"], 3.5)) JUMP_LIGHT = float(_phys(["player", "jump_light"], -12.0)) JUMP_SHADE = float(_phys(["player", "jump_shade"], -9.0)) GRAVITY_LIGHT = float(_phys(["player", "gravity_light"], 0.5)) GRAVITY_SHADE = float(_phys(["player", "gravity_shade"], 0.4)) # Sprite offsets (tune to align art with hitbox) PLAYER_SPRITE_OFFSET_X = int(_cfg(["player", "sprite_offset_x"], 0)) PLAYER_SPRITE_OFFSET_Y = int(_cfg(["player", "sprite_offset_y"], 0)) PLAYER_SPRITE_SCALE = float(_cfg(["player", "sprite_scale"], 1.3)) PLAYER_HITBOX_W = int(_cfg(["player", "hitbox_w"], 48)) PLAYER_HITBOX_H = int(_cfg(["player", "hitbox_h"], 72)) BASE_MAX_CHARGE = float(_phys(["resources", "base_max_charge"], 100.0)) CHARGE_DRAIN_RATE = float(_phys(["resources", "charge_drain_rate"], 40.0)) CHARGE_REFILL_RATE = float(_phys(["resources", "charge_refill_rate"], 10.0)) BASE_MAX_HEALTH = int(_phys(["resources", "base_max_health"], 10)) INVINCIBILITY_DURATION = float(_phys(["resources", "invincibility_duration"], 1.0)) # --- Healing Settings --- HEAL_DURATION = float(_phys(["healing", "heal_duration"], 2.0)) HEAL_COST_FLAT = float(_phys(["healing", "heal_cost_flat"], 50.0)) HEAL_SLOW_MULT = float(_phys(["healing", "heal_slow_mult"], 0.30)) #70% slow when healing # --- Boss shockwave (from physics.json) --- BOSS_SLAM_COOLDOWN = float(_phys(["boss_shockwave", "slam_cooldown"], BOSS_SLAM_COOLDOWN)) BOSS_TRIGGER_CLOSE = float(_phys(["boss_shockwave", "trigger_close"], BOSS_TRIGGER_CLOSE)) BOSS_TRIGGER_FAR = float(_phys(["boss_shockwave", "trigger_far"], BOSS_TRIGGER_FAR)) BOSS_JUMP_VEL = float(_phys(["boss_shockwave", "jump_vel"], BOSS_JUMP_VEL)) SHOCKWAVE_SPEED = float(_phys(["boss_shockwave", "shockwave_speed"], SHOCKWAVE_SPEED)) SHOCKWAVE_LIFETIME = float(_phys(["boss_shockwave", "shockwave_lifetime"], SHOCKWAVE_LIFETIME)) SHOCKWAVE_HEIGHT = int(_phys(["boss_shockwave", "shockwave_height"], SHOCKWAVE_HEIGHT)) SHOCKWAVE_DAMAGE = int(_phys(["boss_shockwave", "shockwave_damage"], SHOCKWAVE_DAMAGE)) # --- Loot physics (from physics.json) --- LOOT_GRAVITY = float(_phys(["loot_physics", "gravity"], LOOT_GRAVITY)) LOOT_TERMINAL_VEL = float(_phys(["loot_physics", "terminal_vel"], LOOT_TERMINAL_VEL)) LOOT_BOUNCE = float(_phys(["loot_physics", "bounce"], LOOT_BOUNCE)) LOOT_FRICTION = float(_phys(["loot_physics", "friction"], LOOT_FRICTION)) LOOT_BOB_AMP = float(_phys(["loot_physics", "bob_amp"], LOOT_BOB_AMP)) LOOT_BOB_SPEED = float(_phys(["loot_physics", "bob_speed"], LOOT_BOB_SPEED)) # --- Ranged / projectile settings (from ranged.json) --- RANGED_CHARGE_COST = float(_ranged(["charge_cost"], RANGED_CHARGE_COST)) RANGED_DAMAGE_MULT = float(_ranged(["damage_mult"], RANGED_DAMAGE_MULT)) RANGED_COOLDOWN = float(_ranged(["cooldown"], RANGED_COOLDOWN)) PROJECTILE_SPEED = float(_ranged(["projectile", "speed"], PROJECTILE_SPEED)) PROJECTILE_LIFETIME = float(_ranged(["projectile", "lifetime"], PROJECTILE_LIFETIME)) PROJECTILE_SIZE = tuple(_ranged(["projectile", "size"], list(PROJECTILE_SIZE))) # --- Reward room settings (from reward_room.json) --- REWARD_ROOM_ID = str(_reward(["room_id"], REWARD_ROOM_ID)) REWARD_CHEST_POS = tuple(_reward(["chest_pos"], list(REWARD_CHEST_POS))) REWARD_ABILITY_POS = tuple(_reward(["ability_pos"], list(REWARD_ABILITY_POS))) REWARD_CHEST_COINS = tuple(_reward(["chest_coins"], list(REWARD_CHEST_COINS))) # --- NPC settings (from npc.json) --- NPC_TALK_RANGE = int(_npc(["talk_range"], NPC_TALK_RANGE)) NPC_TRADE_COST = int(_npc(["trade_cost"], NPC_TRADE_COST)) # --- Player idle animation (from animation.json) --- PLAYER_IDLE_FRAME_TIME = float(_anim(["player_idle", "frame_time"], PLAYER_IDLE_FRAME_TIME)) PLAYER_IDLE_START_DELAY = float(_anim(["player_idle", "start_delay"], PLAYER_IDLE_START_DELAY)) PLAYER_IDLE_BOB_AMP = int(_anim(["player_idle", "bob_amp"], PLAYER_IDLE_BOB_AMP)) PLAYER_IDLE_BOB_SPEED = float(_anim(["player_idle", "bob_speed"], PLAYER_IDLE_BOB_SPEED)) # --- Coins --- COIN_UI_SIZE = int(_ui(["coins_hud", "icon_size"], 14)) COIN_UI_GAP = int(_ui(["coins_hud", "icon_gap"], 6)) # --- Butterfly jar HUD --- BUTTERFLY_JAR_UI_SIZE = int(_ui(["butterfly_jars", "icon_size"], 16)) BUTTERFLY_JAR_UI_GAP = int(_ui(["butterfly_jars", "icon_gap"], 4)) pygame.init() # --- Window setup --- USE_DISPLAY_SIZE = bool(_cfg(["window", "use_display_size"], True)) WIDTH = int(_cfg(["window", "width"], 800)) HEIGHT = int(_cfg(["window", "height"], 450)) FPS = int(_cfg(["window", "fps"], 60)) if USE_DISPLAY_SIZE: display_info = pygame.display.Info() if display_info.current_w > 0 and display_info.current_h > 0: WIDTH, HEIGHT = int(display_info.current_w), int(display_info.current_h) screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(str(_cfg(["window", "caption"], "Shade Prototype"))) clock = pygame.time.Clock() font = pygame.font.SysFont(None, 22) def build_vignette_surface( w: int, h: int, color: tuple[int, int, int], edge_alpha: int = 230, steps: int = 96, radius_scale: float = 0.425, base_alpha: int = 0, min_alpha: float = 0.0, ) -> pygame.Surface: # Darken edges more, keep center lighter with a smooth blend. surf = pygame.Surface((w, h), pygame.SRCALPHA) if base_alpha > 0: surf.fill((*color, base_alpha)) cx, cy = w // 2, h // 2 max_r = int((w * w + h * h) ** 0.5 / 2) for i in range(steps): t = i / max(1, steps - 1) radius = int(max_r * (1.0 - t * radius_scale)) falloff = (1.0 - t) ** 2 alpha = int(edge_alpha * (min_alpha + (1.0 - min_alpha) * falloff)) if radius <= 0 or alpha <= 0: continue pygame.draw.circle(surf, (*color, alpha), (cx, cy), radius) return surf VIGNETTE_LIGHT = build_vignette_surface(WIDTH, HEIGHT, (0, 0, 0), edge_alpha=255, radius_scale=1.0, base_alpha=180, min_alpha=0.30) VIGNETTE_LIGHT_EDGE = build_vignette_surface(WIDTH, HEIGHT, (0, 0, 0), edge_alpha=255, radius_scale=1.0, base_alpha=0, min_alpha=0.0) VIGNETTE_SHADE = build_vignette_surface(WIDTH, HEIGHT, (90, 30, 130), edge_alpha=120, radius_scale=1.0, base_alpha=85, min_alpha=0.05) VIGNETTE_SHADE_EDGE = build_vignette_surface(WIDTH, HEIGHT, (90, 30, 130), edge_alpha=120, radius_scale=1.0, base_alpha=0, min_alpha=0.0) VIGNETTE_CLEAR_RADIUS = 150 _VIGNETTE_HOLE_CACHE: dict[tuple[int, int], pygame.Surface] = {} def _get_vignette_hole(radius: int, feather: int = 40) -> pygame.Surface: r = max(1, int(radius)) f = max(1, int(feather)) key = (r, f) if key in _VIGNETTE_HOLE_CACHE: return _VIGNETTE_HOLE_CACHE[key] surf = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) if feather <= 0: pygame.draw.circle(surf, (0, 0, 0, 255), (r, r), r) else: steps = max(8, f) for i in range(steps): t = i / max(1, steps - 1) rad = int(r * (1.0 - t)) alpha = int(255 * (1.0 - t) ** 2) if rad <= 0 or alpha <= 0: continue pygame.draw.circle(surf, (0, 0, 0, alpha), (r, r), rad) _VIGNETTE_HOLE_CACHE[key] = surf return surf def blit_vignette_with_hole(target: pygame.Surface, vignette: pygame.Surface, center: tuple[int, int], radius: int, feather: int = 40): if vignette is None: return temp = vignette.copy() hole = _get_vignette_hole(radius, feather) hx = int(center[0] - hole.get_width() // 2) hy = int(center[1] - hole.get_height() // 2) temp.blit(hole, (hx, hy), special_flags=pygame.BLEND_RGBA_SUB) target.blit(temp, (0, 0)) # ---------------------------- # Item data (armor + rarities) # ---------------------------- RARITIES_DEFAULT = { "tempered": {"tier": 1, "allows_abilities": False}, "attuned": {"tier": 2, "allows_abilities": False}, "awakened": {"tier": 3, "allows_abilities": True}, } RARITIES = load_json_safe("rarities.json", [ITEMS_DIR, DATA_ROOT], RARITIES_DEFAULT) armor_json = load_json_safe("armor.json", [ITEMS_DIR, DATA_ROOT], {"items": []}) ARMOR_DB = armor_json.get("items", []) if isinstance(armor_json, dict) else [] ARMOR_BY_ID = {it["id"]: it for it in ARMOR_DB if isinstance(it, dict) and "id" in it} necklace_json = load_json_safe("necklaces.json", [ITEMS_DIR, DATA_ROOT], {"items": []}) NECKLACE_DB = necklace_json.get("items", []) if isinstance(necklace_json, dict) else [] NECKLACE_BY_ID = {it["id"]: it for it in NECKLACE_DB if isinstance(it, dict) and "id" in it} ITEM_BY_ID = {} ITEM_BY_ID.update(ARMOR_BY_ID) ITEM_BY_ID.update(NECKLACE_BY_ID) def load_slot_icons(): icons = {} def load_icon(name: str): path = UI_ICONS_SLOTS_DIR / f"{name}.png" if not path.exists(): return None try: img = pygame.image.load(str(path)).convert_alpha() return pygame.transform.smoothscale(img, (16, 16)) except Exception as e: print(f"[UI] Failed to load icon {path}: {e}") return None icons["helmet"] = load_icon("helmet") icons["chest"] = load_icon("chestplate") icons["pants"] = load_icon("pants") icons["necklace"] = load_icon("necklace") icons["unknown"] = load_icon("unknown") return icons def tint_icon(surface: pygame.Surface, color: tuple[int, int, int]) -> pygame.Surface: """ Tint a white/gray icon to `color` while keeping alpha. Works best if the icon is white with transparent background. """ if surface is None: return None tinted = surface.copy().convert_alpha() # Multiply RGB by the tint color; alpha stays. tinted.fill(color + (255,), special_flags=pygame.BLEND_RGBA_MULT) return tinted # ---------------------------- # World-drop icons (tinted by rarity) # ---------------------------- DROP_SLOT_ICONS = load_slot_icons() DROP_ICON_CACHE: dict[tuple[str, str], pygame.Surface | None] = {} def get_drop_icon(slot: str, rarity: str) -> pygame.Surface | None: """ Returns a 16x16 icon for this slot, tinted by rarity (cached). """ slot = slot or "unknown" rarity = rarity or "" key = (slot, rarity) if key in DROP_ICON_CACHE: return DROP_ICON_CACHE[key] base = DROP_SLOT_ICONS.get(slot) or DROP_SLOT_ICONS.get("unknown") if base is None: DROP_ICON_CACHE[key] = None return None col = LOOT_COLORS.get(rarity) icon = tint_icon(base, col) if col else base DROP_ICON_CACHE[key] = icon return icon # ---------------------------- # Set bonuses # ---------------------------- SET_BONUSES_DEFAULT = {} SET_BONUSES = load_json_safe("sets.json", [ITEMS_DIR, DATA_ROOT], SET_BONUSES_DEFAULT) def build_item_instance(base_item: dict, rarity_id: str) -> dict: if rarity_id not in RARITIES: raise ValueError(f"Unknown rarity: {rarity_id}") inst = deepcopy(base_item) inst["rarity"] = rarity_id inst["tier"] = int(RARITIES[rarity_id].get("tier", 1)) stats = deepcopy(inst.get("base_stats", {})) overrides = inst.get("rarity_overrides", {}).get(rarity_id, {}) extra = overrides.get("extra_stats", {}) for k, v in extra.items(): merge_stat_value(stats, k, v) inst["stats"] = stats if RARITIES[rarity_id].get("allows_abilities", False): inst["abilities"] = overrides.get("abilities", []) else: inst["abilities"] = [] return inst def build_item_instance_any(base_item: dict, rarity_id: str | None) -> dict: # Necklaces have no rarity for now if base_item.get("slot") == "necklace" or base_item.get("item_type") == "necklace": inst = deepcopy(base_item) inst["rarity"] = "" inst["tier"] = 0 inst["abilities"] = [] stats = deepcopy(inst.get("base_stats", {})) inst["stats"] = stats return inst if not rarity_id: rarity_id = "tempered" return build_item_instance(base_item, rarity_id) 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): dst[key] = bool(dst.get(key, False)) or value elif isinstance(value, (int, float)): dst[key] = float(dst.get(key, 0.0)) + float(value) else: dst[key] = value def tint_surface_keep_alpha(src: pygame.Surface, rgb: tuple[int, int, int]) -> pygame.Surface: """Multiply-tint a sprite while preserving alpha (works great for white sprites).""" s = src.copy() s.fill((*rgb, 255), special_flags=pygame.BLEND_RGBA_MULT) return s # ---------------------------- # Enemy config (types + drops) # ---------------------------- ENEMY_TYPES_DEFAULT = { "enemy": { "rect": [40, 60], "health": 3, "speed": 2.2, "air_speed": 2.0, "detection_range": 240, "contact_damage": 1, "drop_profile": "normal", }, "miniboss": { "rect": [70, 90], "health": 12, "speed": 1.9, "air_speed": 1.7, "detection_range": 300, "contact_damage": 2, "drop_profile": "miniboss", }, "boss": { "rect": [110, 120], "health": 30, "speed": 1.6, "air_speed": 1.4, "detection_range": 520, "contact_damage": 3, "drop_profile": "boss", }, } ENEMY_DROPS_DEFAULT = { "normal": { "rolls": 1, "rarity_table": {"tempered": 0.10, "none": 0.90} }, "miniboss": { "rolls": 2, "rarity_table": {"tempered": 0.75, "attuned": 0.25, "none": 0.0} }, "boss": { "guaranteed": [ {"rarity": "awakened", "count": 1}, {"rarity": "attuned", "count": 1} ], "rolls": 1, "rarity_table": {"attuned": 0.50, "none": 0.50} } } ENEMY_TYPES = load_json_safe("enemy_types.json", [ENEMIES_DIR, DATA_ROOT], ENEMY_TYPES_DEFAULT) ENEMY_DROPS = load_json_safe("enemy_drops.json", [DROPS_DIR, DATA_ROOT], ENEMY_DROPS_DEFAULT) # ---------------------------- # Rooms (platforms + spawns + exits) # ---------------------------- ROOM_DEFAULT = { "meta": {"id": "room_01", "name": "Default Room"}, "player_spawn": [200, 100], "bounds": {"kill_y": 1400}, "platforms": { "ground": [ [-400, 350, 900, 200], [650, 350, 600, 200], ], "floating": [ [150, 300, 120, 20], [320, 260, 120, 20], ], }, "enemies": [ {"type": "enemy", "pos": [700, 0]}, ], "npcs": [], "exits": [], } def rects_from_list(list_of_rects, layer_map: dict[int, int] | None = None) -> list[pygame.Rect]: out = [] if not isinstance(list_of_rects, list): return out for r in list_of_rects: if isinstance(r, (list, tuple)) and len(r) >= 4: rect = pygame.Rect(int(r[0]), int(r[1]), int(r[2]), int(r[3])) out.append(rect) if layer_map is not None and len(r) >= 5: try: layer_map[id(rect)] = int(r[4]) except (TypeError, ValueError): pass elif isinstance(r, dict): rect = pygame.Rect(int(r.get("x", 0)), int(r.get("y", 0)), int(r.get("w", 0)), int(r.get("h", 0))) out.append(rect) if layer_map is not None: try: layer_map[id(rect)] = int(r.get("layer", 0)) except (TypeError, ValueError): pass return out def normalize_room_id(to_value) -> str: """Accepts 'room_02', 'room_02.json', or a path-like string; returns filename-ish id.""" if not to_value: return "room_01.json" s = str(to_value) s = os.path.basename(s) if not s.lower().endswith(".json"): s = s + ".json" return s def normalize_room_json(room_json: dict) -> dict: """Normalize editor/game room formats into the expected game format.""" if not isinstance(room_json, dict): return {} out = deepcopy(room_json) # platforms -> grouped dict platforms = out.get("platforms", {}) if isinstance(platforms, list): grouped = {"ground": [], "floating": []} for p in platforms: if isinstance(p, (list, tuple)) and len(p) == 4: grouped["ground"].append([p[0], p[1], p[2], p[3]]) elif isinstance(p, dict): rect = [p.get("x", 0), p.get("y", 0), p.get("w", 0), p.get("h", 0)] group = p.get("group", "ground") grouped.setdefault(group, []) grouped[group].append(rect) out["platforms"] = grouped elif not isinstance(platforms, dict): out["platforms"] = {"ground": [], "floating": []} # enemies -> ensure pos:[x,y] enemies = out.get("enemies", []) if isinstance(enemies, list): norm_enemies = [] for e in enemies: if not isinstance(e, dict): continue if not (isinstance(e.get("pos"), (list, tuple)) and len(e["pos"]) == 2): if "x" in e and "y" in e: e = dict(e) e["pos"] = [e.get("x", 0), e.get("y", 0)] norm_enemies.append(e) out["enemies"] = norm_enemies # npcs -> ensure pos:[x,y] npcs = out.get("npcs", []) if isinstance(npcs, list): norm_npcs = [] for n in npcs: if not isinstance(n, dict): continue if not (isinstance(n.get("pos"), (list, tuple)) and len(n["pos"]) == 2): if "x" in n and "y" in n: n = dict(n) n["pos"] = [n.get("x", 0), n.get("y", 0)] norm_npcs.append(n) out["npcs"] = norm_npcs # exits -> ensure rect:[x,y,w,h] exits = out.get("exits", []) if isinstance(exits, list): norm_exits = [] for ex in exits: if not isinstance(ex, dict): continue if not (isinstance(ex.get("rect"), (list, tuple)) and len(ex["rect"]) == 4): if all(k in ex for k in ("x", "y", "w", "h")): ex = dict(ex) ex["rect"] = [ex.get("x", 0), ex.get("y", 0), ex.get("w", 0), ex.get("h", 0)] norm_exits.append(ex) out["exits"] = norm_exits # ability doors -> ensure rect:[x,y,w,h] ability_doors = out.get("ability_doors", []) if isinstance(ability_doors, list): norm_doors = [] for d in ability_doors: if isinstance(d, (list, tuple)) and len(d) == 4: norm_doors.append({"rect": [d[0], d[1], d[2], d[3]]}) elif isinstance(d, dict): if isinstance(d.get("rect"), (list, tuple)) and len(d["rect"]) == 4: norm_doors.append(d) elif all(k in d for k in ("x", "y", "w", "h")): norm_doors.append({"rect": [d.get("x", 0), d.get("y", 0), d.get("w", 0), d.get("h", 0)]}) out["ability_doors"] = norm_doors return out class Room: def __init__(self, room_json: dict, room_id: str): self.id = room_id self.meta = room_json.get("meta", {}) if isinstance(room_json, dict) else {} self.name = self.meta.get("name", room_id) self.player_spawn = room_json.get("player_spawn", [200, 100]) if not (isinstance(self.player_spawn, (list, tuple)) and len(self.player_spawn) == 2): self.player_spawn = [200, 100] self.kill_y = int((room_json.get("bounds", {}) or {}).get("kill_y", 1400)) self.platform_layer: dict[int, int] = {} platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {} if isinstance(platforms, list): self.platforms_ground = rects_from_list(platforms, self.platform_layer) self.platforms_floating = [] elif isinstance(platforms, dict): self.platforms_ground = rects_from_list(platforms.get("ground", []), self.platform_layer) self.platforms_floating = rects_from_list(platforms.get("floating", []), self.platform_layer) else: self.platforms_ground = [] self.platforms_floating = [] self.ground_platforms = self.platforms_ground[:] self.platforms = self.platforms_ground + self.platforms_floating self.enemies = room_json.get("enemies", []) if isinstance(room_json, dict) else [] if not isinstance(self.enemies, list): self.enemies = [] self.exits = room_json.get("exits", []) if isinstance(room_json, dict) else [] if not isinstance(self.exits, list): self.exits = [] self.npcs = room_json.get("npcs", []) if isinstance(room_json, dict) else [] if not isinstance(self.npcs, list): self.npcs = [] self.props = room_json.get("props", []) if isinstance(room_json, dict) else [] if not isinstance(self.props, list): self.props = [] self.entry_spawns = room_json.get("entry_spawns", {}) if isinstance(room_json, dict) else {} if not isinstance(self.entry_spawns, dict): self.entry_spawns = {} self.coin_drops_cfg = room_json.get("coin_drops", None) if isinstance(room_json, dict) else None self.ability_doors = room_json.get("ability_doors", []) if isinstance(room_json, dict) else [] if not isinstance(self.ability_doors, list): self.ability_doors = [] normalized_ability_doors = [] for d in self.ability_doors: rect = None if isinstance(d, dict): if isinstance(d.get("rect"), (list, tuple)) and len(d["rect"]) == 4: rect = d["rect"] elif all(k in d for k in ("x", "y", "w", "h")): rect = [d.get("x", 0), d.get("y", 0), d.get("w", 0), d.get("h", 0)] elif isinstance(d, (list, tuple)) and len(d) == 4: rect = list(d) if rect is None: continue nd = {"rect": rect} if isinstance(d, dict): try: nd["layer"] = int(d.get("layer", 0)) except (TypeError, ValueError): nd["layer"] = 0 normalized_ability_doors.append(nd) self.ability_doors = normalized_ability_doors self.exit_triggers: list[dict] = [] for ex in self.exits: if not isinstance(ex, dict): continue rr = ex.get("rect") if not (isinstance(rr, (list, tuple)) and len(rr) == 4): if all(k in ex for k in ("x", "y", "w", "h")): rr = [ex.get("x", 0), ex.get("y", 0), ex.get("w", 0), ex.get("h", 0)] else: continue self.exit_triggers.append({ "rect": pygame.Rect(int(rr[0]), int(rr[1]), int(rr[2]), int(rr[3])), "to": normalize_room_id(ex.get("to")), "spawn": ex.get("spawn", None), "spawn_id": ex.get("spawn_id", None), "hint": str(ex.get("hint", "")), "requires_boss_dead": bool(ex.get("requires_boss_dead", False)), "requires_ranged": bool(ex.get("requires_ranged", False)), "layer": int(ex.get("layer", 0)), }) class RoomManager: def __init__(self): self.current_room: Room | None = None self.current_room_id: str | None = None self.transition_cooldown = 0.0 # seconds (prevents ping-pong) def load_room(self, room_id: str, forced_spawn=None) -> Room: room_id = normalize_room_id(room_id) room_json = load_json_safe(room_id, [ROOMS_DIR, LEVELS_DIR, DATA_ROOT, BASE_DIR], ROOM_DEFAULT) room_json = normalize_room_json(room_json) room = Room(room_json, room_id) if forced_spawn is not None and isinstance(forced_spawn, (list, tuple)) and len(forced_spawn) == 2: room.player_spawn = [int(forced_spawn[0]), int(forced_spawn[1])] self.current_room = room self.current_room_id = room_id self.transition_cooldown = 2.0 return room def update(self, dt: float): if self.transition_cooldown > 0: self.transition_cooldown -= dt # ---------------------------- # Load player sprite # ---------------------------- PLAYER_SPRITE_PATH = BASE_DIR / str(_cfg(["assets", "player_sprite"], "Player_01.png")) SHADE_SPRITE_PATH = BASE_DIR / str(_cfg(["assets", "player_sprite_shade"], "Player_02.png")) try: PLAYER_SPRITE_RAW = pygame.image.load(str(PLAYER_SPRITE_PATH)).convert_alpha() except Exception as e: print(f"[Sprite load failed] {PLAYER_SPRITE_PATH} -> {e}") PLAYER_SPRITE_RAW = None try: SHADE_SPRITE_RAW = pygame.image.load(str(SHADE_SPRITE_PATH)).convert_alpha() except Exception as e: print(f"[Sprite load failed] {SHADE_SPRITE_PATH} -> {e}") SHADE_SPRITE_RAW = None IDLE_SPRITE_FILES = ["Player_01_idle1.png", "Player_01_idle2.png"] IDLE_SPRITE_RAW = [] for name in IDLE_SPRITE_FILES: p = BASE_DIR / name try: IDLE_SPRITE_RAW.append(pygame.image.load(str(p)).convert_alpha()) except Exception as e: print(f"[Sprite load failed] {p} -> {e}") # ---------------------------- # Load UI sprites (coins) # ---------------------------- COINS_DIR = DATA_ROOT / "ui" / "icons" / "coins" COIN_LIGHT_PATH = COINS_DIR / "coin_light.png" COIN_DARK_PATH = COINS_DIR / "coin_dark.png" ABILITIES_DIR = DATA_ROOT / "ui" / "icons" / "abilities" SHATTER_ICON_LIGHT_PATH = ABILITIES_DIR / "Shatterstep_light.png" SHATTER_ICON_DARK_PATH = ABILITIES_DIR / "Shatterstep_dark.png" TETHER_ICON_LIGHT_PATH = ABILITIES_DIR / "Tether_light.png" TETHER_ICON_DARK_PATH = ABILITIES_DIR / "Tether_dark.png" REALITY_ICON_LIGHT_PATH = ABILITIES_DIR / "RealityBreak_light.png" REALITY_ICON_DARK_PATH = ABILITIES_DIR / "RealityBreak_dark.png" ABILITY_SPRITES_DIR = DATA_ROOT / "sprites" / "abilities" TETHER_SPRITE_LIGHT_PATH = ABILITY_SPRITES_DIR / "Tether_light_ability.png" TETHER_SPRITE_DARK_PATH = ABILITY_SPRITES_DIR / "Tether_dark_ability.png" TETHER_EDGE_LIGHT_PATH = ABILITY_SPRITES_DIR / "Tether_light_ability_edge.png" TETHER_EDGE_DARK_PATH = ABILITY_SPRITES_DIR / "Tether_dark_ability_edge.png" def _load_image_or_none(p: Path): try: return pygame.image.load(str(p)).convert_alpha() except Exception as e: print(f"[Image load failed] {p} -> {e}") return None COIN_LIGHT_RAW = _load_image_or_none(COIN_LIGHT_PATH) COIN_DARK_RAW = _load_image_or_none(COIN_DARK_PATH) SHATTER_ICON_LIGHT_RAW = _load_image_or_none(SHATTER_ICON_LIGHT_PATH) SHATTER_ICON_DARK_RAW = _load_image_or_none(SHATTER_ICON_DARK_PATH) TETHER_ICON_LIGHT_RAW = _load_image_or_none(TETHER_ICON_LIGHT_PATH) TETHER_ICON_DARK_RAW = _load_image_or_none(TETHER_ICON_DARK_PATH) REALITY_ICON_LIGHT_RAW = _load_image_or_none(REALITY_ICON_LIGHT_PATH) REALITY_ICON_DARK_RAW = _load_image_or_none(REALITY_ICON_DARK_PATH) TETHER_SPRITE_LIGHT_RAW = _load_image_or_none(TETHER_SPRITE_LIGHT_PATH) 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 return pygame.transform.smoothscale(img, (size, size)) def _scale_image(img: pygame.Surface | None, size: tuple[int, int]) -> pygame.Surface | None: if img is None: return None return pygame.transform.smoothscale(img, (int(size[0]), int(size[1]))) SHATTER_ICON_LIGHT = _scale_icon(SHATTER_ICON_LIGHT_RAW, SHATTER_ICON_SIZE) SHATTER_ICON_DARK = _scale_icon(SHATTER_ICON_DARK_RAW, SHATTER_ICON_SIZE) TETHER_ICON_LIGHT = _scale_icon(TETHER_ICON_LIGHT_RAW, TETHER_ICON_SIZE) TETHER_ICON_DARK = _scale_icon(TETHER_ICON_DARK_RAW, TETHER_ICON_SIZE) REALITY_ICON_LIGHT = _scale_icon(REALITY_ICON_LIGHT_RAW, REALITY_BREAK_ICON_SIZE) REALITY_ICON_DARK = _scale_icon(REALITY_ICON_DARK_RAW, REALITY_BREAK_ICON_SIZE) TETHER_SPRITE_LIGHT = _scale_image(TETHER_SPRITE_LIGHT_RAW, TETHER_SPRITE_DRAW_SIZE) TETHER_SPRITE_LIGHT_SIZE = TETHER_SPRITE_LIGHT.get_size() if TETHER_SPRITE_LIGHT else TETHER_SIZE TETHER_SPRITE_DARK = _scale_image(TETHER_SPRITE_DARK_RAW, TETHER_SPRITE_DRAW_SIZE) TETHER_SPRITE_DARK_SIZE = TETHER_SPRITE_DARK.get_size() if TETHER_SPRITE_DARK else TETHER_SIZE TETHER_EDGE_LIGHT = _scale_image(TETHER_EDGE_LIGHT_RAW, TETHER_SPRITE_DRAW_SIZE) TETHER_EDGE_DARK = _scale_image(TETHER_EDGE_DARK_RAW, TETHER_SPRITE_DRAW_SIZE) LAMP_PATH = OBJECTS_DIR / "lamp_1.png" LAMP_RAW = _load_image_or_none(LAMP_PATH) BUTTERFLY_PATH = OBJECTS_DIR / "flow_butterfly1.png" BUTTERFLY_RAW = _load_image_or_none(BUTTERFLY_PATH) BUTTERFLY_JAR_PATH = OBJECTS_DIR / "flow_butterfly_jar1.png" BUTTERFLY_JAR_RAW = _load_image_or_none(BUTTERFLY_JAR_PATH) # --- Fairy companion (hovers behind the player's head) --- FAIRY_PATH = DATA_ROOT / "sprites" / "npcs" / "fairy1.png" FAIRY_RAW = _load_image_or_none(FAIRY_PATH) FAIRY_SPRITE = None if FAIRY_RAW is not None: try: fs = max(1, int(FAIRY_RAW.get_width() * PLAYER_SPRITE_SCALE * 0.55)) FAIRY_SPRITE = pygame.transform.smoothscale(FAIRY_RAW, (fs, fs)) except Exception: FAIRY_SPRITE = FAIRY_RAW FAIRY_SPRITE_FLIPPED = pygame.transform.flip(FAIRY_SPRITE, True, False) if FAIRY_SPRITE is not None else None # ---------------------------- # Loot + Shockwave # ---------------------------- def roll_from_table(table: dict) -> str | None: """table keys: awakened/attuned/tempered/none with probabilities that sum to ~1.""" r = random.random() acc = 0.0 for key in ["awakened", "attuned", "tempered", "none"]: p = float(table.get(key, 0.0)) if isinstance(table, dict) else 0.0 acc += p if r < acc: return None if key == "none" else key return None class Shockwave: def __init__(self, x, y, direction: int, speed: float, lifetime: float, height: int, damage: int): self.rect = pygame.Rect(int(x), int(y), 26, int(height)) self.dir = 1 if direction >= 0 else -1 self.speed = float(speed) self.t = float(lifetime) self.damage = int(damage) def update(self, dt: float, platforms: list[pygame.Rect]): self.rect.x += int(self.dir * self.speed * dt) self.t -= dt # kill if it hits a wall for p in platforms: if self.rect.colliderect(p): self.t = 0 break @property def alive(self) -> bool: return self.t > 0 def draw(self, surface, cx, cy): pygame.draw.rect( surface, (250, 240, 120), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), ) pygame.draw.rect( surface, (0, 0, 0), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), 2, ) class Projectile: def __init__(self, x, y, direction: int, damage: float): w, h = PROJECTILE_SIZE self.rect = pygame.Rect(int(x), int(y), int(w), int(h)) self.dir = 1 if direction >= 0 else -1 self.speed = float(PROJECTILE_SPEED) self.t = float(PROJECTILE_LIFETIME) self.damage = float(damage) def update(self, dt: float, platforms: list[pygame.Rect], enemies: list, doors=None): # move self.rect.x += int(self.dir * self.speed * dt) self.t -= dt if self.t <= 0: return # collide with ability doors (open on hit) if doors: for d in doors: if getattr(d, "open", False): continue if self.rect.colliderect(d.rect): d.open = True mark_door_broken(getattr(d, "room_id", None), d.rect) self.t = 0 return # collide with world for p in platforms: if self.rect.colliderect(p): self.t = 0 return # collide with enemies for en in enemies: if self.rect.colliderect(en.rect): en.take_damage(self.damage) self.t = 0 return @property def alive(self) -> bool: 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, (220, 240, 255), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), ) pygame.draw.rect( surface, (0, 0, 0), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), 1, ) class TetherShot: def __init__(self, x, y, direction: int, player_ref: "Player"): w, h = TETHER_SIZE self.dir = 1 if direction >= 0 else -1 self.speed = float(TETHER_SPEED) self.t = float(TETHER_LIFETIME) self.origin = (int(x), int(y)) self.player = player_ref self.alive = True self.use_light_sprite = not player_ref.shade if self.use_light_sprite and TETHER_SPRITE_LIGHT is not None: w, h = TETHER_SPRITE_LIGHT_SIZE elif (not self.use_light_sprite) and TETHER_SPRITE_DARK is not None: w, h = TETHER_SPRITE_DARK_SIZE self.rect = pygame.Rect(0, 0, int(w), int(h)) self.rect.center = (int(x), int(y)) def update(self, dt: float, platforms: list[pygame.Rect], enemies: list): if not self.alive: return self.rect.x += int(self.dir * self.speed * dt) self.t -= dt if self.t <= 0: self.alive = False if self.player.tether_cd <= 0.0 and self.player.tether_pending_cd <= 0.0: self.player.tether_cd = TETHER_COOLDOWN return for p in platforms: if self.rect.colliderect(p): self.alive = False if self.player.tether_cd <= 0.0 and self.player.tether_pending_cd <= 0.0: self.player.tether_cd = TETHER_COOLDOWN return for en in enemies: if en.health <= 0: continue if self.rect.colliderect(en.rect): ratio = (en.health / en.max_health) if en.max_health > 0 else 0 if not self.player.shade: en.bind_timer = max(en.bind_timer, TETHER_BIND_DURATION) en.fear_timer = max(en.fear_timer, TETHER_BIND_DURATION) en.bound_light = True self.player.tether_pending_cd = max(self.player.tether_pending_cd, TETHER_BIND_DURATION) else: thresh = (1.0 / 3.0) if en.type_id == "enemy" else (0.20 if en.type_id == "miniboss" else 0.05) if ratio <= thresh: en.health = 0 self.alive = False self.player.tether_cd = TETHER_COOLDOWN return en.bind_timer = max(en.bind_timer, TETHER_BIND_DURATION) en.bound_light = False en.slow_timer = max(en.slow_timer, TETHER_BIND_DURATION) en.slow_mult = 0.8 self.player.tether_pending_cd = max(self.player.tether_pending_cd, TETHER_BIND_DURATION) self.alive = False return def draw(self, surface, cx, cy): ox, oy = self.origin if self.use_light_sprite and TETHER_SPRITE_LIGHT is not None: img = TETHER_SPRITE_LIGHT elif (not self.use_light_sprite) and TETHER_SPRITE_DARK is not None: img = TETHER_SPRITE_DARK else: img = None if img is not None: if self.dir < 0: img = pygame.transform.flip(img, True, False) seg_w = img.get_width() if seg_w <= 0: return start_x = ox end_x = self.rect.centerx step = seg_w if self.dir > 0 else -seg_w x = start_x # tile the sprite along the tether length while (x <= end_x and self.dir > 0) or (x >= end_x and self.dir < 0): draw_x = int(x - cx - seg_w // 2) draw_y = int(oy - cy - img.get_height() // 2) surface.blit(img, (draw_x, draw_y)) x += step else: pygame.draw.line(surface, (180, 180, 200), (ox - cx, oy - cy), (self.rect.centerx - cx, oy - cy), 2) pygame.draw.rect( surface, (200, 200, 220), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), ) pygame.draw.rect( surface, (40, 40, 60), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), 1, ) class ShatterPlatform: def __init__(self, x: int, y: int, w: int, h: int, lifetime: float): self.rect = pygame.Rect(int(x), int(y), int(w), int(h)) self.t = float(lifetime) def update(self, dt: float): self.t -= dt @property def alive(self) -> bool: return self.t > 0 def draw(self, surface, cx, cy): pygame.draw.rect( surface, (90, 180, 220), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), ) pygame.draw.rect( surface, (20, 40, 50), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), 2, ) class ShatterAOE: def __init__(self, rect: pygame.Rect, lifetime: float): self.rect = rect.copy() self.t = float(lifetime) def update(self, dt: float): self.t -= dt @property def alive(self) -> bool: return self.t > 0 def draw(self, surface, cx, cy): # Visual intentionally disabled; dust puffs indicate the hitbox. return class DustPuff: def __init__(self, x: int, y: int, lifetime: float = 0.4, scale: float = 1.0): self.x = float(x) self.y = float(y) self.t = float(lifetime) self.life = float(lifetime) self.start_r = random.uniform(8, 14) * scale self.end_r = self.start_r + random.uniform(12, 18) * scale self.alpha = 140 self.vx = random.uniform(-20.0, 20.0) self.vy = random.uniform(-12.0, -4.0) def update(self, dt: float): self.t -= dt self.x += self.vx * dt self.y += self.vy * dt self.vx *= 0.9 self.vy *= 0.9 @property def alive(self) -> bool: return self.t > 0 def draw(self, surface, cx, cy): if self.t <= 0: return p = 1.0 - (self.t / self.life) r = int(self.start_r + (self.end_r - self.start_r) * p) a = int(self.alpha * (1.0 - p)) if r <= 0 or a <= 0: return puff = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) pygame.draw.circle(puff, (180, 180, 180, a), (r, r), r) surface.blit(puff, (self.x - r - cx, self.y - r - cy)) class LightSource: def __init__(self, x: int, y: int, radius: int = 180): self.x = int(x) self.y = int(y) self.radius = int(radius) self.t = random.random() * 10.0 if BUTTERFLY_RAW is not None: self.img = BUTTERFLY_RAW self.rect = self.img.get_rect(midbottom=(self.x, self.y)) else: self.img = None self.rect = pygame.Rect(self.x - 16, self.y - 32, 32, 32) def draw(self, surface, cx, cy): # subtle pulse area self.t += 0.03 pulse = 0.06 + 0.04 * (0.5 + 0.5 * math.sin(self.t)) r = int(self.radius * (0.95 + 0.1 * (0.5 + 0.5 * math.sin(self.t * 1.3)))) overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) alpha = int(255 * pulse) pygame.draw.circle(overlay, (255, 230, 150, alpha), (r, r), r, 2) surface.blit(overlay, (self.x - r - cx, self.y - r - cy)) if self.img is not None: bob = int(math.sin(self.t * 1.6) * 3) surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy - 6 + bob)) else: pygame.draw.rect(surface, (240, 230, 120), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height)) class SavePoint: """Lantern save point: pressing interact nearby saves the game and sets the respawn point.""" def __init__(self, x: int, y: int, radius: int = 90): self.x = int(x) self.y = int(y) self.radius = int(radius) self.t = random.random() * 10.0 if LAMP_RAW is not None: self.img = LAMP_RAW self.rect = self.img.get_rect(midbottom=(self.x, self.y)) else: self.img = None self.rect = pygame.Rect(self.x - 16, self.y - 37, 32, 74) def draw(self, surface, cx, cy): self.t += 0.03 if self.img is not None: surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy)) else: pygame.draw.rect(surface, (150, 200, 230), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height)) class ButterflyJar: """Collectible butterfly-in-a-jar. The player can hold up to BUTTERFLY_JARS_MAX.""" def __init__(self, x: int, y: int, key: str = ""): self.x = int(x) self.y = int(y) self.key = key self.bob_t = random.random() * 10.0 if BUTTERFLY_JAR_RAW is not None: self.img = BUTTERFLY_JAR_RAW self.rect = self.img.get_rect(midbottom=(self.x, self.y)) else: self.img = None self.rect = pygame.Rect(self.x - 16, self.y - 32, 32, 32) def update(self, dt: float): self.bob_t += dt def draw(self, surface, cx, cy): bob_y = int(math.sin(self.bob_t * 2.0) * 3) if self.img is not None: surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy + bob_y)) else: pygame.draw.rect(surface, (200, 150, 230), (self.rect.x - cx, self.rect.y - cy + bob_y, self.rect.width, self.rect.height)) class Corpse: """A faded player model left where the player died, holding the coins they lost.""" def __init__(self, x: int, y: int, room_id: str, coins_light: int, coins_dark: int): self.room_id = str(room_id) self.x = int(x) self.y = int(y) self.coins_light = int(coins_light) self.coins_dark = int(coins_dark) self.t = random.random() * 10.0 self.rect = pygame.Rect(int(x), int(y), PLAYER_HITBOX_W, PLAYER_HITBOX_H) self.rect.midbottom = (int(x), int(y)) self.img = self._build_faded_sprite() def _build_faded_sprite(self): if PLAYER_SPRITE_RAW is None: return None scale = PLAYER_SPRITE_SCALE w = max(1, int(PLAYER_SPRITE_RAW.get_width() * scale)) h = max(1, int(PLAYER_SPRITE_RAW.get_height() * scale)) try: img = pygame.transform.smoothscale(PLAYER_SPRITE_RAW, (w, h)) except Exception: return None img.set_alpha(130) return img def draw(self, surface, cx, cy): self.t += 0.02 bob = int(math.sin(self.t) * 2) if self.img is not None: draw_x = self.rect.x - cx - (self.img.get_width() - self.rect.width) // 2 + PLAYER_SPRITE_OFFSET_X draw_y = self.rect.y - cy - (self.img.get_height() - self.rect.height) + PLAYER_SPRITE_OFFSET_Y + bob surface.blit(self.img, (draw_x, draw_y)) else: pygame.draw.rect( surface, PLAYER_LIGHT, pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), 2, ) def spawn_boss_shockwave(boss, shockwaves_list: list[Shockwave]): y = boss.rect.bottom - boss.shockwave_height x = boss.rect.centerx shockwaves_list.append(Shockwave( x, y, -1, boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage )) shockwaves_list.append(Shockwave( x, y, 1, boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage )) class LootPickup: def __init__(self, x, y, item_instance: dict): self.item = item_instance self.rarity = item_instance.get("rarity", "tempered") self.rect = pygame.Rect(int(x), int(y), 16, 16) # physics self.vx = random.uniform(-60, 60) # px/sec initial scatter self.vy = random.uniform(-120, -40) # small upward pop self.on_ground = False # visual bob self.bob_t = random.random() * 10.0 # desync multiple drops def update(self, dt: float, platforms: list[pygame.Rect]): # bob timer always runs (used only when grounded) self.bob_t += dt * LOOT_BOB_SPEED if self.on_ground: # ground friction for horizontal drift self.vx *= LOOT_FRICTION if abs(self.vx) < 5: self.vx = 0.0 self.rect.x += int(self.vx * dt) return # gravity self.vy = min(LOOT_TERMINAL_VEL, self.vy + LOOT_GRAVITY * dt) # move self.rect.x += int(self.vx * dt) self.rect.y += int(self.vy * dt) for p in platforms: if self.rect.colliderect(p): # landing on top 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: # tiny bounce threshold self.vy = 0.0 self.on_ground = True # hitting underside 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 only after it landed bob_y = int(math.sin(self.bob_t) * LOOT_BOB_AMP) if self.on_ground else 0 screen_pos = (int(self.rect.x - cx), int(self.rect.y - cy + bob_y)) # You already have a function/cached icons for rarity tint. # Use whatever you used to draw the dropped item icon. slot = self.item.get("slot", "unknown") icon = get_drop_icon(slot, self.rarity) # <- your existing helper if icon: surface.blit(icon, screen_pos) else: # fallback: colored square col = LOOT_COLORS.get(self.rarity, (255, 255, 255)) pygame.draw.rect(surface, col, (*screen_pos, self.rect.width, self.rect.height)) class CoinPickup: def __init__(self, x, y, kind: str): # kind: "light" or "dark" self.kind = kind self.rect = pygame.Rect(int(x), int(y), 16, 16) # physics (same feel as loot) self.vx = random.uniform(-80, 80) self.vy = random.uniform(-140, -60) self.on_ground = False self.bob_t = random.random() * 10.0 def update(self, dt: float, platforms: list[pygame.Rect]): 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) img = COIN_LIGHT_RAW if self.kind == "light" else COIN_DARK_RAW if img: surface.blit(pygame.transform.scale(img, (16, 16)), (x, y)) else: # fallback square col = (220, 220, 220) if self.kind == "light" else (80, 80, 80) pygame.draw.rect(surface, col, (x, y, 16, 16)) class AbilityDoor: 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: return r = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) pygame.draw.rect(surface, (120, 180, 120), r) 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) door.layer = int(d.get("layer", 0)) 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) self.opened = bool(opened) self.coins = coins def open(self, player): if self.opened: return light_amt, dark_amt = self.coins player.coins_light += int(light_amt) player.coins_dark += int(dark_amt) self.opened = True def draw(self, surface, cx, cy): x = int(self.rect.x - cx) y = int(self.rect.y - cy) base = (150, 95, 50) if not self.opened else (110, 70, 40) lid = (190, 125, 70) if not self.opened else (130, 85, 50) pygame.draw.rect(surface, base, (x, y + 8, self.rect.width, self.rect.height - 8)) pygame.draw.rect(surface, lid, (x, y, self.rect.width, 10)) pygame.draw.rect(surface, (40, 25, 12), (x, y + 8, self.rect.width, self.rect.height - 8), 2) pygame.draw.rect(surface, (40, 25, 12), (x, y, self.rect.width, 10), 2) class AbilityPickup: def __init__(self, x, y, ability_id: str = "ranged"): self.base_x = int(x) self.base_y = int(y) self.rect = pygame.Rect(int(x), int(y), 20, 20) self.bob_t = random.random() * 10.0 self.ability_id = str(ability_id) def update(self, dt: float): self.bob_t += dt * LOOT_BOB_SPEED bob_y = int(math.sin(self.bob_t) * LOOT_BOB_AMP) self.rect.x = self.base_x self.rect.y = self.base_y + bob_y def draw(self, surface, cx, cy): x = int(self.rect.x - cx) y = int(self.rect.y - cy) pygame.draw.rect(surface, (200, 230, 255), (x, y, self.rect.width, self.rect.height)) pygame.draw.rect(surface, (30, 40, 60), (x, y, self.rect.width, self.rect.height), 2) pygame.draw.line(surface, (30, 40, 60), (x + 4, y + 10), (x + 16, y + 10), 2) class NPC: def __init__(self, x, y, npc_type: str = "vendor", trade_cost: int | None = None, offers=None, greet_light: str | None = None, greet_shade: str | None = None, greet_hint: str | None = None, layer: int = 0): self.type = npc_type self.trade_cost = int(trade_cost) if trade_cost is not None else None self.offers = offers if isinstance(offers, list) else [] self.greet_light = greet_light self.greet_shade = greet_shade self.greet_hint = greet_hint self.layer = int(layer) self.rect = pygame.Rect(int(x), int(y), 36, 54) def draw(self, surface, cx, cy): x = int(self.rect.x - cx) y = int(self.rect.y - cy) body = (220, 220, 190) if self.type == "vendor" else (120, 120, 120) pygame.draw.rect(surface, body, (x, y, self.rect.width, self.rect.height)) pygame.draw.rect(surface, (140, 140, 110), (x, y, self.rect.width, self.rect.height), 2) class TradeUI: def __init__(self, inventory: "Inventory", player, font_obj: pygame.font.Font): self.inv = inventory self.player = player self.font = font_obj self.visible = False self.cursor = 0 self.active_npc: NPC | None = None self.options = [] def toggle(self, npc: NPC | None = None): self.visible = not self.visible self.cursor = 0 self.active_npc = npc if self.visible else None if self.visible: self.options = self._build_options(self.active_npc) else: self.options = [] def handle_keydown(self, key): if not self.visible: return if key in (key_for("ui_cancel"), key_for("interact"), key_for("inventory")): self.visible = False return if not self.options: return if key == key_for("ui_up"): self.cursor = (self.cursor - 1) % len(self.options) elif key == key_for("ui_down"): self.cursor = (self.cursor + 1) % len(self.options) elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): self._try_trade() def _random_item(self, slot: str, rarity: str) -> dict | None: # 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) use_rarity = rarity if rarity in RARITIES else "tempered" 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" default_offers = _npc(["defaults", npc_type, "offers"], []) if isinstance(default_offers, list) and default_offers: offers = default_offers if not offers: offers = [ {"type": "sell_item", "slot": "helmet", "label": "Helmet", "rarity": "tempered"}, {"type": "sell_item", "slot": "chest", "label": "Chestplate", "rarity": "tempered"}, {"type": "sell_item", "slot": "pants", "label": "Greaves", "rarity": "tempered"}, ] options = [] for offer in offers: if not isinstance(offer, dict): continue otype = str(offer.get("type", "sell_item")) if otype == "upgrade_attune": required = int(offer.get("required_count", 5)) counts = {} names = {} for it in self.inv.items: if not isinstance(it, dict): continue if it.get("rarity") != "tempered": continue item_id = it.get("id") if not item_id: continue counts[item_id] = counts.get(item_id, 0) + 1 if item_id not in names: names[item_id] = it.get("name", item_id) for item_id, count in counts.items(): if count < required: continue label = f"Upgrade {names.get(item_id, item_id)} (x{required})" options.append({ "type": "upgrade_attune", "item_id": item_id, "label": label, "required_count": required, "cost": offer.get("cost"), }) else: opt = dict(offer) opt["type"] = otype options.append(opt) return options def _try_trade(self): cost = NPC_TRADE_COST if self.active_npc and self.active_npc.trade_cost is not None: cost = int(self.active_npc.trade_cost) if self.player.coins_light < cost: pickup_feed.push_plain("Not enough light coins") return if not self.options: pickup_feed.push_plain("No offers available") return choice = self.options[self.cursor] choice_type = str(choice.get("type", "sell_item")) choice_cost = choice.get("cost") if isinstance(choice_cost, (int, float)): cost = int(choice_cost) if self.player.coins_light < cost: pickup_feed.push_plain("Not enough light coins") return if choice_type == "upgrade_attune": item_id = choice.get("item_id") required = int(choice.get("required_count", 5)) if not item_id: pickup_feed.push_plain("No item selected") return base = safe_get_item(str(item_id)) if not base: pickup_feed.push_plain("Unknown item") return count = sum(1 for it in self.inv.items if isinstance(it, dict) and it.get("id") == item_id and it.get("rarity") == "tempered") if count < required: pickup_feed.push_plain(f"Need {required} tempered copies") return removed = required new_items = [] for it in self.inv.items: if removed > 0 and isinstance(it, dict) and it.get("id") == item_id and it.get("rarity") == "tempered": removed -= 1 continue new_items.append(it) self.inv.items = new_items self.player.coins_light -= cost upgraded = build_item_instance(base, "attuned") self.inv.add(upgraded) pickup_feed.push_plain_rarity(f"Upgraded {upgraded.get('name', 'Item')} to ", "attuned") # refresh options after upgrade 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 == "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: pickup_feed.push_plain("No item selected") return base = safe_get_item(str(item_id)) if not base: pickup_feed.push_plain("Unknown item") return rarity = choice.get("rarity", None) item = build_item_instance_any(base, rarity) self.player.coins_light -= cost self.inv.add(item) pickup_feed.push_plain(f"Bought {item.get('name', 'Item')}") self.options = self._build_options(self.active_npc) else: item = self._random_item(choice["slot"], choice.get("rarity", "tempered")) if not item: pickup_feed.push_plain("No items available") return self.player.coins_light -= cost self.inv.add(item) pickup_feed.push_plain(f"Bought {item.get('name', 'Item')}") # refresh options in case inventory-driven offers changed self.options = self._build_options(self.active_npc) def draw(self, surface: pygame.Surface): if not self.visible: return panel_w = 340 panel_h = 180 x = (WIDTH - panel_w) // 2 y = (HEIGHT - panel_h) // 2 panel = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA) panel.fill((10, 10, 10, 220)) surface.blit(panel, (x, y)) pygame.draw.rect(surface, (50, 50, 50), (x, y, panel_w, panel_h), 2) title = self.font.render("Vendor Trade", True, (230, 230, 230)) surface.blit(title, (x + 14, y + 10)) cost = NPC_TRADE_COST if self.active_npc and self.active_npc.trade_cost is not None: cost = int(self.active_npc.trade_cost) if self.options: opt_cost = self.options[self.cursor].get("cost") if isinstance(opt_cost, (int, float)): cost = int(opt_cost) cost_txt = self.font.render(f"Cost: {cost} light coins", True, (180, 180, 180)) surface.blit(cost_txt, (x + 14, y + 32)) list_y = y + 60 if not self.options: none_txt = self.font.render("No offers available", True, (160, 160, 160)) surface.blit(none_txt, (x + 22, list_y)) return for i, opt in enumerate(self.options): otype = str(opt.get("type", "sell_item")) if otype == "upgrade_attune": label = str(opt.get("label", "Upgrade")) else: rarity = str(opt.get("rarity", "")).title() if rarity: label = f"{opt['label']} - {rarity}" else: label = str(opt.get("label", "Item")) col = (240, 240, 240) if i == self.cursor else (170, 170, 170) line = self.font.render(label, True, col) surface.blit(line, (x + 22, list_y + i * 28)) def spawn_drops_from_enemy(enemy) -> list[LootPickup]: drops: list[LootPickup] = [] prof = ENEMY_DROPS.get(enemy.drop_profile, None) if _cfg(["debug", "drop_debug_print"], True): print("DROP DEBUG:", "enemy_type=", enemy.type_id, "profile=", enemy.drop_profile, "prof_found=", isinstance(prof, dict), "pool=", (prof.get("pool") if isinstance(prof, dict) else None)) if not isinstance(prof, dict): return drops pool = prof.get("pool", []) if not isinstance(pool, list): pool = [] default_pool = prof.get("default_pool", None) if (not pool) and default_pool == "armor": pool = list(ARMOR_BY_ID.keys()) 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 def drop_pos(): nonlocal spawned # base near enemy feet base_x = enemy.rect.centerx - 8 base_y = enemy.rect.bottom - 40 # scatter + gentle "row nudge" so multiple drops don't stack x = base_x + random.randint(-36, 36) + (spawned % 6) * 12 y = base_y + random.randint(-6, 6) spawned += 1 return x, y # guaranteed drops guaranteed = prof.get("guaranteed", []) if isinstance(guaranteed, list): for g in guaranteed: if not isinstance(g, dict): continue rarity = g.get("rarity", None) count = scale_count(int(g.get("count", 1)), ROOM_DROP_MULT) for _ in range(max(0, count)): item_id = g.get("item_id", None) pool_kind = g.get("item_pool", None) if not item_id: if pool_kind == "necklace": item_id = pick_from(list(NECKLACE_BY_ID.keys())) elif pool_kind == "armor": item_id = pick_from(list(ARMOR_BY_ID.keys())) else: 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) continue try: inst = build_item_instance_any(base, rarity) except Exception: continue x, y = drop_pos() drops.append(LootPickup(x, y, inst)) # random rolls rolls = scale_count(int(prof.get("rolls", 0)), ROOM_DROP_MULT) rarity_table = prof.get("rarity_table", {}) for _ in range(rolls): rarity = roll_from_table(rarity_table) if rarity is None: continue if not pool: continue 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) continue try: inst = build_item_instance_any(base, rarity) except Exception: continue x, y = drop_pos() drops.append(LootPickup(x, y, inst)) return drops # ---------------------------- # Inventory # ---------------------------- class Inventory: def __init__(self): self.items: list[dict] = [] def add(self, item_instance: dict): if isinstance(item_instance, dict): self.items.append(item_instance) def __len__(self): return len(self.items) class InventoryUI: def __init__(self, inventory: Inventory, player, font_obj: pygame.font.Font): self.inv = inventory self.player = player self.font = font_obj self.visible = False self.focus = "inv" # "inv" or "equip" self.cursor = 0 self.scroll = 0 self.equip_slots = ["helmet", "chest", "pants", "necklace"] self.equip_idx = 0 self.x = int(_ui(["inventory_ui", "panel", "x"], 20)) self.y = int(_ui(["inventory_ui", "panel", "y"], 20)) self.w = int(_ui(["inventory_ui", "panel", "w"], 560)) self.h = int(_ui(["inventory_ui", "panel", "h"], 380)) self.pad = int(_ui(["inventory_ui", "panel", "pad"], 10)) self.list_w = int(_ui(["inventory_ui", "list", "w"], 360)) self.line_h = int(_ui(["inventory_ui", "list", "line_h"], 18)) # remembers how many lines the list can show (set in draw()) self._last_list_max_lines = 1 self.slot_icons = load_slot_icons() self.slot_icon_cache = {} # (slot, rarity) -> Surface self.token_icon = None # lazily cached scaled boss-token icon self.icon_size = int(_ui(["inventory_ui", "icons", "size"], 16)) self.icon_gap = int(_ui(["inventory_ui", "icons", "gap"], 6)) def _get_token_icon(self) -> pygame.Surface | None: if self.token_icon is None: if BOSS_TOKEN_SPRITE_RAW is not None: try: self.token_icon = pygame.transform.smoothscale( BOSS_TOKEN_SPRITE_RAW, (self.icon_size, self.icon_size)) except Exception: self.token_icon = None return self.token_icon def toggle(self): self.visible = not self.visible if self.visible: self.focus = "inv" self._clamp_cursor() def _clamp_cursor(self): n = len(self.inv.items) if n == 0: self.cursor = 0 self.scroll = 0 return self.cursor = max(0, min(self.cursor, n - 1)) # scroll is NOT allowed to go to n-1; it must stop at the last page start max_scroll = max(0, n - self._last_list_max_lines) self.scroll = max(0, min(self.scroll, max_scroll)) def _ensure_visible(self, max_lines: int): if self.cursor < self.scroll: self.scroll = self.cursor if self.cursor >= self.scroll + max_lines: self.scroll = self.cursor - max_lines + 1 self.scroll = max(0, min(self.scroll, max(0, len(self.inv.items) - max_lines))) def handle_keydown(self, key): if not self.visible: return if key == key_for("ui_cancel"): self.toggle() return if key == key_for("ui_right"): self.focus = "equip" return if key == key_for("ui_left"): self.focus = "inv" return if self.focus == "inv": if key == key_for("ui_up"): self.cursor -= 1 self._clamp_cursor() self._ensure_visible(self._last_list_max_lines) elif key == key_for("ui_down"): self.cursor += 1 self._clamp_cursor() self._ensure_visible(self._last_list_max_lines) elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): self._equip_selected_swap() else: if key == key_for("ui_up"): self.equip_idx = (self.equip_idx - 1) % len(self.equip_slots) elif key == key_for("ui_down"): self.equip_idx = (self.equip_idx + 1) % len(self.equip_slots) elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): self._unequip_selected() def _equip_selected_swap(self): if len(self.inv.items) == 0: return idx = self.cursor new_item = self.inv.items[idx] slot = new_item.get("slot") if slot not in self.player.equipment: return old_item = self.player.equipment.get(slot) # equip new (swap) self.player.equip_swap(new_item) # remove selected from inventory self.inv.items.pop(idx) # insert old at same index (true swap) if old_item is not None: self.inv.items.insert(idx, old_item) self._clamp_cursor() def _unequip_selected(self): """Move the item in the selected equipment slot back to the inventory.""" slot = self.equip_slots[self.equip_idx] item = self.player.equipment.get(slot) if item is None: return self.player.equipment[slot] = None self.inv.items.append(item) self.player.recalc_bonuses() self.player._clamp_resources() self._clamp_cursor() def _blit(self, surface, text, x, y, color=(230, 230, 230)): surface.blit(self.font.render(text, True, color), (x, y)) def _rarity_color(self, rarity: str): return LOOT_COLORS.get(rarity, (230, 230, 230)) def _get_slot_icon(self, slot: str, rarity: str | None) -> pygame.Surface | None: slot = slot or "unknown" rarity = rarity or "" key = (slot, rarity) if key in self.slot_icon_cache: return self.slot_icon_cache[key] base = self.slot_icons.get(slot) or self.slot_icons.get("unknown") if base is None: self.slot_icon_cache[key] = None return None # tint only if we have a known rarity color col = LOOT_COLORS.get(rarity) icon = tint_icon(base, col) if col else base self.slot_icon_cache[key] = icon return icon def _get_stats(self, item: dict) -> dict: if not item or not isinstance(item, dict): return {} stats = item.get("stats", {}) return stats if isinstance(stats, dict) else {} def _format_stat_value(self, key: str, value): """ Format stats nicely: - *_pct are stored as decimals (0.10) but displayed as 10% - other stats displayed as ints if whole, else as 1 decimal """ if key.endswith("_pct"): # 0.10 -> "10%" if isinstance(value, (int, float)): sign = "+" if value > 0 else "" return f"{sign}{value * 100:.0f}%" return str(value) # non-percent stats if isinstance(value, (int, float)) and abs(value - round(value)) < 1e-9: v = int(round(value)) sign = "+" if v > 0 else "" return f"{sign}{v}" if isinstance(value, (int, float)): sign = "+" if value > 0 else "" return f"{sign}{value:.1f}" return str(value) def _stat_display_name(self, key: str) -> str: names = { "health": "Max Health", "charge": "Max Charge", "charge_regen_pct": "Charge Regeneration", "heal_speed_pct": "Heal Speed", "heal_plus": "Heal Amount", "move_speed_pct": "Move Speed", "move_speed_light_pct": "Move Speed (Light)", "move_speed_shade_pct": "Move Speed (Shade)", "attack_speed_pct": "Attack Speed", "jump_height_shade_pct": "Jump Height (Shade)", "negate_damage_chance": "Negate Damage Chance", "shade_double_damage_chance": "Shade Double Damage Chance", "shade_damage_flat": "Shade Damage", "knockback_resistance": "Knockback Resistance", "restore_charge": "Restore Charge", "heal_flat": "Heal On Kill", "shade_charge_drain_reduction_pct": "Shade Charge Drain Reduction", "negate_shade_speed_penalty": "Negate Shade Speed Penalty", } if key in names: return names[key] # Fallback: "some_stat_name" -> "Some Stat Name" return str(key).replace("_", " ").title() def _ellipsize(self, text: str, max_px: int) -> str: """Trim text so it fits max_px pixels, adding '...' if needed.""" if self.font.size(text)[0] <= max_px: return text ell = "..." ell_w = self.font.size(ell)[0] if max_px <= ell_w: return ell lo, hi = 0, len(text) while lo < hi: mid = (lo + hi) // 2 if self.font.size(text[:mid])[0] <= (max_px - ell_w): lo = mid + 1 else: hi = mid cut = max(0, lo - 1) return text[:cut] + ell def _draw_stat_line(self, surface, x, y, name, value, delta=None): display_name = self._stat_display_name(name) value_txt = self._format_stat_value(name, value) base_text = f"{display_name}: {value_txt}" self._blit(surface, base_text, x, y, (230, 230, 230)) if delta is None: return # delta formatting: if *_pct, convert to percentage points if name.endswith("_pct"): # delta is e.g. 0.05 -> "+5%" delta_txt_val = f"{delta * 100:+.0f}%" else: # normal numeric delta if isinstance(delta, (int, float)) and abs(delta - round(delta)) < 1e-9: delta_txt_val = f"{int(round(delta)):+d}" else: delta_txt_val = f"{delta:+.1f}" if delta > 0: col = (120, 255, 120) elif delta < 0: col = (255, 120, 120) else: col = (180, 180, 180) dtxt = f" ({delta_txt_val})" w = self.font.size(base_text)[0] self._blit(surface, dtxt, x + w, y, col) def _active_set_bonus_lines_for_item(self, item: dict) -> list[str]: if not item or not isinstance(item, dict): return [] set_id = item.get("set") if not set_id: return [] cfg = SET_BONUSES.get(set_id, {}) if not isinstance(cfg, dict): return [] required = int(cfg.get("required_pieces", 3)) pieces = [] for it in self.player.equipment.values(): if not it or it.get("set") != set_id: continue rarity_id = it.get("rarity") tier = it.get("tier", None) if tier is None: tier = RARITIES.get(rarity_id, {}).get("tier", 1) tier = int(tier) if tier >= 2: pieces.append(it) if len(pieces) < required: return [] awakened_count = sum(1 for p in pieces if int(p.get("tier", 1)) >= 3) bonus_tier = "awakened" if awakened_count >= required else "attuned" tier_cfg = cfg.get(bonus_tier, {}) if isinstance(cfg, dict) else {} effects = tier_cfg.get("effects", {}) if isinstance(tier_cfg, dict) else {} conds = tier_cfg.get("conditional_effects", []) if isinstance(tier_cfg, dict) else [] lines = [] effect_summary = "" if isinstance(effects, dict) and effects: parts = [] for k, v in effects.items(): label = self._stat_display_name(k) val = self._format_stat_value(k, v) parts.append(f"{label} {val}") effect_summary = ", ".join(parts) if effect_summary: lines.append(f"Set bonus ({bonus_tier}): {effect_summary}") else: set_name = str(set_id).replace("_", " ").title() lines.append(f"Set bonus ({bonus_tier}): {set_name}") if isinstance(conds, list) and conds: for c in conds: if not isinstance(c, dict): continue cond = c.get("condition", "condition") eff = c.get("effects", {}) if not isinstance(eff, dict) or not eff: continue parts = [] for k, v in eff.items(): label = self._stat_display_name(k) val = self._format_stat_value(k, v) parts.append(f"{label} {val}") lines.append(f"{cond}: " + ", ".join(parts)) return lines def draw(self, surface): if not self.visible: return panel = pygame.Rect(self.x, self.y, self.w, self.h) pygame.draw.rect(surface, (40, 40, 40), panel) pygame.draw.rect(surface, (150, 150, 150), panel, 2) room_name = "Room" try: room_name = ROOM.name except Exception: pass self._blit(surface, f"Inventory (I to close) — {room_name}", self.x + self.pad, self.y + self.pad, (240, 240, 240)) # --- Detail areas (reserve bottom space FIRST) --- stats_h = int(_ui(["inventory_ui", "details", "stats_h"], 90)) hint_h = int(_ui(["inventory_ui", "details", "hint_h"], 24)) gap = int(_ui(["inventory_ui", "details", "gap"], 8)) hint_y = self.y + self.h - self.pad - hint_h stats_y = hint_y - gap - stats_h # --- List region (everything between header and stats) --- header_h = int(_ui(["inventory_ui", "list", "header_h"], 40)) list_top = self.y + header_h list_h = (stats_y - gap) - list_top list_h = max(40, list_h) # safety # padding inside the list box (top + bottom) list_pad_y = int(_ui(["inventory_ui", "list", "pad_y"], 6)) usable_h = max(1, list_h - list_pad_y * 2) list_max_lines = max(1, usable_h // self.line_h) self._ensure_visible(list_max_lines) self._last_list_max_lines = list_max_lines list_rect = pygame.Rect(self.x + self.pad, list_top, self.list_w, list_h) pygame.draw.rect(surface, (45, 45, 45), list_rect) pygame.draw.rect(surface, (110, 110, 110), list_rect, 1) start = self.scroll end = min(len(self.inv.items), start + list_max_lines) y = list_top + list_pad_y # --- clip everything to the list box --- old_clip = surface.get_clip() surface.set_clip(list_rect) if len(self.inv.items) == 0: self._blit(surface, "(empty)", self.x + self.pad + 6, y, (160, 160, 160)) else: for idx in range(start, end): it = self.inv.items[idx] name = it.get("name", it.get("id", "")) rarity = it.get("rarity", "?") slot = it.get("slot", "?") if self.focus == "inv" and idx == self.cursor: hl = pygame.Rect(self.x + 4, y - 2, self.list_w - 8, self.line_h) pygame.draw.rect(surface, (60, 60, 60), hl) rarity_text = f"[{rarity}]" if rarity else "" right_text = f" ({slot})" x0 = self.x + self.pad + 6 pad_right = int(_ui(["inventory_ui", "list", "pad_right"], 14)) # keep away from scrollbar max_w = self.list_w - pad_right prefix = f"{idx + 1:02d} " suffix = f" {rarity_text}{right_text}" icon_space = self.icon_size + self.icon_gap avail_for_name = max_w - icon_space - self.font.size(suffix)[0] name_fit = self._ellipsize(name, max(0, avail_for_name)) # --- slot icon (tinted by rarity), or token icon for boss tokens --- icon = None if it.get("item_type") == "boss_token": icon = self._get_token_icon() if icon is None: icon = self._get_slot_icon(slot, rarity) icon_y = y + (self.line_h - self.icon_size) // 2 if icon: surface.blit(icon, (x0, icon_y)) text_x = x0 + self.icon_size + self.icon_gap # --- item name --- self._blit(surface, name_fit + " ", text_x, y, (230, 230, 230)) x1 = text_x + self.font.size(name_fit + " ")[0] x0 = self.x + self.pad + 6 if rarity_text: self._blit(surface, rarity_text, x1, y, self._rarity_color(rarity)) x2 = x1 + (self.font.size(rarity_text)[0] if rarity_text else 0) self._blit(surface, right_text, x2, y, (230, 230, 230)) y += self.line_h surface.set_clip(old_clip) # Equipped column eq_x = self.x + self.list_w + self.pad eq_y = list_top self._blit(surface, "Equipped:", eq_x, eq_y - 18, (220, 220, 220)) for i, slot in enumerate(self.equip_slots): it = self.player.equipment.get(slot) is_sel = (self.focus == "equip" and i == self.equip_idx) rarity = it.get("rarity", "") if it else "" icon = self._get_slot_icon(slot, rarity) if is_sel: hl = pygame.Rect(eq_x - 6, eq_y - 2, self.w - (eq_x - self.x) - self.pad, self.line_h + 2) pygame.draw.rect(surface, (60, 60, 60), hl) # draw icon if icon: icon_y = eq_y + (self.line_h - self.icon_size) // 2 surface.blit(icon, (eq_x, icon_y)) text_x = eq_x + (self.icon_size + self.icon_gap) if not it: self._blit(surface, f"{slot}: (empty)", text_x, eq_y, (170, 170, 170)) else: name = it.get("name", "?") left = f"{slot}: {name} " tag = f"[{rarity}]" if rarity else "" self._blit(surface, left, text_x, eq_y, (230, 230, 230)) if tag: self._blit(surface, tag, text_x + self.font.size(left)[0], eq_y, self._rarity_color(rarity)) 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)) gap = int(_ui(["inventory_ui", "details", "gap"], 8)) hint_y = self.y + self.h - self.pad - hint_h stats_y = hint_y - gap - stats_h # Hint box pygame.draw.rect( surface, (45, 45, 45), (self.x + self.pad, hint_y, self.w - self.pad * 2, hint_h) ) pygame.draw.rect( surface, (110, 110, 110), (self.x + self.pad, hint_y, self.w - self.pad * 2, hint_h), 1 ) # Hint text (NOW hint_y exists) self._blit( surface, "UP/DOWN move ENTER equip / unequip LEFT/RIGHT switch panel", self.x + self.pad + 6, hint_y + 4, (160, 160, 160), ) # Stats box pygame.draw.rect( surface, (50, 50, 50), (self.x + self.pad, stats_y, self.w - self.pad * 2, stats_h) ) pygame.draw.rect( surface, (120, 120, 120), (self.x + self.pad, stats_y, self.w - self.pad * 2, stats_h), 1 ) selected_item = None if self.focus == "inv": selected_item = self.inv.items[self.cursor] if len(self.inv.items) > 0 else None selected_label = "Selected (Inventory):" else: slot = self.equip_slots[self.equip_idx] selected_item = self.player.equipment.get(slot) selected_label = f"Selected (Equipped: {slot}):" self._blit(surface, selected_label, self.x + self.pad + 6, stats_y + 6, (220, 220, 220)) if not selected_item: self._blit(surface, "(none)", self.x + self.pad + 6, stats_y + 24, (170, 170, 170)) return name = selected_item.get("name", selected_item.get("id", "")) rarity = selected_item.get("rarity", "?") self._blit(surface, f"{name} ", self.x + self.pad + 6, stats_y + 24, (230, 230, 230)) if rarity: self._blit(surface, f"[{rarity}]", self.x + self.pad + 6 + self.font.size(f"{name} ")[0], stats_y + 24, self._rarity_color(rarity)) compare_item = None selected_slot = selected_item.get("slot") # Only show comparisons when browsing inventory items. if self.focus == "inv": if selected_slot in self.player.equipment: compare_item = self.player.equipment.get(selected_slot) sel_stats = self._get_stats(selected_item) cmp_stats = self._get_stats(compare_item) if compare_item else {} bonus_lines = [] if selected_item in self.player.equipment.values(): bonus_lines = self._active_set_bonus_lines_for_item(selected_item) stats_x = self.x + self.pad + 6 y_stats = stats_y + 42 stats_bottom = stats_y + stats_h - 8 keys = sorted(set(sel_stats.keys()) | set(cmp_stats.keys())) max_lines = max(1, (stats_bottom - y_stats) // 16) if bonus_lines: max_lines = max(1, max_lines - min(2, len(bonus_lines))) had_stats = False if not keys: self._blit(surface, "Stats: (no stats)", stats_x, y_stats, (200, 200, 200)) else: had_stats = True shown = 0 for k in keys: v = float(sel_stats.get(k, 0)) if compare_item: base = float(cmp_stats.get(k, 0)) d = v - base self._draw_stat_line(surface, stats_x, y_stats, k, v, delta=d) else: self._draw_stat_line(surface, stats_x, y_stats, k, v, delta=None) y_stats += 16 shown += 1 if shown >= max_lines: if len(keys) > max_lines: self._blit(surface, f"... ({len(keys) - max_lines} more)", stats_x, y_stats, (160, 160, 160)) break # Set bonus indicator for equipped items if bonus_lines: y_bonus = y_stats if not had_stats else (y_stats + 2) max_y = stats_bottom for line in bonus_lines[:2]: if y_bonus > max_y: break self._blit(surface, line, stats_x, y_bonus, (200, 200, 200)) y_bonus += 16 # --- Scroll hint + tiny scrollbar --- total = len(self.inv.items) # Always show item count hint = f"{total} items" self._blit(surface, hint, self.x + self.pad + 6, list_top + list_h - 18, (160, 160, 160)) # Only show scrollbar if it’s actually needed if total > max_lines: # scrollbar track track_x = self.x + self.pad + self.list_w - 8 track_y = list_top + list_pad_y track_h = usable_h pygame.draw.rect(surface, (40, 40, 40), (track_x, track_y, 4, track_h)) # thumb size based on how many lines are visible thumb_h = max(10, int(track_h * (max_lines / total))) # thumb position based on cursor (so last item => thumb at bottom) if total <= 1: t = 0.0 else: t = self.cursor / (total - 1) thumb_y = int(track_y + (track_h - thumb_h) * t) pygame.draw.rect(surface, (120, 120, 120), (track_x, thumb_y, 4, thumb_h)) # ---------------------------- # Pickup feed (stacked messages that fade) # ---------------------------- class PickupFeed: def __init__(self, font_obj: pygame.font.Font, max_lines: int = 6): self.font = font_obj self.center_font = pygame.font.SysFont(None, int(_ui(["pickup_feed", "center_font_size"], 48))) # MUST exist before update() is ever called self.lines = [] # list of dicts: {"name": str, "rarity": str, "t": float} # values (some from JSON) self.max_lines = int(max_lines) self.duration = float(_ui(["pickup_feed", "duration"], 3.0)) self.fade_time = float(_ui(["pickup_feed", "fade_time"], 0.6)) self.pad_x = int(_ui(["pickup_feed", "pad_x"], 10)) self.pad_y = int(_ui(["pickup_feed", "pad_y"], 6)) self.line_gap = int(_ui(["pickup_feed", "line_gap"], 4)) def push(self, name: str, rarity: str = ""): self.lines.insert(0, { "kind": "pickup", "name": str(name), "rarity": str(rarity or ""), "t": float(self.duration), }) # trim if len(self.lines) > self.max_lines: self.lines = self.lines[:self.max_lines] def push_plain(self, text: str): self.lines.insert(0, { "kind": "plain", "text": str(text), "t": float(self.duration), }) if len(self.lines) > self.max_lines: self.lines = self.lines[:self.max_lines] def push_plain_rarity(self, text: str, rarity: str): self.lines.insert(0, { "kind": "plain_rarity", "text": str(text), "rarity": str(rarity or ""), "t": float(self.duration), }) if len(self.lines) > self.max_lines: self.lines = self.lines[:self.max_lines] def push_center(self, text: str, duration: float | None = None): self.lines.insert(0, { "kind": "center", "text": str(text), "t": float(self.duration if duration is None else duration), }) if len(self.lines) > self.max_lines: self.lines = self.lines[:self.max_lines] def update(self, dt: float): if not self.lines: return alive = [] for it in self.lines: it["t"] -= dt if it["t"] > 0: alive.append(it) self.lines = alive def _alpha_for(self, t: float) -> int: if t <= 0: return 0 if t < self.fade_time: a = int(255 * (t / self.fade_time)) return max(0, min(255, a)) return 255 def draw(self, surface: pygame.Surface): if not self.lines: return x = int(_ui(["pickup_feed", "x"], 14)) y0 = (HEIGHT // 2) + int(_ui(["pickup_feed", "y_offset_from_center"], -10)) y = y0 for it in self.lines: alpha = self._alpha_for(it["t"]) if alpha <= 0: continue if it.get("kind") == "center": text = it.get("text", "") surf = self.center_font.render(text, True, (240, 240, 240)).convert_alpha() surf.set_alpha(alpha) w = surf.get_width() h = surf.get_height() pad_x = self.pad_x * 2 pad_y = self.pad_y box_w = min(WIDTH - 80, w + pad_x * 2) box_h = h + pad_y * 2 box_x = (WIDTH - box_w) // 2 box_y = int((HEIGHT - box_h) * 0.35) box = pygame.Surface((box_w, box_h), pygame.SRCALPHA) box.fill((0, 0, 0, int(alpha * 0.8))) surface.blit(box, (box_x, box_y)) text_x = box_x + (box_w - w) // 2 text_y = box_y + pad_y surface.blit(surf, (text_x, text_y)) continue if it.get("kind") == "plain": text = it.get("text", "") surf = self.font.render(text, True, (240, 240, 240)).convert_alpha() surf.set_alpha(alpha) w = surf.get_width() h = surf.get_height() box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA) box.fill((0, 0, 0, int(alpha * 0.65))) surface.blit(box, (x, y - self.pad_y)) surface.blit(surf, (x + self.pad_x, y)) y += h + self.line_gap continue if it.get("kind") == "plain_rarity": text = it.get("text", "") rarity = it.get("rarity", "") rarity_col = LOOT_COLORS.get(rarity, (240, 240, 240)) surf_text = self.font.render(text, True, (240, 240, 240)).convert_alpha() surf_tag = self.font.render(str(rarity).title(), True, rarity_col).convert_alpha() for s in (surf_text, surf_tag): s.set_alpha(alpha) w = surf_text.get_width() + surf_tag.get_width() h = max(surf_text.get_height(), surf_tag.get_height()) box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA) box.fill((0, 0, 0, int(alpha * 0.65))) surface.blit(box, (x, y - self.pad_y)) px = x + self.pad_x surface.blit(surf_text, (px, y)) px += surf_text.get_width() surface.blit(surf_tag, (px, y)) y += h + self.line_gap continue name = it["name"] rarity = it["rarity"] left_text = "Picked up " name_text = f"{name} " tag_text = f"[{rarity}]" if rarity else "" # Render parts (so rarity can be colored) surf_left = self.font.render(left_text, True, (240, 240, 240)).convert_alpha() surf_name = self.font.render(name_text, True, (240, 240, 240)).convert_alpha() # rarity colored like inventory rarity_col = LOOT_COLORS.get(rarity, (240, 240, 240)) surf_tag = self.font.render(tag_text, True, rarity_col).convert_alpha() if tag_text else None for s in (surf_left, surf_name, surf_tag): if s is not None: s.set_alpha(alpha) w = surf_left.get_width() + surf_name.get_width() + (surf_tag.get_width() if surf_tag else 0) h = max(surf_left.get_height(), surf_name.get_height(), (surf_tag.get_height() if surf_tag else 0)) # background box per line box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA) box.fill((0, 0, 0, int(alpha * 0.65))) surface.blit(box, (x, y - self.pad_y)) # blit parts px = x + self.pad_x surface.blit(surf_left, (px, y)) px += surf_left.get_width() surface.blit(surf_name, (px, y)) px += surf_name.get_width() if surf_tag: surface.blit(surf_tag, (px, y)) y += h + self.line_gap # ---------------------------- # Player # ---------------------------- class Player: def __init__(self, x, y, sprite_surface=None, shade_sprite_surface=None): self.start_x = int(x) self.start_y = int(y) self.rect = pygame.Rect(int(x), int(y), PLAYER_HITBOX_W, PLAYER_HITBOX_H) self.vel_y = 0.0 self.on_ground = False self.shade = False self.charge = float(BASE_MAX_CHARGE) self.health = int(BASE_MAX_HEALTH) self.charge_regen_mult = 1.0 self.coins_light = 0 self.coins_dark = 0 self.butterfly_jars = 0 # --- abilities --- self.has_ranged = False self.ranged_cd = 0.0 self.reward_chest_opened = False self.has_shatterstep = True self.shatterstep_timer = 0.0 self.shatterstep_cd = 0.0 self.shatterstep_platform_cd = 0.0 self.shatterstep_landing_pending = False self.shatterstep_landing_damage = 0.0 self.prev_vel_y = 0.0 self.fall_start_y = float(self.rect.bottom) self.shatterstep_jump_start_y = 0.0 self.shatterstep_platform_spawned = False self.has_tether = True self.tether_cd = 0.0 self.tether_pending_cd = 0.0 self.has_reality_break = True self.reality_break_flash = 0.0 self.equipment = {"helmet": None, "chest": None, "pants": None, "necklace": None} self.bonus_max_health = 0 self.bonus_max_charge = 0.0 self.bonus_move_speed_pct = 0.0 self.bonus_charge_regen_pct = 0.0 self.bonus_heal_speed_pct = 0.0 self.bonus_heal_plus = 0 # --- combat / misc bonuses (from armor stats + set bonuses) --- self.bonus_attack_speed_pct = 0.0 self.bonus_jump_height_shade_pct = 1.0 self.bonus_negate_damage_chance = 0.0 self.bonus_shade_double_damage_chance = 0.0 self.bonus_shade_damage_flat = 0.0 # --- optional base/test bonuses (ALWAYS applied in addition to gear) --- #self.base_move_speed_shade_pct = 1.0 # <-- test value #self.base_jump_height_shade_pct = 1.0 # <-- test value 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 self.dash_timer = 0.0 self.dash_dir = 1 self.dash_hit_ids = set() self.just_jumped = False self.just_landed = False self.just_air_jumped = False 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 self.invincibility_timer = 0.0 # --- knockback (combat feel) --- self.knockback_vx = 0.0 self.knockback_timer = 0.0 # later driven by armor/set stats (0.0 = no resistance, 0.9 = very resistant) self.bonus_knockback_resistance = 0.0 self.healing = False self.heal_progress = 0.0 self.sprite_raw = sprite_surface self.shade_sprite_raw = shade_sprite_surface self.sprite_right = None self.sprite_left = None self.sprite_right_shade = None self.sprite_left_shade = None self.has_shade_sprite = False self.idle_raw_frames = IDLE_SPRITE_RAW self.idle_right_frames = [] self.idle_left_frames = [] self.idle_right_shade_frames = [] self.idle_left_shade_frames = [] self.idle_frame = 0 self.idle_anim_time = 0.0 self.idle_bob_t = 0.0 self.fairy_bob_t = random.random() * 10.0 self.idle_hold_time = 0.0 self.ground_contact_timer = 0.0 self.is_idle = False self._rebuild_sprites() self.recalc_bonuses() self._clamp_resources() @property def max_health(self) -> int: return int(BASE_MAX_HEALTH + self.bonus_max_health) @property def max_charge(self) -> float: return float(BASE_MAX_CHARGE + self.bonus_max_charge) def heal_charge_cost(self) -> float: return HEAL_COST_FLAT def heal_drain_rate(self) -> float: dur = self.effective_heal_duration() return HEAL_COST_FLAT / dur if dur > 0 else 999999.0 def effective_attack_duration(self) -> float: # 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: return max(0.2, HEAL_DURATION / (1.0 + self.bonus_heal_speed_pct)) def _clamp_resources(self): self.health = max(0, min(self.health, self.max_health)) self.charge = max(0.0, min(self.charge, self.max_charge)) def combined_stats(self) -> dict: out = {} for it in self.equipment.values(): if not it: continue for k, v in it.get("stats", {}).items(): merge_stat_value(out, k, v) return out def recalc_bonuses(self): stats = self.combined_stats() set_stats = self.set_bonus_stats() # merge set stats into total stats for k, v in set_stats.items(): merge_stat_value(stats, k, v) self.bonus_max_health = int(stats.get("health", 0)) self.bonus_max_charge = float(stats.get("charge", 0)) self.bonus_move_speed_pct = float(stats.get("move_speed_pct", 0.0)) self.bonus_move_speed_light_pct = float(stats.get("move_speed_light_pct", 0.0)) self.bonus_move_speed_shade_pct = float(stats.get("move_speed_shade_pct", 0.0)) + float(getattr(self, "base_move_speed_shade_pct", 0.0)) self.bonus_charge_regen_pct = float(stats.get("charge_regen_pct", 0.0)) self.bonus_heal_speed_pct = float(stats.get("heal_speed_pct", 0.0)) self.bonus_heal_plus = int(stats.get("heal_plus", 0)) self.bonus_attack_speed_pct = float(stats.get("attack_speed_pct", 0.0)) self.bonus_jump_height_shade_pct = float(stats.get("jump_height_shade_pct", 0.0)) + float(getattr(self, "base_jump_height_shade_pct", 0.0)) # chances: keep them in [0..1] so bad json can't break stuff self.bonus_negate_damage_chance = max(0.0, min(1.0, float(stats.get("negate_damage_chance", 0.0)))) self.bonus_shade_double_damage_chance = max(0.0, min(1.0, float(stats.get("shade_double_damage_chance", 0.0)))) self.bonus_shade_damage_flat = float(stats.get("shade_damage_flat", 0.0)) # knockback resistance in [0..0.9] (1.0 would feel weird / cancel all force) self.bonus_knockback_resistance = max(0.0, min(0.9, float(stats.get("knockback_resistance", 0.0)))) def equip_swap(self, item_instance: dict) -> dict | None: slot = item_instance.get("slot") if slot not in self.equipment: return None prev = self.equipment.get(slot) self.equipment[slot] = item_instance self.recalc_bonuses() self._clamp_resources() return prev def set_bonus_stats(self) -> dict: """ Activate set bonus when wearing 3 pieces of the same set that are attuned-or-better (tier>=2). If all 3 are awakened -> awakened bonus, otherwise -> attuned bonus. """ pieces_by_set: dict[str, list[dict]] = {} for it in self.equipment.values(): if not it: continue set_id = it.get("set") rarity_id = it.get("rarity") tier = it.get("tier", None) if tier is None: tier = RARITIES.get(rarity_id, {}).get("tier", 1) tier = int(tier) if not set_id or tier < 2: continue pieces_by_set.setdefault(str(set_id), []).append(it) out: dict = {} for set_id, pieces in pieces_by_set.items(): if len(pieces) < 3: continue awakened_count = sum(1 for p in pieces if int(p.get("tier", 1)) >= 3) bonus_tier = "awakened" if awakened_count >= 3 else "attuned" cfg = SET_BONUSES.get(set_id, {}) tier_cfg = cfg.get(bonus_tier, {}) if isinstance(cfg, dict) else {} effects = tier_cfg.get("effects", {}) if isinstance(tier_cfg, dict) else {} if isinstance(effects, dict): for k, v in effects.items(): merge_stat_value(out, k, v) return out def _rebuild_sprites(self): if self.sprite_raw is None: self.sprite_right = None self.sprite_left = None self.sprite_right_shade = None self.sprite_left_shade = None self.idle_right_frames = [] self.idle_left_frames = [] self.idle_right_shade_frames = [] self.idle_left_shade_frames = [] self.has_shade_sprite = False return scale = PLAYER_SPRITE_SCALE w = int(self.sprite_raw.get_width() * scale) h = int(self.sprite_raw.get_height() * scale) scaled = pygame.transform.smoothscale(self.sprite_raw, (w, h)) self.sprite_right = scaled self.sprite_left = pygame.transform.flip(scaled, True, False) if self.shade_sprite_raw is not None: shade = pygame.transform.smoothscale(self.shade_sprite_raw, (w, h)) self.sprite_right_shade = shade self.sprite_left_shade = pygame.transform.flip(shade, True, False) self.has_shade_sprite = True else: shade = scaled.copy() shade.fill((120, 120, 120, 255), special_flags=pygame.BLEND_RGBA_MULT) self.sprite_right_shade = shade self.sprite_left_shade = pygame.transform.flip(shade, True, False) self.has_shade_sprite = False self.idle_right_frames = [] self.idle_left_frames = [] self.idle_right_shade_frames = [] self.idle_left_shade_frames = [] for raw in self.idle_raw_frames: if raw is None: continue fr = pygame.transform.smoothscale(raw, (w, h)) self.idle_right_frames.append(fr) self.idle_left_frames.append(pygame.transform.flip(fr, True, False)) fr_shade = fr.copy() fr_shade.fill((120, 120, 120, 255), special_flags=pygame.BLEND_RGBA_MULT) self.idle_right_shade_frames.append(fr_shade) self.idle_left_shade_frames.append(pygame.transform.flip(fr_shade, True, False)) def _cancel_heal(self): self.healing = False self.heal_progress = 0.0 def is_dashing(self) -> bool: return self.dash_timer > 0.0 def try_dash(self): if self.dash_cd > 0.0: return False if self.healing: self._cancel_heal() if self.attacking: self.attacking = False self.attack_timer = 0.0 if self.shade: if self.health <= DASH_SHADE_HEALTH_COST: return False self.health = max(0, self.health - DASH_SHADE_HEALTH_COST) else: if self.charge < DASH_LIGHT_CHARGE_COST: return False self.charge -= DASH_LIGHT_CHARGE_COST self.charge_regen_lock = max(self.charge_regen_lock, 0.35) self.dash_timer = DASH_DURATION self.dash_cd = DASH_COOLDOWN self.dash_dir = 1 if self.facing_right else -1 self.dash_hit_ids = set() return True def is_shatterstep_active(self) -> bool: return self.shatterstep_timer > 0.0 def try_shatterstep(self): if not self.has_shatterstep: return False if self.shatterstep_timer > 0.0 or self.shatterstep_cd > 0.0: return False self.shatterstep_timer = SHATTERSTEP_DURATION self.shatterstep_cd = 0.0 return True def try_tether(self, tether_list: list): if not self.has_tether: return False if self.tether_cd > 0.0 or self.tether_pending_cd > 0.0: return False direction = 1 if self.facing_right else -1 tether_list.append(TetherShot(self.rect.centerx, self.rect.centery, direction, self)) return True def toggle_shade(self): if not self.shade and self.healing: self._cancel_heal() if not self.shade and self.charge <= 0: return self.shade = not self.shade def attack(self): if self.healing: return 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 self.damage_applied = False # lock regen for a bit (tweak this number to taste) self.charge_regen_lock = max(self.charge_regen_lock, 0.35) def try_ranged_attack(self, projectiles_list: list, enemies_list: list, platforms_list: list): if not self.has_ranged: return if self.healing: return if self.ranged_cd > 0.0: return if self.charge < RANGED_CHARGE_COST: return # ranged costs more than melee self.charge -= RANGED_CHARGE_COST # 50% of melee attack damage (supports 0.5 now because enemy health is float) melee_dmg = 2.0 if self.shade else 1.0 dmg = melee_dmg * RANGED_DAMAGE_MULT # spawn from roughly chest height, slightly in front px = self.rect.centerx + (16 if self.facing_right else -16) py = self.rect.centery - 6 direction = 1 if self.facing_right else -1 projectiles_list.append(Projectile(px, py, direction, dmg)) # cooldown + small regen lock so it doesn't instantly refill self.ranged_cd = RANGED_COOLDOWN self.charge_regen_lock = max(self.charge_regen_lock, 0.75) def get_attack_rect(self): """Legacy single-rect hitbox (kept for older callers).""" boxes = self.get_attack_hitboxes() return boxes[0] if boxes else None def get_attack_hitboxes(self): """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 (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 - 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 + body_overlap), int(h1)) far = pygame.Rect(int(x2), int(y2), int(w2), int(h2)) return [near, far] def take_damage(self, amount) -> bool: """Apply damage if not invincible. Returns True if damage was actually applied (useful for knockback). """ if self.invincibility_timer > 0: return False # chance to negate incoming damage (works in both forms) if self.bonus_negate_damage_chance > 0.0: if random.random() < self.bonus_negate_damage_chance: # optional: tiny "i-frames" so rapid multi-hit doesn't spam-roll self.invincibility_timer = 0.10 return False self.health -= int(amount) self.invincibility_timer = INVINCIBILITY_DURATION if self.healing: self._cancel_heal() self._clamp_resources() 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, pop_up_vel: float = -3.5): """Knock the player away from a hit source. strength_px_per_frame is tuned for your current movement code (dx is per-frame-ish). Resistance reduces strength (0.0 = none, 0.9 = strong resistance). """ # Push away from the source dir_sign = 1.0 if self.rect.centerx >= source_rect.centerx else -1.0 resist = max(0.0, min(0.9, float(getattr(self, "bonus_knockback_resistance", 0.0)))) strength = strength_px_per_frame * (1.0 - resist) # Apply / refresh knockback self.knockback_vx = dir_sign * strength self.knockback_timer = max(self.knockback_timer, duration) # Small upward pop if grounded so it's visually readable if self.on_ground and pop_up_vel is not None: self.vel_y = min(self.vel_y, float(pop_up_vel)) self.on_ground = False def respawn(self, spawn_x=None, spawn_y=None): if spawn_x is None or spawn_y is None: spawn_x, spawn_y = self.start_x, self.start_y else: self.start_x, self.start_y = int(spawn_x), int(spawn_y) self.rect.midbottom = (int(spawn_x), int(spawn_y)) self.vel_y = 0.0 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() self.health = self.max_health self.charge = self.max_charge def consume_butterfly_jar(self) -> bool: """Consume one butterfly jar to instantly refill charge. Returns True if consumed.""" if self.butterfly_jars <= 0: return False self.butterfly_jars -= 1 self.charge = self.max_charge self.charge_regen_lock = 0.0 return True def _start_heal_if_possible(self, keys): if self.shade: return if not key_pressed(keys, "heal"): return if self.healing: return if self.health >= self.max_health: return if self.charge < self.heal_charge_cost(): return self.healing = True self.heal_progress = 0.0 self.attacking = False self.attack_timer = 0.0 def _update_heal(self, keys, dt): if not self.healing: return if not key_pressed(keys, "heal"): self._cancel_heal() return drain = self.heal_drain_rate() * dt self.charge = max(0.0, self.charge - drain) self.heal_progress += dt dur = self.effective_heal_duration() if self.charge <= 0.0 and self.heal_progress < dur: self._cancel_heal() return dur = self.effective_heal_duration() if self.heal_progress >= dur: gain = 1 + self.bonus_heal_plus self.health = min(self.max_health, self.health + gain) self._cancel_heal() def update(self, keys, platforms, dt): prev_on_ground = self.on_ground self.just_jumped = False self.just_landed = False self.just_air_jumped = False self.fairy_bob_t += dt if self.invincibility_timer > 0: self.invincibility_timer -= dt if self.charge_regen_lock > 0: self.charge_regen_lock = max(0.0, self.charge_regen_lock - dt) if self.dash_cd > 0.0: self.dash_cd = max(0.0, self.dash_cd - dt) if self.dash_timer > 0.0: self.dash_timer = max(0.0, self.dash_timer - dt) if self.ranged_cd > 0.0: self.ranged_cd = max(0.0, self.ranged_cd - dt) if self.shatterstep_timer > 0.0: self.shatterstep_timer = max(0.0, self.shatterstep_timer - dt) if self.shatterstep_timer == 0.0 and self.shatterstep_cd <= 0.0: self.shatterstep_cd = SHATTERSTEP_COOLDOWN if self.shatterstep_cd > 0.0: self.shatterstep_cd = max(0.0, self.shatterstep_cd - dt) if self.shatterstep_platform_cd > 0.0: self.shatterstep_platform_cd = max(0.0, self.shatterstep_platform_cd - dt) if self.tether_pending_cd > 0.0: self.tether_pending_cd = max(0.0, self.tether_pending_cd - dt) if self.tether_pending_cd == 0.0 and self.tether_cd <= 0.0: self.tether_cd = TETHER_COOLDOWN if self.tether_cd > 0.0: self.tether_cd = max(0.0, self.tether_cd - dt) if self.reality_break_flash > 0.0: self.reality_break_flash = max(0.0, self.reality_break_flash - dt) if key_pressed(keys, "move_left"): self.facing_right = False elif key_pressed(keys, "move_right"): self.facing_right = True speed = PLAYER_SHADE_SPEED if self.shade else PLAYER_SPEED form_bonus = self.bonus_move_speed_shade_pct if self.shade else self.bonus_move_speed_light_pct speed *= (1.0 + self.bonus_move_speed_pct + form_bonus) if self.healing: speed *= HEAL_SLOW_MULT right = 1 if key_pressed(keys, "move_right") else 0 left = 1 if key_pressed(keys, "move_left") else 0 input_dx = (right - left) * speed if self.is_dashing(): input_dx = self.dash_dir * DASH_SPEED # --- knockback integration --- if self.knockback_timer > 0.0: self.knockback_timer = max(0.0, self.knockback_timer - dt) # decay knockback smoothly decay = 0.85 ** (dt * 60.0) self.knockback_vx *= decay # reduce player control while being knocked back dx = (input_dx * 0.25) + self.knockback_vx else: self.knockback_vx = 0.0 dx = input_dx if (not self.is_dashing()) and key_pressed(keys, "jump") and self.on_ground: if self.shade: # more jump height => more negative jump velocity (higher jump) self.vel_y = JUMP_SHADE * (1.0 + self.bonus_jump_height_shade_pct) if self.is_shatterstep_active(): self.shatterstep_landing_pending = True self.shatterstep_landing_damage = 1.5 * 2.0 else: self.vel_y = JUMP_LIGHT if self.is_shatterstep_active(): self.shatterstep_landing_pending = False self.shatterstep_landing_damage = 0.0 self.on_ground = False self.just_jumped = True self.just_air_jumped = False self.shatterstep_jump_start_y = float(self.rect.bottom) self.shatterstep_platform_spawned = False gravity = GRAVITY_SHADE if self.shade else GRAVITY_LIGHT self.vel_y += gravity dy = self.vel_y # Horizontal collision self.rect.x += int(dx) for p in platforms: if self.rect.colliderect(p): if dx > 0: self.rect.right = p.left elif dx < 0: self.rect.left = p.right # Vertical collision self.rect.y += int(dy) self.on_ground = False for p in platforms: if self.rect.colliderect(p): if dy > 0: self.rect.bottom = p.top self.vel_y = 0 self.on_ground = True elif dy < 0: self.rect.top = p.bottom self.vel_y = 0 if prev_on_ground and (not self.on_ground): self.fall_start_y = float(self.rect.bottom) if (not prev_on_ground) and self.on_ground: self.just_landed = True self.shatterstep_platform_spawned = False fall_dist = float(self.rect.bottom) - float(self.fall_start_y) if self.shade and self.is_shatterstep_active() and fall_dist >= 20.0: self.shatterstep_landing_pending = True self.shatterstep_landing_damage = 1.5 * 2.0 self.prev_vel_y = dy if self.on_ground: self.ground_contact_timer = 0.08 else: self.ground_contact_timer = max(0.0, self.ground_contact_timer - dt) self.is_idle = (abs(input_dx) < 0.01) and (self.ground_contact_timer > 0.0) and (not self.attacking) and (not self.healing) and (not self.is_dashing()) if self.idle_right_frames: if self.is_idle: self.idle_anim_time += dt self.idle_bob_t += dt * PLAYER_IDLE_BOB_SPEED self.idle_hold_time += dt if self.idle_hold_time >= PLAYER_IDLE_START_DELAY: if self.idle_anim_time >= PLAYER_IDLE_FRAME_TIME: self.idle_anim_time -= PLAYER_IDLE_FRAME_TIME self.idle_frame = (self.idle_frame + 1) % len(self.idle_right_frames) else: self.idle_anim_time = 0.0 self.idle_frame = 0 self.idle_bob_t = 0.0 self.idle_hold_time = 0.0 self._start_heal_if_possible(keys) self._update_heal(keys, dt) if self.shade: self.charge -= CHARGE_DRAIN_RATE * getattr(self, "charge_drain_mult", 1.0) * dt if self.charge <= 0: self.charge = 0 self.shade = False else: # no regen while healing, attacking, or during post-attack lockout if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0): regen = CHARGE_REFILL_RATE * (1.0 + self.bonus_charge_regen_pct) * self.charge_regen_mult 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() def _draw_fairy(self, surface, cx, cy, px, py, pw, ph): """Draw the fairy companion hovering behind/beside the player's head.""" img = FAIRY_SPRITE if self.facing_right else FAIRY_SPRITE_FLIPPED if img is None or pw <= 0 or ph <= 0: return fw, fh = img.get_size() # Trail behind the player (opposite the facing direction) at head height. trail = max(1, (pw + fw) // 3) off = -trail if self.facing_right else trail fx = px + (pw - fw) // 2 + off fairy_cy = py + int(ph * 0.18) bob = int(math.sin(self.fairy_bob_t * 2.2) * 3) surface.blit(img, (fx, fairy_cy - fh // 2 + bob)) def draw(self, surface, cx, cy): if self.healing: dur = self.effective_heal_duration() t = max(0.0, min(1.0, self.heal_progress / dur)) col = ( int(HEAL_DARK[0] + (HEAL_LIGHT[0] - HEAL_DARK[0]) * t), int(HEAL_DARK[1] + (HEAL_LIGHT[1] - HEAL_DARK[1]) * t), int(HEAL_DARK[2] + (HEAL_LIGHT[2] - HEAL_DARK[2]) * t), ) outline = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height).inflate(14, 14) pygame.draw.rect(surface, col, outline, 3) if self.sprite_right is None: player_color = PLAYER_HIT_COLOR if self.invincibility_timer > 0 else (PLAYER_DARK if self.shade else PLAYER_LIGHT) pr = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) self._draw_fairy(surface, cx, cy, self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) pygame.draw.rect(surface, player_color, pr) else: use_idle = self.is_idle and self.idle_right_frames and (self.idle_hold_time >= PLAYER_IDLE_START_DELAY) if self.shade: if use_idle and (not self.has_shade_sprite): frames = self.idle_right_shade_frames if self.facing_right else self.idle_left_shade_frames img = frames[self.idle_frame % len(frames)] else: img = self.sprite_right_shade if self.facing_right else self.sprite_left_shade else: if use_idle: frames = self.idle_right_frames if self.facing_right else self.idle_left_frames img = frames[self.idle_frame % len(frames)] else: img = self.sprite_right if self.facing_right else self.sprite_left if self.invincibility_timer > 0: outline = pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) pygame.draw.rect(surface, PLAYER_HIT_COLOR, outline, 3) draw_x = self.rect.x - cx - (img.get_width() - self.rect.width) // 2 + PLAYER_SPRITE_OFFSET_X draw_y = self.rect.y - cy - (img.get_height() - self.rect.height) + PLAYER_SPRITE_OFFSET_Y if self.is_idle and (not self.shade or not self.has_shade_sprite): draw_y -= int(math.sin(self.idle_bob_t) * PLAYER_IDLE_BOB_AMP) self._draw_fairy(surface, cx, cy, draw_x, draw_y, img.get_width(), img.get_height()) surface.blit(img, (draw_x, draw_y)) # Debug: show player hitbox pygame.draw.rect( surface, (0, 200, 255), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), 1, ) if self.attacking: for atk in self.get_attack_hitboxes(): pygame.draw.rect(surface, (255, 0, 0), pygame.Rect(atk.x - cx, atk.y - cy, atk.width, atk.height)) # ---------------------------- # Enemy (configured via JSON) + Boss slam # ---------------------------- class Enemy: def __init__(self, x, y, type_id: str): self.type_id = type_id cfg = ENEMY_TYPES.get(type_id, ENEMY_TYPES.get("enemy", {})) if not isinstance(cfg, dict): cfg = {} w, h = cfg.get("rect", [40, 60]) self.rect = pygame.Rect(int(x), int(y), int(w), int(h)) self.start_x = int(x) self.start_y = int(y) self.vel_x = 0.0 self.vel_y = 0.0 self.max_health = float(cfg.get("health", 3)) self.health = float(self.max_health) self.hit_timer = 0.0 self.stun_timer = 0.0 self.bind_timer = 0.0 self.fear_timer = 0.0 self.slow_timer = 0.0 self.slow_mult = 1.0 self.bound_light = True self.reality_break_glow = False self.on_ground = False self.ground_platform = None self.speed = float(cfg.get("speed", 2.2)) self.air_speed = float(cfg.get("air_speed", 2.0)) self.direction = -1 if random.random() < 0.5 else 1 self.detection_range = float(cfg.get("detection_range", 240)) self.jump_strength = -12.0 self.jump_cooldown = 0.55 self.jump_cd_timer = 0.0 self.jump_when_player_above = 35 self.max_step_up = 160 self.max_step_forward = 120 self.contact_damage = int(cfg.get("contact_damage", 1)) self.drop_profile = str(cfg.get("drop_profile", "normal")) # Boss slam/shockwave tuning (supports per-enemy overrides) self.slam_cooldown_base = float(BOSS_SLAM_COOLDOWN) self.slam_trigger_close = float(BOSS_TRIGGER_CLOSE) self.slam_trigger_far = float(BOSS_TRIGGER_FAR) self.slam_jump_vel = float(BOSS_JUMP_VEL) self.shockwave_speed = float(SHOCKWAVE_SPEED) self.shockwave_lifetime = float(SHOCKWAVE_LIFETIME) self.shockwave_height = int(SHOCKWAVE_HEIGHT) self.shockwave_damage = int(SHOCKWAVE_DAMAGE) boss_cfg = cfg.get("boss_shockwave", cfg.get("slam", {})) if isinstance(cfg, dict) else {} if isinstance(boss_cfg, dict): self.slam_cooldown_base = float(boss_cfg.get("slam_cooldown", self.slam_cooldown_base)) self.slam_trigger_close = float(boss_cfg.get("trigger_close", self.slam_trigger_close)) self.slam_trigger_far = float(boss_cfg.get("trigger_far", self.slam_trigger_far)) self.slam_jump_vel = float(boss_cfg.get("jump_vel", self.slam_jump_vel)) self.shockwave_speed = float(boss_cfg.get("shockwave_speed", self.shockwave_speed)) self.shockwave_lifetime = float(boss_cfg.get("shockwave_lifetime", self.shockwave_lifetime)) self.shockwave_height = int(boss_cfg.get("shockwave_height", self.shockwave_height)) self.shockwave_damage = int(boss_cfg.get("shockwave_damage", self.shockwave_damage)) # Boss slam state self.slam_cd = 0.0 self.slam_primed = False self.bind_timer = 0.0 self.fear_timer = 0.0 self.slow_timer = 0.0 self.slow_mult = 1.0 self.bound_light = True self.reality_break_glow = False # --- anti-jiggle helpers --- self.turn_cooldown = 0.0 # prevents rapid flip-flop at edges/walls self.last_x = self.rect.x self.stuck_timer = 0.0 # (reserved) detects "want to move but can't" # --- aggro break drop grace (prevents snap-back) --- self.was_chasing = False self.last_chase_dir = self.direction self.aggro_drop_timer = 0.0 # When aggro breaks near an edge: keep walking off naturally, then fall self.stepoff_timer = 0.0 self.stepoff_dir = self.direction # --- drop commit (prevents "teleport snap" on the platform we just left) --- self.drop_ignore_timer = 0.0 self.drop_ignore_platform = None def take_damage(self, dmg): self.health = max(0.0, float(self.health) - float(dmg)) self.hit_timer = 0.1 def apply_stun(self, duration: float): self.stun_timer = max(self.stun_timer, float(duration)) def respawn(self): self.rect.topleft = (self.start_x, self.start_y) self.vel_x = 0.0 self.vel_y = 0.0 cfg = ENEMY_TYPES.get(self.type_id, ENEMY_TYPES.get("enemy", {})) self.max_health = float(cfg.get("health", 3)) if isinstance(cfg, dict) else 3.0 self.health = float(self.max_health) self.direction = 1 self.on_ground = False self.ground_platform = None self.jump_cd_timer = 0.0 self.slam_cd = 0.0 self.slam_primed = False self.turn_cooldown = 0.0 self.last_x = self.rect.x self.stuck_timer = 0.0 def get_support_platform(self, platforms): """Return the platform directly under the enemy's feet (or None).""" probe = self.rect.move(0, 1) # 1px below feet best = None best_top = None for p in platforms: if probe.colliderect(p): if best is None or p.top < best_top: best = p best_top = p.top return best def ground_ahead(self, direction, lookahead): """Check for ground ahead using the CURRENT support platform only.""" p = self.ground_platform if p is None: return False if direction > 0: x = self.rect.right + lookahead return x <= p.right else: x = self.rect.left - lookahead return x >= p.left def _probe_ground_now(self, platforms): """Refresh on_ground + ground_platform every frame (stable).""" p = self.get_support_platform(platforms) if p is None: self.on_ground = False self.ground_platform = None return # Snap to the platform top only if we're basically landing / resting on it if self.rect.bottom > p.top: self.rect.bottom = p.top self.on_ground = True self.ground_platform = p if self.vel_y > 0: self.vel_y = 0.0 def reachable_platform_ahead(self, platforms): enemy_feet_y = self.rect.bottom if self.direction > 0: ahead_min_x = self.rect.right ahead_max_x = self.rect.right + self.max_step_forward else: ahead_min_x = self.rect.left - self.max_step_forward ahead_max_x = self.rect.left for p in platforms: if not (enemy_feet_y - self.max_step_up <= p.top < enemy_feet_y): continue if p.right < ahead_min_x or p.left > ahead_max_x: continue if self.rect.right < p.left - 30 or self.rect.left > p.right + 30: continue return True return False def platform_ahead_for_jump(self, platforms): """Return a reachable platform across a small gap (no downward jumps).""" gp = self.ground_platform if gp is None: return None max_drop = 8 # allow tiny downward variance only best = None best_gap = None for p in platforms: if p is gp: continue if p.top < gp.top - self.max_step_up: continue if p.top > gp.top + max_drop: continue if self.direction > 0: gap = p.left - gp.right if gap < 0 or gap > self.max_step_forward: continue else: gap = gp.left - p.right if gap < 0 or gap > self.max_step_forward: continue if best_gap is None or gap < best_gap: best = p best_gap = gap return best def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave]): if self.jump_cd_timer > 0: self.jump_cd_timer -= dt if self.turn_cooldown > 0: self.turn_cooldown -= dt if self.aggro_drop_timer > 0.0: self.aggro_drop_timer = max(0.0, self.aggro_drop_timer - dt) if self.stepoff_timer > 0.0: self.stepoff_timer = max(0.0, self.stepoff_timer - dt) if self.bind_timer > 0.0: self.bind_timer = max(0.0, self.bind_timer - dt) if self.fear_timer > 0.0: self.fear_timer = max(0.0, self.fear_timer - dt) if self.slow_timer > 0.0: self.slow_timer = max(0.0, self.slow_timer - dt) if self.slow_timer == 0.0: self.slow_mult = 1.0 if self.drop_ignore_timer > 0.0: self.drop_ignore_timer = max(0.0, self.drop_ignore_timer - dt) if self.drop_ignore_timer <= 0.0: self.drop_ignore_platform = None # Boss slam cooldown tick if self.type_id == "boss" and self.slam_cd > 0: self.slam_cd -= dt dx = player.rect.centerx - self.rect.centerx dy = player.rect.centery - self.rect.centery distance = math.hypot(dx, dy) chasing = (not player.shade) and (distance <= self.detection_range) feared = self.fear_timer > 0.0 if feared: chasing = False self.direction = -1 if dx > 0 else 1 if chasing: self.direction = 1 if dx > 0 else -1 self.last_chase_dir = self.direction # remember chase intent # aggro just broke (shade OR distance) if self.was_chasing and (not chasing): self.aggro_drop_timer = 0.30 # Only do "step off" behavior if we're ALREADY over the edge at all. if self.on_ground: gp = self.get_support_platform(platforms) if gp is not None: off_edge = (self.rect.left < gp.left) or (self.rect.right > gp.right) if off_edge: self.stepoff_timer = 0.25 self.stepoff_dir = 1 if self.last_chase_dir >= 0 else -1 # IMPORTANT: ignore this platform only for horizontal "teleport shove" self.drop_ignore_platform = gp self.drop_ignore_timer = max(self.drop_ignore_timer, 0.35) if self.hit_timer > 0: self.hit_timer -= dt stunned = False if self.stun_timer > 0.0: self.stun_timer = max(0.0, self.stun_timer - dt) stunned = True # Trigger boss slam: close OR far if (not stunned) and self.type_id == "boss" and (not player.shade): dist = abs(distance) should_slam = (dist <= self.slam_trigger_close) or (dist >= self.slam_trigger_far) if self.on_ground and self.slam_cd <= 0 and should_slam and (not self.slam_primed): self.vel_y = self.slam_jump_vel self.on_ground = False self.ground_platform = None self.slam_primed = True self.slam_cd = self.slam_cooldown_base # gravity + vertical move self.vel_y += 0.5 # clamp fall speed to avoid insane integers if something goes wrong / long fall if self.vel_y > 24.0: self.vel_y = 24.0 prev_bottom = self.rect.bottom prev_top = self.rect.top self.rect.y += int(self.vel_y) # safety: if enemy falls into the void, respawn them # (prevents runaway y values and removes "fell forever" weirdness) if self.rect.top > (ROOM.kill_y + 400): self.respawn() return # IMPORTANT: track landing transition was_on_ground = self.on_ground self.on_ground = False self.ground_platform = None # vertical collision resolve # IMPORTANT: Only "land" on the platform that is actually under our feet. if self.vel_y > 0: gp = self.get_support_platform(platforms) if gp is not None: # Only count as landing if we were above the surface last frame if prev_bottom <= gp.top + 1 and self.rect.bottom >= gp.top: self.rect.bottom = gp.top self.vel_y = 0 self.on_ground = True self.ground_platform = gp # Head bonk (still use colliders, but only if coming from below) elif self.vel_y < 0: for p in platforms: if not self.rect.colliderect(p): continue if prev_top >= p.bottom - 1: self.rect.top = p.bottom self.vel_y = 0 break # --- SUPPORT CHECK (edge handling) --- # In stepoff mode: stay grounded as long as ANY overlap with the platform remains. # Otherwise: use your foot-probe logic (helps patrol behave nicely). if self.on_ground and (self.ground_platform is not None): gp = self.ground_platform if abs(self.rect.bottom - gp.top) <= 1: if getattr(self, "stepoff_timer", 0.0) > 0.0: # overlap in X between enemy and platform overlap_w = min(self.rect.right, gp.right) - max(self.rect.left, gp.left) if overlap_w > 0: # still supported -> keep glued to top self.rect.bottom = gp.top self.vel_y = 0.0 else: # fully off -> now fall self.on_ground = False self.ground_platform = None # do NOT shove down; let gravity take it naturally # self.vel_y stays as-is else: # Patrol: keep grounded as long as any overlap remains. if not chasing: overlap_w = min(self.rect.right, gp.right) - max(self.rect.left, gp.left) if overlap_w > 0: self.rect.bottom = gp.top self.vel_y = 0.0 else: # Clamp back to the nearest edge and turn around. if self.rect.centerx >= gp.right: self.rect.right = gp.right self.direction = -1 elif self.rect.centerx <= gp.left: self.rect.left = gp.left self.direction = 1 self.rect.bottom = gp.top self.vel_y = 0.0 # keep on_ground in patrol self.on_ground = True self.ground_platform = gp else: probe_y = self.rect.bottom + 1 foot_inset = max(10, int(self.rect.width * 0.35)) left_x = self.rect.left + foot_inset right_x = self.rect.right - foot_inset left_supported = gp.collidepoint(left_x, probe_y) right_supported = gp.collidepoint(right_x, probe_y) if (not left_supported) and (not right_supported): self.on_ground = False self.ground_platform = None # no forced downward shove here either # --- HARD ledge prevention (patrol only) --- # Don't clamp during aggro-break grace OR while we're intentionally stepping off a platform. if (not chasing) and (self.aggro_drop_timer <= 0.0) and (self.drop_ignore_timer <= 0.0) and self.on_ground and self.ground_platform is not None: gp = self.ground_platform # How much of the enemy is still over the platform (X overlap) overlap_w = min(self.rect.right, gp.right) - max(self.rect.left, gp.left) overlap_w = max(0, overlap_w) # During "aggro just broke", allow the drop if they're already significantly off. # Bigger threshold => drops sooner (more forgiving). commit_overlap = self.rect.width - 1 if (self.aggro_drop_timer > 0.0) and (overlap_w < self.rect.width - 1): self.on_ground = False self.ground_platform = None self.vel_y = max(self.vel_y, 2.0) self.aggro_drop_timer = 0.0 else: # Normal patrol behavior: never drop, just clamp/flip at bounds. if self.rect.left < gp.left: self.rect.left = gp.left self.direction = 1 elif self.rect.right > gp.right: self.rect.right = gp.right self.direction = -1 # if boss just landed after primed slam -> spawn shockwaves if self.type_id == "boss" and self.on_ground and (not was_on_ground) and self.slam_primed: self.slam_primed = False spawn_boss_shockwave(self, shockwaves_list) if stunned: self.vel_x = 0.0 self.was_chasing = False return # # --- prevent overlap jiggle with the player --- # if self.rect.colliderect(player.rect): # inter = self.rect.clip(player.rect) # if inter.width > 0: # if self.rect.centerx < player.rect.centerx: # self.rect.x -= inter.width # else: # self.rect.x += inter.width # self.vel_x = 0.0 # # tiny cooldown so we don't instantly flip-flop due to edge logic # self.turn_cooldown = max(self.turn_cooldown, 0.08) desired_speed = self.speed if self.on_ground else self.air_speed if self.slow_timer > 0.0: desired_speed *= self.slow_mult if feared: desired_speed *= TETHER_FEAR_SPEED_MULT # --- PATROL / CHASE INTENT --- if chasing: self.direction = 1 if player.rect.centerx > self.rect.centerx else -1 elif self.stepoff_timer > 0.0: # aggro just broke near an edge: keep walking off naturally self.direction = self.stepoff_dir self.vel_x = self.direction * desired_speed # --- CHASE LEDGE DROP --- # If we're chasing and the player is below, step off the platform instead of edge-wiggling. jump_target = None if chasing and self.on_ground and self.ground_platform is not None: gp = self.ground_platform # IMPORTANT: define this right here so it's ALWAYS available player_below = (player.rect.top > self.rect.bottom + 6) # are we at the edge we're trying to walk past? look = 6 at_edge = ( (self.direction > 0 and self.rect.right + look >= gp.right) or (self.direction < 0 and self.rect.left - look <= gp.left) ) # Jump small gaps if a safe platform exists on the other side. if at_edge and (not player_below) and self.jump_cd_timer <= 0: jump_target = self.platform_ahead_for_jump(platforms) if jump_target is not None: self.vel_y = self.jump_strength self.on_ground = False self.ground_platform = None self.jump_cd_timer = self.jump_cooldown if player_below and at_edge: # cleanly step off + fall # ignore THIS platform for a short time so we don't "snap" back into it self.drop_ignore_platform = gp self.drop_ignore_timer = max(self.drop_ignore_timer, 0.35) self.on_ground = False self.ground_platform = None self.vel_y = max(self.vel_y, 2.0) self.turn_cooldown = max(self.turn_cooldown, 0.15) # --- apply horizontal movement --- old_x = self.rect.x self.rect.x += int(self.vel_x) blocked_by_wall = False for p in platforms: # If we're standing on this platform, don't treat it as a horizontal wall. if self.on_ground and (self.ground_platform is p): continue if (not self.on_ground) and (self.drop_ignore_timer > 0.0) and (self.drop_ignore_platform is p): continue if self.rect.colliderect(p): blocked_by_wall = True if self.vel_x > 0: self.rect.right = p.left elif self.vel_x < 0: self.rect.left = p.right break if blocked_by_wall and self.on_ground: if chasing: # chasing: stop and don't vibrate into the wall self.rect.x = old_x self.vel_x = 0.0 else: # patrolling: turn around and keep moving if self.turn_cooldown <= 0.0: self.direction *= -1 self.turn_cooldown = 0.25 # back off a pixel so we don't instantly collide again self.rect.x = old_x + int(self.direction * 2) self.vel_x = self.direction * desired_speed # regular enemy jump helper if self.on_ground and chasing and self.jump_cd_timer <= 0: player_above = player.rect.centery < self.rect.centery - self.jump_when_player_above if player_above and (blocked_by_wall or self.reachable_platform_ahead(platforms)): self.vel_y = self.jump_strength self.on_ground = False self.ground_platform = None self.jump_cd_timer = self.jump_cooldown # track movement for potential future stuck logic / debugging self.last_x = self.rect.x # remember chase state for next frame self.was_chasing = chasing # if self.hit_timer > 0: # self.hit_timer -= dt def draw(self, surface, cx, cy): if self.type_id == "boss": color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR elif self.type_id == "miniboss": color = MINIBOSS_HIT_COLOR if self.hit_timer > 0 else MINIBOSS_COLOR else: color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR pygame.draw.rect( surface, color, pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) ) if self.reality_break_glow: pygame.draw.rect( surface, (240, 210, 90), pygame.Rect(self.rect.x - cx - 2, self.rect.y - cy - 2, self.rect.width + 4, self.rect.height + 4), 2, ) if self.bind_timer > 0.0: _draw_tether_chain_loop(surface, self.rect, light=self.bound_light, cx=cx, cy=cy) def resolve_enemy_collisions(enemies): # for i in range(len(enemies)): # for j in range(i + 1, len(enemies)): # a = enemies[i] # b = enemies[j] # if not a.rect.colliderect(b.rect): # continue # inter = a.rect.clip(b.rect) # if inter.width == 0 or inter.height == 0: # continue # if inter.width < inter.height: # if a.rect.centerx < b.rect.centerx: # a.rect.x -= inter.width // 2 # b.rect.x += inter.width - inter.width // 2 # else: # a.rect.x += inter.width // 2 # b.rect.x -= inter.width - inter.width // 2 # else: # if a.rect.centery < b.rect.centery: # a.rect.y -= inter.height // 2 # b.rect.y += inter.height - inter.height // 2 # a.vel_y = min(a.vel_y, 0) # else: # a.rect.y += inter.height // 2 # b.rect.y -= inter.height - inter.height // 2 # b.vel_y = min(b.vel_y, 0) return # ---------------------------- # Spawn enemies from room JSON # ---------------------------- def spawn_enemies_from_room(room: Room): out = [] for ent in room.enemies: if not isinstance(ent, dict): continue t = str(ent.get("type", "enemy")) if t == "boss" and is_boss_defeated(room.id): continue pos = ent.get("pos", None) if not (isinstance(pos, (list, tuple)) and len(pos) == 2): if "x" in ent and "y" in ent: pos = [ent.get("x", 0), ent.get("y", 0)] else: continue cfg = ENEMY_TYPES.get(t, ENEMY_TYPES.get("enemy", {})) if not isinstance(cfg, dict): cfg = {} w, h = cfg.get("rect", [40, 60]) x0 = int(pos[0] - (w / 2)) y0 = int(pos[1] - (h / 2)) enemy = Enemy(x0, y0, t) enemy.layer = int(ent.get("layer", 0)) out.append(enemy) return out def spawn_npcs_from_room(room: Room): out = [] for ent in room.npcs: if not isinstance(ent, dict): continue t = str(ent.get("type", "vendor")) pos = ent.get("pos", None) if not (isinstance(pos, (list, tuple)) and len(pos) == 2): if "x" in ent and "y" in ent: pos = [ent.get("x", 0), ent.get("y", 0)] else: continue trade_cost = ent.get("trade_cost", None) if trade_cost is None: trade_cost = _npc(["defaults", t, "trade_cost"], NPC_TRADE_COST) offers = ent.get("offers", None) if offers is None: offers = _npc(["defaults", t, "offers"], []) greet_light = ent.get("greet_light", None) if greet_light is None: greet_light = _npc(["defaults", t, "greet_light"], None) greet_shade = ent.get("greet_shade", None) if greet_shade is None: greet_shade = _npc(["defaults", t, "greet_shade"], None) greet_hint = ent.get("greet_hint", None) if greet_hint is None: greet_hint = _npc(["defaults", t, "greet_hint"], None) out.append(NPC(pos[0], pos[1], t, trade_cost=trade_cost, offers=offers, greet_light=greet_light, greet_shade=greet_shade, greet_hint=greet_hint, layer=int(ent.get("layer", 0)))) return out # ---------------------------- # Coin drop helpers # ---------------------------- def spawn_coins(kind: str, count: int, x: int, y: int): count = scale_count(int(count), ROOM_COIN_MULT) for _ in range(int(count)): ox = random.randint(-10, 10) oy = random.randint(-6, 6) coin_drops.append(CoinPickup(x + ox, y + oy, kind)) def _roll_weighted_count(weights, default_weights) -> int: if not isinstance(weights, list) or not weights: weights = default_weights total = float(sum(weights)) if total <= 0: return 0 r = random.random() * total acc = 0.0 for i, w in enumerate(weights): acc += float(w) if r < acc: return i return len(weights) - 1 def roll_light(kind: str, default_min: int, default_max: int) -> int: min_v = int(_coin_drop([kind, "light", "min"], default_min)) max_v = int(_coin_drop([kind, "light", "max"], default_max)) if max_v < min_v: max_v = min_v return random.randint(min_v, max_v) def roll_dark(kind: str, default_weights: list[float]) -> int: weights = _coin_drop([kind, "dark", "weights"], default_weights) return _roll_weighted_count(weights, default_weights) def roll_normal_light() -> int: return roll_light("enemy", 1, 3) def roll_miniboss_light() -> int: return roll_light("miniboss", 3, 5) def roll_boss_light() -> int: return roll_light("boss", 5, 10) def roll_normal_dark() -> int: weights = _coin_drop(["enemy", "dark_shade", "weights"], [0.5, 0.5]) return _roll_weighted_count(weights, [0.5, 0.5]) def roll_miniboss_dark() -> int: weights = _coin_drop(["miniboss", "dark_shade", "weights"], [0.34, 0.33, 0.33]) return _roll_weighted_count(weights, [0.34, 0.33, 0.33]) def roll_boss_dark() -> int: return roll_dark("boss", [0.55, 0.25, 0.15, 0.05]) # ---------------------------- # Debug overlay # ---------------------------- def draw_pause_controls(surface, x, y): lines = [ f"{key_name(key_for('move_left'))}/{key_name(key_for('move_right'))} = move", f"{key_name(key_for('jump'))} = jump", f"{key_name(key_for('attack'))} = attack", f"{key_name(key_for('ranged'))} = ranged", f"{key_name(key_for('dash'))} = dash", f"{key_name(key_for('shatterstep'))} = shatterstep", f"{key_name(key_for('tether'))} = tether", f"{key_name(key_for('reality_break'))} = reality break", f"{key_name(key_for('toggle_shade'))} = toggle shade", f"Hold {key_name(key_for('heal'))} = heal", f"{key_name(key_for('inventory'))} = inventory", f"{key_name(key_for('interact'))} = interact / trade", f"{key_name(key_for('use_butterfly_jar'))} = butterfly jar", f"{key_name(key_for('pause'))} = pause", "Click Close Game to exit", ] line_h = 18 # Keep the whole list on screen: nudge upward if the requested y would overflow. bottom = HEIGHT - 8 if y + len(lines) * line_h > bottom: y = max(8, bottom - len(lines) * line_h) for s in lines: surface.blit(font.render(s, True, (220, 220, 220)), (x, y)) y += line_h def get_pause_close_button_rect() -> pygame.Rect: w, h = 180, 36 x = WIDTH // 2 - w // 2 y = HEIGHT // 2 + 24 return pygame.Rect(x, y, w, h) def draw_speech_bubble(surface, text: str, center_x: int, bottom_y: int, hint_text: str = ""): surf = font.render(text, True, (240, 240, 240)).convert_alpha() pad_x = 8 pad_y = 6 w = surf.get_width() + pad_x * 2 h = surf.get_height() + pad_y * 2 box_x = int(center_x - w // 2) box_y = int(bottom_y - h - 10) bubble = pygame.Surface((w, h), pygame.SRCALPHA) bubble.fill((20, 20, 20, 200)) surface.blit(bubble, (box_x, box_y)) pygame.draw.rect(surface, (10, 10, 10), (box_x, box_y, w, h), 2) tail = [ (center_x - 6, box_y + h), (center_x + 6, box_y + h), (center_x, box_y + h + 8), ] pygame.draw.polygon(surface, (20, 20, 20), tail) pygame.draw.polygon(surface, (10, 10, 10), tail, 2) surface.blit(surf, (box_x + pad_x, box_y + pad_y)) if hint_text: hint = font.render(hint_text, True, (0, 0, 0)).convert_alpha() hint_x = int(center_x - hint.get_width() // 2) hint_y = box_y + h + 10 surface.blit(hint, (hint_x, hint_y)) def draw_house_shape(surface, cx, cy, center_x: int, bottom_y: int): """Draw the procedural house with its bottom-center anchored at (center_x, bottom_y).""" house_w = 140 house_h = 90 base_x = int(center_x - house_w // 2 - cx) base_y = int(bottom_y - house_h - cy) # wall pygame.draw.rect(surface, (180, 170, 150), (base_x, base_y + 30, house_w, house_h - 30)) pygame.draw.rect(surface, (120, 110, 95), (base_x, base_y + 30, house_w, house_h - 30), 2) # door door_w = 26 door_h = 34 door_x = base_x + house_w // 2 - door_w // 2 door_y = base_y + house_h - door_h pygame.draw.rect(surface, (150, 170, 195), (door_x, door_y, door_w, door_h)) pygame.draw.rect(surface, (95, 110, 130), (door_x, door_y, door_w, door_h), 2) # roof (draw last so it sits on top) roof_pts = [ (base_x - 6, base_y + 34), (base_x + house_w // 2, base_y - 8), (base_x + house_w + 6, base_y + 34), ] pygame.draw.polygon(surface, (210, 170, 110), roof_pts) pygame.draw.polygon(surface, (135, 100, 65), roof_pts, 2) class HouseObject: """A standalone house prop placed in a room (bottom-center anchored).""" def __init__(self, x: int, y: int, layer: int = 0): self.x = int(x) self.y = int(y) self.layer = int(layer) self.rect = pygame.Rect(int(x) - 70, int(y) - 90, 140, 90) def draw(self, surface, cx, cy): draw_house_shape(surface, cx, cy, self.x, self.y) def draw_layerable(surface, cx, cy): """Draw all static room objects back-to-front by (layer, category). Lower layer = further back. Ties use category order so default rooms keep a sensible look: props/houses (0) < platforms (1) < ability doors (2) < exits (3) < enemies (4) < NPCs (5). Any object's layer can be raised/lowered to push it into the foreground or background. """ items = [] # props / houses for ls in light_sources: items.append((getattr(ls, "layer", 0), 0, "light", ls)) for sp in save_points: items.append((getattr(sp, "layer", 0), 0, "savepoint", sp)) for bj in butterfly_jars: items.append((getattr(bj, "layer", 0), 0, "jar", bj)) for h in houses: items.append((getattr(h, "layer", 0), 0, "house", h)) for qg in quest_givers: items.append((getattr(qg, "layer", 0), 0, "questgiver", qg)) if diary_obj is not None: items.append((getattr(diary_obj, "layer", 0), 0, "diary", diary_obj)) if reward_chest is not None: items.append((getattr(reward_chest, "layer", 0), 0, "rewardchest", reward_chest)) if reward_ability is not None: items.append((getattr(reward_ability, "layer", 0), 0, "rewardability", reward_ability)) # platforms for p in platforms: items.append((platform_layer.get(id(p), 0), 1, "platform", p)) # ability doors for d in ability_doors: items.append((getattr(d, "layer", 0), 2, "door", d)) # exits (respect locked-exit hiding) for ex in ROOM.exit_triggers: if ex.get("requires_boss_dead", False) and boss_alive_in_room(): continue if ex.get("requires_ranged", False) and not player.has_ranged: continue items.append((ex.get("layer", 0), 3, "exit", ex)) # enemies for en in enemies: items.append((getattr(en, "layer", 0), 4, "enemy", en)) # npcs for n in npcs: items.append((getattr(n, "layer", 0), 5, "npc", n)) items.sort(key=lambda it: (it[0], it[1])) for _z, _cat, kind, obj in items: if kind in ("light", "savepoint", "jar", "house", "questgiver", "diary", "rewardchest", "rewardability", "door", "enemy", "npc"): obj.draw(surface, cx, cy) elif kind == "platform": pygame.draw.rect( surface, PLATFORM_COLOR, pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height), ) elif kind == "exit": rr = obj["rect"] pygame.draw.rect( surface, (140, 220, 140), pygame.Rect(rr.x - cx, rr.y - cy, rr.width, rr.height), 2, ) def draw_ability_icon_bg(surface, x: int, y: int, size: int, shade: bool): pad = 4 w = size + pad * 2 h = size + pad * 2 if shade: fill_col = (150, 150, 165, 120) border_col = (120, 120, 135) else: fill_col = (150, 150, 165, 120) border_col = (120, 120, 135) rect = pygame.Rect(x - pad, y - pad, w, h) pygame.draw.rect(surface, fill_col, rect, border_radius=6) pygame.draw.rect(surface, border_col, rect, 1, border_radius=6) def draw_shatterstep_icon(surface, player_obj: "Player"): if not getattr(player_obj, "has_shatterstep", False): return icon = SHATTER_ICON_DARK if player_obj.shade else SHATTER_ICON_LIGHT if icon is None: return x, y = SHATTER_ICON_POS draw_ability_icon_bg(surface, x, y, SHATTER_ICON_SIZE, player_obj.shade) surface.blit(icon, (x, y)) # Cooldown overlay (always visible while cd > 0) if player_obj.shatterstep_cd > 0.0: cd = max(0.0, min(1.0, player_obj.shatterstep_cd / SHATTERSTEP_COOLDOWN)) overlay = pygame.Surface((SHATTER_ICON_SIZE, SHATTER_ICON_SIZE), pygame.SRCALPHA) overlay.fill((0, 0, 0, 180)) surface.blit(overlay, (x, y)) progress = 1.0 - cd h = int(SHATTER_ICON_SIZE * progress) if h > 0: reveal = icon.subsurface((0, SHATTER_ICON_SIZE - h, SHATTER_ICON_SIZE, h)).copy() surface.blit(reveal, (x, y + (SHATTER_ICON_SIZE - h))) # Active highlight + duration fill (draw after cooldown so it stays visible) if player_obj.is_shatterstep_active(): pygame.draw.rect(surface, (120, 220, 255), (x - 2, y - 2, SHATTER_ICON_SIZE + 4, SHATTER_ICON_SIZE + 4), 2) dur = max(0.0, min(1.0, player_obj.shatterstep_timer / SHATTERSTEP_DURATION)) h = int(SHATTER_ICON_SIZE * dur) if h > 0: active_overlay = pygame.Surface((SHATTER_ICON_SIZE, SHATTER_ICON_SIZE), pygame.SRCALPHA) active_overlay.fill((80, 200, 255, 160)) surface.blit(active_overlay, (x, y)) spent_h = SHATTER_ICON_SIZE - h if spent_h > 0: spent = pygame.Surface((SHATTER_ICON_SIZE, spent_h), pygame.SRCALPHA) spent.fill((0, 0, 0, 190)) surface.blit(spent, (x, y + h)) def draw_tether_icon(surface, player_obj: "Player"): if not getattr(player_obj, "has_tether", False): return icon = TETHER_ICON_DARK if player_obj.shade else TETHER_ICON_LIGHT if icon is None: return x, y = TETHER_ICON_POS draw_ability_icon_bg(surface, x, y, TETHER_ICON_SIZE, player_obj.shade) surface.blit(icon, (x, y)) if player_obj.tether_pending_cd > 0.0: pygame.draw.rect(surface, (220, 180, 120), (x - 2, y - 2, TETHER_ICON_SIZE + 4, TETHER_ICON_SIZE + 4), 2) remaining = player_obj.tether_cd if player_obj.tether_cd > 0.0 else player_obj.tether_pending_cd total = TETHER_COOLDOWN if player_obj.tether_cd > 0.0 else TETHER_BIND_DURATION if remaining > 0.0 and total > 0.0: cd = max(0.0, min(1.0, remaining / total)) overlay = pygame.Surface((TETHER_ICON_SIZE, TETHER_ICON_SIZE), pygame.SRCALPHA) overlay.fill((0, 0, 0, 180)) surface.blit(overlay, (x, y)) progress = 1.0 - cd h = int(TETHER_ICON_SIZE * progress) if h > 0: reveal = icon.subsurface((0, TETHER_ICON_SIZE - h, TETHER_ICON_SIZE, h)).copy() surface.blit(reveal, (x, y + (TETHER_ICON_SIZE - h))) def draw_reality_break_icon(surface, player_obj: "Player"): if not getattr(player_obj, "has_reality_break", False): return icon = REALITY_ICON_DARK if player_obj.shade else REALITY_ICON_LIGHT if icon is None: return x, y = REALITY_BREAK_ICON_POS draw_ability_icon_bg(surface, x, y, REALITY_BREAK_ICON_SIZE, player_obj.shade) surface.blit(icon, (x, y)) if player_obj.reality_break_flash > 0.0: pygame.draw.rect( surface, (255, 230, 150), (x - 2, y - 2, REALITY_BREAK_ICON_SIZE + 4, REALITY_BREAK_ICON_SIZE + 4), 2, ) def _draw_tether_chain_loop(surface, rect: pygame.Rect, light: bool, cx: float, cy: float): seg = TETHER_SPRITE_LIGHT if light else TETHER_SPRITE_DARK corner = TETHER_EDGE_LIGHT if light else TETHER_EDGE_DARK if seg is None or corner is None: return seg_w, seg_h = seg.get_size() if seg_w <= 0 or seg_h <= 0: return # tight loop around enemy legs area pad = 4 left = rect.left - pad right = rect.right + pad band_h = max(int(rect.height * 0.45), seg_h * 2) top = rect.bottom - band_h bottom = rect.bottom + pad # top/bottom x = left while x <= right - seg_w: surface.blit(seg, (x - cx, top - cy)) surface.blit(seg, (x - cx, bottom - cy - seg_h)) x += max(1, seg_w - 4) # left/right (rotate segment) seg_v = pygame.transform.rotate(seg, 90) seg_v_w, seg_v_h = seg_v.get_size() y = top while y <= bottom - seg_v_h: surface.blit(seg_v, (left - cx, y - cy)) surface.blit(seg_v, (right - cx - seg_v_w, y - cy)) y += max(1, seg_v_h - 4) # corners (rotate edge) c_tl = corner c_tr = pygame.transform.flip(corner, True, False) c_bl = pygame.transform.flip(corner, False, True) c_br = pygame.transform.flip(corner, True, True) surface.blit(c_tl, (left - cx, top - cy)) surface.blit(c_tr, (right - cx - c_tr.get_width(), top - cy)) surface.blit(c_bl, (left - cx, bottom - cy - c_bl.get_height())) surface.blit(c_br, (right - cx - c_br.get_width(), bottom - cy - c_br.get_height())) def get_pause_menu_rects(): items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"] rects = [] start_y = HEIGHT // 2 + 20 for i, label in enumerate(items): text = font.render(label, True, (240, 240, 240)) x = WIDTH // 2 - text.get_width() // 2 y = start_y + i * 28 rects.append(pygame.Rect(x - 6, y - 2, text.get_width() + 12, text.get_height() + 4)) return rects def draw_pause_menu(surface, selected_idx: int, use_mouse: bool): items = ["Resume", "Save Game", "Settings", "Main Menu", "Close Game"] rects = get_pause_menu_rects() mouse_pos = pygame.mouse.get_pos() hover_idx = None for i, r in enumerate(rects): if r.collidepoint(mouse_pos): hover_idx = i break active_idx = hover_idx if (use_mouse and hover_idx is not None) else selected_idx for i, label in enumerate(items): r = rects[i] active = (i == active_idx) fill = (60, 60, 60) if active else (40, 40, 40) border = (140, 140, 140) if active else (90, 90, 90) pygame.draw.rect(surface, fill, r, border_radius=6) pygame.draw.rect(surface, border, r, 2, border_radius=6) col = (245, 245, 245) if active else (210, 210, 210) text = font.render(label, True, col) surface.blit(text, (r.x + 6, r.y + 2)) def draw_settings_menu(surface, selected_idx: int, waiting_action: str | None): panel_w = 360 panel_h = 360 x = 20 y = 120 pygame.draw.rect(surface, (15, 15, 15), (x, y, panel_w, panel_h)) pygame.draw.rect(surface, (60, 60, 60), (x, y, panel_w, panel_h), 2) title = font.render("Settings: Keybinds", True, (240, 240, 240)) surface.blit(title, (x + 10, y + 10)) start_y = y + 40 line_h = 18 max_lines = (panel_h - 70) // line_h start_idx = max(0, min(selected_idx - max_lines // 2, len(SETTINGS_ACTIONS) - max_lines)) end_idx = min(len(SETTINGS_ACTIONS), start_idx + max_lines) for i in range(start_idx, end_idx): label, action = SETTINGS_ACTIONS[i] col = (255, 255, 255) if i == selected_idx else (200, 200, 200) key_label = key_name(key_for(action)) if key_for(action) is not None else "-" line = f"{label}: {key_label}" if waiting_action == action: line = f"{label}: " col = (255, 220, 140) txt = font.render(line, True, col) surface.blit(txt, (x + 10, start_y + (i - start_idx) * line_h)) hint = font.render("Up/Down = select Enter = rebind Esc = back", True, (170, 170, 170)) surface.blit(hint, (x + 10, y + panel_h - 24)) def get_near_npc(player_obj: Player, npc_list: list[NPC]) -> NPC | None: for n in npc_list: dx = player_obj.rect.centerx - n.rect.centerx dy = player_obj.rect.centery - n.rect.centery if (dx * dx + dy * dy) <= (NPC_TALK_RANGE * NPC_TALK_RANGE): return n return None # ---------------------------- # Room switching glue # ---------------------------- room_manager = RoomManager() room_manager.load_room("room_01.json") ROOM = room_manager.current_room KILL_Y = ROOM.kill_y platforms_ground = ROOM.platforms_ground platforms_floating = ROOM.platforms_floating ground_platforms = ROOM.ground_platforms platforms = ROOM.platforms platform_layer = ROOM.platform_layer # ---------------------------- # Game init # ---------------------------- player = Player(int(ROOM.player_spawn[0]), int(ROOM.player_spawn[1]), PLAYER_SPRITE_RAW, SHADE_SPRITE_RAW) inventory = Inventory() inv_ui = InventoryUI(inventory, player, font) trade_ui = TradeUI(inventory, player, font) 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 = 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] = [] save_points: list[SavePoint] = [] butterfly_jars: list[ButterflyJar] = [] houses: list[HouseObject] = [] 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] = [] save_points: list[SavePoint] = [] butterfly_jars: list[ButterflyJar] = [] houses: list[HouseObject] = [] quest_givers: list[QuestGiver] = [] diary_obj = None props = room.props if not props and room.id == REWARD_ROOM_ID: props = [ {"type": "reward_chest", "pos": list(REWARD_CHEST_POS), "coins": list(REWARD_CHEST_COINS)}, {"type": "ability_pickup", "pos": list(REWARD_ABILITY_POS), "ability": "ranged"}, ] for p in props: if not isinstance(p, dict): continue p_type = str(p.get("type", "")) pos = p.get("pos", None) if not (isinstance(pos, (list, tuple)) and len(pos) == 2): continue x, y = int(pos[0]), int(pos[1]) if p_type == "reward_chest" and reward_chest_obj is None: coins = p.get("coins", REWARD_CHEST_COINS) if isinstance(coins, (list, tuple)) and len(coins) == 2: coins = (int(coins[0]), int(coins[1])) else: coins = REWARD_CHEST_COINS reward_chest_obj = RewardChest(x, y, opened=player_obj.reward_chest_opened, coins=coins) reward_chest_obj.layer = int(p.get("layer", 0)) elif p_type == "ability_pickup" and reward_ability_obj is None: ability_id = str(p.get("ability", "ranged")) if ability_id == "ranged" and player_obj.has_ranged: continue reward_ability_obj = AbilityPickup(x, y, ability_id=ability_id) reward_ability_obj.layer = int(p.get("layer", 0)) elif p_type == "light_source": radius = int(p.get("radius", 180)) ls = LightSource(x, y, radius) ls.layer = int(p.get("layer", 0)) lights.append(ls) elif p_type == "save_point": radius = int(p.get("radius", 90)) sp = SavePoint(x, y, radius) sp.layer = int(p.get("layer", 0)) save_points.append(sp) elif p_type == "butterfly_jar": key = jar_key(room.id, x, y) if key not in COLLECTED_JARS: bj = ButterflyJar(x, y, key=key) bj.layer = int(p.get("layer", 0)) butterfly_jars.append(bj) elif p_type == "quest_giver": qg = QuestGiver(x, y, str(p.get("quest_id", ""))) qg.layer = int(p.get("layer", 0)) quest_givers.append(qg) elif p_type == "diary": diary_obj = DiaryObject(x, y) diary_obj.layer = int(p.get("layer", 0)) elif p_type == "house": houses.append(HouseObject(x, y, layer=int(p.get("layer", 0)))) return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, houses, quest_givers, diary_obj def rebuild_room_butterfly_jars(): """Rebuild the butterfly jar list for the current room (after jars respawn).""" global butterfly_jars butterfly_jars = [] for p in ROOM.props: if not isinstance(p, dict): continue if str(p.get("type", "")) != "butterfly_jar": continue pos = p.get("pos", None) if not (isinstance(pos, (list, tuple)) and len(pos) == 2): continue x, y = int(pos[0]), int(pos[1]) key = jar_key(ROOM.id, x, y) if key not in COLLECTED_JARS: butterfly_jars.append(ButterflyJar(x, y, key=key)) reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, 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"] by_slot = {} for it in ARMOR_DB: if it.get("set") == set_id and it.get("slot") in slots: by_slot[it.get("slot")] = it if len(by_slot) < len(slots): return False for slot in slots: base = by_slot.get(slot) if not base: continue try: inst = build_item_instance(base, rarity) except Exception: continue if equip: old = player.equip_swap(inst) if old: inventory.add(old) else: inventory.add(inst) return True def grant_five_tempered() -> dict | None: pool = [it for it in ARMOR_DB if isinstance(it, dict) and it.get("id")] if not pool: return None base = random.choice(pool) try: inst = build_item_instance(base, "tempered") except Exception: return None for _ in range(5): inventory.add(deepcopy(inst)) return inst def apply_room(room: Room, set_player_spawn=True): """Updates room-dependent lists + respawns enemies. Keeps inventory/equipment.""" global ROOM, KILL_Y, platforms_ground, platforms_floating, ground_platforms, platforms global platform_layer global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors global shatter_platforms, shatter_aoes, tether_shots global light_sources, save_points, butterfly_jars, houses global dust_puffs global dust_puffs global reward_chest, reward_ability, quest_givers, diary_obj global token_drops global ACTIVE_REFIGHT global ROOM_DROP_MULT, ROOM_COIN_MULT ROOM = room KILL_Y = ROOM.kill_y platforms_ground = ROOM.platforms_ground platforms_floating = ROOM.platforms_floating ground_platforms = ROOM.ground_platforms platforms = ROOM.platforms platform_layer = ROOM.platform_layer 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 = [] tether_shots = [] light_sources = [] save_points = [] butterfly_jars = [] houses = [] dust_puffs = [] dust_puffs = [] ROOM_DROP_MULT = 1.0 ROOM_COIN_MULT = 1.0 enemies = spawn_enemies_from_room(ROOM) npcs = spawn_npcs_from_room(ROOM) ability_doors = spawn_ability_doors(ROOM) reward_chest = None reward_ability = None diary_obj = None reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player) if set_player_spawn: player.start_x = int(ROOM.player_spawn[0]) player.start_y = int(ROOM.player_spawn[1]) player.rect.midbottom = (player.start_x, player.start_y) player.vel_y = 0.0 player.on_ground = False def boss_alive_in_room() -> bool: return any(getattr(en, "type_id", "") == "boss" and en.health > 0 for en in enemies) def reset_room_combat_state(): """Rebuild room enemies/NPCs/doors and clear transient combat objects.""" global enemies, npcs, loot_drops, shockwaves, coin_drops, token_drops, projectiles global shatter_platforms, shatter_aoes, dust_puffs, tether_shots, ability_doors 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 = [] ability_doors = spawn_ability_doors(ROOM) def apply_death_penalty(): """On death: lose butterfly jars for good and drop coins into a corpse. A corpse holds 100% of carried coins on the first death. On a repeat death (while a corpse is still unclaimed) only 50% of the remaining coins stay recoverable — the other 50% is lost forever and the old corpse vanishes. Void deaths (falling out of the world) leave no recoverable corpse. """ global corpse, JAR_DEATH_ROOM player.butterfly_jars = 0 JAR_DEATH_ROOM = ROOM.id is_void_death = player.rect.top > KILL_Y carried_l = player.coins_light carried_d = player.coins_dark if corpse is not None: # "Remaining coins" = coins still held by the old corpse + anything carried # since then. The new corpse only keeps half of that pool. pool_l = corpse.coins_light + carried_l pool_d = corpse.coins_dark + carried_d lost_l = int(pool_l * 0.5) lost_d = int(pool_d * 0.5) else: lost_l = carried_l lost_d = carried_d if not is_void_death: corpse = Corpse(player.rect.centerx, player.rect.bottom, ROOM.id, lost_l, lost_d) player.coins_light = 0 player.coins_dark = 0 def respawn_jars_if_elsewhere() -> bool: """Respawn collected butterfly jars when saving at a lantern in a different room than the one the player died in. Returns True if jars were respawned.""" global JAR_DEATH_ROOM if JAR_DEATH_ROOM is not None and ROOM.id != JAR_DEATH_ROOM: COLLECTED_JARS.clear() JAR_DEATH_ROOM = None rebuild_room_butterfly_jars() return True return False def try_room_transition(keys): if room_manager.transition_cooldown > 0: return False if inv_ui.visible or trade_ui.visible: return False for ex in ROOM.exit_triggers: # Gate exits (e.g., boss doors) if ex.get("requires_boss_dead", False) and boss_alive_in_room(): continue if ex.get("requires_ranged", False) and not player.has_ranged: continue if player.rect.colliderect(ex["rect"]): target = ex.get("to", None) spawn = ex.get("spawn", None) spawn_id = ex.get("spawn_id", None) new_room = room_manager.load_room(target) forced_spawn = None if spawn_id: if spawn_id == "__player_spawn__": forced_spawn = new_room.player_spawn else: pos = new_room.entry_spawns.get(spawn_id) if isinstance(pos, (list, tuple)) and len(pos) == 2: forced_spawn = [int(pos[0]), int(pos[1])] else: print(f"[Room Transition] spawn_id '{spawn_id}' not found in {new_room.id}. Available: {list(new_room.entry_spawns.keys())}") if forced_spawn is not None: print(f"[Room Transition] {ROOM.id} -> {new_room.id} using spawn_id '{spawn_id}' at {forced_spawn}") if forced_spawn is None and isinstance(spawn, (list, tuple)) and len(spawn) == 2: forced_spawn = [int(spawn[0]), int(spawn[1])] if forced_spawn is None: forced_spawn = [60, 200] new_room.player_spawn = forced_spawn apply_room(new_room, set_player_spawn=True) return True return False camera_x = camera_y = 0.0 # Track bosses defeated per room id (runtime only) DEFEATED_BOSSES: set[str] = set() # Reality Break: per-room drop multipliers (reset on room change) ROOM_DROP_MULT = 1.0 ROOM_COIN_MULT = 1.0 def is_boss_defeated(room_id: str) -> bool: return room_id in DEFEATED_BOSSES def mark_boss_defeated(room_id: str): if room_id: 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", 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: 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 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: """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) 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)) 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"}, }, }, { "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() 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", "refight_room": "room_boss_refight_01.json", "token_name": "Gloom Warden's Token", }, } 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 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 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 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 get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -> SavePoint | None: """Return the nearest lantern the player can interact with, or None.""" best = None best_d = None px, py = player_obj.rect.midbottom for sp in save_point_list: r = range_px if range_px is not None else sp.radius if r <= 0: continue dx = px - sp.x dy = py - sp.y d2 = dx * dx + dy * dy if d2 <= (r * r): if best is None or d2 < best_d: best = sp best_d = d2 return best def save_at_lantern(sp: SavePoint): """Set the lantern as the respawn point and save the game.""" global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS LANTERN_RESPAWN_ROOM = ROOM.id LANTERN_RESPAWN_POS = (sp.x, sp.y) _do_save_game() pickup_feed.push_center("Lantern - game saved", duration=2.0) # Butterfly jars lost on death respawn after saving at a lantern in a different room. if respawn_jars_if_elsewhere(): pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5) 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) # ---------------------------- # 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) 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 # ---------------------------- # Save system # ---------------------------- SAVES_DIR = BASE_DIR / "saves" def ensure_saves_dir(): SAVES_DIR.mkdir(parents=True, exist_ok=True) def sanitize_save_name(name: str) -> str: out = [] for ch in str(name): if ch.isalnum() or ch in (" ", "-", "_"): out.append(ch) return "".join(out).strip()[:40] def save_path(name: str) -> Path: return SAVES_DIR / f"{sanitize_save_name(name)}.json" def list_saves() -> list[dict]: """Returns [{name, path, updated}] sorted newest-first.""" ensure_saves_dir() out = [] for p in sorted(SAVES_DIR.glob("*.json"), key=lambda pp: pp.stat().st_mtime, reverse=True): updated_str = "" try: updated_str = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M") except OSError: pass out.append({"name": p.stem, "path": p, "updated": updated_str}) return out def save_game(name: str = "auto") -> str: """Serialize the current game state to a save file. Returns the save name used.""" ensure_saves_dir() name = sanitize_save_name(name) or "auto" data = { "version": 1, "saved_at": datetime.now().isoformat(timespec="seconds"), "player": { "room_id": ROOM.id, "x": player.rect.centerx, "y": player.rect.bottom, "health": player.health, "charge": round(float(player.charge), 2), "shade": player.shade, "coins_light": player.coins_light, "coins_dark": player.coins_dark, "butterfly_jars": player.butterfly_jars, "has_ranged": player.has_ranged, "reward_chest_opened": player.reward_chest_opened, "equipment": {s: it for s, it in player.equipment.items()}, }, "inventory": inventory.items, "quests": { "active": quest_manager.active, "completed_one_time": sorted(quest_manager.completed_one_time), "tokens": quest_manager.tokens, }, "boss_diary": {"slotted": boss_diary.slotted}, "defeated_bosses": sorted(DEFEATED_BOSSES), "broken_doors": sorted(BROKEN_DOORS), "lantern_respawn": ( {"room_id": LANTERN_RESPAWN_ROOM, "x": LANTERN_RESPAWN_POS[0], "y": LANTERN_RESPAWN_POS[1]} if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS else None ), } path = save_path(name) with path.open("w", encoding="utf-8") as f: json.dump(data, f, indent=2) return name def apply_save_data(data: dict): """Apply a save's state to the current live game objects (player/inventory/quests/room).""" global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS global JAR_DEATH_ROOM, corpse pdata = data.get("player", {}) or {} # persistent player fields player.coins_light = int(pdata.get("coins_light", 0)) player.coins_dark = int(pdata.get("coins_dark", 0)) player.butterfly_jars = int(pdata.get("butterfly_jars", 0)) player.has_ranged = bool(pdata.get("has_ranged", False)) player.reward_chest_opened = bool(pdata.get("reward_chest_opened", False)) eq = pdata.get("equipment") or {} player.equipment = {s: None for s in ("helmet", "chest", "pants", "necklace")} for s, it in eq.items(): if s in player.equipment: player.equipment[s] = dict(it) if it else None player.recalc_bonuses() player._clamp_resources() inventory.items = [dict(it) for it in (data.get("inventory") or [])] qdata = data.get("quests") or {} quest_manager.active = {k: dict(v) for k, v in (qdata.get("active") or {}).items()} quest_manager.completed_one_time = set(qdata.get("completed_one_time") or []) quest_manager.tokens = int(qdata.get("tokens", 0)) bdata = data.get("boss_diary") or {} boss_diary.slotted = {k: int(v) for k, v in (bdata.get("slotted") or {}).items()} DEFEATED_BOSSES.clear() DEFEATED_BOSSES.update(data.get("defeated_bosses") or []) BROKEN_DOORS.clear() BROKEN_DOORS.update(data.get("broken_doors") or []) # Butterfly jars respawn at their points on reload; the corpse is session-only. COLLECTED_JARS.clear() JAR_DEATH_ROOM = None corpse = None lrespawn = data.get("lantern_respawn") or {} if lrespawn.get("room_id"): LANTERN_RESPAWN_ROOM = normalize_room_id(str(lrespawn["room_id"])) LANTERN_RESPAWN_POS = (int(lrespawn["x"]), int(lrespawn["y"])) else: LANTERN_RESPAWN_ROOM = None LANTERN_RESPAWN_POS = None # Reloading a save spawns the player at their latest lantern save point. room_id = normalize_room_id(str(pdata.get("room_id") or "room_01.json")) if LANTERN_RESPAWN_ROOM: room_id = LANTERN_RESPAWN_ROOM room = room_manager.load_room(room_id) px, py = pdata.get("x"), pdata.get("y") if LANTERN_RESPAWN_POS: px, py = LANTERN_RESPAWN_POS if isinstance(px, (int, float)) and isinstance(py, (int, float)): room.player_spawn = [int(px), int(py)] apply_room(room, set_player_spawn=True) player.respawn() if "health" in pdata: player.health = int(pdata["health"]) if "charge" in pdata: player.charge = float(pdata["charge"]) player.shade = bool(pdata.get("shade", False)) player._clamp_resources() def fresh_save_data() -> dict: return { "version": 1, "player": { "room_id": "room_01.json", "x": None, "y": None, "health": BASE_MAX_HEALTH, "charge": BASE_MAX_CHARGE, "shade": False, "coins_light": 0, "coins_dark": 0, "butterfly_jars": 0, "has_ranged": False, "reward_chest_opened": False, "equipment": {}, }, "inventory": [], "quests": {"active": {}, "completed_one_time": [], "tokens": 0}, "boss_diary": {"slotted": {}}, "defeated_bosses": [], "broken_doors": [], "lantern_respawn": None, } def load_game(name: str) -> bool: path = save_path(name) if not path.exists(): return False try: data = load_json(path) except Exception as e: print(f"[Load failed] {path} -> {e}") return False apply_save_data(data) return True def start_new_game(name: str) -> str: """Reset all state to a fresh game and save it. Returns the save name used.""" room_id = "room_01.json" room_json = load_json_safe(room_id, [ROOMS_DIR, LEVELS_DIR, DATA_ROOT, BASE_DIR], ROOM_DEFAULT) room_json = normalize_room_json(room_json) spawn = room_json.get("player_spawn", [200, 100]) data = fresh_save_data() if isinstance(spawn, (list, tuple)) and len(spawn) == 2: data["player"]["x"] = int(spawn[0]) data["player"]["y"] = int(spawn[1]) apply_save_data(data) final_name = sanitize_save_name(name) or "auto" base = final_name i = 2 while save_path(final_name).exists(): final_name = f"{base} {i}" i += 1 return save_game(final_name) def wipe_all_saves(): ensure_saves_dir() for p in SAVES_DIR.glob("*.json"): try: p.unlink() except OSError: pass def _do_save_game(): global CURRENT_SAVE_NAME, save_feedback, save_feedback_timer name = CURRENT_SAVE_NAME or "auto" CURRENT_SAVE_NAME = save_game(name) save_feedback = f"Saved: {CURRENT_SAVE_NAME}" save_feedback_timer = 2.0 def _return_to_main_menu(): global GAME_MODE, paused, pause_menu, pause_menu_index, pause_use_mouse global settings_wait_action if CURRENT_SAVE_NAME: _do_save_game() inv_ui.visible = False trade_ui.visible = False trade_ui.active_npc = None diary_ui.visible = False paused = False pause_menu = "main" pause_menu_index = 0 pause_use_mouse = False settings_wait_action = None GAME_MODE = "menu" # ---------------------------- # Main menu # ---------------------------- GAME_MODE = "menu" # menu | select | newgame | settings | confirm_wipe | game MAIN_MENU_ITEMS = ["Select Game", "New Game", "Settings", "Quit"] menu_index = 0 menu_use_mouse = False select_index = 0 select_use_mouse = False newgame_name_input = "" wipe_confirm_idx = 1 wipe_use_mouse = False CURRENT_SAVE_NAME: str | None = None save_feedback = "" save_feedback_timer = 0.0 def get_main_menu_rects(): rects = [] start_y = int(HEIGHT * 0.38) for label in MAIN_MENU_ITEMS: text = font.render(label, True, (240, 240, 240)) x = WIDTH // 2 - text.get_width() // 2 y = start_y rects.append(pygame.Rect(x - 8, y - 4, text.get_width() + 16, text.get_height() + 8)) start_y += 36 return rects def get_wipe_button_rect() -> pygame.Rect: w, h = 150, 26 return pygame.Rect(WIDTH - w - 12, HEIGHT - h - 12, w, h) def draw_main_menu(surface, selected_idx: int, use_mouse: bool, has_saves: bool): title_font = pygame.font.SysFont(None, 72) title = title_font.render("Light & Dark", True, (240, 235, 210)) surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.14))) sub = font.render("Prototype", True, (160, 160, 160)) surface.blit(sub, (WIDTH // 2 - sub.get_width() // 2, int(HEIGHT * 0.14) + 68)) rects = get_main_menu_rects() mouse_pos = pygame.mouse.get_pos() hover_idx = None for i, r in enumerate(rects): if r.collidepoint(mouse_pos): hover_idx = i break active_idx = hover_idx if (use_mouse and hover_idx is not None) else selected_idx for i, label in enumerate(MAIN_MENU_ITEMS): r = rects[i] enabled = (i != 0) or has_saves active = enabled and (i == active_idx) fill = (60, 60, 60) if active else (40, 40, 40) border = (140, 140, 140) if active else (90, 90, 90) pygame.draw.rect(surface, fill, r, border_radius=6) pygame.draw.rect(surface, border, r, 2, border_radius=6) col = (245, 245, 245) if enabled else (95, 95, 95) text = font.render(label, True, col) surface.blit(text, (r.x + 6, r.y + 2)) wr = get_wipe_button_rect() wfill = (70, 40, 40) if has_saves else (40, 40, 40) wborder = (160, 80, 80) if has_saves else (90, 90, 90) pygame.draw.rect(surface, wfill, wr, border_radius=6) pygame.draw.rect(surface, wborder, wr, 2, border_radius=6) wtext = font.render("Wipe All Saves", True, (245, 170, 170) if has_saves else (95, 95, 95)) surface.blit(wtext, (wr.x + 8, wr.y + 4)) def get_select_rects(saves) -> list[pygame.Rect]: rects = [] start_y = int(HEIGHT * 0.30) for i in range(len(saves)): rects.append(pygame.Rect(WIDTH // 2 - 170, start_y + i * 30 - 2, 340, 24)) return rects def draw_select_menu(surface, selected_idx: int, use_mouse: bool): title = font.render("Select Game", True, (240, 235, 210)) surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.15))) saves = list_saves() if not saves: msg = font.render("No save files found.", True, (170, 170, 170)) surface.blit(msg, (WIDTH // 2 - msg.get_width() // 2, HEIGHT // 2)) hint = font.render("Press Esc to return", True, (150, 150, 150)) surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT // 2 + 30)) return rects = get_select_rects(saves) mouse_pos = pygame.mouse.get_pos() hover_idx = None for i, r in enumerate(rects): if r.collidepoint(mouse_pos): hover_idx = i break active_idx = hover_idx if (use_mouse and hover_idx is not None) else min(selected_idx, len(saves) - 1) for i, sv in enumerate(saves): r = rects[i] active = (i == active_idx) fill = (60, 60, 60) if active else (40, 40, 40) border = (140, 140, 140) if active else (90, 90, 90) pygame.draw.rect(surface, fill, r, border_radius=6) pygame.draw.rect(surface, border, r, 2, border_radius=6) label = f"{sv['name']} ({sv['updated']})" if sv.get("updated") else sv["name"] text = font.render(label, True, (240, 240, 240)) surface.blit(text, (r.x + 8, r.y + 3)) hint = font.render("Up/Down = select Enter = continue Esc = back", True, (170, 170, 170)) surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT - 40)) def draw_newgame(surface): title = font.render("New Game", True, (240, 235, 210)) surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.22))) label = font.render("Enter save name:", True, (200, 200, 200)) surface.blit(label, (WIDTH // 2 - label.get_width() // 2, int(HEIGHT * 0.34))) box = pygame.Rect(WIDTH // 2 - 140, int(HEIGHT * 0.40), 280, 28) pygame.draw.rect(surface, (40, 40, 40), box, border_radius=6) pygame.draw.rect(surface, (140, 140, 140), box, 2, border_radius=6) display = newgame_name_input + "_" txt = font.render(display, True, (240, 240, 240)) surface.blit(txt, (box.x + 8, box.y + 5)) hint = font.render("Enter = create Esc = cancel", True, (170, 170, 170)) surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, int(HEIGHT * 0.40) + 40)) def get_confirm_rects() -> list[pygame.Rect]: yes = pygame.Rect(WIDTH // 2 - 130, HEIGHT // 2 + 30, 110, 30) no = pygame.Rect(WIDTH // 2 + 20, HEIGHT // 2 + 30, 110, 30) return [yes, no] def draw_confirm_wipe(surface, selected_idx: int, use_mouse: bool): overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA) overlay.fill((0, 0, 0, 180)) surface.blit(overlay, (0, 0)) n = len(list_saves()) msg = font.render(f"Delete all {n} save file(s)?", True, (240, 240, 240)) surface.blit(msg, (WIDTH // 2 - msg.get_width() // 2, HEIGHT // 2 - 30)) sub = font.render("This cannot be undone.", True, (200, 90, 90)) surface.blit(sub, (WIDTH // 2 - sub.get_width() // 2, HEIGHT // 2 - 4)) rects = get_confirm_rects() mouse_pos = pygame.mouse.get_pos() hover_idx = None for i, r in enumerate(rects): if r.collidepoint(mouse_pos): hover_idx = i break active_idx = hover_idx if (use_mouse and hover_idx is not None) else selected_idx for i, label in enumerate(["Yes", "No"]): r = rects[i] active = (i == active_idx) if active and label == "Yes": fill, border = (90, 40, 40), (200, 80, 80) elif active: fill, border = (60, 60, 60), (140, 140, 140) else: fill, border = (40, 40, 40), (90, 90, 90) pygame.draw.rect(surface, fill, r, border_radius=6) pygame.draw.rect(surface, border, r, 2, border_radius=6) t = font.render(label, True, (245, 245, 245)) surface.blit(t, (r.x + (r.width - t.get_width()) // 2, r.y + 5)) def handle_settings_keydown(key, return_mode: str): global settings_wait_action, settings_index, GAME_MODE, pause_menu if settings_wait_action: BINDINGS[settings_wait_action] = key settings_wait_action = None return True if key_is("ui_up", key): settings_index = (settings_index - 1) % len(SETTINGS_ACTIONS) elif key_is("ui_down", key): settings_index = (settings_index + 1) % len(SETTINGS_ACTIONS) elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): settings_wait_action = SETTINGS_ACTIONS[settings_index][1] elif key_is("ui_cancel", key): if return_mode == "menu": GAME_MODE = "menu" else: pause_menu = "main" else: return False return True def _activate_menu_item(i: int, has_saves: bool): global GAME_MODE, running global select_index, select_use_mouse, newgame_name_input global settings_index, settings_wait_action label = MAIN_MENU_ITEMS[i] if label == "Select Game": if not has_saves: return select_index = 0 select_use_mouse = False GAME_MODE = "select" elif label == "New Game": newgame_name_input = "" GAME_MODE = "newgame" elif label == "Settings": settings_index = 0 settings_wait_action = None GAME_MODE = "settings" elif label == "Quit": running = False def _menu_handle_events(): global running, GAME_MODE, menu_index, menu_use_mouse global select_index, select_use_mouse, newgame_name_input global wipe_confirm_idx, wipe_use_mouse, CURRENT_SAVE_NAME, paused has_saves = len(list_saves()) > 0 for e in pygame.event.get(): if e.type == pygame.QUIT: running = False continue if GAME_MODE == "settings": if e.type == pygame.KEYDOWN: handle_settings_keydown(e.key, "menu") elif GAME_MODE == "confirm_wipe": if e.type == pygame.KEYDOWN: if key_is("ui_cancel", e.key): GAME_MODE = "menu" elif key_is("ui_left", e.key) or key_is("ui_right", e.key): wipe_confirm_idx = 1 - wipe_confirm_idx wipe_use_mouse = False elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): if wipe_confirm_idx == 0: wipe_all_saves() GAME_MODE = "menu" elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: for i, r in enumerate(get_confirm_rects()): if r.collidepoint(e.pos): if i == 0: wipe_all_saves() GAME_MODE = "menu" break elif GAME_MODE == "select": if e.type == pygame.KEYDOWN: if key_is("ui_cancel", e.key): GAME_MODE = "menu" elif key_is("ui_up", e.key): select_index = (select_index - 1) % max(1, len(list_saves())) select_use_mouse = False elif key_is("ui_down", e.key): select_index = (select_index + 1) % max(1, len(list_saves())) select_use_mouse = False elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): svs = list_saves() if svs: idx = min(select_index, len(svs) - 1) if load_game(svs[idx]["name"]): CURRENT_SAVE_NAME = svs[idx]["name"] GAME_MODE = "game" paused = False elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: svs = list_saves() for i, r in enumerate(get_select_rects(svs)): if r.collidepoint(e.pos): if load_game(svs[i]["name"]): CURRENT_SAVE_NAME = svs[i]["name"] GAME_MODE = "game" paused = False break elif GAME_MODE == "newgame": if e.type == pygame.KEYDOWN: if key_is("ui_cancel", e.key): GAME_MODE = "menu" elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): name = newgame_name_input.strip() or f"Save {len(list_saves()) + 1}" CURRENT_SAVE_NAME = start_new_game(name) GAME_MODE = "game" paused = False elif e.key == pygame.K_BACKSPACE: newgame_name_input = newgame_name_input[:-1] else: ch = getattr(e, "unicode", "") or "" if ch and ch.isprintable() and len(newgame_name_input) < 24: newgame_name_input += ch elif GAME_MODE == "menu": if e.type == pygame.MOUSEMOTION: hover = None for i, r in enumerate(get_main_menu_rects()): if r.collidepoint(e.pos): hover = i break if hover is not None: menu_use_mouse = True menu_index = hover else: menu_use_mouse = False elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: if get_wipe_button_rect().collidepoint(e.pos): if has_saves: GAME_MODE = "confirm_wipe" wipe_confirm_idx = 1 wipe_use_mouse = True continue for i, r in enumerate(get_main_menu_rects()): if r.collidepoint(e.pos): menu_use_mouse = True menu_index = i _activate_menu_item(i, has_saves) break elif e.type == pygame.KEYDOWN: if key_is("ui_up", e.key): menu_index = (menu_index - 1) % len(MAIN_MENU_ITEMS) menu_use_mouse = False elif key_is("ui_down", e.key): menu_index = (menu_index + 1) % len(MAIN_MENU_ITEMS) menu_use_mouse = False elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): _activate_menu_item(menu_index, has_saves) def _menu_draw(surface): if GAME_MODE == "settings": draw_settings_menu(surface, settings_index, settings_wait_action) elif GAME_MODE == "confirm_wipe": draw_confirm_wipe(surface, wipe_confirm_idx, wipe_use_mouse) elif GAME_MODE == "select": draw_select_menu(surface, select_index, select_use_mouse) elif GAME_MODE == "newgame": draw_newgame(surface) else: draw_main_menu(surface, menu_index, menu_use_mouse, len(list_saves()) > 0) def draw_butterfly_jar_slots(surface, x, y): """Draw the butterfly-jar collectible slots below the coin counters. Only shown once at least one jar has been collected. Always renders all BUTTERFLY_JARS_MAX slots; collected ones are full-color, the rest greyed out. """ if player.butterfly_jars <= 0: return size = BUTTERFLY_JAR_UI_SIZE for i in range(BUTTERFLY_JARS_MAX): ix = x + i * (size + BUTTERFLY_JAR_UI_GAP) if BUTTERFLY_JAR_RAW is not None: icon = pygame.transform.scale(BUTTERFLY_JAR_RAW, (size, size)) surface.blit(icon, (ix, y)) if i >= player.butterfly_jars: dim = pygame.Surface((size, size), pygame.SRCALPHA) dim.fill((45, 45, 55, 205)) surface.blit(dim, (ix, y)) else: col = (210, 170, 230) if i < player.butterfly_jars else (95, 95, 105) pygame.draw.rect(surface, col, (ix, y, size, size)) # ---------------------------- # Main loop # ---------------------------- running = True paused = False stats_overlay_visible = False pause_menu = "main" # main | settings pause_menu_index = 0 settings_index = 0 settings_wait_action: str | None = None pause_use_mouse = False while running: dt = clock.tick(60) / 1000.0 if save_feedback_timer > 0: save_feedback_timer = max(0.0, save_feedback_timer - dt) if GAME_MODE != "game": _menu_handle_events() screen.fill(BG) _menu_draw(screen) pygame.display.flip() continue if not paused: pickup_feed.update(dt) town_title.update(dt) for e in pygame.event.get(): if e.type == pygame.QUIT: running = False if e.type == pygame.MOUSEMOTION: if paused and pause_menu == "main": hover_idx = None for idx, r in enumerate(get_pause_menu_rects()): if r.collidepoint(e.pos): hover_idx = idx break if hover_idx is not None: pause_use_mouse = True pause_menu_index = hover_idx else: pause_use_mouse = False if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: if paused and pause_menu == "main": for idx, r in enumerate(get_pause_menu_rects()): if r.collidepoint(e.pos): pause_use_mouse = True pause_menu_index = idx if idx == 0: paused = False elif idx == 1: _do_save_game() elif idx == 2: pause_menu = "settings" elif idx == 3: _return_to_main_menu() elif idx == 4: running = False break 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": handle_settings_keydown(e.key, "pause") continue if paused and pause_menu == "main": if key_is("ui_up", e.key): pause_menu_index = (pause_menu_index - 1) % 5 pause_use_mouse = False elif key_is("ui_down", e.key): pause_menu_index = (pause_menu_index + 1) % 5 pause_use_mouse = False elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")): if pause_menu_index == 0: paused = False elif pause_menu_index == 1: _do_save_game() elif pause_menu_index == 2: pause_menu = "settings" elif pause_menu_index == 3: _return_to_main_menu() elif pause_menu_index == 4: running = False elif key_is("pause", e.key): paused = False continue 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 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": if key_is("debug_1", e.key): if grant_full_set("dawnrunner", "attuned", equip=True): pickup_feed.push_center("Debug: Equipped Dawnrunner (attuned)", duration=3.0) if key_is("debug_2", e.key): if grant_full_set("dawnrunner", "awakened", equip=True): pickup_feed.push_center("Debug: Equipped Dawnrunner (awakened)", duration=3.0) if key_is("debug_3", e.key): inst = grant_five_tempered() 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 and not diary_ui.visible: near_lantern = get_near_lantern(player, save_points) if near_lantern: save_at_lantern(near_lantern) elif 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 and not diary_ui.visible: paused = True pause_menu = "main" pause_menu_index = 0 settings_wait_action = None pause_use_mouse = False if (not paused) and (not inv_ui.visible) and (not trade_ui.visible): if key_is("toggle_shade", e.key): player.toggle_shade() if key_is("attack", e.key): player.attack() if key_is("ranged", e.key): player.try_ranged_attack(projectiles, enemies, platforms) if key_is("dash", e.key): player.try_dash() if key_is("shatterstep", e.key): player.try_shatterstep() if key_is("tether", e.key): player.try_tether(tether_shots) if key_is("reality_break", e.key) and player.has_reality_break: if player.shade: ROOM_DROP_MULT = 0.5 ROOM_COIN_MULT = 0.5 for en in enemies: en.health = 0 else: ROOM_DROP_MULT = 2.0 ROOM_COIN_MULT = 2.0 for en in enemies: en.reality_break_glow = True player.reality_break_flash = REALITY_BREAK_FLASH_DURATION if key_is("use_butterfly_jar", e.key): if player.consume_butterfly_jar(): pickup_feed.push_center("Butterfly jar consumed - Charge restored!", duration=2.0) else: pickup_feed.push_center("No butterfly jars left!", duration=1.5) keys = pygame.key.get_pressed() if paused: # draw last frame + pause overlay screen.fill(BG) # Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs) draw_layerable(screen, camera_x, camera_y) for sp in shatter_platforms: sp.draw(screen, camera_x, camera_y) for aoe in shatter_aoes: aoe.draw(screen, camera_x, camera_y) for dp in dust_puffs: dp.draw(screen, camera_x, camera_y) for drop in loot_drops: 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: pr.draw(screen, camera_x, camera_y) for ts in tether_shots: ts.draw(screen, camera_x, camera_y) if corpse is not None and corpse.room_id == ROOM.id: corpse.draw(screen, camera_x, camera_y) player.draw(screen, camera_x, camera_y) if VIGNETTE_LIGHT and VIGNETTE_SHADE: if player.shade: screen.blit(VIGNETTE_SHADE, (0, 0)) screen.blit(VIGNETTE_SHADE_EDGE, (0, 0)) else: screen.blit(VIGNETTE_LIGHT, (0, 0)) screen.blit(VIGNETTE_LIGHT_EDGE, (0, 0)) # HUD draw_shatterstep_icon(screen, player) draw_tether_icon(screen, player) 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 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 pygame.draw.rect(screen, CHARGE_FILL, (WIDTH - 220, 45, int(200 * ch_ratio), 20)) coin_y = int(_ui(["coins_hud", "y"], 72)) coin_x = WIDTH - int(_ui(["coins_hud", "x_start_offset"], 220)) if COIN_LIGHT_RAW: icon = pygame.transform.scale(COIN_LIGHT_RAW, (COIN_UI_SIZE, COIN_UI_SIZE)) screen.blit(icon, (coin_x, coin_y)) screen.blit( font.render(str(player.coins_light), True, (240, 240, 240)), (coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1) ) coin_x += int(_ui(["coins_hud", "group_spacing"], 90)) if COIN_DARK_RAW: icon = pygame.transform.scale(COIN_DARK_RAW, (COIN_UI_SIZE, COIN_UI_SIZE)) screen.blit(icon, (coin_x, coin_y)) screen.blit( font.render(str(player.coins_dark), True, (240, 240, 240)), (coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1) ) draw_butterfly_jar_slots( screen, WIDTH - int(_ui(["coins_hud", "x_start_offset"], 220)), coin_y + COIN_UI_SIZE + COIN_UI_GAP + 4, ) overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA) overlay.fill((0, 0, 0, 150)) screen.blit(overlay, (0, 0)) pause_text = font.render("Paused", True, (240, 240, 240)) screen.blit(pause_text, (WIDTH // 2 - pause_text.get_width() // 2, HEIGHT // 2 - 10)) if pause_menu == "main": draw_pause_menu(screen, pause_menu_index, pause_use_mouse) draw_pause_controls(screen, 20, HEIGHT // 2 + 190) else: draw_settings_menu(screen, settings_index, settings_wait_action) if save_feedback_timer > 0: sf = font.render(save_feedback, True, (150, 220, 150)) screen.blit(sf, (WIDTH // 2 - sf.get_width() // 2, HEIGHT // 2 + 160)) pygame.display.flip() continue # Close inventory on movement input, then allow movement if inv_ui.visible: move_input = ( key_pressed(keys, "move_left") or key_pressed(keys, "move_right") or key_pressed(keys, "jump") or key_pressed(keys, "move_down") ) if move_input: inv_ui.toggle() keys = pygame.key.get_pressed() else: # zero out input by simulating no keys pressed keys = [0] * len(keys) # light source regen boost player.charge_regen_mult = 1.0 for ls in light_sources: px, py = player.rect.midbottom dx = px - ls.x dy = py - ls.y if (dx * dx + dy * dy) <= (ls.radius * ls.radius): player.charge_regen_mult = 2.0 break # Lantern save point: being near a lantern enables the "Press F to save" prompt; # the actual save + respawn point update happens when the player presses interact. near_save_point = False for sp in save_points: px, py = player.rect.midbottom dx = px - sp.x dy = py - sp.y if (dx * dx + dy * dy) <= (sp.radius * sp.radius): near_save_point = True 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): if (not player.shatterstep_platform_spawned) and player.shatterstep_platform_cd <= 0.0: jump_v0 = abs(JUMP_LIGHT) gravity = GRAVITY_LIGHT jump_h = (jump_v0 * jump_v0) / (2.0 * gravity) if gravity > 0 else 0.0 target_y = player.shatterstep_jump_start_y - (0.8 * jump_h) if float(player.rect.bottom) <= target_y: w, h = SHATTER_PLATFORM_SIZE px = int(player.rect.centerx - w // 2) py = int(player.rect.bottom + 2) shatter_platforms.append(ShatterPlatform(px, py, w, h, SHATTER_PLATFORM_LIFETIME)) player.shatterstep_platform_cd = SHATTER_PLATFORM_COOLDOWN player.shatterstep_platform_spawned = True if player.just_landed and player.shatterstep_landing_pending: w, h = SHATTER_AOE_SIZE ax = int(player.rect.centerx - w // 2) ay = int(player.rect.bottom - h) aoe_rect = pygame.Rect(ax, ay, int(w), int(h)) for en in enemies: if en.health > 0 and aoe_rect.colliderect(en.rect): en.take_damage(player.shatterstep_landing_damage) shatter_aoes.append(ShatterAOE(aoe_rect, SHATTER_AOE_LIFETIME)) base_y = int(player.rect.bottom) for _ in range(9): off_x = random.uniform(-w * 0.55, w * 0.55) off_y = random.uniform(-8, 6) scale = random.uniform(0.7, 1.4) dust_puffs.append(DustPuff(int(player.rect.centerx + off_x), base_y + off_y, lifetime=0.45, scale=scale)) player.shatterstep_landing_pending = False player.shatterstep_landing_damage = 0.0 if trade_ui.visible and (player.shade or not get_near_npc(player, npcs)): 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) camera_y = float(player.rect.centery - HEIGHT // 2) continue # Respawn on death/void if player.rect.top > KILL_Y or player.health <= 0: apply_death_penalty() 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: # Respawn at the latest lantern save point if one has been touched. if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS: lx, ly = LANTERN_RESPAWN_POS if LANTERN_RESPAWN_ROOM != ROOM.id: room = room_manager.load_room(LANTERN_RESPAWN_ROOM) room.player_spawn = [int(lx), int(ly)] apply_room(room, set_player_spawn=True) player.respawn(int(lx), int(ly)) else: player.respawn(int(lx), int(ly)) reset_room_combat_state() else: player.respawn(int(ROOM.player_spawn[0]), int(ROOM.player_spawn[1])) reset_room_combat_state() player.shatterstep_landing_pending = False player.shatterstep_landing_damage = 0.0 # Update enemies for en in enemies: en.update(dt, collision_platforms, ground_platforms, player, shockwaves) # Enemy enemies do NOT physically collide. # (Prevents enemies from shoving each other into platforms / off ledges.) # resolve_enemy_collisions(enemies) atk_boxes = player.get_attack_hitboxes() if atk_boxes and not player.damage_applied: dmg = 2 if player.shade else 1 for en in enemies: if any(box.colliderect(en.rect) for box in atk_boxes): en.take_damage(dmg) player.damage_applied = True # Dash interactions if player.is_dashing(): for en in enemies: if en.health <= 0: continue if player.rect.colliderect(en.rect): eid = id(en) if eid in player.dash_hit_ids: continue player.dash_hit_ids.add(eid) if player.shade: dmg = 4 # double shade melee damage (2 -> 4) en.take_damage(dmg) else: # brief invuln on hit + stun player.invincibility_timer = max(player.invincibility_timer, DASH_LIGHT_INVULN) if getattr(en, "type_id", "") == "boss": en.apply_stun(DASH_STUN_BOSS) elif getattr(en, "type_id", "") == "miniboss": en.apply_stun(DASH_STUN_MINIBOSS) else: en.apply_stun(DASH_STUN_NORMAL) # Enemy contact damage (only when player is Light) for en in enemies: if (not player.shade) and player.rect.colliderect(en.rect): did_hit = player.take_damage(en.contact_damage) if did_hit: player.apply_knockback(en.rect, strength_px_per_frame=12.0, duration=0.20, pop_up_vel=-3.5) # Update projectiles for pr in projectiles: pr.update(dt, collision_platforms, enemies, ability_doors) projectiles = [pr for pr in projectiles if pr.alive] # Update tether shots for ts in tether_shots: ts.update(dt, collision_platforms, enemies) tether_shots = [ts for ts in tether_shots if ts.alive] # Update shockwaves for sw in shockwaves: sw.update(dt, collision_platforms) shockwaves = [sw for sw in shockwaves if sw.alive] # Update shatter effects for sp in shatter_platforms: sp.update(dt) shatter_platforms = [sp for sp in shatter_platforms if sp.alive] for aoe in shatter_aoes: aoe.update(dt) shatter_aoes = [aoe for aoe in shatter_aoes if aoe.alive] for dp in dust_puffs: dp.update(dt) dust_puffs = [dp for dp in dust_puffs if dp.alive] # Shockwave hits player (only when player is Light) for sw in shockwaves: if (not player.shade) and player.rect.colliderect(sw.rect): 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 # Update loot drops (gravity + landing) for drop in loot_drops: drop.update(dt, collision_platforms) for c in coin_drops: c.update(dt, collision_platforms) for t in token_drops: t.update(dt, collision_platforms) for bj in butterfly_jars: bj.update(dt) if reward_ability: reward_ability.update(dt) # Handle enemy death -> spawn drops alive = [] for en in enemies: if en.health > 0: alive.append(en) else: loot_drops.extend(spawn_drops_from_enemy(en)) 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 base_y = en.rect.top - 20 if getattr(en, "type_id", "") == "enemy": spawn_coins("light", roll_normal_light(), base_x, base_y) if player.shade: spawn_coins("dark", roll_normal_dark(), base_x, base_y) elif getattr(en, "type_id", "") == "miniboss": spawn_coins("light", roll_miniboss_light(), base_x, base_y) if player.shade: spawn_coins("dark", roll_miniboss_dark(), base_x, base_y) elif getattr(en, "type_id", "") == "boss": boss_light = roll_boss_light() spawn_coins("light", boss_light, base_x, base_y) if player.shade: spawn_coins("dark", boss_light, base_x, base_y) else: # fallback for non-normal enemies (kept as previous behavior) for _ in range(random.randint(1, 3)): kind = "light" if random.random() < 0.5 else "dark" coin_drops.append(CoinPickup(en.rect.centerx - 8, en.rect.top - 20, kind)) enemies = alive # Pickup loot (only when inventory closed) if not inv_ui.visible and not trade_ui.visible: if reward_chest and player.rect.colliderect(reward_chest.rect): reward_chest.open(player) player.reward_chest_opened = True if reward_ability and player.rect.colliderect(reward_ability.rect): if reward_ability.ability_id == "ranged": player.has_ranged = True pickup_feed.push_center("Ranged Ability aquired (Press Q)", duration=5.0) else: pickup_feed.push_center(f"Ability acquired: {reward_ability.ability_id}", duration=5.0) reward_ability = None for drop in loot_drops[:]: if player.rect.colliderect(drop.rect): inventory.add(drop.item) name = drop.item.get("name", drop.item.get("id", "Item")) rarity = drop.item.get("rarity", "") pickup_feed.push(name, rarity) loot_drops.remove(drop) for c in coin_drops[:]: if player.rect.colliderect(c.rect): if c.kind == "light": player.coins_light += 1 else: player.coins_dark += 1 coin_drops.remove(c) for bj in butterfly_jars[:]: if player.rect.colliderect(bj.rect): if player.butterfly_jars < BUTTERFLY_JARS_MAX: player.butterfly_jars += 1 if bj.key: COLLECTED_JARS.add(bj.key) pickup_feed.push_center( f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})", duration=2.5, ) butterfly_jars.remove(bj) else: pickup_feed.push_center("You can't carry any more butterfly jars!", duration=2.0) 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) # Corpse: touching it grants back the coins lost on death. if corpse is not None and corpse.room_id == ROOM.id: if player.rect.colliderect(corpse.rect): recovered = corpse.coins_light + corpse.coins_dark player.coins_light += corpse.coins_light player.coins_dark += corpse.coins_dark if recovered > 0: pickup_feed.push_center(f"Recovered {recovered} coins from your corpse!", duration=2.5) corpse = None # Camera follow camera_x += ((player.rect.centerx - WIDTH // 2) - camera_x) * 0.1 camera_y += ((player.rect.centery - HEIGHT // 2) - camera_y) * 0.1 # ---------------------------- # Draw # ---------------------------- screen.fill(BG) # Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs) draw_layerable(screen, camera_x, camera_y) for sp in shatter_platforms: sp.draw(screen, camera_x, camera_y) for aoe in shatter_aoes: aoe.draw(screen, camera_x, camera_y) for dp in dust_puffs: dp.draw(screen, camera_x, camera_y) # loot for drop in loot_drops: drop.draw(screen, camera_x, camera_y) # coins 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) # shockwaves for sw in shockwaves: sw.draw(screen, camera_x, camera_y) # projectiles for pr in projectiles: pr.draw(screen, camera_x, camera_y) for ts in tether_shots: ts.draw(screen, camera_x, camera_y) # entities (NPCs/enemies are drawn by draw_layerable for layer support) if corpse is not None and corpse.room_id == ROOM.id: corpse.draw(screen, camera_x, camera_y) player.draw(screen, camera_x, camera_y) # NPC greeting bubble (shown near the player) greet_npc = get_near_npc(player, npcs) if greet_npc: if player.shade: shade_text = greet_npc.greet_shade or "Stay away from me, evil!" draw_speech_bubble( screen, shade_text, int(greet_npc.rect.centerx - camera_x), int(greet_npc.rect.top - camera_y), ) else: light_text = greet_npc.greet_light or "Greetings, little light!" hint_text = greet_npc.greet_hint or "(Press F to interact)" draw_speech_bubble( screen, light_text, int(greet_npc.rect.centerx - camera_x), int(greet_npc.rect.top - camera_y), 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), ) # Lantern save hint bubble (shown near the player) if not inv_ui.visible and not trade_ui.visible and not diary_ui.visible: near_lantern = get_near_lantern(player, save_points) if near_lantern: draw_speech_bubble( screen, "Press F - Save at lantern", int(near_lantern.rect.centerx - camera_x), int(near_lantern.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: bar_w = 360 bar_h = 18 x = WIDTH // 2 - bar_w // 2 y = 10 pygame.draw.rect(screen, (25, 25, 25), (x, y, bar_w, bar_h)) pygame.draw.rect(screen, (120, 120, 120), (x, y, bar_w, bar_h), 2) ratio = (boss.health / boss.max_health) if boss.max_health > 0 else 0 pygame.draw.rect(screen, BOSS_COLOR, (x, y, int(bar_w * ratio), bar_h)) txt = font.render(f"{boss.health}/{boss.max_health}", True, (240, 240, 240)) screen.blit(txt, (x + bar_w + 10, y - 1)) if VIGNETTE_LIGHT and VIGNETTE_SHADE: if player.shade: screen.blit(VIGNETTE_SHADE, (0, 0)) screen.blit(VIGNETTE_SHADE_EDGE, (0, 0)) else: screen.blit(VIGNETTE_LIGHT, (0, 0)) screen.blit(VIGNETTE_LIGHT_EDGE, (0, 0)) draw_shatterstep_icon(screen, player) draw_tether_icon(screen, player) draw_reality_break_icon(screen, player) # 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 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)) ch_ratio = (player.charge / player.max_charge) if player.max_charge > 0 else 0 pygame.draw.rect(screen, CHARGE_FILL, (WIDTH - 220, 45, int(200 * ch_ratio), 20)) # --- Coins HUD (icons + counts) --- coin_y = int(_ui(["coins_hud", "y"], 72)) coin_x = WIDTH - int(_ui(["coins_hud", "x_start_offset"], 220)) if COIN_LIGHT_RAW: icon = pygame.transform.scale(COIN_LIGHT_RAW, (COIN_UI_SIZE, COIN_UI_SIZE)) screen.blit(icon, (coin_x, coin_y)) screen.blit( font.render(str(player.coins_light), True, (240, 240, 240)), (coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1) ) # spacing between light + dark coin_x += int(_ui(["coins_hud", "group_spacing"], 90)) if COIN_DARK_RAW: icon = pygame.transform.scale(COIN_DARK_RAW, (COIN_UI_SIZE, COIN_UI_SIZE)) screen.blit(icon, (coin_x, coin_y)) screen.blit( font.render(str(player.coins_dark), True, (240, 240, 240)), (coin_x + COIN_UI_SIZE + COIN_UI_GAP, coin_y - 1) ) draw_butterfly_jar_slots( screen, WIDTH - int(_ui(["coins_hud", "x_start_offset"], 220)), coin_y + COIN_UI_SIZE + COIN_UI_GAP + 4, ) 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() pygame.quit()