Python 932 lines
import random
import pygame
import audio
from settings import (
ATTACK_COOLDOWN,
BOSS_AIM_TIME,
BOSS_CHASE_TIME_MAX,
BOSS_CHASE_TIME_MIN,
BOSS_DASH_SPEED_MULT,
BOSS_DASH_TIME,
BOSS_HITBOX_SIZE,
BOSS_MAX_HP,
BOSS_PHASE2_HP_RATIO,
BOSS_PROJECTILE_DAMAGE,
BOSS_PROJECTILE_SPEED,
BOSS_RECOVER_TIME,
BOSS_SCALE,
BOSS_SPEED,
BOSS_WINDUP_TIME,
DASH_COOLDOWN,
DASH_DURATION,
DASH_INVULN_BONUS,
DASH_SPEED_MULT,
HIT_FLASH_TIME,
PLAYER_HITBOX_SIZE,
PLAYER_MAX_HP,
PLAYER_SCALE,
PLAYER_SPEED,
PROJECTILE_DAMAGE,
PROJECTILE_LIFETIME,
PROJECTILE_RADIUS,
PROJECTILE_SPEED,
)
# Unit vector for each facing direction (used as aim fallback when no
# explicit aim is set and for the boss's targeting).
FACING_VECTORS = {
'down': (0, 1),
'up': (0, -1),
'left': (-1, 0),
'right': (1, 0),
}
class Character(pygame.sprite.Sprite):
"""Base class for all units (player and AI).
Subclasses set ``asset_folder`` plus, optionally, the tuning class
attributes below; all loading, animation, movement, collision and
health behaviour is shared. The player is keyboard-driven via
``get_input``; enemies override it with AI.
"""
asset_folder = None
# Tuning — overridable per subclass.
scale = PLAYER_SCALE
speed = PLAYER_SPEED
hitbox_size = PLAYER_HITBOX_SIZE
max_hp = PLAYER_MAX_HP
attack_damage = PROJECTILE_DAMAGE
attack_cooldown = ATTACK_COOLDOWN
# --- Signature ability ---
# Each playable subclass overrides these plus the activate / tick /
# on_ability_end hooks below; the base values keep non-playable
# units (Enemy / Boss) inert — they never reach the trigger path.
ABILITY_NAME = "" # HUD + character-menu label
ABILITY_DESC = "" # one-line tagline for the character menu
ABILITY_GLYPH = "dash" # which icon draw_ability_meter draws
ABILITY_DURATION = 0.0 # seconds the ability stays active
ABILITY_COOLDOWN = 0.0 # seconds before it can fire again
# When False, left mouse no longer triggers an attack — only Space
# fires. The main-menu's playable avatar sets this off so clicks on
# buttons don't double as shots; gameplay keeps the default.
attack_mouse_enabled = True
# name -> frame count in the sprite sheet
SPRITE_SHEETS = {
'idle_down': ('D_Idle', 4),
'walk_down': ('D_Walk', 6),
'idle_up': ('U_Idle', 4),
'walk_up': ('U_Walk', 6),
'idle_left': ('S_Idle', 4),
'walk_left': ('S_Walk', 6),
}
def __init__(self, x, y, obstacle_sprites=None):
super().__init__()
if self.asset_folder is None:
raise ValueError(
f"{type(self).__name__} must define an 'asset_folder'")
self.facing = 'down'
self.load_assets()
self.status = 'idle_down'
self.frame_index = 0
self.animation_speed = 10
frames = self.animations[self.status]
self.image = (frames[self.frame_index] if frames
else self._placeholder_frame())
self.rect = self.image.get_rect(topleft=(x, y))
# Smaller collision box, centered on the sprite. Walls are
# checked against this, not the (mostly transparent) image rect.
self.hitbox = self.rect.inflate(
self.hitbox_size - self.rect.width,
self.hitbox_size - self.rect.height)
# Sprites this unit cannot walk through. None -> no collision.
self.obstacle_sprites = obstacle_sprites
self.pos = pygame.math.Vector2(x, y)
self.direction = pygame.math.Vector2()
# Health / combat
self.hp = self.max_hp
self.invuln_timer = 0.0
self.attack_timer = 0.0
self.hit_flash_timer = 0.0
# Boss telegraphs are driven by ``tint_color``: an (r,g,b,a) tuple
# the base ``animate`` will additively overlay onto this frame's
# opaque pixels (so transparent padding stays transparent).
self.tint_color = None
# Ability state — the player triggers their character's
# signature ability on Shift; the base class owns only these
# timers, each playable subclass owns the actual mechanic.
# Enemies never reach the trigger path (projectile_group stays
# None), so this stays inert for them.
self.ability_timer = 0.0 # seconds left of active ability
self.ability_cooldown_timer = 0.0 # seconds until it can re-fire
self.ability_active = False
self.speed_mult = 1.0 # >1 during a dash / sprint
# Set by the level for the player only; enemies leave these None
# unless the level wires them up (boss phase 2 needs them too).
self.projectile_group = None
self.projectile_targets = None
def load_assets(self):
path = f"assets/units/{self.asset_folder}/"
def import_frames(name, frame_count):
img_path = f"{path}{name}.png"
try:
sheet = pygame.image.load(img_path).convert_alpha()
except (pygame.error, FileNotFoundError):
print(f"{img_path} not found.")
return [] # crash safety
frames = []
width = sheet.get_width() // frame_count
height = sheet.get_height()
for i in range(frame_count):
rect = pygame.Rect(i * width, 0, width, height)
surf = sheet.subsurface(rect)
scaled = pygame.transform.scale(
surf, (width * self.scale, height * self.scale))
frames.append(scaled)
return frames
self.animations = {
status: import_frames(name, count)
for status, (name, count) in self.SPRITE_SHEETS.items()
}
# Right-facing frames are mirrored left-facing frames.
self.animations['idle_right'] = [
pygame.transform.flip(img, True, False)
for img in self.animations['idle_left']
]
self.animations['walk_right'] = [
pygame.transform.flip(img, True, False)
for img in self.animations['walk_left']
]
# --- input / movement -------------------------------------------
def get_status(self):
if self.direction.magnitude() != 0:
if abs(self.direction.x) > abs(self.direction.y):
self.facing = 'right' if self.direction.x > 0 else 'left'
else:
self.facing = 'down' if self.direction.y > 0 else 'up'
self.status = f'walk_{self.facing}'
else:
self.status = f'idle_{self.facing}'
def get_input(self):
# Belt-and-braces for the focus-loss hitch: while the window is
# unfocused SDL keeps reporting the last-held keys, so treat
# "unfocused" as "no input" even if the pause event raced us.
if not pygame.key.get_focused():
self.direction.update(0, 0)
return
keys = pygame.key.get_pressed()
right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
left = keys[pygame.K_LEFT] or keys[pygame.K_a]
down = keys[pygame.K_DOWN] or keys[pygame.K_s]
up = keys[pygame.K_UP] or keys[pygame.K_w]
self.direction.x = int(right) - int(left)
self.direction.y = int(down) - int(up)
if self.direction.magnitude() != 0:
self.direction = self.direction.normalize()
def move(self, dt):
speed = self.speed * self.speed_mult
# Move and resolve collisions one axis at a time so the unit
# slides along walls instead of getting stuck on them.
self.pos.x += self.direction.x * speed * dt
self.rect.x = round(self.pos.x)
self.hitbox.centerx = self.rect.centerx
self.collide('horizontal')
self.pos.y += self.direction.y * speed * dt
self.rect.y = round(self.pos.y)
self.hitbox.centery = self.rect.centery
self.collide('vertical')
def collide(self, direction):
if self.obstacle_sprites is None:
return
for sprite in self.obstacle_sprites:
if not sprite.hitbox.colliderect(self.hitbox):
continue
if direction == 'horizontal':
if self.direction.x > 0: # moving right
self.hitbox.right = sprite.hitbox.left
elif self.direction.x < 0: # moving left
self.hitbox.left = sprite.hitbox.right
self.rect.centerx = self.hitbox.centerx
self.pos.x = self.rect.x
else:
if self.direction.y > 0: # moving down
self.hitbox.bottom = sprite.hitbox.top
elif self.direction.y < 0: # moving up
self.hitbox.top = sprite.hitbox.bottom
self.rect.centery = self.hitbox.centery
self.pos.y = self.rect.y
def _toward_target(self, min_len=0):
"""Unit vector from this unit to ``self.target`` (set by the AI
subclasses — Boss/Enemy), or zero when closer than
``min_len``."""
to = (pygame.math.Vector2(self.target.hitbox.center)
- pygame.math.Vector2(self.hitbox.center))
if to.length() > max(1, min_len):
return to.normalize()
return pygame.math.Vector2(0, 0)
# --- damage / feedback ------------------------------------------
def take_damage(self, amount):
"""Subtract HP. Each hit always lands (no i-frames here).
Contact-spam protection for the player lives in the level, which
gates repeated hits with ``invuln_timer`` — projectiles, by
contrast, should damage the boss on every shot.
"""
if self.hp <= 0:
return False
self.hp = max(0, self.hp - amount)
# White flash on hit so the damage reads instantly even on the
# boss's huge sprite.
self.hit_flash_timer = HIT_FLASH_TIME
audio.play("hit")
return True
# --- combat -----------------------------------------------------
def current_attack_cooldown(self):
"""Seconds between shots. A hook so an ability (Elf's
rapid-fire) can shorten the cadence for its active window."""
return self.attack_cooldown
def handle_attack(self, dt):
"""Player only: fire a projectile in the facing direction on
Space or left-click.
Aim follows the way the character is looking (the 4 facings) —
there is deliberately no free mouse aim; the cursor doesn't
steer shots.
"""
if self.projectile_group is None:
return
self.attack_timer = max(0.0, self.attack_timer - dt)
keys = pygame.key.get_pressed()
mouse_left = (self.attack_mouse_enabled
and pygame.mouse.get_pressed()[0])
if (keys[pygame.K_SPACE] or mouse_left) and self.attack_timer <= 0:
aim = pygame.math.Vector2(*FACING_VECTORS[self.facing])
spawn = pygame.math.Vector2(self.hitbox.center)
spawn += aim * (self.hitbox_size / 2 + 8)
Projectile(
spawn, aim,
self.obstacle_sprites, self.projectile_targets,
[self.projectile_group],
damage=self.attack_damage, owner='player')
audio.play("shoot")
self.attack_timer = self.current_attack_cooldown()
def handle_ability(self, dt):
"""Shift triggers this character's signature ability.
The base class owns only the timers; each playable subclass
fills in :meth:`activate_ability` (and, where the ability needs
per-frame work or teardown, :meth:`tick_ability` /
:meth:`on_ability_end`). Only the player reaches the trigger
path — enemies leave ``projectile_group`` None.
"""
# Tick the active window first; the cooldown only counts once
# the ability itself has ended.
if self.ability_active:
self.ability_timer = max(0.0, self.ability_timer - dt)
if self.ability_timer == 0:
self.ability_active = False
self.ability_cooldown_timer = self.ABILITY_COOLDOWN
self.on_ability_end()
else:
self.tick_ability(dt)
elif self.ability_cooldown_timer > 0:
self.ability_cooldown_timer = max(
0.0, self.ability_cooldown_timer - dt)
if self.projectile_group is None:
return # only the player triggers abilities via input
keys = pygame.key.get_pressed()
shift = keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT]
if (shift and not self.ability_active
and self.ability_cooldown_timer == 0):
self.ability_active = True
self.ability_timer = self.ABILITY_DURATION
self.activate_ability()
# Subclass hooks — no-ops on the base so Enemy / Boss stay inert
# and a character with no per-frame work only overrides what it
# actually needs.
def activate_ability(self):
"""Run once the instant the ability fires."""
def tick_ability(self, dt):
"""Run every frame the ability is active (not its final frame)."""
def on_ability_end(self):
"""Run once when the active window runs out."""
# --- animation / tick -------------------------------------------
def _placeholder_frame(self):
"""Visible stand-in when a sprite sheet is missing/renamed.
``load_assets`` returns ``[]`` for an absent PNG ('crash
safety'); this keeps that promise so one bad asset degrades to a
loud magenta box instead of an ``IndexError`` that kills the
whole run. Magenta matches the editor's missing-art marker.
"""
s = max(self.hitbox_size, 16)
surf = pygame.Surface((s, s), pygame.SRCALPHA)
surf.fill((180, 60, 180, 120))
pygame.draw.rect(surf, (180, 60, 180), surf.get_rect(), 2)
return surf
def _apply_overlay(self, frame, color_rgba):
"""Additive RGB overlay that respects the sprite's alpha mask.
``BLEND_RGB_ADD`` adds the overlay's RGB to opaque destination
pixels without touching the destination alpha, so a hit flash on
a transparent-padded sprite stays a flash on the *character* and
doesn't fill the bounding box with white.
"""
if frame.get_flags() & pygame.SRCALPHA == 0:
frame = frame.copy()
else:
frame = frame.copy()
intensity = pygame.Surface(frame.get_size(), pygame.SRCALPHA)
intensity.fill(color_rgba)
frame.blit(intensity, (0, 0),
special_flags=pygame.BLEND_RGB_ADD)
return frame
def animate(self, dt):
current_animation = self.animations[self.status]
if not current_animation:
return
self.frame_index += self.animation_speed * dt
self.frame_index %= len(current_animation)
frame = current_animation[int(self.frame_index)]
# Flicker while invulnerable so hits read clearly — but not
# while an ability is active: Penguin's shield shows an aura
# instead, and a 0.18 s dash flicker barely registers anyway.
if (self.invuln_timer > 0 and not self.ability_active
and int(self.invuln_timer * 20) % 2 == 0):
frame = frame.copy()
frame.set_alpha(90)
# Boss telegraph tint (red windup / gold aim) — applied before
# the brighter hit flash so a hit during windup still pops.
if self.tint_color is not None:
frame = self._apply_overlay(frame, self.tint_color)
if self.hit_flash_timer > 0:
v = int(180 * (self.hit_flash_timer / HIT_FLASH_TIME))
frame = self._apply_overlay(frame, (v, v, v, 255))
self.image = frame
def update(self, dt):
if self.invuln_timer > 0:
self.invuln_timer -= dt
if self.hit_flash_timer > 0:
self.hit_flash_timer = max(0.0, self.hit_flash_timer - dt)
self.get_input()
self.handle_ability(dt)
self.get_status()
self.move(dt)
self.animate(dt)
self.handle_attack(dt)
# --- player-character variants -----------------------------------------
# Each character is mechanically distinct (not just a skin).
# The blurbs in CHARACTER_INFO are what the menu shows; keep them in
# sync if you tune the numbers.
class Wizard(Character):
"""Balanced reference character.
Signature — *Slow*: bends time for every enemy, the boss and their
in-flight shots while the Wizard keeps moving and firing at full
speed. The slow itself lives in ``LevelManager.update``, which reads
``ability_active``; this class only triggers it.
"""
asset_folder = "wizard"
# defaults: HP 100, spd 600, cd 0.35, dmg 10
ABILITY_NAME = "SLOW"
ABILITY_DESC = "Slows enemies and their shots for 3s"
ABILITY_GLYPH = "slow"
ABILITY_DURATION = 3.0
ABILITY_COOLDOWN = 12.0
def activate_ability(self):
audio.play("slow")
class Penguin(Character):
"""Tank — slower but takes more punishment and hits a bit harder.
Signature — *Shield*: total damage immunity for a short window.
Implemented by keeping ``invuln_timer`` topped up, so every damage
path (boss / enemy contact, spikes, projectiles — all gated on
``invuln_timer``) is blocked for free.
"""
asset_folder = "penguin"
max_hp = 140
speed = 480
attack_damage = 12
attack_cooldown = 0.45
ABILITY_NAME = "SHIELD"
ABILITY_DESC = "Immune to all damage for 2.5s"
ABILITY_GLYPH = "shield"
ABILITY_DURATION = 2.5
ABILITY_COOLDOWN = 11.0
def activate_ability(self):
audio.play("shield")
def tick_ability(self, dt):
# A hit drops invuln_timer to PLAYER_INVULN_TIME, which can be
# shorter than the shield still has to run — so re-arm a sliver
# of i-frames every frame the shield is up.
self.invuln_timer = max(self.invuln_timer, 0.2)
class Elf(Character):
"""Archer — rapid-fire, lower damage per shot, fragile.
Signature — *Volley*: doubles the fire rate for a short window by
halving the effective attack cooldown.
"""
asset_folder = "elf"
max_hp = 90
speed = 580
attack_damage = 7
attack_cooldown = 0.20
ABILITY_NAME = "VOLLEY"
ABILITY_DESC = "Doubles fire rate for 2s"
ABILITY_GLYPH = "rapid"
ABILITY_DURATION = 2.0
ABILITY_COOLDOWN = 9.0
def activate_ability(self):
audio.play("volley")
def current_attack_cooldown(self):
if self.ability_active:
return self.attack_cooldown * 0.5
return self.attack_cooldown
class Shiggy(Character):
"""Glass cannon — biggest hit, smallest health pool.
Signature — *Dash*: a short, i-framed speed burst. Direction is
locked at dash start (current input dir, falling back to facing) so
a panic dash always commits somewhere sensible. This is the dash
every character shared before v0.5.0 — now Shiggy's alone.
"""
asset_folder = "shiggy"
max_hp = 70
speed = 620
attack_damage = 20
attack_cooldown = 0.40
ABILITY_NAME = "DASH"
ABILITY_DESC = "Quick i-framed burst dash"
ABILITY_GLYPH = "dash"
ABILITY_DURATION = DASH_DURATION
ABILITY_COOLDOWN = DASH_COOLDOWN
def activate_ability(self):
d = self.direction if self.direction.magnitude() != 0 \
else pygame.math.Vector2(*FACING_VECTORS[self.facing])
self._dash_dir = d.normalize()
self.direction.update(self._dash_dir)
self.speed_mult = DASH_SPEED_MULT
self.invuln_timer = max(self.invuln_timer, DASH_DURATION)
audio.play("dash")
def tick_ability(self, dt):
# Lock direction during the dash so mid-burst input can't
# change course.
self.direction.update(self._dash_dir)
def on_ability_end(self):
self.speed_mult = 1.0
# A whisker of i-frames after landing helps cross contact-damage
# windows with frame-perfect dashes.
self.invuln_timer = max(self.invuln_timer, DASH_INVULN_BONUS)
class Wolf(Character):
"""Scout — very fast, modest combat stats.
Signature — *Sprint*: a burst of peak movement speed. Unlike
Shiggy's dash it grants no i-frames and never locks the steering —
full directional control the whole time.
"""
asset_folder = "wolf"
max_hp = 85
speed = 760
attack_damage = 9
attack_cooldown = 0.40
ABILITY_NAME = "SPRINT"
ABILITY_DESC = "Peak movement speed for 1.5s"
ABILITY_GLYPH = "sprint"
ABILITY_DURATION = 1.5
ABILITY_COOLDOWN = 8.0
SPRINT_SPEED_MULT = 2.0
def activate_ability(self):
self.speed_mult = self.SPRINT_SPEED_MULT
audio.play("sprint")
def on_ability_end(self):
self.speed_mult = 1.0
# Catalogue used by the character menu to display stats and by
# levels.py to instantiate the chosen class. Order = menu order.
CHARACTER_INFO = [
("c_wiz", Wizard, "Wizard", "Balanced"),
("c_peng", Penguin, "Penguin", "Tank"),
("c_elf", Elf, "Elf", "Rapid-fire"),
("c_shig", Shiggy, "Shiggy", "Glass cannon"),
("c_wolf", Wolf, "Wolf", "Speedster"),
]
# Pool of "generals" the level picks one of as its boss. Mechanics are
# identical (one Boss class) — only the name, sprite folder and a
# subtle identity overlay change so each level feels distinct. The
# fourth tuple element is an optional RGBA tint baked into every loaded
# frame once at boss init, so the telegraph (red/gold) still sits on
# top during attack windups without fighting a per-frame identity
# overlay.
BOSS_ROSTER = [
("Mr. Green", "mrgreen", None),
("Mr. Orange", "orange", (180, 90, 30, 70)),
("Gen. Frost", "penguin", (60, 120, 210, 90)),
("The Archer", "elf", (70, 180, 90, 90)),
("Mr. Shadow", "shiggy", (90, 40, 140, 90)),
]
# --- boss --------------------------------------------------------------
class Boss(Character):
"""AI enemy with a small state-machine fighting style.
Phase 1 (HP above ``BOSS_PHASE2_HP_RATIO``): chase, then telegraphed
dash attack. Phase 2 (below): also fires a 3-shot spread between
chases. Each new state picks itself the moment the previous timer
runs out, so the rhythm is readable and you can dodge by pattern.
"""
asset_folder = "mrgreen"
scale = BOSS_SCALE
speed = BOSS_SPEED
hitbox_size = BOSS_HITBOX_SIZE
max_hp = BOSS_MAX_HP
def __init__(self, x, y, obstacle_sprites=None, target=None,
projectile_group=None, projectile_targets=None,
*, display_name="Mr. Green", asset_folder=None,
identity_tint=None):
# Set instance asset_folder + identity_tint BEFORE super so
# Character.load_assets reads the chosen sprite folder and the
# overridden load_assets below bakes the tint in.
if asset_folder is not None:
self.asset_folder = asset_folder
self.identity_tint = identity_tint
self.display_name = display_name
super().__init__(x, y, obstacle_sprites)
self.target = target
# The level wires these so phase 2 can spawn boss projectiles
# that hurt only the player (not other enemies / the boss itself).
self.projectile_group = projectile_group
self.projectile_targets = projectile_targets
self.state = 'chase'
self.state_timer = random.uniform(
BOSS_CHASE_TIME_MIN, BOSS_CHASE_TIME_MAX)
self.dash_dir = pygame.math.Vector2(0, 1)
def load_assets(self):
# Bake identity_tint into every frame once at init so the
# telegraph tint (red/gold during windup/aim) still overlays
# cleanly on top without a per-frame swap that pops between
# neutral and tinted between attacks.
super().load_assets()
if self.identity_tint is None:
return
for status, frames in self.animations.items():
self.animations[status] = [
self._apply_overlay(f, self.identity_tint) for f in frames
]
def get_input(self):
if self.target is None or self.target.hp <= 0 or self.hp <= 0:
self.direction.update(0, 0)
return
if self.state == 'chase':
self.direction = self._toward_target(min_len=6)
elif self.state == 'dash':
self.direction.update(self.dash_dir)
else: # windup / aim / recover / shoot — hold still
self.direction.update(0, 0)
def handle_attack(self, dt):
# Boss doesn't fire via input; ranged volleys are scheduled by
# the state machine in :meth:`update`.
return
def handle_ability(self, dt):
# Boss has no signature ability — its dash is an FSM state.
return
def _in_phase2(self):
return self.hp <= self.max_hp * BOSS_PHASE2_HP_RATIO
def _enter(self, state):
self.state = state
if state == 'chase':
self.state_timer = random.uniform(
BOSS_CHASE_TIME_MIN, BOSS_CHASE_TIME_MAX)
self.speed_mult = 1.0
elif state == 'windup':
# Lock in the dash direction at the *start* of the windup so
# the player has the full telegraph to read it.
self.dash_dir = self._toward_target()
if self.dash_dir.length() == 0:
self.dash_dir.update(0, 1)
self.state_timer = BOSS_WINDUP_TIME
self.speed_mult = 0.0
elif state == 'dash':
self.state_timer = BOSS_DASH_TIME
self.speed_mult = BOSS_DASH_SPEED_MULT
elif state == 'recover':
self.state_timer = BOSS_RECOVER_TIME
self.speed_mult = 0.0
elif state == 'aim':
self.state_timer = BOSS_AIM_TIME
self.speed_mult = 0.0
elif state == 'shoot':
# Shoot has zero duration: we fire on the next FSM tick and
# immediately transition to recover.
self.state_timer = 0.0
self.speed_mult = 0.0
def _fire_volley(self):
"""3-shot spread aimed at the player. Reuses :class:`Projectile`."""
if (self.target is None or self.projectile_group is None
or self.projectile_targets is None):
return
base = self._toward_target()
if base.length() == 0:
return
spawn = pygame.math.Vector2(self.hitbox.center)
for deg in (-12, 0, 12):
v = base.rotate(deg)
Projectile(
spawn + v * (self.hitbox_size / 2 + 12), v,
self.obstacle_sprites, self.projectile_targets,
[self.projectile_group],
damage=BOSS_PROJECTILE_DAMAGE,
speed=BOSS_PROJECTILE_SPEED,
color=(255, 130, 70), owner='enemy')
def _update_tint(self):
"""Red ramp during windup, gold ramp during aim — the colour
cue tells the player which attack is coming."""
if self.state == 'windup':
ramp = 1.0 - (self.state_timer / BOSS_WINDUP_TIME)
ramp = max(0.0, min(1.0, ramp))
self.tint_color = (140, 30, 30, int(40 + 80 * ramp))
elif self.state == 'aim':
ramp = 1.0 - (self.state_timer / BOSS_AIM_TIME)
ramp = max(0.0, min(1.0, ramp))
self.tint_color = (140, 110, 30, int(40 + 80 * ramp))
else:
self.tint_color = None
def update(self, dt):
# Tick the FSM first so this frame's direction / speed reflect
# the current state.
if self.hp > 0 and self.target is not None and self.target.hp > 0:
self.state_timer = max(0.0, self.state_timer - dt)
if self.state_timer == 0.0:
self._advance_state()
self._update_tint()
if self.invuln_timer > 0:
self.invuln_timer -= dt
if self.hit_flash_timer > 0:
self.hit_flash_timer = max(0.0, self.hit_flash_timer - dt)
self.get_input()
self.get_status()
prev = self.rect.topleft
self.move(dt)
# A dash that slammed into a wall ends early — scraping along
# the wall for the rest of the dash feels broken.
if self.state == 'dash' and self.rect.topleft == prev:
self._enter('recover')
self.animate(dt)
# Boss has no handle_attack — see _fire_volley instead.
def _advance_state(self):
"""Pick the next state. The boss alternates chase with one of
its attacks; phase 2 unlocks ranged volleys."""
if self.state == 'chase':
# In phase 2, ~50% of the attacks are ranged volleys.
if self._in_phase2() and random.random() < 0.5:
self._enter('aim')
else:
self._enter('windup')
elif self.state == 'windup':
self._enter('dash')
elif self.state == 'dash':
self._enter('recover')
elif self.state == 'recover':
self._enter('chase')
elif self.state == 'aim':
self._enter('shoot')
elif self.state == 'shoot':
self._fire_volley()
self._enter('recover')
# --- enemies -----------------------------------------------------------
# Generic placeable threats (a token char in the level text), as
# opposed to the single scripted Boss. Adding one is a class + a line
# in ENEMY_INFO + a sprite folder; tiles.REGISTRY and the editor
# palette pick it up from ENEMY_INFO automatically.
class Enemy(Character):
"""Plain chaser: walks straight at the player and relies on contact
damage (applied by the level, like the boss touch).
No FSM, no ranged attack, no dash — unlike :class:`Boss` it can be
dropped anywhere and spawns immediately rather than lazily, and it
does **not** gate the exit (only the boss/key do)."""
scale = 6
speed = 300
max_hp = 45
hitbox_size = 70
touch_damage = 12
def __init__(self, x, y, obstacle_sprites=None, target=None):
super().__init__(x, y, obstacle_sprites)
self.target = target
def get_input(self):
if self.target is None or self.target.hp <= 0 or self.hp <= 0:
self.direction.update(0, 0)
return
self.direction = self._toward_target(min_len=4)
def handle_attack(self, dt):
return # contact-only
def handle_ability(self, dt):
return # no signature ability
class Orange(Enemy):
"""The orange blob — the basic roaming enemy."""
asset_folder = "orange"
# Catalogue parallel to CHARACTER_INFO: (level token char, class,
# label). One line here + a sprite folder = a new placeable enemy.
ENEMY_INFO = [
("N", Orange, "Chaser"),
]
# --- projectile --------------------------------------------------------
class Projectile(pygame.sprite.Sprite):
"""A simple orb that flies straight, hurts its targets and dies on walls.
The colour and speed can be customised so the boss's volley reads
visually different from the player's shots. ``owner`` ('player' /
'enemy') tags which side fired it, so the Wizard's slow-time can
spare player shots while slowing enemy ones.
"""
def __init__(self, pos, direction, obstacle_sprites, targets, groups,
damage=PROJECTILE_DAMAGE, color=(120, 230, 255),
speed=PROJECTILE_SPEED, owner='enemy'):
super().__init__(groups)
self.obstacle_sprites = obstacle_sprites
self.targets = targets
self.damage = damage
self.speed = speed
self.owner = owner
r = PROJECTILE_RADIUS
size = (r + 4) * 2
self.image = pygame.Surface((size, size), pygame.SRCALPHA)
# Soft outer halo.
for i in range(3):
pygame.draw.circle(
self.image, (*color, 70 - i * 20),
(size // 2, size // 2), r + 3 - i)
pygame.draw.circle(self.image, color,
(size // 2, size // 2), r)
pygame.draw.circle(self.image, (255, 255, 255),
(size // 2, size // 2), r // 2)
self.rect = self.image.get_rect(center=(int(pos.x), int(pos.y)))
# Smaller hitbox than the visual halo so glancing shots feel fair.
self.hitbox = self.rect.inflate(-8, -8)
self.pos = pygame.math.Vector2(pos)
self.direction = pygame.math.Vector2(direction)
if self.direction.magnitude() != 0:
self.direction = self.direction.normalize()
self.life = PROJECTILE_LIFETIME
def update(self, dt):
self.life -= dt
if self.life <= 0:
self.kill()
return
# Substep so a fast shot can't tunnel a thin wall or a small
# target in one tick: the per-tick move at 950 px/s under the
# dt cap can exceed two projectile hitboxes.
distance = self.speed * dt
max_step = max(1.0, min(self.hitbox.width, self.hitbox.height) * 0.5)
steps = 1 if distance <= max_step else int(distance / max_step) + 1
step_vec = self.direction * (distance / steps)
for _ in range(steps):
self.pos += step_vec
self.rect.center = (round(self.pos.x), round(self.pos.y))
self.hitbox.center = self.rect.center
# Targets first: a shot at an enemy pressed flush against a
# wall is still credited before the wall kills the orb.
if self.targets is not None:
for target in self.targets:
if target.hitbox.colliderect(self.hitbox):
# Honour i-frames so a single tick of overlap
# from a spread shot can't burn the player's
# whole bar.
if getattr(target, 'invuln_timer', 0) > 0:
continue
target.take_damage(self.damage)
self.kill()
return
if self.obstacle_sprites is not None:
for wall in self.obstacle_sprites:
if wall.hitbox.colliderect(self.hitbox):
self.kill()
return