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.49 # recovery between melee swings; attack speed reduces it # Horizontal deadzone: enemies only turn toward the player when the player is this far # to one side, so a player standing directly above/below no longer makes them wiggle. CHASE_DEADZONE = 6 # --- Miniboss "Discharge" (anti-camp AOE) --- DISCHARGE_DAMAGE = 2 # AOE damage to the player DISCHARGE_CHARGE_TIME = 1.5 # seconds standing still before the blast (telegraph) DISCHARGE_RADIUS = 280 # blast radius in px (ignores platforms) DISCHARGE_COOLDOWN = 4.0 # seconds before the miniboss can discharge again DISCHARGE_SUSTAIN = 0.6 # how long the player must be unreachable before it commits DISCHARGE_ABOVE_GAP = 30 # player feet must be this far above the miniboss's head to count as unreachable # --- 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.63 # 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) # --- Dissolve platforms (per-room "dissolve" group) --- DISSOLVE_STAND_TIME = 1.0 # seconds the player must stand before it dissolves DISSOLVE_TIME = 2.0 # seconds it stays translucent (no hitbox) once dissolved # --- 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, "fairy_mode": pygame.K_z, "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"), ("Fairy Mode", "fairy_mode"), ("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 # Fairy Dust collectibles (persisted in saves; feed the Fairy Dust quest) FAIRY_DUST_COLLECTED: set[str] = set() 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], {}) FAIRY_CFG = load_json_safe("fairy.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 _fairy(path, default=None): cur = FAIRY_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"]) FLYER_BOSS_COLOR = _color(["enemy", "boss_flyer"], (150, 90, 220)) FLYER_BOSS_HIT_COLOR = _color(["enemy", "boss_flyer_hit"], (210, 170, 255)) SWOOPER_COLOR = _color(["enemy", "swooper"], (90, 180, 220)) SWOOPER_HIT_COLOR = _color(["enemy", "swooper_hit"], (170, 225, 255)) 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)) JUMP_RELEASE_GRAVITY_MULT = float(_phys(["player", "jump_release_gravity_mult"], 3.0)) SHADE_DOOR_GRACE = float(_phys(["player", "shade_door_grace"], 0.6)) # Down-slash pogo: upward velocity after stomping an enemy, and how long the # player keeps jump-hold gravity + contact-damage immunity afterwards. DOWN_SLASH_BOUNCE = float(_phys(["player", "down_slash_bounce"], -9.0)) POGO_GRACE = float(_phys(["player", "pogo_grace"], 0.35)) # 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)) CHARGE_EMPTY_LOCK = float(_phys(["resources", "charge_empty_lock"], 1.5)) 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))) # --- Fairy combat (granted by the Fairy Dust quest) --- FAIRY_SHOT_CHARGE_COST = float(_ranged(["fairy", "charge_cost"], RANGED_CHARGE_COST * 2.0)) FAIRY_SHOT_COOLDOWN = float(_ranged(["fairy", "cooldown"], 1.4)) FAIRY_SHOT_RANGE = float(_ranged(["fairy", "range"], 560.0)) FAIRY_VOICE_EVERY = max(1, int(_ranged(["fairy", "voice_every"], 3))) FAIRY_DAMAGE_MULT = float(_ranged(["fairy", "damage_mult"], RANGED_DAMAGE_MULT)) FAIRY_PROJECTILE_SPEED = float(_ranged(["fairy", "speed"], 520.0)) FAIRY_PROJECTILE_LIFETIME = float(_ranged(["fairy", "lifetime"], 1.4)) FAIRY_PROJECTILE_SIZE = tuple(_ranged(["fairy", "size"], [16, 16])) FAIRY_SPREAD_DEG = float(_ranged(["fairy", "spread_deg"], 6.0)) FAIRY_DUST_MAX = 5 # --- 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) # --- Mist overlay (per-room environmental haze; tiles seamlessly) --- MIST_TILE_SIZE = 320 def _build_mist_tile(size: int = MIST_TILE_SIZE) -> pygame.Surface: surf = pygame.Surface((size, size), pygame.SRCALPHA) rng = random.Random(1337) surf.fill((80, 25, 130, 6)) for _ in range(14): cx = rng.randint(0, size) cy = rng.randint(0, size) r = rng.randint(50, 130) alpha = rng.randint(8, 20) color = (96, 30, 150, alpha) for ox in (-size, 0, size): for oy in (-size, 0, size): pygame.draw.circle(surf, color, (cx + ox, cy + oy), r) return surf def _build_mist_tile_slow(size: int = MIST_TILE_SIZE) -> pygame.Surface: """Second, fainter mist layer with a different pattern, used to make the haze visibly drift (two layers scrolling at different speeds).""" surf = pygame.Surface((size, size), pygame.SRCALPHA) rng = random.Random(90210) surf.fill((70, 20, 115, 4)) for _ in range(9): cx = rng.randint(0, size) cy = rng.randint(0, size) r = rng.randint(70, 160) alpha = rng.randint(5, 12) color = (110, 40, 165, alpha) for ox in (-size, 0, size): for oy in (-size, 0, size): pygame.draw.circle(surf, color, (cx + ox, cy + oy), r) return surf MIST_TILE = _build_mist_tile() MIST_TILE_SLOW = _build_mist_tile_slow() def draw_mist_overlay(target: pygame.Surface): if MIST_TILE is None or not getattr(ROOM, "mist", False): return size = MIST_TILE_SIZE t = pygame.time.get_ticks() / 1000.0 # Base layer drifts one way... dx = int((t * 30.0) % size) dy = int((t * 22.0) % size) for tx in range(-1, WIDTH // size + 2): for ty in range(-1, HEIGHT // size + 2): target.blit(MIST_TILE, (tx * size - dx, ty * size - dy)) # ...while the fainter layer drifts slower and in a different direction, # so the mist visibly curls instead of looking like a static texture. if MIST_TILE_SLOW is not None: dx2 = int((t * 9.0) % size) dy2 = int((size - int((t * 5.0) % size)) % size) for tx in range(-1, WIDTH // size + 2): for ty in range(-1, HEIGHT // size + 2): target.blit(MIST_TILE_SLOW, (tx * size - dx2, ty * size - dy2)) 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", "behavior": "shockwave", "charger": { "retreat_dist": 150, "retreat_speed_mult": 1.2, "retreat_jump_vel": -14.0, "charge_speed_mult": 4.0, "charge_duration": 1.1, "dash_min_dist": 160, "dash_max_dist": 420, "dash_speed_mult": 4.2, "dash_duration": 0.7, "attack_cooldown": 1.8, "above_retreat": True } }, "boss_flyer": { "rect": [120, 90], "health": 26, "speed": 3.0, "air_speed": 3.0, "detection_range": 620, "contact_damage": 3, "drop_profile": "boss", "behavior": "flyer", "flyer": { "flight_height": 220, "bob_amplitude": 20, "bob_speed": 2.2, "hover_speed": 2.6, "slam_cd": 6.0, "slam_dive_vel": 16.0, "ground_time": 2.0, "summon_cd": 8.0, "summon_max": 3, "summon_type": "boss_minion", "summon_life": 6.0, "summon_heal": 4, "summon_damage": 3, "aoe_cd": 9.0, "aoe_radius": 300, "aoe_damage": 1, "aoe_duration": 0.9, "shot_cd": 5.0, "shot_damage": 5, "shot_speed": 300, "shot_lifetime": 2.2, "shot_homing": 1.8 } }, "boss_minion": { "rect": [34, 50], "health": 4, "speed": 2.4, "air_speed": 2.2, "detection_range": 320, "contact_damage": 1, "drop_profile": "normal" }, "swooper": { "rect": [40, 40], "health": 3, "speed": 2.0, "air_speed": 2.0, "detection_range": 340, "contact_damage": 1, "drop_profile": "normal", "behavior": "swooper", "swooper": { "bob_amplitude": 6, "bob_speed": 2.4, "charge_speed_mult": 3.4, "charge_duration": 0.8, "charge_cooldown": 1.8, "return_speed": 2.2, "windup": 0.0 } }, } 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: arr = [p[0], p[1], p[2], p[3]] if len(p) >= 5: arr.append(p[4]) grouped["ground"].append(arr) elif isinstance(p, dict): group = p.get("group", "ground") if group in ("moving", "trigger", "hazard", "dissolve"): obj = {"x": p.get("x", 0), "y": p.get("y", 0), "w": p.get("w", 0), "h": p.get("h", 0)} if "layer" in p and p.get("layer"): obj["layer"] = p["layer"] if group == "moving": if isinstance(p.get("path"), list): obj["path"] = p["path"] if "speed" in p: obj["speed"] = p["speed"] if group == "dissolve": if "stand_time" in p: obj["stand_time"] = p["stand_time"] if "dissolve_time" in p: obj["dissolve_time"] = p["dissolve_time"] if "name" in p: obj["name"] = p["name"] if "quest" in p: obj["quest"] = p["quest"] if "quest_complete" in p: obj["quest_complete"] = p["quest_complete"] if "quest_turned_in" in p: obj["quest_turned_in"] = p["quest_turned_in"] if "lock_when_pass" in p: obj["lock_when_pass"] = p["lock_when_pass"] if "open_on_miniboss_dead" in p: obj["open_on_miniboss_dead"] = p["open_on_miniboss_dead"] if "open_on_boss_dead" in p: obj["open_on_boss_dead"] = p["open_on_boss_dead"] if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2: obj["respawn"] = p["respawn"] grouped.setdefault(group, []).append(obj) else: rect = [p.get("x", 0), p.get("y", 0), p.get("w", 0), p.get("h", 0)] if p.get("layer"): rect.append(p["layer"]) grouped.setdefault(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 # props -> ensure pos:[x,y] props = out.get("props", []) if isinstance(props, list): norm_props = [] for p in props: if not isinstance(p, dict): continue if not (isinstance(p.get("pos"), (list, tuple)) and len(p["pos"]) == 2): if "x" in p and "y" in p: p = dict(p) p["pos"] = [p.get("x", 0), p.get("y", 0)] norm_props.append(p) out["props"] = norm_props # 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: nd = dict(d) nd["kind"] = d.get("kind", "ranged") norm_doors.append(nd) 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)], "kind": d.get("kind", "ranged"), }) 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] = {} self.platform_meta: dict[int, dict] = {} # id(rect) -> {"name"|"path"|"speed", ...} self.platforms_dissolve: list[pygame.Rect] = [] 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 = [] self.platforms_hazard = [] self.platforms_trigger = [] self.platforms_moving = [] 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) self.platforms_hazard = [] for hp in platforms.get("hazard", []): if isinstance(hp, dict): rect = pygame.Rect(int(hp.get("x", 0)), int(hp.get("y", 0)), int(hp.get("w", 0)), int(hp.get("h", 0))) self.platform_layer[id(rect)] = int(hp.get("layer", 0)) respawn = hp.get("respawn") if isinstance(respawn, (list, tuple)) and len(respawn) >= 2: self.platform_meta[id(rect)] = {"respawn": [int(respawn[0]), int(respawn[1])]} self.platforms_hazard.append(rect) elif isinstance(hp, (list, tuple)) and len(hp) >= 4: rect = pygame.Rect(int(hp[0]), int(hp[1]), int(hp[2]), int(hp[3])) if len(hp) >= 5: try: self.platform_layer[id(rect)] = int(hp[4]) except (TypeError, ValueError): pass self.platforms_hazard.append(rect) self.platforms_trigger = [] for tp in platforms.get("trigger", []): if isinstance(tp, dict): rect = pygame.Rect(int(tp.get("x", 0)), int(tp.get("y", 0)), int(tp.get("w", 0)), int(tp.get("h", 0))) self.platform_layer[id(rect)] = int(tp.get("layer", 0)) meta = {"name": str(tp.get("name", ""))} if tp.get("quest"): meta["quest"] = str(tp.get("quest")) if tp.get("quest_complete"): meta["quest_complete"] = str(tp.get("quest_complete")) if tp.get("quest_turned_in"): meta["quest_turned_in"] = str(tp.get("quest_turned_in")) if tp.get("lock_when_pass"): meta["lock_when_pass"] = True if tp.get("open_on_miniboss_dead"): meta["open_on_miniboss_dead"] = True if tp.get("open_on_boss_dead"): meta["open_on_boss_dead"] = True self.platform_meta[id(rect)] = meta self.platforms_trigger.append(rect) elif isinstance(tp, (list, tuple)) and len(tp) >= 4: rect = pygame.Rect(int(tp[0]), int(tp[1]), int(tp[2]), int(tp[3])) if len(tp) >= 5: try: self.platform_layer[id(rect)] = int(tp[4]) except (TypeError, ValueError): pass self.platforms_trigger.append(rect) self.platforms_moving = [] for mp in platforms.get("moving", []): if isinstance(mp, dict): rect = pygame.Rect(int(mp.get("x", 0)), int(mp.get("y", 0)), int(mp.get("w", 0)), int(mp.get("h", 0))) self.platform_layer[id(rect)] = int(mp.get("layer", 0)) path = mp.get("path") or [] if isinstance(path, list) and all(isinstance(w, (list, tuple)) and len(w) >= 2 for w in path): path = [[int(w[0]), int(w[1])] for w in path] else: path = [[rect.x, rect.y]] speed = float(mp.get("speed", 120)) m_meta = {"path": path, "speed": speed, "i": 0, "t": 0.0} if mp.get("open_on_boss_dead"): m_meta["open_on_boss_dead"] = True self.platform_meta[id(rect)] = m_meta self.platforms_moving.append(rect) elif isinstance(mp, (list, tuple)) and len(mp) >= 4: rect = pygame.Rect(int(mp[0]), int(mp[1]), int(mp[2]), int(mp[3])) if len(mp) >= 5: try: self.platform_layer[id(rect)] = int(mp[4]) except (TypeError, ValueError): pass self.platforms_moving.append(rect) self.platforms_dissolve = [] for dp in platforms.get("dissolve", []): if isinstance(dp, dict): rect = pygame.Rect(int(dp.get("x", 0)), int(dp.get("y", 0)), int(dp.get("w", 0)), int(dp.get("h", 0))) self.platform_layer[id(rect)] = int(dp.get("layer", 0)) self.platform_meta[id(rect)] = { "stand_time": float(dp.get("stand_time", DISSOLVE_STAND_TIME)), "dissolve_time": float(dp.get("dissolve_time", DISSOLVE_TIME)), "timer": 0.0, "dissolving": False, } self.platforms_dissolve.append(rect) elif isinstance(dp, (list, tuple)) and len(dp) >= 4: rect = pygame.Rect(int(dp[0]), int(dp[1]), int(dp[2]), int(dp[3])) if len(dp) >= 5: try: self.platform_layer[id(rect)] = int(dp[4]) except (TypeError, ValueError): pass self.platforms_dissolve.append(rect) else: self.platforms_ground = [] self.platforms_floating = [] self.platforms_hazard = [] self.platforms_trigger = [] self.platforms_moving = [] self.platforms_dissolve = [] self.ground_platforms = self.platforms_ground[:] self.platforms = self.platforms_ground + self.platforms_floating + self.platforms_moving all_platform_rects = self.platforms_ground + self.platforms_floating + self.platforms_moving + self.platforms_hazard + self.platforms_trigger + self.platforms_dissolve if all_platform_rects: self.min_x = min(r.left for r in all_platform_rects) self.max_x = max(r.right for r in all_platform_rects) else: self.min_x = 0 self.max_x = 2000 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.mist = bool(room_json.get("mist", False)) if isinstance(room_json, dict) else False 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, "kind": d.get("kind", "ranged") if isinstance(d, dict) else "ranged"} 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" ATTACK_SPRITE_PATH = ABILITY_SPRITES_DIR / "basic_attack.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) ATTACK_SPRITE_RAW = _load_image_or_none(ATTACK_SPRITE_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", ]) FAIRY_PROJECTILE_SPRITE_RAW = _load_first_existing([ ABILITY_SPRITES_DIR / "fairy_projectile.png", ABILITY_SPRITES_DIR / "Fairy_projectile.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", ]) BOSS_TOKEN_SPRITE_CACHE: dict[str, pygame.Surface | None] = {} def boss_token_sprite(boss_id) -> pygame.Surface | None: """Per-boss token sprite. Uses ``token_sprite`` from BOSS_DEFS_DEFAULT when set, else ``_token.png``; falls back to the shared token sprite.""" key = str(boss_id or "") if key in BOSS_TOKEN_SPRITE_CACHE: return BOSS_TOKEN_SPRITE_CACHE[key] bd = BOSS_DEFS_DEFAULT.get(key, {}) if key else {} candidates: list[Path] = [] if isinstance(bd, dict) and bd.get("token_sprite"): candidates.append(TOKENS_DIR / str(bd["token_sprite"])) if key: candidates.append(TOKENS_DIR / f"{key}_token.png") img = _load_first_existing(candidates) if candidates else None if img is None: img = BOSS_TOKEN_SPRITE_RAW BOSS_TOKEN_SPRITE_CACHE[key] = img return img # Flying boss projectile sprite (faces RIGHT by default; flipped when fired left) ENEMY_SKILLS_DIR = DATA_ROOT / "sprites" / "enemy_skills" BOSS_PROJECTILE_RAW = _load_first_existing([ ENEMY_SKILLS_DIR / "boss_ranged.png", ENEMY_SKILLS_DIR / "boss_ranged", ENEMY_SKILLS_DIR / "Boss_ranged.png", ]) BOSS_PROJECTILE_RAW_LEFT = pygame.transform.flip(BOSS_PROJECTILE_RAW, True, False) if BOSS_PROJECTILE_RAW is not None else None # --- Enemy sprites (authored at each enemy's collision-rect size) --- ENEMIES_SPRITES_DIR = DATA_ROOT / "sprites" / "enemies" def _load_enemy_sprite(name: str): return _load_image_or_none(ENEMIES_SPRITES_DIR / name) ENEMY_SPRITES = { "ground_enemy": _load_enemy_sprite("ground_enemy_1.png"), "ground_miniboss": _load_enemy_sprite("ground_miniboss_1.png"), "ground_boss": _load_enemy_sprite("ground_boss_1.png"), "flying_enemy": _load_enemy_sprite("flying_enemy_1.png"), "flying_boss": _load_enemy_sprite("flying_boss_1.png"), "flying_boss_minion": _load_enemy_sprite("boss_flyer_minion.png"), } def enemy_sprite_info(type_id: str, behavior: str): """Return ``(raw_surface | None, source_facing)`` for an enemy. ``source_facing`` is ``"right"`` / ``"left"`` for art that must be mirrored to match the enemy's facing, or ``None`` for art with no direction (drawn as authored). A ``None`` surface means "no sprite yet" -> the entity keeps its colored-rectangle placeholder.""" if behavior == "flyer": return ENEMY_SPRITES["flying_boss"], None if behavior == "swooper": return ENEMY_SPRITES["flying_enemy"], None if behavior == "charger": return ENEMY_SPRITES["ground_boss"], "left" if behavior == "shockwave": return None, None # shockwave boss art not made yet if type_id == "boss_minion": return ENEMY_SPRITES["flying_boss_minion"], None if type_id == "miniboss": return ENEMY_SPRITES["ground_miniboss"], "right" return ENEMY_SPRITES["ground_enemy"], "right" 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_DUST_PATH = OBJECTS_DIR / "fairy_dust.png" FAIRY_DUST_RAW = _load_image_or_none(FAIRY_DUST_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, poison: bool = True): 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) self.poison = bool(poison) 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, dx: float, dy: float, damage: float): w, h = PROJECTILE_SIZE self.rect = pygame.Rect(int(x), int(y), int(w), int(h)) self.dir = 1 if dx >= 0 else -1 self.dir_x = float(dx) self.dir_y = float(dy) 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_x * self.speed * dt) self.rect.y += int(self.dir_y * 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 getattr(d, "kind", "ranged") == "shade": continue # shade doors cannot be shot open 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)) # Raw sprite points LEFT; flip it so it points right, then rotate to # match the actual flight direction so up/down shots aren't horizontal. img = pygame.transform.flip(img, True, False) angle = -math.degrees(math.atan2(self.dir_y, self.dir_x)) img = pygame.transform.rotate(img, angle) surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx, self.rect.centery - img.get_height() // 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 FairyProjectile(Projectile): """Fairy combat shot. Same collision/hit rules as the player's ranged bolt, so it can also miss moving targets.""" def __init__(self, x, y, dx: float, dy: float, damage: float): super().__init__(x, y, dx, dy, damage) w, h = FAIRY_PROJECTILE_SIZE self.rect = pygame.Rect(int(x), int(y), int(w), int(h)) self.speed = float(FAIRY_PROJECTILE_SPEED) self.t = float(FAIRY_PROJECTILE_LIFETIME) def draw(self, surface, cx, cy): img_raw = FAIRY_PROJECTILE_SPRITE_RAW if img_raw is None: super().draw(surface, cx, cy) return target = 26 scale = target / max(1, img_raw.get_width(), img_raw.get_height()) w = max(1, int(img_raw.get_width() * scale)) h = max(1, int(img_raw.get_height() * scale)) img = pygame.transform.scale(img_raw, (w, h)) # Raw fairy sprite points LEFT; flip it so it points right, then rotate # to match the actual flight direction (same fix as the ranged bolt). img = pygame.transform.flip(img, True, False) angle = -math.degrees(math.atan2(self.dir_y, self.dir_x)) img = pygame.transform.rotate(img, angle) surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx, self.rect.centery - img.get_height() // 2 - cy)) class BossProjectile: """Homing projectile fired by the flying boss. Slightly curves toward the player each frame. Uses boss_ranged.png (faces right; flipped when fired to the left).""" def __init__(self, x, y, dx, dy, damage, speed, lifetime, homing, player): self.rect = pygame.Rect(0, 0, 26, 26) self.rect.center = (int(x), int(y)) self.dir_x = float(dx) self.dir_y = float(dy) d = math.hypot(self.dir_x, self.dir_y) if d > 0.0001: self.dir_x /= d self.dir_y /= d self.speed = float(speed) self.t = float(lifetime) self.damage = float(damage) self.homing = float(homing) self.player = player def update(self, dt: float, platforms: list[pygame.Rect]): # slight homing: steer the direction toward the player px = self.player.rect.centerx - self.rect.centerx py = self.player.rect.centery - self.rect.centery plen = math.hypot(px, py) if plen > 1.0: tx = px / plen ty = py / plen k = min(1.0, self.homing * dt) self.dir_x += (tx - self.dir_x) * k self.dir_y += (ty - self.dir_y) * k d = math.hypot(self.dir_x, self.dir_y) if d > 0.0001: self.dir_x /= d self.dir_y /= d self.rect.x += int(self.dir_x * self.speed * dt) self.rect.y += int(self.dir_y * self.speed * dt) self.t -= dt 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): if BOSS_PROJECTILE_RAW is not None: if self.dir_x >= 0: img = BOSS_PROJECTILE_RAW angle = -math.degrees(math.atan2(self.dir_y, self.dir_x)) else: img = BOSS_PROJECTILE_RAW_LEFT angle = math.degrees(math.atan2(self.dir_y, -self.dir_x)) img = pygame.transform.rotate(img, angle) surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx, self.rect.centery - img.get_height() // 2 - cy)) return # fallback: glowing bolt pygame.draw.rect( surface, (255, 120, 60), 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 BossAoe: """Huge expanding circular blast: low damage, high knockback, one hit.""" def __init__(self, x, y, max_radius, damage, duration, knock_strength=16.0, knock_duration=0.35, pop_up=-6.0): self.center = (int(x), int(y)) self.max_radius = int(max_radius) self.radius = 0 self.damage = int(damage) self.full_duration = max(0.05, float(duration)) self.t = float(duration) self.hit = False self.knock_strength = float(knock_strength) self.knock_duration = float(knock_duration) self.pop_up = float(pop_up) def update(self, dt: float): self.t -= dt progress = 1.0 - max(0.0, self.t / self.full_duration) self.radius = int(self.max_radius * progress) @property def alive(self) -> bool: return self.t > 0 def hits(self, rect: pygame.Rect) -> bool: if self.hit: return False cx, cy = self.center nx = max(rect.left, min(cx, rect.right)) ny = max(rect.top, min(cy, rect.bottom)) return math.hypot(cx - nx, cy - ny) <= self.radius def draw(self, surface, cx, cy): progress = 1.0 - max(0.0, self.t / self.full_duration) alpha = int(110 * (1.0 - progress * 0.65)) px = self.center[0] - cx py = self.center[1] - cy ring = pygame.Surface((self.radius * 2 + 6, self.radius * 2 + 6), pygame.SRCALPHA) pygame.draw.circle(ring, (255, 120, 60, alpha), (self.radius + 3, self.radius + 3), self.radius, 4) pygame.draw.circle(ring, (255, 210, 130, alpha), (self.radius + 3, self.radius + 3), self.radius, 1) surface.blit(ring, (px - self.radius - 3, py - self.radius - 3)) 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. One-time use per room visit: once used, the lantern stays spent until the player leaves the room and comes back (the flag resets on room load).""" def __init__(self, x: int, y: int, radius: int = 90): self.x = int(x) self.y = int(y) self.radius = int(radius) self.used = False 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)) if self.used: dim = pygame.Surface((self.rect.width, self.rect.height), pygame.SRCALPHA) dim.fill((30, 30, 40, 150)) surface.blit(dim, (self.rect.x - cx, self.rect.y - cy)) 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 FairyDust: """Collectible fairy dust; picked up with the interact key.""" INTERACT_RANGE = 70 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 FAIRY_DUST_RAW is not None: target = 26 scale = target / max(1, FAIRY_DUST_RAW.get_width(), FAIRY_DUST_RAW.get_height()) w = max(1, int(FAIRY_DUST_RAW.get_width() * scale)) h = max(1, int(FAIRY_DUST_RAW.get_height() * scale)) self.img = pygame.transform.smoothscale(FAIRY_DUST_RAW, (w, h)) self.rect = self.img.get_rect(midbottom=(self.x, self.y)) else: self.img = None self.rect = pygame.Rect(self.x - 12, self.y - 24, 24, 24) 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.circle(surface, (240, 230, 160), (int(self.rect.centerx - cx), int(self.rect.centery - cy + bob_y)), 8) pygame.draw.circle(surface, (180, 160, 90), (int(self.rect.centerx - cx), int(self.rect.centery - cy + bob_y)), 8, 2) 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], poison: bool = True): 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, poison )) shockwaves_list.append(Shockwave( x, y, 1, boss.shockwave_speed, boss.shockwave_lifetime, boss.shockwave_height, boss.shockwave_damage, poison )) 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, vx: float | None = None, vy: float | None = None): # 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) if vx is None else vx self.vy = random.uniform(-140, -60) if vy is None else vy 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, kind: str = "ranged"): self.rect = rect self.open = False self.room_id = room_id self.kind = "shade" if str(kind) == "shade" else "ranged" 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) if self.kind == "shade": pygame.draw.rect(surface, (110, 100, 170), r) pygame.draw.rect(surface, (60, 50, 110), r, 2) else: 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, kind=d.get("kind", "ranged")) 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 False light_amt, dark_amt = self.coins # spray coins across the chest width and above its top so they don't # all stack on a player standing directly in front of the chest half_span = self.rect.width // 2 + 24 for _ in range(max(0, int(light_amt))): ox = random.randint(-half_span, half_span) oy = random.randint(0, 40) coin_drops.append(CoinPickup( self.rect.centerx + ox, self.rect.top - 18 - oy, "light", vx=random.uniform(-170, 170), vy=random.uniform(-230, -90), )) for _ in range(max(0, int(dark_amt))): ox = random.randint(-half_span, half_span) oy = random.randint(0, 40) coin_drops.append(CoinPickup( self.rect.centerx + ox, self.rect.top - 18 - oy, "dark", vx=random.uniform(-170, 170), vy=random.uniform(-230, -90), )) self.opened = True return 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 _combat_master_quest_option(self) -> dict | None: """The Combat Master's token-collection quest, as a menu option.""" qid = "quest_combat_master_tokens" q = quest_manager.get(qid) if q is None: return None if qid in quest_manager.active: prog = int(quest_manager.active[qid].get("progress", 0)) if quest_manager.is_complete(qid): return {"type": "quest_turnin", "quest_id": qid, "label": f"Turn in: {q.name}"} return {"type": "quest_status", "quest_id": qid, "label": f"{q.name}: {prog}/{q.target_count()} boss tokens"} if qid in quest_manager.completed_one_time or qid in quest_manager.ever_completed: return None return {"type": "quest_accept", "quest_id": qid, "label": f"Take quest: {q.name}"} def _build_options(self, npc: NPC | None) -> list[dict]: # Combat Master: his quest first, then upgrade each carried boss token. if npc is not None and npc.type == "combat_master": options = [] qopt = self._combat_master_quest_option() if qopt: options.append(qopt) 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): 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")) # --- Quest options never cost coins --- if choice_type == "quest_status": return if choice_type == "quest_accept": qid = choice.get("quest_id") q = quest_manager.get(qid) if q is None: return if quest_manager.accept(qid): if (str(q.objective.get("kind", "")) == "collect" and str(q.objective.get("target", "")) == "boss_token"): held = quest_manager.boss_token_count_held() quest_manager.active[qid]["progress"] = min(q.target_count(), held) pickup_feed.push_plain(f"Quest accepted: {q.name}") else: pickup_feed.push_plain("Cannot take that quest right now.") self.options = self._build_options(self.active_npc) self.cursor = max(0, min(self.cursor, len(self.options) - 1)) if self.options else 0 return if choice_type == "quest_turnin": qid = choice.get("quest_id") q = quest_manager.get(qid) result = quest_manager.turn_in(qid) if result is None: pickup_feed.push_plain("Quest is not ready to turn in.") return name = q.name if q else "Quest" parts = [f"{name} complete!"] if result.get("coins"): self.player.coins_light += int(result["coins"]) parts.append(f"+{result['coins']} light coins") if result.get("tokens"): quest_manager.add_tokens(int(result["tokens"])) parts.append(f"+{result['tokens']} token") if result.get("item"): self.inv.add(result["item"]) parts.append(f"+{result['item'].get('name', 'Item')}") pickup_feed.push_center(" ".join(parts), duration=4.0) self.options = self._build_options(self.active_npc) self.cursor = max(0, min(self.cursor, len(self.options) - 1)) if self.options else 0 return # --- Paired trades (may cost light coins) --- cost = NPC_TRADE_COST if self.active_npc and self.active_npc.trade_cost is not None: cost = int(self.active_npc.trade_cost) 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 # ---------------------------- QUEST_ITEM_TYPES = ("boss_token",) def is_quest_item(it) -> bool: """Quest objects (boss tokens today; fairy dust is shown as a virtual entry).""" return isinstance(it, dict) and str(it.get("item_type", "")) in QUEST_ITEM_TYPES def is_equipment_item(it) -> bool: """Anything that is not a quest object is treated as Equipment.""" return isinstance(it, dict) and not is_quest_item(it) 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.tab = "equipment" # "equipment" or "quest" self.tabs = ("equipment", "quest") self.cursor = 0 self.scroll = 0 # Ring slot is planned; add "ring" here once implemented. self.equip_slots = ["helmet", "chest", "pants", "necklace"] 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_icons = {} # boss_id -> 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, boss_id=None) -> pygame.Surface | None: key = str(boss_id or "") if key not in self.token_icons: raw = boss_token_sprite(boss_id) icon = None if raw is not None: try: icon = pygame.transform.smoothscale(raw, (self.icon_size, self.icon_size)) except Exception: icon = None self.token_icons[key] = icon return self.token_icons[key] def _get_dust_icon(self) -> pygame.Surface | None: if getattr(self, "_dust_icon_cache", "missing") != "missing": return self._dust_icon_cache icon = None if FAIRY_DUST_RAW is not None: try: icon = pygame.transform.smoothscale(FAIRY_DUST_RAW, (self.icon_size, self.icon_size)) except Exception: icon = None self._dust_icon_cache = icon return icon def toggle(self): self.visible = not self.visible if self.visible: self.tab = "equipment" self.cursor = 0 self.scroll = 0 self._clamp_cursor() # ---------- tab rows ---------- def _bag_equipment_rows(self) -> list[dict]: return [{"kind": "bag", "item": it} for it in self.inv.items if is_equipment_item(it)] def _equipped_rows(self) -> list[dict]: return [{"kind": "equipped", "slot": s, "item": self.player.equipment.get(s)} for s in self.equip_slots] def _quest_rows(self) -> list[dict]: rows: list[dict] = [] for it in self.inv.items: if is_quest_item(it): rows.append({"kind": "quest", "item": it}) show_dust = ( len(FAIRY_DUST_COLLECTED) > 0 or "quest_fairy_dust" in quest_manager.active or "quest_fairy_dust" in quest_manager.ever_completed ) if show_dust: rows.append({"kind": "dust", "count": len(FAIRY_DUST_COLLECTED)}) return rows def _rows(self) -> list[dict]: """Selectable rows: bag equipment first, then the equipped slots (which are only shown in the side panel, but remain selectable to unequip).""" if self.tab == "equipment": return self._bag_equipment_rows() + self._equipped_rows() return self._quest_rows() def _display_rows(self) -> list[dict]: """Rows actually rendered in the left list (equipped slots live only in the side panel).""" if self.tab == "equipment": return self._bag_equipment_rows() return self._quest_rows() def _clamp_cursor(self): n = len(self._rows()) 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): n = len(self._display_rows()) if n == 0: self.scroll = 0 return 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, n - max_lines))) def _switch_tab(self, step: int): i = self.tabs.index(self.tab) self.tab = self.tabs[(i + step) % len(self.tabs)] self.cursor = 0 self.scroll = 0 def handle_keydown(self, key): if not self.visible: return if key == key_for("ui_cancel"): self.toggle() return if key == key_for("ui_left"): self._switch_tab(-1) return if key == key_for("ui_right"): self._switch_tab(1) return 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._activate_selected() def _activate_selected(self): rows = self._rows() if not rows: return row = rows[min(self.cursor, len(rows) - 1)] kind = row.get("kind") if kind == "equipped": self._unequip_slot(row["slot"]) elif kind == "bag": self._equip_item(row["item"]) elif kind == "quest": pickup_feed.push_plain("Quest objects are used from the Boss Diary.") elif kind == "dust": pickup_feed.push_plain("Fairy Dust - return it to the fairy.") def _equip_item(self, new_item: dict): slot = new_item.get("slot") if slot not in self.player.equipment: return old_item = self.player.equip_swap(new_item) for i, it in enumerate(self.inv.items): if it is new_item: self.inv.items.pop(i) break if old_item is not None: self.inv.items.append(old_item) self._clamp_cursor() def _unequip_slot(self, slot: str): """Move the item in the given equipment slot back to the inventory.""" 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_quests_column(self, surface, list_top): quests_x = self.x + self.list_w + self.pad quests_y = list_top 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"], 6)) 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)) def _draw_equipped_column(self, surface, list_top, sel_row, sel_kind): 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)) line_gap = int(_ui(["inventory_ui", "equipped", "line_gap"], 4)) for slot in self.equip_slots: it = self.player.equipment.get(slot) is_sel = bool(sel_kind == "equipped" and sel_row and sel_row.get("slot") == slot) 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) 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 avail = (self.x + self.w - self.pad) - text_x if not it: self._blit(surface, self._ellipsize(f"{slot}: (empty)", max(0, avail)), text_x, eq_y, (170, 170, 170)) else: name = it.get("name", "?") tag = f"[{rarity}]" if rarity else "" tag_w = self.font.size(tag)[0] if tag else 0 left = self._ellipsize(f"{slot}: {name} ", max(0, avail - tag_w)) 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 + line_gap def _draw_row(self, surface, row, x0, y, list_w): kind = row.get("kind") icon = None name = "" rarity = "" right = "" dim = False if kind == "equipped": slot = row["slot"] it = row.get("item") if it: rarity = it.get("rarity", "") icon = self._get_slot_icon(slot, rarity) name = f"{slot}: {it.get('name', '?')}" else: dim = True name = f"{slot}: (empty)" right = "equipped" elif kind == "bag": it = row["item"] slot = it.get("slot", "?") rarity = it.get("rarity", "") icon = self._get_slot_icon(slot, rarity) name = it.get("name", it.get("id", "")) right = f"({slot})" elif kind == "quest": it = row["item"] icon = self._get_token_icon(it.get("boss_id")) if it.get("item_type") == "boss_token" else None name = it.get("name", it.get("id", "")) right = "Boss Token" elif kind == "dust": icon = self._get_dust_icon() name = "Fairy Dust" right = f"x{row.get('count', 0)}/{FAIRY_DUST_MAX}" if icon: icon_y = y + (self.line_h - self.icon_size) // 2 surface.blit(icon, (x0, icon_y)) text_x = x0 + self.icon_size + self.icon_gap pad_right = int(_ui(["inventory_ui", "list", "pad_right"], 14)) max_w = list_w - pad_right rarity_text = f"[{rarity}]" if rarity else "" suffix = f" {rarity_text} {right}".rstrip() avail = max_w - (self.icon_size + self.icon_gap) - self.font.size(suffix)[0] name_fit = self._ellipsize(name, max(0, avail)) self._blit(surface, name_fit, text_x, y, (170, 170, 170) if dim else (230, 230, 230)) sx = text_x + self.font.size(name_fit)[0] if rarity_text: self._blit(surface, " " + rarity_text, sx, y, self._rarity_color(rarity)) sx += self.font.size(" " + rarity_text)[0] if right: self._blit(surface, " " + right, sx, y, (150, 150, 150)) 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 title = f"Inventory (I to close) — {room_name}" self._blit(surface, self._ellipsize(title, self.w - self.pad * 2), self.x + self.pad, self.y + self.pad, (240, 240, 240)) # --- Tabs: Equipment | Quest Objects --- tab_y = self.y + self.pad + 20 tab_h = 18 tab_w = (self.w - self.pad * 2) // len(self.tabs) tab_labels = {"equipment": "Equipment", "quest": "Quest Objects"} for i, tname in enumerate(self.tabs): tr = pygame.Rect(self.x + self.pad + i * tab_w, tab_y, tab_w, tab_h) active = (tname == self.tab) pygame.draw.rect(surface, (70, 70, 95) if active else (48, 48, 48), tr) pygame.draw.rect(surface, (165, 165, 190) if active else (95, 95, 95), tr, 1) self._blit(surface, tab_labels.get(tname, tname), tr.x + 6, tr.y + 1, (255, 242, 200) if active else (170, 170, 170)) # --- 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 the tab bar and stats) --- list_top = tab_y + tab_h + 6 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) sel_rows = self._rows() display_rows = self._display_rows() 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(display_rows), 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 not display_rows: empty_msg = "(empty)" if self.tab == "equipment" else "(no quest objects yet)" self._blit(surface, empty_msg, self.x + self.pad + 6, y, (160, 160, 160)) else: for ridx in range(start, end): row = display_rows[ridx] if ridx == 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) self._draw_row(surface, row, self.x + self.pad + 6, y, self.list_w) y += self.line_h surface.set_clip(old_clip) # Side column: equipped gear on the Equipment tab, active quests on the # Quest Objects tab. sel_row = sel_rows[min(self.cursor, len(sel_rows) - 1)] if sel_rows else None sel_kind = sel_row.get("kind") if sel_row else None if self.tab == "equipment": self._draw_equipped_column(surface, list_top, sel_row, sel_kind) else: self._draw_quests_column(surface, list_top) # --- 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, self._ellipsize( "UP/DOWN move LEFT/RIGHT switch tab ENTER equip / unequip", self.w - self.pad * 2 - 6, ), 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 ) sel_rows = self._rows() selected_row = sel_rows[min(self.cursor, len(sel_rows) - 1)] if sel_rows else None kind = selected_row.get("kind") if selected_row else None selected_item = selected_row.get("item") if (selected_row and kind in ("equipped", "bag")) else None self._blit(surface, "Selected:", self.x + self.pad + 6, stats_y + 6, (220, 220, 220)) # Quest objects and fairy dust show a description rather than a stat sheet. if kind == "quest": it = selected_row["item"] qname = it.get("name", it.get("id", "")) bid = it.get("boss_id") boss_name = str(BOSS_DEFS_DEFAULT.get(bid, {}).get("name", bid)) if bid else "" self._blit(surface, self._ellipsize(qname, self.w - self.pad * 2 - 12), self.x + self.pad + 6, stats_y + 24, (230, 230, 230)) self._blit(surface, f"Quest object - Boss Token ({boss_name})", self.x + self.pad + 6, stats_y + 42, (190, 190, 210)) return if kind == "dust": self._blit(surface, "Fairy Dust", self.x + self.pad + 6, stats_y + 24, (230, 230, 230)) self._blit(surface, f"Quest object - collected {selected_row.get('count', 0)}/{FAIRY_DUST_MAX}", self.x + self.pad + 6, stats_y + 42, (190, 190, 210)) return 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", "?") name_x = self.x + self.pad + 6 stats_avail = (self.x + self.w - self.pad) - name_x tag = f"[{rarity}]" if rarity else "" tag_w = self.font.size(tag)[0] if tag else 0 name_fit = self._ellipsize(f"{name} ", max(0, stats_avail - tag_w)) self._blit(surface, name_fit, name_x, stats_y + 24, (230, 230, 230)) if tag: self._blit(surface, tag, name_x + self.font.size(name_fit)[0], stats_y + 24, self._rarity_color(rarity)) compare_item = None selected_slot = selected_item.get("slot") # Only show comparisons when browsing bag items. if kind == "bag": if selected_slot in self.player.equipment: compare_item = self.player.equipment.get(selected_slot) if compare_item is selected_item: compare_item = None 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 bonus_avail = (self.x + self.w - self.pad) - stats_x for line in bonus_lines[:2]: if y_bonus > max_y: break self._blit(surface, self._ellipsize(line, max(0, bonus_avail)), stats_x, y_bonus, (200, 200, 200)) y_bonus += 16 # --- Scroll hint + tiny scrollbar --- total = len(display_rows) # Always show item count hint = f"{total} item" + ("" if total == 1 else "s") 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 > list_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 * (list_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)) # Lift center "splash" messages above the normal feed line so they do # not cover the fairy's speech bubble. self.center_lift = int(_ui(["pickup_feed", "center_lift"], 60)) 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)) y = int(HEIGHT * float(_ui(["pickup_feed", "y_frac"], 0.34))) 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 = y - self.center_lift 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)) y += box_h + self.line_gap 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)) surface.blit(surf, (x + self.pad_x, y + self.pad_y)) y += h + self.pad_y * 2 + 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)) px = x + self.pad_x surface.blit(surf_text, (px, y + self.pad_y)) px += surf_text.get_width() surface.blit(surf_tag, (px, y + self.pad_y)) y += h + self.pad_y * 2 + 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)) # blit parts ty = y + self.pad_y px = x + self.pad_x surface.blit(surf_left, (px, ty)) px += surf_left.get_width() surface.blit(surf_name, (px, ty)) px += surf_name.get_width() if surf_tag: surface.blit(surf_tag, (px, ty)) y += h + self.pad_y * 2 + 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.ground_platform = None self.mist_active = False self.shade = False self.shade_door_grace = 0.0 # grace period to pass shade doors after a form switch 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 self.butterfly_jar_seen = False # first jar per save shows the "use" key hint # --- abilities --- self.has_ranged = False self.ranged_cd = 0.0 # Fairy combat mode (unlocked by the Fairy Dust quest) self.has_fairy_combat = False self.fairy_combat = False self.fairy_shot_cd = 0.0 self.fairy_shot_count = 0 self.opened_chests: set[str] = set() 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.attack_dir_x = 1.0 self.attack_dir_y = 0.0 self.swing_hit_ids = set() 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_empty_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 self.pogo_timer = 0.0 # down-slash bounce: jump-hold gravity + contact immunity # --- 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.fairy_screen_rect: pygame.Rect | None = None self.fairy_anim: dict | None = None # unlock animation state 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._spend_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 self.shade_door_grace = SHADE_DOOR_GRACE def toggle_fairy_combat(self) -> bool: """Toggle the fairy's combat mode. Returns the new state (False if locked).""" if not self.has_fairy_combat: return False self.fairy_combat = not self.fairy_combat self.fairy_shot_cd = 0.0 return self.fairy_combat def set_attack_dir(self, camera_x: float, camera_y: float): """Aim the next melee/ranged attack at the mouse cursor (world space).""" mx, my = pygame.mouse.get_pos() dx = (mx + camera_x) - self.rect.centerx dy = (my + camera_y) - self.rect.centery length = math.hypot(dx, dy) if length < 1.0: dx, dy = 1.0, 0.0 length = 1.0 self.attack_dir_x = dx / length self.attack_dir_y = dy / length def _spend_charge(self, amount: float): """Drain charge; hitting exactly 0 starts a regen pause.""" self.charge -= amount if self.charge <= 0.0: self.charge = 0.0 self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK) 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._spend_charge(1) self.swing_hit_ids.clear() # lock regen for a bit (tweak this number to taste) self.charge_regen_lock = max(self.charge_regen_lock, 0.35) # face the aim direction so the sprite follows the mouse if abs(self.attack_dir_x) >= 0.01: self.facing_right = self.attack_dir_x > 0 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._spend_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, offset toward the aim direction px = self.rect.centerx + int(self.attack_dir_x * 16) py = self.rect.centery - 6 + int(self.attack_dir_y * 10) projectiles_list.append(Projectile(px, py, self.attack_dir_x, self.attack_dir_y, 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): """Melee hitbox built from a strip of small boxes along the aim direction, so attacks can fire in any direction (mouse-aimed).""" if not self.attacking: return [] body_overlap = int(self.rect.width * 0.3) # ~30% into the body half_w = self.rect.width * 0.5 # reach from just inside the body out to near width + far tip start = half_w - body_overlap end = half_w + 28 + 18 n = 6 step = (end - start) / n # square cells keep the hitbox thickness consistent (~18px) in any # aim direction; axis-aligned squares never get thinner than 18px cross = 18 boxes = [] for i in range(n): t = start + (i + 0.5) * step cx = self.rect.centerx + self.attack_dir_x * t cy = self.rect.centery + self.attack_dir_y * t boxes.append(pygame.Rect(int(cx - cross // 2), int(cy - cross // 2), cross, cross)) return boxes 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.swing_hit_ids.clear() self.attack_dir_x = 1.0 self.attack_dir_y = 0.0 self.poison_timer = 0.0 self.poison_acc = 0.0 self.invincibility_timer = 0.0 self.pogo_timer = 0.0 self._cancel_heal() self.health = self.max_health self.charge = self.max_charge self.charge_regen_lock = 0.0 self.charge_empty_lock = 0.0 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 self.charge_empty_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 prev_charge = self.charge self.charge = max(0.0, self.charge - drain) if prev_charge > 0.0 and self.charge <= 0.0: self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK) 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.shade_door_grace > 0.0: self.shade_door_grace = max(0.0, self.shade_door_grace - dt) if self.invincibility_timer > 0: self.invincibility_timer -= dt if self.pogo_timer > 0: self.pogo_timer = max(0.0, self.pogo_timer - dt) if self.charge_regen_lock > 0: self.charge_regen_lock = max(0.0, self.charge_regen_lock - dt) if self.charge_empty_lock > 0: self.charge_empty_lock = max(0.0, self.charge_empty_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 not self.attacking: if key_pressed(keys, "move_left"): self.facing_right = False elif key_pressed(keys, "move_right"): self.facing_right = True # In mist rooms the shade form has no movement penalty — the haze is # home to it, so it moves at full (light-form) speed. if self.mist_active: speed = PLAYER_SPEED else: 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 if self.vel_y < 0 and (not key_pressed(keys, "jump")) and self.pogo_timer <= 0: gravity *= JUMP_RELEASE_GRAVITY_MULT self.vel_y += gravity dy = self.vel_y # Horizontal collision (ignore the platform the player is standing on) self.rect.x += int(dx) for p in platforms: if p is self.ground_platform: continue 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 self.ground_platform = None 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 self.ground_platform = p 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: prev_charge = self.charge if not self.mist_active: self.charge -= CHARGE_DRAIN_RATE * getattr(self, "charge_drain_mult", 1.0) * dt if self.charge <= 0: self.charge = 0 if prev_charge > 0: self.charge_empty_lock = max(self.charge_empty_lock, CHARGE_EMPTY_LOCK) self.shade = False self.shade_door_grace = SHADE_DOOR_GRACE else: # no regen while healing, attacking, or during post-attack/empty lockout if (not self.healing) and (not self.attacking) and (self.charge_regen_lock <= 0.0) and (self.charge_empty_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) if self.fairy_anim: # Unlock sequence: detach -> spin -> return to the default hover. anim = self.fairy_anim t01 = min(1.0, max(0.0, anim.get("t", 0.0) / max(0.001, anim.get("duration", 2.4)))) if t01 < 0.3: detach = t01 / 0.3 elif t01 < 0.65: detach = 1.0 else: detach = max(0.0, (1.0 - t01) / 0.35) dirx = 1.0 if self.facing_right else -1.0 base_cx = fx + fw / 2 base_cy = fairy_cy + bob cx_f = base_cx + dirx * 26.0 * detach cy_f = base_cy - 58.0 * detach angle = 0.0 if 0.3 <= t01 <= 0.65: angle = 720.0 * ((t01 - 0.3) / 0.35) rotated = pygame.transform.rotate(img, angle) rx = int(cx_f - rotated.get_width() / 2) ry = int(cy_f - rotated.get_height() / 2) self.fairy_screen_rect = pygame.Rect(rx, ry, rotated.get_width(), rotated.get_height()) surface.blit(rotated, (rx, ry)) return self.fairy_screen_rect = pygame.Rect(fx, fairy_cy - fh // 2 + bob, fw, fh) 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)) if self.attacking and ATTACK_SPRITE_RAW is not None: ang = math.degrees(math.atan2(-self.attack_dir_y, self.attack_dir_x)) atk_img = pygame.transform.rotate(ATTACK_SPRITE_RAW, ang) off = 40.0 sx = self.rect.centerx + self.attack_dir_x * off sy = self.rect.centery + self.attack_dir_y * off surface.blit(atk_img, (int(sx - atk_img.get_width() / 2 - cx), int(sy - atk_img.get_height() / 2 - cy))) # 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), 1) # ---------------------------- # Enemy (configured via JSON) + Boss slam # ---------------------------- class Enemy: def __init__(self, x, y, type_id: str, behavior: str | None = None): 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 # Miniboss "Discharge": stand-still telegraph then a platform-piercing AOE # when the player camps somewhere the miniboss can't reach. self.discharge_state = "idle" # "idle" | "charging" self.discharge_timer = 0.0 self.discharge_cd = 0.0 self.discharge_buildup = 0.0 self.discharge_flash = 0.0 # Head height while grounded. The discharge checks the player against this # (not the current airborne rect.top), so jumping up at a camping player # no longer resets the discharge buildup. self.discharge_ground_top = self.rect.top self.contact_damage = int(cfg.get("contact_damage", 1)) self.drop_profile = str(cfg.get("drop_profile", "normal")) # Behavior dispatch (chaser = default patrol/chase; shockwave = slam boss; charger = retreat-charge boss) self.behavior = str(behavior or cfg.get("behavior", "chaser")) # 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 # Charger behavior state (retreat-jump, then charge; or direct dash) self.charge_cfg = charge_cfg = (cfg.get("charger", {}) if isinstance(cfg, dict) else {}) if not isinstance(charge_cfg, dict): charge_cfg = {} self.retreat_dist = float(charge_cfg.get("retreat_dist", 150)) self.retreat_jump_vel = float(charge_cfg.get("retreat_jump_vel", -14.0)) self.retreat_speed_mult = float(charge_cfg.get("retreat_speed_mult", 1.2)) self.charge_speed_mult = float(charge_cfg.get("charge_speed_mult", 4.0)) self.charge_duration = float(charge_cfg.get("charge_duration", 1.1)) self.dash_min_dist = float(charge_cfg.get("dash_min_dist", 160)) self.dash_max_dist = float(charge_cfg.get("dash_max_dist", 420)) self.dash_speed_mult = float(charge_cfg.get("dash_speed_mult", 4.2)) self.dash_duration = float(charge_cfg.get("dash_duration", 0.7)) self.attack_cooldown = float(charge_cfg.get("attack_cooldown", 1.8)) self.charge_above_y = float(charge_cfg.get("above_y", 40)) self.charge_state = "idle" # idle | retreating | charging | dashing self.charge_dir = 1 self.charge_timer = 0.0 self.attack_cd = 0.0 # --- Flying boss ("flyer") behavior state --- flyer_cfg = (cfg.get("flyer", {}) if isinstance(cfg, dict) else {}) if not isinstance(flyer_cfg, dict): flyer_cfg = {} self.flyer_flight_height = float(flyer_cfg.get("flight_height", 100)) self.flyer_bob_amplitude = float(flyer_cfg.get("bob_amplitude", 20)) self.flyer_bob_speed = float(flyer_cfg.get("bob_speed", 2.2)) self.flyer_hover_speed = float(flyer_cfg.get("hover_speed", 2.6)) self.flyer_slam_cooldown = float(flyer_cfg.get("slam_cd", 6.0)) self.flyer_dive_vel = float(flyer_cfg.get("slam_dive_vel", 16.0)) self.flyer_ground_time = float(flyer_cfg.get("ground_time", 2.0)) self.flyer_summon_cooldown = float(flyer_cfg.get("summon_cd", 8.0)) self.flyer_summon_max = int(flyer_cfg.get("summon_max", 3)) self.flyer_summon_type = str(flyer_cfg.get("summon_type", "boss_minion")) self.flyer_summon_life = float(flyer_cfg.get("summon_life", 6.0)) self.flyer_summon_heal = float(flyer_cfg.get("summon_heal", 4)) self.flyer_summon_damage = float(flyer_cfg.get("summon_damage", 3)) self.flyer_aoe_cooldown = float(flyer_cfg.get("aoe_cd", 9.0)) self.flyer_aoe_radius = float(flyer_cfg.get("aoe_radius", 300)) self.flyer_aoe_damage = int(flyer_cfg.get("aoe_damage", 1)) self.flyer_aoe_duration = float(flyer_cfg.get("aoe_duration", 0.9)) self.flyer_shot_cooldown = float(flyer_cfg.get("shot_cd", 5.0)) self.flyer_shot_damage = float(flyer_cfg.get("shot_damage", 5)) self.flyer_shot_speed = float(flyer_cfg.get("shot_speed", 300)) self.flyer_shot_lifetime = float(flyer_cfg.get("shot_lifetime", 2.2)) self.flyer_shot_homing = float(flyer_cfg.get("shot_homing", 1.8)) self.flyer_startup_delay = float(flyer_cfg.get("startup_delay", 1.5)) self.flyer_phase2_hp_pct = float(flyer_cfg.get("phase2_hp_pct", 0.30)) self.flyer_hard_phase3_hp_pct = float(flyer_cfg.get("hard_phase3_hp_pct", 0.20)) self.flyer_state = "hover" # hover | diving | grounded self.flyer_grounded_timer = 0.0 self.flyer_bob_t = random.random() * 10.0 self.flyer_last_ground_top = self.rect.bottom self.flyer_slam_cd = 0.0 self.flyer_summon_cd = 0.0 self.flyer_aoe_cd = 0.0 self.flyer_shot_cd = 0.0 self.flyer_startup_timer = self.flyer_startup_delay self.flyer_cycle_casts_left = 0 self.flyer_hard = False # --- Hovering flyer ("swooper") state: the enemy idles in the air and, # the moment it spots the player, dives straight at the point the player # occupied when the charge began (that point stays frozen mid-dive). --- swoop_cfg = (cfg.get("swooper", {}) if isinstance(cfg, dict) else {}) if not isinstance(swoop_cfg, dict): swoop_cfg = {} self.swooper_bob_amplitude = float(swoop_cfg.get("bob_amplitude", 6)) self.swooper_bob_speed = float(swoop_cfg.get("bob_speed", 2.4)) self.swooper_charge_speed_mult = float(swoop_cfg.get("charge_speed_mult", 3.4)) self.swooper_charge_duration = float(swoop_cfg.get("charge_duration", 0.8)) self.swooper_charge_cooldown = float(swoop_cfg.get("charge_cooldown", 1.8)) self.swooper_return_speed = float(swoop_cfg.get("return_speed", 2.2)) self.swooper_windup = float(swoop_cfg.get("windup", 0.0)) self.swooper_state = "idle" # idle | windup | charging | recover self.swooper_timer = 0.0 self.swooper_bob_t = random.random() * 10.0 self.swooper_home_x = self.rect.centerx self.swooper_home_y = self.rect.centery self.swooper_dir_x = 0.0 self.swooper_dir_y = 0.0 self.swooper_target_x = float(self.rect.centerx) self.swooper_target_y = float(self.rect.centery) # --- summoned boss minion: expires after a short life, then heals the # boss; killing it early damages the boss. Color fades toward white. --- self.is_minion = (self.type_id == "boss_minion") self.minion_timer = 0.0 self.minion_duration = 0.0 self.minion_owner = None self.minion_heal = 0.0 self.minion_damage = 0.0 self.minion_expired = 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 # Bosses pull aggro once (first light-form entry into their range) and # then track the player permanently, ignoring range and shade form. self.boss_engaged = False # 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 # True when killed by touching a hazard zone (drops nothing). self.hazard_kill = False 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 self.hazard_kill = False 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.charge_state = "idle" self.charge_timer = 0.0 self.attack_cd = 0.0 self.flyer_state = "hover" self.flyer_grounded_timer = 0.0 self.flyer_bob_t = random.random() * 10.0 self.flyer_last_ground_top = self.rect.bottom self.flyer_slam_cd = 0.0 self.flyer_summon_cd = 0.0 self.flyer_aoe_cd = 0.0 self.flyer_shot_cd = 0.0 self.flyer_startup_timer = self.flyer_startup_delay self.flyer_cycle_casts_left = 0 self.swooper_state = "idle" self.swooper_timer = 0.0 self.swooper_bob_t = random.random() * 10.0 self.swooper_home_x = self.rect.centerx self.swooper_home_y = self.rect.centery self.swooper_dir_x = 0.0 self.swooper_dir_y = 0.0 self.swooper_target_x = float(self.rect.centerx) self.swooper_target_y = float(self.rect.centery) 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 support_below(self, platforms, max_dist): """Return the nearest platform strictly below the feet within max_dist (or None).""" probe_y = self.rect.bottom + 1 best = None best_top = None for p in platforms: if p.top >= probe_y and p.left < self.rect.centerx < p.right: if p.top - probe_y <= max_dist: if best is None or p.top < best_top: best = p best_top = p.top return best def _player_unreachable_above(self, player) -> bool: """True if the player is camping above the miniboss, out of reach.""" ground_top = getattr(self, "discharge_ground_top", self.rect.top) return player.rect.bottom < ground_top - DISCHARGE_ABOVE_GAP def _discharge_release(self, player): """Resolve the Discharge blast: platform-piercing AOE damage.""" dx = player.rect.centerx - self.rect.centerx dy = player.rect.centery - self.rect.centery if math.hypot(dx, dy) <= DISCHARGE_RADIUS and (not player.shade): player.take_damage(DISCHARGE_DAMAGE) player.apply_knockback(self.rect, strength_px_per_frame=16.0, duration=0.25, pop_up_vel=-4.0) self.discharge_flash = 0.35 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 # --- behavior dispatch (config-driven AI) --- def _behavior_tick(self, dt): """Per-behavior cooldown timers.""" if self.behavior == "shockwave": if self.slam_cd > 0: self.slam_cd -= dt elif self.behavior == "charger": if self.attack_cd > 0: self.attack_cd -= dt elif self.behavior == "flyer": for attr in ("flyer_slam_cd", "flyer_summon_cd", "flyer_aoe_cd", "flyer_shot_cd"): v = getattr(self, attr) if v > 0: setattr(self, attr, max(0.0, v - dt)) def _behavior_update(self, dt, player, distance, dx, stunned): """Choose/advance an attack per behavior. Runs before gravity/move.""" if self.behavior == "shockwave": self._shockwave_update(player, distance, stunned) elif self.behavior == "charger": self._charger_update(dt, player, distance, dx, stunned) def _shockwave_update(self, player, distance, stunned): # Slam when the player is close (anti-stay) OR far (anti-cheese). if (not stunned) 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 def _charger_update(self, dt, player, distance, dx, stunned): # State machine: idle -> retreat -> charge, or idle -> dash. if stunned: self.charge_state = "idle" self.charge_timer = 0.0 return if self.charge_state == "idle": if not self.on_ground or self.attack_cd > 0 or player.shade: return dist = abs(distance) player_above = player.rect.centery < self.rect.centery - self.charge_above_y if dist <= self.retreat_dist or player_above: # Leap away from the player, then charge back on landing. self.charge_dir = -1 if dx > 0 else 1 self.direction = self.charge_dir self.vel_y = self.retreat_jump_vel self.on_ground = False self.ground_platform = None self.charge_state = "retreating" elif self.dash_min_dist <= dist <= self.dash_max_dist: # Alternate attack: dash straight at the player, no retreat first. self.charge_dir = 1 if dx >= 0 else -1 self.direction = self.charge_dir self.charge_state = "dashing" self.charge_timer = self.dash_duration self.attack_cd = self.attack_cooldown elif self.charge_state == "retreating": # Keep facing away until we land; landing flips us to charging. self.direction = self.charge_dir elif self.charge_state in ("charging", "dashing"): self.charge_timer -= dt if self.charge_timer <= 0: self.charge_state = "idle" self.attack_cd = self.attack_cooldown def _behavior_on_land(self, player, shockwaves_list): if self.behavior == "shockwave": if self.slam_primed: self.slam_primed = False spawn_boss_shockwave(self, shockwaves_list) elif self.behavior == "charger": if self.charge_state == "retreating": self.charge_state = "charging" self.charge_timer = self.charge_duration dx = player.rect.centerx - self.rect.centerx self.charge_dir = 1 if dx >= 0 else -1 self.direction = self.charge_dir def _behavior_vel_x(self, desired_speed): """Return an override horizontal velocity, or None to use chase speed.""" if self.behavior == "charger": if self.charge_state == "retreating": return self.direction * self.speed * self.retreat_speed_mult if self.charge_state == "charging": return self.charge_dir * self.speed * self.charge_speed_mult if self.charge_state == "dashing": return self.charge_dir * self.speed * self.dash_speed_mult return None # --- Flying boss ("flyer") behavior --- def _flyer_update(self, dt, platforms, player, shockwaves_list, projectiles_list, enemies_list, aoes_list): """The flying boss hovers in the air (bobbing like wings), and uses its aerial skills: slam when the player is below, ground minion summoning, a huge low-damage/high-knockback AOE, and a slightly-homing shot.""" # own timer ticks (the flyer bypasses the shared ground-based update body) if self.hit_timer > 0: self.hit_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 self._behavior_tick(dt) if self.flyer_startup_timer > 0.0: self.flyer_startup_timer = max(0.0, self.flyer_startup_timer - dt) stunned = self.stun_timer > 0.0 if self.stun_timer > 0.0: self.stun_timer = max(0.0, self.stun_timer - dt) dx = player.rect.centerx - self.rect.centerx dy = player.rect.centery - self.rect.centery distance = math.hypot(dx, dy) if self.drop_profile == "boss": if (not self.boss_engaged) and (not player.shade) and distance <= self.detection_range: self.boss_engaged = True chasing = self.boss_engaged else: 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 elif chasing and abs(dx) > 8: self.direction = 1 if dx > 0 else -1 speed = self.flyer_hover_speed if self.slow_timer > 0.0: speed *= self.slow_mult if feared: speed *= TETHER_FEAR_SPEED_MULT if self.flyer_state == "diving": self._flyer_update_diving(dt, platforms, speed, shockwaves_list) elif self.flyer_state == "grounded": ground_speed = self.speed * (self.slow_mult if self.slow_timer > 0.0 else 1.0) if feared: ground_speed *= TETHER_FEAR_SPEED_MULT self._flyer_update_grounded(dt, platforms, player, ground_speed, feared) else: self._flyer_update_hover(dt, platforms, player, speed, chasing, feared, stunned, projectiles_list, enemies_list, aoes_list) if self.rect.top > (ROOM.kill_y + 400): self.respawn() return self.was_chasing = chasing def _flyer_update_hover(self, dt, platforms, player, speed, chasing, feared, stunned, projectiles_list, enemies_list, aoes_list): ground = self.support_below(platforms, 5000) if ground is not None: self.flyer_last_ground_top = ground.top ground_top = getattr(self, "flyer_last_ground_top", self.rect.bottom) self.flyer_bob_t += dt * self.flyer_bob_speed bob = math.sin(self.flyer_bob_t) * self.flyer_bob_amplitude target_y = ground_top - self.flyer_flight_height + bob if feared: self.vel_x = -self.direction * speed elif chasing and not stunned: self.vel_x = self.direction * speed else: self.vel_x *= 0.9 if abs(self.vel_x) < 0.5: self.vel_x = 0.0 # ease vertically toward the hover target (stable, frame-rate-based like the rest) diff = target_y - self.rect.y self.rect.y += int(diff * min(1.0, 0.12)) self.rect.x += int(self.vel_x) # room bounds safety net (the flying boss ignores platform walls; it # hovers above the terrain and only needs to stay inside the room) lo = getattr(ROOM, "min_x", None) hi = getattr(ROOM, "max_x", None) if lo is not None: self.rect.left = max(self.rect.left, lo) if hi is not None: self.rect.right = min(self.rect.right, hi) room_h = getattr(ROOM, "room_h", 900) self.rect.y = max(20, min(self.rect.y, room_h - self.rect.height)) if chasing and (not stunned) and (not feared): self._flyer_cast_skills(dt, player, projectiles_list, enemies_list, aoes_list, platforms) def _flyer_update_diving(self, dt, platforms, speed, shockwaves_list): # dive down until we hit a platform, then slam and stay grounded a moment. self.vel_y = min(self.flyer_dive_vel, self.vel_y + 1.0) self.rect.y += int(self.vel_y) self.rect.x += int(self.direction * speed * dt) for p in platforms: if self.rect.colliderect(p): self.rect.bottom = p.top self.vel_y = 0.0 self.flyer_state = "grounded" self.flyer_grounded_timer = self.flyer_ground_time self.flyer_last_ground_top = p.top spawn_boss_shockwave(self, shockwaves_list, poison=False) break def _flyer_update_grounded(self, dt, platforms, player, speed, feared): """After a slam the boss stays grounded briefly: walks toward the player and can deal contact damage, then lifts back into the air.""" self.flyer_grounded_timer -= dt dx = player.rect.centerx - self.rect.centerx if feared: self.direction = -1 if dx > 0 else 1 elif abs(dx) > 8: self.direction = 1 if dx > 0 else -1 # gravity + vertical move/landing self.vel_y = min(24.0, self.vel_y + 0.5) self.rect.y += int(self.vel_y) landed = False for p in platforms: if self.rect.colliderect(p): if self.vel_y > 0: self.rect.bottom = p.top self.vel_y = 0.0 landed = True elif self.vel_y < 0: self.rect.top = p.bottom self.vel_y = 0.0 self.on_ground = landed # horizontal move + wall collision self.vel_x = self.direction * speed self.rect.x += int(self.vel_x) for p in platforms: if self.rect.colliderect(p): if self.vel_x > 0: self.rect.right = p.left elif self.vel_x < 0: self.rect.left = p.right self.vel_x = 0.0 break # room bounds safety net lo = getattr(ROOM, "min_x", None) hi = getattr(ROOM, "max_x", None) if lo is not None: self.rect.left = max(self.rect.left, lo) if hi is not None: self.rect.right = min(self.rect.right, hi) if self.flyer_grounded_timer <= 0.0: self.flyer_state = "hover" self.vel_y = 0.0 self.vel_x = 0.0 def _flyer_max_casts(self) -> int: """Number of ranged skills the boss may cast in the upcoming cycle. Normal: 1, or 2 at/below phase2_hp_pct. Hard: 2 (starts as if already in phase 2), or all 3 at/below hard_phase3_hp_pct.""" frac = (self.health / self.max_health) if self.max_health > 0 else 0.0 if getattr(self, "flyer_hard", False): return 3 if frac <= self.flyer_hard_phase3_hp_pct else 2 return 2 if frac <= self.flyer_phase2_hp_pct else 1 def _flyer_cast_skills(self, dt, player, projectiles_list, enemies_list, aoes_list, platforms): # Give the boss a calm intro instead of opening with a slam. if self.flyer_startup_timer > 0.0: return # Start a new attack cycle whenever the slam comes off cooldown. Each # cycle the boss casts a limited number of ranged skills (see # _flyer_max_casts); it never chains more than that. if self.flyer_slam_cd <= 0.0: self.flyer_cycle_casts_left = self._flyer_max_casts() self.flyer_slam_cd = self.flyer_slam_cooldown # Slam takes priority whenever the player is below the boss. if player.rect.centery > self.rect.centery + 20: self.flyer_state = "diving" self.vel_y = 6.0 return if self.flyer_cycle_casts_left <= 0: return casts = 0 for skill in random.sample(("shot", "summon", "aoe"), 3): if casts >= self.flyer_cycle_casts_left: break if skill == "shot" and self.flyer_shot_cd <= 0.0: self._flyer_cast_shot(player, projectiles_list) casts += 1 elif skill == "summon" and self.flyer_summon_cd <= 0.0: if self._flyer_cast_summon(enemies_list, platforms): casts += 1 elif skill == "aoe" and self.flyer_aoe_cd <= 0.0: self._flyer_cast_aoe(aoes_list) casts += 1 self.flyer_cycle_casts_left -= casts def _flyer_cast_shot(self, player, projectiles_list): sx = self.rect.centerx sy = self.rect.centery tx = player.rect.centerx - sx ty = player.rect.centery - sy length = math.hypot(tx, ty) if length < 1.0: tx, ty, length = 1.0, 0.0, 1.0 projectiles_list.append(BossProjectile( sx, sy, tx / length, ty / length, self.flyer_shot_damage, self.flyer_shot_speed, self.flyer_shot_lifetime, self.flyer_shot_homing, player, )) self.flyer_shot_cd = self.flyer_shot_cooldown def _flyer_cast_summon(self, enemies_list, platforms) -> bool: alive = sum( 1 for en in enemies_list if getattr(en, "type_id", "") == self.flyer_summon_type and en.health > 0 ) if alive >= self.flyer_summon_max: return False ground = self.support_below(platforms, 5000) if ground is not None: gx = self.rect.centerx gy = ground.top else: gx = self.rect.centerx gy = int(getattr(ROOM, "room_h", 900)) - 40 s_cfg = ENEMY_TYPES.get(self.flyer_summon_type, ENEMY_TYPES.get("enemy", {})) sw = int(s_cfg.get("rect", [40, 60])[0]) if isinstance(s_cfg, dict) else 40 sh = int(s_cfg.get("rect", [40, 60])[1]) if isinstance(s_cfg, dict) else 60 minion = Enemy(gx - sw // 2, gy - sh, self.flyer_summon_type) minion.is_minion = True minion.minion_owner = self minion.minion_duration = self.flyer_summon_life minion.minion_timer = self.flyer_summon_life minion.minion_heal = self.flyer_summon_heal minion.minion_damage = self.flyer_summon_damage enemies_list.append(minion) self.flyer_summon_cd = self.flyer_summon_cooldown return True def _flyer_cast_aoe(self, aoes_list): aoes_list.append(BossAoe( self.rect.centerx, self.rect.centery, self.flyer_aoe_radius, self.flyer_aoe_damage, self.flyer_aoe_duration, )) self.flyer_aoe_cd = self.flyer_aoe_cooldown # --- Hovering flyer ("swooper") behavior --- def _swooper_update(self, dt, platforms, player): """Idle in the air bobbing, then -- the instant the player is spotted -- lock onto where the player stood at that moment and dive at that frozen point. After the dive it recovers, drifts back to its perch and can charge again once the cooldown elapses.""" if self.hit_timer > 0.0: self.hit_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 self._behavior_tick(dt) stunned = self.stun_timer > 0.0 if stunned: self.stun_timer = max(0.0, self.stun_timer - dt) dx = player.rect.centerx - self.rect.centerx dy = player.rect.centery - self.rect.centery distance = math.hypot(dx, dy) sees_player = (not player.shade) and (distance <= self.detection_range) feared = self.fear_timer > 0.0 speed = self.air_speed if self.slow_timer > 0.0: speed *= self.slow_mult if feared: # Flee straight away from the player and abort any dive. if distance > 0.001: self._swooper_try_move(-dx / distance * TETHER_FEAR_SPEED_MULT * speed, -dy / distance * TETHER_FEAR_SPEED_MULT * speed, platforms) self.swooper_state = "idle" self.swooper_timer = 0.0 self.was_chasing = False return if stunned: self.swooper_state = "idle" self.swooper_timer = 0.0 self.was_chasing = False return if self.swooper_state == "idle": self._swooper_hover(dt, platforms) if sees_player: self._swooper_start_charge(player) elif self.swooper_state == "windup": self._swooper_hover(dt, platforms) self.swooper_timer -= dt if self.swooper_timer <= 0.0: self.swooper_state = "charging" self.swooper_timer = self.swooper_charge_duration elif self.swooper_state == "charging": charge_speed = speed * self.swooper_charge_speed_mult blocked = self._swooper_try_move( self.swooper_dir_x * charge_speed, self.swooper_dir_y * charge_speed, platforms, ) self.swooper_timer -= dt # Remaining vector to the frozen target; once it points behind us # (or we hit something / ran out of time) the dive is over. rem_x = self.swooper_target_x - self.rect.centerx rem_y = self.swooper_target_y - self.rect.centery passed_target = (rem_x * self.swooper_dir_x + rem_y * self.swooper_dir_y) <= 0.0 if blocked or self.swooper_timer <= 0.0 or passed_target: self.swooper_state = "recover" self.swooper_timer = self.swooper_charge_cooldown else: # recover self._swooper_hover(dt, platforms) self.swooper_timer -= dt if self.swooper_timer <= 0.0: self.swooper_state = "idle" self.was_chasing = sees_player def _swooper_hover(self, dt, platforms): """Ease back toward the perch while bobbing gently on the y axis.""" self.swooper_bob_t += dt * self.swooper_bob_speed bob = math.sin(self.swooper_bob_t) * self.swooper_bob_amplitude to_x = self.swooper_home_x - self.rect.centerx to_y = (self.swooper_home_y + bob) - self.rect.centery dist = math.hypot(to_x, to_y) if dist < 0.001: return hover_speed = self.swooper_return_speed if self.slow_timer > 0.0: hover_speed *= self.slow_mult step = min(hover_speed, dist) self._swooper_try_move(to_x / dist * step, to_y / dist * step, platforms) def _swooper_start_charge(self, player): """Freeze the player's position and begin the dive toward it.""" self.swooper_target_x = float(player.rect.centerx) self.swooper_target_y = float(player.rect.centery) dx = self.swooper_target_x - self.rect.centerx dy = self.swooper_target_y - self.rect.centery length = math.hypot(dx, dy) if length < 1.0: dx, dy, length = 1.0, 0.0, 1.0 self.swooper_dir_x = dx / length self.swooper_dir_y = dy / length self.direction = 1 if self.swooper_dir_x >= 0 else -1 if self.swooper_windup > 0.0: self.swooper_state = "windup" self.swooper_timer = self.swooper_windup else: self.swooper_state = "charging" self.swooper_timer = self.swooper_charge_duration def _swooper_try_move(self, dx, dy, platforms) -> bool: """Move by a fractional per-frame delta, stopping on platforms and clamping to the room. Returns True if a platform blocked the move.""" blocked = False if dx: self.rect.x += int(dx) for p in platforms: if self.rect.colliderect(p): if dx > 0: self.rect.right = p.left else: self.rect.left = p.right blocked = True break if dy: self.rect.y += int(dy) for p in platforms: if self.rect.colliderect(p): if dy > 0: self.rect.bottom = p.top else: self.rect.top = p.bottom blocked = True break lo = getattr(ROOM, "min_x", None) hi = getattr(ROOM, "max_x", None) if lo is not None: self.rect.left = max(self.rect.left, lo) if hi is not None: self.rect.right = min(self.rect.right, hi) room_h = getattr(ROOM, "room_h", 900) self.rect.y = max(20, min(self.rect.y, room_h - self.rect.height)) return blocked def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave], projectiles_list=None, enemies_list=None, aoes_list=None): # Summoned minions expire after their short life: no drops, heals the boss. if getattr(self, "is_minion", False) and self.minion_duration > 0: self.minion_timer -= dt if self.minion_timer <= 0.0: self.minion_timer = 0.0 self.health = 0.0 self.hazard_kill = True self.minion_expired = True # Flying boss: completely replaces the ground-based AI body. if self.behavior == "flyer": self._flyer_update( dt, platforms, player, shockwaves_list, projectiles_list if projectiles_list is not None else [], enemies_list if enemies_list is not None else [], aoes_list if aoes_list is not None else [], ) return # Hovering flyer ("swooper"): also fully aerial; replaces the ground body. if self.behavior == "swooper": self._swooper_update(dt, platforms, player) return 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 # Behavior cooldown tick (slam for shockwave, attack for charger) self._behavior_tick(dt) dx = player.rect.centerx - self.rect.centerx dy = player.rect.centery - self.rect.centery distance = math.hypot(dx, dy) if self.drop_profile == "boss": if (not self.boss_engaged) and (not player.shade) and distance <= self.detection_range: self.boss_engaged = True chasing = self.boss_engaged else: 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: if dx < -CHASE_DEADZONE: self.direction = -1 elif dx > CHASE_DEADZONE: self.direction = 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 # Behavior attack decision (slam for shockwave, retreat-charge/dash for charger) self._behavior_update(dt, player, distance, dx, stunned) # 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 # behavior on landing (shockwave slam impact / charger retreat->charge) if self.on_ground and (not was_on_ground): self._behavior_on_land(player, 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 --- charg_acting = (self.behavior == "charger" and self.charge_state != "idle") if chasing and not charg_acting: cdx = player.rect.centerx - self.rect.centerx if cdx < -CHASE_DEADZONE: self.direction = -1 elif cdx > CHASE_DEADZONE: self.direction = 1 elif self.stepoff_timer > 0.0: # aggro just broke near an edge: keep walking off naturally self.direction = self.stepoff_dir override_vx = self._behavior_vel_x(desired_speed) if override_vx is not None: self.vel_x = override_vx else: self.vel_x = self.direction * desired_speed # --- Miniboss Discharge: anti-camp AOE (stand still, then blast) --- discharging = self.discharge_state == "charging" if self.drop_profile == "miniboss": # Track the resting head height so jumps at a camping player don't # reset the discharge buildup (see _player_unreachable_above). if self.on_ground: self.discharge_ground_top = self.rect.top if self.discharge_cd > 0.0: self.discharge_cd = max(0.0, self.discharge_cd - dt) if self.discharge_flash > 0.0: self.discharge_flash = max(0.0, self.discharge_flash - dt) if discharging: self.discharge_timer -= dt if self.discharge_timer <= 0.0: self.discharge_state = "idle" self.discharge_cd = DISCHARGE_COOLDOWN self.discharge_buildup = 0.0 self._discharge_release(player) discharging = False elif chasing and (not stunned) and (not charg_acting): if self._player_unreachable_above(player): self.discharge_buildup += dt if self.discharge_buildup >= DISCHARGE_SUSTAIN and self.discharge_cd <= 0.0: self.discharge_state = "charging" self.discharge_timer = DISCHARGE_CHARGE_TIME self.discharge_buildup = 0.0 discharging = True else: self.discharge_buildup = 0.0 if discharging: self.vel_x = 0.0 # --- 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 (not discharging) 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 and (not charg_acting): 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: # Only step off if there's actually a landing platform below; # don't chase the player off into the void. if self.support_below(platforms, 500) is not None: # 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) else: # No landing below -> turn around at the edge instead. self.direction *= -1 self.turn_cooldown = max(self.turn_cooldown, 0.2) # --- 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 charg_acting: # Charger slammed into a wall -> end the charge/dash. if self.charge_state in ("charging", "dashing"): self.charge_state = "idle" self.charge_timer = 0.0 self.attack_cd = self.attack_cooldown self.rect.x = old_x self.vel_x = 0.0 elif 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 # Room horizontal bounds safety net: never let an enemy leave the room. lo = getattr(ROOM, "min_x", None) hi = getattr(ROOM, "max_x", None) if lo is not None and hi is not None: if self.rect.left < lo: self.rect.left = lo self.direction = 1 if self.vel_x < 0: self.vel_x = abs(self.vel_x) elif self.rect.right > hi: self.rect.right = hi self.direction = -1 if self.vel_x > 0: self.vel_x = -abs(self.vel_x) # regular enemy jump helper (charger moves via charge states instead) if self.on_ground and chasing and (not discharging) and self.jump_cd_timer <= 0 and (not charg_acting): 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.behavior == "flyer": color = FLYER_BOSS_HIT_COLOR if self.hit_timer > 0 else FLYER_BOSS_COLOR elif self.behavior == "swooper": color = SWOOPER_HIT_COLOR if self.hit_timer > 0 else SWOOPER_COLOR elif self.drop_profile == "boss": color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR elif self.drop_profile == "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 # Summoned minions fade toward white as their life runs out. fade = 0.0 if getattr(self, "is_minion", False) and self.minion_duration > 0: frac = max(0.0, min(1.0, self.minion_timer / self.minion_duration)) fade = (1.0 - frac) * 0.9 raw, source_facing = enemy_sprite_info(self.type_id, self.behavior) if raw is not None: img = raw if source_facing == "right" and self.direction < 0: img = pygame.transform.flip(img, True, False) elif source_facing == "left" and self.direction > 0: img = pygame.transform.flip(img, True, False) draw_x = self.rect.centerx - img.get_width() // 2 - cx draw_y = self.rect.bottom - img.get_height() - cy surface.blit(img, (draw_x, draw_y)) if self.hit_timer > 0: flash = img.copy() flash.fill((140, 140, 140, 0), special_flags=pygame.BLEND_RGBA_ADD) surface.blit(flash, (draw_x, draw_y)) if fade > 0.0: add = int(255 * fade) glow = img.copy() glow.fill((add, add, add, 0), special_flags=pygame.BLEND_RGBA_ADD) surface.blit(glow, (draw_x, draw_y)) else: if fade > 0.0: color = tuple(int(c + (255 - c) * fade) for c in 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) if self.discharge_state == "charging" or self.discharge_flash > 0.0: if self.discharge_state == "charging": progress = 1.0 - (self.discharge_timer / DISCHARGE_CHARGE_TIME) radius = max(20, int(20 + progress * (DISCHARGE_RADIUS - 20))) ring_color = (190, 80, 220, 40 + int(progress * 80)) body_color = (220, 120, 255) else: radius = int(DISCHARGE_RADIUS) ring_color = (255, 180, 90, 130) body_color = (255, 200, 120) px = self.rect.centerx - cx py = self.rect.centery - cy ring = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA) pygame.draw.circle(ring, ring_color, (radius, radius), radius, 3) surface.blit(ring, (px - radius, py - radius)) pygame.draw.rect( surface, body_color, pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height), 2, ) 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")) e_cfg = ENEMY_TYPES.get(t, ENEMY_TYPES.get("enemy", {})) e_prof = str(e_cfg.get("drop_profile", "normal")) if isinstance(e_cfg, dict) else "normal" if e_prof == "boss" and is_boss_defeated(room.id): continue if e_prof == "miniboss" and room.id in DEFEATED_MINIBOSSES: 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)) behavior_override = ent.get("behavior", None) enemy = Enemy(x0, y0, t, behavior=behavior_override) 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", "Right Mouse Button = 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 trigger_wall_blocking(rect) -> bool: """A trigger wall blocks while its unlock condition is NOT met. - ``quest``: wall stays until the quest is accepted (becomes active), and stays open afterwards (once ever completed). - ``quest_complete``: wall stays until the quest objective is done (is_complete, or the quest has been completed at least once). - ``quest_turned_in``: wall stays until the quest has been completed and turned in (only ever_completed counts). - ``lock_when_pass``: wall is gone initially and only appears once the player has walked through it (arena lock). - ``open_on_miniboss_dead``: releases the wall once the room's miniboss is slain (combines with the conditions above; with no other condition the wall is simply present until then). - ``open_on_boss_dead``: releases the wall once the room's boss is slain (combines with the conditions above; with no other condition the wall is simply present until then). Walls with no condition are inert placeholders (never block).""" meta = platform_meta.get(id(rect), {}) # A miniboss gate that has already opened stays open even if the miniboss # respawns later this session. if meta.get("open_on_miniboss_dead") and trigger_wall_key(rect) in OPENED_MINIBOSS_WALLS: return False qid = meta.get("quest") if meta.get("lock_when_pass"): present = id(rect) in LOCKED_TRIGGER_WALLS elif qid: present = qid not in quest_manager.active and qid not in quest_manager.ever_completed elif meta.get("quest_complete"): qc = meta["quest_complete"] present = not (quest_manager.is_complete(qc) or qc in quest_manager.ever_completed) elif meta.get("quest_turned_in"): present = meta["quest_turned_in"] not in quest_manager.ever_completed elif meta.get("open_on_miniboss_dead"): present = True # always present until released elif meta.get("open_on_boss_dead"): present = True # always present until released else: return False # inert placeholder if meta.get("open_on_miniboss_dead"): if miniboss_alive_in_room(): present = present and True else: # Latch the gate open so it cannot close again if the miniboss respawns. OPENED_MINIBOSS_WALLS.add(trigger_wall_key(rect)) present = False if meta.get("open_on_boss_dead"): present = present and boss_alive_in_room() return present def enemy_trigger_wall_blocking(rect) -> bool: """Trigger walls that block enemies. Arena walls (open_on_miniboss_dead) always contain enemies while their miniboss is alive — the player's lock_when_pass gate is ignored for them, so a miniboss can't roam out of its arena before the player locks it in. All other walls follow the same rules as for the player. """ meta = platform_meta.get(id(rect), {}) if meta.get("open_on_miniboss_dead"): if trigger_wall_key(rect) in OPENED_MINIBOSS_WALLS: return False return miniboss_alive_in_room() if meta.get("open_on_boss_dead"): return boss_alive_in_room() return trigger_wall_blocking(rect) # Lock-on-pass trigger walls (per-session, keyed by rect id). LOCKED_TRIGGER_WALLS: set[int] = set() TRIGGER_WALL_TOUCH: dict[int, bool] = {} # Trigger walls released by slaying the room's miniboss stay open for the rest # of the session, even if the miniboss later respawns (saving at a lantern in # another room). Keyed by room id + wall geometry so it survives room reloads. OPENED_MINIBOSS_WALLS: set[str] = set() def trigger_wall_key(rect) -> str: return f"{ROOM.id}:{rect.x},{rect.y},{rect.width},{rect.height}" def miniboss_alive_in_room() -> bool: return any(getattr(en, "drop_profile", "") == "miniboss" and en.health > 0 for en in enemies) def moving_platform_active(rect) -> bool: """A moving platform with ``open_on_boss_dead`` is hidden and inert while the room's boss is alive; it appears and starts moving once the boss is slain.""" meta = platform_meta.get(id(rect), {}) if meta.get("open_on_boss_dead") and boss_alive_in_room(): return False return True def push_rect_out_of_rects(rect, rects): """Push a pygame.Rect out of any overlapping rect, moving to the nearest edge. Used to safely eject the player from a platform they've ended up inside (e.g. a shade door that became solid again, or an awkward spawn point) instead of letting the physics resolve it with a jarring teleport.""" for obs in rects: if not rect.colliderect(obs): continue inter = rect.clip(obs) if inter.width <= 0 or inter.height <= 0: continue if inter.width < inter.height: # narrower overlap on X: push to the nearest horizontal side if rect.right - obs.left <= obs.right - rect.left: rect.right = obs.left else: rect.left = obs.right else: # narrower overlap on Y: push to the nearest vertical side if rect.bottom - obs.top <= obs.bottom - rect.top: rect.bottom = obs.top else: rect.top = obs.bottom return rect def reset_trigger_wall_locks(): """Clear lock-on-pass trigger state (on room change).""" LOCKED_TRIGGER_WALLS.clear() TRIGGER_WALL_TOUCH.clear() prev_moving_pos: dict[int, tuple[int, int]] = {} def moving_platform_blocked(rect: pygame.Rect, new_x: int, new_y: int) -> bool: """True if a moving platform would clip into the player or an enemy. A player genuinely riding on top of the platform (grounded, feet on its top, overlapping horizontally) is carried along and does NOT block. Everything else in the platform's way — a player standing underneath it, or any enemy — stops the platform until the path is clear again.""" candidate = pygame.Rect(new_x, new_y, rect.width, rect.height) if candidate.colliderect(player.rect): riding = ( player.on_ground and player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and player.rect.right > rect.left and player.rect.left < rect.right ) if not riding: return True for en in enemies: if candidate.colliderect(en.rect): return True return False def update_moving_platforms(dt: float): """Advance moving platforms along their waypoint paths (top-left follows the path). A platform stops while the player or an enemy is in its way, and resumes from the same spot once the path is clear again. Boss-gated platforms are skipped entirely while their boss is alive.""" for rect in platforms_moving: if not moving_platform_active(rect): continue mdata = platform_meta.get(id(rect)) if not mdata: continue path = mdata.get("path") or [] if len(path) < 2: continue n = len(path) speed = float(mdata.get("speed", 120)) i = mdata.get("i", 0) t = mdata.get("t", 0.0) remaining = speed * dt while remaining > 0: x0, y0 = path[i] x1, y1 = path[(i + 1) % n] dx = x1 - x0 dy = y1 - y0 seg_len = math.hypot(dx, dy) if seg_len <= 0: i = (i + 1) % n continue to_end = (1.0 - t) * seg_len if remaining < to_end: t += remaining / seg_len remaining = 0.0 else: remaining -= to_end t = 0.0 i = (i + 1) % n x0, y0 = path[i] x1, y1 = path[(i + 1) % n] new_x = int(round(x0 + (x1 - x0) * t)) new_y = int(round(y0 + (y1 - y0) * t)) if moving_platform_blocked(rect, new_x, new_y): continue # wait until the way is clear (keeps current i/t) mdata["i"] = i mdata["t"] = t rect.x = new_x rect.y = new_y def update_dissolve_platforms(dt: float): """Advance dissolve-platform timers. A dissolve platform behaves like a normal platform while solid. If the player stands on it for its ``stand_time``, it turns translucent (no hitbox) for its ``dissolve_time``, then returns to solid again. Returns the rects that are currently solid (callers add these to the collision lists). """ solid_rects = [] for rect in platforms_dissolve: meta = platform_meta.get(id(rect)) if not meta: solid_rects.append(rect) continue if meta.get("dissolving"): meta["timer"] += dt if meta["timer"] >= float(meta.get("dissolve_time", DISSOLVE_TIME)): meta["dissolving"] = False meta["timer"] = 0.0 continue # Standing check uses geometry (like the moving-platform support check), # not player.ground_platform, which flickers when resting exactly on top. standing = ( player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and player.rect.right > rect.left and player.rect.left < rect.right ) if standing: meta["timer"] += dt if meta["timer"] >= float(meta.get("stand_time", DISSOLVE_STAND_TIME)): meta["dissolving"] = True meta["timer"] = 0.0 else: meta["timer"] = 0.0 solid_rects.append(rect) return solid_rects 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)) for fs in fairy_spots: items.append((getattr(fs, "layer", 0), 0, "fairyspot", fs)) for fd in fairy_dusts: items.append((getattr(fd, "layer", 0), 0, "fairydust", fd)) 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: # Boss-gated moving platforms are also in `platforms` (ground+floating+ # moving); skip them here so they're fully invisible while inactive. if not moving_platform_active(p): continue items.append((platform_layer.get(id(p), 0), 1, "platform", p)) # dissolve platforms (solid or translucent) for p in platforms_dissolve: items.append((platform_layer.get(id(p), 0), 1, "dissolve", p)) # moving platforms (solid, follow a path) layer alongside platforms for p in platforms_moving: if not moving_platform_active(p): continue items.append((platform_layer.get(id(p), 0), 1, "moving", p)) # hazard zones (red) layer alongside platforms for p in platforms_hazard: items.append((platform_layer.get(id(p), 0), 1, "hazard", p)) # trigger walls: only drawn while blocking (quest not yet accepted) for p in platforms_trigger: if trigger_wall_blocking(p): items.append((platform_layer.get(id(p), 0), 1, "trigger", 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", "fairyspot", "fairydust", "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 == "dissolve": mdata = platform_meta.get(id(obj), {}) drect = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height) if mdata.get("dissolving"): fade = pygame.Surface((obj.width, obj.height), pygame.SRCALPHA) fade.fill((96, 165, 250, 90)) surface.blit(fade, (drect.x, drect.y)) pygame.draw.rect(surface, (150, 210, 255), drect, 1) else: pygame.draw.rect(surface, PLATFORM_COLOR, drect) elif kind == "moving": mr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height) pygame.draw.rect(surface, (80, 180, 220), mr) pygame.draw.rect(surface, (180, 230, 255), mr, 2) elif kind == "hazard": hz = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height) pygame.draw.rect(surface, (200, 40, 40), hz) pygame.draw.rect(surface, (255, 120, 120), hz, 2) elif kind == "trigger": tr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height) pygame.draw.rect(surface, (220, 190, 60), tr) pygame.draw.rect(surface, (255, 230, 120), tr, 2) 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", "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", "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)) mouse_note = font.render("Attack: Left Click Ranged: Right Click", True, (170, 170, 170)) surface.blit(mouse_note, (x + 10, y + panel_h - 42)) 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 platforms_hazard = ROOM.platforms_hazard platforms_trigger = ROOM.platforms_trigger platforms_moving = ROOM.platforms_moving platforms_dissolve = ROOM.platforms_dissolve platform_meta = ROOM.platform_meta 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 = [] # Uncollected boss tokens persist across room changes and saves, keyed by room: # room_id -> [{"boss_id": str, "x": int, "y": int}] PENDING_TOKEN_DROPS: dict = {} projectiles: list[Projectile] = [] boss_projectiles: list[BossProjectile] = [] boss_aoes: list[BossAoe] = [] 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] = [] fairy_spots: list = [] fairy_dusts: list = [] fairy_speech_text: str | None = None fairy_speech_timer: float = 0.0 fairy_idle_timer: float = float(_fairy(["idle_interval"], 45.0)) fairy_shown_auto: set = set() fairy_fired_spots: set = set() 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): x = int(self.rect.x - cx) y = int(self.rect.y - cy) 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()}") class FairySpot: """A spot where the player can press F to have the fairy comment.""" INTERACT_RANGE = 80 def __init__(self, x: int, y: int, dialogue_id: str, auto: bool = False, quest_id: str = ""): self.rect = pygame.Rect(int(x), int(y), 28, 28) self.dialogue_id = str(dialogue_id) self.quest_id = str(quest_id) self.auto = bool(auto) self.bob_t = random.random() * 10.0 def draw(self, surface, cx, cy): x = int(self.rect.centerx - cx) y = int(self.rect.centery - cy + math.sin(self.bob_t * 2.0) * 3) pygame.draw.circle(surface, (200, 180, 255), (x, y), 6, 1) pygame.draw.circle(surface, (255, 255, 255), (x, y), 2) def fairy_line(dialogue_id: str, shade: bool) -> str | None: """Resolve a dialogue id to text, picking the light/shade variant.""" entry = _fairy(["lines", dialogue_id], None) if isinstance(entry, str): return entry if not isinstance(entry, dict): return None text = entry.get("shade") if shade else entry.get("light") if text is None: text = entry.get("light") if shade else entry.get("shade") if text is None: text = entry.get("text") return str(text) if text is not None else None def fairy_say(dialogue_id: str, duration: float | None = None): """Make the fairy show a single speech bubble for the given dialogue id.""" global fairy_speech_text, fairy_speech_timer text = fairy_line(dialogue_id, getattr(player, "shade", False)) if not text: return fairy_speech_text = text fairy_speech_timer = float(duration if duration is not None else _fairy(["bubble_duration"], 3.5)) def fairy_say_text(text: str, duration: float | None = None): """Speak an arbitrary (dynamic) line, e.g. one that includes a bound key.""" global fairy_speech_text, fairy_speech_timer if not text: return fairy_speech_text = str(text) fairy_speech_timer = float(duration if duration is not None else _fairy(["bubble_duration"], 3.5)) def get_near_fairy_spot(player_obj, spot_list, range_px: int | None = None) -> "FairySpot | None": if range_px is None: range_px = int(_fairy(["talk_range"], FairySpot.INTERACT_RANGE)) best = None best_d = None for fs in spot_list: dx = player_obj.rect.centerx - fs.rect.centerx dy = player_obj.rect.centery - fs.rect.centery d = math.hypot(dx, dy) if d <= range_px and (best is None or d < best_d): best = fs best_d = d return best def get_near_fairy_dust(player_obj, dust_list, range_px: int | None = None) -> "FairyDust | None": if range_px is None: range_px = FairyDust.INTERACT_RANGE best = None best_d = None for fd in dust_list: dx = player_obj.rect.centerx - fd.rect.centerx dy = player_obj.rect.centery - fd.rect.centery d = math.hypot(dx, dy) if d <= range_px and (best is None or d < best_d): best = fd best_d = d return best def collect_fairy_dust(fd: "FairyDust"): """Pick up a fairy dust: records it, advances the quest, and voices a line.""" if fd.key: FAIRY_DUST_COLLECTED.add(fd.key) if fd in fairy_dusts: fairy_dusts.remove(fd) just_completed = False if "quest_fairy_dust" in quest_manager.active: was_complete = quest_manager.is_complete("quest_fairy_dust") quest_manager.progress("quest_fairy_dust", 1) just_completed = (not was_complete) and quest_manager.is_complete("quest_fairy_dust") collected = len(FAIRY_DUST_COLLECTED) pickup_feed.push_center(f"Fairy Dust collected ({collected}/{FAIRY_DUST_MAX})", duration=2.5) if just_completed: fairy_say("quest_return", duration=5.0) else: lines = _fairy(["dust_lines"], []) if isinstance(lines, list) and lines: fairy_say(random.choice(lines)) def interact_fairy_quest(qid: str): """Talk to the fairy about the Fairy Dust quest (accept / progress / redeem).""" q = quest_manager.get(qid) if q is None: return target = q.target_count() if quest_manager.is_complete(qid): quest_manager.turn_in(qid) start_fairy_unlock_animation() return if qid in quest_manager.active: prog = int(quest_manager.active[qid].get("progress", 0)) fairy_say("quest_intro") pickup_feed.push_plain(f"Fairy Dust: {prog}/{target} collected.") return if qid in quest_manager.completed_one_time or qid in quest_manager.ever_completed: fairy_say("quest_done") return # Accept the quest, crediting any dust already collected this run. quest_manager.accept(qid) if qid == "quest_fairy_dust" and qid in quest_manager.active: quest_manager.active[qid]["progress"] = min(target, len(FAIRY_DUST_COLLECTED)) fairy_say("quest_intro") # If already complete, redeem immediately on this press. if quest_manager.is_complete(qid): interact_fairy_quest(qid) def interact_fairy_spot(fs: FairySpot): if getattr(fs, "quest_id", ""): interact_fairy_quest(fs.quest_id) return fairy_say(fs.dialogue_id) def fairy_fire_auto_on_enter(room: Room): """Fairy's self-initiated line when the player enters a configured room.""" entry = _fairy(["auto", room.id, "on_enter"], None) if not isinstance(entry, str) or not entry: return key = f"{room.id}:{entry}" if key in fairy_shown_auto: return fairy_shown_auto.add(key) fairy_say(entry) def start_fairy_unlock_animation(): """Fairy detaches from the player, spins (unlocking combat), then returns.""" player.fairy_anim = {"t": 0.0, "duration": 2.4, "unlocked": False} fairy_say("quest_unlock", duration=2.0) def update_fairy_animation(dt: float): anim = player.fairy_anim if not anim: return anim["t"] = anim.get("t", 0.0) + dt duration = max(0.001, anim.get("duration", 2.4)) if (not anim.get("unlocked")) and anim["t"] >= duration * 0.5: anim["unlocked"] = True player.has_fairy_combat = True player.fairy_combat = False mode_key = key_name(key_for("fairy_mode")).upper() fairy_say_text(f"You found them all! Press {mode_key} to fight!", duration=5.0) pickup_feed.push_center(f"Fairy Combat unlocked - press {mode_key} to toggle Fairy Mode!", duration=5.0) if anim["t"] >= duration: player.fairy_anim = None def _segment_blocked(x1, y1, x2, y2, rects) -> bool: """True if the segment (x1,y1)-(x2,y2) crosses any of the given rects.""" if not rects: return False for r in rects: try: if r.clipline((int(x1), int(y1)), (int(x2), int(y2))): return True except Exception: continue return False def update_fairy_combat(dt: float, enemy_list: list, projectile_list: list, solid_rects=None): """Auto-fire the fairy at nearby enemies while combat mode is on. Each shot deals the player's ranged damage and drains Charge; when Charge runs out the fairy switches herself back to non-combat mode. Targets need line of sight, so the fairy never shoots through walls/platforms.""" if solid_rects is None: solid_rects = [] if not (player.has_fairy_combat and player.fairy_combat): return if player.charge < FAIRY_SHOT_CHARGE_COST: player.fairy_combat = False fairy_say("combat_empty") return player.fairy_shot_cd = max(0.0, player.fairy_shot_cd - dt) if player.fairy_shot_cd > 0.0: return fr = player.fairy_screen_rect if fr is not None: fx = float(fr.centerx) + camera_x fy = float(fr.centery) + camera_y else: fx = float(player.rect.centerx) fy = float(player.rect.centery) target = None best_d2 = FAIRY_SHOT_RANGE * FAIRY_SHOT_RANGE for en in enemy_list: if getattr(en, "health", 0) <= 0: continue dx = en.rect.centerx - fx dy = en.rect.centery - fy d2 = dx * dx + dy * dy if d2 <= best_d2 and not _segment_blocked(fx, fy, en.rect.centerx, en.rect.centery, solid_rects): best_d2 = d2 target = en if target is None: return # Aim at the target's center, leading its velocity a little so the shot # lands more often; a small spread keeps it from being perfectly accurate. tx, ty = target.rect.center lead = math.hypot(tx - fx, ty - fy) / max(1.0, FAIRY_PROJECTILE_SPEED) aim_x = tx + getattr(target, "vel_x", 0.0) * lead * 0.6 - fx aim_y = ty + getattr(target, "vel_y", 0.0) * lead * 0.6 - fy length = math.hypot(aim_x, aim_y) if length < 1.0: aim_x, aim_y, length = 1.0, 0.0, 1.0 aim_x /= length aim_y /= length spread = math.radians(random.uniform(-FAIRY_SPREAD_DEG, FAIRY_SPREAD_DEG)) ca, sa = math.cos(spread), math.sin(spread) dir_x = aim_x * ca - aim_y * sa dir_y = aim_x * sa + aim_y * ca melee_dmg = 2.0 if player.shade else 1.0 projectile_list.append(FairyProjectile(fx, fy, dir_x, dir_y, melee_dmg * FAIRY_DAMAGE_MULT)) player._spend_charge(FAIRY_SHOT_CHARGE_COST) player.charge_regen_lock = max(player.charge_regen_lock, 0.4) player.fairy_shot_cd = FAIRY_SHOT_COOLDOWN player.fairy_shot_count += 1 if player.fairy_shot_count % FAIRY_VOICE_EVERY == 0: lines = _fairy(["attack_lines"], []) if isinstance(lines, list) and lines: fairy_say(random.choice(lines)) if player.charge < FAIRY_SHOT_CHARGE_COST: player.fairy_combat = False fairy_say("combat_empty") def fairy_dust_quest_active() -> bool: """Fairy Dust only appears in the world once its quest is active. Uses a globals() lookup because this is called during module import (before quest_manager is defined) to seed the starting room.""" qm = globals().get("quest_manager") if qm is None: return False return "quest_fairy_dust" in qm.active 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] = [] fairy_spots_list: list[FairySpot] = [] fairy_dusts_list: list[FairyDust] = [] 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 cid = str(p.get("object_id") or "") or f"{room.id}@{int(x)},{int(y)}" reward_chest_obj = RewardChest(x, y, opened=cid in player_obj.opened_chests, coins=coins) reward_chest_obj.id = cid 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)))) elif p_type == "fairy_spot": fs = FairySpot(x, y, str(p.get("dialogue_id", p.get("dialogue", ""))), auto=bool(p.get("auto", False)), quest_id=str(p.get("quest_id", ""))) fs.layer = int(p.get("layer", 0)) fairy_spots_list.append(fs) elif p_type == "fairy_dust": if fairy_dust_quest_active(): key = jar_key(room.id, x, y) if key not in FAIRY_DUST_COLLECTED: fd = FairyDust(x, y, key=key) fd.layer = int(p.get("layer", 0)) fairy_dusts_list.append(fd) return (reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, houses, quest_givers, diary_obj, fairy_spots_list, fairy_dusts_list) 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, fairy_spots, fairy_dusts = spawn_room_props(ROOM, player) fairy_fire_auto_on_enter(ROOM) 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, platforms_hazard, ground_platforms, platforms global platforms_trigger, platforms_moving, platform_meta global platforms_dissolve global platform_layer global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors global boss_projectiles, boss_aoes global shatter_platforms, shatter_aoes, tether_shots global light_sources, save_points, butterfly_jars, houses, fairy_spots, fairy_dusts 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 reset_trigger_wall_locks() ROOM = room player.mist_active = ROOM.mist KILL_Y = ROOM.kill_y platforms_ground = ROOM.platforms_ground platforms_floating = ROOM.platforms_floating platforms_hazard = ROOM.platforms_hazard platforms_trigger = ROOM.platforms_trigger platforms_moving = ROOM.platforms_moving platforms_dissolve = ROOM.platforms_dissolve platform_meta = ROOM.platform_meta 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 = restore_token_drops(ROOM.id) projectiles = [] boss_projectiles = [] boss_aoes = [] shatter_platforms = [] shatter_aoes = [] tether_shots = [] light_sources = [] save_points = [] butterfly_jars = [] houses = [] fairy_spots = [] fairy_dusts = [] 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, fairy_spots, fairy_dusts = spawn_room_props(ROOM, player) fairy_fire_auto_on_enter(ROOM) 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 # Don't spawn embedded in a platform: push out to the nearest edge. push_rect_out_of_rects(player.rect, ROOM.platforms) def boss_alive_in_room() -> bool: return any(getattr(en, "drop_profile", "") == "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 global boss_projectiles, boss_aoes reset_trigger_wall_locks() enemies = spawn_enemies_from_room(ROOM) npcs = spawn_npcs_from_room(ROOM) loot_drops = [] shockwaves = [] coin_drops = [] token_drops = restore_token_drops(ROOM.id) projectiles = [] boss_projectiles = [] boss_aoes = [] 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) if not target: continue # placeholder exit (e.g. a refight door with no destination) 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() # Minibosses stay defeated (per room) until the player saves at a lantern # in a different room — then they respawn. Runtime only, like butterfly jars. DEFEATED_MINIBOSSES: 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) def respawn_defeated_minibosses_if_elsewhere() -> bool: """Minibosses stay defeated until the player saves at a lantern in a different room than the one where the miniboss was slain. Returns True if any defeated miniboss was respawned (i.e. removed from the set).""" if not DEFEATED_MINIBOSSES: return False before = len(DEFEATED_MINIBOSSES) DEFEATED_MINIBOSSES.intersection_update({ROOM.id}) return len(DEFEATED_MINIBOSSES) != before # ---------------------------- # 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.ever_completed: set[str] = set() # completed at least once (incl. repeatable) 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 requires_shade(self, quest_id: str) -> bool: """Whether a quest can only be taken/collected in shade form.""" q = self.quests.get(quest_id) return bool(q is not None and q.objective.get("shade")) 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) self.ever_completed.add(quest_id) if q.is_repeatable: self.tokens += q.token_count() else: self.completed_one_time.add(quest_id) def progress_kill(self, enemy_type: str, shade: bool = False) -> list[str]: """Progress every active kill-quest matching this enemy type. A quest whose objective has ``shade: true`` only counts kills made while the player is in shade form. 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 if obj.get("shade") and not shade: continue was = self.is_complete(qid) self.progress(qid, 1) if not was and self.is_complete(qid): completed.append(qid) return completed def progress_collect(self, target: str, amount: int = 1) -> list[str]: """Progress every active 'collect' quest matching this target. 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") != "collect" or str(obj.get("target", "")) != target: continue was = self.is_complete(qid) self.progress(qid, amount) if not was and self.is_complete(qid): completed.append(qid) return completed def boss_token_count_held(self) -> int: """Boss tokens the player currently owns (carried in the bag or slotted).""" n = sum(1 for it in inventory.items if isinstance(it, dict) and it.get("item_type") == "boss_token") n += len(boss_diary.slotted) return n 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], }, }, { "id": "first_kills_shade", "name": "First Kills (Shade)", "description": "Kill 4 enemies while in shade form.", "type": "one_time", "giver": "object:quest_point", "objective": {"kind": "kill", "target": "enemy", "count": 4, "shade": True}, "reward": { "guaranteed_item": {"item_id": "necklace_placeholder"}, }, }, { "id": "quest_fairy_dust", "name": "Fairy Dust", "description": "Collect 5 Fairy Dust scattered across the rooms.", "type": "one_time", "giver": "object:fairy_spot", "objective": {"kind": "collect", "target": "fairy_dust", "count": 5}, "reward": {}, }, { "id": "quest_combat_master_tokens", "name": "Trophy Hunter", "description": "Bring the Combat Master 3 boss tokens.", "type": "one_time", "giver": "npc:combat_master", "objective": {"kind": "collect", "target": "boss_token", "count": 3}, "reward": {}, }, ] 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", "token_sprite": "Boss1_token.png", }, "boss_02": { "name": "Sky Terror", "room": "room_08.json", "refight_room": "room_boss_fly_refight.json", "token_name": "Sky Terror's Token", "token_sprite": "Boss_fly_token.png", }, } 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, "drop_profile", "") == "boss": en.speed *= 2.0 en.air_speed *= 2.0 if getattr(en, "behavior", "") == "charger": en.charge_speed_mult *= 1.5 en.dash_speed_mult *= 1.5 en.retreat_speed_mult *= 1.5 en.attack_cooldown = max(0.6, en.attack_cooldown * 0.75) elif getattr(en, "behavior", "") == "flyer": en.flyer_hard = True else: 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) raw = boss_token_sprite(self.boss_id) if raw is not None: img = pygame.transform.scale(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) def restore_token_drops(room_id: str) -> list: """Rebuild the uncollected boss-token pickups for a room.""" return [TokenPickup(d.get("x", 0), d.get("y", 0), d.get("boss_id", "")) for d in PENDING_TOKEN_DROPS.get(room_id, [])] 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): x = int(self.rect.x - cx) y = int(self.rect.y - cy) 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 CHEST_INTERACT_RANGE = 70 def get_near_chest(player_obj, chest_obj) -> bool: if chest_obj is None or chest_obj.opened: return False dx = player_obj.rect.centerx - chest_obj.rect.centerx dy = player_obj.rect.centery - chest_obj.rect.centery return math.hypot(dx, dy) <= CHEST_INTERACT_RANGE def get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -> SavePoint | None: """Return the nearest un-used 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: if sp.used: continue 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, restore HP/Charge, and save the game.""" global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS if sp.used: return sp.used = True LANTERN_RESPAWN_ROOM = ROOM.id LANTERN_RESPAWN_POS = (sp.x, sp.y) # Resting at a lantern fully restores the player. player._cancel_heal() player.health = player.max_health player.charge = player.max_charge player.poison_timer = 0.0 player.poison_acc = 0.0 player._clamp_resources() _do_save_game() pickup_feed.push_center("Lantern - game saved, fully restored", duration=2.0) if respawn_jars_if_elsewhere(): pickup_feed.push_center("Butterfly jars have respawned!", duration=2.5) if respawn_defeated_minibosses_if_elsewhere(): pickup_feed.push_center("Defeated minibosses 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): # Only show tokens the player actually owns (slotted in the diary or # carried in the inventory), in canonical boss order. This means the # grid has no empty/uncollected slots, and owned tokens re-sort into # the correct order as more are collected. self.boss_ids = [ bid for bid in BOSS_DEFS_DEFAULT if boss_diary.tier(bid) >= 0 or find_carried_boss_token(self.inv, bid) is not None ] self.grid_cursor = min(self.grid_cursor, len(self.boss_ids) - 1) if self.boss_ids else 0 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.") self._rebuild() 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.") self._rebuild() 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: panel_w, panel_h = 360, 96 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 + 14, y + 10)) msg = self.small_font.render("No boss tokens collected yet.", True, (180, 180, 180)) surface.blit(msg, (x + 14, y + 52)) 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) raw_token = boss_token_sprite(boss_id) if raw_token is not None and has_token: icon = pygame.transform.scale(raw_token, (32, 32)) if tier < 0: 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") # The current first town (where the Boss Diary lives). TOWN_ROOM_ID = "room_09.json" TOWN_DISPLAY_NAMES = { "room_town_gloomreach.json": "Gloomreach", "room_09.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) if player.mist_active: shade_speed = light_speed else: 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, "butterfly_jar_seen": player.butterfly_jar_seen, "has_ranged": player.has_ranged, "has_fairy_combat": player.has_fairy_combat, "opened_chests": sorted(player.opened_chests), "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), "ever_completed": sorted(quest_manager.ever_completed), "tokens": quest_manager.tokens, }, "boss_diary": {"slotted": boss_diary.slotted}, "defeated_bosses": sorted(DEFEATED_BOSSES), "broken_doors": sorted(BROKEN_DOORS), "pending_token_drops": PENDING_TOKEN_DROPS, "opened_miniboss_walls": sorted(OPENED_MINIBOSS_WALLS), "fairy_dust_collected": sorted(FAIRY_DUST_COLLECTED), "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.butterfly_jar_seen = bool(pdata.get("butterfly_jar_seen", False)) player.has_ranged = bool(pdata.get("has_ranged", False)) player.has_fairy_combat = bool(pdata.get("has_fairy_combat", False)) player.fairy_combat = False player.fairy_shot_cd = 0.0 player.opened_chests = set(str(c) for c in (pdata.get("opened_chests") or [])) # migrate the legacy single-flag save (only the reward-room default chest existed) if pdata.get("reward_chest_opened"): player.opened_chests.add(f"{REWARD_ROOM_ID}@{REWARD_CHEST_POS[0]},{REWARD_CHEST_POS[1]}") 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.ever_completed = set(qdata.get("ever_completed") 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 []) PENDING_TOKEN_DROPS.clear() for rid, drops in (data.get("pending_token_drops") or {}).items(): if isinstance(drops, list): PENDING_TOKEN_DROPS[str(rid)] = [d for d in drops if isinstance(d, dict)] # Butterfly jars and minibosses respawn on reload (session-only); corpse too. COLLECTED_JARS.clear() FAIRY_DUST_COLLECTED.clear() FAIRY_DUST_COLLECTED.update(data.get("fairy_dust_collected") or []) DEFEATED_MINIBOSSES.clear() OPENED_MINIBOSS_WALLS.clear() OPENED_MINIBOSS_WALLS.update(str(k) for k in (data.get("opened_miniboss_walls") or [])) 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() # respawn() re-places at the raw spawn; make sure we're not embedded. push_rect_out_of_rects(player.rect, room.platforms) 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, "butterfly_jar_seen": False, "has_ranged": False, "has_fairy_combat": False, "opened_chests": [], "equipment": {}, }, "inventory": [], "quests": {"active": {}, "completed_one_time": [], "ever_completed": [], "tokens": 0}, "boss_diary": {"slotted": {}}, "defeated_bosses": [], "broken_doors": [], "pending_token_drops": {}, "opened_miniboss_walls": [], "fairy_dust_collected": [], "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 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) update_fairy_animation(dt) if fairy_speech_timer > 0.0: fairy_speech_timer = max(0.0, fairy_speech_timer - dt) if fairy_speech_timer <= 0.0: fairy_speech_text = None else: fairy_idle_timer -= dt if fairy_idle_timer <= 0.0: fairy_idle_timer = float(_fairy(["idle_interval"], 45.0)) if (not inv_ui.visible) and (not trade_ui.visible) and (not diary_ui.visible): idle_lines = _fairy(["idle_lines"], []) if isinstance(idle_lines, list) and idle_lines: fairy_say(random.choice(idle_lines)) for fs in fairy_spots: if not getattr(fs, "auto", False): continue key = f"{ROOM.id}:{fs.rect.centerx},{fs.rect.centery}:{fs.dialogue_id}" if key in fairy_fired_spots: continue if get_near_fairy_spot(player, [fs]) is not None: fairy_fired_spots.add(key) fairy_say(fs.dialogue_id) break 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: pause_menu = "settings" elif idx == 2: _return_to_main_menu() elif idx == 3: 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) % 4 pause_use_mouse = False elif key_is("ui_down", e.key): pause_menu_index = (pause_menu_index + 1) % 4 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: pause_menu = "settings" elif pause_menu_index == 2: _return_to_main_menu() elif pause_menu_index == 3: 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_chest(player, reward_chest): if reward_chest.open(player): player.opened_chests.add(reward_chest.id) 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: if quest_manager.requires_shade(near_qg.quest_id) and not player.shade: # Shade-gated quest: no interaction while in light form. pass else: interact_quest_giver(near_qg) else: near_fd = get_near_fairy_dust(player, fairy_dusts) if near_fd: collect_fairy_dust(near_fd) else: near_fs = get_near_fairy_spot(player, fairy_spots) if near_fs: interact_fairy_spot(near_fs) 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("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("fairy_mode", e.key) and player.has_fairy_combat: if player.toggle_fairy_combat(): fairy_say("combat_on") else: fairy_say("combat_off") 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() mouse_buttons = pygame.mouse.get_pressed() # Hold any combat key (or mouse button) to keep re-casting the # ability/attack as soon as its cooldown and resource checks allow # (never faster than the cooldown). if (not paused) and (not inv_ui.visible) and (not trade_ui.visible): if key_pressed(keys, "attack") or mouse_buttons[0]: player.set_attack_dir(camera_x, camera_y) player.attack() if key_pressed(keys, "ranged") or mouse_buttons[2]: player.set_attack_dir(camera_x, camera_y) player.try_ranged_attack(projectiles, enemies, platforms) if key_pressed(keys, "dash"): player.try_dash() if key_pressed(keys, "shatterstep"): player.try_shatterstep() if key_pressed(keys, "tether"): player.try_tether(tether_shots) 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 a in boss_aoes: a.draw(screen, camera_x, camera_y) for pr in projectiles: pr.draw(screen, camera_x, camera_y) for bp in boss_projectiles: bp.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)) draw_mist_overlay(screen) # 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 # Determine the moving platform the player is currently standing on (before it moves). support_moving = None if player.on_ground: for rect in platforms_moving: if not moving_platform_active(rect): continue if (player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and player.rect.right > rect.left and player.rect.left < rect.right): support_moving = rect break # Move moving platforms; carry the player along with their support platform BEFORE # collision so they stay on it (no slipping off / bobbing when moving down). for rect in platforms_moving: prev_moving_pos[id(rect)] = (rect.x, rect.y) update_moving_platforms(dt) if support_moving is not None: px, py = prev_moving_pos.get(id(support_moving)) if px is not None: dx = support_moving.x - px dy = support_moving.y - py if dx or dy: player.rect.x += dx player.rect.y += dy player.vel_y = 0.0 player.ground_platform = support_moving # Trigger walls: solid only while their unlock quest is not yet active. blocking_triggers = [t for t in platforms_trigger if trigger_wall_blocking(t)] # Dissolve platforms: solid while not dissolved; excluded from collision while dissolving. solid_dissolve = update_dissolve_platforms(dt) # Boss-gated moving platforms have no hitbox while their boss is alive. inactive_moving_ids = {id(r) for r in platforms_moving if not moving_platform_active(r)} player_solid = [r for r in platforms if id(r) not in inactive_moving_ids] collision_platforms = player_solid + [d.rect for d in ability_doors if not d.open] + blocking_triggers + solid_dissolve # Enemies collide with ground/floating platforms, doors and trigger walls — # but NOT moving platforms. Moving platforms are player traversal; a platform # descending into an enemy shoves it off edges / through the floor. # Arena walls (open_on_miniboss_dead) always contain enemies. moving_ids = {id(r) for r in platforms_moving} enemy_blocking_triggers = [t for t in platforms_trigger if enemy_trigger_wall_blocking(t)] enemy_collision_platforms = [r for r in platforms if id(r) not in moving_ids] enemy_collision_platforms += [d.rect for d in ability_doors if not d.open] enemy_collision_platforms += enemy_blocking_triggers enemy_collision_platforms += solid_dissolve # Shade doors are solid for the world, but the player can pass through them # freely while in shade form — and for a short grace period after switching # forms, so they aren't instantly ejected from a door they're standing in. player_ignores_shade_doors = player.shade or player.shade_door_grace > 0.0 if player_ignores_shade_doors: shade_door_ids = {id(d.rect) for d in ability_doors if (not d.open) and d.kind == "shade"} player_collision_platforms = [r for r in collision_platforms if id(r) not in shade_door_ids] else: player_collision_platforms = collision_platforms # Grace expired: safely eject the player from any shade door they're # still inside (nearest edge) before collision runs. for d in ability_doors: if (not d.open) and d.kind == "shade" and player.rect.colliderect(d.rect): push_rect_out_of_rects(player.rect, [d.rect]) player_collision_platforms = player_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) # Glue the player to any moving platform they're standing on, so upward movement # doesn't leave a gap that makes on_ground flicker (bobbing / eventual fall-off). for rect in platforms_moving: if not moving_platform_active(rect): continue if (player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and player.rect.right > rect.left and player.rect.left < rect.right): player.rect.bottom = rect.top player.vel_y = 0.0 player.on_ground = True player.ground_platform = rect break # Lock-on-pass trigger walls: gone initially, they appear once the player has # walked through them (crossing from one side to the other) — arena lock. for rect in platforms_trigger: meta = platform_meta.get(id(rect), {}) if not meta.get("lock_when_pass"): continue key = id(rect) if key in LOCKED_TRIGGER_WALLS: continue entry = TRIGGER_WALL_TOUCH.get(key) if entry is None: if player.rect.colliderect(rect): TRIGGER_WALL_TOUCH[key] = "L" if player.rect.centerx < rect.centerx else "R" elif entry == "L" and player.rect.left >= rect.right: LOCKED_TRIGGER_WALLS.add(key) elif entry == "R" and player.rect.right <= rect.left: LOCKED_TRIGGER_WALLS.add(key) # Hazard zones: touching one deals 1 damage and teleports the player to its linked respawn # (defaults to the room's player spawn). A lethal hit falls through to the normal death respawn. for hz in platforms_hazard: if player.rect.colliderect(hz): player.take_damage(1) mdata = platform_meta.get(id(hz), {}) respawn = mdata.get("respawn") if respawn: spx, spy = respawn else: spx, spy = ROOM.player_spawn player.rect.midbottom = (int(spx), int(spy)) player.vel_y = 0.0 player.on_ground = False break 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: if ACTIVE_REFIGHT is not None: # Died in a diary refight. Refights are consequence-free practice: # skip the death penalty entirely (no coin/jar loss, no corpse) and # return the player to the diary in town. ACTIVE_REFIGHT = None room = room_manager.load_room(TOWN_ROOM_ID) 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: apply_death_penalty() # Normal death respawn: latest lantern save point, else the room spawn. 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 # Never respawn embedded in a platform: push out to the nearest edge. push_rect_out_of_rects(player.rect, ROOM.platforms) # Update enemies for en in enemies: en.update(dt, enemy_collision_platforms, ground_platforms, player, shockwaves, boss_projectiles, enemies, boss_aoes) # Enemies that touch a hazard zone die instantly, dropping nothing. for en in enemies: if en.health <= 0: continue if any(en.rect.colliderect(hz) for hz in platforms_hazard): en.health = 0.0 en.hazard_kill = True # 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 player.attacking: dmg = 2 if player.shade else 1 for en in enemies: if en.health <= 0: continue eid = id(en) if eid in player.swing_hit_ids: continue if any(box.colliderect(en.rect) for box in atk_boxes): player.swing_hit_ids.add(eid) en.take_damage(dmg) # Downward strike bounce (stomp/pogo): hitting from above while # swinging down launches the player up. The grace window keeps # jump-hold gravity off and blocks contact damage while escaping. if player.attack_dir_y > 0.3 and player.rect.centery <= en.rect.centery: player.vel_y = min(player.vel_y, DOWN_SLASH_BOUNCE) if player.rect.bottom > en.rect.top: player.rect.bottom = en.rect.top player.on_ground = False player.ground_platform = None player.pogo_timer = max(player.pogo_timer, POGO_GRACE) # 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, "drop_profile", "") == "boss": en.apply_stun(DASH_STUN_BOSS) elif getattr(en, "drop_profile", "") == "miniboss": en.apply_stun(DASH_STUN_MINIBOSS) else: en.apply_stun(DASH_STUN_NORMAL) # Enemy contact damage (only when player is Light, and not during a pogo escape) for en in enemies: if (not player.shade) and player.pogo_timer <= 0 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) # Fairy combat mode: auto-fire at nearby enemies (drains Charge). update_fairy_combat(dt, enemies, projectiles, collision_platforms) # Update projectiles for pr in projectiles: pr.update(dt, collision_platforms, enemies, ability_doors) projectiles = [pr for pr in projectiles if pr.alive] # Update flying-boss projectiles (homing shots) for bp in boss_projectiles: bp.update(dt, collision_platforms) boss_projectiles = [bp for bp in boss_projectiles if bp.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 flying-boss AOE rings for a in boss_aoes: a.update(dt) boss_aoes = [a for a in boss_aoes if a.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) if sw.poison: player.apply_poison(BOSS_POISON_DURATION) sw.t = 0 # one-hit # Flying-boss AOE rings hit the player (one hit per ring) for a in boss_aoes: if (not player.shade) and a.hits(player.rect): did_hit = player.take_damage(a.damage) if did_hit: player.apply_knockback( pygame.Rect(a.center[0] - 2, a.center[1] - 2, 4, 4), strength_px_per_frame=a.knock_strength, duration=a.knock_duration, pop_up_vel=a.pop_up, ) a.hit = True # Flying-boss homing projectiles hit the player for bp in boss_projectiles[:]: if (not player.shade) and player.rect.colliderect(bp.rect): did_hit = player.take_damage(bp.damage) if did_hit: player.apply_knockback(bp.rect, strength_px_per_frame=10.0, duration=0.18, pop_up_vel=-3.0) boss_projectiles.remove(bp) # 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: # Summoned minion expired on its own: heals the boss, no drops. if getattr(en, "minion_expired", False): owner = getattr(en, "minion_owner", None) if owner is not None and owner.health > 0: owner.health = min(owner.max_health, owner.health + getattr(en, "minion_heal", 0.0)) continue if getattr(en, "hazard_kill", False): continue # hazard deaths drop nothing and give no quest credit # Player-killed minion: damages the boss, no drops. if getattr(en, "type_id", "") == "boss_minion": owner = getattr(en, "minion_owner", None) if owner is not None and owner.health > 0: owner.take_damage(getattr(en, "minion_damage", 0.0)) continue loot_drops.extend(spawn_drops_from_enemy(en)) etype = getattr(en, "type_id", "") eprof = getattr(en, "drop_profile", "") if eprof == "boss": mark_boss_defeated(ROOM.id) bid = boss_id_for_room(ROOM.id) if bid and ACTIVE_REFIGHT is None: tx = en.rect.centerx - 8 ty = en.rect.top - 20 token_drops.append(TokenPickup(tx, ty, bid)) # Remember the drop so it survives leaving the room / reloading. PENDING_TOKEN_DROPS.setdefault(ROOM.id, []).append( {"boss_id": bid, "x": int(tx), "y": int(ty)}) # A defeated flying boss takes its summoned minions down with it. for other in enemies: if other is en: continue if getattr(other, "type_id", "") == "boss_minion" and other.health > 0: other.health = 0.0 other.hazard_kill = True elif eprof == "miniboss": DEFEATED_MINIBOSSES.add(ROOM.id) # Quest kill tracking for qid in quest_manager.progress_kill(etype, player.shade): 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, "drop_profile", "") == "normal": 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, "drop_profile", "") == "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, "drop_profile", "") == "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: # Chests open on interact (F); the interact handler calls reward_chest.open(). 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 acquired (Right Mouse Button)", 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) if not player.butterfly_jar_seen: player.butterfly_jar_seen = True use_key = key_name(key_for("use_butterfly_jar")).upper() pickup_feed.push_center( f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})" f" - press {use_key} to use", duration=4.5, ) else: 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']}!") for qid in quest_manager.progress_collect("boss_token", 1): q = quest_manager.get(qid) if q: pickup_feed.push_plain(f"Quest fulfilled: {q.name} - return to the quest object!") token_drops.remove(t) pend = PENDING_TOKEN_DROPS.get(ROOM.id) if pend: for i, d in enumerate(pend): if d.get("boss_id") == t.boss_id: pend.pop(i) break if not pend: PENDING_TOKEN_DROPS.pop(ROOM.id, None) # 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) # flying-boss AOE rings for a in boss_aoes: a.draw(screen, camera_x, camera_y) # projectiles for pr in projectiles: pr.draw(screen, camera_x, camera_y) for bp in boss_projectiles: bp.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) # Fairy companion bubble: active line, or a talk prompt when at a fairy spot. fairy_rect = player.fairy_screen_rect if fairy_rect is not None: # Anchor the bubble above the player rather than the fairy: the fairy # swaps sides when the player flips, which made the text jump. bubble_x = player.rect.centerx - camera_x if fairy_speech_text: draw_speech_bubble(screen, fairy_speech_text, bubble_x, fairy_rect.top) elif get_near_fairy_dust(player, fairy_dusts): draw_speech_bubble(screen, "(Press F to collect)", bubble_x, fairy_rect.top) elif get_near_fairy_spot(player, fairy_spots): draw_speech_bubble(screen, "(Press F to talk)", bubble_x, fairy_rect.top) # 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) # Shade-gated quests are only interactable while in shade form; in light # form the object is visible but shows no interaction prompt. if near_qg and quest_manager.requires_shade(near_qg.quest_id) and not player.shade: near_qg = None 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), ) # Chest open hint bubble if not inv_ui.visible and not trade_ui.visible and not diary_ui.visible: if get_near_chest(player, reward_chest): draw_speech_bubble( screen, "Press F - Open chest", int(reward_chest.rect.centerx - camera_x), int(reward_chest.rect.top - camera_y), ) # Boss health bar (if boss exists) boss = next((en for en in enemies if getattr(en, "drop_profile", "") == "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_mist_overlay(screen) 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()