Compare commits
3 Commits
d30b491ba7
...
a17a68a07e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17a68a07e | ||
|
|
f29d8c953c | ||
|
|
b0ad9610be |
57
CLAUDE.md
Normal file
57
CLAUDE.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repo. **`SPEC.md` is the source of truth for engine
|
||||
behavior and the level format** — read it before changing mechanics, and update
|
||||
it (and `README.md`) when behavior changes. This file only covers workflow and
|
||||
conventions not found there.
|
||||
|
||||
## Commands
|
||||
|
||||
The working virtualenv is `.venv` (Python 3.13). Prefix commands with
|
||||
`.venv/bin/` or activate it first.
|
||||
|
||||
```bash
|
||||
.venv/bin/python main.py # play every level in ./levels
|
||||
.venv/bin/python main.py levels/foo.yaml # play specific level(s), in order
|
||||
.venv/bin/python main.py --debug # force the debug view (see SPEC §14)
|
||||
.venv/bin/python -m pytest # run the test suite
|
||||
.venv/bin/black main.py game tests tools # format (do this before committing)
|
||||
```
|
||||
|
||||
The test suite runs headless — `conftest.py` sets the SDL dummy video/audio
|
||||
drivers, so no window opens and nothing needs a display.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Formatting: `black`** (default 88-col line length, no config file). Run it on
|
||||
any Python you touch. It's in `requirements-dev.txt`.
|
||||
- The game must stay **fully playable with zero art assets**: every sprite falls
|
||||
back to a labeled colored rectangle (see `game/assets.py`). Don't add a hard
|
||||
dependency on any PNG.
|
||||
- **Physics/collision live in true coordinates.** `Level.render_offset` is a
|
||||
draw-only translation for the debug overscan — never fold it into gameplay
|
||||
math.
|
||||
- All tunables (physics, timing, colors, the placeholder table) belong in
|
||||
`game/settings.py`, not scattered as literals.
|
||||
|
||||
## Adding a trap type
|
||||
|
||||
Subclass `Trap` in `game/traps.py`, implement only the hooks it needs
|
||||
(`solid_rects`, `hazard_rects`, `carriers`, `update`, `draw`, `reset`, …), and
|
||||
register the class in the `TRAP_TYPES` factory map. Nothing else in the engine
|
||||
needs to change. See SPEC §11–13 for the hook contract and the trap catalog.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/` mirrors the behavior described in `SPEC.md` (physics, every trap and
|
||||
trigger, mounting, crush/shove, game-flow states). When you change behavior, add
|
||||
or update the matching test — several existing tests are regression guards for
|
||||
specific bugs (corner-warp, fits-under-no-crush, ride-into-wall-no-crush), so
|
||||
read the test's comment before altering its expectations.
|
||||
|
||||
## Module map
|
||||
|
||||
See SPEC §15 for the full layout. In short: `game/game.py` (loop/states/HUD),
|
||||
`game/level.py` (YAML → geometry + traps), `game/player.py` (physics &
|
||||
collision), `game/traps.py` (triggers + all trap types), `game/settings.py`
|
||||
(tunables), `game/assets.py` (sprites + placeholders).
|
||||
@@ -15,8 +15,8 @@ from . import settings
|
||||
class AssetStore:
|
||||
def __init__(self, assets_dir):
|
||||
self.assets_dir = assets_dir
|
||||
self._raw = {} # name -> original loaded Surface (or None if missing)
|
||||
self._cache = {} # (name, w, h) -> scaled Surface
|
||||
self._raw = {} # name -> original loaded Surface (or None if missing)
|
||||
self._cache = {} # (name, w, h) -> scaled Surface
|
||||
self._font = None
|
||||
|
||||
def _font_for(self, h):
|
||||
|
||||
107
game/game.py
107
game/game.py
@@ -9,8 +9,8 @@ from .assets import AssetStore
|
||||
from .level import Level
|
||||
from .player import Player, InputState
|
||||
|
||||
HUD_H = 46 # height of the status bar above the play area
|
||||
MIN_W = 480 # keep the window wide enough for the HUD text
|
||||
HUD_H = 46 # height of the status bar above the play area
|
||||
MIN_W = 480 # keep the window wide enough for the HUD text
|
||||
|
||||
|
||||
class Game:
|
||||
@@ -18,7 +18,7 @@ class Game:
|
||||
pygame.init()
|
||||
pygame.display.set_caption(S.CAPTION)
|
||||
self.level_paths = level_paths
|
||||
self.force_debug = debug # --debug: turn debug view on for every level
|
||||
self.force_debug = debug # --debug: turn debug view on for every level
|
||||
self.assets = AssetStore(assets_dir)
|
||||
self.clock = pygame.time.Clock()
|
||||
self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True)
|
||||
@@ -26,12 +26,12 @@ class Game:
|
||||
|
||||
self.index = 0
|
||||
self.running = True
|
||||
self.state = "playing" # playing | dying | won_level | won_all
|
||||
self.death_timer = 0.0 # counts down during the "dying" pause
|
||||
self.death_rect = None # where to draw the corpse
|
||||
self.deaths = 0 # total deaths this session
|
||||
self.level_deaths = 0 # deaths on the current level (resets per level)
|
||||
self._pending_reload = False # F5: reload the level file on next respawn
|
||||
self.state = "playing" # playing | dying | won_level | won_all
|
||||
self.death_timer = 0.0 # counts down during the "dying" pause
|
||||
self.death_rect = None # where to draw the corpse
|
||||
self.deaths = 0 # total deaths this session
|
||||
self.level_deaths = 0 # deaths on the current level (resets per level)
|
||||
self._pending_reload = False # F5: reload the level file on next respawn
|
||||
|
||||
# Size the window ONCE to fit the largest level, then never resize it —
|
||||
# calling set_mode again mid-session recreates the window (it flickers /
|
||||
@@ -62,7 +62,7 @@ class Game:
|
||||
def _load_current(self):
|
||||
self.level = Level(self.level_paths[self.index])
|
||||
if self.force_debug:
|
||||
self.level.debug = True # CLI flag overrides the per-level setting
|
||||
self.level.debug = True # CLI flag overrides the per-level setting
|
||||
self.player = Player(self.level)
|
||||
self.battery = self.level.battery
|
||||
# In debug, grow the play surface by a margin and shift all drawing into
|
||||
@@ -78,7 +78,7 @@ class Game:
|
||||
self.state = "playing"
|
||||
self.death_timer = 0.0
|
||||
self.death_rect = None
|
||||
self.level_deaths = 0 # fresh count for the level we just loaded
|
||||
self.level_deaths = 0 # fresh count for the level we just loaded
|
||||
|
||||
def _start_death(self):
|
||||
# Begin the death pause: freeze everything, leave the corpse on screen.
|
||||
@@ -104,7 +104,7 @@ class Game:
|
||||
try:
|
||||
self._load_current()
|
||||
self.level_deaths = kept
|
||||
except Exception as ex: # bad edit -> don't crash
|
||||
except Exception as ex: # bad edit -> don't crash
|
||||
print(f"[reload] {self.level_paths[self.index]}: {ex}")
|
||||
self.player.respawn()
|
||||
self.level.reset()
|
||||
@@ -112,7 +112,7 @@ class Game:
|
||||
else:
|
||||
self.player.respawn()
|
||||
self.level.reset()
|
||||
self.battery = self.level.battery # phone gets plugged back in at start
|
||||
self.battery = self.level.battery # phone gets plugged back in at start
|
||||
self.state = "playing"
|
||||
|
||||
def _reload_level(self):
|
||||
@@ -129,7 +129,7 @@ class Game:
|
||||
self.state = "charging"
|
||||
self.charge_timer = 0.0
|
||||
tf = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
|
||||
self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start
|
||||
self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start
|
||||
self._final = self.index + 1 >= len(self.level_paths)
|
||||
|
||||
def _advance(self):
|
||||
@@ -157,8 +157,8 @@ class Game:
|
||||
if self.fade_timer < S.FADE_TIME:
|
||||
return
|
||||
if self.fade_phase == "out":
|
||||
self.fade_action() # swap levels while the screen is black
|
||||
self.state = "fading" # _load_current() flips to "playing"; undo it
|
||||
self.fade_action() # swap levels while the screen is black
|
||||
self.state = "fading" # _load_current() flips to "playing"; undo it
|
||||
self.fade_phase = "in"
|
||||
self.fade_timer = 0.0
|
||||
else:
|
||||
@@ -195,7 +195,7 @@ class Game:
|
||||
def run(self):
|
||||
while self.running:
|
||||
dt = self.clock.tick(S.FPS) / 1000.0
|
||||
dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes
|
||||
dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes
|
||||
inp = self._poll_events()
|
||||
|
||||
if self.state == "playing":
|
||||
@@ -224,8 +224,8 @@ class Game:
|
||||
# death conditions
|
||||
pr = self.player.rect
|
||||
died = self.battery <= 0
|
||||
died = died or pr.top > self.level.height + 160 # fell out of the world
|
||||
died = died or self.player.crushed # pinched by a moving block
|
||||
died = died or pr.top > self.level.height + 160 # fell out of the world
|
||||
died = died or self.player.crushed # pinched by a moving block
|
||||
if not died:
|
||||
for hz in self.level.hazard_rects():
|
||||
if pr.colliderect(hz):
|
||||
@@ -257,7 +257,9 @@ class Game:
|
||||
if self.state == "dying" and self.death_rect is not None:
|
||||
# The traps stay drawn in their moment-of-death state; the player is
|
||||
# replaced by a corpse sprite where they fell.
|
||||
sprite = self.assets.get("player_dead", self.death_rect.w, self.death_rect.h)
|
||||
sprite = self.assets.get(
|
||||
"player_dead", self.death_rect.w, self.death_rect.h
|
||||
)
|
||||
self.world.blit(sprite, self.death_rect.move(self.level.render_offset))
|
||||
else:
|
||||
self.player.draw(self.world, self.assets)
|
||||
@@ -278,13 +280,17 @@ class Game:
|
||||
|
||||
if self.state == "won_level":
|
||||
plural = "death" if self.level_deaths == 1 else "deaths"
|
||||
self._draw_win_splash("LEVEL COMPLETE — phone charged!",
|
||||
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level")
|
||||
self._draw_win_splash(
|
||||
"LEVEL COMPLETE — phone charged!",
|
||||
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level",
|
||||
)
|
||||
elif self.state == "won_all":
|
||||
self._draw_win_splash("YOU MADE IT! Phone fully charged.",
|
||||
f"All levels cleared with {self.deaths} deaths. ENTER to replay")
|
||||
self._draw_win_splash(
|
||||
"YOU MADE IT! Phone fully charged.",
|
||||
f"All levels cleared with {self.deaths} deaths. ENTER to replay",
|
||||
)
|
||||
|
||||
if self.state == "fading": # fade-in only; fade-out returned early above
|
||||
if self.state == "fading": # fade-in only; fade-out returned early above
|
||||
p = min(1.0, self.fade_timer / S.FADE_TIME)
|
||||
overlay = pygame.Surface(self.screen.get_size())
|
||||
overlay.fill((0, 0, 0))
|
||||
@@ -300,16 +306,22 @@ class Game:
|
||||
r = max(2, rect.h // 6)
|
||||
inset = max(2, rect.h // 10)
|
||||
pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r)
|
||||
pygame.draw.rect(surf, (28, 30, 40),
|
||||
rect.inflate(-inset, -inset), border_radius=r)
|
||||
pygame.draw.rect(
|
||||
surf, (28, 30, 40), rect.inflate(-inset, -inset), border_radius=r
|
||||
)
|
||||
fw = int((rect.w - inset * 2) * max(0.0, min(1.0, fill_frac)))
|
||||
if fill_frac > 0:
|
||||
fw = max(inset, fw)
|
||||
pygame.draw.rect(surf, col, (rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
|
||||
border_radius=r)
|
||||
pygame.draw.rect(
|
||||
surf,
|
||||
col,
|
||||
(rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
|
||||
border_radius=r,
|
||||
)
|
||||
nub_w, nub_h = max(3, rect.h // 4), rect.h // 2
|
||||
pygame.draw.rect(surf, (60, 60, 72),
|
||||
(rect.right, rect.centery - nub_h // 2, nub_w, nub_h))
|
||||
pygame.draw.rect(
|
||||
surf, (60, 60, 72), (rect.right, rect.centery - nub_h // 2, nub_w, nub_h)
|
||||
)
|
||||
|
||||
def _draw_hud(self, hide_battery=False):
|
||||
w = self.screen.get_width()
|
||||
@@ -322,17 +334,24 @@ class Game:
|
||||
bar_w, bar_h = 180, 20
|
||||
bx, by = 12, (HUD_H - bar_h) // 2
|
||||
if not hide_battery:
|
||||
time_frac = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
|
||||
time_frac = (
|
||||
max(0.0, self.battery / self.level.battery)
|
||||
if self.level.battery
|
||||
else 0.0
|
||||
)
|
||||
visual = time_frac * (self.level.battery_pct / 100.0)
|
||||
col = (240, 170, 60) if time_frac > 0.25 else (240, 80, 80)
|
||||
self._draw_battery(self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col)
|
||||
self._draw_battery(
|
||||
self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col
|
||||
)
|
||||
|
||||
secs = max(0.0, self.battery)
|
||||
label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD)
|
||||
self.screen.blit(label, (bx + bar_w + 16, by - 1))
|
||||
|
||||
deaths = self.hud_font.render(
|
||||
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD)
|
||||
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD
|
||||
)
|
||||
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
|
||||
|
||||
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
|
||||
@@ -355,8 +374,8 @@ class Game:
|
||||
it fills to 100%."""
|
||||
W, H = self.screen.get_size()
|
||||
t = min(1.0, self.charge_timer / S.CHARGE_TIME)
|
||||
move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40%
|
||||
fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80%
|
||||
move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40%
|
||||
fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80%
|
||||
fill = self.charge_from + (1.0 - self.charge_from) * fill_p
|
||||
|
||||
dim = pygame.Surface((W, H), pygame.SRCALPHA)
|
||||
@@ -367,9 +386,12 @@ class Game:
|
||||
home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2)
|
||||
target = self._hero_battery()
|
||||
pos = home.lerp(pygame.Vector2(target.center), move_p)
|
||||
rect = pygame.Rect(0, 0,
|
||||
round(bar_w + (target.w - bar_w) * move_p),
|
||||
round(bar_h + (target.h - bar_h) * move_p))
|
||||
rect = pygame.Rect(
|
||||
0,
|
||||
0,
|
||||
round(bar_w + (target.w - bar_w) * move_p),
|
||||
round(bar_h + (target.h - bar_h) * move_p),
|
||||
)
|
||||
rect.center = (round(pos.x), round(pos.y))
|
||||
self._draw_battery(self.screen, rect, fill, (90, 220, 120))
|
||||
|
||||
@@ -391,7 +413,10 @@ class Game:
|
||||
s = self.hud_font.render(subtitle, True, S.COLOR_HUD)
|
||||
self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34)))
|
||||
|
||||
|
||||
def discover_levels(levels_dir):
|
||||
paths = sorted(glob.glob(os.path.join(levels_dir, "*.yaml")) +
|
||||
glob.glob(os.path.join(levels_dir, "*.yml")))
|
||||
paths = sorted(
|
||||
glob.glob(os.path.join(levels_dir, "*.yaml"))
|
||||
+ glob.glob(os.path.join(levels_dir, "*.yml"))
|
||||
)
|
||||
return paths
|
||||
|
||||
@@ -39,8 +39,6 @@ class Level:
|
||||
|
||||
rows = data.get("map", "").splitlines()
|
||||
# Strip a leading blank line from block-scalar formatting, keep shape.
|
||||
rows = [r for r in rows if r.strip("\n") != "" or True]
|
||||
rows = [r.rstrip("\n") for r in rows]
|
||||
if rows and rows[0] == "":
|
||||
rows = rows[1:]
|
||||
|
||||
@@ -55,8 +53,8 @@ class Level:
|
||||
# by the Game). (0, 0) in normal play, so nothing moves.
|
||||
self.render_offset = (0, 0)
|
||||
|
||||
self.solids = [] # list[pygame.Rect] — full blocking
|
||||
self.oneways = [] # list[pygame.Rect] — blocking only from above
|
||||
self.solids = [] # list[pygame.Rect] — full blocking
|
||||
self.oneways = [] # list[pygame.Rect] — blocking only from above
|
||||
self.spawn = (self.tile, self.tile)
|
||||
self.goal_rect = pygame.Rect(self.width - self.tile, 0, self.tile, self.tile)
|
||||
|
||||
@@ -144,8 +142,10 @@ class Level:
|
||||
for rect in self.oneways:
|
||||
surface.blit(oneway_img, rect.move(ox, oy))
|
||||
|
||||
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h),
|
||||
self.goal_rect.move(ox, oy))
|
||||
surface.blit(
|
||||
assets.get("goal", self.goal_rect.w, self.goal_rect.h),
|
||||
self.goal_rect.move(ox, oy),
|
||||
)
|
||||
for tr in self.traps:
|
||||
tr.render(surface, assets)
|
||||
|
||||
@@ -173,10 +173,12 @@ class Level:
|
||||
# Shade the off-screen overscan so the real play area reads clearly.
|
||||
view = pygame.Rect(ox, oy, self.width, self.height)
|
||||
shade = (10, 12, 20, 130)
|
||||
for band in (pygame.Rect(0, 0, sw, oy), # above
|
||||
pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below
|
||||
pygame.Rect(0, oy, ox, self.height), # left
|
||||
pygame.Rect(view.right, oy, sw - view.right, self.height)): # right
|
||||
for band in (
|
||||
pygame.Rect(0, 0, sw, oy), # above
|
||||
pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below
|
||||
pygame.Rect(0, oy, ox, self.height), # left
|
||||
pygame.Rect(view.right, oy, sw - view.right, self.height),
|
||||
): # right
|
||||
if band.w > 0 and band.h > 0:
|
||||
overlay.fill(shade, band)
|
||||
|
||||
@@ -190,14 +192,16 @@ class Level:
|
||||
pygame.draw.line(overlay, line, (0, k * t), (sw, k * t))
|
||||
label = (150, 162, 190)
|
||||
for k in range(sw // t):
|
||||
overlay.blit(Level._grid_font.render(str(k - mx), True, label),
|
||||
(k * t + 2, 1))
|
||||
overlay.blit(
|
||||
Level._grid_font.render(str(k - mx), True, label), (k * t + 2, 1)
|
||||
)
|
||||
for k in range(sh // t):
|
||||
overlay.blit(Level._grid_font.render(str(k - my), True, label),
|
||||
(1, k * t + 1))
|
||||
overlay.blit(
|
||||
Level._grid_font.render(str(k - my), True, label), (1, k * t + 1)
|
||||
)
|
||||
|
||||
# Outline the actual runtime viewport (the level's true bounds).
|
||||
if (mx or my):
|
||||
if mx or my:
|
||||
pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2)
|
||||
|
||||
surface.blit(overlay, (0, 0))
|
||||
|
||||
@@ -16,8 +16,8 @@ class InputState:
|
||||
|
||||
def __init__(self):
|
||||
self.left = self.right = self.down = False
|
||||
self.jump_pressed = False # edge: pressed this frame
|
||||
self.jump_held = False # level: currently down
|
||||
self.jump_pressed = False # edge: pressed this frame
|
||||
self.jump_held = False # level: currently down
|
||||
|
||||
|
||||
class Player:
|
||||
@@ -42,7 +42,6 @@ class Player:
|
||||
self.drop_through_timer = 0.0
|
||||
self.was_jump_held = False
|
||||
self.crushed = False
|
||||
self.carry = (0.0, 0.0)
|
||||
self._sync_rect()
|
||||
|
||||
def _sync_rect(self):
|
||||
@@ -78,8 +77,8 @@ class Player:
|
||||
# jumping into the left side of a right-moving block) teleports us
|
||||
# clear across it. Minimal displacement keeps the shove-along and
|
||||
# shove-into-wall behaviours intact.
|
||||
pen_right = rect.right - p.left # displacement to exit rightward
|
||||
pen_left = p.right - rect.left # displacement to exit leftward
|
||||
pen_right = rect.right - p.left # displacement to exit rightward
|
||||
pen_left = p.right - rect.left # displacement to exit leftward
|
||||
if pen_right <= pen_left:
|
||||
p.left = rect.right
|
||||
else:
|
||||
@@ -118,11 +117,15 @@ class Player:
|
||||
pinned = any(p.colliderect(s) for s in solids)
|
||||
|
||||
for r, dx, dy in movers:
|
||||
if dy > 0 and bd and pinned and r.colliderect(up): # squished down onto floor
|
||||
if (
|
||||
dy > 0 and bd and pinned and r.colliderect(up)
|
||||
): # squished down onto floor
|
||||
return True
|
||||
if dy < 0 and bu and pinned and r.colliderect(down): # squished up into ceiling
|
||||
if (
|
||||
dy < 0 and bu and pinned and r.colliderect(down)
|
||||
): # squished up into ceiling
|
||||
return True
|
||||
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
|
||||
if dx > 0 and br and r.colliderect(left): # pushed right into a wall
|
||||
return True
|
||||
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
|
||||
return True
|
||||
@@ -137,15 +140,15 @@ class Player:
|
||||
|
||||
def _ride_platforms(self):
|
||||
# If standing on a moving platform, inherit its motion this frame.
|
||||
self.carry = (0.0, 0.0)
|
||||
for rect, dx, dy in self.level.carriers():
|
||||
if (abs(self.rect.bottom - rect.top) <= 3
|
||||
and self.rect.right > rect.left + 1
|
||||
and self.rect.left < rect.right - 1):
|
||||
if (
|
||||
abs(self.rect.bottom - rect.top) <= 3
|
||||
and self.rect.right > rect.left + 1
|
||||
and self.rect.left < rect.right - 1
|
||||
):
|
||||
self.fx += dx
|
||||
self.fy += dy
|
||||
self._sync_rect()
|
||||
self.carry = (dx, dy)
|
||||
break
|
||||
|
||||
def _horizontal(self, dt, inp):
|
||||
@@ -188,8 +191,11 @@ class Player:
|
||||
self.drop_through_timer = 0.12
|
||||
|
||||
# jump (buffered + coyote)
|
||||
if self.jump_buffer > 0 and (self.on_ground or self.coyote > 0) \
|
||||
and self.drop_through_timer <= 0:
|
||||
if (
|
||||
self.jump_buffer > 0
|
||||
and (self.on_ground or self.coyote > 0)
|
||||
and self.drop_through_timer <= 0
|
||||
):
|
||||
self.vy = -S.JUMP_SPEED
|
||||
self.on_ground = False
|
||||
self.coyote = 0.0
|
||||
@@ -225,11 +231,11 @@ class Player:
|
||||
# if the overlap is *more horizontal than vertical* — a block
|
||||
# sitting on top of us is a vertical collision; pushing us
|
||||
# sideways out from under it would dodge a crush.
|
||||
pen_left = self.rect.right - s.left # sank in from the left
|
||||
pen_right = s.right - self.rect.left # sank in from the right
|
||||
pen_left = self.rect.right - s.left # sank in from the left
|
||||
pen_right = s.right - self.rect.left # sank in from the right
|
||||
pen_y = min(self.rect.bottom - s.top, s.bottom - self.rect.top)
|
||||
if min(pen_left, pen_right) > pen_y:
|
||||
continue # let the Y pass handle it
|
||||
continue # let the Y pass handle it
|
||||
if pen_right <= pen_left:
|
||||
self.rect.left = s.right
|
||||
else:
|
||||
@@ -241,8 +247,8 @@ class Player:
|
||||
if self.rect.colliderect(s):
|
||||
# Resolve toward the nearer edge, not by velocity sign — so a
|
||||
# block descending onto us can't pop us out its top.
|
||||
overlap_top = self.rect.bottom - s.top # sank onto its top
|
||||
overlap_bottom = s.bottom - self.rect.top # rose into its underside
|
||||
overlap_top = self.rect.bottom - s.top # sank onto its top
|
||||
overlap_bottom = s.bottom - self.rect.top # rose into its underside
|
||||
if overlap_top <= overlap_bottom:
|
||||
self.rect.bottom = s.top
|
||||
self.on_ground = True
|
||||
@@ -266,5 +272,6 @@ class Player:
|
||||
# --- rendering -----------------------------------------------------------
|
||||
def draw(self, surface, assets):
|
||||
ox, oy = self.level.render_offset
|
||||
surface.blit(assets.get("player", self.rect.w, self.rect.h),
|
||||
self.rect.move(ox, oy))
|
||||
surface.blit(
|
||||
assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy)
|
||||
)
|
||||
|
||||
@@ -1,52 +1,51 @@
|
||||
"""Global tunables. Anything a level does not override falls back to these."""
|
||||
|
||||
# --- Display -----------------------------------------------------------------
|
||||
TILE = 32 # default tile size in pixels (levels may override)
|
||||
TILE = 32 # default tile size in pixels (levels may override)
|
||||
FPS = 60
|
||||
CAPTION = "Dying Phone"
|
||||
# Debug view only: how many extra tiles to reveal beyond the level on every side,
|
||||
# so off-screen geometry (e.g. an invisible catch-wall a step off the map) is
|
||||
# visible while designing. The true play area is outlined within this margin.
|
||||
DEBUG_VIEW_MARGIN = 1 # tiles of overscan shown around the level in debug
|
||||
DEBUG_VIEW_MARGIN = 1 # tiles of overscan shown around the level in debug
|
||||
|
||||
# --- Physics (pixels / second, unless noted) ---------------------------------
|
||||
GRAVITY = 2200.0 # downward acceleration
|
||||
MAX_FALL = 1400.0 # terminal velocity
|
||||
MOVE_SPEED = 320.0 # horizontal run speed
|
||||
ACCEL = 3200.0 # ground acceleration toward target speed
|
||||
AIR_ACCEL = 2200.0 # weaker control in the air
|
||||
FRICTION = 3600.0 # deceleration when no input on ground
|
||||
JUMP_SPEED = 760.0 # initial upward velocity of a jump
|
||||
JUMP_CUT = 0.45 # velocity retained when jump released early (variable height)
|
||||
COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump
|
||||
JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
|
||||
GRAVITY = 2200.0 # downward acceleration
|
||||
MAX_FALL = 1400.0 # terminal velocity
|
||||
MOVE_SPEED = 320.0 # horizontal run speed
|
||||
ACCEL = 3200.0 # ground acceleration toward target speed
|
||||
AIR_ACCEL = 2200.0 # weaker control in the air
|
||||
FRICTION = 3600.0 # deceleration when no input on ground
|
||||
JUMP_SPEED = 760.0 # initial upward velocity of a jump
|
||||
JUMP_CUT = 0.45 # velocity retained when jump released early (variable height)
|
||||
COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump
|
||||
JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
|
||||
|
||||
# --- Gameplay ----------------------------------------------------------------
|
||||
DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one
|
||||
DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one
|
||||
DEFAULT_BATTERY_PCT = 12.0 # how full the battery *looks* at the start (visual only;
|
||||
# the phone is dying, so the bar reads near-empty)
|
||||
DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn
|
||||
CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear
|
||||
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
|
||||
# the phone is dying, so the bar reads near-empty)
|
||||
DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn
|
||||
CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear
|
||||
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
|
||||
|
||||
# --- Colors (placeholder rendering) ------------------------------------------
|
||||
COLOR_BG = (24, 26, 38)
|
||||
COLOR_HUD = (235, 235, 245)
|
||||
COLOR_HUD_WARN = (240, 90, 90)
|
||||
|
||||
# Fallback colors + labels for placeholder rectangles, keyed by sprite name.
|
||||
PLACEHOLDERS = {
|
||||
"player": ((90, 200, 255), "P"),
|
||||
"player_dead": ((120, 40, 40), "X"),
|
||||
"block": ((110, 120, 140), ""),
|
||||
"goal": ((90, 230, 130), "GOAL"),
|
||||
"fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose
|
||||
"spike": ((230, 80, 80), "^"),
|
||||
"moving_block": ((150, 130, 90), ""),
|
||||
"patrol_block": ((130, 100, 170), ""),
|
||||
"player": ((90, 200, 255), "P"),
|
||||
"player_dead": ((120, 40, 40), "X"),
|
||||
"block": ((110, 120, 140), ""),
|
||||
"goal": ((90, 230, 130), "GOAL"),
|
||||
"fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose
|
||||
"spike": ((230, 80, 80), "^"),
|
||||
"moving_block": ((150, 130, 90), ""),
|
||||
"patrol_block": ((130, 100, 170), ""),
|
||||
"crumble_block": ((150, 120, 100), ""),
|
||||
"arrow_shooter": ((80, 80, 95), ""),
|
||||
"arrow": ((250, 220, 90), ">"),
|
||||
"spike_block": ((150, 156, 172), "*"),
|
||||
"phase_block": ((90, 180, 210), ""),
|
||||
"arrow": ((250, 220, 90), ">"),
|
||||
"spike_block": ((150, 156, 172), "*"),
|
||||
"phase_block": ((90, 180, 210), ""),
|
||||
}
|
||||
|
||||
147
game/traps.py
147
game/traps.py
@@ -15,12 +15,12 @@ Nothing else in the engine needs to change.
|
||||
|
||||
import pygame
|
||||
|
||||
from . import settings
|
||||
|
||||
|
||||
# --- helpers -----------------------------------------------------------------
|
||||
_DIRS = {
|
||||
"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0),
|
||||
"up": (0, -1),
|
||||
"down": (0, 1),
|
||||
"left": (-1, 0),
|
||||
"right": (1, 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ _DIRS = {
|
||||
# trigger: { timer: { interval: 1.2, up_time: 0.7 } }
|
||||
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
|
||||
|
||||
|
||||
class _Always:
|
||||
def evaluate(self, trap, game, dt):
|
||||
return True
|
||||
@@ -46,6 +47,7 @@ class _Always:
|
||||
|
||||
class _Within:
|
||||
"""Player within N tiles of the trap centre (Euclidean radius)."""
|
||||
|
||||
def __init__(self, n):
|
||||
self.n = float(n)
|
||||
|
||||
@@ -69,6 +71,7 @@ class _Directional:
|
||||
left/right (same rows) or *directly* above/below (same columns).
|
||||
inclusive: specify if the trap tile itself counts in the given direction.
|
||||
"""
|
||||
|
||||
def __init__(self, direction, rng, aligned, inclusive):
|
||||
self.dir = direction
|
||||
self.rng = None if rng is None else float(rng)
|
||||
@@ -113,6 +116,7 @@ class _Directional:
|
||||
|
||||
class _Timer:
|
||||
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
|
||||
|
||||
def __init__(self, interval, up_time):
|
||||
self.interval = float(interval)
|
||||
self.up_time = float(up_time)
|
||||
@@ -155,7 +159,9 @@ def make_condition(spec):
|
||||
if spec == "always":
|
||||
return _Always()
|
||||
if not isinstance(spec, dict):
|
||||
raise ValueError(f"trigger must be 'always' or a condition object, got {spec!r}")
|
||||
raise ValueError(
|
||||
f"trigger must be 'always' or a condition object, got {spec!r}"
|
||||
)
|
||||
if "all" in spec:
|
||||
return _All([make_condition(s) for s in spec["all"]])
|
||||
if "any" in spec:
|
||||
@@ -166,7 +172,12 @@ def make_condition(spec):
|
||||
if "within" in spec:
|
||||
return _Within(spec["within"])
|
||||
if "dir" in spec:
|
||||
return _Directional(spec["dir"], spec.get("range"), spec.get("aligned", False), spec.get("inclusive", False))
|
||||
return _Directional(
|
||||
spec["dir"],
|
||||
spec.get("range"),
|
||||
spec.get("aligned", False),
|
||||
spec.get("inclusive", False),
|
||||
)
|
||||
raise ValueError(f"unrecognized trigger condition: {spec!r}")
|
||||
|
||||
|
||||
@@ -178,7 +189,7 @@ class Trap:
|
||||
at = spec.get("at", [0, 0])
|
||||
self.col, self.row = int(at[0]), int(at[1])
|
||||
self.base_rect = level.cell_rect(self.col, self.row)
|
||||
self._mounted = False # set True on traps that ride another (a mount)
|
||||
self._mounted = False # set True on traps that ride another (a mount)
|
||||
# Any trap can be made invisible (still functional; revealed in debug).
|
||||
self.invisible = bool(spec.get("invisible", False))
|
||||
|
||||
@@ -274,13 +285,14 @@ class Trap:
|
||||
def _follow(self, parent):
|
||||
ox, oy = self._mount_off
|
||||
pr = parent.current_rect()
|
||||
self.base_rect = pygame.Rect(pr.x + ox, pr.y + oy,
|
||||
self.base_rect.w, self.base_rect.h)
|
||||
self.base_rect = pygame.Rect(
|
||||
pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h
|
||||
)
|
||||
|
||||
def tick(self, dt, game):
|
||||
self.update(dt, game)
|
||||
for c in self.mounts:
|
||||
c._follow(self) # reposition after we've moved this frame
|
||||
c._follow(self) # reposition after we've moved this frame
|
||||
c.tick(dt, game)
|
||||
|
||||
def render(self, surface, assets):
|
||||
@@ -354,6 +366,7 @@ class Spike(Trap):
|
||||
direction: which edge of the cell the spike sits on (up/down/left/right).
|
||||
See the trigger-condition docs at the top of this module.
|
||||
"""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.direction = spec.get("direction", "up")
|
||||
@@ -369,13 +382,13 @@ class Spike(Trap):
|
||||
t = self.tile
|
||||
r = self.base_rect
|
||||
dx, dy = _DIRS.get(self.direction, (0, -1))
|
||||
if dy == -1: # up: bottom half is ground, tip points up
|
||||
if dy == -1: # up: bottom half is ground, tip points up
|
||||
return pygame.Rect(r.x, r.y + t // 2, t, t // 2)
|
||||
if dy == 1: # down (ceiling spike)
|
||||
if dy == 1: # down (ceiling spike)
|
||||
return pygame.Rect(r.x, r.y, t, t // 2)
|
||||
if dx == -1: # left (from right wall pointing left)
|
||||
if dx == -1: # left (from right wall pointing left)
|
||||
return pygame.Rect(r.x + t // 2, r.y, t // 2, t)
|
||||
return pygame.Rect(r.x, r.y, t // 2, t) # right
|
||||
return pygame.Rect(r.x, r.y, t // 2, t) # right
|
||||
|
||||
# CCW rotation to point the (up-facing) sprite the right way.
|
||||
_ANGLE = {"up": 0, "left": 90, "down": 180, "right": -90}
|
||||
@@ -415,6 +428,7 @@ class Block(Trap):
|
||||
start extending and be clear for ``release`` seconds to start
|
||||
retracting — hysteresis that stops boundary jitter.
|
||||
"""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
t = self.tile
|
||||
@@ -429,15 +443,23 @@ class Block(Trap):
|
||||
self.speed = float(spec.get("speed", 140.0))
|
||||
self.mode = spec.get("mode", "once")
|
||||
self.deadly = bool(spec.get("deadly", False))
|
||||
self.fake = bool(spec.get("fake", False)) # looks solid, isn't
|
||||
self.fake = bool(spec.get("fake", False)) # looks solid, isn't
|
||||
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
|
||||
self.crumble_delay = float(spec.get("crumble_delay", 0.4))
|
||||
self.respawn = float(spec.get("respawn", 2.5))
|
||||
self.sprite = spec.get("sprite",
|
||||
"spike_block" if self.deadly else
|
||||
"fake_block" if self.fake else
|
||||
"crumble_block" if self.crumble else "moving_block")
|
||||
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
|
||||
self.sprite = spec.get(
|
||||
"sprite",
|
||||
(
|
||||
"spike_block"
|
||||
if self.deadly
|
||||
else (
|
||||
"fake_block"
|
||||
if self.fake
|
||||
else "crumble_block" if self.crumble else "moving_block"
|
||||
)
|
||||
),
|
||||
)
|
||||
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
|
||||
# `home` (default): sense the trigger from the resting cell, so the block
|
||||
# moving away can't toggle its own trigger (no jitter). `current`: sense
|
||||
# from the live position — for blocks the player rides (e.g. a dropper),
|
||||
@@ -463,8 +485,9 @@ class Block(Trap):
|
||||
# `home` sensing tracks the resting cell as it rides along the parent.
|
||||
ox, oy = self._origin
|
||||
offx, offy = self._mount_off
|
||||
self.base_rect = pygame.Rect(round(ox + offx), round(oy + offy),
|
||||
self.tile, self.tile)
|
||||
self.base_rect = pygame.Rect(
|
||||
round(ox + offx), round(oy + offy), self.tile, self.tile
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
self._reset_trigger()
|
||||
@@ -474,12 +497,12 @@ class Block(Trap):
|
||||
# LOCAL — the live rect is always _origin + (x, y).
|
||||
self._origin = (0.0, 0.0)
|
||||
self.prev = (self.x, self.y)
|
||||
self.dir = 1 # pingpong direction
|
||||
self.phase = "rest" # once mode: rest|extending|extended|retracting
|
||||
self.dir = 1 # pingpong direction
|
||||
self.phase = "rest" # once mode: rest|extending|extended|retracting
|
||||
self._release_t = 0.0
|
||||
# index of the waypoint we're AT (once) / heading toward (patrol)
|
||||
self.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0)
|
||||
self.cstate = "solid" # crumble: solid|crumbling|gone
|
||||
self.cstate = "solid" # crumble: solid|crumbling|gone
|
||||
self.ctimer = 0.0
|
||||
self.shake = 0.0
|
||||
self.emerge_kill = False
|
||||
@@ -491,7 +514,7 @@ class Block(Trap):
|
||||
# block executes its path/move relative to the parent it rides.
|
||||
if not self._mounted:
|
||||
self.prev = (self.x, self.y)
|
||||
active = self.triggered(game, dt) # call every frame to keep the timer live
|
||||
active = self.triggered(game, dt) # call every frame to keep the timer live
|
||||
if len(self.points) >= 2:
|
||||
step = self.speed * dt
|
||||
if self.mode == "once":
|
||||
@@ -505,8 +528,9 @@ class Block(Trap):
|
||||
self.emerge_kill = False
|
||||
r = self._rect()
|
||||
p = game.player.rect
|
||||
on_top = (abs(p.bottom - r.top) <= 4
|
||||
and p.right > r.left + 2 and p.left < r.right - 2)
|
||||
on_top = (
|
||||
abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2
|
||||
)
|
||||
if self.cstate == "solid":
|
||||
if on_top:
|
||||
self.cstate = "crumbling"
|
||||
@@ -590,8 +614,7 @@ class Block(Trap):
|
||||
|
||||
def _rect(self):
|
||||
ox, oy = self._origin
|
||||
return pygame.Rect(round(ox + self.x), round(oy + self.y),
|
||||
self.tile, self.tile)
|
||||
return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile)
|
||||
|
||||
def current_rect(self):
|
||||
return self._rect()
|
||||
@@ -654,6 +677,7 @@ class ArrowShooter(Trap):
|
||||
direction: up/down/left/right. speed: px/s. interval: seconds between shots.
|
||||
trigger: only fires while the condition holds (default ``always``).
|
||||
"""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.direction = spec.get("direction", "left")
|
||||
@@ -676,10 +700,14 @@ class ArrowShooter(Trap):
|
||||
rect = pygame.Rect(0, 0, w, h)
|
||||
rect.center = r.center
|
||||
# nudge the arrow to the emitting edge
|
||||
if dx == -1: rect.right = r.left
|
||||
elif dx == 1: rect.left = r.right
|
||||
elif dy == -1: rect.bottom = r.top
|
||||
elif dy == 1: rect.top = r.bottom
|
||||
if dx == -1:
|
||||
rect.right = r.left
|
||||
elif dx == 1:
|
||||
rect.left = r.right
|
||||
elif dy == -1:
|
||||
rect.bottom = r.top
|
||||
elif dy == 1:
|
||||
rect.top = r.bottom
|
||||
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
|
||||
|
||||
def update(self, dt, game):
|
||||
@@ -704,8 +732,9 @@ class ArrowShooter(Trap):
|
||||
return [a.rect for a in self.arrows]
|
||||
|
||||
def draw(self, surface, assets):
|
||||
surface.blit(assets.get("arrow_shooter", self.tile, self.tile),
|
||||
self._rt(self.base_rect))
|
||||
surface.blit(
|
||||
assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect)
|
||||
)
|
||||
for a in self.arrows:
|
||||
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect))
|
||||
|
||||
@@ -717,7 +746,8 @@ class Warp(Trap):
|
||||
destination and fades away, so the (otherwise invisible) teleport reads on
|
||||
screen. The level's ``debug`` flag tints it (and draws a line to its
|
||||
destination) while designing."""
|
||||
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
|
||||
|
||||
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
|
||||
|
||||
# Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn
|
||||
# largest-first so the bright core sits on top.
|
||||
@@ -735,8 +765,8 @@ class Warp(Trap):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self._armed = True # re-arms once the player has left the tile
|
||||
self._pulse = 0.0 # 1.0 at activation, fades to 0 over _PULSE_TIME
|
||||
self._armed = True # re-arms once the player has left the tile
|
||||
self._pulse = 0.0 # 1.0 at activation, fades to 0 over _PULSE_TIME
|
||||
|
||||
def update(self, dt, game):
|
||||
if self._pulse > 0.0:
|
||||
@@ -749,7 +779,7 @@ class Warp(Trap):
|
||||
p.vx = p.vy = 0.0
|
||||
p._sync_rect()
|
||||
self._armed = False
|
||||
self._pulse = 1.0 # flash at both ends this frame, then fade
|
||||
self._pulse = 1.0 # flash at both ends this frame, then fade
|
||||
elif not inside:
|
||||
self._armed = True
|
||||
|
||||
@@ -763,8 +793,9 @@ class Warp(Trap):
|
||||
c.render(surface, assets)
|
||||
|
||||
def _dest_rect(self):
|
||||
return pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile,
|
||||
self.tile, self.tile)
|
||||
return pygame.Rect(
|
||||
self.dest[0] * self.tile, self.dest[1] * self.tile, self.tile, self.tile
|
||||
)
|
||||
|
||||
def _draw_aura(self, surface, rect):
|
||||
# An expanding, fading glow centred on the cell. As the pulse decays the
|
||||
@@ -788,8 +819,13 @@ class Warp(Trap):
|
||||
if self.level.debug:
|
||||
self._debug_tint(surface, (210, 80, 235), alpha=90)
|
||||
dest = self._dest_rect()
|
||||
pygame.draw.line(surface, (210, 80, 235),
|
||||
self._rt(self.base_rect).center, self._rt(dest).center, 1)
|
||||
pygame.draw.line(
|
||||
surface,
|
||||
(210, 80, 235),
|
||||
self._rt(self.base_rect).center,
|
||||
self._rt(dest).center,
|
||||
1,
|
||||
)
|
||||
self._debug_tint(surface, (210, 80, 235), dest, 45)
|
||||
|
||||
|
||||
@@ -799,6 +835,7 @@ class PhaseBlock(Trap):
|
||||
a solid obstacle over ``fade`` seconds (and fades back out when the trigger
|
||||
releases). If the player is standing in the cell the instant it *starts*
|
||||
appearing, they're killed."""
|
||||
|
||||
def __init__(self, spec, level):
|
||||
super().__init__(spec, level)
|
||||
self.fade = float(spec.get("fade", 0.3))
|
||||
@@ -814,14 +851,17 @@ class PhaseBlock(Trap):
|
||||
# How deep the player may be into the cell and still be nudged clear rather
|
||||
# than killed. A shallow clip (feet/shoulder in the cell) gets shoved out;
|
||||
# forming through their middle stays lethal.
|
||||
_EDGE_GRACE = 0.5 # fraction of a tile
|
||||
_EDGE_GRACE = 0.5 # fraction of a tile
|
||||
|
||||
def update(self, dt, game):
|
||||
self.emerge_kill = False
|
||||
active = self.triggered(game, dt)
|
||||
if active:
|
||||
if self.alpha == 0.0 and not self.solid \
|
||||
and game.player.rect.colliderect(self.base_rect):
|
||||
if (
|
||||
self.alpha == 0.0
|
||||
and not self.solid
|
||||
and game.player.rect.colliderect(self.base_rect)
|
||||
):
|
||||
# Forming into the player. If they're only clipping an edge, shove
|
||||
# them clear and let the block solidify behind them; only if it's
|
||||
# forming through their middle — or the shove would squish them
|
||||
@@ -847,15 +887,15 @@ class PhaseBlock(Trap):
|
||||
b = self.base_rect
|
||||
# Distance to move the player to clear the block on each side.
|
||||
outs = {
|
||||
"up": p.bottom - b.top,
|
||||
"down": b.bottom - p.top,
|
||||
"left": p.right - b.left,
|
||||
"up": p.bottom - b.top,
|
||||
"down": b.bottom - p.top,
|
||||
"left": p.right - b.left,
|
||||
"right": b.right - p.left,
|
||||
}
|
||||
side = min(outs, key=outs.get)
|
||||
dist = outs[side]
|
||||
if dist > self.tile * self._EDGE_GRACE:
|
||||
return False # deep overlap — forming through them
|
||||
return False # deep overlap — forming through them
|
||||
dx, dy = _DIRS[side]
|
||||
moved = p.move(dx * dist, dy * dist)
|
||||
# The block isn't solid yet, so it's absent from solid_rects(); any hit
|
||||
@@ -891,8 +931,9 @@ class PhaseBlock(Trap):
|
||||
self._debug_tint(surface, (120, 210, 240), alpha=45)
|
||||
return
|
||||
img = assets.get("phase_block", self.tile, self.tile).copy()
|
||||
img.fill((255, 255, 255, int(255 * self.alpha)),
|
||||
special_flags=pygame.BLEND_RGBA_MULT)
|
||||
img.fill(
|
||||
(255, 255, 255, int(255 * self.alpha)), special_flags=pygame.BLEND_RGBA_MULT
|
||||
)
|
||||
surface.blit(img, self._rt(self.base_rect))
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=7.0
|
||||
black
|
||||
|
||||
@@ -22,7 +22,7 @@ DT = 1 / 60.0
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _pygame():
|
||||
pygame.init()
|
||||
pygame.display.set_mode((64, 64)) # a display so convert_alpha() works
|
||||
pygame.display.set_mode((64, 64)) # a display so convert_alpha() works
|
||||
yield
|
||||
pygame.quit()
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ def test_stationary_is_solid():
|
||||
def test_deadly_is_hazard_not_solid():
|
||||
b = block(at=[3, 3], deadly=True)
|
||||
assert b.solid_rects() == []
|
||||
assert b.hazard_rects() # non-empty
|
||||
assert b.hazard_rects() # non-empty
|
||||
|
||||
|
||||
def test_fake_is_drawn_but_not_solid():
|
||||
b = block(at=[3, 3], fake=True)
|
||||
assert b.solid_rects() == [] # you fall through
|
||||
assert b.solid_rects() == [] # you fall through
|
||||
assert b.sprite == "fake_block" # looks like a real block
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ traps:
|
||||
if g.deaths > d0:
|
||||
killed = True
|
||||
break
|
||||
assert "gone" in states # it crumbled away
|
||||
assert killed # re-formed onto the standing player
|
||||
assert "gone" in states # it crumbled away
|
||||
assert killed # re-formed onto the standing player
|
||||
|
||||
|
||||
def test_moving_block_that_crumbles(make_game):
|
||||
@@ -78,7 +78,7 @@ traps:
|
||||
respawn: 1.0
|
||||
""")
|
||||
b = g.level.traps[0]
|
||||
place(g, 2, 1) # ride the platform
|
||||
place(g, 2, 1) # ride the platform
|
||||
moved = crumbled = False
|
||||
start = b.x
|
||||
for _ in range(200):
|
||||
@@ -103,18 +103,23 @@ def test_patrol_loop_visits_all_waypoints():
|
||||
def test_slider_once_no_jitter_and_retracts():
|
||||
# sense=home (default): a slider moving away can't toggle its own trigger.
|
||||
b = block(at=[16, 6], move=[-3, 0], speed=260, trigger={"within": 3})
|
||||
g = FakeGame(pygame.Rect(17 * 32, 6 * 32, 23, 29)) # player parked to the right
|
||||
g = FakeGame(pygame.Rect(17 * 32, 6 * 32, 23, 29)) # player parked to the right
|
||||
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
|
||||
assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced
|
||||
g.player.rect.x = 30 * 32 # leave
|
||||
assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced
|
||||
g.player.rect.x = 30 * 32 # leave
|
||||
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
|
||||
assert reversals(xs) == 0 and abs(xs[-1] - 16 * 32) < 2 # retracted to base
|
||||
assert reversals(xs) == 0 and abs(xs[-1] - 16 * 32) < 2 # retracted to base
|
||||
|
||||
|
||||
def test_sense_current_rides_down():
|
||||
# A dropper (sense=current) commits to the bottom and holds while ridden.
|
||||
b = block(at=[21, 9], move=[0, 4], speed=220, sense="current",
|
||||
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]})
|
||||
b = block(
|
||||
at=[21, 9],
|
||||
move=[0, 4],
|
||||
speed=220,
|
||||
sense="current",
|
||||
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]},
|
||||
)
|
||||
# player standing on top of it
|
||||
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
|
||||
g = FakeGame(p)
|
||||
@@ -124,15 +129,15 @@ def test_sense_current_rides_down():
|
||||
# keep the player riding the block top
|
||||
p.bottom = b._rect().top
|
||||
ys.append(b.y)
|
||||
assert reversals(ys) == 0 and abs(ys[-1] - 13 * 32) < 2 # dropped fully, no bob
|
||||
assert reversals(ys) == 0 and abs(ys[-1] - 13 * 32) < 2 # dropped fully, no bob
|
||||
|
||||
|
||||
def test_carriers_reports_motion():
|
||||
b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120)
|
||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||
b.update(1 / 60, g)
|
||||
(rect, dx, dy), = b.carriers()
|
||||
assert dx != 0 and dy == 0 # moving horizontally
|
||||
((rect, dx, dy),) = b.carriers()
|
||||
assert dx != 0 and dy == 0 # moving horizontally
|
||||
|
||||
|
||||
# --- array expansion (count / spacing) --------------------------------------
|
||||
@@ -147,8 +152,12 @@ def test_expand_line():
|
||||
|
||||
|
||||
def test_expand_grid_with_spacing():
|
||||
ats = [s["at"] for s in expand_spec(
|
||||
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]})]
|
||||
ats = [
|
||||
s["at"]
|
||||
for s in expand_spec(
|
||||
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]}
|
||||
)
|
||||
]
|
||||
assert ats == [[0, 0], [2, 0], [4, 0], [0, 3], [2, 3], [4, 3]]
|
||||
# count/spacing are stripped from each expanded spec
|
||||
for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}):
|
||||
@@ -173,4 +182,9 @@ traps:
|
||||
blocks = [t for t in lvl.traps if isinstance(t, Block)]
|
||||
assert len(blocks) == 4
|
||||
assert all(b.deadly for b in blocks)
|
||||
assert sorted(b.current_rect().x for b in blocks) == [2 * 32, 3 * 32, 4 * 32, 5 * 32]
|
||||
assert sorted(b.current_rect().x for b in blocks) == [
|
||||
2 * 32,
|
||||
3 * 32,
|
||||
4 * 32,
|
||||
5 * 32,
|
||||
]
|
||||
|
||||
@@ -100,7 +100,7 @@ traps:
|
||||
mode: pingpong
|
||||
speed: 160
|
||||
""")
|
||||
place(g, 4, 3) # on the floor, at the block's body level
|
||||
place(g, 4, 3) # on the floor, at the block's body level
|
||||
floor_top = 4 * 32
|
||||
start_x = g.player.rect.x
|
||||
max_bottom = g.player.rect.bottom
|
||||
@@ -109,8 +109,8 @@ traps:
|
||||
max_bottom = max(max_bottom, g.player.rect.bottom)
|
||||
if g.state != "playing":
|
||||
break
|
||||
assert max_bottom <= floor_top + 1 # never pushed into the floor
|
||||
assert g.player.rect.x < start_x # got shoved along
|
||||
assert max_bottom <= floor_top + 1 # never pushed into the floor
|
||||
assert g.player.rect.x < start_x # got shoved along
|
||||
|
||||
|
||||
def test_ride_into_wall_stops_no_crush(make_game):
|
||||
@@ -137,7 +137,7 @@ traps:
|
||||
""")
|
||||
# stand the player on top of the platform at its left end (body in row 2)
|
||||
place(g, 1, 2)
|
||||
g.player.fy = float(3 * 32 - g.player.h) # feet on the block's top
|
||||
g.player.fy = float(3 * 32 - g.player.h) # feet on the block's top
|
||||
g.player._sync_rect()
|
||||
wall_left = 8 * 32
|
||||
hit_wall = False
|
||||
@@ -145,12 +145,12 @@ traps:
|
||||
step(g)
|
||||
if g.state != "playing":
|
||||
break
|
||||
assert not g.player.crushed # never crushed
|
||||
assert g.player.rect.right <= wall_left + 1 # stopped at the wall
|
||||
assert not g.player.crushed # never crushed
|
||||
assert g.player.rect.right <= wall_left + 1 # stopped at the wall
|
||||
if g.player.rect.right >= wall_left - 1:
|
||||
hit_wall = True
|
||||
assert g.state == "playing" # survived the whole time
|
||||
assert hit_wall # actually reached the wall
|
||||
assert g.state == "playing" # survived the whole time
|
||||
assert hit_wall # actually reached the wall
|
||||
|
||||
|
||||
def test_corner_clip_does_not_warp(make_game):
|
||||
@@ -177,7 +177,7 @@ traps:
|
||||
prev = g.player.rect.x
|
||||
for _ in range(120):
|
||||
step(g, hold(left=True))
|
||||
assert g.player.rect.x - prev <= 28 # no sudden rightward warp
|
||||
assert g.player.rect.x - prev <= 28 # no sudden rightward warp
|
||||
prev = g.player.rect.x
|
||||
|
||||
|
||||
@@ -213,4 +213,4 @@ traps:
|
||||
if not g.player.on_ground and g.player.rect.bottom > 3 * 32:
|
||||
left_at = reversalsN
|
||||
break
|
||||
assert left_at == 0 # fell before any block reversal
|
||||
assert left_at == 0 # fell before any block reversal
|
||||
|
||||
@@ -5,7 +5,6 @@ from conftest import step, run, place, hold, DT
|
||||
from game import settings as S
|
||||
from game.player import InputState
|
||||
|
||||
|
||||
SIMPLE = """
|
||||
name: My Level
|
||||
tile_size: 32
|
||||
@@ -23,7 +22,7 @@ def test_level_parse(make_level):
|
||||
assert lvl.width == 5 * 32 and lvl.height == 3 * 32
|
||||
assert lvl.spawn == (1 * 32, 1 * 32)
|
||||
assert lvl.goal_rect.topleft == (3 * 32, 1 * 32)
|
||||
assert len(lvl.solids) == 5 + 5 + 2 # top row + bottom row + side walls
|
||||
assert len(lvl.solids) == 5 + 5 + 2 # top row + bottom row + side walls
|
||||
|
||||
|
||||
def test_unknown_trap_type_is_skipped(make_level):
|
||||
@@ -38,7 +37,7 @@ traps:
|
||||
- type: not_a_real_trap
|
||||
at: [1, 1]
|
||||
""")
|
||||
assert lvl.traps == [] # skipped, no crash
|
||||
assert lvl.traps == [] # skipped, no crash
|
||||
|
||||
|
||||
def test_death_pause_then_respawn(make_game):
|
||||
@@ -62,6 +61,7 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
|
||||
a.write_text(SIMPLE)
|
||||
b.write_text(SIMPLE.replace("My Level", "Two"))
|
||||
from game.game import Game
|
||||
|
||||
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||
|
||||
def die():
|
||||
@@ -69,24 +69,26 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
|
||||
while g.state == "dying":
|
||||
step(g)
|
||||
|
||||
die(); die(); die()
|
||||
die()
|
||||
die()
|
||||
die()
|
||||
assert g.level_deaths == 3 and g.deaths == 3
|
||||
g._advance()
|
||||
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
|
||||
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
|
||||
die()
|
||||
assert g.level_deaths == 1 and g.deaths == 4
|
||||
g._replay()
|
||||
assert g.index == 0 and g.deaths == 0 # replay resets the total
|
||||
assert g.index == 0 and g.deaths == 0 # replay resets the total
|
||||
|
||||
|
||||
def test_reach_goal_charges_then_wins(make_game):
|
||||
g = make_game(SIMPLE)
|
||||
g._reach_goal()
|
||||
assert g.state == "charging"
|
||||
assert 0 <= g.charge_from < 0.2 # near-empty start
|
||||
assert 0 <= g.charge_from < 0.2 # near-empty start
|
||||
for _ in range(70):
|
||||
step(g)
|
||||
assert g.state == "won_all" # single level -> won_all
|
||||
assert g.state == "won_all" # single level -> won_all
|
||||
|
||||
|
||||
def test_fade_swaps_level_at_black(make_game, tmp_path):
|
||||
@@ -95,6 +97,7 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
|
||||
a.write_text(SIMPLE)
|
||||
b.write_text(SIMPLE.replace("My Level", "Two"))
|
||||
from game.game import Game
|
||||
|
||||
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||
g.state = "won_level"
|
||||
g._start_fade(g._advance)
|
||||
@@ -110,32 +113,45 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
|
||||
done = i
|
||||
break
|
||||
assert swapped and done and g.index == 1
|
||||
assert done - swapped >= 15 # fade-in ~0.3s not skipped
|
||||
assert done - swapped >= 15 # fade-in ~0.3s not skipped
|
||||
|
||||
|
||||
def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch):
|
||||
a = tmp_path / "a.yaml"
|
||||
b = tmp_path / "b.yaml"
|
||||
a.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") # 3 rows
|
||||
b.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n") # 4 rows
|
||||
a.write_text(
|
||||
"name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
|
||||
) # 3 rows
|
||||
b.write_text(
|
||||
"name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n"
|
||||
) # 4 rows
|
||||
from game.game import Game
|
||||
|
||||
calls = []
|
||||
orig = pygame.display.set_mode
|
||||
monkeypatch.setattr(pygame.display, "set_mode",
|
||||
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k))
|
||||
monkeypatch.setattr(
|
||||
pygame.display,
|
||||
"set_mode",
|
||||
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k),
|
||||
)
|
||||
g = Game([str(a), str(b)], str(tmp_path / "noassets"))
|
||||
g._advance(); g._replay()
|
||||
assert len(calls) == 1 # never recreated the window
|
||||
g._advance()
|
||||
g._replay()
|
||||
assert len(calls) == 1 # never recreated the window
|
||||
# sized to the tallest level (4 rows + HUD)
|
||||
from game.game import HUD_H
|
||||
|
||||
assert g.win_h == 4 * 32 + HUD_H
|
||||
|
||||
|
||||
def test_debug_view_adds_overscan_margin(tmp_path):
|
||||
from game.game import Game, HUD_H
|
||||
from game import settings as S
|
||||
|
||||
p = tmp_path / "d.yaml"
|
||||
p.write_text("name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #P.G#\n #####\n")
|
||||
p.write_text(
|
||||
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #P.G#\n #####\n"
|
||||
)
|
||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||
m = S.DEBUG_VIEW_MARGIN * 32
|
||||
assert m > 0
|
||||
@@ -148,6 +164,7 @@ def test_debug_view_adds_overscan_margin(tmp_path):
|
||||
|
||||
def test_no_overscan_without_debug(tmp_path):
|
||||
from game.game import Game
|
||||
|
||||
p = tmp_path / "n.yaml"
|
||||
p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||
@@ -169,39 +186,46 @@ def test_cli_debug_forces_all_levels(make_game):
|
||||
|
||||
def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
|
||||
from game.game import Game
|
||||
|
||||
p = tmp_path / "hot.yaml"
|
||||
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||
assert g.level.name == "A" and len(g.level.traps) == 0
|
||||
d0 = g.deaths
|
||||
# edit the file on disk, then F5
|
||||
p.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
|
||||
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n")
|
||||
p.write_text(
|
||||
"name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
|
||||
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n"
|
||||
)
|
||||
g._reload_level()
|
||||
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
|
||||
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
|
||||
while g.state == "dying":
|
||||
step(g)
|
||||
assert g.level.name == "B" and len(g.level.traps) == 1 # picked up the edit
|
||||
assert g.level_deaths == 1 # counted, not reset to 0
|
||||
assert g.level.name == "B" and len(g.level.traps) == 1 # picked up the edit
|
||||
assert g.level_deaths == 1 # counted, not reset to 0
|
||||
|
||||
|
||||
def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
|
||||
from game.game import Game
|
||||
|
||||
p = tmp_path / "bad.yaml"
|
||||
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
|
||||
g = Game([str(p)], str(tmp_path / "noassets"))
|
||||
p.write_text("name: A\n bad: [unclosed\n") # invalid YAML
|
||||
p.write_text("name: A\n bad: [unclosed\n") # invalid YAML
|
||||
g._reload_level()
|
||||
while g.state == "dying":
|
||||
step(g)
|
||||
assert g.state == "playing" # fell back, no crash
|
||||
assert g.level.name == "A" # kept the old level
|
||||
assert g.state == "playing" # fell back, no crash
|
||||
assert g.level.name == "A" # kept the old level
|
||||
|
||||
|
||||
def test_debug_grid_adds_pixels(make_level):
|
||||
import pygame
|
||||
from game.assets import AssetStore
|
||||
lvl = make_level("name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n")
|
||||
|
||||
lvl = make_level(
|
||||
"name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n"
|
||||
)
|
||||
a = AssetStore("noassets")
|
||||
|
||||
def painted(dbg):
|
||||
@@ -209,6 +233,11 @@ def test_debug_grid_adds_pixels(make_level):
|
||||
w = pygame.Surface((lvl.width, lvl.height))
|
||||
w.fill((0, 0, 0))
|
||||
lvl.draw(w, a)
|
||||
return sum(1 for y in range(lvl.height) for x in range(lvl.width)
|
||||
if w.get_at((x, y))[:3] != (0, 0, 0))
|
||||
assert painted(True) > painted(False) # grid + labels add ink
|
||||
return sum(
|
||||
1
|
||||
for y in range(lvl.height)
|
||||
for x in range(lvl.width)
|
||||
if w.get_at((x, y))[:3] != (0, 0, 0)
|
||||
)
|
||||
|
||||
assert painted(True) > painted(False) # grid + labels add ink
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from conftest import step, DT
|
||||
from game.traps import Block, Spike
|
||||
|
||||
|
||||
PLATFORM_WITH_SPIKE = """
|
||||
name: t
|
||||
tile_size: 32
|
||||
@@ -74,8 +73,10 @@ traps:
|
||||
for m in plat.mounts:
|
||||
assert m._mounted and m.deadly
|
||||
# each deadly mount tracks the platform at its offset and is lethal
|
||||
exp = (plat.current_rect().x + m._mount_off[0],
|
||||
plat.current_rect().y + m._mount_off[1])
|
||||
exp = (
|
||||
plat.current_rect().x + m._mount_off[0],
|
||||
plat.current_rect().y + m._mount_off[1],
|
||||
)
|
||||
assert (m.current_rect().x, m.current_rect().y) == exp
|
||||
assert m.hazard_rects()
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ map: |
|
||||
|
||||
def test_falls_and_lands_on_floor(make_game):
|
||||
g = make_game(FLAT)
|
||||
place(g, 4, 1) # up in the air
|
||||
place(g, 4, 1) # up in the air
|
||||
run(g, 60)
|
||||
assert g.player.on_ground
|
||||
assert g.player.rect.bottom == 5 * 32 # floor is row 5's top (y=160)
|
||||
assert g.player.rect.bottom == 5 * 32 # floor is row 5's top (y=160)
|
||||
|
||||
|
||||
def test_terminal_velocity(make_game):
|
||||
@@ -34,13 +34,13 @@ def test_terminal_velocity(make_game):
|
||||
|
||||
def test_jump_gains_height_then_returns(make_game):
|
||||
g = make_game(FLAT)
|
||||
run(g, 30) # settle on floor
|
||||
run(g, 30) # settle on floor
|
||||
ground = g.player.rect.bottom
|
||||
peak = ground
|
||||
for i in range(60):
|
||||
step(g, hold(jump_pressed=(i == 0), jump_held=True))
|
||||
peak = min(peak, g.player.rect.bottom)
|
||||
assert peak < ground - 32 # rose at least a tile
|
||||
assert peak < ground - 32 # rose at least a tile
|
||||
|
||||
|
||||
def test_variable_jump_height(make_game):
|
||||
@@ -55,6 +55,7 @@ def test_variable_jump_height(make_game):
|
||||
step(g, hold(jump_pressed=(i == 0), jump_held=held))
|
||||
hi = min(hi, g.player.rect.bottom)
|
||||
return ground - hi
|
||||
|
||||
assert peak(60) > peak(1) + 8
|
||||
|
||||
|
||||
@@ -71,22 +72,22 @@ map: |
|
||||
#P#...#
|
||||
#######
|
||||
""")
|
||||
place(g, 1, 3) # standing on the little step at [1,3] top
|
||||
place(g, 1, 3) # standing on the little step at [1,3] top
|
||||
# walk right off the step for a couple frames, then jump
|
||||
run(g, 4, lambda i: hold(right=True))
|
||||
y_before = g.player.rect.bottom
|
||||
step(g, hold(right=True, jump_pressed=True, jump_held=True))
|
||||
step(g, hold(right=True, jump_held=True))
|
||||
assert g.player.vy < 0 # a jump actually started
|
||||
assert g.player.vy < 0 # a jump actually started
|
||||
|
||||
|
||||
def test_walls_stop_horizontal_movement(make_game):
|
||||
g = make_game(FLAT)
|
||||
place(g, 1, 4)
|
||||
run(g, 120, lambda i: hold(right=True))
|
||||
assert g.player.rect.right <= 9 * 32 # right wall inner edge (x=288)
|
||||
assert g.player.rect.right <= 9 * 32 # right wall inner edge (x=288)
|
||||
run(g, 120, lambda i: hold(left=True))
|
||||
assert g.player.rect.left >= 1 * 32 # left wall inner edge (x=32)
|
||||
assert g.player.rect.left >= 1 * 32 # left wall inner edge (x=32)
|
||||
|
||||
|
||||
def test_oneway_platform_land_from_above_pass_from_below(make_game):
|
||||
@@ -103,7 +104,7 @@ map: |
|
||||
#######
|
||||
""")
|
||||
# Fall onto the one-way from above -> lands on it.
|
||||
place(g, 1, 1) # open air above the one-way (row 3)
|
||||
place(g, 1, 1) # open air above the one-way (row 3)
|
||||
run(g, 60)
|
||||
assert g.player.on_ground and g.player.rect.bottom == 3 * 32
|
||||
|
||||
@@ -119,12 +120,12 @@ map: |
|
||||
#P...G#
|
||||
#######
|
||||
""")
|
||||
place(g2, 1, 4) # on the floor, below the one-way (row 2)
|
||||
run(g2, 5) # settle so on_ground is set before jumping
|
||||
place(g2, 1, 4) # on the floor, below the one-way (row 2)
|
||||
run(g2, 5) # settle so on_ground is set before jumping
|
||||
passed = False
|
||||
for i in range(40):
|
||||
step(g2, hold(jump_pressed=(i == 0), jump_held=(i < 12)))
|
||||
if g2.player.rect.top < 2 * 32: # rose above the one-way row
|
||||
if g2.player.rect.top < 2 * 32: # rose above the one-way row
|
||||
passed = True
|
||||
assert passed
|
||||
|
||||
@@ -142,10 +143,10 @@ map: |
|
||||
#....G#
|
||||
#######
|
||||
""")
|
||||
place(g, 1, 2) # standing on the one-way at row 3
|
||||
place(g, 1, 2) # standing on the one-way at row 3
|
||||
run(g, 20)
|
||||
assert g.player.on_ground
|
||||
# press down + jump to drop through
|
||||
for i in range(30):
|
||||
step(g, hold(down=True, jump_pressed=(i == 0)))
|
||||
assert g.player.rect.top > 3 * 32 # fell below the one-way
|
||||
assert g.player.rect.top > 3 * 32 # fell below the one-way
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import hashlib
|
||||
import pygame
|
||||
from conftest import (FakeGame, step, run, place, hold, DT)
|
||||
from conftest import FakeGame, step, run, place, hold, DT
|
||||
from game.assets import AssetStore
|
||||
from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
|
||||
|
||||
@@ -51,7 +51,7 @@ traps:
|
||||
def test_spike_sprite_rotates_per_direction(tmp_path):
|
||||
# A deliberately asymmetric sprite so each rotation is distinct.
|
||||
surf = pygame.Surface((8, 8), pygame.SRCALPHA)
|
||||
surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only
|
||||
surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only
|
||||
adir = tmp_path / "assets"
|
||||
adir.mkdir()
|
||||
pygame.image.save(surf, str(adir / "spike.png"))
|
||||
@@ -62,7 +62,8 @@ def test_spike_sprite_rotates_per_direction(tmp_path):
|
||||
|
||||
def h(s):
|
||||
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
|
||||
assert h(up) != h(down) # rotation actually happened
|
||||
|
||||
assert h(up) != h(down) # rotation actually happened
|
||||
assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
|
||||
|
||||
|
||||
@@ -85,10 +86,10 @@ traps:
|
||||
""")
|
||||
sh = lvl.traps[0]
|
||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||
for _ in range(40): # ~0.66s -> at least one shot
|
||||
for _ in range(40): # ~0.66s -> at least one shot
|
||||
sh.update(DT, g)
|
||||
assert sh.arrows # spawned arrows
|
||||
assert sh.hazard_rects() # arrows are hazards
|
||||
assert sh.arrows # spawned arrows
|
||||
assert sh.hazard_rects() # arrows are hazards
|
||||
|
||||
|
||||
def test_arrow_shooter_trigger_gates_firing(make_level):
|
||||
@@ -108,10 +109,10 @@ traps:
|
||||
trigger: { within: 1 }
|
||||
""")
|
||||
sh = lvl.traps[0]
|
||||
far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near
|
||||
far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near
|
||||
for _ in range(60):
|
||||
sh.update(DT, far)
|
||||
assert not sh.arrows # never fired while player out of range
|
||||
assert not sh.arrows # never fired while player out of range
|
||||
|
||||
|
||||
# --- invisible wall, now an array of invisible blocks ------------------------
|
||||
@@ -132,10 +133,10 @@ traps:
|
||||
count: [1, 2]
|
||||
""")
|
||||
blocks = [t for t in lvl.traps if isinstance(t, Block)]
|
||||
assert len(blocks) == 2 # one per cell
|
||||
assert len(blocks) == 2 # one per cell
|
||||
assert all(b.invisible and b.solid_rects() for b in blocks)
|
||||
ys = sorted(b.current_rect().top for b in blocks)
|
||||
assert ys == [1 * 32, 2 * 32] # stacked vertically
|
||||
assert ys == [1 * 32, 2 * 32] # stacked vertically
|
||||
|
||||
|
||||
# --- warp --------------------------------------------------------------------
|
||||
@@ -178,9 +179,9 @@ traps:
|
||||
to: [7, 1]
|
||||
""")
|
||||
warp = g.level.traps[0]
|
||||
assert warp._pulse == 0.0 # dormant: no aura
|
||||
place(g, 4, 2) # stand on the warp tile
|
||||
step(g) # -> teleports, aura flashes on
|
||||
assert warp._pulse == 0.0 # dormant: no aura
|
||||
place(g, 4, 2) # stand on the warp tile
|
||||
step(g) # -> teleports, aura flashes on
|
||||
assert warp._pulse > 0.9
|
||||
|
||||
# The aura paints at BOTH the source and the destination — even though the
|
||||
@@ -218,9 +219,9 @@ traps:
|
||||
pb = g.level.traps[0]
|
||||
place(g, 1, 3)
|
||||
step(g)
|
||||
assert not pb.solid and pb.alpha == 0 # dormant far away
|
||||
assert not pb.solid and pb.alpha == 0 # dormant far away
|
||||
run(g, 40, lambda i: hold(right=True))
|
||||
assert pb.solid and pb.alpha > 0 # phased in solid on approach
|
||||
assert pb.solid and pb.alpha > 0 # phased in solid on approach
|
||||
|
||||
|
||||
def test_phase_block_kills_if_inside_when_it_forms(make_game):
|
||||
@@ -239,7 +240,7 @@ traps:
|
||||
trigger: { within: 3 }
|
||||
""")
|
||||
pb = g.level.traps[0]
|
||||
place(g, 3, 2) # standing right where it will form
|
||||
place(g, 3, 2) # standing right where it will form
|
||||
killed, d0 = False, g.deaths
|
||||
for _ in range(10):
|
||||
step(g)
|
||||
@@ -268,7 +269,7 @@ traps:
|
||||
pb = g.level.traps[0]
|
||||
b = pb.base_rect
|
||||
# Straddle the block's left edge: mostly outside, just clipping into it.
|
||||
g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap
|
||||
g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap
|
||||
g.player.fy = float(b.top + 2)
|
||||
g.player.vx = g.player.vy = 0.0
|
||||
g.player._sync_rect()
|
||||
@@ -331,21 +332,23 @@ traps:
|
||||
trigger: { within: 3 }
|
||||
""")
|
||||
pb = g.level.traps[0]
|
||||
place(g, 5, 2) # near enough to trigger, not on the cell
|
||||
for _ in range(6): # let it partially fade in
|
||||
place(g, 5, 2) # near enough to trigger, not on the cell
|
||||
for _ in range(6): # let it partially fade in
|
||||
step(g)
|
||||
assert 0 < pb.alpha < 1
|
||||
g._start_death() # die from something
|
||||
assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze
|
||||
g._start_death() # die from something
|
||||
assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze
|
||||
|
||||
|
||||
def test_debug_overscan_reveals_offmap_trap(make_level):
|
||||
# In debug the play surface is enlarged and shifted (render_offset) so a trap
|
||||
# placed just off the map is drawn into the overscan instead of being clipped.
|
||||
from game import settings as S
|
||||
|
||||
lvl = make_level(
|
||||
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #...#\n #####\n"
|
||||
"traps:\n - type: block\n at: [5, 1]\n deadly: true\n") # col 5 = off-map
|
||||
"traps:\n - type: block\n at: [5, 1]\n deadly: true\n"
|
||||
) # col 5 = off-map
|
||||
a = AssetStore("noassets")
|
||||
m = S.DEBUG_VIEW_MARGIN * lvl.tile
|
||||
lvl.render_offset = (m, m)
|
||||
@@ -354,7 +357,7 @@ def test_debug_overscan_reveals_offmap_trap(make_level):
|
||||
lvl.draw(surf, a)
|
||||
cx = 5 * lvl.tile + m + lvl.tile // 2
|
||||
cy = 1 * lvl.tile + m + lvl.tile // 2
|
||||
assert surf.get_at((cx, cy))[:3] != (0, 0, 0) # off-map block painted in overscan
|
||||
assert surf.get_at((cx, cy))[:3] != (0, 0, 0) # off-map block painted in overscan
|
||||
|
||||
|
||||
# --- generic invisible flag --------------------------------------------------
|
||||
@@ -384,9 +387,12 @@ traps:
|
||||
w = pygame.Surface((lvl.width, lvl.height))
|
||||
w.fill((0, 0, 0))
|
||||
sp.render(w, a)
|
||||
return any(w.get_at((x, y))[:3] != (0, 0, 0)
|
||||
for y in range(32, 96) for x in range(32, 96))
|
||||
return any(
|
||||
w.get_at((x, y))[:3] != (0, 0, 0)
|
||||
for y in range(32, 96)
|
||||
for x in range(32, 96)
|
||||
)
|
||||
|
||||
assert painted(False) is False # invisible in play
|
||||
assert painted(True) is True # revealed in debug
|
||||
assert sp.hazard_rects() # still deadly either way
|
||||
assert painted(False) is False # invisible in play
|
||||
assert painted(True) is True # revealed in debug
|
||||
assert sp.hazard_rects() # still deadly either way
|
||||
|
||||
@@ -6,9 +6,12 @@ from game.traps import make_condition
|
||||
|
||||
|
||||
def ev(spec, px, py, w=20, h=28, dt=0.0):
|
||||
trap = type("T", (), {"tile": 32,
|
||||
"sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)})()
|
||||
trap = type(
|
||||
"T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)}
|
||||
)()
|
||||
return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt)
|
||||
|
||||
|
||||
# trap centre = (116, 116); left100 right132 top100 bottom132
|
||||
|
||||
|
||||
@@ -17,26 +20,26 @@ def test_always():
|
||||
|
||||
|
||||
def test_within_radius():
|
||||
assert ev({"within": 2}, 106, 104) is True # ~10px away
|
||||
assert ev({"within": 2}, 400, 116) is False # far
|
||||
assert ev({"within": 2}, 106, 104) is True # ~10px away
|
||||
assert ev({"within": 2}, 400, 116) is False # far
|
||||
|
||||
|
||||
def test_dir_left_right():
|
||||
assert ev({"dir": "left"}, 40, 105) is True # to the left
|
||||
assert ev({"dir": "left"}, 200, 105) is False # to the right
|
||||
assert ev({"dir": "left"}, 40, 105) is True # to the left
|
||||
assert ev({"dir": "left"}, 200, 105) is False # to the right
|
||||
assert ev({"dir": "right"}, 200, 105) is True
|
||||
|
||||
|
||||
def test_dir_range():
|
||||
assert ev({"dir": "left", "range": 2}, 60, 105) is True # within 2 tiles left
|
||||
assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left
|
||||
assert ev({"dir": "left", "range": 2}, 60, 105) is True # within 2 tiles left
|
||||
assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left
|
||||
|
||||
|
||||
def test_dir_aligned():
|
||||
# above + aligned requires horizontal overlap with the trap column
|
||||
assert ev({"dir": "above", "aligned": True}, 108, 60) is True
|
||||
assert ev({"dir": "above", "aligned": True}, 108, 110) is False # not above
|
||||
assert ev({"dir": "above", "aligned": True}, 400, 60) is False # not aligned
|
||||
assert ev({"dir": "above", "aligned": True}, 400, 60) is False # not aligned
|
||||
# without aligned, any column counts
|
||||
assert ev({"dir": "above"}, 400, 60) is True
|
||||
|
||||
@@ -44,8 +47,8 @@ def test_dir_aligned():
|
||||
def test_dir_inclusive():
|
||||
# trap column spans x 100..132 (centre 116). A player standing *inside* the
|
||||
# column (centre 116) is neither strictly left nor strictly right of it.
|
||||
assert ev({"dir": "left"}, 106, 105) is False # centre 116, inside -> not left
|
||||
assert ev({"dir": "right"}, 106, 105) is False # inside -> not right
|
||||
assert ev({"dir": "left"}, 106, 105) is False # centre 116, inside -> not left
|
||||
assert ev({"dir": "right"}, 106, 105) is False # inside -> not right
|
||||
# inclusive counts the trap's own tile as being on that side
|
||||
assert ev({"dir": "left", "inclusive": True}, 106, 105) is True
|
||||
assert ev({"dir": "right", "inclusive": True}, 106, 105) is True
|
||||
@@ -59,24 +62,26 @@ def test_dir_inclusive():
|
||||
def test_all_and_any():
|
||||
c = {"all": [{"within": 3}, {"dir": "above", "aligned": True}]}
|
||||
assert ev(c, 108, 80) is True
|
||||
assert ev(c, 108, 110) is False # close but not above
|
||||
assert ev(c, 108, 110) is False # close but not above
|
||||
c2 = {"any": [{"dir": "left"}, {"dir": "right"}]}
|
||||
assert ev(c2, 40, 105) is True
|
||||
assert ev(c2, 110, 40) is False # above-only satisfies neither
|
||||
assert ev(c2, 110, 40) is False # above-only satisfies neither
|
||||
|
||||
|
||||
def test_timer_cycles():
|
||||
c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}})
|
||||
trap = type("T", (), {"tile": 32,
|
||||
"sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)})()
|
||||
trap = type(
|
||||
"T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)}
|
||||
)()
|
||||
g = FakeGame(pygame.Rect(0, 0, 4, 4))
|
||||
states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)]
|
||||
assert states[0] is False # starts in the "off" interval
|
||||
assert any(states) and not all(states) # cycles on and off
|
||||
assert states[0] is False # starts in the "off" interval
|
||||
assert any(states) and not all(states) # cycles on and off
|
||||
|
||||
|
||||
def test_bad_condition_raises():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
make_condition({"nope": 1})
|
||||
|
||||
@@ -97,10 +102,10 @@ traps:
|
||||
delay: 0.25
|
||||
""")
|
||||
sp = lvl.traps[0]
|
||||
g = FakeGame(sp.base_rect.copy()) # player right on it -> in range
|
||||
g = FakeGame(sp.base_rect.copy()) # player right on it -> in range
|
||||
armed = None
|
||||
for i in range(30):
|
||||
if sp.triggered(g, 1 / 60):
|
||||
armed = i
|
||||
break
|
||||
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames
|
||||
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames
|
||||
|
||||
@@ -21,11 +21,11 @@ def _s():
|
||||
|
||||
# --- individual sprites ------------------------------------------------------
|
||||
def draw_block(s):
|
||||
s.fill((96, 104, 122)) # stone
|
||||
s.fill((96, 104, 122)) # stone
|
||||
seam = (70, 76, 92)
|
||||
pygame.draw.rect(s, seam, (0, 0, TILE, TILE), 2) # border
|
||||
pygame.draw.line(s, seam, (0, 16), (TILE, 16), 2) # course seam
|
||||
pygame.draw.line(s, seam, (16, 2), (16, 16), 2) # offset bricks
|
||||
pygame.draw.rect(s, seam, (0, 0, TILE, TILE), 2) # border
|
||||
pygame.draw.line(s, seam, (0, 16), (TILE, 16), 2) # course seam
|
||||
pygame.draw.line(s, seam, (16, 2), (16, 16), 2) # offset bricks
|
||||
pygame.draw.line(s, seam, (8, 16), (8, TILE - 2), 2)
|
||||
pygame.draw.line(s, seam, (24, 16), (24, TILE - 2), 2)
|
||||
pygame.draw.line(s, (132, 140, 158), (2, 2), (TILE - 3, 2), 1) # top highlight
|
||||
@@ -38,33 +38,34 @@ def draw_fake_block(s):
|
||||
|
||||
def draw_player(s):
|
||||
body = pygame.Rect(6, 2, 20, 28)
|
||||
pygame.draw.rect(s, (40, 44, 60), body, border_radius=5) # phone body
|
||||
pygame.draw.rect(s, (18, 20, 30), body, 2, border_radius=5) # outline
|
||||
pygame.draw.rect(s, (95, 205, 255), (9, 6, 14, 16)) # screen
|
||||
pygame.draw.rect(s, (20, 30, 45), (12, 10, 3, 4)) # eyes
|
||||
pygame.draw.rect(s, (40, 44, 60), body, border_radius=5) # phone body
|
||||
pygame.draw.rect(s, (18, 20, 30), body, 2, border_radius=5) # outline
|
||||
pygame.draw.rect(s, (95, 205, 255), (9, 6, 14, 16)) # screen
|
||||
pygame.draw.rect(s, (20, 30, 45), (12, 10, 3, 4)) # eyes
|
||||
pygame.draw.rect(s, (20, 30, 45), (18, 10, 3, 4))
|
||||
pygame.draw.line(s, (20, 30, 45), (12, 17), (20, 17), 2) # smile
|
||||
pygame.draw.rect(s, (120, 126, 140), (14, 25, 4, 2)) # home button
|
||||
pygame.draw.line(s, (20, 30, 45), (12, 17), (20, 17), 2) # smile
|
||||
pygame.draw.rect(s, (120, 126, 140), (14, 25, 4, 2)) # home button
|
||||
|
||||
|
||||
def draw_player_dead(s):
|
||||
body = pygame.Rect(6, 2, 20, 28)
|
||||
pygame.draw.rect(s, (62, 42, 46), body, border_radius=5)
|
||||
pygame.draw.rect(s, (30, 20, 22), body, 2, border_radius=5)
|
||||
pygame.draw.rect(s, (72, 76, 86), (9, 6, 14, 16)) # dead grey screen
|
||||
for ex in (11, 17): # X eyes
|
||||
pygame.draw.rect(s, (72, 76, 86), (9, 6, 14, 16)) # dead grey screen
|
||||
for ex in (11, 17): # X eyes
|
||||
pygame.draw.line(s, (225, 85, 85), (ex, 9), (ex + 4, 14), 2)
|
||||
pygame.draw.line(s, (225, 85, 85), (ex + 4, 9), (ex, 14), 2)
|
||||
pygame.draw.lines(s, (200, 205, 215), False,
|
||||
[(9, 8), (14, 13), (12, 18), (21, 21)], 1) # crack
|
||||
pygame.draw.lines(
|
||||
s, (200, 205, 215), False, [(9, 8), (14, 13), (12, 18), (21, 21)], 1
|
||||
) # crack
|
||||
|
||||
|
||||
def draw_goal(s):
|
||||
pad = pygame.Rect(4, 4, 24, 24)
|
||||
pygame.draw.rect(s, (36, 110, 66), pad, border_radius=5) # charger pad
|
||||
pygame.draw.rect(s, (36, 110, 66), pad, border_radius=5) # charger pad
|
||||
pygame.draw.rect(s, (90, 230, 140), pad, 2, border_radius=5)
|
||||
bolt = [(19, 5), (10, 18), (15, 18), (12, 27), (23, 13), (17, 13)]
|
||||
pygame.draw.polygon(s, (245, 240, 130), bolt) # lightning bolt
|
||||
pygame.draw.polygon(s, (245, 240, 130), bolt) # lightning bolt
|
||||
pygame.draw.polygon(s, (200, 190, 80), bolt, 1)
|
||||
|
||||
|
||||
@@ -77,43 +78,46 @@ def draw_spike(s):
|
||||
pts = [(x, TILE), (x + w / 2, 3), (x + w, TILE)]
|
||||
pygame.draw.polygon(s, base, pts)
|
||||
pygame.draw.polygon(s, edge, pts, 1)
|
||||
pygame.draw.rect(s, edge, (0, TILE - 4, TILE, 4)) # base strip
|
||||
pygame.draw.rect(s, edge, (0, TILE - 4, TILE, 4)) # base strip
|
||||
|
||||
|
||||
def draw_moving_block(s):
|
||||
s.fill((172, 122, 72)) # crate
|
||||
s.fill((172, 122, 72)) # crate
|
||||
pygame.draw.rect(s, (120, 80, 45), (0, 0, TILE, TILE), 2)
|
||||
for bx, by in [(5, 5), (TILE - 6, 5), (5, TILE - 6), (TILE - 6, TILE - 6)]:
|
||||
pygame.draw.circle(s, (92, 60, 35), (bx, by), 2) # bolts
|
||||
pygame.draw.circle(s, (92, 60, 35), (bx, by), 2) # bolts
|
||||
plate = pygame.Rect(10, 10, 12, 12)
|
||||
pygame.draw.rect(s, (152, 106, 60), plate)
|
||||
pygame.draw.rect(s, (120, 80, 45), plate, 1)
|
||||
|
||||
|
||||
def draw_patrol_block(s):
|
||||
s.fill((122, 102, 178)) # platform
|
||||
s.fill((122, 102, 178)) # platform
|
||||
pygame.draw.rect(s, (80, 65, 125), (0, 0, TILE, TILE), 2)
|
||||
pygame.draw.rect(s, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight
|
||||
for off in (0, 9): # motion chevrons
|
||||
pygame.draw.lines(s, (92, 76, 142), False,
|
||||
[(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2)
|
||||
pygame.draw.rect(s, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight
|
||||
for off in (0, 9): # motion chevrons
|
||||
pygame.draw.lines(
|
||||
s, (92, 76, 142), False, [(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2
|
||||
)
|
||||
|
||||
|
||||
def draw_crumble_block(s):
|
||||
s.fill((166, 136, 96))
|
||||
pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2)
|
||||
cr = (112, 86, 56)
|
||||
pygame.draw.lines(s, cr, False, [(8, 2), (12, 10), (9, 16), (14, 24), (12, TILE)], 1)
|
||||
pygame.draw.lines(
|
||||
s, cr, False, [(8, 2), (12, 10), (9, 16), (14, 24), (12, TILE)], 1
|
||||
)
|
||||
pygame.draw.lines(s, cr, False, [(22, 3), (19, 9), (24, 15), (20, 22)], 1)
|
||||
pygame.draw.line(s, cr, (2, 14), (9, 16), 1)
|
||||
pygame.draw.line(s, cr, (24, 15), (TILE, 13), 1)
|
||||
|
||||
|
||||
def draw_arrow_shooter(s):
|
||||
s.fill((72, 76, 92)) # turret block
|
||||
s.fill((72, 76, 92)) # turret block
|
||||
pygame.draw.rect(s, (45, 48, 60), (0, 0, TILE, TILE), 2)
|
||||
for bx, by in [(5, 5), (TILE - 6, 5), (5, TILE - 6), (TILE - 6, TILE - 6)]:
|
||||
pygame.draw.circle(s, (40, 42, 52), (bx, by), 2) # rivets
|
||||
pygame.draw.circle(s, (40, 42, 52), (bx, by), 2) # rivets
|
||||
pygame.draw.circle(s, (22, 24, 30), (TILE // 2, TILE // 2), 6) # barrel
|
||||
pygame.draw.circle(s, (150, 64, 64), (TILE // 2, TILE // 2), 3)
|
||||
|
||||
@@ -122,14 +126,18 @@ def draw_spike_block(s):
|
||||
base, edge = (152, 158, 174), (84, 88, 104)
|
||||
m = TILE // 2
|
||||
tris = [
|
||||
[(m, 0), (m - 4, 11), (m + 4, 11)], # up
|
||||
[(m, TILE), (m - 4, TILE - 11), (m + 4, TILE - 11)], # down
|
||||
[(0, m), (11, m - 4), (11, m + 4)], # left
|
||||
[(TILE, m), (TILE - 11, m - 4), (TILE - 11, m + 4)], # right
|
||||
[(2, 2), (13, 6), (6, 13)], # up-left
|
||||
[(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right
|
||||
[(2, TILE - 2), (13, TILE - 6), (6, TILE - 13)], # down-left
|
||||
[(TILE - 2, TILE - 2), (TILE - 13, TILE - 6), (TILE - 6, TILE - 13)], # down-right
|
||||
[(m, 0), (m - 4, 11), (m + 4, 11)], # up
|
||||
[(m, TILE), (m - 4, TILE - 11), (m + 4, TILE - 11)], # down
|
||||
[(0, m), (11, m - 4), (11, m + 4)], # left
|
||||
[(TILE, m), (TILE - 11, m - 4), (TILE - 11, m + 4)], # right
|
||||
[(2, 2), (13, 6), (6, 13)], # up-left
|
||||
[(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right
|
||||
[(2, TILE - 2), (13, TILE - 6), (6, TILE - 13)], # down-left
|
||||
[
|
||||
(TILE - 2, TILE - 2),
|
||||
(TILE - 13, TILE - 6),
|
||||
(TILE - 6, TILE - 13),
|
||||
], # down-right
|
||||
]
|
||||
for t in tris:
|
||||
pygame.draw.polygon(s, base, t)
|
||||
@@ -144,8 +152,7 @@ def draw_phase_block(s):
|
||||
pygame.draw.rect(s, (120, 210, 240), (0, 0, TILE, TILE), 2)
|
||||
pygame.draw.line(s, (150, 230, 255), (4, 4), (TILE - 5, TILE - 5), 1)
|
||||
pygame.draw.line(s, (150, 230, 255), (TILE - 5, 4), (4, TILE - 5), 1)
|
||||
pygame.draw.polygon(s, (150, 230, 255),
|
||||
[(16, 4), (28, 16), (16, 28), (4, 16)], 1)
|
||||
pygame.draw.polygon(s, (150, 230, 255), [(16, 4), (28, 16), (16, 28), (4, 16)], 1)
|
||||
|
||||
|
||||
def draw_arrow(s):
|
||||
|
||||
Reference in New Issue
Block a user