Загрузка данных


"""
BLOCK BLAST — клон головоломки на pygame.

Правила:
    Поле 8x8. Внизу лежат три фигуры, их надо перетащить на поле.
    Заполненная строка ИЛИ столбец схлопывается и даёт очки.
    Новые фигуры приходят, только когда израсходованы все три.
    Игра кончается, когда ни одну из оставшихся фигур некуда поставить.

Управление:
    ЛКМ (зажать и тащить) — перетащить фигуру на поле
    R                     — новая игра
    ESC                   — выход

Запуск:
    pip install pygame-ce
    python blockblast.py

Графика:
    Если рядом лежит папка assets/ — игра рисует ей (картинки из дизайна).
    Если папки нет — всё рисуется кодом, игра работает точно так же.
"""

import json      # чтение tokens.json и файла с рекордом
import math      # синус для пульсации текста, tau для случайного угла
import os        # пути к файлам, проверка существования папки assets
import random    # выбор фигур, разлёт частиц
import sys       # выход из программы

import pygame    # собственно движок


# ═════════════════════════════════════════════════════════════════════════════
# 1. КОНСТАНТЫ И ГЕОМЕТРИЯ
# ═════════════════════════════════════════════════════════════════════════════

GRID = 8                  # сторона игрового поля в клетках (поле 8x8)
CELL = 58                 # сторона одной клетки поля в пикселях
TRAY_CELL = 31            # сторона клетки, когда фигура лежит в лотке (мельче)

WIDTH, HEIGHT = 560, 850  # размеры окна игры в пикселях
BOARD_W = GRID * CELL                  # ширина поля = 8 * 58 = 464 px
BOARD_X = (WIDTH - BOARD_W) // 2       # отступ поля слева, чтобы оно было по центру
BOARD_Y = 210                          # отступ поля сверху (под ним шапка со счётом)

TRAY_Y = BOARD_Y + BOARD_W + 34        # верхняя граница лотка с фигурами
TRAY_H = 130                           # высота лотка

FPS = 60                               # целевая частота кадров

BASE_DIR = os.path.dirname(os.path.abspath(__file__))   # папка, где лежит этот файл
ASSETS_DIR = os.path.join(BASE_DIR, "assets")           # папка с картинками из дизайна
SAVE_FILE = os.path.join(BASE_DIR, "blockblast_save.json")  # файл с рекордом


# ═════════════════════════════════════════════════════════════════════════════
# 2. ЦВЕТА
# ═════════════════════════════════════════════════════════════════════════════

# Значения по умолчанию. Если есть assets/tokens.json — они будут перезаписаны оттуда.
DEFAULT_TOKENS = {
    "bg_top": "#1A1630",        # верх фонового градиента
    "bg_bottom": "#0E0C1C",     # низ фонового градиента
    "panel": "#262142",         # подложка поля и карточки проигрыша
    "panel_edge": "#342D58",    # кромка вокруг подложки
    "cell_empty": "#1E1A36",    # пустая клетка поля
    "cell_empty_hi": "#2C264A", # пустая клетка в линии, которая сейчас схлопнется
    "text": "#F5F3FF",          # основной текст
    "text_dim": "#968FB9",      # приглушённый текст
    "gold": "#FFCD54",          # цвет рекорда и прибавки очков
    "combo": "#78E6FF",         # цвет строки комбо
    "overlay": "#0A0816",       # затемнение экрана при проигрыше
    "block_red": "#FF5E6C",     # ↓ восемь цветов блоков
    "block_orange": "#FF9A44",
    "block_yellow": "#FFD64A",
    "block_green": "#6ADE86",
    "block_cyan": "#48C7EC",
    "block_blue": "#6C8CFF",
    "block_purple": "#C47AFF",
    "block_pink": "#FF7ABE",
}

# Порядок имён важен: блок хранится в поле как ИНДЕКС в этом списке,
# и по этому же имени ищется файл assets/blocks/block_<имя>.png
BLOCK_NAMES = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink"]


def hex_to_rgb(value):
    """Превращает строку '#FF5E6C' в кортеж (255, 94, 108)."""
    value = value.lstrip("#")                                    # убираем ведущую решётку
    return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4))     # режем по 2 символа и переводим из 16-ричной


def load_tokens():
    """Читает палитру из assets/tokens.json, а чего нет — берёт из умолчаний."""
    tokens = dict(DEFAULT_TOKENS)                                # копия умолчаний (оригинал не портим)
    path = os.path.join(ASSETS_DIR, "tokens.json")               # ожидаемый путь к файлу палитры
    try:                                                          # файла может не быть — это нормально
        with open(path, "r", encoding="utf-8") as f:             # открываем на чтение
            tokens.update(json.load(f))                          # перекрываем умолчания значениями из файла
    except Exception:                                             # нет файла / битый JSON
        pass                                                      # молча остаёмся на умолчаниях
    return {k: hex_to_rgb(v) for k, v in tokens.items()}         # все значения переводим в RGB


TOKENS = load_tokens()                                            # палитра, посчитанная один раз при старте

BG_TOP = TOKENS["bg_top"]                    # ↓ раскладываем палитру по коротким именам
BG_BOTTOM = TOKENS["bg_bottom"]
PANEL = TOKENS["panel"]
PANEL_EDGE = TOKENS["panel_edge"]
EMPTY_CELL = TOKENS["cell_empty"]
EMPTY_CELL_HI = TOKENS["cell_empty_hi"]
TEXT = TOKENS["text"]
TEXT_DIM = TOKENS["text_dim"]
GOLD = TOKENS["gold"]
COMBO_COLOR = TOKENS["combo"]
OVERLAY = TOKENS["overlay"]

# Список цветов блоков в том же порядке, что BLOCK_NAMES — доступ по индексу
BLOCK_COLORS = [TOKENS["block_" + name] for name in BLOCK_NAMES]


# ═════════════════════════════════════════════════════════════════════════════
# 3. ФИГУРЫ
# ═════════════════════════════════════════════════════════════════════════════

def _rect(h, w):
    """Прямоугольная фигура h на w: список смещений (строка, столбец)."""
    return [(r, c) for r in range(h) for c in range(w)]          # все клетки прямоугольника


# Каждая фигура — список смещений от её левого верхнего угла.
# Вращения нет: как в оригинале, фигура ставится только в том виде, в каком выпала.
SHAPES = [
    _rect(1, 1),                                     # одна клетка
    _rect(1, 2), _rect(2, 1),                        # палки из 2 клеток: лёжа и стоя
    _rect(1, 3), _rect(3, 1),                        # палки из 3
    _rect(1, 4), _rect(4, 1),                        # палки из 4
    _rect(1, 5), _rect(5, 1),                        # палки из 5
    _rect(2, 2),                                     # квадрат 2x2
    _rect(3, 3),                                     # квадрат 3x3 (самая тяжёлая фигура)
    [(0, 0), (0, 1), (1, 0)],                        # ↓ маленькие уголки: квадрат 2x2 без одной клетки
    [(0, 0), (0, 1), (1, 1)],
    [(0, 0), (1, 0), (1, 1)],
    [(0, 1), (1, 0), (1, 1)],
    [(0, 0), (1, 0), (2, 0), (2, 1), (2, 2)],        # ↓ большие уголки: две стороны квадрата 3x3
    [(0, 0), (0, 1), (0, 2), (1, 0), (2, 0)],
    [(0, 0), (0, 1), (0, 2), (1, 2), (2, 2)],
    [(0, 2), (1, 2), (2, 0), (2, 1), (2, 2)],
    [(0, 0), (1, 0), (2, 0), (2, 1)],                # ↓ буквы L и J в четырёх разворотах
    [(0, 1), (1, 1), (2, 1), (2, 0)],
    [(0, 0), (0, 1), (1, 0), (2, 0)],
    [(0, 0), (0, 1), (1, 1), (2, 1)],
    [(0, 0), (0, 1), (0, 2), (1, 1)],                # ↓ буква T в четырёх разворотах
    [(0, 1), (1, 0), (1, 1), (1, 2)],
    [(0, 0), (1, 0), (1, 1), (2, 0)],
    [(0, 1), (1, 0), (1, 1), (2, 1)],
    [(0, 1), (0, 2), (1, 0), (1, 1)],                # ↓ зигзаги S и Z
    [(0, 0), (0, 1), (1, 1), (1, 2)],
    [(0, 0), (1, 0), (1, 1), (2, 1)],
    [(0, 1), (1, 0), (1, 1), (2, 0)],
]

# Вес = шанс выпадения. Чем больше клеток в фигуре, тем реже она приходит:
# 9 минус размер, но не меньше 1. Так игра не душит квадратами 3x3.
SHAPE_WEIGHTS = [max(1.0, 9.0 - len(s)) for s in SHAPES]


# ═════════════════════════════════════════════════════════════════════════════
# 4. ЗАГРУЗКА КАРТИНОК ИЗ assets/
# ═════════════════════════════════════════════════════════════════════════════

class Assets:
    """
    Держит картинки из папки assets и отдаёт их отмасштабированными.

    Если папки нет или какой-то файл не прочитался — соответствующий метод
    вернёт None, и вызывающий код нарисует то же самое вручную.
    Поэтому игра запускается и с ассетами, и без них.
    """

    def __init__(self):
        self.images = {}                                          # сырые загруженные картинки: имя → Surface
        self.cache = {}                                           # готовые масштабированные версии: ключ → Surface
        self._load("bg", "bg.png")                                # фон целиком, 560x850
        self._load("panel_board", os.path.join("ui", "panel_board.png"))   # подложка поля, 488x488
        self._load("panel_card", os.path.join("ui", "panel_card.png"))     # карточка проигрыша, 400x280
        self._load("cell_empty", os.path.join("blocks", "cell_empty.png")) # пустая клетка, 58x58
        for name in BLOCK_NAMES:                                  # восемь цветных блоков
            self._load("block_" + name, os.path.join("blocks", "block_%s.png" % name))

    def _load(self, key, relative_path):
        """Пробует загрузить одну картинку. Не смог — просто не кладёт её в словарь."""
        path = os.path.join(ASSETS_DIR, relative_path)            # полный путь к файлу
        if not os.path.isfile(path):                              # файла нет — выходим тихо
            return
        try:
            self.images[key] = pygame.image.load(path).convert_alpha()  # грузим и переводим в формат экрана
        except Exception:                                          # битый файл или неподдерживаемый формат
            pass                                                   # останемся без него, сработает откат

    def get(self, key, size=None, alpha=255):
        """
        Отдаёт картинку `key`, при необходимости отмасштабированную до `size`
        и с прозрачностью `alpha`. Результат кэшируется, чтобы не пересчитывать каждый кадр.
        """
        if key not in self.images:                                 # картинки нет — пусть рисуют вручную
            return None
        size = int(round(size)) if size else None                  # дробный размер округляем до пикселя
        cache_key = (key, size, alpha)                             # чем однозначно определяется результат
        if cache_key in self.cache:                                # уже считали такую — отдаём готовую
            return self.cache[cache_key]
        image = self.images[key]                                   # исходник
        if size and size != image.get_width():                     # нужен другой размер — масштабируем
            image = pygame.transform.smoothscale(image, (size, size))   # smoothscale = сглаженное масштабирование
        if alpha < 255:                                            # нужна полупрозрачность
            image = image.copy()                                    # копия, чтобы не портить исходник
            image.set_alpha(alpha)                                  # выставляем прозрачность всей картинке
        self.cache[cache_key] = image                              # запоминаем на будущее
        return image


ASSETS = None    # сюда попадёт экземпляр Assets — после pygame.init() и создания окна


# ═════════════════════════════════════════════════════════════════════════════
# 5. ФУНКЦИИ РИСОВАНИЯ
# ═════════════════════════════════════════════════════════════════════════════

def shade(color, factor):
    """Осветляет (factor > 1) или затемняет (factor < 1) цвет, не выходя за 0..255."""
    return tuple(max(0, min(255, int(c * factor))) for c in color[:3])   # обрезаем каждый канал в допустимый диапазон


def draw_block(surface, x, y, size, color_id, alpha=255):
    """
    Рисует одну плитку в точке (x, y) со стороной size.
    color_id — индекс цвета в BLOCK_COLORS (он же индекс имени в BLOCK_NAMES).
    Сначала пробует картинку из assets, если её нет — рисует «конфетку» вручную.
    """
    image = ASSETS.get("block_" + BLOCK_NAMES[color_id], size, alpha) if ASSETS else None
    if image is not None:                                          # картинка есть — просто ставим её
        surface.blit(image, (int(x), int(y)))                      # blit = копирование картинки на поверхность
        return                                                     # дальше рисовать нечего

    color = BLOCK_COLORS[color_id]                                 # цвет для ручной отрисовки
    pad = max(1, int(size * 0.055))                                # внутренний отступ — это и есть зазор между плитками
    radius = max(3, int(size * 0.24))                              # радиус скругления углов
    body = pygame.Rect(int(x + pad), int(y + pad),                 # прямоугольник тела плитки
                       int(size - pad * 2), int(size - pad * 2))

    if alpha >= 255:                                               # непрозрачно — рисуем прямо на экране
        target, ox, oy = surface, 0, 0                             # цель рисования и нулевое смещение
    else:                                                           # полупрозрачно — рисуем на отдельном холсте
        target = pygame.Surface((body.w + 2, body.h + 2), pygame.SRCALPHA)  # холст с поддержкой прозрачности
        ox, oy = body.x - 1, body.y - 1                             # где потом поставим этот холст
        body = pygame.Rect(1, 1, body.w, body.h)                    # на холсте координаты локальные

    pygame.draw.rect(target, shade(color, 0.62), body, border_radius=radius)   # тёмная кромка снизу
    top = pygame.Rect(body.x, body.y, body.w,                                   # тело чуть ниже полной высоты,
                      max(1, body.h - max(2, int(size * 0.11))))                # чтобы снизу торчала кромка
    pygame.draw.rect(target, color, top, border_radius=radius)                  # само тело плитки
    gloss = pygame.Rect(body.x + int(size * 0.13), body.y + int(size * 0.11),   # светлая полоска-блик сверху
                        max(1, body.w - int(size * 0.26)), max(1, int(size * 0.22)))
    pygame.draw.rect(target, shade(color, 1.32), gloss,                          # блик = цвет, осветлённый в 1.32 раза
                     border_radius=max(2, int(size * 0.11)))

    if alpha < 255:                                                # если рисовали на отдельном холсте —
        target.set_alpha(alpha)                                    # задаём ему прозрачность
        surface.blit(target, (ox, oy))                             # и ставим на экран


def draw_empty_cell(surface, x, y, highlighted):
    """Рисует пустую клетку поля. highlighted — клетка в линии, которая сейчас схлопнется."""
    image = ASSETS.get("cell_empty", CELL) if ASSETS else None     # картинка пустой клетки из assets
    if image is not None:                                          # картинка есть
        surface.blit(image, (int(x), int(y)))                      # ставим её
    else:                                                           # картинки нет — рисуем скруглённый квадрат
        pygame.draw.rect(surface, EMPTY_CELL,
                         pygame.Rect(x + 3, y + 3, CELL - 6, CELL - 6), border_radius=13)
    if highlighted:                                                # клетка попадает в схлопывающуюся линию —
        tint = pygame.Surface((CELL - 6, CELL - 6), pygame.SRCALPHA)   # готовим полупрозрачную подсветку
        tint.fill((*EMPTY_CELL_HI, 150))                            # цвет подсветки + прозрачность 150 из 255
        surface.blit(tint, (int(x) + 3, int(y) + 3))                # накладываем поверх клетки


def make_background():
    """Готовит фон один раз при старте: картинка из assets либо градиент, нарисованный кодом."""
    image = ASSETS.get("bg") if ASSETS else None                   # пробуем взять bg.png
    if image is not None:                                          # картинка есть
        surface = pygame.Surface((WIDTH, HEIGHT))                  # холст размером с окно
        surface.blit(image, (0, 0))                                # кладём фон
        return surface                                             # готово, ничего рисовать не надо

    surface = pygame.Surface((WIDTH, HEIGHT))                      # холст под ручной градиент
    for y in range(HEIGHT):                                        # идём по всем строкам пикселей сверху вниз
        t = y / max(1, HEIGHT - 1)                                 # доля пути от 0 (верх) до 1 (низ)
        surface.fill(                                              # закрашиваем строку смешанным цветом
            (
                int(BG_TOP[0] + (BG_BOTTOM[0] - BG_TOP[0]) * t),   # красный канал: линейно от верхнего к нижнему
                int(BG_TOP[1] + (BG_BOTTOM[1] - BG_TOP[1]) * t),   # зелёный канал
                int(BG_TOP[2] + (BG_BOTTOM[2] - BG_TOP[2]) * t),   # синий канал
            ),
            pygame.Rect(0, y, WIDTH, 1),                           # полоска высотой в 1 пиксель
        )
    glow = pygame.Surface((WIDTH, WIDTH), pygame.SRCALPHA)         # отдельный холст под фиолетовое свечение
    for i in range(60, 0, -1):                                     # 60 вложенных кругов от большого к малому
        a = int(1.6 * (60 - i) / 60 * 12)                          # чем ближе к центру, тем плотнее
        pygame.draw.circle(glow, (120, 90, 220, a), (WIDTH // 2, WIDTH // 2), i * 5)   # круг с прозрачностью
    surface.blit(glow, (0, BOARD_Y - 40))                          # свечение примерно за игровым полем
    return surface                                                 # готовый фон


# ═════════════════════════════════════════════════════════════════════════════
# 6. ЭФФЕКТЫ
# ═════════════════════════════════════════════════════════════════════════════

class Particle:
    """Один квадратик, разлетающийся при взрыве линии."""

    __slots__ = ("x", "y", "vx", "vy", "size", "color", "life", "max_life", "spin", "ang")
    # __slots__ фиксирует набор полей: частиц бывает по 500 штук, так они занимают меньше памяти

    def __init__(self, x, y, color):
        angle = random.uniform(0, math.tau)                        # случайное направление разлёта (tau = 2*pi)
        speed = random.uniform(1.5, 6.5)                           # случайная скорость
        self.x, self.y = x, y                                      # стартовая позиция = центр взорвавшейся клетки
        self.vx = math.cos(angle) * speed                          # скорость по горизонтали
        self.vy = math.sin(angle) * speed - random.uniform(1.0, 4.0)   # по вертикали, с подбросом вверх
        self.size = random.uniform(5, 13)                          # стартовый размер квадратика
        self.color = color                                         # цвет — от блока, который взорвался
        self.max_life = self.life = random.uniform(0.45, 0.9)      # сколько секунд живёт (и сколько осталось)
        self.ang = random.uniform(0, 360)                          # текущий угол поворота
        self.spin = random.uniform(-9, 9)                          # скорость вращения, может быть в обе стороны

    def update(self, dt):
        """Двигает частицу. Возвращает False, когда её пора удалить."""
        self.vy += 14.3 * dt                                       # гравитация тянет вниз
        self.x += self.vx                                          # сдвигаем по горизонтали
        self.y += self.vy                                          # сдвигаем по вертикали
        self.vx *= 0.97                                            # сопротивление воздуха гасит горизонтальный разгон
        self.life -= dt                                            # тратим время жизни
        self.ang += self.spin                                      # подкручиваем
        return self.life > 0                                       # True = ещё жива

    def draw(self, surface):
        """Рисует частицу: чем меньше жизни, тем меньше и прозрачнее квадратик."""
        t = max(0.0, self.life / self.max_life)                    # доля оставшейся жизни, от 1 до 0
        s = self.size * t                                          # размер тает вместе с жизнью
        if s < 1:                                                  # меньше пикселя — рисовать нечего
            return
        box = pygame.Surface((int(s) + 2, int(s) + 2), pygame.SRCALPHA)   # маленький холст под квадратик
        pygame.draw.rect(box, (*self.color, int(255 * t)),                 # цвет + тающая прозрачность
                         box.get_rect(), border_radius=int(s * 0.3))
        box = pygame.transform.rotate(box, self.ang)                       # поворачиваем на текущий угол
        surface.blit(box, box.get_rect(center=(int(self.x), int(self.y)))) # ставим по центру позиции частицы


class FloatText:
    """Всплывающая надпись вроде «+480» — поднимается вверх и тает."""

    def __init__(self, x, y, text, color, font):
        self.x, self.y = x, y                                      # стартовая позиция
        self.surf = font.render(text, True, color)                 # текст сразу превращаем в картинку
        self.life = self.max_life = 1.1                            # живёт 1.1 секунды

    def update(self, dt):
        """Поднимает надпись вверх. Возвращает False, когда пора удалить."""
        self.y -= 55 * dt                                          # 55 пикселей вверх за секунду
        self.life -= dt                                            # тратим время жизни
        return self.life > 0                                       # True = ещё видна

    def draw(self, surface):
        """Рисует надпись с прозрачностью, зависящей от остатка жизни."""
        t = max(0.0, min(1.0, self.life / self.max_life))          # доля жизни, зажатая в 0..1
        image = self.surf.copy()                                   # копия, чтобы не портить оригинал
        image.set_alpha(int(255 * t))                              # чем меньше жизни, тем прозрачнее
        surface.blit(image, image.get_rect(center=(int(self.x), int(self.y))))   # ставим по центру


# ═════════════════════════════════════════════════════════════════════════════
# 7. ФИГУРА
# ═════════════════════════════════════════════════════════════════════════════

class Piece:
    """Фигура в лотке: набор клеток + цвет."""

    def __init__(self, cells, color_id):
        self.cells = list(cells)                                   # смещения (строка, столбец) от левого верхнего угла
        self.color_id = color_id                                   # индекс цвета в BLOCK_COLORS
        self.h = max(r for r, _ in self.cells) + 1                 # высота фигуры в клетках
        self.w = max(c for _, c in self.cells) + 1                 # ширина фигуры в клетках
        self.pop = 0.0                                             # анимация появления: 0 — только выпала, 1 — выросла

    @staticmethod
    def random():
        """Создаёт случайную фигуру: форма по весам, цвет — любой из восьми."""
        shape = random.choices(SHAPES, weights=SHAPE_WEIGHTS, k=1)[0]   # мелкие формы выпадают чаще
        return Piece(shape, random.randrange(len(BLOCK_COLORS)))         # случайный цвет из палитры


# ═════════════════════════════════════════════════════════════════════════════
# 8. ИГРА
# ═════════════════════════════════════════════════════════════════════════════

class Game:
    """Всё состояние игры и главный цикл."""

    # ── Запуск ───────────────────────────────────────────────────────────────
    def __init__(self):
        global ASSETS                                              # будем записывать в глобальную переменную
        pygame.init()                                              # поднимаем pygame (видео, шрифты, события)
        pygame.display.set_caption("Block Blast")                  # заголовок окна
        self.screen = pygame.display.set_mode((WIDTH, HEIGHT))     # создаём окно нужного размера
        self.clock = pygame.time.Clock()                           # часы: держат стабильный FPS и меряют dt

        ASSETS = Assets()                                          # грузим картинки (после создания окна!)
        self.bg = make_background()                                # готовим фон один раз

        def font(size, bold=True):
            """Берёт системный шрифт: первый из списка, который найдётся в системе."""
            return pygame.font.SysFont("Arial Rounded MT Bold,Verdana,Arial,Helvetica", size, bold=bold)

        self.f_score = font(64)                                    # крупный счёт в шапке
        self.f_big = font(44)                                      # заголовок проигрыша и прибавка очков
        self.f_mid = font(24)                                      # подсказки и подписи комбо
        self.f_small = font(17)                                    # рекорд, мелкие подписи
        self.f_tiny = font(14)                                     # название игры

        self.best = self.load_best()                               # читаем рекорд с диска
        self.reset()                                               # готовим первую партию

    # ── Сохранение рекорда ───────────────────────────────────────────────────
    def load_best(self):
        """Читает рекорд из файла. Файла нет или он битый — считаем, что рекорд 0."""
        try:
            with open(SAVE_FILE, "r", encoding="utf-8") as f:      # открываем файл сохранения
                return int(json.load(f).get("best", 0))            # достаём поле best
        except Exception:                                           # первый запуск или испорченный файл
            return 0                                                # начинаем с нуля

    def save_best(self):
        """Пишет рекорд на диск. Если записать не вышло — молча продолжаем играть."""
        try:
            with open(SAVE_FILE, "w", encoding="utf-8") as f:      # открываем на запись
                json.dump({"best": self.best}, f)                  # сохраняем как JSON
        except Exception:                                           # нет прав на запись и т.п.
            pass                                                    # рекорд не сохранится, но игра не упадёт

    # ── Новая партия ─────────────────────────────────────────────────────────
    def reset(self):
        """Сбрасывает всё состояние в начальное."""
        self.grid = [[None] * GRID for _ in range(GRID)]           # поле: None = пусто, число = индекс цвета
        self.score = 0                                             # текущий счёт
        self.combo = 0                                             # серия ходов подряд со схлопыванием
        self.particles = []                                        # живые частицы
        self.floats = []                                           # живые всплывающие надписи
        self.shake = 0.0                                           # сила тряски экрана, затухает до нуля
        self.game_over = False                                     # закончилась ли партия
        self.over_anim = 0.0                                       # сколько секунд показывается экран проигрыша
        self.drag = None                                           # что перетаскиваем: [индекс, доля X, доля Y]
        self.mouse = (0, 0)                                        # последняя позиция курсора
        self.tray = [Piece.random() for _ in range(3)]             # три стартовые фигуры
        width = WIDTH // 3                                         # ширина одной ячейки лотка
        self.slots = [pygame.Rect(i * width, TRAY_Y, width, TRAY_H) for i in range(3)]   # зоны трёх фигур

    # ── Правила поля ─────────────────────────────────────────────────────────
    def can_place(self, piece, row, col):
        """Влезает ли фигура левым верхним углом в клетку (row, col)?"""
        for dr, dc in piece.cells:                                 # проверяем каждую клетку фигуры
            r, c = row + dr, col + dc                              # её абсолютные координаты на поле
            if not (0 <= r < GRID and 0 <= c < GRID):              # вылезла за край поля
                return False                                        # не влезает
            if self.grid[r][c] is not None:                        # клетка уже занята
                return False                                        # не влезает
        return True                                                 # все клетки свободны и в границах

    def has_any_move(self, piece):
        """Есть ли на поле хоть одно место для этой фигуры?"""
        for r in range(GRID):                                      # перебираем все строки
            for c in range(GRID):                                  # и все столбцы
                if self.can_place(piece, r, c):                    # нашлось место
                    return True                                     # дальше искать не нужно
        return False                                                # обошли всё поле — места нет

    def full_lines(self, grid):
        """Возвращает (список полных строк, список полных столбцов) для переданного поля."""
        rows = [r for r in range(GRID) if all(grid[r][c] is not None for c in range(GRID))]   # строки без пустот
        cols = [c for c in range(GRID) if all(grid[r][c] is not None for r in range(GRID))]   # столбцы без пустот
        return rows, cols

    def preview_lines(self, piece, row, col):
        """Какие линии схлопнутся, если поставить фигуру сюда? Само поле не меняется."""
        temp = [r[:] for r in self.grid]                           # копия поля (копируем каждую строку отдельно!)
        for dr, dc in piece.cells:                                 # мысленно ставим фигуру
            temp[row + dr][col + dc] = piece.color_id              # на копии
        return self.full_lines(temp)                               # и смотрим, что заполнилось

    def cell_center(self, r, c):
        """Экранные координаты центра клетки (r, c) — нужны для частиц и надписей."""
        return BOARD_X + c * CELL + CELL / 2, BOARD_Y + r * CELL + CELL / 2

    # ── Ход ──────────────────────────────────────────────────────────────────
    def place(self, index, row, col):
        """Ставит фигуру с номером index в клетку (row, col) и разбирает последствия."""
        piece = self.tray[index]                                   # какая фигура ставится
        for dr, dc in piece.cells:                                 # закрашиваем каждую её клетку
            self.grid[row + dr][col + dc] = piece.color_id         # на поле
        self.score += len(piece.cells)                             # очко за каждую поставленную клетку
        self.tray[index] = None                                    # фигура израсходована, слот пустеет

        rows, cols = self.full_lines(self.grid)                    # какие линии заполнились
        n = len(rows) + len(cols)                                  # сколько линий схлопнется всего

        if n:                                                       # линии есть — взрыв
            self.combo += 1                                         # серия продолжается
            cleared = set()                                         # множество клеток на удаление (без повторов)
            for r in rows:                                          # каждая полная строка —
                cleared.update((r, c) for c in range(GRID))         # это все её клетки
            for c in cols:                                          # каждый полный столбец —
                cleared.update((r, c) for r in range(GRID))         # это все его клетки
                                                                    # set сам решает пересечение строк и столбцов

            for r, c in cleared:                                    # по всем удаляемым клеткам
                color = BLOCK_COLORS[self.grid[r][c]]               # запоминаем цвет для частиц
                x, y = self.cell_center(r, c)                       # центр клетки на экране
                for _ in range(7):                                  # 7 частиц с каждой клетки
                    self.particles.append(Particle(x, y, color))    # создаём и добавляем
                self.grid[r][c] = None                              # и очищаем клетку

            gain = int(len(cleared) * 10 * n * (1 + 0.5 * (self.combo - 1)))   # очки: клетки × 10 × линии × комбо
            self.score += gain                                      # начисляем
            self.shake = min(1.0, 0.35 + 0.2 * n)                   # трясём экран тем сильнее, чем больше линий

            cx = sum(self.cell_center(r, c)[0] for r, c in cleared) / len(cleared)   # центр взрыва по X
            cy = sum(self.cell_center(r, c)[1] for r, c in cleared) / len(cleared)   # центр взрыва по Y
            self.floats.append(FloatText(cx, cy, "+%d" % gain, GOLD, self.f_big))    # надпись с прибавкой
            if n > 1:                                                                 # схлопнулось больше одной линии
                self.floats.append(FloatText(cx, cy - 46, "%dx LINES!" % n, TOKENS["block_pink"], self.f_mid))
            if self.combo > 1:                                                        # идёт серия
                self.floats.append(FloatText(cx, cy - 78, "COMBO x%d" % self.combo, COMBO_COLOR, self.f_mid))
        else:                                                       # линий не собрано —
            self.combo = 0                                          # серия обрывается

        if all(p is None for p in self.tray):                      # израсходованы все три фигуры —
            self.tray = [Piece.random() for _ in range(3)]         # только тогда приходят новые

        if self.best < self.score:                                  # побили рекорд —
            self.best = self.score                                  # обновляем его на лету

        if not any(p and self.has_any_move(p) for p in self.tray): # ни одну оставшуюся фигуру некуда поставить
            self.game_over = True                                   # партия окончена
            self.save_best()                                        # сохраняем рекорд

    # ── Мышь ─────────────────────────────────────────────────────────────────
    def piece_tray_rect(self, i):
        """Прямоугольник, который занимает фигура i в лотке."""
        piece = self.tray[i]                                       # сама фигура
        slot = self.slots[i]                                       # её ячейка лотка
        w, h = piece.w * TRAY_CELL, piece.h * TRAY_CELL            # размер фигуры в пикселях
        return pygame.Rect(slot.centerx - w // 2, slot.centery - h // 2, w, h)   # центрируем в ячейке

    def on_mouse_down(self, pos):
        """Нажали кнопку мыши: если попали по фигуре в лотке — берём её."""
        if self.game_over:                                         # на экране проигрыша фигуры не берутся
            return
        for i, piece in enumerate(self.tray):                      # проверяем три слота
            if piece is None:                                      # слот уже пустой
                continue                                            # пропускаем
            if self.slots[i].collidepoint(pos):                    # курсор внутри этой ячейки лотка
                rect = self.piece_tray_rect(i)                     # где именно нарисована фигура
                fx = (pos[0] - rect.x) / max(1, rect.w)            # за какую точку фигуры взялись по X (доля 0..1)
                fy = (pos[1] - rect.y) / max(1, rect.h)            # то же по Y
                self.drag = [i, fx, fy]                            # запоминаем захват — доли, а не пиксели,
                return                                              # потому что на поле фигура крупнее, чем в лотке

    def drag_origin(self):
        """Экранные координаты левого верхнего угла фигуры, которую тащим."""
        i, fx, fy = self.drag                                      # номер фигуры и точка захвата
        piece = self.tray[i]                                       # сама фигура
        w, h = piece.w * CELL, piece.h * CELL                      # её размер в масштабе поля (крупнее, чем в лотке)
        x = self.mouse[0] - fx * w                                 # сдвигаем так, чтобы точка захвата была под курсором
        y = self.mouse[1] - fy * h - CELL * 0.85                   # и приподнимаем над курсором, чтобы не закрывать
        return x, y

    def drag_target(self):
        """В какую клетку встанет фигура прямо сейчас? None — если поставить нельзя."""
        if not self.drag:                                          # ничего не тащим
            return None
        x, y = self.drag_origin()                                  # угол фигуры на экране
        col = int(round((x - BOARD_X) / CELL))                     # ближайший столбец (round = притягивание к сетке)
        row = int(round((y - BOARD_Y) / CELL))                     # ближайшая строка
        piece = self.tray[self.drag[0]]                            # что тащим
        if self.can_place(piece, row, col):                        # туда можно поставить
            return row, col                                         # вот целевая клетка
        return None                                                 # нельзя — цели нет

    def on_mouse_up(self):
        """Отпустили кнопку: ставим фигуру, если есть куда, иначе она возвращается в лоток."""
        if not self.drag:                                          # ничего не тащили
            return
        target = self.drag_target()                                # есть ли валидная цель
        if target:                                                  # есть —
            self.place(self.drag[0], *target)                      # делаем ход
        self.drag = None                                            # в любом случае перетаскивание закончено

    # ── Отрисовка ────────────────────────────────────────────────────────────
    def draw_header(self, surface):
        """Шапка: название, счёт, рекорд, комбо."""
        title = self.f_tiny.render("BLOCK   BLAST", True, TEXT_DIM)          # название игры
        surface.blit(title, title.get_rect(center=(WIDTH // 2, 40)))         # по центру, y=40

        score = self.f_score.render(str(self.score), True, TEXT)             # текущий счёт крупно
        surface.blit(score, score.get_rect(center=(WIDTH // 2, 92)))         # по центру, y=92

        best = self.f_small.render("BEST   %d" % self.best, True, GOLD)      # рекорд
        rect = best.get_rect(center=(WIDTH // 2, 138))                       # его место
        pygame.draw.rect(surface, PANEL, rect.inflate(28, 14), border_radius=14)   # подложка-плашка под текстом
        surface.blit(best, rect)                                             # сам текст поверх плашки

        if self.combo > 1:                                                   # серия идёт со второго схлопывания
            combo = self.f_small.render("COMBO x%d" % self.combo, True, COMBO_COLOR)
            surface.blit(combo, combo.get_rect(center=(WIDTH // 2, 166)))    # под рекордом

    def draw_board(self, surface, hl_rows, hl_cols, ghost):
        """
        Поле: подложка, клетки, подсветка схлопывающихся линий, призрак фигуры.
        hl_rows / hl_cols — строки и столбцы, которые схлопнутся, если поставить сюда.
        ghost — кортеж (фигура, строка, столбец) или None.
        """
        panel = pygame.Rect(BOARD_X - 12, BOARD_Y - 12, BOARD_W + 24, BOARD_W + 24)   # рамка вокруг поля (488x488)
        image = ASSETS.get("panel_board") if ASSETS else None                 # подложка из assets
        if image is not None:                                                 # картинка есть
            surface.blit(image, panel.topleft)                                # ставим её
        else:                                                                  # нет — рисуем два скруглённых прямоугольника
            pygame.draw.rect(surface, PANEL_EDGE, panel.inflate(6, 6), border_radius=26)   # кромка
            pygame.draw.rect(surface, PANEL, panel, border_radius=24)                       # тело подложки

        for r in range(GRID):                                                 # по всем строкам поля
            for c in range(GRID):                                             # и всем столбцам
                x = BOARD_X + c * CELL                                        # экранная координата клетки по X
                y = BOARD_Y + r * CELL                                        # и по Y
                color_id = self.grid[r][c]                                    # что в клетке: None или индекс цвета
                if color_id is None:                                          # пусто —
                    draw_empty_cell(surface, x, y, r in hl_rows or c in hl_cols)   # рисуем пустую (может, с подсветкой)
                else:                                                          # занято —
                    draw_block(surface, x, y, CELL, color_id)                 # рисуем плитку

        for r in hl_rows:                                                     # каждая схлопывающаяся строка
            glow = pygame.Surface((BOARD_W, CELL), pygame.SRCALPHA)           # белая плёнка на всю ширину поля
            glow.fill((255, 255, 255, 34))                                    # почти прозрачная
            surface.blit(glow, (BOARD_X, BOARD_Y + r * CELL))                 # поверх строки
        for c in hl_cols:                                                     # каждый схлопывающийся столбец
            glow = pygame.Surface((CELL, BOARD_W), pygame.SRCALPHA)           # плёнка на всю высоту поля
            glow.fill((255, 255, 255, 34))                                    # так же прозрачна
            surface.blit(glow, (BOARD_X + c * CELL, BOARD_Y))                 # поверх столбца

        if ghost:                                                             # если есть валидная цель —
            piece, row, col = ghost                                           # распаковываем
            for dr, dc in piece.cells:                                        # рисуем полупрозрачную копию фигуры
                x = BOARD_X + (col + dc) * CELL                               # в целевых клетках
                y = BOARD_Y + (row + dr) * CELL
                draw_block(surface, x, y, CELL, piece.color_id, alpha=95)     # alpha=95 — это и есть «призрак»

    def draw_tray(self, surface):
        """Лоток внизу: три фигуры. Тусклая фигура = её некуда поставить."""
        for i, piece in enumerate(self.tray):                                 # по трём слотам
            if piece is None:                                                 # слот израсходован
                continue                                                       # ничего не рисуем
            if self.drag and self.drag[0] == i:                               # эту фигуру сейчас тащим —
                continue                                                       # она рисуется у курсора, не в лотке

            rect = self.piece_tray_rect(i)                                    # место фигуры в лотке
            playable = self.has_any_move(piece)                               # влезает ли она хоть куда-то
            scale = 0.5 + 0.5 * min(1.0, piece.pop)                           # анимация появления: от половины к полному
            cx, cy = rect.center                                              # центр, вокруг которого рисуем
            size = TRAY_CELL * scale                                          # текущий размер клетки фигуры

            for dr, dc in piece.cells:                                        # рисуем каждую клетку фигуры
                x = cx - piece.w * size / 2 + dc * size                       # от центра влево на половину ширины
                y = cy - piece.h * size / 2 + dr * size                       # и вверх на половину высоты
                draw_block(surface, x, y, size, piece.color_id,
                           alpha=255 if playable else 165)                    # не влезает — рисуем полупрозрачно

    def draw_dragged(self, surface):
        """Фигура, которую тащим — рисуется поверх всего, в масштабе поля."""
        if not self.drag:                                                     # ничего не тащим
            return
        piece = self.tray[self.drag[0]]                                       # что тащим
        x, y = self.drag_origin()                                             # где её левый верхний угол
        for dr, dc in piece.cells:                                            # рисуем клетку за клеткой
            draw_block(surface, x + dc * CELL, y + dr * CELL, CELL, piece.color_id, alpha=235)

    def draw_game_over(self, surface):
        """Экран проигрыша: затемнение и карточка с результатом."""
        t = min(1.0, self.over_anim / 0.35)                                   # прогресс появления, 0→1 за 0.35 сек
        veil = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)              # тёмная плёнка на весь экран
        veil.fill((*OVERLAY, int(215 * t)))                                   # плотность нарастает вместе с t
        surface.blit(veil, (0, 0))                                            # накрываем игру

        card = pygame.Rect(0, 0, 400, 280)                                    # карточка результата
        card.center = (WIDTH // 2, int(HEIGHT // 2 + 60 * (1 - t)))          # выезжает снизу вверх к центру
        image = ASSETS.get("panel_card") if ASSETS else None                  # подложка карточки из assets
        if image is not None:                                                 # картинка есть
            surface.blit(image, card.topleft)                                 # ставим её
        else:                                                                  # нет — рисуем вручную
            pygame.draw.rect(surface, PANEL_EDGE, card.inflate(8, 8), border_radius=32)   # кромка
            pygame.draw.rect(surface, PANEL, card, border_radius=28)                       # тело карточки

        title = self.f_big.render("ХОДОВ НЕТ", True, TEXT)                   # заголовок
        surface.blit(title, title.get_rect(center=(card.centerx, card.top + 58)))

        score = self.f_score.render(str(self.score), True, GOLD)             # итоговый счёт
        surface.blit(score, score.get_rect(center=(card.centerx, card.top + 132)))

        best = self.f_small.render("Рекорд:  %d" % self.best, True, TEXT_DIM)   # рекорд
        surface.blit(best, best.get_rect(center=(card.centerx, card.top + 186)))

        pulse = 0.5 + 0.5 * math.sin(pygame.time.get_ticks() / 260)          # плавная пульсация от 0 до 1
        hint = self.f_mid.render("R — играть снова", True, shade(TEXT, 0.6 + 0.4 * pulse))   # мигающая подсказка
        surface.blit(hint, hint.get_rect(center=(card.centerx, card.bottom - 42)))

    def draw(self):
        """Собирает один кадр целиком и показывает его."""
        frame = self.bg.copy()                                                # начинаем с копии готового фона

        hl_rows, hl_cols, ghost = [], [], None                                # по умолчанию подсветки и призрака нет
        target = self.drag_target()                                           # есть ли сейчас валидная цель
        if target:                                                             # есть —
            piece = self.tray[self.drag[0]]                                   # какая фигура
            row, col = target                                                  # куда встанет
            hl_rows, hl_cols = self.preview_lines(piece, row, col)            # какие линии схлопнутся
            ghost = (piece, row, col)                                          # и где рисовать призрак

        self.draw_header(frame)                                               # шапка
        self.draw_board(frame, hl_rows, hl_cols, ghost)                       # поле с подсветкой и призраком
        self.draw_tray(frame)                                                 # лоток
        self.draw_dragged(frame)                                              # фигура в руке — поверх поля

        for particle in self.particles:                                       # все живые частицы
            particle.draw(frame)                                              # поверх остального
        for text in self.floats:                                              # все всплывающие надписи
            text.draw(frame)                                                  # самым верхним слоем

        if self.game_over:                                                    # партия окончена —
            self.draw_game_over(frame)                                        # накрываем всё карточкой

        offset = (0, 0)                                                       # смещение кадра для тряски
        if self.shake > 0:                                                    # тряска активна
            magnitude = self.shake * 9                                        # амплитуда в пикселях
            offset = (random.uniform(-magnitude, magnitude),                  # случайный сдвиг по X
                      random.uniform(-magnitude, magnitude))                  # и по Y

        self.screen.fill(BG_BOTTOM)                                           # заливаем края, которые оголит тряска
        self.screen.blit(frame, offset)                                       # кладём готовый кадр со смещением
        pygame.display.flip()                                                 # показываем кадр на экране

    # ── Главный цикл ─────────────────────────────────────────────────────────
    def run(self):
        """Крутится, пока игру не закроют: события → обновление → отрисовка."""
        while True:                                                           # бесконечный игровой цикл
            dt = self.clock.tick(FPS) / 1000.0                                # ждём до 60 кадров/сек, dt = секунд с прошлого кадра
            self.mouse = pygame.mouse.get_pos()                               # запоминаем позицию курсора

            for event in pygame.event.get():                                  # разбираем накопившиеся события
                if event.type == pygame.QUIT:                                 # закрыли окно крестиком
                    self.save_best()                                          # сохраняем рекорд
                    pygame.quit()                                             # гасим pygame
                    sys.exit()                                                # и выходим из программы

                elif event.type == pygame.KEYDOWN:                            # нажали клавишу
                    if event.key == pygame.K_ESCAPE:                          # ESC — выход
                        self.save_best()                                      # сохраняем рекорд
                        pygame.quit()                                         # гасим pygame
                        sys.exit()                                            # выходим
                    if event.key == pygame.K_r:                               # R — новая игра
                        self.save_best()                                      # сохраняем рекорд текущей партии
                        self.reset()                                          # и начинаем заново

                elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:   # нажали левую кнопку
                    self.on_mouse_down(event.pos)                             # пробуем взять фигуру

                elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:     # отпустили левую кнопку
                    self.on_mouse_up()                                        # пробуем поставить фигуру

            for piece in self.tray:                                           # анимация появления фигур в лотке
                if piece and piece.pop < 1.0:                                 # фигура ещё не доросла
                    piece.pop = min(1.0, piece.pop + dt * 5.5)                # растёт примерно за 0.18 секунды

            self.particles = [p for p in self.particles if p.update(dt)]      # двигаем частицы, отжившие выбрасываем
            self.floats = [f for f in self.floats if f.update(dt)]            # то же для всплывающих надписей
            self.shake = max(0.0, self.shake - dt * 2.6)                      # тряска затухает примерно за 0.4 сек
            if self.game_over:                                                # если партия окончена —
                self.over_anim += dt                                          # крутим анимацию появления карточки

            self.draw()                                                       # рисуем кадр


# ═════════════════════════════════════════════════════════════════════════════
# 9. ТОЧКА ВХОДА
# ═════════════════════════════════════════════════════════════════════════════

if __name__ == "__main__":       # запускаем игру, только если файл вызван напрямую,
    Game().run()                 # а не импортирован из другого модуля