mirror of
https://github.com/EDeev/pixel_gamble.git
synced 2026-10-07 23:49:29 +03:00
- 10 волн, 5 видов врагов со своим поведением и босс в три фазы - меч, копьё и посох, 11 улучшений между волнами, опыт и уровни - меню, пауза, настройки, итоги партии и рекорд, русский и английский - мир 480×270 с масштабированием, интерфейс в 960×540, скорости от dt - асинхронный цикл: та же игра в браузере через pygbag - тесты без окна, автоигрок, CI, релизы exe/Linux, GitHub Pages - README на двух языках; исходная версия — релиз v1.0.0
217 lines
8 KiB
Python
217 lines
8 KiB
Python
"""Враги: слайм, летучая мышь, ниндзя, скелет-метатель, демон-циклоп."""
|
||
|
||
import math
|
||
import random
|
||
|
||
from . import assets
|
||
from .entity import Entity, Vector, direction_name
|
||
|
||
KINDS = {
|
||
"slime": {"hp": 22, "speed": 34, "contact": 1, "xp": 3, "score": 30, "hit": (12, 8)},
|
||
"bat": {"hp": 12, "speed": 80, "contact": 1, "xp": 3, "score": 35, "hit": (10, 8)},
|
||
"ninja": {"hp": 38, "speed": 54, "contact": 1, "xp": 6, "score": 60, "hit": (10, 8)},
|
||
"skeleton": {"hp": 28, "speed": 42, "contact": 1, "xp": 7, "score": 70, "hit": (10, 8)},
|
||
"cyclop": {"hp": 150, "speed": 27, "contact": 3, "xp": 22, "score": 250, "hit": (24, 12)},
|
||
}
|
||
|
||
SPAWN_TIME = 0.7
|
||
|
||
|
||
class Enemy(Entity):
|
||
boss = False
|
||
flying = False
|
||
heavy = False
|
||
|
||
def __init__(self, kind, pos, hp_mult=1.0):
|
||
data = KINDS[kind]
|
||
super().__init__(pos, *data["hit"])
|
||
self.kind = kind
|
||
self.max_hp = self.hp = data["hp"] * hp_mult
|
||
self.speed = data["speed"] * random.uniform(0.9, 1.1)
|
||
self.contact = data["contact"]
|
||
self.xp, self.score = data["xp"], data["score"]
|
||
self.state, self.timer = "spawn", SPAWN_TIME
|
||
self.cooldown = random.uniform(0.5, 1.5)
|
||
self.dir = Vector()
|
||
self.load_frames()
|
||
|
||
# --- общая часть ---
|
||
@property
|
||
def active(self):
|
||
return self.state != "spawn"
|
||
|
||
def load_frames(self):
|
||
self.frames = assets.monster(f"{self.kind}.png")
|
||
|
||
def hurt(self, amount, source_pos, knock):
|
||
if not self.active:
|
||
return False
|
||
self.hp -= amount
|
||
self.flash = 0.1
|
||
away = self.pos - Vector(source_pos)
|
||
if away.length_squared() > 0:
|
||
self.knock = away.normalize() * knock * (0.25 if self.heavy else 1.0)
|
||
return True
|
||
|
||
def update(self, dt, game):
|
||
self.flash = max(0.0, self.flash - dt)
|
||
self.cooldown = max(0.0, self.cooldown - dt)
|
||
obstacles = game.walls if self.flying else game.obstacles
|
||
if self.state == "spawn":
|
||
self.timer -= dt
|
||
if self.timer <= 0:
|
||
self.state = "move"
|
||
else:
|
||
self.think(dt, game, obstacles)
|
||
self.apply_knock(dt, obstacles)
|
||
self.animate(dt)
|
||
|
||
def chase(self, dt, game, obstacles, speed=None, direction=None):
|
||
direction = direction if direction is not None else self.direction_to(game.player)
|
||
if direction.length_squared():
|
||
self.facing = direction_name(direction)
|
||
self.steer(direction, (speed or self.speed) * dt, obstacles, dt)
|
||
self.dir = direction
|
||
|
||
def think(self, dt, game, obstacles):
|
||
self.chase(dt, game, obstacles)
|
||
|
||
def animate(self, dt):
|
||
if self.state == "spawn":
|
||
self.image = assets.image("chars/shadow.png") if self.timer < SPAWN_TIME * 0.6 else self.image
|
||
self.rect = self.image.get_rect(center=self.hitbox.center)
|
||
return
|
||
self.anim = (self.anim + dt * 7) % 4
|
||
self.place_image(self.frames[self.facing]["walk"][int(self.anim)], 3)
|
||
|
||
|
||
class Slime(Enemy):
|
||
pass
|
||
|
||
|
||
class Bat(Enemy):
|
||
"""Летает зигзагом и не замечает камней."""
|
||
|
||
flying = True
|
||
|
||
def think(self, dt, game, obstacles):
|
||
to_player = self.direction_to(game.player)
|
||
side = Vector(-to_player.y, to_player.x) * math.sin(self.anim * 2.2 + id(self) % 7) * 0.9
|
||
direction = to_player + side
|
||
self.chase(dt, game, obstacles, direction=direction.normalize() if direction.length_squared() else to_player)
|
||
|
||
|
||
class Ninja(Enemy):
|
||
"""Подбегает, замахивается и делает быстрый выпад."""
|
||
|
||
def load_frames(self):
|
||
self.frames = assets.character("ninja.png")
|
||
|
||
def think(self, dt, game, obstacles):
|
||
player = game.player
|
||
if self.state == "move":
|
||
self.chase(dt, game, obstacles)
|
||
if self.distance_to(player) < 30 and self.cooldown <= 0:
|
||
self.state, self.timer = "windup", 0.4
|
||
self.dir = self.direction_to(player)
|
||
self.facing = direction_name(self.dir)
|
||
elif self.state == "windup":
|
||
self.timer -= dt
|
||
if self.timer <= 0:
|
||
self.state, self.timer = "strike", 0.16
|
||
game.audio.play("sword")
|
||
elif self.state == "strike":
|
||
self.timer -= dt
|
||
self.move(self.dir * 230 * dt, obstacles)
|
||
if self.hitbox.inflate(6, 6).colliderect(player.hitbox):
|
||
game.damage_player(2, self.pos)
|
||
if self.timer <= 0:
|
||
self.state, self.cooldown = "move", 1.1
|
||
|
||
def animate(self, dt):
|
||
if self.state in ("windup", "strike"):
|
||
self.place_image(self.frames[self.facing]["attack"][0], 3)
|
||
if self.state == "windup" and int(self.timer * 30) % 2:
|
||
self.image = assets.white(self.image)
|
||
return
|
||
super().animate(dt)
|
||
|
||
|
||
class Skeleton(Enemy):
|
||
"""Держит дистанцию и метает сюрикены."""
|
||
|
||
def load_frames(self):
|
||
self.frames = assets.character("skeleton.png")
|
||
|
||
def think(self, dt, game, obstacles):
|
||
player = game.player
|
||
dist = self.distance_to(player)
|
||
to_player = self.direction_to(player)
|
||
cornered = not game.world.inner.inflate(-56, -56).collidepoint(self.pos)
|
||
if dist < 85 and not cornered:
|
||
direction = -to_player
|
||
elif dist < 85: # у края арены не пятится, а уходит вбок к центру
|
||
side = Vector(-to_player.y, to_player.x)
|
||
if side.dot(game.world.center - self.pos) < 0:
|
||
side = -side
|
||
direction = side
|
||
elif dist > 150:
|
||
direction = to_player
|
||
else:
|
||
direction = Vector(-to_player.y, to_player.x) * (1 if id(self) % 2 else -1) * 0.6
|
||
self.chase(dt, game, obstacles, direction=direction)
|
||
self.facing = direction_name(to_player)
|
||
if self.cooldown <= 0 and dist < 220:
|
||
self.cooldown = random.uniform(2.0, 2.8)
|
||
game.enemy_shot(self.pos + Vector(0, -6), to_player * 115, 1)
|
||
|
||
|
||
class Cyclop(Enemy):
|
||
"""Медленный и живучий: предупреждает и бросается тараном."""
|
||
|
||
heavy = True
|
||
|
||
def load_frames(self):
|
||
walk = assets.strip("chars/cyclop_walk.png", 50)
|
||
hit = assets.strip("chars/cyclop_hit.png", 50)
|
||
self.frames = {"right": walk, "left": assets.flipped(walk), "hit": hit, "hit_left": assets.flipped(hit)}
|
||
|
||
def think(self, dt, game, obstacles):
|
||
player = game.player
|
||
if self.state == "move":
|
||
self.chase(dt, game, obstacles)
|
||
if self.distance_to(player) < 120 and self.cooldown <= 0:
|
||
self.state, self.timer = "windup", 0.65
|
||
self.dir = self.direction_to(player)
|
||
elif self.state == "windup":
|
||
self.timer -= dt
|
||
if self.timer <= 0:
|
||
self.state, self.timer = "charge", 0.7
|
||
game.audio.play("boss_stomp")
|
||
game.shake.add(2)
|
||
elif self.state == "charge":
|
||
self.timer -= dt
|
||
before = Vector(self.pos)
|
||
self.move(self.dir * 190 * dt, obstacles)
|
||
stuck = self.pos.distance_squared_to(before) < 0.5
|
||
if self.timer <= 0 or stuck:
|
||
self.state, self.cooldown = "move", 2.4
|
||
|
||
def animate(self, dt):
|
||
if self.state == "spawn":
|
||
return super().animate(dt)
|
||
left = self.dir.x < 0
|
||
if self.state == "windup":
|
||
frames = self.frames["hit_left" if left else "hit"]
|
||
image = frames[int(self.timer * 12) % len(frames)]
|
||
else:
|
||
self.anim = (self.anim + dt * (14 if self.state == "charge" else 7)) % 6
|
||
image = self.frames["left" if left else "right"][int(self.anim)]
|
||
self.place_image(image, 9)
|
||
|
||
|
||
CLASSES = {"slime": Slime, "bat": Bat, "ninja": Ninja, "skeleton": Skeleton, "cyclop": Cyclop}
|
||
|
||
|
||
def make(kind, pos, hp_mult=1.0):
|
||
return CLASSES[kind](kind, pos, hp_mult)
|