Пишем игру на Python: неоновая аркада с разбором кода и ИИ
Pygame часто считают библиотекой для школьных проектов, и зря. На нём за вечер собирается динамичная аркада с неоновой графикой, частицами, тряской камеры и волнами врагов, которая выглядит как маленькая инди-игра. В этой статье мы напишем такую игру на Python с нуля, разберём каждый важный кусок кода и покажем, как ускорить разработку с помощью ИИ-ассистентов и грамотных промптов. В конце вас ждёт полный код, который можно скопировать и сразу запустить.
Что получится в итоге
Игра называется Neon Void. Это твин-стик шутер в духе Geometry Wars: вы управляете светящимся кораблём на WASD, целитесь мышью, стреляете левой кнопкой и уходите от врагов рывком на пробел. Противники лезут со всех сторон волнами, каждая следующая быстрее и плотнее, а с третьей волны появляются жёлтые «танки», которым нужно шесть попаданий. За серию убийств растёт множитель очков вплоть до x10, а пропущенный удар сбрасывает его. Весь проект умещается в один файл примерно на 340 строк без картинок и внешних ассетов: вся графика рисуется кодом.
Установка и запуск
Понадобится Python 3.10 или новее и библиотека Pygame. Установите её одной командой, сохраните код из конца статьи в файл neon_void.py и запустите.
pip install pygame
python neon_void.pyЕсли под вашу версию Python нет готовой сборки pygame, поставьте активно развиваемый форк pygame-ce командой pip install pygame-ce. Код совместим с обоими вариантами без изменений.
Игровой цикл и delta time
Любая игра крутится в бесконечном цикле из трёх шагов: обработать ввод, обновить состояние мира и нарисовать кадр. У нас за это отвечает метод run класса Game.
def run(self):
running = True
while running:
# dt в секундах: игра работает одинаково на 60 и на 144 Гц
dt = min(self.clock.tick(FPS) / 1000, 1 / 30)
running = self.handle_events()
self.update(dt)
self.draw()
pygame.quit()
sys.exit()Главная деталь здесь переменная dt, время кадра в секундах. Все скорости в игре заданы в пикселях в секунду и умножаются на dt, поэтому игра идёт одинаково на мониторе 60 Гц и 144 Гц. Ограничение min(…, 1 / 30) защищает от ситуации, когда окно перетащили, игра подвисла на секунду и враги за один кадр телепортировались сквозь игрока.
Неоновое свечение без шейдеров
Эффект свечения обычно делают шейдерами, но в Pygame можно обойтись хитростью. Функция glow_circle рисует на прозрачной поверхности несколько полупрозрачных кругов убывающего размера, а сверху яркое ядро.
def glow_circle(radius, color, layers=4):
"""Рисует круг с мягким свечением на отдельной поверхности."""
size = radius * 4
surf = pygame.Surface((size, size), pygame.SRCALPHA)
center = (size // 2, size // 2)
for i in range(layers, 0, -1):
alpha = int(60 / i)
pygame.draw.circle(surf, (*color, alpha), center, radius + i * radius // 2)
pygame.draw.circle(surf, color, center, radius)
return surfПри отрисовке спрайт выводится с флагом pygame.BLEND_ADD: цвета не перекрывают друг друга, а складываются, поэтому пересекающиеся объекты светятся ярче, как настоящий неон. Спрайт создаётся один раз в конструкторе объекта, а не на каждом кадре, и это заметно экономит ресурсы.
Управление, которое приятно чувствовать
Если просто присваивать скорость по нажатию клавиши, корабль двигается как на шарнирах. Мы плавно тянем текущую скорость к целевой, и управление сразу становится «дорогим» и отзывчивым.
def update(self, dt, keys):
direction = Vector2(
keys[pygame.K_d] - keys[pygame.K_a],
keys[pygame.K_s] - keys[pygame.K_w],
)
if direction.length_squared() > 0:
direction = direction.normalize()
self.dash_cooldown = max(0.0, self.dash_cooldown - dt)
self.invulnerable = max(0.0, self.invulnerable - dt)
self.fire_timer = max(0.0, self.fire_timer - dt)
if self.dash_timer > 0:
self.dash_timer -= dt
else:
# Плавное ускорение вместо мгновенной скорости: так управление ощущается "дорогим"
target = direction * self.SPEED
self.vel += (target - self.vel) * min(1.0, 12 * dt)
self.pos += self.vel * dt
self.pos.x = max(self.radius, min(WIDTH - self.radius, self.pos.x))
self.pos.y = max(self.radius, min(HEIGHT - self.radius, self.pos.y))Нормализация вектора направления нужна, чтобы по диагонали корабль не летал быстрее, чем по прямой. Рывок устроен ещё проще: на 0.12 секунды скорость резко вырастает до 1400 пикселей в секунду, а игрок получает короткую неуязвимость. Этот приём знаком по Hades и Enter the Gungeon, он превращает уклонение в навык, а не в удачу.
def dash(self):
if self.dash_cooldown > 0 or self.vel.length_squared() < 1:
return False
self.vel = self.vel.normalize() * self.DASH_SPEED
self.dash_timer = self.DASH_TIME
self.dash_cooldown = self.DASH_COOLDOWN
self.invulnerable = max(self.invulnerable, self.DASH_TIME + 0.05)
return TrueВ стрельбе есть маленький, но важный штрих: случайный разброс в несколько сотых радиана через rotate_rad. Очередь перестаёт выглядеть как лазерная линия и становится живой.
Враги и система волн
Функция spawn_enemy выбирает случайную сторону экрана и создаёт противника чуть за её краем, чтобы он не появлялся прямо перед носом игрока. Скорость обычных врагов растёт с номером волны, а с третьей волны с шансом 20% приходит медленный, но толстый танк.
def spawn_enemy(wave):
"""Враг появляется за краем экрана. Чем выше волна, тем злее противники."""
side = random.choice(("top", "bottom", "left", "right"))
margin = 40
if side == "top":
pos = (random.uniform(0, WIDTH), -margin)
elif side == "bottom":
pos = (random.uniform(0, WIDTH), HEIGHT + margin)
elif side == "left":
pos = (-margin, random.uniform(0, HEIGHT))
else:
pos = (WIDTH + margin, random.uniform(0, HEIGHT))
if wave >= 3 and random.random() < 0.2:
return Enemy(pos, speed=70 + wave * 4, hp=6, radius=26, color=YELLOW)
return Enemy(pos, speed=120 + wave * 10, hp=1, radius=12, color=MAGENTA)Метод update_waves выпускает врагов по одному с интервалом, который тоже сокращается с каждой волной. Когда все противники волны уничтожены, начинается следующая, и в ней врагов становится на три больше. Такая простая формула даёт плавный рост сложности без ручной настройки уровней.
def update_waves(self, dt):
if self.to_spawn > 0:
self.spawn_timer -= dt
if self.spawn_timer <= 0:
self.enemies.append(spawn_enemy(self.wave))
self.to_spawn -= 1
self.spawn_timer = max(0.15, 0.8 - self.wave * 0.05)
elif not self.enemies:
self.wave += 1
self.to_spawn = 6 + self.wave * 3Частицы, тряска экрана и комбо
Именно эти мелочи отличают сочную аркаду от учебного примера. Каждая частица хранит позицию, скорость и время жизни, замедляется на каждом кадре и тускнеет по мере угасания.
@dataclass
class Particle:
pos: Vector2
vel: Vector2
color: tuple
life: float
max_life: float = field(init=False)
def __post_init__(self):
self.max_life = self.life
def update(self, dt):
self.pos += self.vel * dt
self.vel *= 0.92
self.life -= dt
return self.life > 0
def draw(self, screen, offset):
k = self.life / self.max_life
radius = max(1, int(4 * k))
color = tuple(int(c * k) for c in self.color)
pygame.draw.circle(screen, color, self.pos + offset, radius)Метод burst разбрасывает пачку частиц во все стороны при взрыве врага, получении урона и рывке. Для тряски мы не двигаем объекты, а считаем общее смещение offset, которое добавляется ко всем координатам при отрисовке. Сила тряски зависит от события: мелкий враг почти не трясёт экран, танк встряхивает заметнее, а удар по игроку ощущается по-настоящему. Белая вспышка hit_flash на 50 миллисекунд при попадании даёт мгновенную обратную связь, что пуля долетела.
def draw(self):
offset = Vector2()
if self.shake > 0:
power = self.shake * 40
offset = Vector2(random.uniform(-power, power), random.uniform(-power, power))
self.screen.fill(BG_COLOR)
self.draw_grid(offset)
for p in self.particles:
p.draw(self.screen, offset)
for b in self.bullets:
pygame.draw.circle(self.screen, YELLOW, b.pos + offset, b.radius)
for e in self.enemies:
e.draw(self.screen, offset)
if not self.game_over:
self.player.draw(self.screen, offset)
self.draw_hud()
pygame.display.flip()Коллизии
Все объекты в игре круглые, поэтому проверка столкновений сводится к одному сравнению: расстояние между центрами меньше суммы радиусов. Обратите внимание, что мы перебираем копии списков через срез [:], ведь удалять элементы из списка во время его обхода нельзя.
def handle_collisions(self):
for enemy in self.enemies[:]:
for bullet in self.bullets[:]:
if enemy.pos.distance_to(bullet.pos) < enemy.radius + bullet.radius:
self.bullets.remove(bullet)
enemy.hp -= 1
enemy.hit_flash = 0.05
if enemy.hp <= 0:
self.enemies.remove(enemy)
self.burst(enemy.pos, enemy.color, count=int(enemy.radius * 1.5))
self.score += 10 * self.combo
self.combo = min(self.combo + 1, 10)
self.combo_timer = 2.0
self.shake = max(self.shake, 0.15 if enemy.radius > 20 else 0.06)
breakДля сотни объектов такой перебор «каждый с каждым» работает мгновенно. Если захотите тысячи врагов, разбейте поле на сетку ячеек и проверяйте только соседей, это классическая оптимизация spatial hashing.
Как использовать ИИ и промпты в разработке игр
ИИ-ассистенты вроде ChatGPT, Claude, Gemini или GitHub Copilot отлично подходят для такой разработки, но только если правильно с ними разговаривать. Просьба «напиши мне игру» обычно заканчивается кашей из устаревших API и кода, который невозможно поддерживать. Работает другой подход: вы остаётесь архитектором, а ИИ пишет отдельные блоки по чёткому техническому заданию.
Первое правило: давайте контекст и ограничения. Укажите версию библиотеки, размер окна, стиль кода и то, чего делать не нужно. Хороший стартовый промпт для этого проекта выглядит так.
Ты опытный разработчик игр на Python. Напиши на Pygame 2 каркас аркады:
класс Game с игровым циклом, delta time, окно 1280x720, 60 FPS.
Код в одном файле, без внешних ассетов, с комментариями на русском.
Пока без врагов и стрельбы, только движение игрока на WASD с инерцией.Второе правило: одна система за раз. Не просите сразу врагов, частицы и меню. Добавляйте механики по одной и передавайте ассистенту актуальный код, чтобы он не выдумывал структуру, которой у вас нет.
Вот мой класс Player: [вставить код].
Добавь рывок на пробел: скорость 1400 px/s, длительность 0.12 с,
кулдаун 0.9 с, неуязвимость на время рывка.
Не меняй остальную логику и покажи только изменённые методы.Третье правило: используйте ИИ как ревьюера. Модели хорошо находят баги и узкие места, если прямо попросить их об этом и задать сценарий нагрузки.
Проверь этот код на баги и проблемы производительности
при 500 врагах на экране. Выдай список проблем по приоритету
и исправление для каждой.Четвёртое правило: просите идеи, а не только код. Ассистент отлично генерирует варианты геймдизайна, из которых вы выберете лучшее.
Предложи 5 идей, как сделать эту аркаду сочнее (game feel).
Каждая должна реализовываться меньше чем в 30 строк
и не требовать внешних файлов.И главное правило: не доверяйте слепо. Запускайте каждый сгенерированный фрагмент, читайте его и спрашивайте «почему так», если что-то непонятно. ИИ ускоряет разработку в разы, но ответственность за архитектуру и качество остаётся на вас. Кстати, именно так появились многие детали этой игры: плавная инерция, разброс пуль и вспышка при попадании пришли из запроса про game feel.
Куда развивать проект
Базовая версия готова, и её легко расширять. Добавьте звуки выстрелов и взрывов через pygame.mixer, сохраняйте рекорд в JSON-файл между запусками, сделайте бонусы вроде тройного выстрела или щита, а каждую пятую волну выпускайте босса со своим паттерном атак. Когда игра будет готова, соберите её в исполняемый файл через PyInstaller и отправьте друзьям. Каждую из этих задач удобно отдавать ИИ-ассистенту по правилам из раздела выше.
Полный код игры на Python
Скопируйте код целиком в файл neon_void.py и запустите командой python neon_void.py.
"""Neon Void: неоновая аркада на Python и Pygame.
Управление: WASD двигаться, мышь целиться, ЛКМ стрелять,
Пробел рывок, R рестарт после поражения, Esc выход.
"""
import math
import random
import sys
from dataclasses import dataclass, field
import pygame
from pygame.math import Vector2
WIDTH, HEIGHT = 1280, 720
FPS = 60
BG_COLOR = (8, 8, 20)
CYAN = (0, 240, 255)
MAGENTA = (255, 40, 200)
YELLOW = (255, 220, 60)
WHITE = (240, 240, 255)
def glow_circle(radius, color, layers=4):
"""Рисует круг с мягким свечением на отдельной поверхности."""
size = radius * 4
surf = pygame.Surface((size, size), pygame.SRCALPHA)
center = (size // 2, size // 2)
for i in range(layers, 0, -1):
alpha = int(60 / i)
pygame.draw.circle(surf, (*color, alpha), center, radius + i * radius // 2)
pygame.draw.circle(surf, color, center, radius)
return surf
@dataclass
class Particle:
pos: Vector2
vel: Vector2
color: tuple
life: float
max_life: float = field(init=False)
def __post_init__(self):
self.max_life = self.life
def update(self, dt):
self.pos += self.vel * dt
self.vel *= 0.92
self.life -= dt
return self.life > 0
def draw(self, screen, offset):
k = self.life / self.max_life
radius = max(1, int(4 * k))
color = tuple(int(c * k) for c in self.color)
pygame.draw.circle(screen, color, self.pos + offset, radius)
@dataclass
class Bullet:
pos: Vector2
vel: Vector2
radius: int = 4
def update(self, dt):
self.pos += self.vel * dt
return 0 <= self.pos.x <= WIDTH and 0 <= self.pos.y <= HEIGHT
class Player:
SPEED = 380
DASH_SPEED = 1400
DASH_TIME = 0.12
DASH_COOLDOWN = 0.9
FIRE_RATE = 0.11
def __init__(self):
self.pos = Vector2(WIDTH / 2, HEIGHT / 2)
self.vel = Vector2()
self.radius = 14
self.hp = 3
self.fire_timer = 0.0
self.dash_timer = 0.0
self.dash_cooldown = 0.0
self.invulnerable = 0.0
self.sprite = glow_circle(self.radius, CYAN)
def update(self, dt, keys):
direction = Vector2(
keys[pygame.K_d] - keys[pygame.K_a],
keys[pygame.K_s] - keys[pygame.K_w],
)
if direction.length_squared() > 0:
direction = direction.normalize()
self.dash_cooldown = max(0.0, self.dash_cooldown - dt)
self.invulnerable = max(0.0, self.invulnerable - dt)
self.fire_timer = max(0.0, self.fire_timer - dt)
if self.dash_timer > 0:
self.dash_timer -= dt
else:
# Плавное ускорение вместо мгновенной скорости: так управление ощущается "дорогим"
target = direction * self.SPEED
self.vel += (target - self.vel) * min(1.0, 12 * dt)
self.pos += self.vel * dt
self.pos.x = max(self.radius, min(WIDTH - self.radius, self.pos.x))
self.pos.y = max(self.radius, min(HEIGHT - self.radius, self.pos.y))
def dash(self):
if self.dash_cooldown > 0 or self.vel.length_squared() < 1:
return False
self.vel = self.vel.normalize() * self.DASH_SPEED
self.dash_timer = self.DASH_TIME
self.dash_cooldown = self.DASH_COOLDOWN
self.invulnerable = max(self.invulnerable, self.DASH_TIME + 0.05)
return True
def try_fire(self, target):
if self.fire_timer > 0:
return None
self.fire_timer = self.FIRE_RATE
aim = target - self.pos
if aim.length_squared() == 0:
aim = Vector2(0, -1)
aim = aim.normalize()
spread = random.uniform(-0.04, 0.04)
return Bullet(self.pos + aim * 20, aim.rotate_rad(spread) * 900)
def draw(self, screen, offset):
if self.invulnerable > 0 and int(self.invulnerable * 20) % 2 == 0:
return
rect = self.sprite.get_rect(center=self.pos + offset)
screen.blit(self.sprite, rect, special_flags=pygame.BLEND_ADD)
class Enemy:
def __init__(self, pos, speed, hp, radius, color):
self.pos = Vector2(pos)
self.speed = speed
self.hp = hp
self.radius = radius
self.color = color
self.sprite = glow_circle(radius, color)
self.hit_flash = 0.0
def update(self, dt, player_pos):
to_player = player_pos - self.pos
if to_player.length_squared() > 0:
self.pos += to_player.normalize() * self.speed * dt
self.hit_flash = max(0.0, self.hit_flash - dt)
def draw(self, screen, offset):
rect = self.sprite.get_rect(center=self.pos + offset)
screen.blit(self.sprite, rect, special_flags=pygame.BLEND_ADD)
if self.hit_flash > 0:
pygame.draw.circle(screen, WHITE, self.pos + offset, self.radius)
def spawn_enemy(wave):
"""Враг появляется за краем экрана. Чем выше волна, тем злее противники."""
side = random.choice(("top", "bottom", "left", "right"))
margin = 40
if side == "top":
pos = (random.uniform(0, WIDTH), -margin)
elif side == "bottom":
pos = (random.uniform(0, WIDTH), HEIGHT + margin)
elif side == "left":
pos = (-margin, random.uniform(0, HEIGHT))
else:
pos = (WIDTH + margin, random.uniform(0, HEIGHT))
if wave >= 3 and random.random() < 0.2:
return Enemy(pos, speed=70 + wave * 4, hp=6, radius=26, color=YELLOW)
return Enemy(pos, speed=120 + wave * 10, hp=1, radius=12, color=MAGENTA)
class Game:
def __init__(self):
pygame.init()
pygame.display.set_caption("Neon Void")
self.screen = pygame.display.set_mode((WIDTH, HEIGHT))
self.clock = pygame.time.Clock()
self.font = pygame.font.Font(None, 36)
self.big_font = pygame.font.Font(None, 96)
self.best_score = 0
self.reset()
def reset(self):
self.player = Player()
self.bullets = []
self.enemies = []
self.particles = []
self.score = 0
self.combo = 1
self.combo_timer = 0.0
self.wave = 1
self.to_spawn = 6
self.spawn_timer = 0.0
self.shake = 0.0
self.game_over = False
def burst(self, pos, color, count=18, speed=320):
for _ in range(count):
angle = random.uniform(0, math.tau)
vel = Vector2(math.cos(angle), math.sin(angle)) * random.uniform(0.3, 1) * speed
self.particles.append(Particle(Vector2(pos), vel, color, random.uniform(0.3, 0.7)))
def handle_events(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
return False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
return False
if event.key == pygame.K_SPACE and not self.game_over:
if self.player.dash():
self.burst(self.player.pos, CYAN, count=10, speed=200)
if event.key == pygame.K_r and self.game_over:
self.reset()
return True
def update_waves(self, dt):
if self.to_spawn > 0:
self.spawn_timer -= dt
if self.spawn_timer <= 0:
self.enemies.append(spawn_enemy(self.wave))
self.to_spawn -= 1
self.spawn_timer = max(0.15, 0.8 - self.wave * 0.05)
elif not self.enemies:
self.wave += 1
self.to_spawn = 6 + self.wave * 3
def handle_collisions(self):
for enemy in self.enemies[:]:
for bullet in self.bullets[:]:
if enemy.pos.distance_to(bullet.pos) < enemy.radius + bullet.radius:
self.bullets.remove(bullet)
enemy.hp -= 1
enemy.hit_flash = 0.05
if enemy.hp <= 0:
self.enemies.remove(enemy)
self.burst(enemy.pos, enemy.color, count=int(enemy.radius * 1.5))
self.score += 10 * self.combo
self.combo = min(self.combo + 1, 10)
self.combo_timer = 2.0
self.shake = max(self.shake, 0.15 if enemy.radius > 20 else 0.06)
break
for enemy in self.enemies[:]:
touching = self.player.pos.distance_to(enemy.pos) < self.player.radius + enemy.radius
if touching and self.player.invulnerable <= 0:
self.enemies.remove(enemy)
self.player.hp -= 1
self.player.invulnerable = 1.5
self.combo = 1
self.shake = 0.35
self.burst(self.player.pos, CYAN, count=40, speed=450)
if self.player.hp <= 0:
self.game_over = True
self.best_score = max(self.best_score, self.score)
def update(self, dt):
self.particles = [p for p in self.particles if p.update(dt)]
self.shake = max(0.0, self.shake - dt)
if self.game_over:
return
keys = pygame.key.get_pressed()
self.player.update(dt, keys)
if pygame.mouse.get_pressed()[0]:
bullet = self.player.try_fire(Vector2(pygame.mouse.get_pos()))
if bullet:
self.bullets.append(bullet)
self.bullets = [b for b in self.bullets if b.update(dt)]
for enemy in self.enemies:
enemy.update(dt, self.player.pos)
self.combo_timer -= dt
if self.combo_timer <= 0:
self.combo = 1
self.update_waves(dt)
self.handle_collisions()
def draw_grid(self, offset):
for x in range(0, WIDTH, 64):
pygame.draw.line(self.screen, (20, 20, 45), (x + offset.x, 0), (x + offset.x, HEIGHT))
for y in range(0, HEIGHT, 64):
pygame.draw.line(self.screen, (20, 20, 45), (0, y + offset.y), (WIDTH, y + offset.y))
def draw_hud(self):
hud = f"SCORE {self.score} x{self.combo} WAVE {self.wave} HP {self.player.hp}"
self.screen.blit(self.font.render(hud, True, WHITE), (20, 16))
if self.player.dash_cooldown > 0:
width = int(120 * (1 - self.player.dash_cooldown / Player.DASH_COOLDOWN))
pygame.draw.rect(self.screen, CYAN, (20, 52, width, 6))
if self.game_over:
title = self.big_font.render("GAME OVER", True, MAGENTA)
info = self.font.render(
f"Счёт: {self.score} Рекорд: {self.best_score} R: заново", True, WHITE
)
self.screen.blit(title, title.get_rect(center=(WIDTH / 2, HEIGHT / 2 - 40)))
self.screen.blit(info, info.get_rect(center=(WIDTH / 2, HEIGHT / 2 + 30)))
def draw(self):
offset = Vector2()
if self.shake > 0:
power = self.shake * 40
offset = Vector2(random.uniform(-power, power), random.uniform(-power, power))
self.screen.fill(BG_COLOR)
self.draw_grid(offset)
for p in self.particles:
p.draw(self.screen, offset)
for b in self.bullets:
pygame.draw.circle(self.screen, YELLOW, b.pos + offset, b.radius)
for e in self.enemies:
e.draw(self.screen, offset)
if not self.game_over:
self.player.draw(self.screen, offset)
self.draw_hud()
pygame.display.flip()
def run(self):
running = True
while running:
# dt в секундах: игра работает одинаково на 60 и на 144 Гц
dt = min(self.clock.tick(FPS) / 1000, 1 / 30)
running = self.handle_events()
self.update(dt)
self.draw()
pygame.quit()
sys.exit()
if __name__ == "__main__":
Game().run()Попробуйте побить рекорд в 1000 очков и поделитесь в комментариях, какие механики добавили сами.






