Загрузка данных
"""
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() # а не импортирован из другого модуля