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

Python — карточная стратегия

Разработчику Средний уровень

О практикуме

Соберём карточный roguelike в духе Slay the Spire с темпом боя ближе к Hearthstone: колода, рука, энергия на ход, несколько врагов, намерения, карта путей между боями, награды и реликвии. Стек — Python 3.10+ и Pygame 2.5+, без стороннего игрового движка.

Эталонный проект
Полная реализация с 66+ картами, редактором, звуком и мета-прогрессом — репозиторий Spirzen/AutoBattler («Тени Шпиля»). Практикум ведёт к той же архитектуре папок и модулей; на каждом этапе код запускается, даже если это только меню или один бой на прямоугольниках.

Для кого материал
Нужны классы, списки, словари, базовый JSON и знакомство с Pygame из статьи Разработка игр на Python. Опыт deckbuilder-ов полезен, но не обязателен — механики вводим по одной.

Управление в финальной версии практикума

ДействиеУправление
Клик по карте, врагу, узлу карты, кнопкамМышь
Завершить ход в боюE
Меню / выход из редактораEsc

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

  1. Архитектура — экраны, бой, данные.
  2. Зависимости и структура папок.
  3. Этап 0 — минимальный запуск.
  4. Этапы 1–14 — ядро забега и боя.
  5. Этапы 15–16 — реестр эффектов и редактор карт (как в эталоне).
  6. Моддинг и отладка · Итоговая самопроверка.

Имя папки в примерах — auto_battler/. Параллельно держите клон AutoBattler (F:\Projects\Games\AutoBattler или git clone) и сверяйте одноимённые файлы после каждого этапа.

Что получится в конце

РезультатОписание
Играбельный забегменю → карта узлов → бои → награда → костёр/лавка → босс
Чистая архитектуралогика без Pygame в classes/, контент в data/*.json
Мост к эталонуте же имена экранов, CombatManager, RunState, что в «Тенях Шпиля»

Ориентир по времени (с перерывами, без полировки графики):

БлокЭтапыЧасы
Окно и данные0–32–4
Бой без UI4–74–6
Pygame и забег8–116–10
Мета и контент12–164–8
Итого0–1616–28

Архитектура

Карточный roguelike строится вокруг забега (run): карта узлов → события → сражения → усиление колоды → босс. Каждый бой — отдельная подсистема со своим конечным автоматом; между боями колода и реликвии сохраняются, HP и золото тоже.

Жанровые опоры

ИдеяОткуда в жанреКак реализуем
Карта путейSlay the SpireGameMap, узлы combat / rest / shop
Энергия и рука каждый ходHearthstone, Slay the SpirePlayer.energy, добор в CombatManager
Намерения враговSlay the SpireEnemy._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.

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

СлойОтветственностьМодули
ДанныеКарты, враги, реликвии из JSONdata/*, card_catalog.py
МодельСущности и правила без Pygamecard.py, player.py, enemy.py, combat.py, relic.py
ЗабегКарта, золото, смена экрановgame_state.py, map.py
ПредставлениеРисование, хитбоксыui.py, widgets.py, effects.py
ВходКлики, клавишиmain.pyGame

Правило для поддерживаемости: урон и блок считаются в 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.

Диаграмма классов (целевая)

Почему JSON, а не код для каждой карты
Дизайнер баланса правит 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–1main.py, settings.pymain.py, settings.py
2Card, data/cards.jsonclasses/card.py, data/cards.json
3–4Deck, Hand, Playerclasses/card.py, classes/player.py
5Enemy, Encounterclasses/enemy.py, data/enemies.json
6–7CombatManagerclasses/combat.py
8UI.draw_combatclasses/ui.py (фрагмент)
9–11RunState, GameMapclasses/game_state.py, classes/map.py
12–13реликвии, shop/restclasses/relic.py, экраны в main.py
14SaveSystem, localeclasses/game_state.py, locale.py
15card_effects.pyclasses/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__.

Кодировка JSON
Файлы в 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:

ПолеТипРоль
idstringключ в базе, дубликаты в колоде допустимы
name, descriptionstringUI и подсказка
typestringattack, block, buff, debuff, draw, creature
costintэнергия при розыгрыше
valueintурон или величина эффекта по типу
raritystringbasic, common, uncommon, rare
blockintдоп. броня на карте атаки
drawintдобор при розыгрыше
aoeboolурон/дебафф по всем врагам
effect, effect_valuestring, intпервый бонус (vulnerable, poison…)
effect2, effect2_valuestring, intвторой бонус
bonuseslist[dict]несколько эффектов в одной карте
lifesteal, pierce (через bonuses)boolфлаги из card_effects.py
kindstringspell / creature для редактора
healthint«здоровье» существа → броня при розыгрыше

Пример карты эталона с двумя эффектами (сокращённо):

{
"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 в эталоне:

  1. Собрать targets (один враг, AOE или случайный).
  2. Нанести урон / выдать броню / призвать «существо».
  3. Вызвать apply_on_play_bonuses для effect / bonuses[].
  4. Обработать draw, energy_gain, self_damage.
  5. При флаге 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, множитель HP 1.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 для боя устроен так:

  1. Клик по врагу — запомнить combat.selected_enemy_index.
  2. Клик по карте — если карте нужна цель и враг не выбран при нескольких живых, розыгрыш отклоняется; иначе play_card(index, selected_enemy_index).
  3. Клик по кнопке «Конец хода» или клавиша Eend_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 эталон задаёт вероятности:

ТипВесСмысл
combat45обычный бой
elite12сложный бой, лучшая награда
rest8лечение / кузница
shop10покупки
treasure8реликвия
event17случайное событие

Каждый 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 в эталоне (карты наезжают друг на друга, хитбокс уже визуальной ширины).

Смешение list и dict в enemies.json
Учебный этап 5 использует массив врагов, эталон — словарь по id. Перед копированием контента из AutoBattler конвертируйте формат или унифицируйте 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 + custom66+
Эффекты5+, рост до ~40card_effects.py, ALL_EFFECT_IDS
Этажей карты815 (GameMap.FLOORS)
Враги2–3 id9 + боссы в JSON
Реликвии1–215 в relics.json
Редактор картэтап 16card_editor.py + виджеты
Звукпроцедурный audio.py
Переходы UItransitions.py, BannerQueue, fade
Локализациячастичноlocale.py целиком

Маршрут чтения эталона после практикума

Рекомендуемый порядок файлов в клоне:

  1. settings.py — баланс и константы узлов.
  2. classes/card.pycard_catalog.pycard_effects.py — данные и механика карт.
  3. classes/player.pyenemy.pycombat.py — бой.
  4. classes/game_state.pymap.py — забег.
  5. classes/ui.py — отрисовка (большой файл, читайте по draw_*).
  6. 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 не собрали discardget_all_cards() как в эталоне
JSON не грузитсязапуск не из корняcd auto_battler или _data_path() от __file__
Бесконечный доборнет лимита рукиHand.add возвращает False
play_card всегда False при 2 врагахнет выбранной целисначала клик по врагу
Эффект из JSON игнорируетсялогика только в attack/blockэтап 15, apply_on_play_bonuses
Кастомная карта не в наградекаталог не читает customload_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 при необходимости свежих сборок.

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


См. также

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

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