Commit
the-way-out
v0.8.0
modified CHANGELOG.md
@@ -1,5 +1,29 @@
# CHANGELOG
## v0.8.0
Combat now reads — every hit registers. Adds game-feel polish across
the level: brief hit-pause on impactful events, particle bursts on
hits / deaths / abilities, and a fade between level start and end.
### Gameplay
- Brief hit-pause on impactful events: the screen freezes for a few
frames when the player takes damage, when the boss is hit, when the
boss dies, and when the player dies. The pause is short enough to
read as a snap, not a hang, and the camera + transition keep moving
through it.
### UI
- Particle bursts on hits, kills, ability activations, and boss death.
Player hits are red, regular hits are white, boss death sprays gold
with a white core, and each character's signature ability blooms in
its own colour (Wizard violet, Penguin ice blue, Elf leaf green,
Shiggy warm dust, Wolf white).
- Levels fade in on start and fade out on completion / death-to-retry
so transitions read as a deliberate cut.
## v0.7.0
Lets you give each custom map its own **visual theme** in the level
modified VERSION
@@ -1 +1 @@
v0.7.0
v0.8.0
added effects.py
@@ -0,0 +1,163 @@
"""Particle bursts and full-screen fades — game-feel polish.
Owned by :class:`levels.LevelManager`; emitters fire from the level's
event handlers (hit/death/ability) and the field is ticked + drawn each
frame. Particles live in world space and are clipped to the camera's
visible rect so off-screen bursts cost nothing to render.
"""
import random
import pygame
class Particle:
"""One short-lived sprite-less puff. Linear motion, decaying alpha.
Kept tiny on purpose: the field can hold hundreds at once during a
boss death, and every attribute access counts.
"""
__slots__ = ("x", "y", "vx", "vy", "life", "life0", "color", "size",
"gravity", "drag")
def __init__(self, x, y, vx, vy, life, color, size,
gravity=0.0, drag=0.0):
self.x = x
self.y = y
self.vx = vx
self.vy = vy
self.life = life
self.life0 = life
self.color = color
self.size = size
self.gravity = gravity
self.drag = drag
def update(self, dt):
self.life -= dt
self.x += self.vx * dt
self.y += self.vy * dt
if self.gravity:
self.vy += self.gravity * dt
if self.drag:
k = max(0.0, 1.0 - self.drag * dt)
self.vx *= k
self.vy *= k
@property
def alive(self):
return self.life > 0
class ParticleField:
"""Pool of particles for one level run."""
def __init__(self):
self._items = []
def clear(self):
self._items.clear()
def __len__(self):
return len(self._items)
def burst(self, x, y, count, color, *, speed=240, life=0.45,
size=4, spread=1.0, size_jitter=2,
gravity=0.0, drag=2.0):
"""Spawn ``count`` particles radiating from ``(x, y)``.
``spread`` is a 0..1 fraction of a full circle (1 = ring, 0.5 =
cone-ish). ``drag`` decelerates them so the burst stops dead
instead of drifting forever.
"""
for _ in range(count):
theta = random.random() * spread * 2 * 3.14159
s = speed * (0.5 + random.random())
vx = s * pygame.math.Vector2(1, 0).rotate_rad(theta).x
vy = s * pygame.math.Vector2(1, 0).rotate_rad(theta).y
sz = max(1, size + random.randint(-size_jitter, size_jitter))
self._items.append(Particle(
x, y, vx, vy,
life * (0.7 + 0.6 * random.random()),
color, sz, gravity, drag))
def update(self, dt):
if not self._items:
return
alive = []
for p in self._items:
p.update(dt)
if p.alive:
alive.append(p)
self._items = alive
def draw(self, screen, world_to_screen_offset, view_w, view_h):
"""Blit every alive particle, skipping any off the camera."""
if not self._items:
return
ox, oy = world_to_screen_offset
for p in self._items:
sx = int(p.x - ox)
sy = int(p.y - oy)
if sx < -p.size or sx > view_w + p.size:
continue
if sy < -p.size or sy > view_h + p.size:
continue
a = max(0, min(255, int(255 * (p.life / p.life0))))
r, g, b = p.color
# Per-particle SRCALPHA surface so each fades independently;
# cheap because particles are tiny (size ~2-8 px).
d = p.size * 2
surf = pygame.Surface((d, d), pygame.SRCALPHA)
pygame.draw.circle(surf, (r, g, b, a), (p.size, p.size), p.size)
screen.blit(surf, (sx - p.size, sy - p.size))
class FadeState:
"""Linear alpha overlay for level start / end transitions.
Holds a single direction-and-duration pair: ``mode='in'`` starts at
alpha=255 and decays to 0; ``mode='out'`` starts at 0 and rises to
255. ``alpha()`` returns the current alpha to blit, or 0 when idle.
"""
def __init__(self):
self.mode = None # 'in' | 'out' | None
self.time = 0.0 # seconds remaining
self.total = 0.0
self.color = (0, 0, 0)
def start_in(self, duration, color=(0, 0, 0)):
self.mode = 'in'
self.time = duration
self.total = duration
self.color = color
def start_out(self, duration, color=(0, 0, 0)):
self.mode = 'out'
self.time = duration
self.total = duration
self.color = color
def update(self, dt):
if self.mode is None:
return
self.time = max(0.0, self.time - dt)
if self.time == 0.0 and self.mode == 'in':
self.mode = None
def alpha(self):
if self.mode is None or self.total <= 0:
return 0
ratio = self.time / self.total
if self.mode == 'in':
return int(255 * ratio)
return int(255 * (1.0 - ratio))
def draw(self, screen, view_w, view_h):
a = self.alpha()
if a <= 0:
return
overlay = pygame.Surface((view_w, view_h), pygame.SRCALPHA)
overlay.fill((*self.color, a))
screen.blit(overlay, (0, 0))
modified levels.py
@@ -7,18 +7,36 @@ import pygame
from settings import (
TILE_SIZE, BOSS_TOUCH_DAMAGE, PLAYER_INVULN_TIME,
SPIKE_DAMAGE, LEVER_REACH, SLOW_SCALE,
HIT_PAUSE_PLAYER_HIT, HIT_PAUSE_BOSS_HIT,
HIT_PAUSE_BOSS_DEATH, HIT_PAUSE_PLAYER_DEATH,
PARTICLES_PLAYER_HIT, PARTICLES_ENEMY_HIT, PARTICLES_ENEMY_DEATH,
PARTICLES_BOSS_HIT, PARTICLES_BOSS_DEATH, PARTICLES_ABILITY,
ABILITY_COLOR_WIZARD, ABILITY_COLOR_PENGUIN, ABILITY_COLOR_ELF,
ABILITY_COLOR_SHIGGY, ABILITY_COLOR_WOLF,
FADE_IN_TIME, FADE_OUT_TIME,
)
from units import (
Wizard, Penguin, Boss, BOSS_ROSTER, CHARACTER_INFO, ENEMY_INFO)
Wizard, Penguin, Elf, Shiggy, Wolf,
Boss, BOSS_ROSTER, CHARACTER_INFO, ENEMY_INFO)
from static_objects import Tile, TileTextures, Prop
from interactables import Spikes, Lever, Gate, KeyItem, PressurePlate
from tiles import PROP_CHARS
from effects import ParticleField, FadeState
import tileset
import level_catalog
import save
import audio
import theme
# Per-character ability burst colour, keyed by player class.
_ABILITY_COLORS = {
Wizard: ABILITY_COLOR_WIZARD,
Penguin: ABILITY_COLOR_PENGUIN,
Elf: ABILITY_COLOR_ELF,
Shiggy: ABILITY_COLOR_SHIGGY,
Wolf: ABILITY_COLOR_WOLF,
}
# Boss state -> badge colour. One named set instead of inline tuples
# scattered through draw_boss_health (FAIL = imminent hit, ACCENT =
# ranged tell, INK = neutral).
@@ -229,6 +247,12 @@ class LevelManager:
# Damage-edge tracking for screen shake.
self._last_player_hp = 0
self._last_boss_hp = None
self._last_ability_active = False
# Game-feel: particle bursts, transition fades, hit-pause clock.
self.particles = ParticleField()
self.fade = FadeState()
self._hit_pause = 0.0
self.title_font = theme.font(90)
self.big_font = theme.font(110)
@@ -336,6 +360,10 @@ class LevelManager:
self.arena_rect = None
self._e_was_down = False
self._last_boss_hp = None
self._last_ability_active = False
self.particles.clear()
self._hit_pause = 0.0
self.fade.start_in(FADE_IN_TIME)
# Pick the general for this level deterministically from the
# level id. zlib.crc32 (not Python's built-in hash) so the
# choice is stable across game restarts, not just retries —
@@ -541,10 +569,20 @@ class LevelManager:
self.intro_timer = max(0.0, self.intro_timer - dt)
self.camera.update_shake(dt)
self.particles.update(dt)
self.fade.update(dt)
if self._hit_pause > 0:
self._hit_pause = max(0.0, self._hit_pause - dt)
if self.completed or self.failed:
return
# Hit-pause freezes only the gameplay actors — the camera, the
# particles, the fade and the timers all kept ticking above so
# the impact still reads as a snap, not a hang.
if self._hit_pause > 0:
return
# Wizard's Slow ability scales the per-frame dt of every enemy,
# the boss and enemy projectiles. self.entities is exactly
# {player} ∪ enemy_sprites, so dispatch per actor instead of one
@@ -562,6 +600,13 @@ class LevelManager:
self.interactable_sprites.update(dt)
self.camera.follow(self.player, dt)
# Ability rising edge — one particle burst at activation.
if (self.player is not None and self.player.ability_active
and not self._last_ability_active):
self._emit_ability_burst()
if self.player is not None:
self._last_ability_active = self.player.ability_active
# Boss only materialises once you actually enter the final hall.
if (self.has_boss and self.boss is None and not self.boss_defeated
and self.arena_rect is not None
@@ -595,8 +640,15 @@ class LevelManager:
# below) — the two are intentionally not merged.
for en in [e for e in self.enemy_sprites if e is not self.boss]:
if en.hp <= 0:
self._emit_enemy_death(en)
en.kill()
continue
# Edge-detect each enemy's HP so a projectile hit puffs even
# if it doesn't kill.
prev = getattr(en, '_last_hp_for_fx', en.max_hp)
if en.hp < prev:
self._emit_enemy_hit(en)
en._last_hp_for_fx = en.hp
if (self.intro_timer <= 0
and self.player.invuln_timer <= 0
and en.hitbox.colliderect(self.player.hitbox)):
@@ -607,7 +659,9 @@ class LevelManager:
# projectiles and contact damage all shake the camera uniformly.
if (self.player is not None
and self.player.hp < self._last_player_hp):
self.camera.shake(5, 0.18)
self.camera.shake(4, 0.18)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_PLAYER_HIT)
self._emit_player_hit()
if self.player is not None:
self._last_player_hp = self.player.hp
@@ -615,10 +669,14 @@ class LevelManager:
if (self._last_boss_hp is not None
and self.boss.hp < self._last_boss_hp):
self.camera.shake(2, 0.08)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_BOSS_HIT)
self._emit_boss_hit()
self._last_boss_hp = self.boss.hp
if self.boss is not None and self.boss.hp <= 0:
self.camera.shake(12, 0.7)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_BOSS_DEATH)
self._emit_boss_death()
audio.play("boss_death")
self.boss.kill()
self.boss = None
@@ -626,6 +684,8 @@ class LevelManager:
if self.player is not None and self.player.hp <= 0:
self.camera.shake(8, 0.4)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_PLAYER_DEATH)
self.fade.start_out(FADE_OUT_TIME)
audio.stop_music()
audio.play("player_death")
self.failed = True
@@ -642,8 +702,52 @@ class LevelManager:
save.record_time(self.level_id, self.time)
audio.stop_music()
audio.play("level_complete")
self.fade.start_out(FADE_OUT_TIME)
self._saved = True
# --- effect emitters --------------------------------------------
def _emit_player_hit(self):
cx, cy = self.player.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_PLAYER_HIT, (255, 80, 80),
speed=320, life=0.40, size=4, drag=3.0)
def _emit_enemy_hit(self, enemy):
cx, cy = enemy.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_ENEMY_HIT, (255, 255, 255),
speed=260, life=0.30, size=3, drag=3.5)
def _emit_enemy_death(self, enemy):
cx, cy = enemy.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_ENEMY_DEATH, (240, 240, 240),
speed=300, life=0.55, size=4, drag=2.5)
def _emit_boss_hit(self):
cx, cy = self.boss.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_BOSS_HIT, (255, 220, 120),
speed=260, life=0.35, size=5, drag=3.0)
def _emit_boss_death(self):
cx, cy = self.boss.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_BOSS_DEATH, (255, 210, 90),
speed=520, life=0.95, size=6, size_jitter=3, drag=1.8)
# White core puff in the same place.
self.particles.burst(
cx, cy, PARTICLES_BOSS_DEATH // 2, (255, 255, 255),
speed=320, life=0.7, size=4, drag=2.5)
def _emit_ability_burst(self):
color = _ABILITY_COLORS.get(type(self.player), (255, 255, 255))
cx, cy = self.player.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_ABILITY, color,
speed=360, life=0.55, size=4, drag=2.2)
def _handle_levers(self):
"""Edge-detected 'E' near a lever pulls it and opens its gate."""
e_down = pygame.key.get_pressed()[pygame.K_e]
@@ -794,6 +898,14 @@ class LevelManager:
for proj in self.projectile_sprites:
screen.blit(proj.image, self.camera.world_to_screen(proj.rect))
# Particles sit on the playfield (under HUD, under vignette so
# the edges don't look painted on top of darkness).
self.particles.draw(
screen,
(self.camera.offset.x + self.camera.shake_offset.x,
self.camera.offset.y + self.camera.shake_offset.y),
self.width, self.height)
screen.blit(self._vignette, (0, 0))
if self.player is not None and not self.completed:
@@ -813,6 +925,10 @@ class LevelManager:
self.draw_end_overlay(
screen, "You were defeated...", theme.FAIL)
# Transition fade covers everything (HUD + end overlay too) so
# the screen reads as a single sealed frame at the moment of cut.
self.fade.draw(screen, self.width, self.height)
def _draw_exit(self, screen):
if self.exit_rect is None:
return
modified settings.py
@@ -76,6 +76,36 @@ BOSS_PROJECTILE_SPEED = 720
# Hit feedback shared by every unit.
HIT_FLASH_TIME = 0.12
# --- Game-feel polish ---
# Hit-pause briefly freezes gameplay actors (player, enemies, boss,
# projectiles) so each impact reads. The level's own clock (camera
# shake, particles, fades) keeps ticking, so the freeze is a snap, not
# a hang.
HIT_PAUSE_PLAYER_HIT = 0.06
HIT_PAUSE_BOSS_HIT = 0.04
HIT_PAUSE_BOSS_DEATH = 0.18
HIT_PAUSE_PLAYER_DEATH = 0.14
# Particle burst sizes per event. Tuned so a busy room still reads at
# 60 FPS — the field auto-culls off-screen and dead particles.
PARTICLES_PLAYER_HIT = 14
PARTICLES_ENEMY_HIT = 8
PARTICLES_ENEMY_DEATH = 22
PARTICLES_BOSS_HIT = 12
PARTICLES_BOSS_DEATH = 90
PARTICLES_ABILITY = 28
# Per-character ability burst colours.
ABILITY_COLOR_WIZARD = (140, 110, 255) # arcane violet
ABILITY_COLOR_PENGUIN = (120, 200, 255) # ice blue
ABILITY_COLOR_ELF = (180, 240, 130) # leaf green
ABILITY_COLOR_SHIGGY = (255, 200, 120) # warm dust
ABILITY_COLOR_WOLF = (240, 240, 240) # speed white
# Transition fade timings (alpha overlay drawn last in LevelManager).
FADE_IN_TIME = 0.22 # level start
FADE_OUT_TIME = 0.35 # level complete / death-to-retry
# --- Hazards / Puzzles ---
# Spike traps run on one shared clock so the rhythm is readable:
# safe -> warning (telegraph) -> deadly -> safe ...