Загрузка данных
import pygame
import math
import random
import sys
import json
import os
pygame.init()
# ---------- Поле ----------
WIDTH, HEIGHT = 1100, 750
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Рогалик")
clock = pygame.time.Clock()
FONT = pygame.font.SysFont("Arial", 22, bold=True)
BIG_FONT = pygame.font.SysFont("Arial", 34, bold=True)
HUGE_FONT = pygame.font.SysFont("Arial", 48, bold=True)
SMALL_FONT = pygame.font.SysFont("Arial", 16)
TINY_FONT = pygame.font.SysFont("Arial", 14)
SAVE_FILE = "roguelike_save.json"
# ---------- Настройки ----------
PLAYER_RADIUS = 16
ZOMBIE_RADIUS = 13
PHANTOM_RADIUS = 9
FAT_RADIUS = 26
SUMMONER_RADIUS = 18
TANK_RADIUS = 20
SPLITTER_RADIUS = 15
PLAYER_SPEED = 4
ZOMBIE_BASE_SPEED = 1.2
PHANTOM_SPEED_MULT = 1.9
FAT_SPEED_MULT = 0.55
SUMMONER_SPEED_MULT = 0.7
TANK_SPEED_MULT = 0.45
SPLITTER_SPEED_MULT = 1.1
FAT_HP_BASE = 8
FAT_EXPLOSION_RADIUS = 90
FAT_EXPLOSION_DAMAGE = 3
WAVE_DELAY = 5.0
ATTACK_COOLDOWN = 2.0
ATTACK_RANGE = 140
ATTACK_WIDTH = 26
SWING_TIME = 0.18
PHANTOM_START_WAVE = 5
FAT_START_WAVE = 7
SPLITTER_START_WAVE = 8
SUMMONER_START_WAVE = 10
TANK_START_WAVE = 13
GROUP_SPAWN_START_WAVE = 6
GROUP_SPAWN_TIME = 4.0
HUD_SHOW_TIME = 3.0
SUMMONER_HP_BASE = 4
SUMMONER_SUMMON_CD = 8.0
SUMMONER_MAX_PHANTOMS = 3
SUMMONER_SUMMON_RADIUS = 34
TANK_HP_BASE = 14
TANK_DAMAGE = 2
SPLITTER_HP_BASE = 3
SPLITTER_CHILDREN = 3
MAX_PARTICLES = 400
CHAOS_EVERY = 3
# ---------- Настройки монет ----------
MONEY_KILL_MIN = 1
MONEY_KILL_MAX = 10
MONEY_PHANTOM = 5
MONEY_FAT_MIN = 11
MONEY_FAT_MAX = 22
MONEY_SUMMONER_MIN = 25
MONEY_SUMMONER_MAX = 40
MONEY_TANK_MIN = 30
MONEY_TANK_MAX = 50
MONEY_SPLITTER_MIN = 8
MONEY_SPLITTER_MAX = 15
MONEY_WAVE_BASE = 50
MONEY_WAVE_GROWTH = 15
# ---------- Скины ----------
SKINS = [
{"name": "Синий", "color": (80, 180, 255), "glow": (120, 200, 255), "price": 0},
{"name": "Красный", "color": (255, 90, 90), "glow": (255, 130, 130), "price": 0},
{"name": "Зелёный", "color": (110, 220, 110), "glow": (160, 255, 160), "price": 0},
{"name": "Жёлтый", "color": (255, 220, 100), "glow": (255, 240, 150), "price": 0},
{"name": "Фиолетовый", "color": (200, 120, 255), "glow": (230, 170, 255), "price": 0},
{"name": "Золотой", "color": (255, 200, 60), "glow": (255, 240, 100), "price": 300},
{"name": "Кровавый", "color": (180, 30, 30), "glow": (255, 80, 80), "price": 500},
{"name": "Ледяной", "color": (150, 240, 255), "glow": (200, 255, 255), "price": 700},
{"name": "Тёмный", "color": (60, 60, 90), "glow": (150, 150, 200), "price": 1000},
{"name": "Радужный", "color": (255, 100, 200), "glow": (150, 255, 255), "price": 1500, "rainbow": True},
]
# ---------- Перки ----------
PERKS = [
{"id": "dash", "name": "Рывок", "price": 800,
"desc": "ПРОБЕЛ — рывок в сторону курсора. Во время рывка неуязвим. КД 3 сек.",
"color": (120, 220, 255)},
{"id": "second_wind", "name": "Второе дыхание", "price": 1200,
"desc": "Один раз за волну при смерти восстанавливает 3 HP.",
"color": (120, 255, 160)},
{"id": "bloodthirst", "name": "Кровожадность", "price": 900,
"desc": "+10% шанс крита за убийство в волне (макс +50%).",
"color": (255, 80, 80)},
{"id": "magnet", "name": "Магнит", "price": 600,
"desc": "Пикапы здоровья притягиваются к тебе.",
"color": (140, 255, 200)},
{"id": "thorns", "name": "Шипы", "price": 1000,
"desc": "Враги, коснувшиеся тебя, получают 1 урон.",
"color": (200, 150, 255)},
{"id": "explosive", "name": "Осколки", "price": 1300,
"desc": "Убитые враги с шансом 25% взрываются.",
"color": (255, 140, 60)},
{"id": "lucky", "name": "Счастливчик", "price": 700,
"desc": "+20% монет за убийство.",
"color": (255, 220, 120)},
{"id": "vampire", "name": "Вампир", "price": 1100,
"desc": "15% шанс вылечиться на 1 при убийстве.",
"color": (255, 100, 140)},
]
# ---------- Сохранение ----------
def load_save():
default = {
"money": 0,
"owned_skins": [0, 1, 2, 3, 4],
"selected_skin": 0,
"owned_perks": [],
"equipped_perks": [],
"admin_unlocked": False,
}
if os.path.exists(SAVE_FILE):
try:
with open(SAVE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in default.items():
if k not in data:
data[k] = v
return data
except Exception:
return default
return default
def save_game(data):
try:
with open(SAVE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception:
pass
# ---------- Вспомогательные ----------
def draw_glow_circle(surf, color, pos, radius, glow_color=None):
if glow_color is None:
glow_color = color
glow_surf = pygame.Surface((radius * 4, radius * 4), pygame.SRCALPHA)
for i in range(6, 0, -1):
alpha = int(30 * (1 - i / 6))
pygame.draw.circle(glow_surf, (*glow_color, alpha),
(radius * 2, radius * 2), radius + i * 2)
surf.blit(glow_surf, (pos[0] - radius * 2, pos[1] - radius * 2))
pygame.draw.circle(surf, color, pos, radius)
def spawn_particles(lst, x, y, color, count, speed, life, size):
room = MAX_PARTICLES - len(lst)
if room <= 0:
return
n = min(count, room)
for _ in range(n):
lst.append(Particle(x, y, color, speed=speed, life=life, size=size))
def wrap_text(text, font, max_width):
words = text.split()
lines = []
cur = ""
for w in words:
test = (cur + " " + w).strip()
if font.size(test)[0] <= max_width:
cur = test
else:
if cur:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
return lines
def render_fit(text, max_width, max_height, color, preferred_font=None):
sizes = []
if preferred_font is not None:
sizes.append(preferred_font)
sizes += [pygame.font.SysFont("Arial", s, bold=True) for s in (20, 18, 16, 14, 12)]
for font in sizes:
lines = wrap_text(text, font, max_width)
total_h = len(lines) * font.get_linesize()
if total_h <= max_height:
return [font.render(ln, True, color) for ln in lines]
font = pygame.font.SysFont("Arial", 12, bold=True)
lines = wrap_text(text, font, max_width)
return [font.render(ln, True, color) for ln in lines]
# ---------- Объекты ----------
class HitMarker:
def __init__(self, x, y, text, color=(255, 240, 100)):
self.x, self.y, self.text, self.color = x, y, text, color
self.life = self.max_life = 0.8
self.vx = random.uniform(-20, 20)
self.vy = -60
def update(self, dt):
self.x += self.vx * dt
self.y += self.vy * dt
self.vy += 100 * dt
self.life -= dt
return self.life > 0
def draw(self, surf):
t = max(0, self.life / self.max_life)
alpha = int(255 * t)
size = int(20 + 8 * (1 - t))
font = pygame.font.SysFont("Arial", size, bold=True)
img = font.render(self.text, True, self.color)
img.set_alpha(alpha)
surf.blit(img, (self.x - img.get_width() // 2, self.y))
class Particle:
__slots__ = ("x", "y", "color", "size", "vx", "vy", "life", "max_life")
def __init__(self, x, y, color, speed=120, life=0.5, size=4):
self.x, self.y, self.color, self.size = x, y, color, size
ang = random.uniform(0, math.tau)
sp = random.uniform(speed * 0.3, speed)
self.vx = math.cos(ang) * sp
self.vy = math.sin(ang) * sp
self.life = self.max_life = life
def update(self, dt):
self.x += self.vx * dt
self.y += self.vy * dt
self.vx *= 0.92
self.vy *= 0.92
self.life -= dt
return self.life > 0
def draw(self, surf):
t = max(0, self.life / self.max_life)
r = max(1, int(self.size * t))
pygame.draw.circle(surf, self.color, (int(self.x), int(self.y)), r)
class SlashEffect:
def __init__(self, x, y, angle, length, width):
self.x, self.y, self.angle = x, y, angle
self.length, self.width = length, width
self.life = self.max_life = SWING_TIME
def update(self, dt):
self.life -= dt
return self.life > 0
def draw(self, surf):
t = max(0, self.life / self.max_life)
progress = 1.0 - t
cur_len = self.length * min(1.0, progress * 1.6)
alpha = int(255 * t)
w = max(2, int(self.width * (0.4 + 0.6 * t)))
half = w / 2
ex = self.x + math.cos(self.angle) * cur_len
ey = self.y + math.sin(self.angle) * cur_len
px = -math.sin(self.angle) * half
py = math.cos(self.angle) * half
p1 = (self.x + px, self.y + py)
p2 = (self.x - px, self.y - py)
p3 = (ex - px, ey - py)
p4 = (ex + px, ey + py)
glow = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
for g in range(4, 0, -1):
gw = half + g * 3
gpx = -math.sin(self.angle) * gw
gpy = math.cos(self.angle) * gw
a = int(alpha * 0.15 * (g / 4))
pygame.draw.polygon(glow, (255, 220, 120, a),
[(self.x + gpx, self.y + gpy),
(self.x - gpx, self.y - gpy),
(ex - gpx, ey - gpy),
(ex + gpx, ey + gpy)])
surf.blit(glow, (0, 0), special_flags=pygame.BLEND_RGBA_ADD)
core = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
pygame.draw.polygon(core, (255, 255, 220, alpha), [p1, p2, p3, p4])
inner = max(1, w // 3)
ipx = -math.sin(self.angle) * inner / 2
ipy = math.cos(self.angle) * inner / 2
pygame.draw.polygon(core, (255, 255, 255, alpha),
[(self.x + ipx, self.y + ipy),
(self.x - ipx, self.y - ipy),
(ex - ipx, ey - ipy),
(ex + ipx, ey + ipy)])
surf.blit(core, (0, 0), special_flags=pygame.BLEND_RGBA_ADD)
pygame.draw.circle(surf, (255, 255, 220), (int(ex), int(ey)), max(2, int(6 * t)))
class Projectile:
def __init__(self, x, y, angle, speed, damage, pierce, player, max_range=700):
self.x, self.y = x, y
self.angle = angle
self.vx = math.cos(angle) * speed
self.vy = math.sin(angle) * speed
self.radius = 6
self.damage = damage
self.pierce = pierce
self.hit_ids = set()
self.player = player
self.traveled = 0.0
self.max_range = max_range
self.trail = []
def update(self, dt, zombies, particles, hit_markers):
step = math.hypot(self.vx * dt, self.vy * dt)
self.x += self.vx * dt
self.y += self.vy * dt
self.traveled += step
self.trail.append([self.x, self.y, 0.22])
self.trail = [t for t in self.trail if t[2] - dt > 0]
for t in self.trail:
t[2] -= dt
if not (-50 < self.x < WIDTH + 50 and -50 < self.y < HEIGHT + 50):
return False
if self.traveled > self.max_range:
return False
for z in zombies[:]:
if id(z) in self.hit_ids:
continue
if math.hypot(z.x - self.x, z.y - self.y) < self.radius + z.radius:
crit = random.random() < getattr(self.player, "crit", 0)
crit_mult = getattr(self.player, "crit_mult", 2.0)
dmg = self.damage * (crit_mult if crit else 1)
z.hp -= dmg
z.hit_timer = 0.15
color = (255, 100, 100) if crit else (255, 240, 100)
text = f"{int(dmg)}" + ("!" if crit else "")
hit_markers.append(HitMarker(z.x, z.y - z.radius - 5, text, color))
self.hit_ids.add(id(z))
kb = self.player.knockback
z.knock_vx += math.cos(self.angle) * kb * 3
z.knock_vy += math.sin(self.angle) * kb * 3
spawn_particles(particles, z.x, z.y, (220, 60, 60), 3, 150, 0.3, 3)
if self.pierce <= 0:
return False
self.pierce -= 1
return True
def draw(self, surf):
for tx, ty, t in self.trail:
a = int(180 * (t / 0.22))
r = max(1, int(self.radius * (t / 0.22)))
s = pygame.Surface((r * 2 + 2, r * 2 + 2), pygame.SRCALPHA)
pygame.draw.circle(s, (255, 220, 120, a), (r + 1, r + 1), r)
surf.blit(s, (tx - r - 1, ty - r - 1))
pygame.draw.circle(surf, (255, 240, 160), (int(self.x), int(self.y)), self.radius)
pygame.draw.circle(surf, (255, 255, 255), (int(self.x), int(self.y)), self.radius - 2)
class Player:
def __init__(self, player_class="melee", skin=None, perks=None):
self.player_class = player_class
self.skin = skin or SKINS[0]
self.perks = perks or []
self.x, self.y = WIDTH // 2, HEIGHT // 2
self.radius = PLAYER_RADIUS
self.hp = self.max_hp = 5
self.damage = 1
self.attack_cooldown = ATTACK_COOLDOWN
self.attack_timer = 0.0
self.attack_range = ATTACK_RANGE
self.attack_width = ATTACK_WIDTH
self.aim_angle = 0.0
self.speed = PLAYER_SPEED
self.hit_flash = 0.0
self.bob = 0.0
self.knockback = 18
self.lifesteal = 0
self.regen = 0.0
self.regen_acc = 0.0
self.armor = 0
self.projectile_speed = 750
self.projectile_pierce = 0
self.multishot = 1
self.attack_range_max = 600
# Перковые поля
self.dash_cd = 0.0
self.dash_cd_max = 3.0
self.dash_timer = 0.0
self.dash_dir = (0, 0)
self.invulnerable = False
self.second_wind_used = False
self.bloodthirst_bonus = 0.0
def has_perk(self, pid):
return pid in self.perks
def update_aim(self):
mx, my = pygame.mouse.get_pos()
self.aim_angle = math.atan2(my - self.y, mx - self.x)
def move(self, keys, dt):
# рывок перекрывает обычное движение
if self.dash_timer > 0:
self.x += self.dash_dir[0] * 22 * dt * 60
self.y += self.dash_dir[1] * 22 * dt * 60
self.dash_timer -= dt
if self.dash_timer <= 0:
self.invulnerable = False
self.x = max(self.radius, min(WIDTH - self.radius, self.x))
self.y = max(self.radius, min(HEIGHT - self.radius, self.y))
return
dx = dy = 0
if keys[pygame.K_a] or keys[pygame.K_LEFT]: dx -= 1
if keys[pygame.K_d] or keys[pygame.K_RIGHT]: dx += 1
if keys[pygame.K_w] or keys[pygame.K_UP]: dy -= 1
if keys[pygame.K_s] or keys[pygame.K_DOWN]: dy += 1
if dx or dy:
l = math.hypot(dx, dy)
self.x += dx / l * self.speed * dt * 60
self.y += dy / l * self.speed * dt * 60
self.bob += dt * 15
self.x = max(self.radius, min(WIDTH - self.radius, self.x))
self.y = max(self.radius, min(HEIGHT - self.radius, self.y))
def try_dash(self):
if not self.has_perk("dash"):
return False
if self.dash_cd > 0 or self.dash_timer > 0:
return False
self.dash_cd = self.dash_cd_max
self.dash_timer = 0.18
self.dash_dir = (math.cos(self.aim_angle), math.sin(self.aim_angle))
self.invulnerable = True
return True
def update_perk_timers(self, dt):
if self.dash_cd > 0:
self.dash_cd -= dt
def draw(self, surf):
bob_offset = math.sin(self.bob) * 2 if self.bob else 0
cx, cy = int(self.x), int(self.y + bob_offset)
shadow = pygame.Surface((self.radius * 3, self.radius), pygame.SRCALPHA)
pygame.draw.ellipse(shadow, (0, 0, 0, 90), shadow.get_rect())
surf.blit(shadow, (self.x - self.radius * 1.5, self.y + self.radius - 2))
if self.player_class == "melee":
ax = cx + math.cos(self.aim_angle) * self.attack_range
ay = cy + math.sin(self.aim_angle) * self.attack_range
else:
ax = cx + math.cos(self.aim_angle) * 200
ay = cy + math.sin(self.aim_angle) * 200
aim = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
pygame.draw.line(aim, (255, 255, 255, 40), (cx, cy), (ax, ay), 1)
surf.blit(aim, (0, 0))
# Цвет скина
if self.skin.get("rainbow"):
t = pygame.time.get_ticks() / 1000.0
base = (int(128 + 127 * math.sin(t)),
int(128 + 127 * math.sin(t + 2.1)),
int(128 + 127 * math.sin(t + 4.2)))
glow_color = tuple(min(255, c + 40) for c in base)
else:
base = self.skin["color"]
glow_color = self.skin["glow"]
if self.hit_flash > 0:
glow_color = (255, 120, 120)
if self.invulnerable:
glow_color = (255, 255, 255)
draw_glow_circle(surf, base, (cx, cy), self.radius, glow_color)
bright = tuple(min(255, c + 60) for c in base)
pygame.draw.circle(surf, bright,
(cx - self.radius // 3, cy - self.radius // 3),
self.radius // 3)
pygame.draw.circle(surf, (240, 240, 255), (cx, cy), self.radius, 2)
if self.player_class == "melee":
sx = cx + math.cos(self.aim_angle) * (self.radius + 4)
sy = cy + math.sin(self.aim_angle) * (self.radius + 4)
ex = cx + math.cos(self.aim_angle) * (self.radius + 22)
ey = cy + math.sin(self.aim_angle) * (self.radius + 22)
pygame.draw.line(surf, (230, 230, 240), (sx, sy), (ex, ey), 4)
pygame.draw.line(surf, (255, 255, 255), (sx, sy), (ex, ey), 2)
else:
sx = cx + math.cos(self.aim_angle) * (self.radius + 2)
sy = cy + math.sin(self.aim_angle) * (self.radius + 2)
ex = cx + math.cos(self.aim_angle) * (self.radius + 18)
ey = cy + math.sin(self.aim_angle) * (self.radius + 18)
pygame.draw.line(surf, (200, 200, 210), (sx, sy), (ex, ey), 6)
pygame.draw.circle(surf, (255, 230, 150), (int(ex), int(ey)), 4)
if self.hit_flash > 0:
self.hit_flash -= 1 / 60
# ---------- Враги ----------
class Zombie:
def __init__(self, x, y, speed, hp=1):
self.x, self.y = x, y
self.radius = ZOMBIE_RADIUS
self.hp = self.max_hp = hp
self.speed = speed
self.damage = 1
self.hit_timer = 0.0
self.attack_cd = 0.0
self.spawn = 0.5
self.angle = 0.0
self.wobble = random.uniform(0, math.tau)
self.knock_vx = 0.0
self.knock_vy = 0.0
self.is_phantom = False
self.is_fat = False
self.is_summoner = False
self.is_tank = False
self.is_splitter = False
self.is_child = False
def update(self, player, dt):
self.x += self.knock_vx * dt
self.y += self.knock_vy * dt
self.knock_vx *= 0.85
self.knock_vy *= 0.85
if abs(self.knock_vx) < 1: self.knock_vx = 0
if abs(self.knock_vy) < 1: self.knock_vy = 0
if self.spawn > 0:
self.spawn -= dt
return
dx = player.x - self.x
dy = player.y - self.y
d = math.hypot(dx, dy)
if d > 0:
self.angle = math.atan2(dy, dx)
self.x += dx / d * self.speed * dt * 60
self.y += dy / d * self.speed * dt * 60
if self.hit_timer > 0: self.hit_timer -= dt
if self.attack_cd > 0: self.attack_cd -= dt
self.wobble += dt * 8
def draw(self, surf):
r = self.radius
if self.spawn > 0:
r = max(1, int(self.radius * (1 - self.spawn / 0.5)))
cx, cy = int(self.x), int(self.y)
shadow = pygame.Surface((r * 3, r), pygame.SRCALPHA)
pygame.draw.ellipse(shadow, (0, 0, 0, 80), shadow.get_rect())
surf.blit(shadow, (self.x - r * 1.5, self.y + r - 2))
base = (200, 60, 60) if self.hit_timer <= 0 else (255, 220, 220)
dark = (110, 20, 20)
pulse = math.sin(self.wobble) * 1.5
pygame.draw.circle(surf, dark, (cx, cy), int(r + 2 + pulse))
pygame.draw.circle(surf, base, (cx, cy), int(r + pulse))
pygame.draw.circle(surf, (255, 160, 160), (cx - r // 3, cy - r // 3), max(2, r // 4))
ex = math.cos(self.angle) * 3
ey = math.sin(self.angle) * 3
pygame.draw.circle(surf, (255, 240, 100), (int(cx + ex - 3), int(cy + ey - 1)), 2)
pygame.draw.circle(surf, (255, 240, 100), (int(cx + ex + 3), int(cy + ey - 1)), 2)
class Phantom(Zombie):
def __init__(self, x, y, speed):
super().__init__(x, y, speed, hp=1)
self.radius = PHANTOM_RADIUS
self.is_phantom = True
def draw(self, surf):
r = self.radius
if self.spawn > 0:
r = max(1, int(self.radius * (1 - self.spawn / 0.5)))
cx, cy = int(self.x), int(self.y)
shadow = pygame.Surface((r * 3, r), pygame.SRCALPHA)
pygame.draw.ellipse(shadow, (0, 0, 0, 60), shadow.get_rect())
surf.blit(shadow, (self.x - r * 1.5, self.y + r - 2))
flick = 0.75 + 0.25 * math.sin(self.wobble * 2.5)
alpha = int(200 * flick)
aura = pygame.Surface((r * 6, r * 6), pygame.SRCALPHA)
for i in range(5, 0, -1):
a = int(40 * (i / 5) * flick)
pygame.draw.circle(aura, (200, 60, 220, a), (r * 3, r * 3), r + i * 3)
surf.blit(aura, (cx - r * 3, cy - r * 3))
body = pygame.Surface((r * 2 + 6, r * 2 + 6), pygame.SRCALPHA)
col_outer = (170, 30, 130, alpha) if self.hit_timer <= 0 else (255, 220, 255, alpha)
col_inner = (230, 80, 120, alpha)
pygame.draw.circle(body, col_outer, (r + 3, r + 3), r + 2)
pygame.draw.circle(body, col_inner, (r + 3, r + 3), r)
pygame.draw.circle(body, (255, 180, 255, alpha // 2), (r + 3, r + 1), max(2, r // 3))
surf.blit(body, (cx - r - 3, cy - r - 3))
ex = math.cos(self.angle) * 2
ey = math.sin(self.angle) * 2
pygame.draw.circle(surf, (255, 60, 60), (int(cx + ex - 2), int(cy + ey - 1)), 2)
pygame.draw.circle(surf, (255, 60, 60), (int(cx + ex + 2), int(cy + ey - 1)), 2)
class FatZombie(Zombie):
def __init__(self, x, y, speed, hp):
super().__init__(x, y, speed, hp=hp)
self.radius = FAT_RADIUS
self.is_fat = True
def draw(self, surf):
r = self.radius
if self.spawn > 0:
r = max(1, int(self.radius * (1 - self.spawn / 0.5)))
cx, cy = int(self.x), int(self.y)
shadow = pygame.Surface((r * 3, r), pygame.SRCALPHA)
pygame.draw.ellipse(shadow, (0, 0, 0, 90), shadow.get_rect())
surf.blit(shadow, (self.x - r * 1.5, self.y + r - 2))
base = (90, 190, 90) if self.hit_timer <= 0 else (220, 255, 220)
dark = (40, 110, 40)
pulse = math.sin(self.wobble) * 2.0
pygame.draw.circle(surf, dark, (cx, cy), int(r + 3 + pulse))
pygame.draw.circle(surf, base, (cx, cy), int(r + pulse))
for ang, dist in [(0.5, 0.45), (2.4, 0.5), (4.2, 0.4)]:
bx = cx + math.cos(ang) * r * dist
by = cy + math.sin(ang) * r * dist