1
0
Fork 0
mirror of https://github.com/EDeev/pixel_gamble.git synced 2026-10-07 23:49:29 +03:00
pixel_gamble/game/pixelgamble/game.py
Egor Deev 4ecb9257c8 Pixel Gamble 2.0: законченная игра на ресурсах Ninja Adventure
- 10 волн, 5 видов врагов со своим поведением и босс в три фазы
- меч, копьё и посох, 11 улучшений между волнами, опыт и уровни
- меню, пауза, настройки, итоги партии и рекорд, русский и английский
- мир 480×270 с масштабированием, интерфейс в 960×540, скорости от dt
- асинхронный цикл: та же игра в браузере через pygbag
- тесты без окна, автоигрок, CI, релизы exe/Linux, GitHub Pages
- README на двух языках; исходная версия — релиз v1.0.0
2026-10-07 12:29:41 +00:00

496 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Партия: волны, бой, улучшения между волнами, камера и интерфейс."""
import math
import random
import pygame
from . import assets, enemies, ui, upgrades
from .boss import Boss
from .effects import Effect, FloatText, Heart, Particle, Projectile, Shake
from .entity import Vector
from .player import WEAPONS, Player
from .settings import (
BLUE,
GOLD,
GREEN,
GREY,
HEART_DROP_CHANCE,
HEIGHT,
RED,
SCALE,
SCREEN_H,
SCREEN_W,
WAVE_BREAK,
WAVES_TOTAL,
WHITE,
WIDTH,
XP_BASE,
XP_STEP,
)
from .waves import Spawner, hp_multiplier
from .world import World
# поворот спрайта взмаха (нарисован для удара вниз) под направление удара
ARC_ANGLES = {(0, 1): 0, (0, -1): 180, (1, 0): 90, (-1, 0): -90}
def xp_needed(level):
return XP_BASE + XP_STEP * (level - 1)
class Game:
def __init__(self, app, start_wave=1, rng=None):
self.app, self.audio, self.lang = app, app.audio, app.lang
self.rng = rng or random.Random()
self.world = World()
self.walls = self.world.walls
self.obstacles = self.world.obstacles()
self.player = Player(self.world.center)
self.enemies = pygame.sprite.Group()
self.shots = pygame.sprite.Group() # снаряды игрока
self.enemy_shots = pygame.sprite.Group()
self.effects = pygame.sprite.Group()
self.hearts = pygame.sprite.Group()
self.texts = pygame.sprite.Group()
self.shake = Shake()
self.camera = Vector(self.player.pos) - Vector(WIDTH / 2, HEIGHT / 2)
self.clamp_camera()
self.drawn_offset = (0, 0)
self.wave = start_wave
self.taken = {}
self.kills = 0
self.score = 0
self.log = [] # (волна, секунд на волну, здоровье в конце) — для проверок баланса
self.time = 0.0
self.wave_time = 0.0
self.boss = None
self.result = None # "dead" / "victory" — когда партия закончилась
self.end_timer = 0.0
self.offer = []
self.card = 0
self.paused = False
self.pause_menu = ui.Menu([("resume", self.lang("resume")), ("to_menu", self.lang("to_menu"))],
(SCREEN_W // 2, 300))
self.smoke = assets.strip("fx/smoke.png", 32)
self.arc = assets.strip("fx/arc.png", 38)
self.ball = assets.strip("fx/energy_ball.png", 16)
self.shuriken = assets.strip("fx/shuriken.png", 16)
self.rock_bits = assets.strip("fx/rock.png", 16)
self.shadow = assets.image("chars/shadow.png").copy()
self.shadow.set_alpha(120)
self.big_shadow = pygame.transform.scale_by(self.shadow, 2)
self.big_shadow.set_alpha(120)
self.start_wave()
# ------------------------------------------------------------------ волны
def start_wave(self):
self.state, self.timer = "intro", 1.8
self.spawner = Spawner(self.wave, self.rng)
self.wave_time = 0.0
self.audio.music("boss" if self.wave == WAVES_TOTAL else "battle")
self.audio.play("boss_appear" if self.wave == WAVES_TOTAL else "wave_start")
def finish_wave(self):
self.log.append((self.wave, round(self.wave_time, 1), self.player.hp))
bonus = 100 * self.wave + max(0, int(90 - self.wave_time)) * 3
self.score += bonus
self.texts.add(FloatText(f"+{bonus}", self.player.pos + (0, -30), GOLD, 20, 1.4))
if self.wave == WAVES_TOTAL:
self.end("victory")
return
self.state, self.timer = "clear", WAVE_BREAK
self.audio.play("wave_clear")
def open_upgrades(self):
self.offer = upgrades.offer(self.player, self.taken, self.wave + 1, self.rng)
self.card = 0
self.state = "upgrade" if self.offer else "next"
def choose(self, index):
if 0 <= index < len(self.offer):
upgrades.apply(self.offer[index], self.player, self.taken)
self.audio.play("power_up")
self.wave += 1
self.start_wave()
def end(self, result):
if self.result:
return
self.result = result
self.end_timer = 2.0
if result == "victory":
self.score += 2000 + self.player.hp * 50
self.audio.play("victory")
else:
self.audio.play("game_over")
self.effects.add(Effect(self.smoke, self.player.pos + (0, -6), 12))
self.audio.fadeout(1500)
# ------------------------------------------------------------ для врагов
def damage_player(self, amount, source_pos):
if self.result:
return False
if self.player.hurt(amount, source_pos):
self.audio.play("player_hurt")
self.shake.add(3)
if self.player.dead:
self.end("dead")
return True
return False
def enemy_shot(self, pos, velocity, damage):
self.enemy_shots.add(Projectile(self.shuriken, pos, velocity, damage, "enemy", life=2.6, radius=3, spin=True))
self.audio.play("enemy_shoot")
def summon(self, kinds, near):
for kind in kinds:
angle = self.rng.random() * math.tau
pos = Vector(near) + Vector(math.cos(angle), math.sin(angle)) * 40
pos.x = min(max(pos.x, self.world.inner.left + 8), self.world.inner.right - 8)
pos.y = min(max(pos.y, self.world.inner.top + 8), self.world.inner.bottom - 8)
self.add_enemy(kind, pos)
def add_enemy(self, kind, pos):
if kind == "boss":
enemy = Boss(pos)
self.boss = enemy
else:
enemy = enemies.make(kind, pos, hp_multiplier(self.wave))
self.enemies.add(enemy)
self.effects.add(Effect(self.smoke, (pos[0], pos[1] - 4), 10))
# ------------------------------------------------------------- события
def handle(self, event):
if self.paused:
choice = self.pause_menu.handle(event, self.audio)
if choice == "resume" or (event.type == pygame.KEYDOWN and event.key in (pygame.K_ESCAPE, pygame.K_p)):
self.paused = False
elif choice == "to_menu":
self.app.to_menu()
return
if event.type == pygame.KEYDOWN:
if event.key in (pygame.K_ESCAPE, pygame.K_p) and not self.result:
self.paused = True
self.pause_menu.index = 0
self.audio.play("menu_back")
elif self.state == "upgrade":
if event.key in (pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_KP1, pygame.K_KP2, pygame.K_KP3):
self.choose({pygame.K_1: 0, pygame.K_2: 1, pygame.K_3: 2, pygame.K_KP1: 0,
pygame.K_KP2: 1, pygame.K_KP3: 2}[event.key])
elif event.key in (pygame.K_LEFT, pygame.K_a):
self.card = (self.card - 1) % len(self.offer)
self.audio.play("menu_move")
elif event.key in (pygame.K_RIGHT, pygame.K_d):
self.card = (self.card + 1) % len(self.offer)
self.audio.play("menu_move")
elif event.key in (pygame.K_RETURN, pygame.K_SPACE, pygame.K_KP_ENTER):
self.choose(self.card)
elif event.key in (pygame.K_q, pygame.K_TAB):
if self.player.switch(1):
self.audio.play("switch")
elif event.key in (pygame.K_1, pygame.K_2, pygame.K_3):
if self.player.switch(index=event.key - pygame.K_1):
self.audio.play("switch")
elif event.type == pygame.MOUSEWHEEL and self.state != "upgrade":
if self.player.switch(-1 if event.y > 0 else 1):
self.audio.play("switch")
elif self.state == "upgrade" and event.type == pygame.MOUSEMOTION:
for i, rect in enumerate(ui.card_rects(len(self.offer))):
if rect.collidepoint(event.pos):
self.card = i
elif self.state == "upgrade" and event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
for i, rect in enumerate(ui.card_rects(len(self.offer))):
if rect.collidepoint(event.pos):
self.choose(i)
def read_controls(self):
"""Движение и атака с клавиатуры/мыши (бот подменяет этот метод)."""
keys = pygame.key.get_pressed()
move = Vector((keys[pygame.K_d] or keys[pygame.K_RIGHT]) - (keys[pygame.K_a] or keys[pygame.K_LEFT]),
(keys[pygame.K_s] or keys[pygame.K_DOWN]) - (keys[pygame.K_w] or keys[pygame.K_UP]))
mouse = pygame.mouse.get_pressed()[0] and pygame.mouse.get_focused()
attack = mouse or keys[pygame.K_SPACE] or keys[pygame.K_j]
target = Vector(pygame.mouse.get_pos()) / SCALE + self.camera if mouse else None
return move, attack, target
# ------------------------------------------------------------- кадр
def update(self, dt, controls=None):
if self.paused:
return
if self.result:
self.end_timer -= dt
dt *= 0.35 # замедление в конце партии
if self.end_timer <= 0:
self.app.show_results(self)
return
self.time += dt
self.wave_time += dt
move, attack, target = controls or self.read_controls()
busy = self.state == "upgrade" or self.result
if busy:
move, attack = Vector(), False
self.player.update(dt, move, self.obstacles)
if attack:
self.attack(target)
if self.state == "intro":
self.timer -= dt
if self.timer <= 0:
self.state = "fight"
elif self.state == "fight":
alive = sum(1 for e in self.enemies if not e.boss)
for kind, pos in self.spawner.update(dt, alive, self.player.pos, self.world.spawns):
self.add_enemy(kind, pos)
if self.spawner.done() and not self.enemies and not self.result:
self.finish_wave()
elif self.state == "clear":
self.timer -= dt
if self.timer <= 0:
self.open_upgrades()
elif self.state == "next":
self.wave += 1
self.start_wave()
for enemy in list(self.enemies):
enemy.update(dt, self)
self.separate()
for actor in [self.player, *self.enemies]:
actor.keep_inside(self.world.inner)
self.contact_damage()
self.update_shots(dt)
self.effects.update(dt)
self.texts.update(dt)
for heart in list(self.hearts):
heart.update(dt, self.player)
if heart.alive() and heart.rect.colliderect(self.player.hitbox.inflate(6, 10)):
if self.player.heal(4): # при полном здоровье сердечко остаётся лежать
self.audio.play("heal")
heart.kill()
self.follow(dt)
def attack(self, target):
direction = self.player.try_attack(target)
if direction is None:
return
player = self.player
weapon = WEAPONS[player.weapon]
self.audio.play(player.weapon)
if weapon["kind"] == "bolt":
spread = (-14, 0, 14) if player.multishot else (0,)
for angle in spread:
vel = direction.rotate(angle) * weapon["speed"]
start = player.pos + Vector(0, -6) + direction * 6
damage = player.damage() * (1.0 if angle == 0 else 0.6) # боковые заряды слабее
self.shots.add(Projectile(self.ball, start, vel, damage, "player", weapon["life"],
weapon["knock"] * player.knock_mult))
return
box = player.melee_box(direction)
self.melee_effect(direction, box, weapon["kind"])
for enemy in list(self.enemies):
body = enemy.hitbox.inflate(6, 12).move(0, -5)
if box.colliderect(body) and enemy.hurt(player.damage(), player.pos, weapon["knock"] * player.knock_mult):
self.on_enemy_hit(enemy)
for prop in list(self.world.props):
if prop.breakable and box.colliderect(prop.hitbox):
self.break_rock(prop)
def melee_effect(self, direction, box, kind):
if kind == "arc":
angle = ARC_ANGLES[(round(direction.x), round(direction.y))]
frames = [pygame.transform.rotate(f, angle) for f in self.arc]
self.effects.add(Effect(frames, box.center, 34, sort_y=box.centery + 10))
else:
image = assets.image("items/lance_hand.png")
angle = -math.degrees(math.atan2(direction.y, direction.x)) - 90
spear = pygame.transform.rotate(pygame.transform.flip(image, False, True), angle)
hand = self.player.pos + Vector(0, -5)
effect = Effect([spear] * 5, hand + direction * 16, 30, sort_y=self.player.sort_y + 1)
effect.thrust = (Vector(hand), direction)
self.effects.add(effect)
quarter = ARC_ANGLES[(round(direction.x), round(direction.y))]
tip = [pygame.transform.rotate(f, quarter) for f in self.arc[2:]]
self.effects.add(Effect(tip, box.center + direction * 6, 40, sort_y=box.centery + 10))
def on_enemy_hit(self, enemy):
self.audio.play("enemy_hit")
if enemy.hp <= 0:
self.kill_enemy(enemy)
def kill_enemy(self, enemy):
enemy.kill()
self.kills += 1
self.score += enemy.score
self.effects.add(Effect(self.smoke, enemy.hitbox.center, 14))
self.audio.play("enemy_die")
if enemy.boss:
self.boss = None
self.shake.add(5)
for other in list(self.enemies):
other.kill()
self.effects.add(Effect(self.smoke, other.hitbox.center, 14))
return
levels = self.player.gain_xp(enemy.xp, xp_needed)
if levels:
self.player.damage_mult *= 1.02 ** levels
self.player.heal(3 * levels)
self.audio.play("level_up")
self.texts.add(FloatText(self.lang("level_up"), self.player.pos + (0, -24), GOLD, 20, 1.2))
if self.player.vampire and self.rng.random() < self.player.vampire:
if self.player.heal(1):
self.texts.add(FloatText("+", self.player.pos + (0, -18), GREEN))
chance = 0.5 if enemy.kind == "cyclop" else HEART_DROP_CHANCE
if self.rng.random() < chance:
self.hearts.add(Heart(enemy.pos))
def break_rock(self, prop):
prop.kill()
self.obstacles = self.world.obstacles()
self.audio.play("rock_break")
self.score += 5
for _ in range(6):
angle = self.rng.random() * math.tau
self.effects.add(Particle(self.rng.choice(self.rock_bits), prop.hitbox.center,
Vector(math.cos(angle), math.sin(angle) - 0.8) * self.rng.uniform(30, 70)))
if self.rng.random() < 0.2:
self.hearts.add(Heart(prop.hitbox.center))
def separate(self):
"""Враги не слипаются в одну точку."""
crowd = [e for e in self.enemies if e.active and not e.boss]
for i, a in enumerate(crowd):
for b in crowd[i + 1:]:
delta = a.pos - b.pos
dist2 = delta.length_squared()
if 0 < dist2 < 144:
push = delta.normalize() * (12 - math.sqrt(dist2)) * 0.5
a.move(push, self.walls if a.flying else self.obstacles)
b.move(-push, self.walls if b.flying else self.obstacles)
def contact_damage(self):
player = self.player
for enemy in self.enemies:
if enemy.active and enemy.hitbox.colliderect(player.hitbox.inflate(2, 2)):
damage = enemy.contact
if getattr(enemy, "state", "") in ("charge", "dash"):
damage += 1
self.damage_player(damage, enemy.pos)
def update_shots(self, dt):
self.shots.update(dt)
self.enemy_shots.update(dt)
for shot in list(self.shots):
box = shot.hitbox()
for enemy in self.enemies:
if enemy.active and box.colliderect(enemy.hitbox.inflate(6, 12).move(0, -5)):
if enemy.hurt(shot.damage, shot.pos - shot.vel.normalize() * 8, shot.knock):
self.on_enemy_hit(enemy)
shot.kill()
break
if not shot.alive():
continue
if box.collidelist(self.walls) != -1:
shot.kill()
continue
for prop in self.world.props:
if box.colliderect(prop.hitbox):
if prop.breakable:
self.break_rock(prop)
shot.kill()
break
for shot in list(self.enemy_shots):
box = shot.hitbox()
if box.colliderect(self.player.hitbox.inflate(2, 6)):
self.damage_player(shot.damage, shot.pos)
shot.kill()
elif box.collidelist(self.obstacles) != -1:
shot.kill()
# ------------------------------------------------------------- камера
def clamp_camera(self):
self.camera.x = min(max(self.camera.x, 0), self.world.width - WIDTH)
self.camera.y = min(max(self.camera.y, 0), self.world.height - HEIGHT)
def follow(self, dt):
goal = self.player.pos - Vector(WIDTH / 2, HEIGHT / 2)
self.camera += (goal - self.camera) * min(1.0, dt * 8)
self.clamp_camera()
# ------------------------------------------------------------- рисование
def draw_world(self, view, dt):
shake = self.shake.offset(dt)
ox, oy = round(self.camera.x - shake.x), round(self.camera.y - shake.y)
self.drawn_offset = (ox, oy)
view.blit(self.world.floor, (-ox, -oy))
area = pygame.Rect(ox - 48, oy - 48, WIDTH + 96, HEIGHT + 96)
actors = [e for e in self.enemies if e.active]
if not self.player.dead:
actors.append(self.player)
for actor in actors:
shadow = self.big_shadow if actor.heavy else self.shadow
view.blit(shadow, shadow.get_rect(center=(actor.hitbox.centerx - ox, actor.hitbox.bottom - oy)))
drawables = [p for p in self.world.props if area.colliderect(p.rect)]
drawables += [e for e in self.enemies if area.colliderect(e.rect)]
drawables += list(self.hearts) + list(self.shots) + list(self.enemy_shots) + list(self.effects)
if not self.player.dead:
drawables.append(self.player)
for sprite in sorted(drawables, key=lambda s: s.sort_y):
thrust = getattr(sprite, "thrust", None)
if thrust: # копьё выдвигается вперёд за время удара
base, direction = thrust
pos = base + direction * (8 + 18 * min(1.0, sprite.t * 10))
rect = sprite.image.get_rect(center=(round(pos.x), round(pos.y)))
view.blit(sprite.image, rect.move(-ox, -oy))
else:
view.blit(sprite.image, sprite.rect.move(-ox, -oy))
def draw_ui(self, screen):
lang, player = self.lang, self.player
ox, oy = self.drawn_offset
for sprite in self.texts:
pos = ((sprite.pos.x - ox) * SCALE, (sprite.pos.y - oy) * SCALE)
screen.blit(sprite.image, sprite.image.get_rect(center=(round(pos[0]), round(pos[1]))))
ui.hearts(screen, player, (12, 10))
ui.text(screen, lang("level", n=player.level), (12, 46), ui.SMALL, GOLD)
ui.bar(screen, (80, 52, 128, 12), player.xp / xp_needed(player.level), BLUE)
ui.weapon_slots(screen, player, (12, SCREEN_H - 66))
if len(player.weapons) > 1:
ui.text(screen, "Q", (12 + len(player.weapons) * 44 + 4, SCREEN_H - 40), ui.SMALL, GREY)
ui.text(screen, lang("wave", n=self.wave, total=WAVES_TOTAL), (SCREEN_W // 2, 10), ui.SMALL, WHITE, "midtop")
if self.state == "fight" and not self.boss:
left = len(self.spawner.queue) + len(self.enemies)
ui.text(screen, lang("enemies_left", n=left), (SCREEN_W // 2, 34), ui.SMALL, GREY, "midtop")
ui.text(screen, f"{self.score:,}".replace(",", " "), (SCREEN_W - 12, 10), ui.SMALL, GOLD, "topright")
if self.boss and self.boss.alive():
rect = pygame.Rect(SCREEN_W // 2 - 220, SCREEN_H - 40, 440, 18)
ui.bar(screen, rect, max(0.0, self.boss.hp / self.boss.max_hp), RED)
ui.text(screen, lang("boss_name"), (SCREEN_W // 2, rect.top - 4), ui.SMALL, WHITE, "midbottom")
face = ui.big(assets.image("chars/boss_face.png"))
screen.blit(face, face.get_rect(midright=(rect.left - 8, rect.centery - 12)))
middle = (SCREEN_W // 2, SCREEN_H // 2 - 60)
if self.state == "intro":
label = lang("boss_wave") if self.wave == WAVES_TOTAL else lang("wave_big", n=self.wave)
ui.text(screen, label, middle, ui.HUGE, GOLD, "center")
elif self.state == "clear":
ui.text(screen, lang("wave_clear"), middle, ui.LARGE, GREEN, "center")
elif self.state == "upgrade":
ui.dim(screen, 140)
ui.text(screen, lang("choose"), (SCREEN_W // 2, 80), ui.LARGE, GOLD, "center")
ui.cards(screen, self.offer, lang, self.card)
ui.text(screen, lang("choose_hint"), (SCREEN_W // 2, SCREEN_H - 52), ui.SMALL, GREY, "center")
if self.result:
label = lang("victory") if self.result == "victory" else lang("game_over")
ui.text(screen, label, middle, ui.HUGE, GOLD if self.result == "victory" else RED, "center")
if self.paused:
ui.dim(screen, 150)
ui.text(screen, lang("pause"), (SCREEN_W // 2, 200), ui.LARGE, WHITE, "center")
self.pause_menu.draw(screen)