Files
Light_And_Dark/light_and_dark/main.py
T
Lena 53fc17338f room 3 changes
chest, tiny jump and run with moving platforms
2026-09-29 19:48:49 +02:00

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