Python 303 lines
"""Escape-room props: spike traps, levers, gates, the key and plates.
All visuals are procedural (one cached surface set per class, like
``static_objects.TileTextures``) so the game stays clone-safe and costs
only a handful of Surface allocations no matter how many props a level
has. Every prop is a normal ``Sprite`` with ``image``/``rect``/``hitbox``
so the level can blit and collision-test it like any other sprite.
"""
import pygame
import audio
from settings import (
PLATE_TRIGGER_DELAY,
SPIKE_CYCLE,
SPIKE_DANGER_TIME,
SPIKE_WARN_TIME,
TILE_SIZE,
)
TS = TILE_SIZE
# Shared state cues for the escape-room props. The lever uses both;
# the plate's "off" state is intentionally a quiet grey (the plate is
# *inactive*, not *dangerous*), so only ON_COL is shared with it.
ON_COL = (90, 220, 130)
OFF_COL = (205, 90, 90)
class Spikes(pygame.sprite.Sprite):
"""Floor trap on a shared clock: safe -> warning -> deadly -> safe.
Every spike is created with the same phase, so the whole field
pulses together and the player can learn the rhythm. It only hurts
while fully extended; the warning frame telegraphs that so timing a
crossing is fair rather than a coin flip.
"""
_imgs = None
def __init__(self, pos, groups, phase=0.0):
super().__init__(groups)
self._build_images()
self.t = phase
self.image = self._imgs['down']
self.rect = self.image.get_rect(topleft=pos)
# Inset so you can stand on the very edge of a tile unharmed.
self.hitbox = self.rect.inflate(-16, -16)
self.deadly = False
@classmethod
def _build_images(cls):
if cls._imgs is not None:
return
def base():
s = pygame.Surface((TS, TS), pygame.SRCALPHA)
pygame.draw.rect(s, (26, 24, 32), (3, 3, TS - 6, TS - 6),
border_radius=4)
pygame.draw.rect(s, (12, 11, 16), (3, 3, TS - 6, TS - 6), 2,
border_radius=4)
for ox in range(2):
for oy in range(2):
cx, cy = TS // 4 + ox * TS // 2, TS // 4 + oy * TS // 2
pygame.draw.circle(s, (8, 8, 12), (cx, cy), 5)
return s
def teeth(color, h):
s = base()
for ox in range(2):
for oy in range(2):
cx, cy = TS // 4 + ox * TS // 2, TS // 4 + oy * TS // 2
pygame.draw.polygon(s, color, [
(cx - 7, cy + 6), (cx + 7, cy + 6), (cx, cy - h)])
pygame.draw.line(s, (255, 255, 255),
(cx - 2, cy - h + 4), (cx, cy - h), 2)
return s
cls._imgs = {
'down': base(),
'warn': teeth((148, 96, 74), 9),
'up': teeth((214, 82, 70), 18),
}
def update(self, dt):
self.t = (self.t + dt) % SPIKE_CYCLE
danger_start = SPIKE_CYCLE - SPIKE_DANGER_TIME
warn_start = danger_start - SPIKE_WARN_TIME
if self.t >= danger_start:
self.image, self.deadly = self._imgs['up'], True
elif self.t >= warn_start:
self.image, self.deadly = self._imgs['warn'], False
else:
self.image, self.deadly = self._imgs['down'], False
class Lever(pygame.sprite.Sprite):
"""Pull-once switch. ``use()`` flips it and the level opens every
gate whose ``group_id`` matches this lever's ``gate_group``."""
_imgs = None
def __init__(self, pos, groups, gate_group):
super().__init__(groups)
self._build_images()
self.gate_group = gate_group
self.activated = False
self.image = self._imgs[False]
self.rect = self.image.get_rect(topleft=pos)
self.hitbox = self.rect.copy()
@classmethod
def _build_images(cls):
if cls._imgs is not None:
return
def make(on):
s = pygame.Surface((TS, TS), pygame.SRCALPHA)
plate = pygame.Rect(TS // 4, TS // 6, TS // 2, TS * 2 // 3)
pygame.draw.rect(s, (42, 40, 52), plate, border_radius=6)
pygame.draw.rect(s, (16, 15, 22), plate, 2, border_radius=6)
pivot = (TS // 2, TS * 2 // 3)
knob = (TS * 2 // 3, TS // 3) if on else (TS // 3, TS // 3)
col = ON_COL if on else OFF_COL
pygame.draw.line(s, (18, 17, 24), pivot, knob, 8)
pygame.draw.line(s, (158, 158, 168), pivot, knob, 4)
pygame.draw.circle(s, col, knob, 9)
pygame.draw.circle(s, (235, 235, 240), knob, 9, 2)
pygame.draw.circle(s, (20, 19, 26), pivot, 5)
return s
cls._imgs = {False: make(False), True: make(True)}
def use(self):
if self.activated:
return False
self.activated = True
self.image = self._imgs[True]
audio.play("lever_click")
return True
class Gate(pygame.sprite.Sprite):
"""One cell of a (possibly multi-cell) gate.
While shut it sits in the obstacle group for collision and draws as
a barred door. Its lever calls :meth:`open`, which pulls it out of
the obstacle group (collision gone) and swaps to the open frame.
"""
_imgs = None
def __init__(self, pos, draw_group, obstacle_group, group_id):
super().__init__(draw_group)
self._build_images()
self.group_id = group_id
self.obstacle_group = obstacle_group
obstacle_group.add(self)
self.opened = False
self.image = self._imgs[False]
self.rect = self.image.get_rect(topleft=pos)
self.hitbox = self.rect.inflate(0, -8)
@classmethod
def _build_images(cls):
if cls._imgs is not None:
return
shut = pygame.Surface((TS, TS), pygame.SRCALPHA)
shut.fill((28, 26, 34))
pygame.draw.rect(shut, (72, 68, 88), (0, 0, TS, TS), 3)
for i in range(1, 4):
x = i * TS // 4
pygame.draw.line(shut, (122, 118, 142), (x, 4), (x, TS - 4), 6)
pygame.draw.line(shut, (58, 56, 72), (x + 2, 4), (x + 2, TS - 4), 2)
for y in (TS // 3, 2 * TS // 3):
pygame.draw.line(shut, (98, 94, 114), (4, y), (TS - 4, y), 4)
opened = pygame.Surface((TS, TS), pygame.SRCALPHA)
pygame.draw.rect(opened, (60, 58, 74), (0, 0, TS, 6))
pygame.draw.rect(opened, (60, 58, 74), (0, TS - 6, TS, 6))
for x in (5, TS - 11): # bars retracted into the side jambs
pygame.draw.rect(opened, (92, 88, 108), (x, 6, 6, TS - 12))
cls._imgs = {False: shut, True: opened}
def open(self):
if self.opened:
return
self.opened = True
self.obstacle_group.remove(self)
self.image = self._imgs[True]
audio.play("gate_open")
class KeyItem(pygame.sprite.Sprite):
"""The key to the way out. Walking over it picks it up; the level
bobs/glows it when drawing."""
_img = None
def __init__(self, pos, groups):
super().__init__(groups)
self._build_image()
self.image = self._img
self.rect = self.image.get_rect(topleft=pos)
self.hitbox = self.rect.inflate(-10, -10)
self.t = 0.0
@classmethod
def _build_image(cls):
if cls._img is not None:
return
s = pygame.Surface((TS, TS), pygame.SRCALPHA)
c, cx = (245, 205, 70), TS // 2
pygame.draw.circle(s, c, (cx, TS // 2 - 9), 12)
pygame.draw.circle(s, (58, 48, 16), (cx, TS // 2 - 9), 5)
pygame.draw.rect(s, c, (cx - 4, TS // 2 - 2, 8, 26))
pygame.draw.rect(s, c, (cx + 4, TS // 2 + 12, 10, 6))
pygame.draw.rect(s, c, (cx + 4, TS // 2 + 20, 8, 5))
cls._img = s
def update(self, dt):
self.t += dt
class PressurePlate(pygame.sprite.Sprite):
"""Floor plate that opens its paired gate when the player stands on
it long enough.
Levers need a button press (E) within reach; plates need only your
weight — but you have to commit to standing on them for a heartbeat
so a stray cross-the-room doesn't trip them by accident. Once
triggered the plate stays down for the rest of the run, matching
the one-shot feel of levers, and the level pairs them to gates by
reading order exactly like levers.
"""
_imgs = None
def __init__(self, pos, groups, gate_group):
super().__init__(groups)
self._build_images()
self.gate_group = gate_group
self.activated = False
self.charge = 0.0 # seconds player has stood on it
self.image = self._imgs[False]
self.rect = self.image.get_rect(topleft=pos)
# Trigger area covers most of the cell but not the very edges
# so brushing past doesn't count.
self.hitbox = self.rect.inflate(-12, -12)
@classmethod
def _build_images(cls):
if cls._imgs is not None:
return
def make(on):
s = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA)
# Sunken stone base
base = pygame.Rect(6, 6, TILE_SIZE - 12, TILE_SIZE - 12)
pygame.draw.rect(s, (38, 36, 50), base, border_radius=8)
pygame.draw.rect(s, (14, 13, 20), base, 3, border_radius=8)
# Plate top — pressed lower & lit green when activated
inset = base.inflate(-10, -14)
if on:
inset.y += 4
plate_col = ON_COL
rim_col = (40, 110, 70)
else:
plate_col = (78, 76, 92)
rim_col = (28, 26, 36)
pygame.draw.rect(s, plate_col, inset, border_radius=6)
pygame.draw.rect(s, rim_col, inset, 2, border_radius=6)
# Rune mark on top — lit when on
cx, cy = TILE_SIZE // 2, TILE_SIZE // 2
mark_col = (235, 250, 215) if on else (110, 108, 120)
pygame.draw.circle(s, mark_col, (cx, cy), 6, 2)
pygame.draw.line(s, mark_col, (cx - 8, cy), (cx + 8, cy), 2)
return s
cls._imgs = {False: make(False), True: make(True)}
def step_on(self, dt):
"""Called by the level while the player overlaps this plate.
Returns True the moment the plate trips (so the level can open
the gate exactly once)."""
if self.activated:
return False
self.charge += dt
if self.charge >= PLATE_TRIGGER_DELAY:
self.activated = True
self.image = self._imgs[True]
audio.play("plate_press")
return True
return False
def step_off(self):
"""Reset the charge if the player walks off before tripping."""
if not self.activated:
self.charge = 0.0