attack changes
dissolve platforms, mouse for attack, directional attack (i think), hold for attack
This commit is contained in:
+363
-55
@@ -19,16 +19,24 @@ 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
|
||||
ATTACK_COOLDOWN_BASE = 0.49 # recovery between melee swings; attack speed reduces it
|
||||
|
||||
# Horizontal deadzone: enemies only turn toward the player when the player is this far
|
||||
# to one side, so a player standing directly above/below no longer makes them wiggle.
|
||||
CHASE_DEADZONE = 6
|
||||
|
||||
# --- Miniboss "Discharge" (anti-camp AOE) ---
|
||||
DISCHARGE_DAMAGE = 2 # AOE damage to the player
|
||||
DISCHARGE_CHARGE_TIME = 1.5 # seconds standing still before the blast (telegraph)
|
||||
DISCHARGE_RADIUS = 280 # blast radius in px (ignores platforms)
|
||||
DISCHARGE_COOLDOWN = 4.0 # seconds before the miniboss can discharge again
|
||||
DISCHARGE_SUSTAIN = 0.6 # how long the player must be unreachable before it commits
|
||||
DISCHARGE_ABOVE_GAP = 30 # player feet must be this far above the miniboss's head to count as unreachable
|
||||
|
||||
# --- Ranged ability / projectile settings ---
|
||||
RANGED_CHARGE_COST = 5.0 # costs more than melee (melee costs 1)
|
||||
RANGED_DAMAGE_MULT = 0.5 # 50% of melee damage
|
||||
RANGED_COOLDOWN = 0.45 # seconds between shots (feel-tweak)
|
||||
RANGED_COOLDOWN = 0.63 # seconds between shots (feel-tweak)
|
||||
PROJECTILE_SPEED = 650.0 # px/sec
|
||||
PROJECTILE_LIFETIME = 1.2 # seconds
|
||||
PROJECTILE_SIZE = (14, 6) # width, height
|
||||
@@ -79,6 +87,10 @@ SHATTER_AOE_LIFETIME = 0.25
|
||||
SHATTER_ICON_SIZE = 32
|
||||
SHATTER_ICON_POS = (20, 20)
|
||||
|
||||
# --- Dissolve platforms (per-room "dissolve" group) ---
|
||||
DISSOLVE_STAND_TIME = 1.0 # seconds the player must stand before it dissolves
|
||||
DISSOLVE_TIME = 2.0 # seconds it stays translucent (no hitbox) once dissolved
|
||||
|
||||
# --- Tether ability ---
|
||||
TETHER_BIND_DURATION = 3.0
|
||||
TETHER_COOLDOWN = 6.0
|
||||
@@ -578,18 +590,45 @@ def _build_mist_tile(size: int = MIST_TILE_SIZE) -> pygame.Surface:
|
||||
pygame.draw.circle(surf, color, (cx + ox, cy + oy), r)
|
||||
return surf
|
||||
|
||||
def _build_mist_tile_slow(size: int = MIST_TILE_SIZE) -> pygame.Surface:
|
||||
"""Second, fainter mist layer with a different pattern, used to make the
|
||||
haze visibly drift (two layers scrolling at different speeds)."""
|
||||
surf = pygame.Surface((size, size), pygame.SRCALPHA)
|
||||
rng = random.Random(90210)
|
||||
surf.fill((70, 20, 115, 4))
|
||||
for _ in range(9):
|
||||
cx = rng.randint(0, size)
|
||||
cy = rng.randint(0, size)
|
||||
r = rng.randint(70, 160)
|
||||
alpha = rng.randint(5, 12)
|
||||
color = (110, 40, 165, alpha)
|
||||
for ox in (-size, 0, size):
|
||||
for oy in (-size, 0, size):
|
||||
pygame.draw.circle(surf, color, (cx + ox, cy + oy), r)
|
||||
return surf
|
||||
|
||||
MIST_TILE = _build_mist_tile()
|
||||
MIST_TILE_SLOW = _build_mist_tile_slow()
|
||||
|
||||
def draw_mist_overlay(target: pygame.Surface):
|
||||
if MIST_TILE is None or not getattr(ROOM, "mist", False):
|
||||
return
|
||||
size = MIST_TILE_SIZE
|
||||
t = pygame.time.get_ticks() / 1000.0
|
||||
dx = int((t * 10.0) % size)
|
||||
dy = int((t * 6.0) % size)
|
||||
# Base layer drifts one way...
|
||||
dx = int((t * 30.0) % size)
|
||||
dy = int((t * 22.0) % size)
|
||||
for tx in range(-1, WIDTH // size + 2):
|
||||
for ty in range(-1, HEIGHT // size + 2):
|
||||
target.blit(MIST_TILE, (tx * size - dx, ty * size - dy))
|
||||
# ...while the fainter layer drifts slower and in a different direction,
|
||||
# so the mist visibly curls instead of looking like a static texture.
|
||||
if MIST_TILE_SLOW is not None:
|
||||
dx2 = int((t * 9.0) % size)
|
||||
dy2 = int((size - int((t * 5.0) % size)) % size)
|
||||
for tx in range(-1, WIDTH // size + 2):
|
||||
for ty in range(-1, HEIGHT // size + 2):
|
||||
target.blit(MIST_TILE_SLOW, (tx * size - dx2, ty * size - dy2))
|
||||
|
||||
VIGNETTE_CLEAR_RADIUS = 150
|
||||
_VIGNETTE_HOLE_CACHE: dict[tuple[int, int], pygame.Surface] = {}
|
||||
@@ -974,7 +1013,7 @@ def normalize_room_json(room_json: dict) -> dict:
|
||||
grouped["ground"].append(arr)
|
||||
elif isinstance(p, dict):
|
||||
group = p.get("group", "ground")
|
||||
if group in ("moving", "trigger", "hazard"):
|
||||
if group in ("moving", "trigger", "hazard", "dissolve"):
|
||||
obj = {"x": p.get("x", 0), "y": p.get("y", 0), "w": p.get("w", 0), "h": p.get("h", 0)}
|
||||
if "layer" in p and p.get("layer"):
|
||||
obj["layer"] = p["layer"]
|
||||
@@ -983,6 +1022,11 @@ def normalize_room_json(room_json: dict) -> dict:
|
||||
obj["path"] = p["path"]
|
||||
if "speed" in p:
|
||||
obj["speed"] = p["speed"]
|
||||
if group == "dissolve":
|
||||
if "stand_time" in p:
|
||||
obj["stand_time"] = p["stand_time"]
|
||||
if "dissolve_time" in p:
|
||||
obj["dissolve_time"] = p["dissolve_time"]
|
||||
if "name" in p:
|
||||
obj["name"] = p["name"]
|
||||
if "quest" in p:
|
||||
@@ -995,6 +1039,8 @@ def normalize_room_json(room_json: dict) -> dict:
|
||||
obj["lock_when_pass"] = p["lock_when_pass"]
|
||||
if "open_on_miniboss_dead" in p:
|
||||
obj["open_on_miniboss_dead"] = p["open_on_miniboss_dead"]
|
||||
if "open_on_boss_dead" in p:
|
||||
obj["open_on_boss_dead"] = p["open_on_boss_dead"]
|
||||
if isinstance(p.get("respawn"), (list, tuple)) and len(p["respawn"]) >= 2:
|
||||
obj["respawn"] = p["respawn"]
|
||||
grouped.setdefault(group, []).append(obj)
|
||||
@@ -1099,6 +1145,7 @@ class Room:
|
||||
|
||||
self.platform_layer: dict[int, int] = {}
|
||||
self.platform_meta: dict[int, dict] = {} # id(rect) -> {"name"|"path"|"speed", ...}
|
||||
self.platforms_dissolve: list[pygame.Rect] = []
|
||||
platforms = room_json.get("platforms", {}) if isinstance(room_json, dict) else {}
|
||||
if isinstance(platforms, list):
|
||||
self.platforms_ground = rects_from_list(platforms, self.platform_layer)
|
||||
@@ -1142,6 +1189,8 @@ class Room:
|
||||
meta["lock_when_pass"] = True
|
||||
if tp.get("open_on_miniboss_dead"):
|
||||
meta["open_on_miniboss_dead"] = True
|
||||
if tp.get("open_on_boss_dead"):
|
||||
meta["open_on_boss_dead"] = True
|
||||
self.platform_meta[id(rect)] = meta
|
||||
self.platforms_trigger.append(rect)
|
||||
elif isinstance(tp, (list, tuple)) and len(tp) >= 4:
|
||||
@@ -1163,7 +1212,10 @@ class Room:
|
||||
else:
|
||||
path = [[rect.x, rect.y]]
|
||||
speed = float(mp.get("speed", 120))
|
||||
self.platform_meta[id(rect)] = {"path": path, "speed": speed, "i": 0, "t": 0.0}
|
||||
m_meta = {"path": path, "speed": speed, "i": 0, "t": 0.0}
|
||||
if mp.get("open_on_boss_dead"):
|
||||
m_meta["open_on_boss_dead"] = True
|
||||
self.platform_meta[id(rect)] = m_meta
|
||||
self.platforms_moving.append(rect)
|
||||
elif isinstance(mp, (list, tuple)) and len(mp) >= 4:
|
||||
rect = pygame.Rect(int(mp[0]), int(mp[1]), int(mp[2]), int(mp[3]))
|
||||
@@ -1173,16 +1225,37 @@ class Room:
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
self.platforms_moving.append(rect)
|
||||
self.platforms_dissolve = []
|
||||
for dp in platforms.get("dissolve", []):
|
||||
if isinstance(dp, dict):
|
||||
rect = pygame.Rect(int(dp.get("x", 0)), int(dp.get("y", 0)), int(dp.get("w", 0)), int(dp.get("h", 0)))
|
||||
self.platform_layer[id(rect)] = int(dp.get("layer", 0))
|
||||
self.platform_meta[id(rect)] = {
|
||||
"stand_time": float(dp.get("stand_time", DISSOLVE_STAND_TIME)),
|
||||
"dissolve_time": float(dp.get("dissolve_time", DISSOLVE_TIME)),
|
||||
"timer": 0.0,
|
||||
"dissolving": False,
|
||||
}
|
||||
self.platforms_dissolve.append(rect)
|
||||
elif isinstance(dp, (list, tuple)) and len(dp) >= 4:
|
||||
rect = pygame.Rect(int(dp[0]), int(dp[1]), int(dp[2]), int(dp[3]))
|
||||
if len(dp) >= 5:
|
||||
try:
|
||||
self.platform_layer[id(rect)] = int(dp[4])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
self.platforms_dissolve.append(rect)
|
||||
else:
|
||||
self.platforms_ground = []
|
||||
self.platforms_floating = []
|
||||
self.platforms_hazard = []
|
||||
self.platforms_trigger = []
|
||||
self.platforms_moving = []
|
||||
self.platforms_dissolve = []
|
||||
self.ground_platforms = self.platforms_ground[:]
|
||||
self.platforms = self.platforms_ground + self.platforms_floating + self.platforms_moving
|
||||
|
||||
all_platform_rects = self.platforms_ground + self.platforms_floating + self.platforms_moving + self.platforms_hazard + self.platforms_trigger
|
||||
all_platform_rects = self.platforms_ground + self.platforms_floating + self.platforms_moving + self.platforms_hazard + self.platforms_trigger + self.platforms_dissolve
|
||||
if all_platform_rects:
|
||||
self.min_x = min(r.left for r in all_platform_rects)
|
||||
self.max_x = max(r.right for r in all_platform_rects)
|
||||
@@ -1534,9 +1607,12 @@ class Projectile:
|
||||
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))
|
||||
# Raw sprite points LEFT; flip it so it points right, then rotate to
|
||||
# match the actual flight direction so up/down shots aren't horizontal.
|
||||
img = pygame.transform.flip(img, True, False)
|
||||
angle = -math.degrees(math.atan2(self.dir_y, self.dir_x))
|
||||
img = pygame.transform.rotate(img, angle)
|
||||
surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx, self.rect.centery - img.get_height() // 2 - cy))
|
||||
return
|
||||
# simple readable bolt
|
||||
pygame.draw.rect(
|
||||
@@ -1761,11 +1837,15 @@ class LightSource:
|
||||
|
||||
|
||||
class SavePoint:
|
||||
"""Lantern save point: pressing interact nearby saves the game and sets the respawn point."""
|
||||
"""Lantern save point: pressing interact nearby saves the game and sets the respawn point.
|
||||
|
||||
One-time use per room visit: once used, the lantern stays spent until the
|
||||
player leaves the room and comes back (the flag resets on room load)."""
|
||||
def __init__(self, x: int, y: int, radius: int = 90):
|
||||
self.x = int(x)
|
||||
self.y = int(y)
|
||||
self.radius = int(radius)
|
||||
self.used = False
|
||||
self.t = random.random() * 10.0
|
||||
if LAMP_RAW is not None:
|
||||
self.img = LAMP_RAW
|
||||
@@ -1780,6 +1860,10 @@ class SavePoint:
|
||||
surface.blit(self.img, (self.rect.x - cx, self.rect.y - cy))
|
||||
else:
|
||||
pygame.draw.rect(surface, (150, 200, 230), pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height))
|
||||
if self.used:
|
||||
dim = pygame.Surface((self.rect.width, self.rect.height), pygame.SRCALPHA)
|
||||
dim.fill((30, 30, 40, 150))
|
||||
surface.blit(dim, (self.rect.x - cx, self.rect.y - cy))
|
||||
|
||||
|
||||
class ButterflyJar:
|
||||
@@ -1933,14 +2017,14 @@ class LootPickup:
|
||||
|
||||
|
||||
class CoinPickup:
|
||||
def __init__(self, x, y, kind: str):
|
||||
def __init__(self, x, y, kind: str, vx: float | None = None, vy: float | None = None):
|
||||
# kind: "light" or "dark"
|
||||
self.kind = kind
|
||||
self.rect = pygame.Rect(int(x), int(y), 16, 16)
|
||||
|
||||
# physics (same feel as loot)
|
||||
self.vx = random.uniform(-80, 80)
|
||||
self.vy = random.uniform(-140, -60)
|
||||
self.vx = random.uniform(-80, 80) if vx is None else vx
|
||||
self.vy = random.uniform(-140, -60) if vy is None else vy
|
||||
self.on_ground = False
|
||||
|
||||
self.bob_t = random.random() * 10.0
|
||||
@@ -2029,12 +2113,29 @@ class RewardChest:
|
||||
if self.opened:
|
||||
return False
|
||||
light_amt, dark_amt = self.coins
|
||||
cx = self.rect.centerx
|
||||
cy = self.rect.top - 18 # pop out of the chest top, clear of its platform
|
||||
# spray coins across the chest width and above its top so they don't
|
||||
# all stack on a player standing directly in front of the chest
|
||||
half_span = self.rect.width // 2 + 24
|
||||
for _ in range(max(0, int(light_amt))):
|
||||
coin_drops.append(CoinPickup(cx, cy, "light"))
|
||||
ox = random.randint(-half_span, half_span)
|
||||
oy = random.randint(0, 40)
|
||||
coin_drops.append(CoinPickup(
|
||||
self.rect.centerx + ox,
|
||||
self.rect.top - 18 - oy,
|
||||
"light",
|
||||
vx=random.uniform(-170, 170),
|
||||
vy=random.uniform(-230, -90),
|
||||
))
|
||||
for _ in range(max(0, int(dark_amt))):
|
||||
coin_drops.append(CoinPickup(cx, cy, "dark"))
|
||||
ox = random.randint(-half_span, half_span)
|
||||
oy = random.randint(0, 40)
|
||||
coin_drops.append(CoinPickup(
|
||||
self.rect.centerx + ox,
|
||||
self.rect.top - 18 - oy,
|
||||
"dark",
|
||||
vx=random.uniform(-170, 170),
|
||||
vy=random.uniform(-230, -90),
|
||||
))
|
||||
self.opened = True
|
||||
return True
|
||||
|
||||
@@ -3978,7 +4079,12 @@ class Player:
|
||||
elif key_pressed(keys, "move_right"):
|
||||
self.facing_right = True
|
||||
|
||||
speed = PLAYER_SHADE_SPEED if self.shade else PLAYER_SPEED
|
||||
# In mist rooms the shade form has no movement penalty — the haze is
|
||||
# home to it, so it moves at full (light-form) speed.
|
||||
if self.mist_active:
|
||||
speed = PLAYER_SPEED
|
||||
else:
|
||||
speed = PLAYER_SHADE_SPEED if self.shade else PLAYER_SPEED
|
||||
form_bonus = self.bonus_move_speed_shade_pct if self.shade else self.bonus_move_speed_light_pct
|
||||
speed *= (1.0 + self.bonus_move_speed_pct + form_bonus)
|
||||
|
||||
@@ -4255,6 +4361,18 @@ class Enemy:
|
||||
self.max_step_up = 160
|
||||
self.max_step_forward = 120
|
||||
|
||||
# Miniboss "Discharge": stand-still telegraph then a platform-piercing AOE
|
||||
# when the player camps somewhere the miniboss can't reach.
|
||||
self.discharge_state = "idle" # "idle" | "charging"
|
||||
self.discharge_timer = 0.0
|
||||
self.discharge_cd = 0.0
|
||||
self.discharge_buildup = 0.0
|
||||
self.discharge_flash = 0.0
|
||||
# Head height while grounded. The discharge checks the player against this
|
||||
# (not the current airborne rect.top), so jumping up at a camping player
|
||||
# no longer resets the discharge buildup.
|
||||
self.discharge_ground_top = self.rect.top
|
||||
|
||||
self.contact_damage = int(cfg.get("contact_damage", 1))
|
||||
self.drop_profile = str(cfg.get("drop_profile", "normal"))
|
||||
|
||||
@@ -4403,6 +4521,20 @@ class Enemy:
|
||||
best_top = p.top
|
||||
return best
|
||||
|
||||
def _player_unreachable_above(self, player) -> bool:
|
||||
"""True if the player is camping above the miniboss, out of reach."""
|
||||
ground_top = getattr(self, "discharge_ground_top", self.rect.top)
|
||||
return player.rect.bottom < ground_top - DISCHARGE_ABOVE_GAP
|
||||
|
||||
def _discharge_release(self, player):
|
||||
"""Resolve the Discharge blast: platform-piercing AOE damage."""
|
||||
dx = player.rect.centerx - self.rect.centerx
|
||||
dy = player.rect.centery - self.rect.centery
|
||||
if math.hypot(dx, dy) <= DISCHARGE_RADIUS and (not player.shade):
|
||||
player.take_damage(DISCHARGE_DAMAGE)
|
||||
player.apply_knockback(self.rect, strength_px_per_frame=16.0, duration=0.25, pop_up_vel=-4.0)
|
||||
self.discharge_flash = 0.35
|
||||
|
||||
|
||||
def _probe_ground_now(self, platforms):
|
||||
"""Refresh on_ground + ground_platform every frame (stable)."""
|
||||
@@ -4833,10 +4965,43 @@ class Enemy:
|
||||
self.vel_x = self.direction * desired_speed
|
||||
|
||||
|
||||
# --- Miniboss Discharge: anti-camp AOE (stand still, then blast) ---
|
||||
discharging = self.discharge_state == "charging"
|
||||
if self.drop_profile == "miniboss":
|
||||
# Track the resting head height so jumps at a camping player don't
|
||||
# reset the discharge buildup (see _player_unreachable_above).
|
||||
if self.on_ground:
|
||||
self.discharge_ground_top = self.rect.top
|
||||
if self.discharge_cd > 0.0:
|
||||
self.discharge_cd = max(0.0, self.discharge_cd - dt)
|
||||
if self.discharge_flash > 0.0:
|
||||
self.discharge_flash = max(0.0, self.discharge_flash - dt)
|
||||
if discharging:
|
||||
self.discharge_timer -= dt
|
||||
if self.discharge_timer <= 0.0:
|
||||
self.discharge_state = "idle"
|
||||
self.discharge_cd = DISCHARGE_COOLDOWN
|
||||
self.discharge_buildup = 0.0
|
||||
self._discharge_release(player)
|
||||
discharging = False
|
||||
elif chasing and (not stunned) and (not charg_acting):
|
||||
if self._player_unreachable_above(player):
|
||||
self.discharge_buildup += dt
|
||||
if self.discharge_buildup >= DISCHARGE_SUSTAIN and self.discharge_cd <= 0.0:
|
||||
self.discharge_state = "charging"
|
||||
self.discharge_timer = DISCHARGE_CHARGE_TIME
|
||||
self.discharge_buildup = 0.0
|
||||
discharging = True
|
||||
else:
|
||||
self.discharge_buildup = 0.0
|
||||
if discharging:
|
||||
self.vel_x = 0.0
|
||||
|
||||
|
||||
# --- CHASE LEDGE DROP ---
|
||||
# If we're chasing and the player is below, step off the platform instead of edge-wiggling.
|
||||
jump_target = None
|
||||
if chasing and self.on_ground and self.ground_platform is not None:
|
||||
if chasing and (not discharging) and self.on_ground and self.ground_platform is not None:
|
||||
gp = self.ground_platform
|
||||
|
||||
# IMPORTANT: define this right here so it's ALWAYS available
|
||||
@@ -4941,7 +5106,7 @@ class Enemy:
|
||||
|
||||
|
||||
# regular enemy jump helper (charger moves via charge states instead)
|
||||
if self.on_ground and chasing and self.jump_cd_timer <= 0 and (not charg_acting):
|
||||
if self.on_ground and chasing and (not discharging) and self.jump_cd_timer <= 0 and (not charg_acting):
|
||||
player_above = player.rect.centery < self.rect.centery - self.jump_when_player_above
|
||||
if player_above and (blocked_by_wall or self.reachable_platform_ahead(platforms)):
|
||||
self.vel_y = self.jump_strength
|
||||
@@ -4985,6 +5150,28 @@ class Enemy:
|
||||
if self.bind_timer > 0.0:
|
||||
_draw_tether_chain_loop(surface, self.rect, light=self.bound_light, cx=cx, cy=cy)
|
||||
|
||||
if self.discharge_state == "charging" or self.discharge_flash > 0.0:
|
||||
if self.discharge_state == "charging":
|
||||
progress = 1.0 - (self.discharge_timer / DISCHARGE_CHARGE_TIME)
|
||||
radius = max(20, int(20 + progress * (DISCHARGE_RADIUS - 20)))
|
||||
ring_color = (190, 80, 220, 40 + int(progress * 80))
|
||||
body_color = (220, 120, 255)
|
||||
else:
|
||||
radius = int(DISCHARGE_RADIUS)
|
||||
ring_color = (255, 180, 90, 130)
|
||||
body_color = (255, 200, 120)
|
||||
px = self.rect.centerx - cx
|
||||
py = self.rect.centery - cy
|
||||
ring = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
|
||||
pygame.draw.circle(ring, ring_color, (radius, radius), radius, 3)
|
||||
surface.blit(ring, (px - radius, py - radius))
|
||||
pygame.draw.rect(
|
||||
surface,
|
||||
body_color,
|
||||
pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height),
|
||||
2,
|
||||
)
|
||||
|
||||
|
||||
def resolve_enemy_collisions(enemies):
|
||||
# for i in range(len(enemies)):
|
||||
@@ -5146,7 +5333,7 @@ def draw_pause_controls(surface, x, y):
|
||||
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",
|
||||
"Right Mouse Button = ranged",
|
||||
f"{key_name(key_for('dash'))} = dash",
|
||||
f"{key_name(key_for('shatterstep'))} = shatterstep",
|
||||
f"{key_name(key_for('tether'))} = tether",
|
||||
@@ -5261,6 +5448,9 @@ def trigger_wall_blocking(rect) -> bool:
|
||||
- ``open_on_miniboss_dead``: releases the wall once the room's miniboss
|
||||
is slain (combines with the conditions above; with no other condition
|
||||
the wall is simply present until then).
|
||||
- ``open_on_boss_dead``: releases the wall once the room's boss is slain
|
||||
(combines with the conditions above; with no other condition the wall
|
||||
is simply present until then).
|
||||
Walls with no condition are inert placeholders (never block)."""
|
||||
meta = platform_meta.get(id(rect), {})
|
||||
qid = meta.get("quest")
|
||||
@@ -5275,11 +5465,15 @@ def trigger_wall_blocking(rect) -> bool:
|
||||
present = meta["quest_turned_in"] not in quest_manager.ever_completed
|
||||
elif meta.get("open_on_miniboss_dead"):
|
||||
present = True # always present until released
|
||||
elif meta.get("open_on_boss_dead"):
|
||||
present = True # always present until released
|
||||
else:
|
||||
return False # inert placeholder
|
||||
|
||||
if meta.get("open_on_miniboss_dead"):
|
||||
present = present and miniboss_alive_in_room()
|
||||
if meta.get("open_on_boss_dead"):
|
||||
present = present and boss_alive_in_room()
|
||||
return present
|
||||
|
||||
|
||||
@@ -5294,6 +5488,8 @@ def enemy_trigger_wall_blocking(rect) -> bool:
|
||||
meta = platform_meta.get(id(rect), {})
|
||||
if meta.get("open_on_miniboss_dead"):
|
||||
return miniboss_alive_in_room()
|
||||
if meta.get("open_on_boss_dead"):
|
||||
return boss_alive_in_room()
|
||||
return trigger_wall_blocking(rect)
|
||||
|
||||
|
||||
@@ -5306,6 +5502,15 @@ def miniboss_alive_in_room() -> bool:
|
||||
return any(getattr(en, "drop_profile", "") == "miniboss" and en.health > 0 for en in enemies)
|
||||
|
||||
|
||||
def moving_platform_active(rect) -> bool:
|
||||
"""A moving platform with ``open_on_boss_dead`` is hidden and inert while
|
||||
the room's boss is alive; it appears and starts moving once the boss is slain."""
|
||||
meta = platform_meta.get(id(rect), {})
|
||||
if meta.get("open_on_boss_dead") and boss_alive_in_room():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def push_rect_out_of_rects(rect, rects):
|
||||
"""Push a pygame.Rect out of any overlapping rect, moving to the nearest edge.
|
||||
|
||||
@@ -5370,8 +5575,11 @@ def update_moving_platforms(dt: float):
|
||||
"""Advance moving platforms along their waypoint paths (top-left follows the path).
|
||||
|
||||
A platform stops while the player or an enemy is in its way, and resumes
|
||||
from the same spot once the path is clear again."""
|
||||
from the same spot once the path is clear again. Boss-gated platforms are
|
||||
skipped entirely while their boss is alive."""
|
||||
for rect in platforms_moving:
|
||||
if not moving_platform_active(rect):
|
||||
continue
|
||||
mdata = platform_meta.get(id(rect))
|
||||
if not mdata:
|
||||
continue
|
||||
@@ -5412,6 +5620,45 @@ def update_moving_platforms(dt: float):
|
||||
rect.y = new_y
|
||||
|
||||
|
||||
def update_dissolve_platforms(dt: float):
|
||||
"""Advance dissolve-platform timers.
|
||||
|
||||
A dissolve platform behaves like a normal platform while solid. If the player
|
||||
stands on it for its ``stand_time``, it turns translucent (no hitbox) for its
|
||||
``dissolve_time``, then returns to solid again. Returns the rects that are
|
||||
currently solid (callers add these to the collision lists).
|
||||
"""
|
||||
solid_rects = []
|
||||
for rect in platforms_dissolve:
|
||||
meta = platform_meta.get(id(rect))
|
||||
if not meta:
|
||||
solid_rects.append(rect)
|
||||
continue
|
||||
if meta.get("dissolving"):
|
||||
meta["timer"] += dt
|
||||
if meta["timer"] >= float(meta.get("dissolve_time", DISSOLVE_TIME)):
|
||||
meta["dissolving"] = False
|
||||
meta["timer"] = 0.0
|
||||
continue
|
||||
# Standing check uses geometry (like the moving-platform support check),
|
||||
# not player.ground_platform, which flickers when resting exactly on top.
|
||||
standing = (
|
||||
player.rect.bottom >= rect.top - 2
|
||||
and player.rect.bottom <= rect.top + 5
|
||||
and player.rect.right > rect.left
|
||||
and player.rect.left < rect.right
|
||||
)
|
||||
if standing:
|
||||
meta["timer"] += dt
|
||||
if meta["timer"] >= float(meta.get("stand_time", DISSOLVE_STAND_TIME)):
|
||||
meta["dissolving"] = True
|
||||
meta["timer"] = 0.0
|
||||
else:
|
||||
meta["timer"] = 0.0
|
||||
solid_rects.append(rect)
|
||||
return solid_rects
|
||||
|
||||
|
||||
def draw_layerable(surface, cx, cy):
|
||||
"""Draw all static room objects back-to-front by (layer, category).
|
||||
|
||||
@@ -5440,9 +5687,18 @@ def draw_layerable(surface, cx, cy):
|
||||
items.append((getattr(reward_ability, "layer", 0), 0, "rewardability", reward_ability))
|
||||
# platforms
|
||||
for p in platforms:
|
||||
# Boss-gated moving platforms are also in `platforms` (ground+floating+
|
||||
# moving); skip them here so they're fully invisible while inactive.
|
||||
if not moving_platform_active(p):
|
||||
continue
|
||||
items.append((platform_layer.get(id(p), 0), 1, "platform", p))
|
||||
# dissolve platforms (solid or translucent)
|
||||
for p in platforms_dissolve:
|
||||
items.append((platform_layer.get(id(p), 0), 1, "dissolve", p))
|
||||
# moving platforms (solid, follow a path) layer alongside platforms
|
||||
for p in platforms_moving:
|
||||
if not moving_platform_active(p):
|
||||
continue
|
||||
items.append((platform_layer.get(id(p), 0), 1, "moving", p))
|
||||
# hazard zones (red) layer alongside platforms
|
||||
for p in platforms_hazard:
|
||||
@@ -5478,6 +5734,16 @@ def draw_layerable(surface, cx, cy):
|
||||
PLATFORM_COLOR,
|
||||
pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height),
|
||||
)
|
||||
elif kind == "dissolve":
|
||||
mdata = platform_meta.get(id(obj), {})
|
||||
drect = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
|
||||
if mdata.get("dissolving"):
|
||||
fade = pygame.Surface((obj.width, obj.height), pygame.SRCALPHA)
|
||||
fade.fill((96, 165, 250, 90))
|
||||
surface.blit(fade, (drect.x, drect.y))
|
||||
pygame.draw.rect(surface, (150, 210, 255), drect, 1)
|
||||
else:
|
||||
pygame.draw.rect(surface, PLATFORM_COLOR, drect)
|
||||
elif kind == "moving":
|
||||
mr = pygame.Rect(obj.x - cx, obj.y - cy, obj.width, obj.height)
|
||||
pygame.draw.rect(surface, (80, 180, 220), mr)
|
||||
@@ -5735,6 +6001,7 @@ platforms_floating = ROOM.platforms_floating
|
||||
platforms_hazard = ROOM.platforms_hazard
|
||||
platforms_trigger = ROOM.platforms_trigger
|
||||
platforms_moving = ROOM.platforms_moving
|
||||
platforms_dissolve = ROOM.platforms_dissolve
|
||||
platform_meta = ROOM.platform_meta
|
||||
ground_platforms = ROOM.ground_platforms
|
||||
platforms = ROOM.platforms
|
||||
@@ -5966,6 +6233,7 @@ def apply_room(room: Room, set_player_spawn=True):
|
||||
"""Updates room-dependent lists + respawns enemies. Keeps inventory/equipment."""
|
||||
global ROOM, KILL_Y, platforms_ground, platforms_floating, platforms_hazard, ground_platforms, platforms
|
||||
global platforms_trigger, platforms_moving, platform_meta
|
||||
global platforms_dissolve
|
||||
global platform_layer
|
||||
global enemies, npcs, loot_drops, shockwaves, coin_drops, projectiles, ability_doors
|
||||
global shatter_platforms, shatter_aoes, tether_shots
|
||||
@@ -5987,6 +6255,7 @@ def apply_room(room: Room, set_player_spawn=True):
|
||||
platforms_hazard = ROOM.platforms_hazard
|
||||
platforms_trigger = ROOM.platforms_trigger
|
||||
platforms_moving = ROOM.platforms_moving
|
||||
platforms_dissolve = ROOM.platforms_dissolve
|
||||
platform_meta = ROOM.platform_meta
|
||||
ground_platforms = ROOM.ground_platforms
|
||||
platforms = ROOM.platforms
|
||||
@@ -6633,12 +6902,25 @@ def get_near_diary(player_obj, diary_obj) -> bool:
|
||||
return math.hypot(dx, dy) <= DiaryObject.INTERACT_RANGE
|
||||
|
||||
|
||||
CHEST_INTERACT_RANGE = 70
|
||||
|
||||
|
||||
def get_near_chest(player_obj, chest_obj) -> bool:
|
||||
if chest_obj is None or chest_obj.opened:
|
||||
return False
|
||||
dx = player_obj.rect.centerx - chest_obj.rect.centerx
|
||||
dy = player_obj.rect.centery - chest_obj.rect.centery
|
||||
return math.hypot(dx, dy) <= CHEST_INTERACT_RANGE
|
||||
|
||||
|
||||
def get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -> SavePoint | None:
|
||||
"""Return the nearest lantern the player can interact with, or None."""
|
||||
"""Return the nearest un-used lantern the player can interact with, or None."""
|
||||
best = None
|
||||
best_d = None
|
||||
px, py = player_obj.rect.midbottom
|
||||
for sp in save_point_list:
|
||||
if sp.used:
|
||||
continue
|
||||
r = range_px if range_px is not None else sp.radius
|
||||
if r <= 0:
|
||||
continue
|
||||
@@ -6655,6 +6937,9 @@ def get_near_lantern(player_obj, save_point_list, range_px: int | None = None) -
|
||||
def save_at_lantern(sp: SavePoint):
|
||||
"""Set the lantern as the respawn point, restore HP/Charge, and save the game."""
|
||||
global LANTERN_RESPAWN_ROOM, LANTERN_RESPAWN_POS
|
||||
if sp.used:
|
||||
return
|
||||
sp.used = True
|
||||
LANTERN_RESPAWN_ROOM = ROOM.id
|
||||
LANTERN_RESPAWN_POS = (sp.x, sp.y)
|
||||
# Resting at a lantern fully restores the player.
|
||||
@@ -6949,7 +7234,10 @@ 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)
|
||||
if player.mist_active:
|
||||
shade_speed = light_speed
|
||||
else:
|
||||
shade_speed = PLAYER_SHADE_SPEED * (1.0 + player.bonus_move_speed_pct + player.bonus_move_speed_shade_pct)
|
||||
heal_dur = player.effective_heal_duration()
|
||||
cycle = player.effective_attack_duration() + player.effective_attack_cooldown()
|
||||
atk_rate = (1.0 / cycle) if cycle > 0 else 0.0
|
||||
@@ -7665,14 +7953,6 @@ while running:
|
||||
running = False
|
||||
break
|
||||
|
||||
if (not paused) and (not inv_ui.visible) and (not trade_ui.visible):
|
||||
if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
|
||||
player.set_attack_dir(camera_x, camera_y)
|
||||
player.attack()
|
||||
elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 3:
|
||||
player.set_attack_dir(camera_x, camera_y)
|
||||
player.try_ranged_attack(projectiles, enemies, platforms)
|
||||
|
||||
if e.type == pygame.KEYDOWN:
|
||||
was_trade_visible = trade_ui.visible
|
||||
was_inv_visible = inv_ui.visible
|
||||
@@ -7734,6 +8014,9 @@ while running:
|
||||
near_lantern = get_near_lantern(player, save_points)
|
||||
if near_lantern:
|
||||
save_at_lantern(near_lantern)
|
||||
elif get_near_chest(player, reward_chest):
|
||||
if reward_chest.open(player):
|
||||
player.opened_chests.add(reward_chest.id)
|
||||
elif get_near_diary(player, diary_obj):
|
||||
diary_ui.toggle()
|
||||
else:
|
||||
@@ -7764,23 +8047,6 @@ while running:
|
||||
if key_is("toggle_shade", e.key):
|
||||
player.toggle_shade()
|
||||
|
||||
if key_is("attack", e.key):
|
||||
player.set_attack_dir(camera_x, camera_y)
|
||||
player.attack()
|
||||
|
||||
if key_is("ranged", e.key):
|
||||
player.set_attack_dir(camera_x, camera_y)
|
||||
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
|
||||
@@ -7802,6 +8068,29 @@ while running:
|
||||
|
||||
|
||||
keys = pygame.key.get_pressed()
|
||||
mouse_buttons = pygame.mouse.get_pressed()
|
||||
|
||||
# Hold any combat key (or mouse button) to keep re-casting the
|
||||
# ability/attack as soon as its cooldown and resource checks allow
|
||||
# (never faster than the cooldown).
|
||||
if (not paused) and (not inv_ui.visible) and (not trade_ui.visible):
|
||||
if key_pressed(keys, "attack") or mouse_buttons[0]:
|
||||
player.set_attack_dir(camera_x, camera_y)
|
||||
player.attack()
|
||||
|
||||
if key_pressed(keys, "ranged") or mouse_buttons[2]:
|
||||
player.set_attack_dir(camera_x, camera_y)
|
||||
player.try_ranged_attack(projectiles, enemies, platforms)
|
||||
|
||||
if key_pressed(keys, "dash"):
|
||||
player.try_dash()
|
||||
|
||||
if key_pressed(keys, "shatterstep"):
|
||||
player.try_shatterstep()
|
||||
|
||||
if key_pressed(keys, "tether"):
|
||||
player.try_tether(tether_shots)
|
||||
|
||||
if paused:
|
||||
# draw last frame + pause overlay
|
||||
screen.fill(BG)
|
||||
@@ -7934,6 +8223,8 @@ while running:
|
||||
support_moving = None
|
||||
if player.on_ground:
|
||||
for rect in platforms_moving:
|
||||
if not moving_platform_active(rect):
|
||||
continue
|
||||
if (player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and
|
||||
player.rect.right > rect.left and player.rect.left < rect.right):
|
||||
support_moving = rect
|
||||
@@ -7958,7 +8249,13 @@ while running:
|
||||
# Trigger walls: solid only while their unlock quest is not yet active.
|
||||
blocking_triggers = [t for t in platforms_trigger if trigger_wall_blocking(t)]
|
||||
|
||||
collision_platforms = platforms + [d.rect for d in ability_doors if not d.open] + blocking_triggers
|
||||
# Dissolve platforms: solid while not dissolved; excluded from collision while dissolving.
|
||||
solid_dissolve = update_dissolve_platforms(dt)
|
||||
|
||||
# Boss-gated moving platforms have no hitbox while their boss is alive.
|
||||
inactive_moving_ids = {id(r) for r in platforms_moving if not moving_platform_active(r)}
|
||||
player_solid = [r for r in platforms if id(r) not in inactive_moving_ids]
|
||||
collision_platforms = player_solid + [d.rect for d in ability_doors if not d.open] + blocking_triggers + solid_dissolve
|
||||
|
||||
# Enemies collide with ground/floating platforms, doors and trigger walls —
|
||||
# but NOT moving platforms. Moving platforms are player traversal; a platform
|
||||
@@ -7969,6 +8266,7 @@ while running:
|
||||
enemy_collision_platforms = [r for r in platforms if id(r) not in moving_ids]
|
||||
enemy_collision_platforms += [d.rect for d in ability_doors if not d.open]
|
||||
enemy_collision_platforms += enemy_blocking_triggers
|
||||
enemy_collision_platforms += solid_dissolve
|
||||
|
||||
# Shade doors are solid for the world, but the player can pass through them
|
||||
# freely while in shade form — and for a short grace period after switching
|
||||
@@ -7997,6 +8295,8 @@ while running:
|
||||
# Glue the player to any moving platform they're standing on, so upward movement
|
||||
# doesn't leave a gap that makes on_ground flicker (bobbing / eventual fall-off).
|
||||
for rect in platforms_moving:
|
||||
if not moving_platform_active(rect):
|
||||
continue
|
||||
if (player.rect.bottom >= rect.top - 2 and player.rect.bottom <= rect.top + 5 and
|
||||
player.rect.right > rect.left and player.rect.left < rect.right):
|
||||
player.rect.bottom = rect.top
|
||||
@@ -8287,14 +8587,12 @@ while running:
|
||||
|
||||
# Pickup loot (only when inventory closed)
|
||||
if not inv_ui.visible and not trade_ui.visible:
|
||||
if reward_chest and player.rect.colliderect(reward_chest.rect):
|
||||
if reward_chest.open(player):
|
||||
player.opened_chests.add(reward_chest.id)
|
||||
# Chests open on interact (F); the interact handler calls reward_chest.open().
|
||||
|
||||
if reward_ability and player.rect.colliderect(reward_ability.rect):
|
||||
if reward_ability.ability_id == "ranged":
|
||||
player.has_ranged = True
|
||||
pickup_feed.push_center("Ranged Ability aquired (Press Q)", duration=5.0)
|
||||
pickup_feed.push_center("Ranged Ability acquired (Right Mouse Button)", duration=5.0)
|
||||
else:
|
||||
pickup_feed.push_center(f"Ability acquired: {reward_ability.ability_id}", duration=5.0)
|
||||
reward_ability = None
|
||||
@@ -8460,6 +8758,16 @@ while running:
|
||||
int(near_lantern.rect.top - camera_y),
|
||||
)
|
||||
|
||||
# Chest open hint bubble
|
||||
if not inv_ui.visible and not trade_ui.visible and not diary_ui.visible:
|
||||
if get_near_chest(player, reward_chest):
|
||||
draw_speech_bubble(
|
||||
screen,
|
||||
"Press F - Open chest",
|
||||
int(reward_chest.rect.centerx - camera_x),
|
||||
int(reward_chest.rect.top - camera_y),
|
||||
)
|
||||
|
||||
# Boss health bar (if boss exists)
|
||||
boss = next((en for en in enemies if getattr(en, "drop_profile", "") == "boss"), None)
|
||||
if boss is not None:
|
||||
|
||||
Reference in New Issue
Block a user