Перейти к основному содержимому

Python — Battle City

Разработчику Начальный уровень

О практикуме

Battle City (Танчики, Tank 1990) — аркада на сетке: игрок на танке защищает штаб, разрушает кирпичи, уничтожает вражеские танки. В этом практикуме соберём узнаваемый прототип на Python 3 и Pygame — без спрайтов из оригинала, на цветных прямоугольниках, зато с полным разбором архитектуры и логики.

Откуда взялась игра

ГодСобытие
1985Battle City на Famicom/NES (Namco) — кооператив на одном экране, редактор карт, 35 уровней
1990-еКлоны «Танчики 1990» в DOS и на телевизионных приставках
СейчасЖанр top-down tank shooter жив в веб- и mobile-клонах; логика сетки и волн врагов — учебный эталон

Механики, которые мы воспроизводим:

  • карта из тайлов фиксированного размера;
  • танк движется по четырём направлениям, стреляет одной активной пулей (у игрока);
  • кирпич разрушается, бетон — нет;
  • штаб (орёл) внизу карты — главная цель защиты;
  • враги спавнятся волнами с верхней кромки.

Для кого материал
Нужны базовые Python (классы, списки, циклы) и знакомство с Pygame из статьи Разработка игр на Python. Каждый этап — запускаемый код: после шага проект можно запустить и увидеть новую механику.

Наш прототип и NES-оригинал

АспектNES Battle CityЭтот практикум
Размер карты26×26 тайлов (13×13 «мета-клеток» по 2×2)13×13 тайлов (этап 16 — масштабирование)
ГрафикаСпрайты 16×16Цветные Rect, позже — PNG
Игроки2 на одном экране1 (кооп — упражнение)
БонусыЗвезда, лопата, танк, граната…Этап 15 — звезда и лопата
ПулиДо 1 у игрока; у врагов — свои правила1 пуля игрока; список пуль врагов
КирпичПоловина блока за выстрелЭтап 6 — четверть тайла (2×2 sub-grid)

Как читать длинную статью
Идите этапы по порядку. Пропускать можно только блок «Архитектура», если уже делали игры на Pygame. После этапа 8 имеет смысл открыть промежуточную сборку — там один связный main.py без класса Game.

Управление в финальной версии

КлавишаДействие
W A S D или стрелкиДвижение танка
ПробелВыстрел
PПауза
RПерезапуск уровня (после поражения)
EscВыход

Маршрут чтения

  1. Архитектура — как устроен проект до первой строки кода.
  2. Зависимости и структура папок — окружение и файлы.
  3. Этап 0 — минимальный запуск — чёрное окно и игровой цикл.
  4. Этапы 1–16 — по одной механике за шаг.
  5. Промежуточная сборка (этап 8) — цельный main.py для проверки.
  6. Полный листинг Game — финальная архитектура.
  7. Итоговая самопроверка.

Сводная таблица этапов

ЭтапТемаНовая механикаКлючевые файлы
0ЗапускОкно, цикл, Escmain.py
1ПолеСетка, HUD-заглушкаsettings.py
2КартаTileMap, .txt уровеньgame/map.py, levels/
3ИгрокTank, WASDgame/tank.py
4СтеныКоллизии, откат по осиgame/collision.py
5ВыстрелBullet, разрушение #game/bullet.py
6КирпичЧетверть-блока, вспышкаmap.py sub-grid
7ВрагиСпавн, простой ИИEnemyTank
8УронЖизни, респawn, неуязвимостьmain.py
9ПравилаWIN / LOSE, штабсостояния
10HUDОчки, иконки жизнейgame/hud.py
11UIМеню, паузаgame/states.py
12Уровниlevel_02.txt, цепочкаsettings.LEVELS
13ПолировкаЛес поверх танка, водадвойной draw
14РефакторингКласс Gamegame/game.py
15БонусыЗвезда, лопатаgame/powerup.py
16МасштабКарта 26×26settings.py

Архитектура

Прежде чем писать код, зафиксируем что из чего состоит и как данные текут по кадру.

Игровой цикл

Любая игра на Pygame крутит один и тот же цикл. В Battle City порядок шагов важен — сначала ввод и логика, потом отрисовка.

На каждом кадре внутри обновления выполняется цепочка:

  1. Прочитать нажатые клавиши.
  2. Обновить игрока (направление, позиция).
  3. Обновить врагов (ИИ, движение).
  4. Обновить пули (полёт, столкновения).
  5. Применить разрушение тайлов и проверить победу/поражение.

Слои приложения

СлойОтветственностьПримеры сущностей
ВводСобытия клавиатуры, пауза, выходKEYDOWN, KEYUP, QUIT
МирСетка тайлов, коллизии со стенамиTileMap, типы блоков
АкторыТанки и пулиPlayerTank, EnemyTank, Bullet
ПравилаЖизни, очки, спавн врагов, победаGameState, счётчики
ПредставлениеРисование поля, HUD, экраныdraw_map, draw_hud

Слой правил не рисует напрямую — он меняет состояние; слой представления только читает состояние и выводит кадр. Так проще тестировать логику и менять графику.

Поток данных одного кадра

Когда игра в состоянии PLAYING, порядок вызовов фиксирован — нарушение порядка даёт типичные баги (пуля проходит сквозь стену, враг «телепортируется»).

Если обновить … до …Типичный баг
Пули до движения танкаПуля «отстаёт» от ствола на 1 кадр
Отрисовку до updateКадр показывает прошлое состояние (ghosting)
Спавн врага без проверки клеткиТанк застревает внутри стены
WIN до удаления последнего врагаЭкран победы на кадр раньше взрыва

Координатная система

Классическая карта Battle City — прямоугольная сетка. Удобно хранить мир в тайлах, а танки — в пикселях с привязкой к сетке.

Экран (пиксели)
┌──────────────────────────────────┬─────────┐
│ │ HUD │
│ Игровое поле │ жизни │
│ MAP_COLS × TILE_SIZE │ враги │
│ MAP_ROWS × TILE_SIZE │ уровень│
│ │ │
└──────────────────────────────────┴─────────┘

Рекомендуемые константы (можно менять, но все модули должны брать размеры из одного места):

КонстантаЗначениеСмысл
TILE_SIZE32Размер клетки в пикселях
MAP_COLS, MAP_ROWS13Поле 13×13 (компактно для учебника; в оригинале 26×26)
HUD_WIDTH160Полоса статуса справа
FPS60Кадров в секунду

Перевод пиксели ↔ тайл:

def tile_at_px(x, y):
return x // TILE_SIZE, y // TILE_SIZE

def rect_tiles(rect):
"""Какие тайлы пересекает прямоугольник танка."""
left = rect.left // TILE_SIZE
right = (rect.right - 1) // TILE_SIZE
top = rect.top // TILE_SIZE
bottom = (rect.bottom - 1) // TILE_SIZE
return left, top, right, bottom

Типы тайлов

КодИмяТанкПуляПоведение
.пустопроездлетит
#кирпичблокразрушаетисчезает
@бетонблокблок (обычная пуля)неразрушим
~водаблоклетитдекоративное препятствие
%леспроездлетиттанк рисуется «под» травой (этап 13)
*штабблокпоражениеуничтожение = game over

Уровень храним как многострочный текст — удобно править в редакторе без JSON.

Конечный автомат состояний

Игра переключается между экранами через явное поле state:

Состояния MENU, PLAYING, PAUSED, WIN, LOSE — отдельные ветки в update() и draw().

Структура файлов (целевая)

К этапу 6 достаточно одного main.py. Дальше проект раскладываем по модулям — так же строят небольшие indie-проекты.

battle_city/
├── main.py # точка входа, цикл while
├── settings.py # константы, цвета, FPS
├── assets/ # позже — звуки и картинки
├── levels/
│ ├── level_01.txt
│ └── level_02.txt
├── game/
│ ├── __init__.py
│ ├── map.py # TileMap — загрузка и коллизии
│ ├── tank.py # Tank, PlayerTank, EnemyTank
│ ├── bullet.py # Bullet, пул пуль
│ ├── collision.py # rect vs tiles
│ ├── hud.py # панель справа
│ └── states.py # MENU / PLAYING / …
└── requirements.txt

Почему сетка, а не «свободная» физика
Battle City задуман как дискретная аркада: стены ровно по клеткам, танк выравнивается по направлению. Коллизии через пересечение Rect с тайлами проще, чем полноценный physics engine, и дают тот же геймплей.

Диаграмма объектов на кадре


Зависимости и подготовка окружения

Требования

  • Python 3.10+ (удобны match/case; на 3.9 код тоже работает, если заменить match на if/elif).
  • Pygame 2.5+ — единственная внешняя библиотека.

Установка

mkdir battle_city && cd battle_city
python -m venv .venv

Активация виртуального окружения:

  • Windows (PowerShell): .venv\Scripts\Activate.ps1
  • Linux / macOS: source .venv/bin/activate
pip install pygame
python -c "import pygame; print('Pygame', pygame.ver)"

Файл requirements.txt:

pygame>=2.5.0

Первичная структура

На этапе 0 создайте только main.py. Папки levels/ и game/ добавим по ходу.

Запуск из IDE и рабочая директория

Pygame ищет levels/level_01.txt относительно текущей папки процесса, а не файла main.py. Если запускаете из Cursor/VS Code кнопкой Run, задайте cwd проекта:

{
"version": "0.2.0",
"configurations": [
{
"name": "Battle City",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/battle_city/main.py",
"cwd": "${workspaceFolder}/battle_city",
"console": "integratedTerminal"
}
]
}

Универсальный путь к ресурсам (добавьте в settings.py на этапе 2):

from pathlib import Path

ROOT = Path(__file__).resolve().parent
LEVELS_DIR = ROOT / "levels"

Загрузка: TileMap.load_file(LEVELS_DIR / "level_01.txt").

README проекта

Минимальный README.md в корне battle_city/:

# Battle City (Pygame)

## Запуск
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
python main.py

## Управление
WASD / стрелки — движение, Space — выстрел, P — пауза, R — рестарт уровня.

Кодировка уровней
Файлы уровней сохраняйте в UTF-8. Откройте их в VS Code / Cursor и проверьте кодировку в статус-баре. На Windows блокнот иногда сохраняет CP1251 — тогда символы карты «ломаются».


Этап 0 — минимальный запускаемый код

Цель — окно, цикл событий, выход по крестику и Esc, стабильные 60 FPS.

Создайте main.py:

import sys
import pygame

pygame.init()

SCREEN_W, SCREEN_H = 800, 600
FPS = 60

screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("Battle City — этап 0")
clock = pygame.time.Clock()

running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False

screen.fill((20, 20, 30))
pygame.display.flip()
clock.tick(FPS)

pygame.quit()
sys.exit()

Запуск:

python main.py

Самопроверка этапа 0

  • Окно открывается без traceback.
  • Фон тёмно-синий, без мерцания.
  • Esc и крестик закрывают программу.

На следующих этапах не удаляем цикл — только расширяем тело while.


Этап 1 — константы и игровое поле

Цель — вынести настройки в settings.py, нарисовать сетку и зону HUD.

settings.py:

TILE_SIZE = 32
MAP_COLS = 13
MAP_ROWS = 13
HUD_WIDTH = 160

FIELD_W = MAP_COLS * TILE_SIZE
FIELD_H = MAP_ROWS * TILE_SIZE
SCREEN_W = FIELD_W + HUD_WIDTH
SCREEN_H = FIELD_H

FPS = 60

# Цвета (R, G, B)
COLOR_BG = (24, 24, 32)
COLOR_GRID = (40, 40, 55)
COLOR_HUD = (16, 16, 24)
COLOR_HUD_TEXT = (220, 220, 230)

Обновите main.py:

import sys
import pygame
import settings as S

pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
pygame.display.set_caption("Battle City — этап 1")
clock = pygame.time.Clock()
font = pygame.font.SysFont("consolas", 18)


def draw_field(surface):
surface.fill(S.COLOR_BG, (0, 0, S.FIELD_W, S.FIELD_H))
for c in range(S.MAP_COLS + 1):
x = c * S.TILE_SIZE
pygame.draw.line(surface, S.COLOR_GRID, (x, 0), (x, S.FIELD_H))
for r in range(S.MAP_ROWS + 1):
y = r * S.TILE_SIZE
pygame.draw.line(surface, S.COLOR_GRID, (0, y), (S.FIELD_W, y))


def draw_hud_placeholder(surface):
hud_rect = pygame.Rect(S.FIELD_W, 0, S.HUD_WIDTH, S.SCREEN_H)
pygame.draw.rect(surface, S.COLOR_HUD, hud_rect)
label = font.render("HUD", True, S.COLOR_HUD_TEXT)
surface.blit(label, (S.FIELD_W + 12, 12))


running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False

draw_field(screen)
draw_hud_placeholder(screen)
pygame.display.flip()
clock.tick(S.FPS)

pygame.quit()
sys.exit()

Самопроверка

  • Справа серая полоса HUD.
  • Сетка 13×13 клеток по 32 px.
  • Размер окна ровно 576 + 160 по ширине.

Этап 2 — карта уровня из текста

Цель — класс TileMap, загрузка из строки/файла, отрисовка кирпича, бетона, воды и штаба.

Создайте levels/level_01.txt:

..........#..
..........#..
..##..##..#..
..##..##..#..
..##..##.....
..##..##..@..
..........@..
....##....@..
....##....*..
....##.......
....##.......
.............
.............

Создайте game/map.py:

import pygame
import settings as S

TILE_EMPTY = "."
TILE_BRICK = "#"
TILE_STEEL = "@"
TILE_WATER = "~"
TILE_FOREST = "%"
TILE_BASE = "*"

TILE_COLORS = {
TILE_BRICK: (180, 90, 50),
TILE_STEEL: (140, 140, 150),
TILE_WATER: (40, 80, 200),
TILE_FOREST: (30, 120, 40),
TILE_BASE: (220, 200, 60),
}


class TileMap:
def __init__(self, rows):
self.rows = [list(row.ljust(S.MAP_COLS)[: S.MAP_COLS]) for row in rows]
if len(self.rows) < S.MAP_ROWS:
self.rows += [list(TILE_EMPTY * S.MAP_COLS)] * (S.MAP_ROWS - len(self.rows))
self.rows = self.rows[: S.MAP_ROWS]

@classmethod
def load_file(cls, path):
with open(path, encoding="utf-8") as f:
lines = [line.rstrip("\n") for line in f if line.strip()]
return cls(lines)

def get(self, col, row):
if 0 <= col < S.MAP_COLS and 0 <= row < S.MAP_ROWS:
return self.rows[row][col]
return TILE_STEEL # за пределами карты — «стена»

def is_blocking_tank(self, col, row):
t = self.get(col, row)
return t in (TILE_BRICK, TILE_STEEL, TILE_WATER, TILE_BASE)

def is_blocking_bullet(self, col, row):
t = self.get(col, row)
return t in (TILE_BRICK, TILE_STEEL)

def destroy_at(self, col, row):
if self.get(col, row) == TILE_BRICK:
self.rows[row][col] = TILE_EMPTY
return True
return False

def base_destroyed(self):
for row in self.rows:
if TILE_BASE in row:
return False
return True

def draw(self, surface):
for r in range(S.MAP_ROWS):
for c in range(S.MAP_COLS):
tile = self.get(c, r)
if tile == TILE_EMPTY:
continue
color = TILE_COLORS.get(tile, (255, 0, 255))
rect = pygame.Rect(c * S.TILE_SIZE, r * S.TILE_SIZE, S.TILE_SIZE, S.TILE_SIZE)
pygame.draw.rect(surface, color, rect)
if tile == TILE_BRICK:
# простая «кладка» — линии
x, y = rect.topleft
pygame.draw.line(surface, (120, 60, 30), (x, y + 10), (x + S.TILE_SIZE, y + 10))
pygame.draw.line(surface, (120, 60, 30), (x + 16, y), (x + 16, y + S.TILE_SIZE))

Пустой game/__init__.py и обновление main.py:

import sys
import pygame
import settings as S
from game.map import TileMap

pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
pygame.display.set_caption("Battle City — этап 2")
clock = pygame.time.Clock()

tile_map = TileMap.load_file("levels/level_01.txt")

# ... draw_field, draw_hud_placeholder как на этапе 1 ...

running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False

draw_field(screen)
tile_map.draw(screen)
draw_hud_placeholder(screen)
pygame.display.flip()
clock.tick(S.FPS)

pygame.quit()
sys.exit()

Самопроверка

  • Кирпич коричневый, бетон серый, штаб (*) жёлтый внизу карты.
  • Карта ровно 13 строк; лишние символы обрезаются.

Этап 3 — танк игрока без коллизий

Цель — класс танка, четыре направления, плавное движение по WASD / стрелкам.

Файлы этапа: game/tank.py, правки main.py.

game/tank.py (начало файла):

import pygame
import settings as S

DIR_UP, DIR_RIGHT, DIR_DOWN, DIR_LEFT = 0, 1, 2, 3
TANK_SIZE = S.TILE_SIZE - 4
PLAYER_SPEED = 2


def _draw_tank_surface(color, direction):
"""Отдельная поверхность под каждое направление — «ствол» смотрит в сторону хода."""
surf = pygame.Surface((TANK_SIZE, TANK_SIZE), pygame.SRCALPHA)
surf.fill(color)
barrel = (30, 30, 30)
w = TANK_SIZE
if direction == DIR_UP:
pygame.draw.rect(surf, barrel, (w // 2 - 2, 0, 4, w // 2))
elif direction == DIR_DOWN:
pygame.draw.rect(surf, barrel, (w // 2 - 2, w // 2, 4, w // 2))
elif direction == DIR_LEFT:
pygame.draw.rect(surf, barrel, (0, w // 2 - 2, w // 2, 4))
else:
pygame.draw.rect(surf, barrel, (w // 2, w // 2 - 2, w // 2, 4))
return surf


class Tank(pygame.sprite.Sprite):
def __init__(self, x, y, color):
super().__init__()
self.color = color
self.direction = DIR_UP
self._surfaces = {d: _draw_tank_surface(color, d) for d in range(4)}
self.image = self._surfaces[self.direction]
self.rect = self.image.get_rect(topleft=(x, y))
self.speed = PLAYER_SPEED
self.alive = True

def handle_input(self, keys, tile_map=None):
dx = dy = 0
if keys[pygame.K_w] or keys[pygame.K_UP]:
dy, self.direction = -self.speed, DIR_UP
if keys[pygame.K_s] or keys[pygame.K_DOWN]:
dy, self.direction = self.speed, DIR_DOWN
if keys[pygame.K_a] or keys[pygame.K_LEFT]:
dx, self.direction = -self.speed, DIR_LEFT
if keys[pygame.K_d] or keys[pygame.K_RIGHT]:
dx, self.direction = self.speed, DIR_RIGHT
self.image = self._surfaces[self.direction]
if tile_map is None:
self.rect.x += dx
self.rect.y += dy
else:
self.move_with_collision(dx, dy, tile_map)
self._clamp_to_field()

def _clamp_to_field(self):
self.rect.left = max(0, self.rect.left)
self.rect.top = max(0, self.rect.top)
self.rect.right = min(S.FIELD_W, self.rect.right)
self.rect.bottom = min(S.FIELD_H, self.rect.bottom)

def draw(self, surface):
surface.blit(self.image, self.rect)

В main.py после загрузки карты:

from game.tank import Tank

player = Tank(S.TILE_SIZE * 4, S.TILE_SIZE * 12, (80, 200, 80))

# в цикле, после событий:
keys = pygame.key.get_pressed()
player.handle_input(keys) # tile_map подключим на этапе 4

# отрисовка:
draw_field(screen)
tile_map.draw(screen)
player.draw(screen)

Самопроверка

  • Зелёный квадрат двигается по полю.
  • При смене направления «ствол» поворачивается (видно даже на прямоугольниках).
  • Танк не выходит за границы серого поля (но пока проезжает сквозь стены — это этап 4).

Оригинал и «прилипание» к сетке
В NES-версии танк визуально выравнивается по клетке при повороте. Для аркадного ощущения можно после каждого поворота подтягивать координату к ближайшему кратному 2 px — упражнение на этапе 13. Базовый прототип этого не требует.


Этап 4 — коллизии танка со стенами

Цель — после движения откатывать позицию, если Rect танка пересекает блокирующий тайл.

game/collision.py:

import settings as S
from game.map import TileMap


def rect_tile_range(rect):
left = rect.left // S.TILE_SIZE
right = (rect.right - 1) // S.TILE_SIZE
top = rect.top // S.TILE_SIZE
bottom = (rect.bottom - 1) // S.TILE_SIZE
return left, top, right, bottom


def tank_hits_wall(rect, tile_map: TileMap):
left, top, right, bottom = rect_tile_range(rect)
for row in range(top, bottom + 1):
for col in range(left, right + 1):
if tile_map.is_blocking_tank(col, row):
return True
return False

Добавьте в Tank метод:

from game.collision import tank_hits_wall

def move_with_collision(self, dx, dy, tile_map):
if dx:
self.rect.x += dx
if tank_hits_wall(self.rect, tile_map):
self.rect.x -= dx
if dy:
self.rect.y += dy
if tank_hits_wall(self.rect, tile_map):
self.rect.y -= dy

И перепишите handle_input — на этапе 4 передавайте tile_map:

player.handle_input(keys, tile_map)

На этапе 3 move_with_collision ещё не вызывался — метод добавляется здесь же в класс Tank.

Самопроверка

  • Танк останавливается у кирпича и бетона.
  • Вода (~) тоже непроходима (как в оригинале).
  • Движение по диагонали не «проскальзывает» сквозь угол — откат по оси X и Y раздельно.

Техника «пробное смещение»
Сначала двигаем по одной оси, проверяем стену, при столкновении откатываем. Так реализовано большинство top-down танковых аркад без физического движка.


Этап 5 — пули и выстрел

Цель — одна пуля игрока на экране (как в классике), полёт, исчезновение у стены.

Матрица взаимодействий пули

ОбъектПуля игрокаПуля врага
Кирпич #Разрушает, пуля гаснетТо же
Бетон @Пуля гаснетПуля гаснет
Вода ~ПролетаетПролетает
Штаб *ПоражениеПоражение
Танк игрокаУрон + респawn
Танк врагаУничтожение
Другая пуляЭтап 8 — обе гаснутЭтап 8

game/bullet.py:

import pygame
import settings as S
from game.collision import rect_tile_range
from game.map import TileMap, TILE_BASE
from game.tank import DIR_UP, DIR_RIGHT, DIR_DOWN, DIR_LEFT

BULLET_SIZE = 6
BULLET_SPEED = 6


class Bullet(pygame.sprite.Sprite):
def __init__(self, x, y, direction, owner):
super().__init__()
self.image = pygame.Surface((BULLET_SIZE, BULLET_SIZE))
self.image.fill((250, 250, 100))
self.rect = self.image.get_rect(center=(x, y))
self.direction = direction
self.owner = owner # "player" | "enemy"
self.active = True

def update(self, tile_map: TileMap):
if not self.active:
return
if self.direction == DIR_UP:
self.rect.y -= BULLET_SPEED
elif self.direction == DIR_DOWN:
self.rect.y += BULLET_SPEED
elif self.direction == DIR_LEFT:
self.rect.x -= BULLET_SPEED
else:
self.rect.x += BULLET_SPEED

if not (0 < self.rect.centerx < S.FIELD_W and 0 < self.rect.centery < S.FIELD_H):
self.active = False
return

left, top, right, bottom = rect_tile_range(self.rect)
for row in range(top, bottom + 1):
for col in range(left, right + 1):
if tile_map.is_blocking_bullet(col, row):
tile_map.destroy_at(col, row)
self.active = False
return
if tile_map.get(col, row) == TILE_BASE:
tile_map.destroy_base_at(col, row)
self.active = False
return

def draw(self, surface):
if self.active:
surface.blit(self.image, self.rect)

В Tank добавьте:

def muzzle_pos(self):
cx, cy = self.rect.center
if self.direction == DIR_UP:
return cx, self.rect.top
if self.direction == DIR_DOWN:
return cx, self.rect.bottom
if self.direction == DIR_LEFT:
return self.rect.left, cy
return self.rect.right, cy

В main.py:

from game.bullet import Bullet

player_bullet = None
fire_cooldown = 0

# KEYDOWN:
elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
if player_bullet is None or not player_bullet.active:
mx, my = player.muzzle_pos()
player_bullet = Bullet(mx, my, player.direction, "player")

# update:
if player_bullet and player_bullet.active:
player_bullet.update(tile_map)
elif player_bullet and not player_bullet.active:
player_bullet = None

# draw после танка:
if player_bullet:
player_bullet.draw(screen)

Самопроверка

  • Пробел выпускает жёлтую пулю в сторону «ствола».
  • Пуля исчезает у бетона и пробивает кирпич (клетка очищается).
  • Попадание в штаб уничтожает жёлтую клетку *.

Этап 6 — разрушение кирпича по четвертям

Цель — как в оригинале: один выстрел сносит четверть кирпичного тайла; визуальная обратная связь (вспышка).

В NES один тайл 16×16 — это 4 «кирпичика» 8×8. Реализуем sub-grid 2×2 внутри клетки.

Добавьте в TileMap.__init__:

# self.brick_mask[row][col] — 4 бита, какие четверти кирпича стоят
self.brick_mask = [[0 for _ in range(S.MAP_COLS)] for _ in range(S.MAP_ROWS)]
for r, row in enumerate(self.rows):
for c, ch in enumerate(row):
if ch == TILE_BRICK:
self.brick_mask[r][c] = 0b1111

Метод попадания пули:

def hit_brick_by_bullet(self, bullet_rect):
col = bullet_rect.centerx // S.TILE_SIZE
row = bullet_rect.centery // S.TILE_SIZE
if self.get(col, row) != TILE_BRICK:
return False
local_x = bullet_rect.centerx % S.TILE_SIZE
local_y = bullet_rect.centery % S.TILE_SIZE
quarter = (1 if local_x >= S.TILE_SIZE // 2 else 0) + (2 if local_y >= S.TILE_SIZE // 2 else 0)
mask = self.brick_mask[row][col]
bit = 1 << quarter
if mask & bit:
self.brick_mask[row][col] = mask & ~bit
if self.brick_mask[row][col] == 0:
self.rows[row][col] = TILE_EMPTY
return True
return False

def destroy_base_at(self, col, row):
if self.get(col, row) == TILE_BASE:
self.rows[row][col] = TILE_EMPTY
return True
return False

Отрисовка кирпича с учётом маски:

def _draw_brick_quarters(self, surface, rect, mask):
half = S.TILE_SIZE // 2
color = TILE_COLORS[TILE_BRICK]
for q, (dx, dy) in enumerate(((0, 0), (half, 0), (0, half), (half, half))):
if mask & (1 << q):
pygame.draw.rect(surface, color, (rect.x + dx, rect.y + dy, half, half))

В Bullet.update вместо destroy_at для кирпича вызывайте hit_brick_by_bullet(self.rect).

Вспышка при разрушении:

# в main.py после update пули:
flash_timer = 0
# если пуля только что уничтожила кирпич — flash_timer = 6
# в draw:
if flash_timer > 0:
overlay = pygame.Surface((S.FIELD_W, S.FIELD_H), pygame.SRCALPHA)
overlay.fill((255, 255, 255, 40))
screen.blit(overlay, (0, 0))
flash_timer -= 1

Самопроверка

  • Один выстрел в угол кирпича сносит четверть, а не весь тайл.
  • Четыре точных попадания полностью очищают клетку.
  • Бетон @ остаётся нетронутым.
  • Белая вспышка на 6 кадров при попадании.

Этап 7 — вражеские танки и спавн

Цель — 3–4 врага, точки появления вверху карты, простой цвет.

Расширьте game/tank.py:

import random
from game.collision import tank_hits_wall
from game.bullet import Bullet

ENEMY_SPEED = 1
class EnemyTank(Tank):
def __init__(self, x, y):
super().__init__(x, y, (200, 80, 80))
self.speed = ENEMY_SPEED
self.change_dir_timer = 0
self.shoot_timer = random.randint(60, 120)

def update_ai(self, tile_map):
self.change_dir_timer -= 1
if self.change_dir_timer <= 0:
self.direction = random.randint(0, 3)
self.change_dir_timer = random.randint(30, 90)

dx = dy = 0
if self.direction == DIR_UP:
dy = -self.speed
elif self.direction == DIR_DOWN:
dy = self.speed
elif self.direction == DIR_LEFT:
dx = -self.speed
else:
dx = self.speed

self.move_with_collision(dx, dy, tile_map)

self.shoot_timer -= 1
if self.shoot_timer <= 0:
self.shoot_timer = random.randint(90, 180)
mx, my = self.muzzle_pos()
return Bullet(mx, my, self.direction, "enemy")
return None

В settings.py:

ENEMY_SPAWN_POINTS = [(0, 0), (6 * TILE_SIZE, 0), (12 * TILE_SIZE, 0)]
MAX_ENEMIES_ON_FIELD = 3
TOTAL_ENEMIES_LEVEL = 10
SPAWN_SAFE_SIZE = TILE_SIZE # клетка спавна должна быть пустой

Проверка безопасного спавна (добавьте в collision.py):

import pygame
import settings as S
from game.map import TileMap


def spawn_is_clear(tile_map: TileMap, x, y):
test = pygame.Rect(x, y, S.TILE_SIZE, S.TILE_SIZE)
left, top, right, bottom = rect_tile_range(test)
for row in range(top, bottom + 1):
for col in range(left, right + 1):
if tile_map.is_blocking_tank(col, row):
return False
return True

В цикле спавна:

from game.collision import spawn_is_clear

# ...
x, y = slot[0] + 2, slot[1] + 2
if spawn_is_clear(tile_map, x, y):
enemies.add(EnemyTank(x, y))
enemies_spawned += 1
spawn_cooldown = 90
else:
spawn_cooldown = 30 # повторить попытку скоро

В main.py заведите списки:

enemies = pygame.sprite.Group()
enemy_bullets = []
enemies_spawned = 0
spawn_cooldown = 120

# каждый кадр:
spawn_cooldown -= 1
if (
spawn_cooldown <= 0
and enemies_spawned < S.TOTAL_ENEMIES_LEVEL
and len(enemies) < S.MAX_ENEMIES_ON_FIELD
):
slot = S.ENEMY_SPAWN_POINTS[len(enemies) % len(S.ENEMY_SPAWN_POINTS)]
enemies.add(EnemyTank(slot[0] + 2, slot[1] + 2))
enemies_spawned += 1
spawn_cooldown = 90

for enemy in list(enemies):
bullet = enemy.update_ai(tile_map)
if bullet:
enemy_bullets.append(bullet)

Самопроверка

  • Красные танки появляются сверху по очереди.
  • На поле не больше 3 врагов одновременно.
  • Враги ездят и периодически стреляют.

Этап 8 — столкновения пуль с танками

Цель — пуля игрока уничтожает врага; пуля врага отнимает жизнь; взаимное гашение пуль; краткая неуязвимость после респawna.

В settings.py:

PLAYER_LIVES = 3
INVULN_FRAMES = 120 # ~2 сек при 60 FPS
BULLET_COOLDOWN = 20

В Tank / игроке:

self.invuln_timer = 0

def tick_invuln(self):
if self.invuln_timer > 0:
self.invuln_timer -= 1

def draw(self, surface):
if self.invuln_timer > 0 and (self.invuln_timer // 4) % 2:
return # мигание
surface.blit(self.image, self.rect)

В main.py после bullet.update:

# пуля игрока vs враги
if player_bullet and player_bullet.active:
hit = pygame.sprite.spritecollideany(player_bullet, enemies)
if hit:
hit.kill()
player_bullet.active = False
score += 100

# пули врагов vs игрок
if player.invuln_timer == 0:
for b in enemy_bullets:
if b.active and b.rect.colliderect(player.rect):
b.active = False
respawn_player()

# пуля игрока vs пули врагов
if player_bullet and player_bullet.active:
for b in enemy_bullets:
if b.active and player_bullet.rect.colliderect(b.rect):
player_bullet.active = False
b.active = False

player.tick_invuln()

Функция респawna:

lives = S.PLAYER_LIVES
score = 0

def respawn_player():
global lives, player
lives -= 1
if lives > 0:
player = Tank(S.TILE_SIZE * 4, S.TILE_SIZE * 12, (80, 200, 80))
player.invuln_timer = S.INVULN_FRAMES
else:
game_state = "LOSE"

Кулдаун выстрела
Без BULLET_COOLDOWN зажатый Space создаёт «пулемёт». Храните fire_cooldown и уменьшайте каждый кадр; выстрел только при fire_cooldown == 0.

Самопроверка

  • Враг исчезает от одного попадания.
  • При попадании по игроку он появляется на старте (пока есть жизни).
  • Счётчик жизней уменьшается.
  • После респawna танк мигает ~2 секунды.
  • Две пули на линии огня исчезают обе.

Промежуточная сборка — цельный main.py (после этапа 8)

Если вы шли по этапам и хотите сверить проект с эталоном, ниже — монолитный файл (~220 строк) без класса Game. Скопируйте в main_milestone8.py, убедитесь что модули settings, game.map, game.tank, game.bullet, game.collision, game.hud на месте.

Зачем отдельный файл
На этапах 9–11 легко потерять порядок вызовов в цикле. Эталон фиксирует рабочую последовательность до рефакторинга в класс Game.

Структура файла (скелет — заполните методами из этапов 1–8):

"""main_milestone8.py — Battle City после этапа 8."""
import sys
import pygame
import settings as S
from game.map import TileMap
from game.tank import Tank, EnemyTank
from game.bullet import Bullet
from game.hud import draw_hud
from game.collision import spawn_is_clear

# --- состояние игры ---
game_state = "PLAYING"
lives = S.PLAYER_LIVES
score = 0
player_bullet = None
enemy_bullets = []
enemies = pygame.sprite.Group()
enemies_spawned = 0
spawn_cooldown = 60
fire_cooldown = 0
flash_timer = 0

def respawn_player():
global lives, player
lives -= 1
if lives <= 0:
return "LOSE"
player = Tank(S.TILE_SIZE * 4, S.TILE_SIZE * 12, (80, 200, 80))
player.invuln_timer = S.INVULN_FRAMES
return None

def draw_field(surface):
surface.fill(S.COLOR_BG, (0, 0, S.FIELD_W, S.FIELD_H))

def draw_game(surface):
draw_field(surface)
tile_map.draw(surface)
for e in enemies:
e.draw(surface)
player.draw(surface)
if player_bullet and player_bullet.active:
player_bullet.draw(surface)
for b in enemy_bullets:
b.draw(surface)
enemies_left = S.TOTAL_ENEMIES_LEVEL - (enemies_spawned - len(enemies))
draw_hud(surface, lives=lives, enemies_left=enemies_left, level=1, score=score)

def update_game():
global player_bullet, fire_cooldown, spawn_cooldown, flash_timer, game_state
keys = pygame.key.get_pressed()
player.handle_input(keys, tile_map)
player.tick_invuln()

spawn_cooldown -= 1
if spawn_cooldown <= 0 and enemies_spawned < S.TOTAL_ENEMIES_LEVEL and len(enemies) < S.MAX_ENEMIES_ON_FIELD:
slot = S.ENEMY_SPAWN_POINTS[enemies_spawned % len(S.ENEMY_SPAWN_POINTS)]
x, y = slot[0] + 2, slot[1] + 2
if spawn_is_clear(tile_map, x, y):
enemies.add(EnemyTank(x, y))
enemies_spawned += 1
spawn_cooldown = 90

for enemy in list(enemies):
b = enemy.update_ai(tile_map)
if b:
enemy_bullets.append(b)

if player_bullet and player_bullet.active:
player_bullet.update(tile_map)
enemy_bullets = [b for b in enemy_bullets if b.active]
for b in enemy_bullets:
b.update(tile_map)
# ... коллизии пуль (этап 8) ...

if tile_map.base_destroyed():
game_state = "LOSE"
elif enemies_spawned >= S.TOTAL_ENEMIES_LEVEL and len(enemies) == 0:
game_state = "WIN"

def main():
global tile_map, player, game_state, player_bullet, enemy_bullets
pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
clock = pygame.time.Clock()
tile_map = TileMap.load_file(S.LEVELS_DIR / "level_01.txt")
player = Tank(S.TILE_SIZE * 4, S.TILE_SIZE * 12, (80, 200, 80))

running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
running = False
elif event.key == pygame.K_SPACE and game_state == "PLAYING":
if (player_bullet is None or not player_bullet.active) and fire_cooldown == 0:
mx, my = player.muzzle_pos()
player_bullet = Bullet(mx, my, player.direction, "player")
fire_cooldown = S.BULLET_COOLDOWN
if game_state == "PLAYING":
update_game()
if fire_cooldown > 0:
fire_cooldown -= 1
draw_game(screen)
pygame.display.flip()
clock.tick(S.FPS)
pygame.quit()
sys.exit()

if __name__ == "__main__":
main()

Полный эталонный репозиторий можно собрать, последовательно подставив тела методов из этапов 1–8 — или перейти сразу к классу Game на этапе 14.


Этап 9 — штаб, победа и поражение

Цель — явные состояния WIN / LOSE, перезапуск уровня.

game_state = "PLAYING" # MENU | PLAYING | PAUSED | WIN | LOSE

def check_win():
return enemies_spawned >= S.TOTAL_ENEMIES_LEVEL and len(enemies) == 0

# после обновления:
if tile_map.base_destroyed():
game_state = "LOSE"
elif check_win():
game_state = "WIN"

# KEYDOWN R при LOSE — перезагрузка level_01 и сброс счётчиков

Отрисовка оверлея:

def draw_center_text(surface, text, color=(255, 255, 255)):
f = pygame.font.SysFont("consolas", 36)
img = f.render(text, True, color)
r = img.get_rect(center=(S.FIELD_W // 2, S.FIELD_H // 2))
surface.blit(img, r)

# в draw при LOSE:
draw_center_text(screen, "ПОРАЖЕНИЕ — R")
# при WIN:
draw_center_text(screen, "УРОВЕНЬ ПРОЙДЕН")

Самопроверка

  • Уничтожение штаба — экран поражения.
  • Уничтожение всех 10 врагов — победа.
  • R перезапускает уровень после поражения.

Этап 10 — HUD (жизни, оставшиеся танки, уровень)

Цель — панель справа как в аркаде.

game/hud.py:

import pygame
import settings as S


def draw_hud(surface, *, lives, enemies_left, level, score):
hud_rect = pygame.Rect(S.FIELD_W, 0, S.HUD_WIDTH, S.SCREEN_H)
pygame.draw.rect(surface, S.COLOR_HUD, hud_rect)
font = pygame.font.SysFont("consolas", 16)
lines = [
f"УР. {level}",
f"ОЧК {score}",
f"ЖИЗН {lives}",
f"ВРАГ {enemies_left}",
]
y = 16
for line in lines:
img = font.render(line, True, S.COLOR_HUD_TEXT)
surface.blit(img, (S.FIELD_W + 10, y))
y += 28
# иконки жизней — маленькие зелёные квадраты
for i in range(lives):
pygame.draw.rect(
surface,
(80, 200, 80),
(S.FIELD_W + 10 + i * 22, y, 18, 18),
)

В игровом цикле:

from game.hud import draw_hud

enemies_left = S.TOTAL_ENEMIES_LEVEL - (enemies_spawned - len(enemies))
draw_hud(screen, lives=lives, enemies_left=enemies_left, level=1, score=score)

Очки начисляйте при hit.kill() — например score += 100.

Самопроверка

  • HUD показывает актуальные жизни и счёт.
  • Счётчик «врагов» уменьшается по мере уничтожения.

Этап 11 — пауза и экран меню

Цель — состояния MENU и PAUSED, отдельные функции отрисовки.

game/states.py:

import pygame
import settings as S


def draw_menu(surface, title="BATTLE CITY"):
surface.fill(S.COLOR_BG)
f_title = pygame.font.SysFont("consolas", 40)
f_hint = pygame.font.SysFont("consolas", 20)
t = f_title.render(title, True, (255, 255, 255))
h = f_hint.render("Enter — начать Esc — выход", True, (180, 180, 180))
surface.blit(t, t.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H // 2 - 30)))
surface.blit(h, h.get_rect(center=(S.SCREEN_W // 2, S.SCREEN_H // 2 + 20)))


def draw_paused_overlay(surface):
overlay = pygame.Surface((S.FIELD_W, S.FIELD_H), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 120))
surface.blit(overlay, (0, 0))
font = pygame.font.SysFont("consolas", 32)
img = font.render("ПАУЗА", True, (255, 255, 255))
surface.blit(img, img.get_rect(center=(S.FIELD_W // 2, S.FIELD_H // 2)))

В main.py вместо глубокой вложенности:

if game_state == "MENU":
draw_menu(screen)
elif game_state == "PLAYING":
update_game(...)
draw_game(...)
elif game_state == "PAUSED":
draw_game(...)
draw_paused_overlay(screen)

Самопроверка

  • P замирает танки и пули.
  • Повторное P продолжает игру.
  • Стартовый экран с подсказкой «Enter — играть».

Этап 12 — второй уровень и загрузчик

Цель — цепочка файлов уровней, переход после WIN.

levels/level_02.txt — больше воды и бетона, узкий коридор к штабу:

.............
..@...@.....
..@..%@.....
..~~~~~.....
....#.#.....
..##*##.....
..#...#.....
..#.#.#.....
....#.......
....#.......
.............
.............
.............

В settings.py:

from pathlib import Path
ROOT = Path(__file__).resolve().parent
LEVELS_DIR = ROOT / "levels"
LEVELS = [LEVELS_DIR / "level_01.txt", LEVELS_DIR / "level_02.txt"]
current_level_index = 0

def load_level(index):
global tile_map, enemies, player, enemies_spawned, enemy_bullets, player_bullet, game_state
tile_map = TileMap.load_file(S.LEVELS[index])
player = Tank(S.TILE_SIZE * 4, S.TILE_SIZE * 12, (80, 200, 80))
enemies.empty()
enemy_bullets.clear()
player_bullet = None
enemies_spawned = 0
spawn_cooldown = 60
game_state = "PLAYING"

При game_state == "WIN" и нажатии Enter — current_level_index += 1 или цикл по модулю.

Самопроверка

  • После победы открывается следующая карта.
  • Жизни и очки можно сохранять между уровнями (или сбрасывать — на ваш выбор, зафиксируйте в README).

Этап 13 — лес, вода, полировка

Цель — танк под % рисуется «под» травой; вода анимирована; опционально звук.

Доработайте TileMap.draw:

def draw(self, surface, skip_forest=False):
water_frame = (pygame.time.get_ticks() // 500) % 2
for r in range(S.MAP_ROWS):
for c in range(S.MAP_COLS):
tile = self.get(c, r)
if tile == TILE_EMPTY or (skip_forest and tile == TILE_FOREST):
continue
rect = pygame.Rect(c * S.TILE_SIZE, r * S.TILE_SIZE, S.TILE_SIZE, S.TILE_SIZE)
if tile == TILE_WATER:
color = (40, 80, 200) if water_frame == 0 else (35, 70, 180)
pygame.draw.rect(surface, color, rect)
elif tile == TILE_BRICK:
self._draw_brick_quarters(surface, rect, self.brick_mask[r][c])
else:
pygame.draw.rect(surface, TILE_COLORS.get(tile, (255, 0, 255)), rect)

def draw_forest_only(self, surface):
for r in range(S.MAP_ROWS):
for c in range(S.MAP_COLS):
if self.get(c, r) == TILE_FOREST:
rect = pygame.Rect(c * S.TILE_SIZE, r * S.TILE_SIZE, S.TILE_SIZE, S.TILE_SIZE)
pygame.draw.rect(surface, TILE_COLORS[TILE_FOREST], rect)
pygame.draw.line(surface, (20, 90, 30), rect.topleft, rect.bottomright, 1)

Лес — двухпроходная отрисовка:

def draw_world(surface, tile_map, actors):
tile_map.draw(surface, skip_forest=True)
for sprite in actors:
sprite.draw(surface)
tile_map.draw_forest_only(surface)

Звук (опционально):

pygame.mixer.init()
fire_sound = pygame.mixer.Sound("assets/fire.wav") # короткий щелчок

Ассеты
Для учебного проекта достаточно примитивов Pygame. Если добавляете спрайты — положите их в assets/ и загружайте через pygame.image.load; логика коллизий остаётся на Rect.


Этап 14 — класс Game и чистый main.py

Цель — собрать разрозненные функции в один класс, оставить в main.py только запуск.

Полная реализация — в разделе Полный листинг Game. Здесь — контракт класса и финальный main.py.

МетодНазначение
__init__Начальное состояние MENU, счётчики
reset_level()Загрузка карты, сброс врагов и пуль
handle_event(event)Клавиши; False = выход
update()Логика кадра при PLAYING
draw(surface)Поле, HUD, оверлеи
_update_spawns()Очередь врагов
_update_bullets()Движение и коллизии пуль
_check_end_conditions()WIN / LOSE

Финальный main.py:

import sys
import pygame
import settings as S
from game.game import Game


def main():
pygame.init()
screen = pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H))
pygame.display.set_caption("Battle City")
clock = pygame.time.Clock()
game = Game()

running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
else:
if not game.handle_event(event):
running = False

game.update()
screen.fill(S.COLOR_BG)
game.draw(screen)
pygame.display.flip()
clock.tick(S.FPS)

pygame.quit()
sys.exit()


if __name__ == "__main__":
main()

Самопроверка

  • main.py короче 40 строк.
  • Перезапуск уровня и смена состояния не дублируют код.

Полный листинг — settings.py и game/game.py

settings.py (финальный)

from pathlib import Path

TILE_SIZE = 32
MAP_COLS = 13
MAP_ROWS = 13
HUD_WIDTH = 160

FIELD_W = MAP_COLS * TILE_SIZE
FIELD_H = MAP_ROWS * TILE_SIZE
SCREEN_W = FIELD_W + HUD_WIDTH
SCREEN_H = FIELD_H

FPS = 60
PLAYER_LIVES = 3
INVULN_FRAMES = 120
BULLET_COOLDOWN = 20
MAX_ENEMIES_ON_FIELD = 3
TOTAL_ENEMIES_LEVEL = 10

ROOT = Path(__file__).resolve().parent
LEVELS_DIR = ROOT / "levels"
LEVELS = [LEVELS_DIR / "level_01.txt", LEVELS_DIR / "level_02.txt"]

ENEMY_SPAWN_POINTS = [(0, 0), (6 * TILE_SIZE, 0), (12 * TILE_SIZE, 0)]

COLOR_BG = (24, 24, 32)
COLOR_GRID = (40, 40, 55)
COLOR_HUD = (16, 16, 24)
COLOR_HUD_TEXT = (220, 220, 230)

game/game.py

import pygame
import settings as S
from game.map import TileMap
from game.tank import Tank, EnemyTank
from game.bullet import Bullet
from game.hud import draw_hud
from game.collision import spawn_is_clear


class Game:
def __init__(self):
self.state = "MENU"
self.level_index = 0
self.score = 0
self.lives = S.PLAYER_LIVES
self.fire_cooldown = 0
self.flash_timer = 0
self.reset_level()

def reset_level(self):
self.tile_map = TileMap.load_file(S.LEVELS[self.level_index])
self.player = Tank(S.TILE_SIZE * 4, S.TILE_SIZE * 12, (80, 200, 80))
self.enemies = pygame.sprite.Group()
self.enemy_bullets = []
self.player_bullet = None
self.enemies_spawned = 0
self.spawn_cooldown = 60

def handle_event(self, event):
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
return False
if self.state == "MENU" and event.key in (pygame.K_RETURN, pygame.K_SPACE):
self.state = "PLAYING"
elif self.state == "PLAYING" and event.key == pygame.K_p:
self.state = "PAUSED"
elif self.state == "PAUSED" and event.key == pygame.K_p:
self.state = "PLAYING"
elif self.state == "LOSE" and event.key == pygame.K_r:
self.lives = S.PLAYER_LIVES
self.score = 0
self.reset_level()
elif self.state == "WIN" and event.key == pygame.K_RETURN:
self.level_index = (self.level_index + 1) % len(S.LEVELS)
self.reset_level()
elif self.state == "PLAYING" and event.key == pygame.K_SPACE:
self._try_fire()
return True

def _try_fire(self):
if self.fire_cooldown > 0:
return
if self.player_bullet and self.player_bullet.active:
return
mx, my = self.player.muzzle_pos()
self.player_bullet = Bullet(mx, my, self.player.direction, "player")
self.fire_cooldown = S.BULLET_COOLDOWN

def update(self):
if self.fire_cooldown > 0:
self.fire_cooldown -= 1
if self.flash_timer > 0:
self.flash_timer -= 1
if self.state != "PLAYING":
return

keys = pygame.key.get_pressed()
self.player.handle_input(keys, self.tile_map)
self.player.tick_invuln()
self._update_spawns()
self._update_bullets()
self._check_end_conditions()

def _update_spawns(self):
self.spawn_cooldown -= 1
if self.spawn_cooldown > 0:
return
if self.enemies_spawned >= S.TOTAL_ENEMIES_LEVEL:
return
if len(self.enemies) >= S.MAX_ENEMIES_ON_FIELD:
return
slot = S.ENEMY_SPAWN_POINTS[self.enemies_spawned % len(S.ENEMY_SPAWN_POINTS)]
x, y = slot[0] + 2, slot[1] + 2
if spawn_is_clear(self.tile_map, x, y):
self.enemies.add(EnemyTank(x, y))
self.enemies_spawned += 1
self.spawn_cooldown = 90
else:
self.spawn_cooldown = 30

for enemy in list(self.enemies):
bullet = enemy.update_ai(self.tile_map)
if bullet:
self.enemy_bullets.append(bullet)

def _update_bullets(self):
if self.player_bullet and self.player_bullet.active:
self.player_bullet.update(self.tile_map)
elif self.player_bullet and not self.player_bullet.active:
self.player_bullet = None

for b in self.enemy_bullets:
if b.active:
b.update(self.tile_map)
self.enemy_bullets = [b for b in self.enemy_bullets if b.active]

if self.player_bullet and self.player_bullet.active:
hit = pygame.sprite.spritecollideany(self.player_bullet, self.enemies)
if hit:
hit.kill()
self.player_bullet.active = False
self.score += 100
self.flash_timer = 6

if self.player.invuln_timer == 0:
for b in self.enemy_bullets:
if b.active and b.rect.colliderect(self.player.rect):
b.active = False
self._hurt_player()

if self.player_bullet and self.player_bullet.active:
for b in self.enemy_bullets:
if b.active and self.player_bullet.rect.colliderect(b.rect):
self.player_bullet.active = False
b.active = False

def _hurt_player(self):
self.lives -= 1
if self.lives <= 0:
self.state = "LOSE"
return
self.player = Tank(S.TILE_SIZE * 4, S.TILE_SIZE * 12, (80, 200, 80))
self.player.invuln_timer = S.INVULN_FRAMES

def _check_end_conditions(self):
if self.tile_map.base_destroyed():
self.state = "LOSE"
elif self.enemies_spawned >= S.TOTAL_ENEMIES_LEVEL and len(self.enemies) == 0:
self.state = "WIN"

def draw(self, surface):
surface.fill(S.COLOR_BG, (0, 0, S.FIELD_W, S.FIELD_H))
self.tile_map.draw(surface, skip_forest=True)
for e in self.enemies:
e.draw(surface)
self.player.draw(surface)
if self.player_bullet and self.player_bullet.active:
self.player_bullet.draw(surface)
for b in self.enemy_bullets:
b.draw(surface)
self.tile_map.draw_forest_only(surface)

enemies_left = S.TOTAL_ENEMIES_LEVEL - (self.enemies_spawned - len(self.enemies))
draw_hud(surface, lives=self.lives, enemies_left=enemies_left,
level=self.level_index + 1, score=self.score)

if self.flash_timer > 0:
overlay = pygame.Surface((S.FIELD_W, S.FIELD_H), pygame.SRCALPHA)
overlay.fill((255, 255, 255, 40))
surface.blit(overlay, (0, 0))

if self.state == "MENU":
self._center_text(surface, "BATTLE CITY", "Enter — играть")
elif self.state == "PAUSED":
self._center_text(surface, "ПАУЗА", "P — продолжить")
elif self.state == "WIN":
self._center_text(surface, "УРОВЕНЬ ПРОЙДЕН", "Enter — дальше")
elif self.state == "LOSE":
self._center_text(surface, "ПОРАЖЕНИЕ", "R — заново")

def _center_text(self, surface, title, subtitle=""):
f1 = pygame.font.SysFont("consolas", 32)
f2 = pygame.font.SysFont("consolas", 18)
t = f1.render(title, True, (255, 255, 255))
surface.blit(t, t.get_rect(center=(S.FIELD_W // 2, S.FIELD_H // 2 - 20)))
if subtitle:
s = f2.render(subtitle, True, (200, 200, 200))
surface.blit(s, s.get_rect(center=(S.FIELD_W // 2, S.FIELD_H // 2 + 20)))

Для draw(..., skip_forest=True) и draw_forest_only добавьте в TileMap параметры из этапа 13.


Этап 15 — бонусы (звезда и лопата)

Цель — после уничтожения врага с шансом 20% появляется бонус; подбор меняет параметры игрока.

СимволЭффектДлительность
ЗвездаСкорость пули ×2, пробивает бетонДо конца уровня
ЛопатаУкрепляет штаб бетоном20 секунд
Танк+1 жизньмгновенно

game/powerup.py:

import random
import pygame
import settings as S

KIND_STAR, KIND_SHOVEL, KIND_TANK = 0, 1, 2
COLORS = {KIND_STAR: (255, 220, 50), KIND_SHOVEL: (160, 120, 60), KIND_TANK: (80, 200, 80)}


class PowerUp(pygame.sprite.Sprite):
def __init__(self, x, y, kind):
super().__init__()
self.kind = kind
self.image = pygame.Surface((20, 20))
self.image.fill(COLORS[kind])
self.rect = self.image.get_rect(center=(x, y))
self.timer = 600 # исчезает через ~10 сек

def update(self):
self.timer -= 1
return self.timer > 0


def maybe_drop(enemies_group, powerups_group):
if not enemies_group:
return
if random.random() > 0.2:
return
enemy = enemies_group.sprites()[-1]
kind = random.choice([KIND_STAR, KIND_SHOVEL, KIND_TANK])
powerups_group.add(PowerUp(enemy.rect.centerx, enemy.rect.centery, kind))

При подборе (player.rect.colliderect(pu.rect)):

if pu.kind == KIND_STAR:
player.bullet_power = 2
elif pu.kind == KIND_SHOVEL:
tile_map.reinforce_base(duration_frames=1200)
elif pu.kind == KIND_TANK:
lives += 1
pu.kill()

В Bullet.update при owner == "player" и power >= 2 добавьте бетон в список разрушаемых или пролетаемых тайлов.


Этап 16 — масштаб карты 26×26

Цель — приблизить размер к NES-оригиналу.

В settings.py:

MAP_COLS = 26
MAP_ROWS = 26
TILE_SIZE = 16 # окно остаётся ~416 + HUD
FIELD_W = MAP_COLS * TILE_SIZE
FIELD_H = MAP_ROWS * TILE_SIZE
SCREEN_W = FIELD_W + HUD_WIDTH

Перерисуйте уровни — 26 строк по 26 символов. Танк и пули масштабируются через TILE_SIZE:

TANK_SIZE = S.TILE_SIZE - 2
BULLET_SIZE = max(4, S.TILE_SIZE // 4)

Производительность
Сетка 26×26 при примитивах Pygame на современном ПК держит 60 FPS без оптимизаций. Узкое место — количество врагов и пуль, а не размер карты.


Отладка и диагностика

Режим отладки (F1)

DEBUG = False

# KEYDOWN F1:
DEBUG = not DEBUG

# в draw после HUD:
if DEBUG:
mx, my = pygame.mouse.get_pos()
col, row = mx // S.TILE_SIZE, my // S.TILE_SIZE
if col < S.MAP_COLS and row < S.MAP_ROWS:
info = font.render(f"tile {col},{row} = {tile_map.get(col, row)}", True, (255, 255, 0))
surface.blit(info, (8, 8))
pygame.draw.rect(surface, (255, 0, 0), player.rect, 1)

Логирование в консоль

import logging
logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger("battle_city")
# log.debug("spawn enemy %s", enemies_spawned)

Частые симптомы при интеграции модулей

СимптомГде искать
Игра «замирает» на менюstate не переключается в PLAYING
Пуля летит не тудаdirection меняется после выстрела — сохраняйте направление в момент Bullet(...)
Враг стреляет из стеныmuzzle_pos вне Rect танка — проверьте offset
HUD не обновляетсяenemies_left считается до kill()
Уровень не грузитсяPath vs строка; проверьте LEVELS_DIR.exists()

Итоговая самопроверка проекта

Пройдите чек-лист готового прототипа:

#КритерийДа / нет
1Окно фиксированного размера, стабильный FPS
2Карта грузится из .txt
3Игрок двигается и не проходит сквозь стены
4Кирпич разрушается, бетон держит удар
5Враги спавнятся волнами, стреляют
6Есть жизни, очки, HUD
7Победа и поражение с перезапуском
8Минимум два уровня
9Код разбит на модули game/*
10Четверти кирпича, вспышка при попадании
11Неуязвимость после урона, гашение пуль
12Отладка F1 показывает тайл под курсором

Упражнения по нарастающей сложности

  1. Лёгкое — добавьте счётчик времени уровня в HUD (pygame.time.get_ticks()).
  2. Среднее — второй игрок на IJKL с отдельной пулей и жизнью.
  3. Среднее — враг «следует» по оси X к игроку, если видит прямую линию без стен (raycast по тайлам).
  4. Сложное — процедурная генерация уровня: случайные блоки # с проверкой связности пути от спавна до штаба (BFS).
  5. Сложное — replays: запись списка (frame, keys, events) и воспроизведение.

Идеи для расширения

  • Бонусы после уничтожения врага (звезда — быстрый выстрел, лопата — пройти по воде).
  • Разные типы врагов — быстрый, «бронированный» (2 попадания).
  • Редактор уровней на Pygame — клик мышью ставит тайл.
  • Сохранение рекорда в highscore.txt.
  • Увеличение карты до 26×26 как в NES — потребует только новых MAP_COLS/MAP_ROWS и файлов уровней.

Типичные ошибки

СимптомВероятная причинаЧто сделать
Чёрный экран, нет ошибокЗабыли pygame.display.flip()Вызовите flip в конце цикла
Танк застревает в стенеСмещение по двум осям сразуОткат по одной оси в move_with_collision
Пуля не уничтожает кирпичНеверный тип тайла или маска не инициализированаСимвол #; проверьте brick_mask в __init__
FileNotFoundError для уровняЗапуск не из корня проектаcwd в launch.json или Path(__file__)
Враги не появляютсяСпавн-клетка занята или spawn_cooldownspawn_is_clear; уменьшите задержку
NameError: TILE_BASEКонстанта в map.py, не импортирована в bullet.pyfrom game.map import TILE_BASE
Мигание не работаетtick_invuln не вызываетсяВызов каждый кадр в update
Двойной урон за кадрНет неуязвимостиinvuln_timer после респawna
Окно слишком большое после этапа 16Забыли уменьшить TILE_SIZE26×16 = 416 px по ширине поля

Связанные материалы


См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").

Освоение главы0%