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