Files
Lena d56540785f flyer boss, town
flyer boss adjustments, town addition
2026-10-05 15:04:20 +02:00

9519 lines
363 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
"use_butterfly_jar": pygame.K_g,
"toggle_shade": pygame.K_LSHIFT,
"inventory": pygame.K_i,
"interact": pygame.K_f,
"stats_overlay": pygame.K_h,
"pause": pygame.K_ESCAPE,
"ui_up": pygame.K_UP,
"ui_down": pygame.K_DOWN,
"ui_left": pygame.K_LEFT,
"ui_right": pygame.K_RIGHT,
"ui_confirm": pygame.K_RETURN,
"ui_confirm_alt": pygame.K_KP_ENTER,
"ui_cancel": pygame.K_ESCAPE,
"debug_1": pygame.K_F5,
"debug_2": pygame.K_F6,
"debug_3": pygame.K_F7,
}
BINDINGS = dict(BINDINGS_DEFAULT)
def key_name(key_code: int) -> str:
if key_code is None:
return "-"
try:
return pygame.key.name(key_code)
except Exception:
return str(key_code)
def key_for(action: str) -> int | None:
return BINDINGS.get(action)
def key_is(action: str, key: int) -> bool:
return key_for(action) == key
def key_pressed(keys, action: str) -> bool:
k = key_for(action)
return bool(k is not None and keys[k])
def scale_count(base: int, mult: float) -> int:
if mult <= 0:
return 0
scaled = float(base) * float(mult)
whole = int(scaled)
frac = scaled - whole
if random.random() < frac:
whole += 1
return max(0, whole)
SETTINGS_ACTIONS = [
("Move Left", "move_left"),
("Move Right", "move_right"),
("Move Down", "move_down"),
("Jump", "jump"),
("Heal", "heal"),
("Attack", "attack"),
("Ranged", "ranged"),
("Dash", "dash"),
("Shatterstep", "shatterstep"),
("Tether", "tether"),
("Reality Break", "reality_break"),
("Use Butterfly Jar", "use_butterfly_jar"),
("Toggle Shade", "toggle_shade"),
("Inventory", "inventory"),
("Interact/Trade", "interact"),
("Stats Overlay", "stats_overlay"),
("Pause", "pause"),
("Debug 1", "debug_1"),
("Debug 2", "debug_2"),
("Debug 3", "debug_3"),
]
# ----------------------------
# Paths + JSON loading helpers
# ----------------------------
BASE_DIR = Path(__file__).resolve().parent
DATA_ROOT = BASE_DIR / "data"
CONFIG_DIR = DATA_ROOT / "config"
DROPS_DIR = DATA_ROOT / "drops"
ITEMS_DIR = DATA_ROOT / "items"
ENEMIES_DIR = DATA_ROOT / "enemies"
LEVELS_DIR = DATA_ROOT / "levels" # legacy (still supported)
ROOMS_DIR = DATA_ROOT / "world" / "rooms"
UI_DIR = DATA_ROOT / "ui"
UI_ICONS_SLOTS_DIR = UI_DIR / "icons" / "slots"
OBJECTS_DIR = DATA_ROOT / "sprites" / "objects"
def load_json(path: Path) -> dict:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def find_file_anywhere(filename: str, roots: list[Path]) -> Path | None:
"""Try typical locations first, then fallback to rglob search inside the given roots."""
for r in roots:
p = r / filename
if p.exists():
return p
for r in roots:
if r.exists():
hits = list(r.rglob(filename))
if hits:
return hits[0]
return None
def load_json_safe(filename: str, roots: list[Path], default: dict) -> dict:
p = find_file_anywhere(filename, roots)
if not p:
print(f"[JSON missing] {filename} (using defaults)")
return deepcopy(default)
try:
return load_json(p)
except Exception as e:
print(f"[JSON load failed] {p} -> {e} (using defaults)")
return deepcopy(default)
# ----------------------------
# Broken ability doors (in-memory, resets on restart)
# ----------------------------
BROKEN_DOORS: set[str] = set()
def broken_door_key(room_id: str, rect) -> str:
return f"{room_id}:{int(rect[0])},{int(rect[1])},{int(rect[2])},{int(rect[3])}"
def is_door_broken(room_id: str, rect) -> bool:
return broken_door_key(room_id, rect) in BROKEN_DOORS
def mark_door_broken(room_id: str, rect):
if not room_id:
return
BROKEN_DOORS.add(broken_door_key(room_id, rect))
# ----------------------------
# Collected butterfly jars (persisted in saves)
# ----------------------------
COLLECTED_JARS: set[str] = set()
BUTTERFLY_JARS_MAX = 3
def jar_key(room_id: str, x: int, y: int) -> str:
return f"{room_id}:{int(x)},{int(y)}"
# ----------------------------
# Lantern save-point respawn (persisted in saves)
# ----------------------------
LANTERN_RESPAWN_ROOM: str | None = None
LANTERN_RESPAWN_POS: tuple[int, int] | None = None
# ----------------------------
# Death penalty state (session-only, not persisted)
# ----------------------------
corpse = None # Corpse | None
JAR_DEATH_ROOM: str | None = None
# ----------------------------
# Game config (numbers & colors)
# ----------------------------
GAME_CONFIG_DEFAULT = {
"window": {"width": 800, "height": 450, "caption": "Light_and_Dark Prototype", "fps": 60},
"colors": {},
"loot_colors": {},
"physics": {},
"resources": {},
"healing": {},
"boss_shockwave": {},
"assets": {"player_sprite": "Player_01.png", "player_sprite_shade": "Player_02.png"},
"debug": {"drop_debug_print": True}
}
GAME_CONFIG = load_json_safe("game_config.json", [CONFIG_DIR, DATA_ROOT], GAME_CONFIG_DEFAULT)
# --- Colors config (separate file to declutter code) ---
COLOR_CFG = load_json_safe("colors.json", [CONFIG_DIR, DATA_ROOT], {})
# --- Physics config (separate file to declutter code) ---
PHYS_CFG = load_json_safe("physics.json", [CONFIG_DIR, DATA_ROOT], {})
# --- UI layout config (separate file to declutter UI numbers) ---
UI_CFG = load_json_safe("ui_layout.json", [CONFIG_DIR, DATA_ROOT], {})
# --- Additional tuning configs ---
RANGED_CFG = load_json_safe("ranged.json", [CONFIG_DIR, DATA_ROOT], {})
REWARD_CFG = load_json_safe("reward_room.json", [CONFIG_DIR, DATA_ROOT], {})
NPC_CFG = load_json_safe("npc.json", [CONFIG_DIR, DATA_ROOT], {})
ANIM_CFG = load_json_safe("animation.json", [CONFIG_DIR, DATA_ROOT], {})
COIN_DROPS_CFG = load_json_safe("coin_drops.json", [CONFIG_DIR, DATA_ROOT], {})
def _cfg(path, default=None):
cur = GAME_CONFIG
for key in path:
if not isinstance(cur, dict) or key not in cur:
return default
cur = cur[key]
return cur
def _color(path, default=(255, 255, 255)):
cur = COLOR_CFG
for key in path:
if not isinstance(cur, dict) or key not in cur:
return default
cur = cur[key]
# Expect JSON arrays like [30, 30, 30]
return tuple(int(x) for x in cur)
def _phys(path, default=None):
cur = PHYS_CFG
for key in path:
if not isinstance(cur, dict) or key not in cur:
return default
cur = cur[key]
return cur
def _ui(path, default=None):
cur = UI_CFG
for key in path:
if not isinstance(cur, dict) or key not in cur:
return default
cur = cur[key]
return cur
def _ranged(path, default=None):
cur = RANGED_CFG
for key in path:
if not isinstance(cur, dict) or key not in cur:
return default
cur = cur[key]
return cur
def _reward(path, default=None):
cur = REWARD_CFG
for key in path:
if not isinstance(cur, dict) or key not in cur:
return default
cur = cur[key]
return cur
def _npc(path, default=None):
cur = NPC_CFG
for key in path:
if not isinstance(cur, dict) or key not in cur:
return default
cur = cur[key]
return cur
def _anim(path, default=None):
cur = ANIM_CFG
for key in path:
if not isinstance(cur, dict) or key not in cur:
return default
cur = cur[key]
return cur
def _coin_drop(path, default=None):
def _get_from(cfg):
cur = cfg
for key in path:
if not isinstance(cur, dict) or key not in cur:
return None
cur = cur[key]
return cur
room_cfg = None
try:
room_cfg = ROOM.coin_drops_cfg
except Exception:
room_cfg = None
if isinstance(room_cfg, dict):
room_val = _get_from(room_cfg)
if room_val is not None:
return room_val
global_val = _get_from(COIN_DROPS_CFG)
if global_val is not None:
return global_val
return default
# --- Colors ---
BG = _color(["bg"])
PLAYER_LIGHT = _color(["player", "light"])
PLAYER_DARK = _color(["player", "dark"])
PLATFORM_COLOR = _color(["platform"])
CHARGE_BG = _color(["bars", "charge_bg"])
CHARGE_FILL = _color(["bars", "charge_fill"])
HEALTH_BG = _color(["bars", "health_bg"])
HEALTH_FILL = _color(["bars", "health_fill"])
ENEMY_COLOR = _color(["enemy", "normal"])
ENEMY_HIT_COLOR = _color(["enemy", "normal_hit"])
MINIBOSS_COLOR = _color(["enemy", "miniboss"])
MINIBOSS_HIT_COLOR = _color(["enemy", "miniboss_hit"])
BOSS_COLOR = _color(["enemy", "boss"])
BOSS_HIT_COLOR = _color(["enemy", "boss_hit"])
FLYER_BOSS_COLOR = _color(["enemy", "boss_flyer"], (150, 90, 220))
FLYER_BOSS_HIT_COLOR = _color(["enemy", "boss_flyer_hit"], (210, 170, 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))
# 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)))
# --- 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"
},
}
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",
])
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 ``<boss_id>_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
def _scale_icon(img: pygame.Surface | None, size: int) -> pygame.Surface | None:
if img is None:
return None
return pygame.transform.smoothscale(img, (size, size))
def _scale_image(img: pygame.Surface | None, size: tuple[int, int]) -> pygame.Surface | None:
if img is None:
return None
return pygame.transform.smoothscale(img, (int(size[0]), int(size[1])))
SHATTER_ICON_LIGHT = _scale_icon(SHATTER_ICON_LIGHT_RAW, SHATTER_ICON_SIZE)
SHATTER_ICON_DARK = _scale_icon(SHATTER_ICON_DARK_RAW, SHATTER_ICON_SIZE)
TETHER_ICON_LIGHT = _scale_icon(TETHER_ICON_LIGHT_RAW, TETHER_ICON_SIZE)
TETHER_ICON_DARK = _scale_icon(TETHER_ICON_DARK_RAW, TETHER_ICON_SIZE)
REALITY_ICON_LIGHT = _scale_icon(REALITY_ICON_LIGHT_RAW, REALITY_BREAK_ICON_SIZE)
REALITY_ICON_DARK = _scale_icon(REALITY_ICON_DARK_RAW, REALITY_BREAK_ICON_SIZE)
TETHER_SPRITE_LIGHT = _scale_image(TETHER_SPRITE_LIGHT_RAW, TETHER_SPRITE_DRAW_SIZE)
TETHER_SPRITE_LIGHT_SIZE = TETHER_SPRITE_LIGHT.get_size() if TETHER_SPRITE_LIGHT else TETHER_SIZE
TETHER_SPRITE_DARK = _scale_image(TETHER_SPRITE_DARK_RAW, TETHER_SPRITE_DRAW_SIZE)
TETHER_SPRITE_DARK_SIZE = TETHER_SPRITE_DARK.get_size() if TETHER_SPRITE_DARK else TETHER_SIZE
TETHER_EDGE_LIGHT = _scale_image(TETHER_EDGE_LIGHT_RAW, TETHER_SPRITE_DRAW_SIZE)
TETHER_EDGE_DARK = _scale_image(TETHER_EDGE_DARK_RAW, TETHER_SPRITE_DRAW_SIZE)
LAMP_PATH = OBJECTS_DIR / "lamp_1.png"
LAMP_RAW = _load_image_or_none(LAMP_PATH)
BUTTERFLY_PATH = OBJECTS_DIR / "flow_butterfly1.png"
BUTTERFLY_RAW = _load_image_or_none(BUTTERFLY_PATH)
BUTTERFLY_JAR_PATH = OBJECTS_DIR / "flow_butterfly_jar1.png"
BUTTERFLY_JAR_RAW = _load_image_or_none(BUTTERFLY_JAR_PATH)
# --- Fairy companion (hovers behind the player's head) ---
FAIRY_PATH = DATA_ROOT / "sprites" / "npcs" / "fairy1.png"
FAIRY_RAW = _load_image_or_none(FAIRY_PATH)
FAIRY_SPRITE = None
if FAIRY_RAW is not None:
try:
fs = max(1, int(FAIRY_RAW.get_width() * PLAYER_SPRITE_SCALE * 0.55))
FAIRY_SPRITE = pygame.transform.smoothscale(FAIRY_RAW, (fs, fs))
except Exception:
FAIRY_SPRITE = FAIRY_RAW
FAIRY_SPRITE_FLIPPED = pygame.transform.flip(FAIRY_SPRITE, True, False) if FAIRY_SPRITE is not None else None
# ----------------------------
# Loot + Shockwave
# ----------------------------
def roll_from_table(table: dict) -> str | None:
"""table keys: awakened/attuned/tempered/none with probabilities that sum to ~1."""
r = random.random()
acc = 0.0
for key in ["awakened", "attuned", "tempered", "none"]:
p = float(table.get(key, 0.0)) if isinstance(table, dict) else 0.0
acc += p
if r < acc:
return None if key == "none" else key
return None
class Shockwave:
def __init__(self, x, y, direction: int, speed: float, lifetime: float, height: int, damage: int, 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 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 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 _build_options(self, npc: NPC | None) -> list[dict]:
# Combat Master: offer to upgrade each carried boss token
if npc is not None and npc.type == "combat_master":
options = []
for it in self.inv.items:
if isinstance(it, dict) and it.get("item_type") == "boss_token":
tier = int(it.get("tier", 0))
if tier >= 1:
continue
options.append({
"type": "upgrade_token",
"boss_id": it.get("boss_id"),
"label": f"Upgrade {it.get('name', 'Token')} (Hard)",
})
return options
offers = npc.offers if npc and npc.offers else []
if not offers:
npc_type = npc.type if npc else "vendor"
default_offers = _npc(["defaults", npc_type, "offers"], [])
if isinstance(default_offers, list) and default_offers:
offers = default_offers
if not offers:
offers = [
{"type": "sell_item", "slot": "helmet", "label": "Helmet", "rarity": "tempered"},
{"type": "sell_item", "slot": "chest", "label": "Chestplate", "rarity": "tempered"},
{"type": "sell_item", "slot": "pants", "label": "Greaves", "rarity": "tempered"},
]
options = []
for offer in offers:
if not isinstance(offer, dict):
continue
otype = str(offer.get("type", "sell_item"))
if otype == "upgrade_attune":
required = int(offer.get("required_count", 5))
counts = {}
names = {}
for it in self.inv.items:
if not isinstance(it, dict):
continue
if it.get("rarity") != "tempered":
continue
item_id = it.get("id")
if not item_id:
continue
counts[item_id] = counts.get(item_id, 0) + 1
if item_id not in names:
names[item_id] = it.get("name", item_id)
for item_id, count in counts.items():
if count < required:
continue
label = f"Upgrade {names.get(item_id, item_id)} (x{required})"
options.append({
"type": "upgrade_attune",
"item_id": item_id,
"label": label,
"required_count": required,
"cost": offer.get("cost"),
})
else:
opt = dict(offer)
opt["type"] = otype
options.append(opt)
return options
def _try_trade(self):
cost = NPC_TRADE_COST
if self.active_npc and self.active_npc.trade_cost is not None:
cost = int(self.active_npc.trade_cost)
if self.player.coins_light < cost:
pickup_feed.push_plain("Not enough light coins")
return
if not self.options:
pickup_feed.push_plain("No offers available")
return
choice = self.options[self.cursor]
choice_type = str(choice.get("type", "sell_item"))
choice_cost = choice.get("cost")
if isinstance(choice_cost, (int, float)):
cost = int(choice_cost)
if self.player.coins_light < cost:
pickup_feed.push_plain("Not enough light coins")
return
if choice_type == "upgrade_attune":
item_id = choice.get("item_id")
required = int(choice.get("required_count", 5))
if not item_id:
pickup_feed.push_plain("No item selected")
return
base = safe_get_item(str(item_id))
if not base:
pickup_feed.push_plain("Unknown item")
return
count = sum(1 for it in self.inv.items if isinstance(it, dict)
and it.get("id") == item_id and it.get("rarity") == "tempered")
if count < required:
pickup_feed.push_plain(f"Need {required} tempered copies")
return
removed = required
new_items = []
for it in self.inv.items:
if removed > 0 and isinstance(it, dict) and it.get("id") == item_id and it.get("rarity") == "tempered":
removed -= 1
continue
new_items.append(it)
self.inv.items = new_items
self.player.coins_light -= cost
upgraded = build_item_instance(base, "attuned")
self.inv.add(upgraded)
pickup_feed.push_plain_rarity(f"Upgraded {upgraded.get('name', 'Item')} to ", "attuned")
# refresh options after upgrade
self.options = self._build_options(self.active_npc)
if self.options:
self.cursor = max(0, min(self.cursor, len(self.options) - 1))
else:
self.cursor = 0
elif choice_type == "upgrade_token":
boss_id = choice.get("boss_id")
idx = None
for i, it in enumerate(self.inv.items):
if (isinstance(it, dict) and it.get("item_type") == "boss_token"
and it.get("boss_id") == boss_id and int(it.get("tier", 0)) == 0):
idx = i
break
if idx is None:
pickup_feed.push_plain("Token not found.")
return
token = dict(self.inv.items.pop(idx))
token["tier"] = 1
token["name"] = make_boss_token(boss_id, 1)["name"]
self.inv.add(token)
pickup_feed.push_plain(f"Upgraded to {token['name']}!")
self.options = self._build_options(self.active_npc)
if self.options:
self.cursor = max(0, min(self.cursor, len(self.options) - 1))
else:
self.cursor = 0
elif choice_type == "sell_specific":
item_id = choice.get("item_id")
if not item_id:
pickup_feed.push_plain("No item selected")
return
base = safe_get_item(str(item_id))
if not base:
pickup_feed.push_plain("Unknown item")
return
rarity = choice.get("rarity", None)
item = build_item_instance_any(base, rarity)
self.player.coins_light -= cost
self.inv.add(item)
pickup_feed.push_plain(f"Bought {item.get('name', 'Item')}")
self.options = self._build_options(self.active_npc)
else:
item = self._random_item(choice["slot"], choice.get("rarity", "tempered"))
if not item:
pickup_feed.push_plain("No items available")
return
self.player.coins_light -= cost
self.inv.add(item)
pickup_feed.push_plain(f"Bought {item.get('name', 'Item')}")
# refresh options in case inventory-driven offers changed
self.options = self._build_options(self.active_npc)
def draw(self, surface: pygame.Surface):
if not self.visible:
return
panel_w = 340
panel_h = 180
x = (WIDTH - panel_w) // 2
y = (HEIGHT - panel_h) // 2
panel = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA)
panel.fill((10, 10, 10, 220))
surface.blit(panel, (x, y))
pygame.draw.rect(surface, (50, 50, 50), (x, y, panel_w, panel_h), 2)
title = self.font.render("Vendor Trade", True, (230, 230, 230))
surface.blit(title, (x + 14, y + 10))
cost = NPC_TRADE_COST
if self.active_npc and self.active_npc.trade_cost is not None:
cost = int(self.active_npc.trade_cost)
if self.options:
opt_cost = self.options[self.cursor].get("cost")
if isinstance(opt_cost, (int, float)):
cost = int(opt_cost)
cost_txt = self.font.render(f"Cost: {cost} light coins", True, (180, 180, 180))
surface.blit(cost_txt, (x + 14, y + 32))
list_y = y + 60
if not self.options:
none_txt = self.font.render("No offers available", True, (160, 160, 160))
surface.blit(none_txt, (x + 22, list_y))
return
for i, opt in enumerate(self.options):
otype = str(opt.get("type", "sell_item"))
if otype == "upgrade_attune":
label = str(opt.get("label", "Upgrade"))
else:
rarity = str(opt.get("rarity", "")).title()
if rarity:
label = f"{opt['label']} - {rarity}"
else:
label = str(opt.get("label", "Item"))
col = (240, 240, 240) if i == self.cursor else (170, 170, 170)
line = self.font.render(label, True, col)
surface.blit(line, (x + 22, list_y + i * 28))
def spawn_drops_from_enemy(enemy) -> list[LootPickup]:
drops: list[LootPickup] = []
prof = ENEMY_DROPS.get(enemy.drop_profile, None)
if _cfg(["debug", "drop_debug_print"], True):
print("DROP DEBUG:",
"enemy_type=", enemy.type_id,
"profile=", enemy.drop_profile,
"prof_found=", isinstance(prof, dict),
"pool=", (prof.get("pool") if isinstance(prof, dict) else None))
if not isinstance(prof, dict):
return drops
pool = prof.get("pool", [])
if not isinstance(pool, list):
pool = []
default_pool = prof.get("default_pool", None)
if (not pool) and default_pool == "armor":
pool = list(ARMOR_BY_ID.keys())
elif (not pool) and default_pool == "necklace":
pool = list(NECKLACE_BY_ID.keys())
# Boss (first boss) drops only light/general gear; the miniboss keeps everything.
restrict_shade = (enemy.drop_profile == "boss")
def _shade_ok(item_id):
base = safe_get_item(item_id)
return base is not None and not item_is_shade_restricted(base)
def pick_from(source):
if restrict_shade:
source = [i for i in source if _shade_ok(i)]
return random.choice(source) if source else None
# ---- drop placement helper (prevents overlap) ----
spawned = 0
def drop_pos():
nonlocal spawned
# base near enemy feet
base_x = enemy.rect.centerx - 8
base_y = enemy.rect.bottom - 40
# scatter + gentle "row nudge" so multiple drops don't stack
x = base_x + random.randint(-36, 36) + (spawned % 6) * 12
y = base_y + random.randint(-6, 6)
spawned += 1
return x, y
# guaranteed drops
guaranteed = prof.get("guaranteed", [])
if isinstance(guaranteed, list):
for g in guaranteed:
if not isinstance(g, dict):
continue
rarity = g.get("rarity", None)
count = scale_count(int(g.get("count", 1)), ROOM_DROP_MULT)
for _ in range(max(0, count)):
item_id = g.get("item_id", None)
pool_kind = g.get("item_pool", None)
if not item_id:
if pool_kind == "necklace":
item_id = pick_from(list(NECKLACE_BY_ID.keys()))
elif pool_kind == "armor":
item_id = pick_from(list(ARMOR_BY_ID.keys()))
else:
item_id = pick_from(pool)
if not item_id:
continue
base = safe_get_item(item_id)
if not base:
print("DROP DEBUG: missing item ->", item_id)
continue
try:
inst = build_item_instance_any(base, rarity)
except Exception:
continue
x, y = drop_pos()
drops.append(LootPickup(x, y, inst))
# random rolls
rolls = scale_count(int(prof.get("rolls", 0)), ROOM_DROP_MULT)
rarity_table = prof.get("rarity_table", {})
for _ in range(rolls):
rarity = roll_from_table(rarity_table)
if rarity is None:
continue
if not pool:
continue
item_id = pick_from(pool)
if not item_id:
continue
base = safe_get_item(item_id)
if not base:
print("DROP DEBUG: missing item ->", item_id)
continue
try:
inst = build_item_instance_any(base, rarity)
except Exception:
continue
x, y = drop_pos()
drops.append(LootPickup(x, y, inst))
return drops
# ----------------------------
# Inventory
# ----------------------------
class Inventory:
def __init__(self):
self.items: list[dict] = []
def add(self, item_instance: dict):
if isinstance(item_instance, dict):
self.items.append(item_instance)
def __len__(self):
return len(self.items)
class InventoryUI:
def __init__(self, inventory: Inventory, player, font_obj: pygame.font.Font):
self.inv = inventory
self.player = player
self.font = font_obj
self.visible = False
self.focus = "inv" # "inv" or "equip"
self.cursor = 0
self.scroll = 0
self.equip_slots = ["helmet", "chest", "pants", "necklace"]
self.equip_idx = 0
self.x = int(_ui(["inventory_ui", "panel", "x"], 20))
self.y = int(_ui(["inventory_ui", "panel", "y"], 20))
self.w = int(_ui(["inventory_ui", "panel", "w"], 560))
self.h = int(_ui(["inventory_ui", "panel", "h"], 380))
self.pad = int(_ui(["inventory_ui", "panel", "pad"], 10))
self.list_w = int(_ui(["inventory_ui", "list", "w"], 360))
self.line_h = int(_ui(["inventory_ui", "list", "line_h"], 18))
# remembers how many lines the list can show (set in draw())
self._last_list_max_lines = 1
self.slot_icons = load_slot_icons()
self.slot_icon_cache = {} # (slot, rarity) -> Surface
self.token_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 toggle(self):
self.visible = not self.visible
if self.visible:
self.focus = "inv"
self._clamp_cursor()
def _clamp_cursor(self):
n = len(self.inv.items)
if n == 0:
self.cursor = 0
self.scroll = 0
return
self.cursor = max(0, min(self.cursor, n - 1))
# scroll is NOT allowed to go to n-1; it must stop at the last page start
max_scroll = max(0, n - self._last_list_max_lines)
self.scroll = max(0, min(self.scroll, max_scroll))
def _ensure_visible(self, max_lines: int):
if self.cursor < self.scroll:
self.scroll = self.cursor
if self.cursor >= self.scroll + max_lines:
self.scroll = self.cursor - max_lines + 1
self.scroll = max(0, min(self.scroll, max(0, len(self.inv.items) - max_lines)))
def handle_keydown(self, key):
if not self.visible:
return
if key == key_for("ui_cancel"):
self.toggle()
return
if key == key_for("ui_right"):
self.focus = "equip"
return
if key == key_for("ui_left"):
self.focus = "inv"
return
if self.focus == "inv":
if key == key_for("ui_up"):
self.cursor -= 1
self._clamp_cursor()
self._ensure_visible(self._last_list_max_lines)
elif key == key_for("ui_down"):
self.cursor += 1
self._clamp_cursor()
self._ensure_visible(self._last_list_max_lines)
elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
self._equip_selected_swap()
else:
if key == key_for("ui_up"):
self.equip_idx = (self.equip_idx - 1) % len(self.equip_slots)
elif key == key_for("ui_down"):
self.equip_idx = (self.equip_idx + 1) % len(self.equip_slots)
elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
self._unequip_selected()
def _equip_selected_swap(self):
if len(self.inv.items) == 0:
return
idx = self.cursor
new_item = self.inv.items[idx]
slot = new_item.get("slot")
if slot not in self.player.equipment:
return
old_item = self.player.equipment.get(slot)
# equip new (swap)
self.player.equip_swap(new_item)
# remove selected from inventory
self.inv.items.pop(idx)
# insert old at same index (true swap)
if old_item is not None:
self.inv.items.insert(idx, old_item)
self._clamp_cursor()
def _unequip_selected(self):
"""Move the item in the selected equipment slot back to the inventory."""
slot = self.equip_slots[self.equip_idx]
item = self.player.equipment.get(slot)
if item is None:
return
self.player.equipment[slot] = None
self.inv.items.append(item)
self.player.recalc_bonuses()
self.player._clamp_resources()
self._clamp_cursor()
def _blit(self, surface, text, x, y, color=(230, 230, 230)):
surface.blit(self.font.render(text, True, color), (x, y))
def _rarity_color(self, rarity: str):
return LOOT_COLORS.get(rarity, (230, 230, 230))
def _get_slot_icon(self, slot: str, rarity: str | None) -> pygame.Surface | None:
slot = slot or "unknown"
rarity = rarity or ""
key = (slot, rarity)
if key in self.slot_icon_cache:
return self.slot_icon_cache[key]
base = self.slot_icons.get(slot) or self.slot_icons.get("unknown")
if base is None:
self.slot_icon_cache[key] = None
return None
# tint only if we have a known rarity color
col = LOOT_COLORS.get(rarity)
icon = tint_icon(base, col) if col else base
self.slot_icon_cache[key] = icon
return icon
def _get_stats(self, item: dict) -> dict:
if not item or not isinstance(item, dict):
return {}
stats = item.get("stats", {})
return stats if isinstance(stats, dict) else {}
def _format_stat_value(self, key: str, value):
"""
Format stats nicely:
- *_pct are stored as decimals (0.10) but displayed as 10%
- other stats displayed as ints if whole, else as 1 decimal
"""
if key.endswith("_pct"):
# 0.10 -> "10%"
if isinstance(value, (int, float)):
sign = "+" if value > 0 else ""
return f"{sign}{value * 100:.0f}%"
return str(value)
# non-percent stats
if isinstance(value, (int, float)) and abs(value - round(value)) < 1e-9:
v = int(round(value))
sign = "+" if v > 0 else ""
return f"{sign}{v}"
if isinstance(value, (int, float)):
sign = "+" if value > 0 else ""
return f"{sign}{value:.1f}"
return str(value)
def _stat_display_name(self, key: str) -> str:
names = {
"health": "Max Health",
"charge": "Max Charge",
"charge_regen_pct": "Charge Regeneration",
"heal_speed_pct": "Heal Speed",
"heal_plus": "Heal Amount",
"move_speed_pct": "Move Speed",
"move_speed_light_pct": "Move Speed (Light)",
"move_speed_shade_pct": "Move Speed (Shade)",
"attack_speed_pct": "Attack Speed",
"jump_height_shade_pct": "Jump Height (Shade)",
"negate_damage_chance": "Negate Damage Chance",
"shade_double_damage_chance": "Shade Double Damage Chance",
"shade_damage_flat": "Shade Damage",
"knockback_resistance": "Knockback Resistance",
"restore_charge": "Restore Charge",
"heal_flat": "Heal On Kill",
"shade_charge_drain_reduction_pct": "Shade Charge Drain Reduction",
"negate_shade_speed_penalty": "Negate Shade Speed Penalty",
}
if key in names:
return names[key]
# Fallback: "some_stat_name" -> "Some Stat Name"
return str(key).replace("_", " ").title()
def _ellipsize(self, text: str, max_px: int) -> str:
"""Trim text so it fits max_px pixels, adding '...' if needed."""
if self.font.size(text)[0] <= max_px:
return text
ell = "..."
ell_w = self.font.size(ell)[0]
if max_px <= ell_w:
return ell
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi) // 2
if self.font.size(text[:mid])[0] <= (max_px - ell_w):
lo = mid + 1
else:
hi = mid
cut = max(0, lo - 1)
return text[:cut] + ell
def _draw_stat_line(self, surface, x, y, name, value, delta=None):
display_name = self._stat_display_name(name)
value_txt = self._format_stat_value(name, value)
base_text = f"{display_name}: {value_txt}"
self._blit(surface, base_text, x, y, (230, 230, 230))
if delta is None:
return
# delta formatting: if *_pct, convert to percentage points
if name.endswith("_pct"):
# delta is e.g. 0.05 -> "+5%"
delta_txt_val = f"{delta * 100:+.0f}%"
else:
# normal numeric delta
if isinstance(delta, (int, float)) and abs(delta - round(delta)) < 1e-9:
delta_txt_val = f"{int(round(delta)):+d}"
else:
delta_txt_val = f"{delta:+.1f}"
if delta > 0:
col = (120, 255, 120)
elif delta < 0:
col = (255, 120, 120)
else:
col = (180, 180, 180)
dtxt = f" ({delta_txt_val})"
w = self.font.size(base_text)[0]
self._blit(surface, dtxt, x + w, y, col)
def _active_set_bonus_lines_for_item(self, item: dict) -> list[str]:
if not item or not isinstance(item, dict):
return []
set_id = item.get("set")
if not set_id:
return []
cfg = SET_BONUSES.get(set_id, {})
if not isinstance(cfg, dict):
return []
required = int(cfg.get("required_pieces", 3))
pieces = []
for it in self.player.equipment.values():
if not it or it.get("set") != set_id:
continue
rarity_id = it.get("rarity")
tier = it.get("tier", None)
if tier is None:
tier = RARITIES.get(rarity_id, {}).get("tier", 1)
tier = int(tier)
if tier >= 2:
pieces.append(it)
if len(pieces) < required:
return []
awakened_count = sum(1 for p in pieces if int(p.get("tier", 1)) >= 3)
bonus_tier = "awakened" if awakened_count >= required else "attuned"
tier_cfg = cfg.get(bonus_tier, {}) if isinstance(cfg, dict) else {}
effects = tier_cfg.get("effects", {}) if isinstance(tier_cfg, dict) else {}
conds = tier_cfg.get("conditional_effects", []) if isinstance(tier_cfg, dict) else []
lines = []
effect_summary = ""
if isinstance(effects, dict) and effects:
parts = []
for k, v in effects.items():
label = self._stat_display_name(k)
val = self._format_stat_value(k, v)
parts.append(f"{label} {val}")
effect_summary = ", ".join(parts)
if effect_summary:
lines.append(f"Set bonus ({bonus_tier}): {effect_summary}")
else:
set_name = str(set_id).replace("_", " ").title()
lines.append(f"Set bonus ({bonus_tier}): {set_name}")
if isinstance(conds, list) and conds:
for c in conds:
if not isinstance(c, dict):
continue
cond = c.get("condition", "condition")
eff = c.get("effects", {})
if not isinstance(eff, dict) or not eff:
continue
parts = []
for k, v in eff.items():
label = self._stat_display_name(k)
val = self._format_stat_value(k, v)
parts.append(f"{label} {val}")
lines.append(f"{cond}: " + ", ".join(parts))
return lines
def draw(self, surface):
if not self.visible:
return
panel = pygame.Rect(self.x, self.y, self.w, self.h)
pygame.draw.rect(surface, (40, 40, 40), panel)
pygame.draw.rect(surface, (150, 150, 150), panel, 2)
room_name = "Room"
try:
room_name = ROOM.name
except Exception:
pass
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))
# --- Detail areas (reserve bottom space FIRST) ---
stats_h = int(_ui(["inventory_ui", "details", "stats_h"], 90))
hint_h = int(_ui(["inventory_ui", "details", "hint_h"], 24))
gap = int(_ui(["inventory_ui", "details", "gap"], 8))
hint_y = self.y + self.h - self.pad - hint_h
stats_y = hint_y - gap - stats_h
# --- List region (everything between header and stats) ---
header_h = int(_ui(["inventory_ui", "list", "header_h"], 40))
list_top = self.y + header_h
list_h = (stats_y - gap) - list_top
list_h = max(40, list_h) # safety
# padding inside the list box (top + bottom)
list_pad_y = int(_ui(["inventory_ui", "list", "pad_y"], 6))
usable_h = max(1, list_h - list_pad_y * 2)
list_max_lines = max(1, usable_h // self.line_h)
self._ensure_visible(list_max_lines)
self._last_list_max_lines = list_max_lines
list_rect = pygame.Rect(self.x + self.pad, list_top, self.list_w, list_h)
pygame.draw.rect(surface, (45, 45, 45), list_rect)
pygame.draw.rect(surface, (110, 110, 110), list_rect, 1)
start = self.scroll
end = min(len(self.inv.items), start + list_max_lines)
y = list_top + list_pad_y
# --- clip everything to the list box ---
old_clip = surface.get_clip()
surface.set_clip(list_rect)
if len(self.inv.items) == 0:
self._blit(surface, "(empty)", self.x + self.pad + 6, y, (160, 160, 160))
else:
for idx in range(start, end):
it = self.inv.items[idx]
name = it.get("name", it.get("id", "<?>"))
rarity = it.get("rarity", "?")
slot = it.get("slot", "?")
if self.focus == "inv" and idx == self.cursor:
hl = pygame.Rect(self.x + 4, y - 2, self.list_w - 8, self.line_h)
pygame.draw.rect(surface, (60, 60, 60), hl)
rarity_text = f"[{rarity}]" if rarity else ""
right_text = f" ({slot})"
x0 = self.x + self.pad + 6
pad_right = int(_ui(["inventory_ui", "list", "pad_right"], 14)) # keep away from scrollbar
max_w = self.list_w - pad_right
prefix = f"{idx + 1:02d} "
suffix = f" {rarity_text}{right_text}"
icon_space = self.icon_size + self.icon_gap
avail_for_name = max_w - icon_space - self.font.size(suffix)[0]
name_fit = self._ellipsize(name, max(0, avail_for_name))
# --- slot icon (tinted by rarity), or token icon for boss tokens ---
icon = None
if it.get("item_type") == "boss_token":
icon = self._get_token_icon(it.get("boss_id"))
if icon is None:
icon = self._get_slot_icon(slot, rarity)
icon_y = y + (self.line_h - self.icon_size) // 2
if icon:
surface.blit(icon, (x0, icon_y))
text_x = x0 + self.icon_size + self.icon_gap
# --- item name ---
self._blit(surface, name_fit + " ", text_x, y, (230, 230, 230))
x1 = text_x + self.font.size(name_fit + " ")[0]
x0 = self.x + self.pad + 6
if rarity_text:
self._blit(surface, rarity_text, x1, y, self._rarity_color(rarity))
x2 = x1 + (self.font.size(rarity_text)[0] if rarity_text else 0)
self._blit(surface, right_text, x2, y, (230, 230, 230))
y += self.line_h
surface.set_clip(old_clip)
# Equipped column
eq_x = self.x + self.list_w + self.pad
eq_y = list_top
self._blit(surface, "Equipped:", eq_x, eq_y - 18, (220, 220, 220))
for i, slot in enumerate(self.equip_slots):
it = self.player.equipment.get(slot)
is_sel = (self.focus == "equip" and i == self.equip_idx)
rarity = it.get("rarity", "") if it else ""
icon = self._get_slot_icon(slot, rarity)
if is_sel:
hl = pygame.Rect(eq_x - 6, eq_y - 2, self.w - (eq_x - self.x) - self.pad, self.line_h + 2)
pygame.draw.rect(surface, (60, 60, 60), hl)
# draw icon
if icon:
icon_y = eq_y + (self.line_h - self.icon_size) // 2
surface.blit(icon, (eq_x, icon_y))
text_x = eq_x + (self.icon_size + self.icon_gap)
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 + int(_ui(["inventory_ui", "equipped", "line_gap"], 4))
# --- Quests (below equipped) ---
quests_x = eq_x
quests_y = eq_y + int(_ui(["inventory_ui", "quests", "gap"], 8))
quest_w = self.w - (quests_x - self.x) - self.pad
self._blit(surface, "Quests:", quests_x, quests_y, (220, 220, 220))
quests_y += self.line_h
active_ids = list(quest_manager.active.keys())
max_q = int(_ui(["inventory_ui", "quests", "max_lines"], 4))
shown = 0
for qid in active_ids[:max_q]:
q = quest_manager.get(qid)
if q is None:
continue
st = quest_manager.active[qid]
prog = int(st.get("progress", 0))
complete = prog >= q.target_count()
color = (150, 220, 150) if complete else (230, 230, 230)
label = self._ellipsize(f"{q.name}: {prog}/{q.target_count()}", max(0, quest_w))
self._blit(surface, label, quests_x, quests_y, color)
quests_y += self.line_h
shown += 1
if not active_ids:
self._blit(surface, "(no active quests)", quests_x, quests_y, (160, 160, 160))
elif len(active_ids) > shown:
self._blit(surface, f"+{len(active_ids) - shown} more", quests_x, quests_y, (160, 160, 160))
# --- Detail areas ---
stats_h = int(_ui(["inventory_ui", "details", "stats_h"], 90))
hint_h = int(_ui(["inventory_ui", "details", "hint_h"], 24))
gap = int(_ui(["inventory_ui", "details", "gap"], 8))
hint_y = self.y + self.h - self.pad - hint_h
stats_y = hint_y - gap - stats_h
# Hint box
pygame.draw.rect(
surface, (45, 45, 45),
(self.x + self.pad, hint_y, self.w - self.pad * 2, hint_h)
)
pygame.draw.rect(
surface, (110, 110, 110),
(self.x + self.pad, hint_y, self.w - self.pad * 2, hint_h),
1
)
# Hint text (NOW hint_y exists)
self._blit(
surface,
self._ellipsize(
"UP/DOWN move ENTER equip / unequip LEFT/RIGHT switch panel",
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
)
selected_item = None
if self.focus == "inv":
selected_item = self.inv.items[self.cursor] if len(self.inv.items) > 0 else None
selected_label = "Selected (Inventory):"
else:
slot = self.equip_slots[self.equip_idx]
selected_item = self.player.equipment.get(slot)
selected_label = f"Selected (Equipped: {slot}):"
self._blit(surface, selected_label, self.x + self.pad + 6, stats_y + 6, (220, 220, 220))
if not selected_item:
self._blit(surface, "(none)", self.x + self.pad + 6, stats_y + 24, (170, 170, 170))
return
name = selected_item.get("name", selected_item.get("id", "<?>"))
rarity = selected_item.get("rarity", "?")
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 inventory items.
if self.focus == "inv":
if selected_slot in self.player.equipment:
compare_item = self.player.equipment.get(selected_slot)
sel_stats = self._get_stats(selected_item)
cmp_stats = self._get_stats(compare_item) if compare_item else {}
bonus_lines = []
if selected_item in self.player.equipment.values():
bonus_lines = self._active_set_bonus_lines_for_item(selected_item)
stats_x = self.x + self.pad + 6
y_stats = stats_y + 42
stats_bottom = stats_y + stats_h - 8
keys = sorted(set(sel_stats.keys()) | set(cmp_stats.keys()))
max_lines = max(1, (stats_bottom - y_stats) // 16)
if bonus_lines:
max_lines = max(1, max_lines - min(2, len(bonus_lines)))
had_stats = False
if not keys:
self._blit(surface, "Stats: (no stats)", stats_x, y_stats, (200, 200, 200))
else:
had_stats = True
shown = 0
for k in keys:
v = float(sel_stats.get(k, 0))
if compare_item:
base = float(cmp_stats.get(k, 0))
d = v - base
self._draw_stat_line(surface, stats_x, y_stats, k, v, delta=d)
else:
self._draw_stat_line(surface, stats_x, y_stats, k, v, delta=None)
y_stats += 16
shown += 1
if shown >= max_lines:
if len(keys) > max_lines:
self._blit(surface, f"... ({len(keys) - max_lines} more)",
stats_x, y_stats, (160, 160, 160))
break
# Set bonus indicator for equipped items
if bonus_lines:
y_bonus = y_stats if not had_stats else (y_stats + 2)
max_y = stats_bottom
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(self.inv.items)
# Always show item count
hint = f"{total} items"
self._blit(surface, hint, self.x + self.pad + 6, list_top + list_h - 18, (160, 160, 160))
# Only show scrollbar if it’s actually needed
if total > max_lines:
# scrollbar track
track_x = self.x + self.pad + self.list_w - 8
track_y = list_top + list_pad_y
track_h = usable_h
pygame.draw.rect(surface, (40, 40, 40), (track_x, track_y, 4, track_h))
# thumb size based on how many lines are visible
thumb_h = max(10, int(track_h * (max_lines / total)))
# thumb position based on cursor (so last item => thumb at bottom)
if total <= 1:
t = 0.0
else:
t = self.cursor / (total - 1)
thumb_y = int(track_y + (track_h - thumb_h) * t)
pygame.draw.rect(surface, (120, 120, 120), (track_x, thumb_y, 4, thumb_h))
# ----------------------------
# Pickup feed (stacked messages that fade)
# ----------------------------
class PickupFeed:
def __init__(self, font_obj: pygame.font.Font, max_lines: int = 6):
self.font = font_obj
self.center_font = pygame.font.SysFont(None, int(_ui(["pickup_feed", "center_font_size"], 48)))
# MUST exist before update() is ever called
self.lines = [] # list of dicts: {"name": str, "rarity": str, "t": float}
# values (some from JSON)
self.max_lines = int(max_lines)
self.duration = float(_ui(["pickup_feed", "duration"], 3.0))
self.fade_time = float(_ui(["pickup_feed", "fade_time"], 0.6))
self.pad_x = int(_ui(["pickup_feed", "pad_x"], 10))
self.pad_y = int(_ui(["pickup_feed", "pad_y"], 6))
self.line_gap = int(_ui(["pickup_feed", "line_gap"], 4))
def push(self, name: str, rarity: str = ""):
self.lines.insert(0, {
"kind": "pickup",
"name": str(name),
"rarity": str(rarity or ""),
"t": float(self.duration),
})
# trim
if len(self.lines) > self.max_lines:
self.lines = self.lines[:self.max_lines]
def push_plain(self, text: str):
self.lines.insert(0, {
"kind": "plain",
"text": str(text),
"t": float(self.duration),
})
if len(self.lines) > self.max_lines:
self.lines = self.lines[:self.max_lines]
def push_plain_rarity(self, text: str, rarity: str):
self.lines.insert(0, {
"kind": "plain_rarity",
"text": str(text),
"rarity": str(rarity or ""),
"t": float(self.duration),
})
if len(self.lines) > self.max_lines:
self.lines = self.lines[:self.max_lines]
def push_center(self, text: str, duration: float | None = None):
self.lines.insert(0, {
"kind": "center",
"text": str(text),
"t": float(self.duration if duration is None else duration),
})
if len(self.lines) > self.max_lines:
self.lines = self.lines[:self.max_lines]
def update(self, dt: float):
if not self.lines:
return
alive = []
for it in self.lines:
it["t"] -= dt
if it["t"] > 0:
alive.append(it)
self.lines = alive
def _alpha_for(self, t: float) -> int:
if t <= 0:
return 0
if t < self.fade_time:
a = int(255 * (t / self.fade_time))
return max(0, min(255, a))
return 255
def draw(self, surface: pygame.Surface):
if not self.lines:
return
x = int(_ui(["pickup_feed", "x"], 14))
y0 = (HEIGHT // 2) + int(_ui(["pickup_feed", "y_offset_from_center"], -10))
y = y0
for it in self.lines:
alpha = self._alpha_for(it["t"])
if alpha <= 0:
continue
if it.get("kind") == "center":
text = it.get("text", "")
surf = self.center_font.render(text, True, (240, 240, 240)).convert_alpha()
surf.set_alpha(alpha)
w = surf.get_width()
h = surf.get_height()
pad_x = self.pad_x * 2
pad_y = self.pad_y
box_w = min(WIDTH - 80, w + pad_x * 2)
box_h = h + pad_y * 2
box_x = (WIDTH - box_w) // 2
box_y = int((HEIGHT - box_h) * 0.35)
box = pygame.Surface((box_w, box_h), pygame.SRCALPHA)
box.fill((0, 0, 0, int(alpha * 0.8)))
surface.blit(box, (box_x, box_y))
text_x = box_x + (box_w - w) // 2
text_y = box_y + pad_y
surface.blit(surf, (text_x, text_y))
continue
if it.get("kind") == "plain":
text = it.get("text", "")
surf = self.font.render(text, True, (240, 240, 240)).convert_alpha()
surf.set_alpha(alpha)
w = surf.get_width()
h = surf.get_height()
box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA)
box.fill((0, 0, 0, int(alpha * 0.65)))
surface.blit(box, (x, y - self.pad_y))
surface.blit(surf, (x + self.pad_x, y))
y += h + self.line_gap
continue
if it.get("kind") == "plain_rarity":
text = it.get("text", "")
rarity = it.get("rarity", "")
rarity_col = LOOT_COLORS.get(rarity, (240, 240, 240))
surf_text = self.font.render(text, True, (240, 240, 240)).convert_alpha()
surf_tag = self.font.render(str(rarity).title(), True, rarity_col).convert_alpha()
for s in (surf_text, surf_tag):
s.set_alpha(alpha)
w = surf_text.get_width() + surf_tag.get_width()
h = max(surf_text.get_height(), surf_tag.get_height())
box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA)
box.fill((0, 0, 0, int(alpha * 0.65)))
surface.blit(box, (x, y - self.pad_y))
px = x + self.pad_x
surface.blit(surf_text, (px, y))
px += surf_text.get_width()
surface.blit(surf_tag, (px, y))
y += h + self.line_gap
continue
name = it["name"]
rarity = it["rarity"]
left_text = "Picked up "
name_text = f"{name} "
tag_text = f"[{rarity}]" if rarity else ""
# Render parts (so rarity can be colored)
surf_left = self.font.render(left_text, True, (240, 240, 240)).convert_alpha()
surf_name = self.font.render(name_text, True, (240, 240, 240)).convert_alpha()
# rarity colored like inventory
rarity_col = LOOT_COLORS.get(rarity, (240, 240, 240))
surf_tag = self.font.render(tag_text, True, rarity_col).convert_alpha() if tag_text else None
for s in (surf_left, surf_name, surf_tag):
if s is not None:
s.set_alpha(alpha)
w = surf_left.get_width() + surf_name.get_width() + (surf_tag.get_width() if surf_tag else 0)
h = max(surf_left.get_height(), surf_name.get_height(), (surf_tag.get_height() if surf_tag else 0))
# background box per line
box = pygame.Surface((w + self.pad_x * 2, h + self.pad_y * 2), pygame.SRCALPHA)
box.fill((0, 0, 0, int(alpha * 0.65)))
surface.blit(box, (x, y - self.pad_y))
# blit parts
px = x + self.pad_x
surface.blit(surf_left, (px, y))
px += surf_left.get_width()
surface.blit(surf_name, (px, y))
px += surf_name.get_width()
if surf_tag:
surface.blit(surf_tag, (px, y))
y += h + self.line_gap
# ----------------------------
# Player
# ----------------------------
class Player:
def __init__(self, x, y, sprite_surface=None, shade_sprite_surface=None):
self.start_x = int(x)
self.start_y = int(y)
self.rect = pygame.Rect(int(x), int(y), PLAYER_HITBOX_W, PLAYER_HITBOX_H)
self.vel_y = 0.0
self.on_ground = False
self.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
# --- abilities ---
self.has_ranged = False
self.ranged_cd = 0.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
# --- knockback (combat feel) ---
self.knockback_vx = 0.0
self.knockback_timer = 0.0
# later driven by armor/set stats (0.0 = no resistance, 0.9 = very resistant)
self.bonus_knockback_resistance = 0.0
self.healing = False
self.heal_progress = 0.0
self.sprite_raw = sprite_surface
self.shade_sprite_raw = shade_sprite_surface
self.sprite_right = None
self.sprite_left = None
self.sprite_right_shade = None
self.sprite_left_shade = None
self.has_shade_sprite = False
self.idle_raw_frames = IDLE_SPRITE_RAW
self.idle_right_frames = []
self.idle_left_frames = []
self.idle_right_shade_frames = []
self.idle_left_shade_frames = []
self.idle_frame = 0
self.idle_anim_time = 0.0
self.idle_bob_t = 0.0
self.fairy_bob_t = random.random() * 10.0
self.idle_hold_time = 0.0
self.ground_contact_timer = 0.0
self.is_idle = False
self._rebuild_sprites()
self.recalc_bonuses()
self._clamp_resources()
@property
def max_health(self) -> int:
return int(BASE_MAX_HEALTH + self.bonus_max_health)
@property
def max_charge(self) -> float:
return float(BASE_MAX_CHARGE + self.bonus_max_charge)
def heal_charge_cost(self) -> float:
return HEAL_COST_FLAT
def heal_drain_rate(self) -> float:
dur = self.effective_heal_duration()
return HEAL_COST_FLAT / dur if dur > 0 else 999999.0
def effective_attack_duration(self) -> float:
# The swing itself is always the same length. Attack speed increases rate, not swing speed.
return float(self.attack_duration)
def effective_attack_cooldown(self) -> float:
# Recovery between swings: attack_speed_pct reduces it -> faster attack rate.
return ATTACK_COOLDOWN_BASE / (1.0 + self.bonus_attack_speed_pct)
def effective_heal_duration(self) -> float:
return max(0.2, HEAL_DURATION / (1.0 + self.bonus_heal_speed_pct))
def _clamp_resources(self):
self.health = max(0, min(self.health, self.max_health))
self.charge = max(0.0, min(self.charge, self.max_charge))
def combined_stats(self) -> dict:
out = {}
for it in self.equipment.values():
if not it:
continue
for k, v in it.get("stats", {}).items():
merge_stat_value(out, k, v)
return out
def recalc_bonuses(self):
stats = self.combined_stats()
set_stats = self.set_bonus_stats()
# merge set stats into total stats
for k, v in set_stats.items():
merge_stat_value(stats, k, v)
self.bonus_max_health = int(stats.get("health", 0))
self.bonus_max_charge = float(stats.get("charge", 0))
self.bonus_move_speed_pct = float(stats.get("move_speed_pct", 0.0))
self.bonus_move_speed_light_pct = float(stats.get("move_speed_light_pct", 0.0))
self.bonus_move_speed_shade_pct = float(stats.get("move_speed_shade_pct", 0.0)) + float(getattr(self, "base_move_speed_shade_pct", 0.0))
self.bonus_charge_regen_pct = float(stats.get("charge_regen_pct", 0.0))
self.bonus_heal_speed_pct = float(stats.get("heal_speed_pct", 0.0))
self.bonus_heal_plus = int(stats.get("heal_plus", 0))
self.bonus_attack_speed_pct = float(stats.get("attack_speed_pct", 0.0))
self.bonus_jump_height_shade_pct = float(stats.get("jump_height_shade_pct", 0.0)) + float(getattr(self, "base_jump_height_shade_pct", 0.0))
# chances: keep them in [0..1] so bad json can't break stuff
self.bonus_negate_damage_chance = max(0.0, min(1.0, float(stats.get("negate_damage_chance", 0.0))))
self.bonus_shade_double_damage_chance = max(0.0, min(1.0, float(stats.get("shade_double_damage_chance", 0.0))))
self.bonus_shade_damage_flat = float(stats.get("shade_damage_flat", 0.0))
# knockback resistance in [0..0.9] (1.0 would feel weird / cancel all force)
self.bonus_knockback_resistance = max(0.0, min(0.9, float(stats.get("knockback_resistance", 0.0))))
def equip_swap(self, item_instance: dict) -> dict | None:
slot = item_instance.get("slot")
if slot not in self.equipment:
return None
prev = self.equipment.get(slot)
self.equipment[slot] = item_instance
self.recalc_bonuses()
self._clamp_resources()
return prev
def set_bonus_stats(self) -> dict:
"""
Activate set bonus when wearing 3 pieces of the same set that are attuned-or-better (tier>=2).
If all 3 are awakened -> awakened bonus, otherwise -> attuned bonus.
"""
pieces_by_set: dict[str, list[dict]] = {}
for it in self.equipment.values():
if not it:
continue
set_id = it.get("set")
rarity_id = it.get("rarity")
tier = it.get("tier", None)
if tier is None:
tier = RARITIES.get(rarity_id, {}).get("tier", 1)
tier = int(tier)
if not set_id or tier < 2:
continue
pieces_by_set.setdefault(str(set_id), []).append(it)
out: dict = {}
for set_id, pieces in pieces_by_set.items():
if len(pieces) < 3:
continue
awakened_count = sum(1 for p in pieces if int(p.get("tier", 1)) >= 3)
bonus_tier = "awakened" if awakened_count >= 3 else "attuned"
cfg = SET_BONUSES.get(set_id, {})
tier_cfg = cfg.get(bonus_tier, {}) if isinstance(cfg, dict) else {}
effects = tier_cfg.get("effects", {}) if isinstance(tier_cfg, dict) else {}
if isinstance(effects, dict):
for k, v in effects.items():
merge_stat_value(out, k, v)
return out
def _rebuild_sprites(self):
if self.sprite_raw is None:
self.sprite_right = None
self.sprite_left = None
self.sprite_right_shade = None
self.sprite_left_shade = None
self.idle_right_frames = []
self.idle_left_frames = []
self.idle_right_shade_frames = []
self.idle_left_shade_frames = []
self.has_shade_sprite = False
return
scale = PLAYER_SPRITE_SCALE
w = int(self.sprite_raw.get_width() * scale)
h = int(self.sprite_raw.get_height() * scale)
scaled = pygame.transform.smoothscale(self.sprite_raw, (w, h))
self.sprite_right = scaled
self.sprite_left = pygame.transform.flip(scaled, True, False)
if self.shade_sprite_raw is not None:
shade = pygame.transform.smoothscale(self.shade_sprite_raw, (w, h))
self.sprite_right_shade = shade
self.sprite_left_shade = pygame.transform.flip(shade, True, False)
self.has_shade_sprite = True
else:
shade = scaled.copy()
shade.fill((120, 120, 120, 255), special_flags=pygame.BLEND_RGBA_MULT)
self.sprite_right_shade = shade
self.sprite_left_shade = pygame.transform.flip(shade, True, False)
self.has_shade_sprite = False
self.idle_right_frames = []
self.idle_left_frames = []
self.idle_right_shade_frames = []
self.idle_left_shade_frames = []
for raw in self.idle_raw_frames:
if raw is None:
continue
fr = pygame.transform.smoothscale(raw, (w, h))
self.idle_right_frames.append(fr)
self.idle_left_frames.append(pygame.transform.flip(fr, True, False))
fr_shade = fr.copy()
fr_shade.fill((120, 120, 120, 255), special_flags=pygame.BLEND_RGBA_MULT)
self.idle_right_shade_frames.append(fr_shade)
self.idle_left_shade_frames.append(pygame.transform.flip(fr_shade, True, False))
def _cancel_heal(self):
self.healing = False
self.heal_progress = 0.0
def is_dashing(self) -> bool:
return self.dash_timer > 0.0
def try_dash(self):
if self.dash_cd > 0.0:
return False
if self.healing:
self._cancel_heal()
if self.attacking:
self.attacking = False
self.attack_timer = 0.0
if self.shade:
if self.health <= DASH_SHADE_HEALTH_COST:
return False
self.health = max(0, self.health - DASH_SHADE_HEALTH_COST)
else:
if self.charge < DASH_LIGHT_CHARGE_COST:
return False
self._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 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._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.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")):
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)
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
# --- 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.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
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
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.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.
if getattr(self, "is_minion", False) and self.minion_duration > 0:
frac = max(0.0, min(1.0, self.minion_timer / self.minion_duration))
light = 1.0 - frac
color = tuple(int(c + (255 - c) * light * 0.9) 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), {})
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"):
present = present and miniboss_alive_in_room()
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"):
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] = {}
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))
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", "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}: <press key>"
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 = []
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] = []
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()}")
def spawn_room_props(room: Room, player_obj: "Player"):
reward_chest_obj = None
reward_ability_obj = None
lights: list[LightSource] = []
save_points: list[SavePoint] = []
butterfly_jars: list[ButterflyJar] = []
houses: list[HouseObject] = []
quest_givers: list[QuestGiver] = []
diary_obj = None
props = room.props
if not props and room.id == REWARD_ROOM_ID:
props = [
{"type": "reward_chest", "pos": list(REWARD_CHEST_POS), "coins": list(REWARD_CHEST_COINS)},
{"type": "ability_pickup", "pos": list(REWARD_ABILITY_POS), "ability": "ranged"},
]
for p in props:
if not isinstance(p, dict):
continue
p_type = str(p.get("type", ""))
pos = p.get("pos", None)
if not (isinstance(pos, (list, tuple)) and len(pos) == 2):
continue
x, y = int(pos[0]), int(pos[1])
if p_type == "reward_chest" and reward_chest_obj is None:
coins = p.get("coins", REWARD_CHEST_COINS)
if isinstance(coins, (list, tuple)) and len(coins) == 2:
coins = (int(coins[0]), int(coins[1]))
else:
coins = REWARD_CHEST_COINS
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))))
return reward_chest_obj, reward_ability_obj, lights, save_points, butterfly_jars, houses, quest_givers, diary_obj
def rebuild_room_butterfly_jars():
"""Rebuild the butterfly jar list for the current room (after jars respawn)."""
global butterfly_jars
butterfly_jars = []
for p in ROOM.props:
if not isinstance(p, dict):
continue
if str(p.get("type", "")) != "butterfly_jar":
continue
pos = p.get("pos", None)
if not (isinstance(pos, (list, tuple)) and len(pos) == 2):
continue
x, y = int(pos[0]), int(pos[1])
key = jar_key(ROOM.id, x, y)
if key not in COLLECTED_JARS:
butterfly_jars.append(ButterflyJar(x, y, key=key))
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player)
def grant_full_set(set_id: str, rarity: str, equip: bool = True) -> bool:
slots = ["helmet", "chest", "pants"]
by_slot = {}
for it in ARMOR_DB:
if it.get("set") == set_id and it.get("slot") in slots:
by_slot[it.get("slot")] = it
if len(by_slot) < len(slots):
return False
for slot in slots:
base = by_slot.get(slot)
if not base:
continue
try:
inst = build_item_instance(base, rarity)
except Exception:
continue
if equip:
old = player.equip_swap(inst)
if old:
inventory.add(old)
else:
inventory.add(inst)
return True
def grant_five_tempered() -> dict | None:
pool = [it for it in ARMOR_DB if isinstance(it, dict) and it.get("id")]
if not pool:
return None
base = random.choice(pool)
try:
inst = build_item_instance(base, "tempered")
except Exception:
return None
for _ in range(5):
inventory.add(deepcopy(inst))
return inst
def apply_room(room: Room, set_player_spawn=True):
"""Updates room-dependent lists + respawns enemies. Keeps inventory/equipment."""
global ROOM, KILL_Y, platforms_ground, platforms_floating, 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
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 = []
projectiles = []
boss_projectiles = []
boss_aoes = []
shatter_platforms = []
shatter_aoes = []
tether_shots = []
light_sources = []
save_points = []
butterfly_jars = []
houses = []
dust_puffs = []
dust_puffs = []
ROOM_DROP_MULT = 1.0
ROOM_COIN_MULT = 1.0
enemies = spawn_enemies_from_room(ROOM)
npcs = spawn_npcs_from_room(ROOM)
ability_doors = spawn_ability_doors(ROOM)
reward_chest = None
reward_ability = None
diary_obj = None
reward_chest, reward_ability, light_sources, save_points, butterfly_jars, houses, quest_givers, diary_obj = spawn_room_props(ROOM, player)
if set_player_spawn:
player.start_x = int(ROOM.player_spawn[0])
player.start_y = int(ROOM.player_spawn[1])
player.rect.midbottom = (player.start_x, player.start_y)
player.vel_y = 0.0
player.on_ground = False
# 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 = []
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)
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 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"},
},
},
]
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)
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):
self.boss_ids = list(BOSS_DEFS_DEFAULT.keys())
def toggle(self):
self.visible = not self.visible
self.grid_cursor = 0
self.action_open = False
self.actions = []
self.action_cursor = 0
if self.visible:
self._rebuild()
def _boss_actions(self, boss_id: str) -> list[dict]:
bd = BOSS_DEFS_DEFAULT.get(boss_id, {})
name = bd.get("name", boss_id)
tier = boss_diary.tier(boss_id)
carried = find_carried_boss_token(self.inv, boss_id)
actions = []
if tier >= 0:
actions.append({"action": "fight", "boss_id": boss_id, "variant": "normal",
"label": f"Fight {name} (Normal)"})
if tier >= 1:
actions.append({"action": "fight", "boss_id": boss_id, "variant": "hard",
"label": f"Fight {name} (Hard)"})
actions.append({"action": "unslot", "boss_id": boss_id,
"label": f"Remove {bd.get('token_name', name + ' Token')} from diary"})
elif carried is not None:
actions.append({"action": "slot", "boss_id": boss_id, "tier": int(carried.get("tier", 0)),
"label": f"Use {carried.get('name', 'Token')} (add to diary)"})
else:
actions.append({"action": "none", "boss_id": boss_id, "label": f"{name}: no token"})
return actions
def handle_keydown(self, key):
if not self.visible:
return
if key in (key_for("ui_cancel"), key_for("interact"), key_for("inventory")):
if self.action_open:
self.action_open = False
self.actions = []
self.action_cursor = 0
else:
self.visible = False
return
if self.action_open:
if not self.actions:
return
if key == key_for("ui_up"):
self.action_cursor = (self.action_cursor - 1) % len(self.actions)
elif key == key_for("ui_down"):
self.action_cursor = (self.action_cursor + 1) % len(self.actions)
elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
self._do_action(self.actions[self.action_cursor])
return
n = len(self.boss_ids)
if n == 0:
return
row = self.grid_cursor // self.cols
col = self.grid_cursor % self.cols
if key == key_for("ui_left"):
col = (col - 1) % self.cols
elif key == key_for("ui_right"):
col = (col + 1) % self.cols
elif key == key_for("ui_up"):
row = max(0, row - 1)
elif key == key_for("ui_down"):
max_row = max(0, (n - 1) // self.cols)
row = min(max_row, row + 1)
elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
self.actions = self._boss_actions(self.boss_ids[self.grid_cursor])
self.action_cursor = 0
self.action_open = True
return
self.grid_cursor = min(n - 1, row * self.cols + col)
def _do_action(self, choice: dict):
action = choice.get("action")
boss_id = choice.get("boss_id")
if action == "slot":
it = find_carried_boss_token(self.inv, boss_id)
if it is None:
pickup_feed.push_plain("Token not found.")
return
self.inv.items.remove(it)
boss_diary.slot(boss_id, int(choice.get("tier", 0)))
pickup_feed.push_plain(f"{it.get('name')} added to diary.")
elif action == "unslot":
tier = boss_diary.unslot(boss_id)
if tier is None:
return
token = make_boss_token(boss_id, tier)
self.inv.add(token)
pickup_feed.push_plain(f"{token['name']} returned to inventory.")
elif action == "fight":
self.visible = False
start_refight(boss_id, choice.get("variant", "normal"))
return
self.actions = self._boss_actions(boss_id)
self.action_cursor = 0
def draw(self, surface: pygame.Surface):
if not self.visible:
return
n = len(self.boss_ids)
if n == 0:
return
cell_w = 120
cell_h = 96
gap = 10
pad = 14
cols = self.cols
rows = (n + cols - 1) // cols
panel_w = pad * 2 + cols * cell_w + (cols - 1) * gap
header_h = 36
action_h = 8 + len(self.actions) * 20 if self.action_open else 0
panel_h = header_h + rows * cell_h + (rows - 1) * gap + pad + action_h
x = (WIDTH - panel_w) // 2
y = (HEIGHT - panel_h) // 2
panel = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA)
panel.fill((12, 12, 20, 235))
surface.blit(panel, (x, y))
pygame.draw.rect(surface, (100, 100, 130), (x, y, panel_w, panel_h), 2)
surface.blit(self.font.render("Boss Diary", True, (235, 230, 215)), (x + pad, y + 10))
for i, boss_id in enumerate(self.boss_ids):
bd = BOSS_DEFS_DEFAULT.get(boss_id, {})
cx = x + pad + (i % cols) * (cell_w + gap)
cy = y + header_h + (i // cols) * (cell_h + gap)
cell = pygame.Rect(cx, cy, cell_w, cell_h)
pygame.draw.rect(surface, (35, 35, 50), cell)
is_sel = (i == self.grid_cursor)
border = (230, 200, 120) if is_sel else (90, 90, 110)
pygame.draw.rect(surface, border, cell, 2 if is_sel else 1)
tier = boss_diary.tier(boss_id)
carried = find_carried_boss_token(self.inv, boss_id)
icon_x = cell.centerx - 16
icon_y = cell.y + 8
has_token = (tier >= 0 or carried is not None)
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")
TOWN_DISPLAY_NAMES = {
"room_town_gloomreach.json": "Gloomreach",
}
def is_town_room(room) -> bool:
"""A room counts as a town if marked in meta or it contains town NPCs."""
meta = getattr(room, "meta", {}) or {}
if meta.get("is_town") or meta.get("town"):
return True
for n in getattr(room, "npcs", []) or []:
if isinstance(n, dict) and n.get("type") in TOWN_NPC_TYPES:
return True
return False
def town_display_name(room) -> str:
name = TOWN_DISPLAY_NAMES.get(getattr(room, "id", ""))
if name:
return name
meta = getattr(room, "meta", {}) or {}
return str(meta.get("display_name") or meta.get("name") or getattr(room, "id", ""))
class TownTitleBanner:
def __init__(self, font_obj: pygame.font.Font):
self.font = font_obj
self.title_font = pygame.font.SysFont(None, 64)
self.text = ""
self.timer = 0.0
self.duration = 3.0
self.seen: set[str] = set()
def trigger(self, town_id: str, town_name: str):
if town_id in self.seen:
return
self.seen.add(town_id)
self.text = town_name
self.timer = self.duration
def update(self, dt: float):
if self.timer > 0:
self.timer = max(0.0, self.timer - dt)
@property
def active(self) -> bool:
return self.timer > 0
def draw(self, surface: pygame.Surface):
if not self.active or not self.text:
return
alpha = 255
if self.timer < 0.5:
alpha = int(255 * (self.timer / 0.5))
title_surf = self.title_font.render(self.text, True, (240, 235, 210)).convert_alpha()
title_surf.set_alpha(alpha)
x = (WIDTH - title_surf.get_width()) // 2
y = int(HEIGHT * 0.28) # upper half of the screen
surface.blit(title_surf, (x, y))
town_title = TownTitleBanner(font)
def draw_stats_overlay(surface):
if not stats_overlay_visible:
return
light_speed = PLAYER_SPEED * (1.0 + player.bonus_move_speed_pct + player.bonus_move_speed_light_pct)
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,
"has_ranged": player.has_ranged,
"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),
"lantern_respawn": (
{"room_id": LANTERN_RESPAWN_ROOM, "x": LANTERN_RESPAWN_POS[0], "y": LANTERN_RESPAWN_POS[1]}
if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS else None
),
}
path = save_path(name)
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
return name
def apply_save_data(data: dict):
"""Apply a save's state to the current live game objects (player/inventory/quests/room)."""
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
global JAR_DEATH_ROOM, corpse
pdata = data.get("player", {}) or {}
# persistent player fields
player.coins_light = int(pdata.get("coins_light", 0))
player.coins_dark = int(pdata.get("coins_dark", 0))
player.butterfly_jars = int(pdata.get("butterfly_jars", 0))
player.has_ranged = bool(pdata.get("has_ranged", False))
player.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 [])
# Butterfly jars and minibosses respawn on reload (session-only); corpse too.
COLLECTED_JARS.clear()
DEFEATED_MINIBOSSES.clear()
JAR_DEATH_ROOM = None
corpse = None
lrespawn = data.get("lantern_respawn") or {}
if lrespawn.get("room_id"):
LANTERN_RESPAWN_ROOM = normalize_room_id(str(lrespawn["room_id"]))
LANTERN_RESPAWN_POS = (int(lrespawn["x"]), int(lrespawn["y"]))
else:
LANTERN_RESPAWN_ROOM = None
LANTERN_RESPAWN_POS = None
# Reloading a save spawns the player at their latest lantern save point.
room_id = normalize_room_id(str(pdata.get("room_id") or "room_01.json"))
if LANTERN_RESPAWN_ROOM:
room_id = LANTERN_RESPAWN_ROOM
room = room_manager.load_room(room_id)
px, py = pdata.get("x"), pdata.get("y")
if LANTERN_RESPAWN_POS:
px, py = LANTERN_RESPAWN_POS
if isinstance(px, (int, float)) and isinstance(py, (int, float)):
room.player_spawn = [int(px), int(py)]
apply_room(room, set_player_spawn=True)
player.respawn()
# 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,
"has_ranged": False,
"opened_chests": [],
"equipment": {},
},
"inventory": [],
"quests": {"active": {}, "completed_one_time": [], "ever_completed": [], "tokens": 0},
"boss_diary": {"slotted": {}},
"defeated_bosses": [],
"broken_doors": [],
"lantern_respawn": None,
}
def load_game(name: str) -> bool:
path = save_path(name)
if not path.exists():
return False
try:
data = load_json(path)
except Exception as e:
print(f"[Load failed] {path} -> {e}")
return False
apply_save_data(data)
return True
def start_new_game(name: str) -> str:
"""Reset all state to a fresh game and save it. Returns the save name used."""
room_id = "room_01.json"
room_json = load_json_safe(room_id, [ROOMS_DIR, LEVELS_DIR, DATA_ROOT, BASE_DIR], ROOM_DEFAULT)
room_json = normalize_room_json(room_json)
spawn = room_json.get("player_spawn", [200, 100])
data = fresh_save_data()
if isinstance(spawn, (list, tuple)) and len(spawn) == 2:
data["player"]["x"] = int(spawn[0])
data["player"]["y"] = int(spawn[1])
apply_save_data(data)
final_name = sanitize_save_name(name) or "auto"
base = final_name
i = 2
while save_path(final_name).exists():
final_name = f"{base} {i}"
i += 1
return save_game(final_name)
def wipe_all_saves():
ensure_saves_dir()
for p in SAVES_DIR.glob("*.json"):
try:
p.unlink()
except OSError:
pass
def _do_save_game():
global CURRENT_SAVE_NAME, save_feedback, save_feedback_timer
name = CURRENT_SAVE_NAME or "auto"
CURRENT_SAVE_NAME = save_game(name)
save_feedback = f"Saved: {CURRENT_SAVE_NAME}"
save_feedback_timer = 2.0
def _return_to_main_menu():
global GAME_MODE, paused, pause_menu, pause_menu_index, pause_use_mouse
global settings_wait_action
inv_ui.visible = False
trade_ui.visible = False
trade_ui.active_npc = None
diary_ui.visible = False
paused = False
pause_menu = "main"
pause_menu_index = 0
pause_use_mouse = False
settings_wait_action = None
GAME_MODE = "menu"
# ----------------------------
# Main menu
# ----------------------------
GAME_MODE = "menu" # menu | select | newgame | settings | confirm_wipe | game
MAIN_MENU_ITEMS = ["Select Game", "New Game", "Settings", "Quit"]
menu_index = 0
menu_use_mouse = False
select_index = 0
select_use_mouse = False
newgame_name_input = ""
wipe_confirm_idx = 1
wipe_use_mouse = False
CURRENT_SAVE_NAME: str | None = None
save_feedback = ""
save_feedback_timer = 0.0
def get_main_menu_rects():
rects = []
start_y = int(HEIGHT * 0.38)
for label in MAIN_MENU_ITEMS:
text = font.render(label, True, (240, 240, 240))
x = WIDTH // 2 - text.get_width() // 2
y = start_y
rects.append(pygame.Rect(x - 8, y - 4, text.get_width() + 16, text.get_height() + 8))
start_y += 36
return rects
def get_wipe_button_rect() -> pygame.Rect:
w, h = 150, 26
return pygame.Rect(WIDTH - w - 12, HEIGHT - h - 12, w, h)
def draw_main_menu(surface, selected_idx: int, use_mouse: bool, has_saves: bool):
title_font = pygame.font.SysFont(None, 72)
title = title_font.render("Light & Dark", True, (240, 235, 210))
surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.14)))
sub = font.render("Prototype", True, (160, 160, 160))
surface.blit(sub, (WIDTH // 2 - sub.get_width() // 2, int(HEIGHT * 0.14) + 68))
rects = get_main_menu_rects()
mouse_pos = pygame.mouse.get_pos()
hover_idx = None
for i, r in enumerate(rects):
if r.collidepoint(mouse_pos):
hover_idx = i
break
active_idx = hover_idx if (use_mouse and hover_idx is not None) else selected_idx
for i, label in enumerate(MAIN_MENU_ITEMS):
r = rects[i]
enabled = (i != 0) or has_saves
active = enabled and (i == active_idx)
fill = (60, 60, 60) if active else (40, 40, 40)
border = (140, 140, 140) if active else (90, 90, 90)
pygame.draw.rect(surface, fill, r, border_radius=6)
pygame.draw.rect(surface, border, r, 2, border_radius=6)
col = (245, 245, 245) if enabled else (95, 95, 95)
text = font.render(label, True, col)
surface.blit(text, (r.x + 6, r.y + 2))
wr = get_wipe_button_rect()
wfill = (70, 40, 40) if has_saves else (40, 40, 40)
wborder = (160, 80, 80) if has_saves else (90, 90, 90)
pygame.draw.rect(surface, wfill, wr, border_radius=6)
pygame.draw.rect(surface, wborder, wr, 2, border_radius=6)
wtext = font.render("Wipe All Saves", True, (245, 170, 170) if has_saves else (95, 95, 95))
surface.blit(wtext, (wr.x + 8, wr.y + 4))
def get_select_rects(saves) -> list[pygame.Rect]:
rects = []
start_y = int(HEIGHT * 0.30)
for i in range(len(saves)):
rects.append(pygame.Rect(WIDTH // 2 - 170, start_y + i * 30 - 2, 340, 24))
return rects
def draw_select_menu(surface, selected_idx: int, use_mouse: bool):
title = font.render("Select Game", True, (240, 235, 210))
surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.15)))
saves = list_saves()
if not saves:
msg = font.render("No save files found.", True, (170, 170, 170))
surface.blit(msg, (WIDTH // 2 - msg.get_width() // 2, HEIGHT // 2))
hint = font.render("Press Esc to return", True, (150, 150, 150))
surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT // 2 + 30))
return
rects = get_select_rects(saves)
mouse_pos = pygame.mouse.get_pos()
hover_idx = None
for i, r in enumerate(rects):
if r.collidepoint(mouse_pos):
hover_idx = i
break
active_idx = hover_idx if (use_mouse and hover_idx is not None) else min(selected_idx, len(saves) - 1)
for i, sv in enumerate(saves):
r = rects[i]
active = (i == active_idx)
fill = (60, 60, 60) if active else (40, 40, 40)
border = (140, 140, 140) if active else (90, 90, 90)
pygame.draw.rect(surface, fill, r, border_radius=6)
pygame.draw.rect(surface, border, r, 2, border_radius=6)
label = f"{sv['name']} ({sv['updated']})" if sv.get("updated") else sv["name"]
text = font.render(label, True, (240, 240, 240))
surface.blit(text, (r.x + 8, r.y + 3))
hint = font.render("Up/Down = select Enter = continue Esc = back", True, (170, 170, 170))
surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT - 40))
def draw_newgame(surface):
title = font.render("New Game", True, (240, 235, 210))
surface.blit(title, (WIDTH // 2 - title.get_width() // 2, int(HEIGHT * 0.22)))
label = font.render("Enter save name:", True, (200, 200, 200))
surface.blit(label, (WIDTH // 2 - label.get_width() // 2, int(HEIGHT * 0.34)))
box = pygame.Rect(WIDTH // 2 - 140, int(HEIGHT * 0.40), 280, 28)
pygame.draw.rect(surface, (40, 40, 40), box, border_radius=6)
pygame.draw.rect(surface, (140, 140, 140), box, 2, border_radius=6)
display = newgame_name_input + "_"
txt = font.render(display, True, (240, 240, 240))
surface.blit(txt, (box.x + 8, box.y + 5))
hint = font.render("Enter = create Esc = cancel", True, (170, 170, 170))
surface.blit(hint, (WIDTH // 2 - hint.get_width() // 2, int(HEIGHT * 0.40) + 40))
def get_confirm_rects() -> list[pygame.Rect]:
yes = pygame.Rect(WIDTH // 2 - 130, HEIGHT // 2 + 30, 110, 30)
no = pygame.Rect(WIDTH // 2 + 20, HEIGHT // 2 + 30, 110, 30)
return [yes, no]
def draw_confirm_wipe(surface, selected_idx: int, use_mouse: bool):
overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 180))
surface.blit(overlay, (0, 0))
n = len(list_saves())
msg = font.render(f"Delete all {n} save file(s)?", True, (240, 240, 240))
surface.blit(msg, (WIDTH // 2 - msg.get_width() // 2, HEIGHT // 2 - 30))
sub = font.render("This cannot be undone.", True, (200, 90, 90))
surface.blit(sub, (WIDTH // 2 - sub.get_width() // 2, HEIGHT // 2 - 4))
rects = get_confirm_rects()
mouse_pos = pygame.mouse.get_pos()
hover_idx = None
for i, r in enumerate(rects):
if r.collidepoint(mouse_pos):
hover_idx = i
break
active_idx = hover_idx if (use_mouse and hover_idx is not None) else selected_idx
for i, label in enumerate(["Yes", "No"]):
r = rects[i]
active = (i == active_idx)
if active and label == "Yes":
fill, border = (90, 40, 40), (200, 80, 80)
elif active:
fill, border = (60, 60, 60), (140, 140, 140)
else:
fill, border = (40, 40, 40), (90, 90, 90)
pygame.draw.rect(surface, fill, r, border_radius=6)
pygame.draw.rect(surface, border, r, 2, border_radius=6)
t = font.render(label, True, (245, 245, 245))
surface.blit(t, (r.x + (r.width - t.get_width()) // 2, r.y + 5))
def handle_settings_keydown(key, return_mode: str):
global settings_wait_action, settings_index, GAME_MODE, pause_menu
if settings_wait_action:
BINDINGS[settings_wait_action] = key
settings_wait_action = None
return True
if key_is("ui_up", key):
settings_index = (settings_index - 1) % len(SETTINGS_ACTIONS)
elif key_is("ui_down", key):
settings_index = (settings_index + 1) % len(SETTINGS_ACTIONS)
elif key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
settings_wait_action = SETTINGS_ACTIONS[settings_index][1]
elif key_is("ui_cancel", key):
if return_mode == "menu":
GAME_MODE = "menu"
else:
pause_menu = "main"
else:
return False
return True
def _activate_menu_item(i: int, has_saves: bool):
global GAME_MODE, running
global select_index, select_use_mouse, newgame_name_input
global settings_index, settings_wait_action
label = MAIN_MENU_ITEMS[i]
if label == "Select Game":
if not has_saves:
return
select_index = 0
select_use_mouse = False
GAME_MODE = "select"
elif label == "New Game":
newgame_name_input = ""
GAME_MODE = "newgame"
elif label == "Settings":
settings_index = 0
settings_wait_action = None
GAME_MODE = "settings"
elif label == "Quit":
running = False
def _menu_handle_events():
global running, GAME_MODE, menu_index, menu_use_mouse
global select_index, select_use_mouse, newgame_name_input
global wipe_confirm_idx, wipe_use_mouse, CURRENT_SAVE_NAME, paused
has_saves = len(list_saves()) > 0
for e in pygame.event.get():
if e.type == pygame.QUIT:
running = False
continue
if GAME_MODE == "settings":
if e.type == pygame.KEYDOWN:
handle_settings_keydown(e.key, "menu")
elif GAME_MODE == "confirm_wipe":
if e.type == pygame.KEYDOWN:
if key_is("ui_cancel", e.key):
GAME_MODE = "menu"
elif key_is("ui_left", e.key) or key_is("ui_right", e.key):
wipe_confirm_idx = 1 - wipe_confirm_idx
wipe_use_mouse = False
elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
if wipe_confirm_idx == 0:
wipe_all_saves()
GAME_MODE = "menu"
elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
for i, r in enumerate(get_confirm_rects()):
if r.collidepoint(e.pos):
if i == 0:
wipe_all_saves()
GAME_MODE = "menu"
break
elif GAME_MODE == "select":
if e.type == pygame.KEYDOWN:
if key_is("ui_cancel", e.key):
GAME_MODE = "menu"
elif key_is("ui_up", e.key):
select_index = (select_index - 1) % max(1, len(list_saves()))
select_use_mouse = False
elif key_is("ui_down", e.key):
select_index = (select_index + 1) % max(1, len(list_saves()))
select_use_mouse = False
elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
svs = list_saves()
if svs:
idx = min(select_index, len(svs) - 1)
if load_game(svs[idx]["name"]):
CURRENT_SAVE_NAME = svs[idx]["name"]
GAME_MODE = "game"
paused = False
elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
svs = list_saves()
for i, r in enumerate(get_select_rects(svs)):
if r.collidepoint(e.pos):
if load_game(svs[i]["name"]):
CURRENT_SAVE_NAME = svs[i]["name"]
GAME_MODE = "game"
paused = False
break
elif GAME_MODE == "newgame":
if e.type == pygame.KEYDOWN:
if key_is("ui_cancel", e.key):
GAME_MODE = "menu"
elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
name = newgame_name_input.strip() or f"Save {len(list_saves()) + 1}"
CURRENT_SAVE_NAME = start_new_game(name)
GAME_MODE = "game"
paused = False
elif e.key == pygame.K_BACKSPACE:
newgame_name_input = newgame_name_input[:-1]
else:
ch = getattr(e, "unicode", "") or ""
if ch and ch.isprintable() and len(newgame_name_input) < 24:
newgame_name_input += ch
elif GAME_MODE == "menu":
if e.type == pygame.MOUSEMOTION:
hover = None
for i, r in enumerate(get_main_menu_rects()):
if r.collidepoint(e.pos):
hover = i
break
if hover is not None:
menu_use_mouse = True
menu_index = hover
else:
menu_use_mouse = False
elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
if get_wipe_button_rect().collidepoint(e.pos):
if has_saves:
GAME_MODE = "confirm_wipe"
wipe_confirm_idx = 1
wipe_use_mouse = True
continue
for i, r in enumerate(get_main_menu_rects()):
if r.collidepoint(e.pos):
menu_use_mouse = True
menu_index = i
_activate_menu_item(i, has_saves)
break
elif e.type == pygame.KEYDOWN:
if key_is("ui_up", e.key):
menu_index = (menu_index - 1) % len(MAIN_MENU_ITEMS)
menu_use_mouse = False
elif key_is("ui_down", e.key):
menu_index = (menu_index + 1) % len(MAIN_MENU_ITEMS)
menu_use_mouse = False
elif e.key in (key_for("ui_confirm"), key_for("ui_confirm_alt")):
_activate_menu_item(menu_index, has_saves)
def _menu_draw(surface):
if GAME_MODE == "settings":
draw_settings_menu(surface, settings_index, settings_wait_action)
elif GAME_MODE == "confirm_wipe":
draw_confirm_wipe(surface, wipe_confirm_idx, wipe_use_mouse)
elif GAME_MODE == "select":
draw_select_menu(surface, select_index, select_use_mouse)
elif GAME_MODE == "newgame":
draw_newgame(surface)
else:
draw_main_menu(surface, menu_index, menu_use_mouse, len(list_saves()) > 0)
def draw_butterfly_jar_slots(surface, x, y):
"""Draw the butterfly-jar collectible slots below the coin counters.
Only shown once at least one jar has been collected. Always renders all
BUTTERFLY_JARS_MAX slots; collected ones are full-color, the rest greyed out.
"""
if player.butterfly_jars <= 0:
return
size = BUTTERFLY_JAR_UI_SIZE
for i in range(BUTTERFLY_JARS_MAX):
ix = x + i * (size + BUTTERFLY_JAR_UI_GAP)
if BUTTERFLY_JAR_RAW is not None:
icon = pygame.transform.scale(BUTTERFLY_JAR_RAW, (size, size))
surface.blit(icon, (ix, y))
if i >= player.butterfly_jars:
dim = pygame.Surface((size, size), pygame.SRCALPHA)
dim.fill((45, 45, 55, 205))
surface.blit(dim, (ix, y))
else:
col = (210, 170, 230) if i < player.butterfly_jars else (95, 95, 105)
pygame.draw.rect(surface, col, (ix, y, size, size))
# ----------------------------
# Main loop
# ----------------------------
running = True
paused = False
stats_overlay_visible = False
pause_menu = "main" # main | settings
pause_menu_index = 0
settings_index = 0
settings_wait_action: str | None = None
pause_use_mouse = False
while running:
dt = clock.tick(60) / 1000.0
if save_feedback_timer > 0:
save_feedback_timer = max(0.0, save_feedback_timer - dt)
if GAME_MODE != "game":
_menu_handle_events()
screen.fill(BG)
_menu_draw(screen)
pygame.display.flip()
continue
if not paused:
pickup_feed.update(dt)
town_title.update(dt)
for e in pygame.event.get():
if e.type == pygame.QUIT:
running = False
if e.type == pygame.MOUSEMOTION:
if paused and pause_menu == "main":
hover_idx = None
for idx, r in enumerate(get_pause_menu_rects()):
if r.collidepoint(e.pos):
hover_idx = idx
break
if hover_idx is not None:
pause_use_mouse = True
pause_menu_index = hover_idx
else:
pause_use_mouse = False
if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
if paused and pause_menu == "main":
for idx, r in enumerate(get_pause_menu_rects()):
if r.collidepoint(e.pos):
pause_use_mouse = True
pause_menu_index = idx
if idx == 0:
paused = False
elif idx == 1:
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)
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("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:
apply_death_penalty()
if ACTIVE_REFIGHT is not None:
# Died in a diary refight -> return to the diary in town.
ACTIVE_REFIGHT = None
room = room_manager.load_room("room_town_gloomreach.json")
diary_spawn = room.entry_spawns.get("entry_diary") or room.player_spawn
room.player_spawn = [int(diary_spawn[0]), int(diary_spawn[1])]
player.respawn(int(diary_spawn[0]), int(diary_spawn[1]))
apply_room(room, set_player_spawn=True)
player.shatterstep_landing_pending = False
player.shatterstep_landing_damage = 0.0
else:
# 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: hitting an enemy from above while
# swinging down bounces the player up (stomp/pogo).
if player.attack_dir_y > 0.3 and player.rect.bottom <= en.rect.top + 6:
player.vel_y = min(player.vel_y, -8.0)
player.on_ground = False
player.ground_platform = None
# 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)
for en in enemies:
if (not player.shade) and player.rect.colliderect(en.rect):
did_hit = player.take_damage(en.contact_damage)
if did_hit:
player.apply_knockback(en.rect, strength_px_per_frame=12.0, duration=0.20, pop_up_vel=-3.5)
# Update projectiles
for pr in projectiles:
pr.update(dt, collision_platforms, enemies, ability_doors)
projectiles = [pr for pr in projectiles if pr.alive]
# Update 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:
token_drops.append(TokenPickup(en.rect.centerx - 8, en.rect.top - 20, bid))
# 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)
pickup_feed.push_center(
f"Butterfly jar collected ({player.butterfly_jars}/{BUTTERFLY_JARS_MAX})",
duration=2.5,
)
butterfly_jars.remove(bj)
else:
pickup_feed.push_center("You can't carry any more butterfly jars!", duration=2.0)
for t in token_drops[:]:
if player.rect.colliderect(t.rect):
token = make_boss_token(t.boss_id)
inventory.add(token)
pickup_feed.push_plain(f"Collected {token['name']}!")
token_drops.remove(t)
# Corpse: touching it grants back the coins lost on death.
if corpse is not None and corpse.room_id == ROOM.id:
if player.rect.colliderect(corpse.rect):
recovered = corpse.coins_light + corpse.coins_dark
player.coins_light += corpse.coins_light
player.coins_dark += corpse.coins_dark
if recovered > 0:
pickup_feed.push_center(f"Recovered {recovered} coins from your corpse!", duration=2.5)
corpse = None
# Camera follow
camera_x += ((player.rect.centerx - WIDTH // 2) - camera_x) * 0.1
camera_y += ((player.rect.centery - HEIGHT // 2) - camera_y) * 0.1
# ----------------------------
# Draw
# ----------------------------
screen.fill(BG)
# Layerable room objects (props, houses, platforms, doors, exits, enemies, NPCs)
draw_layerable(screen, camera_x, camera_y)
for sp in shatter_platforms:
sp.draw(screen, camera_x, camera_y)
for aoe in shatter_aoes:
aoe.draw(screen, camera_x, camera_y)
for dp in dust_puffs:
dp.draw(screen, camera_x, camera_y)
# loot
for drop in loot_drops:
drop.draw(screen, camera_x, camera_y)
# coins
for c in coin_drops:
c.draw(screen, camera_x, camera_y)
# boss tokens
for t in token_drops:
t.draw(screen, camera_x, camera_y)
# shockwaves
for sw in shockwaves:
sw.draw(screen, camera_x, camera_y)
# 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)
# 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()