Python — карточная стратегия
О практикуме
Соберём карточный roguelike в духе Slay the Spire с темпом боя ближе к Hearthstone: колода, рука, энергия на ход, несколько врагов, намерения, карта путей между боями, награды и реликвии. Стек — Python 3.10+ и Pygame 2.5+, без стороннего игрового движка.
Управление в финальной версии практикума
| Действие | Управление |
|---|---|
| Клик по карте, врагу, узлу карты, кнопкам | Мышь |
| Завершить ход в бою | E |
| Меню / выход из редактора | Esc |
Маршрут чтения
- Архитектура — экраны, бой, данные.
- Зависимости и структура папок.
- Этап 0 — минимальный запуск.
- Этапы 1–14 — ядро забега и боя.
- Этапы 15–16 — реестр эффектов и редактор карт (как в эталоне).
- Моддинг и отладка · Итоговая самопроверка.
Имя папки в примерах — auto_battler/. Параллельно держите клон AutoBattler (F:\Projects\Games\AutoBattler или git clone) и сверяйте одноимённые файлы после каждого этапа.
Что получится в конце
| Результат | Описание |
|---|---|
| Играбельный забег | меню → карта узлов → бои → награда → костёр/лавка → босс |
| Чистая архитектура | логика без Pygame в classes/, контент в data/*.json |
| Мост к эталону | те же имена экранов, CombatManager, RunState, что в «Тенях Шпиля» |
Ориентир по времени (с перерывами, без полировки графики):
| Блок | Этапы | Часы |
|---|---|---|
| Окно и данные | 0–3 | 2–4 |
| Бой без UI | 4–7 | 4–6 |
| Pygame и забег | 8–11 | 6–10 |
| Мета и контент | 12–16 | 4–8 |
| Итого | 0–16 | 16–28 |
Архитектура
Карточный roguelike строится вокруг забега (run): карта узлов → события → сражения → усиление колоды → босс. Каждый бой — отдельная подсистема со своим конечным автоматом; между боями колода и реликвии сохраняются, HP и золото тоже.
Жанровые опоры
| Идея | Откуда в жанре | Как реализуем |
|---|---|---|
| Карта путей | Slay the Spire | GameMap, узлы combat / rest / shop … |
| Энергия и рука каждый ход | Hearthstone, Slay the Spire | Player.energy, добор в CombatManager |
| Намерения врагов | Slay the Spire | Enemy._plan_intent(), иконка на UI |
| Несколько врагов | Darkest Dungeon, STS (редко) | Encounter, выбор цели атаки |
| Реликвии | STS | пассивные объекты на Player.relics |
| Контент в данных | моддинг | data/cards.json, enemies.json |
Игровой цикл Pygame
Класс Game в main.py — оркестратор: он не считает урон карт напрямую, а делегирует RunState и CombatManager.
Экраны забега (конечный автомат UI)
Поле RunState.screen переключает ветки в Game._handle_click и UI.draw_*. Логика боя живёт в CombatManager, пока screen == combat.
Слои приложения
| Слой | Ответственность | Модули |
|---|---|---|
| Данные | Карты, враги, реликвии из JSON | data/*, card_catalog.py |
| Модель | Сущности и правила без Pygame | card.py, player.py, enemy.py, combat.py, relic.py |
| Забег | Карта, золото, смена экранов | game_state.py, map.py |
| Представление | Рисование, хитбоксы | ui.py, widgets.py, effects.py |
| Вход | Клики, клавиши | main.py → Game |
Правило для поддерживаемости: урон и блок считаются в CombatManager, UI только вызывает play_card(index) и end_player_turn().
Поток данных в бою
Целевая структура файлов
К концу практикума (и в AutoBattler) дерево выглядит так:
auto_battler/
├── main.py # класс Game, цикл while
├── settings.py # баланс, цвета, константы типов карт
├── locale.py # строки UI (опционально с этапа 9)
├── requirements.txt
├── save_data.json # создаётся игрой, в .gitignore
│
├── classes/
│ ├── card.py # Card, Deck, Hand
│ ├── card_catalog.py # загрузка JSON
│ ├── player.py
│ ├── enemy.py
│ ├── combat.py
│ ├── relic.py
│ ├── map.py
│ ├── game_state.py # RunState, SaveSystem
│ ├── card_effects.py # этап 15
│ ├── card_text.py # подписи эффектов
│ ├── card_editor.py # этап 16
│ ├── widgets.py # поля ввода редактора
│ ├── ui.py # отрисовка
│ ├── effects.py # вспышки (опционально)
│ ├── audio.py # звук (опционально)
│ └── transitions.py # fade и баннеры (опционально)
│
└── data/
├── cards.json
├── enemies.json
├── relics.json
└── custom_cards.json # создаёт редактор
На этапах 0–5 достаточно main.py + settings.py + один-два файла в classes/. Папку data/ добавляем на этапе 2.
Диаграмма классов (целевая)
data/cards.json без пересборки логики. Код знает типы (attack, block, buff…) и эффекты (vulnerable, draw…), а числа лежат в данных — так устроен и полный AutoBattler.Глоссарий механик
| Термин | Значение в практикуме |
|---|---|
| Забег (run) | одна попытка пройти карту от старта до босса или смерти |
| Колода (deck) | все карты игрока; в бою делится на стопку добора и сброс |
| Рука (hand) | карты, которые можно разыграть сейчас (лимит 10) |
| Энергия | ресурс хода; восстанавливается в start_turn |
| Броня (block) | поглощает урон до конца хода (у игрока и врагов) |
| Намерение (intent) | запланированное действие врага на следующий его ход |
| Энкаунтер | группа врагов в одном бою |
| Узел | точка на карте путей (бой, лавка, костёр…) |
| Реликвия | пассивный артефакт на весь забег |
| Мета-прогресс | статистика и открытия между забегами в save_data.json |
Конечный автомат боя
Отдельно от экранов UI у CombatManager три состояния хода:
Пока state == player_turn, принимаются play_card и клики по цели. Фаза врага блокирует ввод — в эталоне это же условие плюс ScreenFade / баннеры в Game._input_blocked().
Формулы урона и брони
Базовые правила совпадают с Slay the Spire и с Player / Enemy в AutoBattler:
| Ситуация | Формула |
|---|---|
| Атака по врагу | урон = value + player.strength (плюс модификаторы карт) |
| Входящий урон по игроку с Уязвимостью | int(amount * 1.5) |
| Входящий урон по игроку с бронёй | сначала вычитается block, остаток бьёт HP |
| Слабость у атакующего врага | урон врага уменьшается (в эталоне — в Enemy._calc_damage) |
| Хрупкость (frail) | получаемая броня ×0.75 |
Урон по врагу с бронёй: dealt = max(0, amount - enemy.block), затем enemy.block уменьшается. Пробивание (pierce в card_effects.py) обходит броню и бьёт HP напрямую.
Раскладка экрана боя (1280×720)
Эталон рисует зоны предсказуемо — удобно повторить на этапе 8:
┌────────────────────────────────────────────────────────────────┐
│ [HP] [Энергия] [Броня] [Золото] реликвии (иконки) │
├────────────────────────────────────────────────────────────────┤
│ │
│ Враг 1 Враг 2 Враг 3 │
│ [intent] [intent] [intent] │
│ │
│ журнал боя (последние 5–8 строк) │
├────────────────────────────────────────────────────────────────┤
│ [карта][карта][карта][карта][карта] │
│ [ Конец хода (E) ] │
└────────────────────────────────────────────────────────────────┘
Константы размеров карт в эталоне — CARD_WIDTH = 160, CARD_HEIGHT = 240, CARD_SPACING = 12 в settings.py. Рука центрируется по формуле из этапа 8.
Карта этапов → файлы эталона
| Этап | Вы добавляете | Сверка в AutoBattler |
|---|---|---|
| 0–1 | main.py, settings.py | main.py, settings.py |
| 2 | Card, data/cards.json | classes/card.py, data/cards.json |
| 3–4 | Deck, Hand, Player | classes/card.py, classes/player.py |
| 5 | Enemy, Encounter | classes/enemy.py, data/enemies.json |
| 6–7 | CombatManager | classes/combat.py |
| 8 | UI.draw_combat | classes/ui.py (фрагмент) |
| 9–11 | RunState, GameMap | classes/game_state.py, classes/map.py |
| 12–13 | реликвии, shop/rest | classes/relic.py, экраны в main.py |
| 14 | SaveSystem, locale | classes/game_state.py, locale.py |
| 15 | card_effects.py | classes/card_effects.py |
| 16 | редактор | classes/card_editor.py, card_catalog.py |
Зависимости и подготовка окружения
Требования
- Python 3.10+ (аннотации
list[Card],int | None). - Pygame 2.5+ — единственная внешняя зависимость в
requirements.txtэталона.
Установка
mkdir auto_battler && cd auto_battler
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
Клон эталона для сверки
git clone https://github.com/Spirzen/AutoBattler.git
cd AutoBattler
pip install -r requirements.txt
python main.py
Окно 1280×720, главное меню на русском. Практикум можно проходить параллельно в auto_battler/, сверяя готовые модули с одноимёнными файлами в репозитории.
Пакет classes и .gitignore
Создайте пустые файлы, чтобы импорты from classes.card import … работали стабильно:
auto_battler/
├── classes/
│ └── __init__.py # можно оставить пустым
.gitignore в корне учебного проекта:
.venv/
__pycache__/
*.pyc
save_data.json
data/custom_cards.json
В AutoBattler save_data.json и custom_cards.json тоже исключены из git — прогресс и кастомные карты локальны.
Шрифты с кириллицей
Pygame по умолчанию может взять шрифт без русских букв. Эталон перебирает список:
FONT_NAMES = ["segoeui", "arial", "tahoma", "calibri", "verdana"]
def pick_font(size: int, bold: bool = False):
for name in settings.FONT_NAMES:
path = pygame.font.match_font(name, bold=bold)
if path:
return pygame.font.Font(path, size)
return pygame.font.SysFont(None, size)
Подключите pick_font на этапе 8 в UI.__init__.
data/ сохраняйте в UTF-8 с ensure_ascii=False при записи из Python — иначе кириллица в названиях карт сломается на Windows.Этап 0 — минимальный запускаемый код
Цель — окно 1280×720, цикл 60 FPS, выход по крестику и Esc. Размер совпадает с эталоном, чтобы дальше не перенастраивать UI.
main.py:
import sys
import pygame
SCREEN_W, SCREEN_H = 1280, 720
FPS = 60
TITLE = "Карточный roguelike — этап 0"
pygame.init()
screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption(TITLE)
clock = pygame.time.Clock()
font = pygame.font.SysFont("segoeui", 28)
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((12, 10, 24))
hint = font.render("Этап 0 — Esc для выхода", True, (220, 210, 240))
screen.blit(hint, (SCREEN_W // 2 - hint.get_width() // 2, SCREEN_H // 2 - 14))
pygame.display.flip()
clock.tick(FPS)
pygame.quit()
sys.exit()
Самопроверка
- Окно без traceback.
-
Escи крестик закрывают программу. - Стабильные ~60 FPS (без
tickкадр «улетает»).
Этап 1 — настройки и палитра
Цель — вынести константы в settings.py, подготовить типы карт и цвета как в AutoBattler.
settings.py:
SCREEN_WIDTH = 1280
SCREEN_HEIGHT = 720
FPS = 60
TITLE = "Карточный roguelike — этап 1"
BG_DARK = (8, 6, 18)
TEXT_WHITE = (245, 240, 255)
TEXT_GOLD = (255, 215, 90)
HP_COLOR = (220, 55, 65)
ENERGY_COLOR = (255, 200, 50)
BLOCK_COLOR = (70, 150, 230)
CARD_ATTACK = "attack"
CARD_BLOCK = "block"
CARD_BUFF = "buff"
CARD_DRAW = "draw"
CARD_TYPE_COLORS = {
CARD_ATTACK: (200, 55, 55),
CARD_BLOCK: (55, 110, 200),
CARD_BUFF: (200, 150, 40),
CARD_DRAW: (55, 170, 120),
}
STARTING_HP = 80
STARTING_ENERGY = 3
STARTING_HAND = 5
DRAW_PER_TURN = 5
MAX_HAND = 10
Обновите main.py — импорт settings, заливка фона, подпись этапа.
Самопроверка
- Все модули импортируют размеры только из
settings. - Цвета карт заданы один раз в
CARD_TYPE_COLORS.
Этап 2 — модель карты и JSON
Цель — класс Card, загрузка базы из data/cards.json, фабрика create_card.
Создайте data/cards.json (минимальный набор):
[
{
"id": "strike",
"name": "Удар",
"type": "attack",
"cost": 1,
"value": 6,
"description": "Наносит урон одной цели.",
"rarity": "basic"
},
{
"id": "defend",
"name": "Защита",
"type": "block",
"cost": 1,
"value": 5,
"description": "Даёт броню до конца хода.",
"rarity": "basic"
},
{
"id": "bash",
"name": "Сокрушение",
"type": "attack",
"cost": 2,
"value": 8,
"description": "Сильный удар.",
"rarity": "common",
"effect": "vulnerable",
"effect_value": 2
}
]
classes/card.py:
import copy
import json
import os
import settings
class Card:
def __init__(self, data: dict):
self.id = data["id"]
self.name = data["name"]
self.type = data["type"]
self.cost = data["cost"]
self.value = data.get("value", 0)
self.description = data.get("description", "")
self.rarity = data.get("rarity", "common")
self.effect = data.get("effect")
self.effect_value = data.get("effect_value", 0)
def copy(self):
return Card(self.to_dict())
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"type": self.type,
"cost": self.cost,
"value": self.value,
"description": self.description,
"rarity": self.rarity,
"effect": self.effect,
"effect_value": self.effect_value,
}
@property
def color(self):
return settings.CARD_TYPE_COLORS.get(self.type, (80, 80, 100))
def _data_path(filename: str) -> str:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, "data", filename)
def load_card_database() -> list[dict]:
with open(_data_path("cards.json"), encoding="utf-8") as f:
return json.load(f)
def create_card(data: dict) -> Card:
return Card(copy.deepcopy(data))
def create_starting_deck() -> list[Card]:
db = {c["id"]: c for c in load_card_database()}
strike = db["strike"]
defend = db["defend"]
deck = []
for _ in range(5):
deck.append(create_card(strike))
for _ in range(5):
deck.append(create_card(defend))
return deck
Проверка из консоли (из корня auto_battler/):
python -c "from classes.card import create_starting_deck; print(len(create_starting_deck()), create_starting_deck[0].name)"
Ожидается 10 Удар.
Самопроверка
-
FileNotFoundErrorисчезает при запуске из корня проекта. - У каждой карты есть
type,cost,value.
Справочник полей cards.json (эталон)
Практикум начинает с короткой записи; в AutoBattler карты богаче. Основные поля класса Card:
| Поле | Тип | Роль |
|---|---|---|
id | string | ключ в базе, дубликаты в колоде допустимы |
name, description | string | UI и подсказка |
type | string | attack, block, buff, debuff, draw, creature |
cost | int | энергия при розыгрыше |
value | int | урон или величина эффекта по типу |
rarity | string | basic, common, uncommon, rare |
block | int | доп. броня на карте атаки |
draw | int | добор при розыгрыше |
aoe | bool | урон/дебафф по всем врагам |
effect, effect_value | string, int | первый бонус (vulnerable, poison…) |
effect2, effect2_value | string, int | второй бонус |
bonuses | list[dict] | несколько эффектов в одной карте |
lifesteal, pierce (через bonuses) | bool | флаги из card_effects.py |
kind | string | spell / creature для редактора |
health | int | «здоровье» существа → броня при розыгрыше |
Пример карты эталона с двумя эффектами (сокращённо):
{
"id": "uppercut",
"name": "Апперкот",
"type": "attack",
"cost": 2,
"value": 13,
"effect": "vulnerable",
"effect_value": 2,
"effect2": "weak",
"effect2_value": 1,
"description": "Удар с ослаблением.",
"rarity": "uncommon"
}
На этапе 15 эти поля обрабатывает apply_on_play_bonuses, а не разросшийся if внутри play_card.
Этап 3 — колода и рука
Цель — Deck (стопка добора, сброс, перетасовка) и Hand (лимит карт).
Дополните classes/card.py — классы Deck и Hand в том же файле, что и Card:
import random
import settings
class Deck:
def __init__(self, cards: list[Card] | None = None):
self.draw_pile: list[Card] = list(cards or [])
self.discard_pile: list[Card] = []
self.shuffle()
def shuffle(self):
random.shuffle(self.draw_pile)
def draw(self, count: int = 1) -> list[Card]:
drawn = []
for _ in range(count):
if not self.draw_pile and self.discard_pile:
self.draw_pile = self.discard_pile[:]
self.discard_pile.clear()
self.shuffle()
if self.draw_pile:
drawn.append(self.draw_pile.pop())
return drawn
def discard_all(self, cards: list[Card]):
self.discard_pile.extend(cards)
class Hand:
def __init__(self, max_size: int = settings.MAX_HAND):
self.cards: list[Card] = []
self.max_size = max_size
def add(self, card: Card) -> bool:
if len(self.cards) >= self.max_size:
return False
self.cards.append(card)
return True
def remove(self, index: int) -> Card | None:
if 0 <= index < len(self.cards):
return self.cards.pop(index)
return None
def clear(self):
self.cards.clear()
Тест:
from classes.card import create_starting_deck, Deck, Hand
d = Deck(create_starting_deck())
h = Hand()
for c in d.draw(5):
h.add(c)
print([c.name for c in h.cards], "осталось в колоде", len(d.draw_pile))
Самопроверка
- Пустая стопка добора перетасовывает сброс.
- Рука не принимает 11-ю карту при
MAX_HAND = 10.
Этап 4 — игрок (HP, энергия, блок)
Цель — Player с боевым сбросом состояния и экономикой урона/брони.
classes/player.py:
import settings
from classes.card import Deck, Hand, create_starting_deck
class Player:
def __init__(self):
self.max_hp = settings.STARTING_HP
self.hp = settings.STARTING_HP
self.max_energy = settings.STARTING_ENERGY
self.energy = settings.STARTING_ENERGY
self.block = 0
self.strength = 0
self.vulnerable = 0
self.deck = Deck(create_starting_deck())
self.hand = Hand()
def reset_combat(self):
self.energy = self.max_energy
self.block = 0
self.vulnerable = 0
self.hand.clear()
all_cards = self.deck.draw_pile + self.deck.discard_pile
self.deck = Deck(all_cards)
self.deck.shuffle()
def start_turn(self):
self.energy = self.max_energy
self.block = 0
if self.vulnerable > 0:
self.vulnerable -= 1
def end_turn(self):
cards = self.hand.cards[:]
self.hand.clear()
self.deck.discard_all(cards)
def spend_energy(self, cost: int) -> bool:
if self.energy >= cost:
self.energy -= cost
return True
return False
def gain_block(self, amount: int):
self.block += amount
def take_damage(self, amount: int) -> int:
if self.vulnerable > 0:
amount = int(amount * 1.5)
blocked = min(self.block, amount)
self.block -= blocked
damage = amount - blocked
self.hp = max(0, self.hp - damage)
return damage
def apply_vulnerable(self, turns: int):
self.vulnerable = max(self.vulnerable, turns)
Самопроверка
-
reset_combatвозвращает все карты в колоду и тасует. - Блок поглощает урон до обнуления HP.
Этап 5 — враг, намерение, энкаунтер
Цель — один враг с циклом намерений attack / block, загрузка из JSON.
data/enemies.json:
[
{
"id": "slime",
"name": "Слизень",
"hp": 28,
"attack_damage": 5,
"block_value": 6,
"intent_pattern": ["attack", "attack", "block"],
"description": "Медленный, но упрямый."
},
{
"id": "cultist",
"name": "Культист",
"hp": 48,
"attack_damage": 6,
"buff_value": 3,
"intent_pattern": ["buff", "attack", "attack"],
"description": "Копит силу."
}
]
classes/enemy.py (упрощённо):
import json
import os
import random
INTENT_ATTACK = "attack"
INTENT_BLOCK = "block"
INTENT_BUFF = "buff"
class Enemy:
def __init__(self, data: dict, hp_mult: float = 1.0):
self.name = data["name"]
self.max_hp = int(data["hp"] * hp_mult)
self.hp = self.max_hp
self.block = 0
self.strength = 0
self.attack_damage = data.get("attack_damage", 5)
self.block_value = data.get("block_value", 5)
self.buff_value = data.get("buff_value", 2)
self.intent_pattern = data.get("intent_pattern", ["attack"])
self.intent_index = 0
self.current_intent = INTENT_ATTACK
self.intent_value = 0
self.alive = True
self._plan_intent()
def _plan_intent(self):
key = self.intent_pattern[self.intent_index % len(self.intent_pattern)]
self.intent_index += 1
self.current_intent = key
if key == INTENT_ATTACK:
self.intent_value = self.attack_damage + self.strength
elif key == INTENT_BLOCK:
self.intent_value = self.block_value
elif key == INTENT_BUFF:
self.intent_value = self.buff_value
def execute_intent(self, player):
if not self.alive:
return
if self.current_intent == INTENT_ATTACK:
player.take_damage(self.intent_value)
elif self.current_intent == INTENT_BLOCK:
self.block += self.intent_value
elif self.current_intent == INTENT_BUFF:
self.strength += self.intent_value
self.block = 0
self._plan_intent()
def take_damage(self, amount: int) -> int:
blocked = min(self.block, amount)
self.block -= blocked
dmg = amount - blocked
self.hp = max(0, self.hp - dmg)
if self.hp <= 0:
self.alive = False
return dmg
class Encounter:
def __init__(self, enemies: list[Enemy]):
self.enemies = enemies
def get_living_enemies(self) -> list[Enemy]:
return [e for e in self.enemies if e.alive]
def all_dead(self) -> bool:
return len(self.get_living_enemies()) == 0
def load_enemies() -> list[dict]:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(base, "data", "enemies.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
def create_encounter(enemy_id: str, hp_mult: float = 1.0) -> Encounter:
db = {e["id"]: e for e in load_enemies()}
return Encounter([Enemy(db[enemy_id], hp_mult)])
Самопроверка
- После хода врага видно следующее намерение (
_plan_intentв конце). -
Encounter.all_dead()становитсяTrue, когда HP врага 0.
Отображение намерений
В эталоне locale.INTENT_LABELS переводит attack → «Атака», block → «Защита», buff → «Усиление». Цвета — INTENT_COLORS в enemy.py. На UI рисуйте под портретом врага:
label = INTENT_LABELS.get(enemy.current_intent, "?")
value = enemy.intent_value
color = INTENT_COLORS.get(enemy.current_intent, (200, 200, 200))
# иконка: красный клинок для attack, щит для block
Игрок всегда видит следующий ход врага — ключевое отличие deckbuilder от «чистого» автобаттлера без информации.
Этап 6 — CombatManager без UI
Цель — пошаговый бой в консоли или через print — розыгрыш карт, конец хода, ход врагов.
classes/combat.py:
import settings
from classes.enemy import Encounter, create_encounter
class CombatManager:
STATE_PLAYER_TURN = "player_turn"
STATE_ENEMY_TURN = "enemy_turn"
STATE_VICTORY = "victory"
STATE_DEFEAT = "defeat"
def __init__(self, player, encounter: Encounter):
self.player = player
self.encounter = encounter
self.state = self.STATE_PLAYER_TURN
self.turn = 0
self.log: list[str] = []
self.combat_over = False
self.victory = False
self._init_combat()
def _init_combat(self):
self.player.reset_combat()
for card in self.player.deck.draw(settings.STARTING_HAND):
self.player.hand.add(card)
self.turn = 1
self.log.append(f"Бой начался. Ход {self.turn}")
def can_play_card(self, index: int) -> bool:
if self.state != self.STATE_PLAYER_TURN:
return False
if index < 0 or index >= len(self.player.hand.cards):
return False
return self.player.energy >= self.player.hand.cards[index].cost
def play_card(self, index: int, target_index: int = 0) -> bool:
if not self.can_play_card(index):
return False
card = self.player.hand.cards[index]
if card.type == settings.CARD_ATTACK:
living = self.encounter.get_living_enemies()
if not living:
return False
target = living[min(target_index, len(living) - 1)]
else:
target = None
if not self.player.spend_energy(card.cost):
return False
self.player.hand.remove(index)
if card.type == settings.CARD_ATTACK and target:
dmg = card.value + self.player.strength
dealt = target.take_damage(dmg)
self.log.append(f"{card.name}: {dealt} урона → {target.name}")
if card.effect == "vulnerable" and target.alive:
target.vulnerable = max(getattr(target, "vulnerable", 0), card.effect_value)
elif card.type == settings.CARD_BLOCK:
self.player.gain_block(card.value)
self.log.append(f"{card.name}: +{card.value} брони")
elif card.type == settings.CARD_DRAW:
for c in self.player.deck.draw(card.value):
self.player.hand.add(c)
self.log.append(f"{card.name}: добор")
self.player.deck.discard_all([card])
if self.encounter.all_dead():
self._set_victory()
return True
def end_player_turn(self):
if self.state != self.STATE_PLAYER_TURN:
return
self.player.end_turn()
self.state = self.STATE_ENEMY_TURN
self._enemy_phase()
def _enemy_phase(self):
for enemy in self.encounter.get_living_enemies():
enemy.execute_intent(self.player)
if self.player.hp <= 0:
self._set_defeat()
return
if self.player.hp <= 0:
return
self.turn += 1
self.player.start_turn()
for c in self.player.deck.draw(settings.DRAW_PER_TURN):
self.player.hand.add(c)
self.state = self.STATE_PLAYER_TURN
self.log.append(f"Ход {self.turn}. Ваша очередь.")
def _set_victory(self):
self.state = self.STATE_VICTORY
self.combat_over = True
self.victory = True
self.log.append("Победа!")
def _set_defeat(self):
self.state = self.STATE_DEFEAT
self.combat_over = True
self.victory = False
self.log.append("Поражение…")
def demo_combat_cli():
from classes.player import Player
p = Player()
cm = CombatManager(p, create_encounter("slime"))
cm.play_card(0, 0)
cm.end_player_turn()
for line in cm.log:
print(line)
print("HP игрока", p.hp, "враг", cm.encounter.enemies[0].hp)
if __name__ == "__main__":
demo_combat_cli()
Запуск: python -m classes.combat (из корня, с пакетом classes как папкой; при необходимости добавьте пустые classes/__init__.py).
Самопроверка
- Атака тратит энергию и уходит в сброс.
- После
end_player_turnвраг бьёт, игрок добирает карты. - Победа при
hpврага ≤ 0.
Цепочка play_card в эталоне
Когда учебный CombatManager обрастает механиками, перенесите разбор эффектов в отдельный метод — как в AutoBattler:
Тело _resolve_card в эталоне:
- Собрать
targets(один враг, AOE или случайный). - Нанести урон / выдать броню / призвать «существо».
- Вызвать
apply_on_play_bonusesдляeffect/bonuses[]. - Обработать
draw,energy_gain,self_damage. - При флаге Echo — повторить удар.
Упрощённый скелет для этапа 15:
def _resolve_card(self, card, target_index: int | None) -> list[str]:
messages = []
living = self.encounter.get_living_enemies()
# ... урон / блок по type ...
apply_on_play_bonuses(self, card, target_index, messages)
if card.draw:
for c in self.player.deck.draw(card.draw):
self.player.hand.add(c)
return messages
_add_log в эталоне обрезает список до 8 строк — UI всегда читает «хвост», без прокрутки.Этап 7 — несколько врагов и выбор цели
Цель — энкаунтер из 2–3 врагов, поле selected_enemy_index, атаки только по живым.
Расширьте create_encounter:
def create_combat_encounter(node_type: str) -> Encounter:
db = load_enemies()
if node_type == "elite":
return Encounter([Enemy(db[1], 1.4)])
group = [Enemy(db[0]), Enemy(db[0])]
return Encounter(group)
В CombatManager добавьте:
self.selected_enemy_index: int | None = None
def card_needs_target(self, card) -> bool:
return card.type == settings.CARD_ATTACK
def select_enemy(self, index: int):
living = self.encounter.get_living_enemies()
if 0 <= index < len(living):
self.selected_enemy_index = index
def play_card(self, index: int, target_index: int | None = None) -> bool:
# ...
if self.card_needs_target(card):
ti = target_index if target_index is not None else (self.selected_enemy_index or 0)
living = self.encounter.get_living_enemies()
target = living[min(ti, len(living) - 1)]
# ...
В эталоне AutoBattler то же делает CombatManager.card_needs_target и клик по портрету врага перед картой.
Генерация энкаунтера по этажу (эталон)
В classes/enemy.py функция create_combat_encounter(is_elite, is_boss, floor) подбирает состав и число врагов:
- босс — один из
hexaghost,slime_boss,guardian; - элита —
gremlin_nobилиsentry, множитель HP1.1; - обычный бой — пул зависит от
floor, от 1 до 3 врагов с весами.
Подключите упрощённую версию при входе в бой с карты:
def start_combat_from_node(self, node):
from classes.enemy import create_combat_encounter
elite = node.type == settings.NODE_ELITE
boss = node.type == settings.NODE_BOSS
floor = node.floor
enc = create_combat_encounter(elite, boss, floor)
self.combat = CombatManager(self.player, enc)
self.screen = self.SCREEN_COMBAT
Самопроверка
- Атака без живых врагов возвращает
False. - Можно переключить цель между двумя слизнями.
- На этаже 3+ иногда появляется два врага.
Этап 8 — боевой UI (рука, энергия, журнал)
Цель — один экран боя: прямоугольники карт, полоса HP, кнопка «Конец хода», журнал снизу.
classes/ui.py (фрагмент):
import pygame
import settings
class UI:
def __init__(self, screen: pygame.Surface):
self.screen = screen
self.font = pygame.font.SysFont("segoeui", 20)
self.title_font = pygame.font.SysFont("segoeui", 24, bold=True)
def draw_combat(self, player, combat):
self.screen.fill(settings.BG_DARK)
self._draw_player_panel(player)
self._draw_enemies(combat.encounter)
self._draw_hand(player, combat)
self._draw_log(combat.log)
end_rect = pygame.Rect(settings.SCREEN_WIDTH - 160, settings.SCREEN_HEIGHT - 56, 140, 40)
pygame.draw.rect(self.screen, (60, 50, 90), end_rect, border_radius=6)
label = self.font.render("Конец хода (E)", True, settings.TEXT_WHITE)
self.screen.blit(label, (end_rect.x + 8, end_rect.y + 10))
return {"end_turn": end_rect}
def _draw_player_panel(self, player):
pygame.draw.rect(self.screen, (28, 24, 48), (24, 24, 280, 120), border_radius=8)
hp = self.title_font.render(f"HP {player.hp}/{player.max_hp}", True, settings.HP_COLOR)
en = self.font.render(f"Энергия {player.energy}/{player.max_energy}", True, settings.ENERGY_COLOR)
bl = self.font.render(f"Броня {player.block}", True, settings.BLOCK_COLOR)
self.screen.blit(hp, (40, 40))
self.screen.blit(en, (40, 72))
self.screen.blit(bl, (40, 96))
def _draw_hand(self, player, combat):
cards = player.hand.cards
if not cards:
return
card_w, card_h = 120, 160
gap = 12
total_w = len(cards) * card_w + (len(cards) - 1) * gap
start_x = (settings.SCREEN_WIDTH - total_w) // 2
y = settings.SCREEN_HEIGHT - card_h - 80
rects = []
for i, card in enumerate(cards):
x = start_x + i * (card_w + gap)
rect = pygame.Rect(x, y, card_w, card_h)
can = combat.can_play_card(i)
color = card.color if can else (50, 50, 60)
pygame.draw.rect(self.screen, color, rect, border_radius=8)
pygame.draw.rect(self.screen, (30, 30, 40), rect, 2, border_radius=8)
name = self.font.render(card.name[:10], True, settings.TEXT_WHITE)
cost = self.font.render(str(card.cost), True, settings.ENERGY_COLOR)
self.screen.blit(name, (x + 8, y + 8))
self.screen.blit(cost, (x + card_w - 24, y + 8))
rects.append(rect)
combat._hand_rects = rects # учебный хак; в эталоне rect хранят в UI
def _draw_enemies(self, encounter):
living = encounter.get_living_enemies()
for i, enemy in enumerate(living):
x = 400 + i * 200
pygame.draw.rect(self.screen, (90, 40, 50), (x, 120, 160, 100), border_radius=8)
nm = self.font.render(enemy.name, True, settings.TEXT_WHITE)
hp = self.font.render(f"{enemy.hp}/{enemy.max_hp}", True, settings.HP_COLOR)
intent = self.font.render(f"{enemy.current_intent} {enemy.intent_value}", True, settings.TEXT_GOLD)
self.screen.blit(nm, (x + 8, 130))
self.screen.blit(hp, (x + 8, 155))
self.screen.blit(intent, (x + 8, 180))
def _draw_log(self, log):
y = settings.SCREEN_HEIGHT - 200
for line in log[-5:]:
surf = self.font.render(line[:70], True, settings.TEXT_WHITE)
self.screen.blit(surf, (24, y))
y += 22
main.py — режим «только бой» для отладки:
import pygame
import settings
from classes.player import Player
from classes.combat import CombatManager
from classes.enemy import create_encounter
from classes.ui import UI
def main():
pygame.init()
screen = pygame.display.set_mode((settings.SCREEN_WIDTH, settings.SCREEN_HEIGHT))
clock = pygame.time.Clock()
player = Player()
combat = CombatManager(player, create_encounter("slime"))
ui = UI(screen)
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_e:
combat.end_player_turn()
if event.key == pygame.K_ESCAPE:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if hasattr(combat, "_hand_rects"):
for i, rect in enumerate(combat._hand_rects):
if rect.collidepoint(event.pos):
combat.play_card(i, 0)
if combat.combat_over:
running = False
hitboxes = ui.draw_combat(player, combat)
pygame.display.flip()
clock.tick(settings.FPS)
pygame.quit()
if __name__ == "__main__":
main()
Самопроверка
- Клик по карте с достаточной энергией наносит урон.
-
Eзавершает ход, враг атакует, в журнале новые строки. - Серая карта при нехватке маны (визуально темнее).
Двухшаговый ввод (карта → цель)
В AutoBattler Game._handle_click для боя устроен так:
- Клик по врагу — запомнить
combat.selected_enemy_index. - Клик по карте — если карте нужна цель и враг не выбран при нескольких живых, розыгрыш отклоняется; иначе
play_card(index, selected_enemy_index). - Клик по кнопке «Конец хода» или клавиша E —
end_player_turn().
Учебный обработчик:
def _handle_combat_click(self, pos):
combat = self.run.combat
if combat is None:
return
# 1) хитбоксы врагов из ui.last_enemy_rects
for i, rect in enumerate(self.ui.last_enemy_rects):
if rect.collidepoint(pos):
combat.select_enemy(i)
return
# 2) карты
for i, rect in enumerate(self.ui.last_hand_rects):
if rect.collidepoint(pos):
combat.play_card(i, combat.selected_enemy_index)
return
# 3) конец хода
if self.ui.end_turn_rect.collidepoint(pos):
combat.end_player_turn()
Храните last_hand_rects / last_enemy_rects в UI после отрисовки — те же координаты, что при draw, иначе клики «мимо».
Этап 9 — RunState и главное меню
Цель — переключение screen между меню и боем, класс Game как в AutoBattler.
classes/game_state.py:
import settings
from classes.player import Player
from classes.combat import CombatManager
from classes.enemy import create_encounter
class RunState:
SCREEN_MENU = "menu"
SCREEN_COMBAT = "combat"
SCREEN_MAP = "map"
def __init__(self):
self.screen = self.SCREEN_MENU
self.player = Player()
self.combat: CombatManager | None = None
def start_debug_combat(self):
self.player = Player()
self.combat = CombatManager(self.player, create_encounter("slime"))
self.screen = self.SCREEN_COMBAT
def return_menu(self):
self.screen = self.SCREEN_MENU
self.combat = None
main.py — кнопка «Новый бой (тест)» на меню и RunState.
Сверьте с RunState в game_state.py эталона — там больше экранов (reward, shop, rest, victory).
Самопроверка
- Из меню вход в бой и возврат
Esc(пока без карты). -
RunStateне создаёт второйCombatManager, пока бой не закончен.
Этап 10 — процедурная карта узлов
Цель — GameMap с этажами и связями, клик по доступному узлу запускает бой.
classes/map.py (ядро):
import random
import settings
class MapNode:
def __init__(self, node_type: str, floor: int, row: int):
self.type = node_type
self.floor = floor
self.row = row
self.connections: list["MapNode"] = []
self.available = False
self.completed = False
@property
def color(self):
return settings.NODE_COLORS.get(self.type, (120, 120, 120))
class GameMap:
FLOORS = 8 # в практикуме короче, в эталоне 15
def __init__(self):
self.floors: list[list[MapNode]] = []
self.current_node: MapNode | None = None
self._generate()
def _generate(self):
for f in range(self.FLOORS):
row = random.randint(0, 2)
ntype = settings.NODE_BOSS if f == self.FLOORS - 1 else settings.NODE_COMBAT
if f > 0 and f % 4 == 0:
ntype = settings.NODE_REST
self.floors.append([MapNode(ntype, f, row)])
for f in range(len(self.floors) - 1):
for a in self.floors[f]:
for b in self.floors[f + 1]:
if abs(a.row - b.row) <= 1:
a.connections.append(b)
for node in self.floors[0]:
node.available = True
def select_node(self, node: MapNode) -> bool:
if not node.available or node.completed:
return False
if self.current_node and node not in self.current_node.connections:
if node.floor != 0:
return False
self.current_node = node
node.completed = True
for n in self._all_nodes():
n.available = False
for nxt in node.connections:
nxt.available = True
return True
def _all_nodes(self):
for floor in self.floors:
for n in floor:
yield n
Добавьте в settings.py константы NODE_COMBAT, NODE_REST, NODE_BOSS, NODE_COLORS.
В RunState.start_new_run():
from classes.map import GameMap
def start_new_run(self):
self.player = Player()
self.game_map = GameMap()
self.combat = None
self.screen = self.SCREEN_MAP
Самопроверка
- После первого узла доступны только соседи следующего этажа.
- Узел босса на последнем этаже.
Веса узлов (эталон)
В GameMap._random_node_type эталон задаёт вероятности:
| Тип | Вес | Смысл |
|---|---|---|
combat | 45 | обычный бой |
elite | 12 | сложный бой, лучшая награда |
rest | 8 | лечение / кузница |
shop | 10 | покупки |
treasure | 8 | реликвия |
event | 17 | случайное событие |
Каждый 5-й этаж (f % 5 == 0) в генераторе эталона принудительно получает костёр в одной из колонок — ритм «бой → бой → отдых».
Отрисовка карты — UI.draw_map: узлы по координатам (floor, row), линии между connections, подсветка available / completed.
Этап 11 — связка карта → бой → награда
Цель — после победы экран reward с тремя картами на выбор, добавление в колоду.
classes/card_catalog.py:
import random
from classes.card import load_card_database, create_card, Card
def get_reward_cards(count: int = 3, exclude_basic: bool = True) -> list[Card]:
pool = load_card_database()
if exclude_basic:
pool = [c for c in pool if c.get("rarity") != "basic"]
picks = random.sample(pool, min(count, len(pool)))
return [create_card(p) for p in picks]
В RunState:
SCREEN_REWARD = "reward"
def on_combat_victory(self):
from classes.card_catalog import get_reward_cards
self.reward_cards = get_reward_cards(3)
self.screen = self.SCREEN_REWARD
def pick_reward(self, index: int):
if 0 <= index < len(self.reward_cards):
self.player.deck.add_to_draw(self.reward_cards[index])
self.reward_cards = []
self.screen = self.SCREEN_MAP
Game после combat.combat_over и combat.victory вызывает run.on_combat_victory().
Самопроверка
- Колода растёт после выбора карты.
- Возврат на карту, текущий узел отмечен пройденным.
Этап 12 — реликвии
Цель — пассивный предмет на весь забег, срабатывание при старте боя.
data/relics.json:
[
{
"id": "burning_blood",
"name": "Горящая кровь",
"description": "После боя восстанавливает 6 HP.",
"effect": "heal_after_combat",
"value": 6,
"rarity": "common"
},
{
"id": "bag_of_marbles",
"name": "Мешок шариков",
"description": "В начале боя все враги получают Уязвимость на 1 ход.",
"effect": "start_vulnerable",
"value": 1,
"rarity": "common"
}
]
classes/relic.py + хуки в CombatManager._init_combat и после победы.
def apply_relic_on_combat_start(player, relic):
if relic.effect == "extra_draw":
return relic.value # добавить к STARTING_HAND в _init_combat
return 0
def apply_relic_on_combat_end(player, relic, victory: bool):
if victory and relic.effect == "heal_after_combat":
player.heal(relic.value)
В RunState.start_new_run выдайте стартовую реликвию:
from classes.relic import get_starter_relic
self.player.relics.append(get_starter_relic())
Самопроверка
- После победы HP растёт при
burning_blood. - Враги начинают бой с уязвимостью при
bag_of_marbles.
Этап 13 — лавка и костёр
Цель — узлы shop и rest на карте.
| Узел | Логика |
|---|---|
| rest | +30% max_hp или улучшение случайной карты в колоде |
| shop | покупка карты за 50 золота, удаление карты за 75 |
В эталоне экраны SCREEN_SHOP, SCREEN_REST, списки shop_cards / shop_relics в RunState.
Минимальный rest:
def rest_heal(self):
heal = max(1, int(self.player.max_hp * 0.3))
self.player.hp = min(self.player.max_hp, self.player.hp + heal)
Самопроверка
- Золото уменьшается при покупке.
- На костре HP не превышает максимум.
Этап 14 — сохранения, локализация, полировка
Цель — мета-прогресс между забегами и вынос строк.
SaveSystem (как в эталоне):
total_runs,total_wins,best_floor,unlocked_cards;- файл
save_data.jsonв корне, в.gitignore; record_run_end(floor, won, kills)приGAME_OVER/VICTORY.
locale.py — константы MENU_NEW_RUN, COMBAT_YOUR_TURN, подписи узлов. В AutoBattler весь видимый текст вынесен туда для единообразия.
Опциональные улучшения как в репозитории:
classes/audio.py— процедурные звуки без файлов;classes/effects.py— вспышки урона, частицы победы;classes/card_editor.py— редактор кастомных карт →data/custom_cards.json;classes/card_effects.py— расширенные эффекты (~40 механик).
Рефакторинг main.py в класс Game:
class Game:
def __init__(self):
pygame.init()
self.screen = pygame.display.set_mode((settings.SCREEN_WIDTH, settings.SCREEN_HEIGHT))
self.clock = pygame.time.Clock()
self.save = SaveSystem()
self.run = RunState(self.save)
self.ui = UI(self.screen)
self.running = True
def run_loop(self):
while self.running:
dt = self.clock.tick(settings.FPS)
for event in pygame.event.get():
self._handle_event(event)
self._update(dt)
self._draw()
pygame.display.flip()
pygame.quit()
Самопроверка
- Второй забег показывает статистику в меню.
-
main.pyостаётся тонким контроллером.
Этап 15 — реестр эффектов (card_effects.py)
Цель — вынести десятки механик из CombatManager в модуль с явными идентификаторами, как в card_effects.py.
Зачем отдельный файл
| Проблема без реестра | Решение |
|---|---|
play_card на 400+ строк | _resolve_card + apply_on_play_bonuses |
дубли if effect == "poison" | константы EFFECT_POISON, таблица целей |
| сложно тестировать | вызов apply_effect(combat, bonus, target) из unit-теста |
Минимальный реестр (5 эффектов)
Создайте classes/card_effects.py:
EFFECT_VULNERABLE = "vulnerable"
EFFECT_WEAK = "weak"
EFFECT_STRENGTH = "strength"
EFFECT_DRAW = "draw"
EFFECT_POISON = "poison"
TARGET_ENEMY = "enemy"
TARGET_SELF = "self"
TARGET_ALL_ENEMIES = "all_enemies"
def collect_card_bonuses(card) -> list[dict]:
"""Собрать effect/effect2 и bonuses[] в единый список."""
out = []
if card.effect:
out.append({"id": card.effect, "value": card.effect_value, "target": TARGET_ENEMY})
if getattr(card, "effect2", None):
out.append({"id": card.effect2, "value": card.effect2_value, "target": TARGET_ENEMY})
for b in getattr(card, "bonuses", []) or []:
out.append(b)
return out
def apply_on_play_bonuses(combat, card, target_index, messages: list[str]):
for bonus in collect_card_bonuses(card):
_apply_one(combat, bonus, target_index, messages)
def _apply_one(combat, bonus: dict, target_index, messages: list[str]):
eid = bonus.get("id") or bonus.get("effect")
value = int(bonus.get("value", 0))
target = bonus.get("target", TARGET_ENEMY)
player = combat.player
living = combat.encounter.get_living_enemies()
if eid == EFFECT_VULNERABLE and living:
idx = target_index if target_index is not None else 0
living[min(idx, len(living) - 1)].vulnerable = max(
getattr(living[min(idx, len(living) - 1)], "vulnerable", 0), value
)
messages.append(f"Уязвимость {value}!")
elif eid == EFFECT_WEAK and living:
idx = target_index if target_index is not None else 0
living[min(idx, len(living) - 1)].weak = value
messages.append("Слабость!")
elif eid == EFFECT_STRENGTH:
player.strength += value
messages.append(f"+{value} силы")
elif eid == EFFECT_DRAW:
for c in player.deck.draw(value):
player.hand.add(c)
messages.append(f"Добор {value}")
elif eid == EFFECT_POISON and living:
idx = target_index if target_index is not None else 0
enemy = living[min(idx, len(living) - 1)]
enemy.poison = getattr(enemy, "poison", 0) + value
messages.append(f"Яд {value}")
Тики яда и кровотечения в _enemy_phase
Перед execute_intent у каждого врага:
if getattr(enemy, "poison", 0) > 0:
enemy.take_damage(enemy.poison)
enemy.poison = max(0, enemy.poison - 1)
В эталоне аналогичные тики и Execute (×1.5 по раненым), Lifesteal, Echo уже подключены — добавляйте по одному elif в _apply_one и строку в card_text.BONUS_LABELS.
Самопроверка
- Карта только с
effect: "vulnerable"в JSON работает без правкиcombat.py. -
collect_card_bonusesвидит иbonuses[], и legacy-поляeffect/effect2.
Этап 16 — редактор карт и custom_cards.json
Цель — экран SCREEN_CARD_EDITOR, сохранение пользовательских карт в пул наград и магазина.
Поток редактора
Черновик и виджеты
CardEditorState хранит draft: dict с полями name, description, cost, damage, health, kind, effect. В эталоне ввод через TextInput, NumberInput, OptionPicker из classes/widgets.py.
Минимальное сохранение:
def editor_data_to_card_dict(data: dict) -> dict:
effect = data.get("effect", "damage")
kind = data.get("kind", "spell")
card_type = "creature" if kind == "creature" else {
"damage": "attack", "block": "block", "buff": "buff",
"debuff": "debuff", "draw": "draw",
}[effect]
return {
"id": data.get("id") or f"custom_{uuid.uuid4().hex[:8]}",
"name": data.get("name", "Без названия"),
"type": card_type,
"cost": int(data.get("cost", 1)),
"value": int(data.get("damage", 0)),
"health": int(data.get("health", 0)),
"description": data.get("description", ""),
"rarity": "custom",
"custom": True,
"kind": kind,
}
card_catalog.load_all_cards() = cards.json + custom_cards.json. После сохранения новая карта попадает в get_reward_cards() без перезапуска, если каталог перечитывается при генерации награды.
Подсказки на картах
classes/card_text.py строит строки для tooltip из механики — build_tooltip_lines(card, player_energy). Подключите на этапе 8 при наведении: если card.get_tooltip_lines(energy), рисуйте всплывающий прямоугольник над картой.
Самопроверка
- Карта из редактора появляется в награде после победы.
-
Escвозвращает в меню без падения. -
custom_cards.jsonв.gitignore.
Моддинг и отладка
Баланс в settings.py
Эталонные значения (можно копировать в учебный проект):
STARTING_HP = 85
STARTING_ENERGY = 3
STARTING_GOLD = 99
STARTING_HAND = 5
DRAW_PER_TURN = 5
MAX_HAND = 10
Новый враг
Запись в data/enemies.json (ключ — id):
"jaw_worm": {
"name": "Челюстной червь",
"hp": 40,
"attack_damage": 11,
"intent_pattern": ["attack", "buff", "attack"],
"buff_value": 3,
"description": "Разгоняется и бьёт сильно."
}
В эталоне файл — объект id → данные, в учебнике на этапе 5 — массив; при слиянии с эталоном приведите формат к одному стилю.
Новая реликвия
{
"id": "oddly_smooth_stone",
"name": "Гладкий камень",
"description": "+1 энергия в начале боя.",
"effect": "extra_energy",
"value": 1,
"rarity": "common"
}
Обработчик extra_energy в apply_relic_on_combat_start увеличивает player.max_energy на время забега или даёт энергию в первый ход — сверьте с classes/relic.py эталона.
Отладка без UI
python -c "
from classes.player import Player
from classes.combat import CombatManager
from classes.enemy import create_encounter
p = Player()
c = CombatManager(p, create_encounter('slime'))
c.play_card(0, 0)
c.end_player_turn()
print(c.log[-3:])
"
Логирование кликов
На время этапа 8:
if event.type == pygame.MOUSEBUTTONDOWN:
print("click", event.pos, "hand", getattr(ui, "last_hand_rects", []))
Сравните координаты клика с Rect карт — расхождение на 1–2 px часто из-за CARD_OVERLAP в эталоне (карты наезжают друг на друга, хитбокс уже визуальной ширины).
load_enemies().Итоговая самопроверка и эталон
Чек-лист учебного прототипа
| # | Критерий | Да / нет |
|---|---|---|
| 1 | Окно 1280×720, стабильный FPS | |
| 2 | Карты и враги грузятся из JSON | |
| 3 | Колода, рука, сброс, перетасовка | |
| 4 | Энергия, блок, уязвимость работают | |
| 5 | Несколько врагов, выбор цели атаки | |
| 6 | Намерения врагов видны до их хода | |
| 7 | Карта узлов, переход в бой | |
| 8 | Награда — добавление карты в колоду | |
| 9 | Хотя бы одна реликвия влияет на бой | |
| 10 | Код разбит на classes/* и data/* | |
| 11 | Реестр эффектов отделён от боя | |
| 12 | Редактор пишет в custom_cards.json |
Сравнение с AutoBattler
| Компонент | Практикум (минимум) | AutoBattler |
|---|---|---|
| Карт в базе | 3–10 + custom | 66+ |
| Эффекты | 5+, рост до ~40 | card_effects.py, ALL_EFFECT_IDS |
| Этажей карты | 8 | 15 (GameMap.FLOORS) |
| Враги | 2–3 id | 9 + боссы в JSON |
| Реликвии | 1–2 | 15 в relics.json |
| Редактор карт | этап 16 | card_editor.py + виджеты |
| Звук | — | процедурный audio.py |
| Переходы UI | — | transitions.py, BannerQueue, fade |
| Локализация | частично | locale.py целиком |
Маршрут чтения эталона после практикума
Рекомендуемый порядок файлов в клоне:
settings.py— баланс и константы узлов.classes/card.py→card_catalog.py→card_effects.py— данные и механика карт.classes/player.py→enemy.py→combat.py— бой.classes/game_state.py→map.py— забег.classes/ui.py— отрисовка (большой файл, читайте поdraw_*).main.py— склейка экранов,_handle_click, звук и баннеры.
Полезные точки входа для экспериментов:
- изменить стартовую колоду —
create_starting_deck()вcard_catalog.py; - усложнить обычные бои —
create_combat_encounter(..., floor=...); - добавить баннер босса —
Game._on_screen_changedприNODE_BOSS.
Типичные ошибки
| Симптом | Вероятная причина | Что сделать |
|---|---|---|
| Чёрный экран | нет display.flip() | вызов в конце цикла |
| Карты не кликаются | rect руки не совпадает с отрисовкой | храните rect в UI, передавайте в обработчик |
| Враг бьёт дважды за ход | _plan_intent не в конце execute_intent | планируйте намерение после действия |
| Колода «теряет» карты | при reset_combat не собрали discard | get_all_cards() как в эталоне |
| JSON не грузится | запуск не из корня | cd auto_battler или _data_path() от __file__ |
| Бесконечный добор | нет лимита руки | Hand.add возвращает False |
play_card всегда False при 2 врагах | нет выбранной цели | сначала клик по врагу |
| Эффект из JSON игнорируется | логика только в attack/block | этап 15, apply_on_play_bonuses |
| Кастомная карта не в награде | каталог не читает custom | load_all_cards() в get_reward_cards |
Идеи для расширения (самостоятельно)
- События (
NODE_EVENT) — случайный исход с риском HP/золота, как в эталонеRunState+event_message. - Существа (
CARD_CREATURE) — урон плюсgain_block(health)при розыгрыше. - Улучшение карт на костре — копия карты с
upgraded: trueи +25% кvalue. - Достижения в
SaveSystem.data["achievements"]. - HoverAnimator и вспышки урона из
effects.py/hover_anim.py. - Спрайты в
assets/вместоpygame.draw.rect. - Порт на pygame-ce при необходимости свежих сборок.
Связанные материалы
- Практикум разработки игр — о разделе — другие треки (Battle City, Tetris, diabloид).
- Разработка игр на Python — Pygame, цикл, ввод.
- Компьютерные игры — о разделе — жанры deckbuilder и roguelike.
- Эталонный код — github.com/Spirzen/AutoBattler.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Пошаговый практикум — Battle City на Python и Pygame: архитектура, 16 этапов, полные листинги, сравнение с NES-оригиналом, отладка и расширения. Пошаговый практикум Match-3 на Python и Pygame — архитектура, 14 этапов, консольный прототип, отладка, тесты, подсказки, анимация и спец-фишки. Пошаговый практикум — аркада Ping Pong (Pong) на Python и Pygame: архитектура, баланс, зависимости, 14 этапов до прототипа, бонус — substeps и звук. Пошаговый практикум — гоночная мини-игра на Python и Pygame: архитектура, физика, зависимости, 16 этапов до заезда с кругами, таймером, соперниками и полировкой. Пошаговый практикум — Tetris (тетрис) на Python и Pygame: архитектура, 7 тетромино, вращение, линии, очки, уровни, ghost, 7-bag, hold и 20 этапов до играбельного прототипа. Пошаговый практикум — hack and slash в духе Diablo на Python и Pygame: архитектура, гейм-дизайн, зависимости, 18 обязательных этапов и 4 бонусных до полноценного ARPG-прототипа. Пошаговый практикум — survivor-like в духе Vampire Survivors на Java (Swing, Java2D): архитектура, гейм-дизайн, Maven, 18 этапов с полным кодом ключевых систем и карта расширений до Java Survivors. Пошаговый практикум — карточный roguelike в браузере на TypeScript, React и Vite: архитектура, dispatch, 16 этапов, cardEffects, PWA и деплой. Эталон — OnlineCardGame («Приключения Урала Батыра»).Python — Battle City
Python — Match3
Python — Ping Pong
Python — Racing
Python — Tetris
Python — диаблоид
Java — Java Survivors
TypeScript — OnlineCardGame