$ sudo teach IT
Модуль 7 · Финал · Урок 7.1

Рекорд в файле

Вы научитесь сохранять рекорд в файл и читать его обратно так, чтобы игра не падала ни на пустом, ни на испорченном файле.

Теория + практика~30 минутСредний

В игре уже есть меню, пауза и экран «Игра окончена» с очками. Но закройте окно — и всё набранное исчезнет: при следующем запуске игра ничего не помнит. Чтобы рекорд пережил выключение программы, его нужно положить в файл, а при старте прочитать обратно. Заодно научимся не бояться, что файл окажется пустым или испорченным.

Зачем игре файл

Переменные игры — score, lives, state — похожи на записи маркером на белой доске. Пока окно открыто, запись на месте. Закрыли окно — доску стёрли начисто. Если хочется, чтобы рекорд дожил до завтра, его надо переписать в тетрадь, то есть сохранить на диск.

Данные, которые переживают закрытие программы, называют постоянными (по-английски persistent), а запись их на диск — сохранением. Диск хранит всё в файлах, поэтому наша «тетрадь» — маленький файл record.json рядом с main.py.

Осталось решить, в каком виде писать число. Можно просто «1234», но когда захочется хранить рядом ещё что-нибудь (скажем, громкость), придётся изобретать правила. Для этого давно придуман готовый формат — JSON.

JSON: рекорд обычным текстом

Представьте записку на холодильнике: «молоко: 2, хлеб: 1». Любой человек поймёт её с первого взгляда. JSON — такая же записка, только по строгим правилам, чтобы её одинаково понимали люди и программы. Название расшифровывается как JavaScript Object Notation, но формат давно используется в любых языках, и Python тоже умеет с ним работать.

Выглядит JSON почти как знакомый вам словарь: фигурные скобки, ключ в двойных кавычках, двоеточие и значение. Наш файл с рекордом будет таким:

JSON · record.json
{"record": 1234}

Превращение данных Python в такой текст официально называется сериализацией, обратное превращение — десериализацией. В Python за это отвечает встроенный модуль json (подключается строкой import json) с двумя нужными нам функциями: json.dumps превращает данные в строку, а json.loads — строку обратно в данные. Запомнить просто: буква s — от слова string, «строка».

Python · фрагмент
import json

data = {"record": 1234}
text = json.dumps(data)
print(text)
print(type(text))

back = json.loads(text)
print(back["record"])
Вывод
{"record": 1234}
<class 'str'>
1234

Разбор по строкам

  • import json — подключаем модуль для работы с JSON; ставить его не нужно, он идёт вместе с Python.
  • data = {"record": 1234} — обычный словарь с одним ключом.
  • text = json.dumps(data) — словарь превратился в строку с тем самым текстом {"record": 1234}.
  • print(type(text)) — убеждаемся, что это именно строка (str), а не словарь: так её можно записать в файл.
  • back = json.loads(text) — из строки снова получился словарь, и back["record"] даёт число 1234.

Запись и чтение файла

Строку нужно куда-то положить. С путями к файлам мы уже встречались: Path из модуля pathlib описывает путь, а у него есть два удобных метода. write_text(строка, encoding="utf-8") создаёт файл (или перезаписывает старый) и кладёт в него текст, read_text(encoding="utf-8") возвращает содержимое файла строкой.

Зачем каждый раз писать encoding="utf-8"? Кодировка — это правило, по которому буквы превращаются в байты на диске. Если её не указать, Python возьмёт ту, что принята в вашей системе, а на разных компьютерах она разная (на Windows часто не UTF-8). С явным utf-8 файл читается одинаково везде.

Python · фрагмент
from pathlib import Path

path = Path("record.json")
path.write_text('{"record": 1234}', encoding="utf-8")
print(path.read_text(encoding="utf-8"))
Вывод
{"record": 1234}

Разбор по строкам

  • path = Path("record.json") — путь к файлу; сам файл при этом ещё не создан и не открыт.
  • path.write_text(..., encoding="utf-8") — создаёт record.json в текущей папке и записывает туда строку.
  • path.read_text(encoding="utf-8") — читает файл целиком и возвращает строку; её и печатаем.

Относительный путь и «рядом с main.py»

Путь Path("record.json") считается от папки, из которой запустили программу. Если запустить игру из другой папки, файл окажется в другом месте. Поэтому в игре мы привяжем путь к самому main.py: Path(__file__).parent / "record.json" — так же, как раньше делали с папкой assets.

Что может пойти не так

Пока всё гладко: записали, прочитали. Но игрок живёт в реальном мире, а в нём файл бывает совсем не таким, как вы ожидали. Три типичные неприятности:

Что случилосьКак называется ошибка PythonКогда бывает
Файла нетFileNotFoundErrorСамый первый запуск игры: рекорда ещё не было
В файле мусор или он пустойjson.JSONDecodeErrorФайл открыли блокнотом и испортили, или запись оборвалась
JSON правильный, но внутри не тоошибки нет, программа просто получит странные данныеВместо числа строка "500", вместо словаря список [1, 2], отрицательное число

Если ничего не предусмотреть, игра при первом же запуске упадёт с красным сообщением ещё до меню. Рекордная таблица не стоит того, чтобы ломать всю игру, поэтому на любую из этих бед у нас будет план Б: считать рекорд нулём.

try и except

Представьте канатоходца со страховкой. Он идёт по канату как обычно, но если сорвётся — не упадёт на землю, а повиснет на тросе, и представление продолжится. В Python так работает пара try («пробуй») и except («кроме случая»): в блоке try пишем то, что может сломаться, а в блоке except — что делать, если сломалось.

Официально это называется обработкой исключений: когда Python сталкивается с ошибкой, он «выбрасывает исключение» с определённым именем (ValueError, FileNotFoundError...), и except с таким же именем его перехватывает.

Python · фрагмент
try:
    number = int("abc")
except ValueError:
    number = 0
print(number)
Вывод
0

Разбор по строкам

  • try: — начинаем «опасный» блок: сюда кладём строки, которые могут упасть.
  • number = int("abc") — из слова «abc» число не получится, поэтому Python выбросит исключение ValueError.
  • except ValueError: — «если случилась именно такая ошибка, выполни блок ниже». Остаток блока try пропускается.
  • number = 0 — наш план Б. Программа не падает и идёт дальше.
  • print(number) — выполняется в любом случае и печатает 0.

Ловушка: except без имени

Запись except: без типа ловит вообще любую ошибку, даже опечатку в имени переменной. Игра будет молча делать вид, что всё хорошо, а вы часами будете искать причину. Всегда указывайте, что именно ловите: ошибку, которую вы предвидите, и только её.

Файла нет, а в нём мусор

Теперь применим это к нашему файлу. Ошибки чтения файла Python собирает в «семью» OSError (в неё входит и FileNotFoundError, и «нет прав на чтение»). Если же в файле лежат байты, которые нельзя прочитать как UTF-8, будет UnicodeDecodeError. Перечислить несколько ошибок можно в круглых скобках через запятую:

Python · фрагмент
def read_text_or_empty(path):
    try:
        return path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return ""

Разбор по строкам

  • def read_text_or_empty(path): — функция, которой отдаём Path; она всегда вернёт строку.
  • return path.read_text(...) — если чтение прошло гладко, функция сразу возвращает текст, и except не трогается.
  • except (OSError, UnicodeDecodeError): — одна из перечисленных ошибок: файла нет, нет прав, файл не читается как UTF-8.
  • return "" — план Б: пустая строка. Дальше с ней разберётся проверка содержимого, о которой ниже.

Проверка содержимого: isinstance

Третья неприятность — «JSON хороший, а внутри не то». Тут try не поможет: ошибки нет, просто вместо числа пришла строка. Нужен вопрос к самому значению: «ты число?». Как у вахтёра, который сверяет документ с описанием.

Этот вопрос задаёт функция isinstance(значение, тип): она возвращает True, если значение принадлежит указанному типу. Например, isinstance(5, int) — True, а isinstance("5", int) — False.

Ловушка: True тоже число

В Python логические значения — это особые целые числа: True равно 1, а False равно 0, поэтому isinstance(True, int) даёт True. Если в файле окажется {"record": true}, обычная проверка на int его пропустит, и рекорд станет единицей. Значит, булево значение нужно исключить отдельной проверкой.

Логика рекорда: parse_record и best_score

Главный приём курса — логика отдельно от отрисовки. Здесь он работает ещё и для файлов: функция, разбирающая содержимое файла, не должна сама лезть на диск. Пусть она принимает готовый текст и возвращает число. Тогда её можно проверять без всякой игры, а работа с диском останется в main.py. Пишем в logic.py:

Python · kometa/logic.py
import json


def parse_record(text):
    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return 0
    if not isinstance(data, dict):
        return 0
    value = data.get("record")
    if isinstance(value, bool) or not isinstance(value, int):
        return 0
    return max(0, value)


def best_score(record, score):
    return max(record, score)

Разбор по строкам

  • import json — в самом верху файла logic.py (к остальным импортам). Модуль json стандартный, pygame для него не нужен.
  • json.loads(text) в try — пробуем превратить текст в данные. Для пустой строки и для мусора выбросится json.JSONDecodeError — тогда сразу return 0.
  • if not isinstance(data, dict) — dict это тип словарей. JSON-то правильный, но верхним значением может быть список, число или null. Нам нужен словарь, иначе рекорд — ноль.
  • value = data.get("record") — метод словаря get возвращает значение по ключу, а если ключа нет — None, не падая, как это делает data["record"].
  • isinstance(value, bool) or not isinstance(value, int) — отсеиваем и true/false, и строки, и дробные числа, и None: остаётся только настоящее целое.
  • return max(0, value) — отрицательный рекорд невозможен, поэтому всё, что меньше нуля, превращаем в 0.
  • best_score(record, score) — возвращает большее из старого рекорда и очков партии: рекорд никогда не уменьшается.

Заметьте: любая «странность» в файле даёт ноль. Для игрока это значит «рекорда пока нет» — вполне нормальная ситуация, а не катастрофа.

Когда сохранять

Файл — штука медленная по меркам игры: на один кадр у нас всего около 16 миллисекунд, а запись на диск иногда занимает больше. Если записывать рекорд в каждом кадре (то есть 60 раз в секунду), игра начнёт подтормаживать, а диск зря изнашиваться. К тому же рекорд меняется редко.

Поэтому сохраняем один раз, в тот момент, когда он действительно мог измениться, — когда партия закончилась и игра переходит в состояние "gameover". В этот момент сравниваем очки с рекордом: если побили, обновляем переменную record, записываем файл и поднимаем флаг new_record, чтобы экран «Игра окончена» мог показать надпись «Новый рекорд!».

Загружаем рекорд, наоборот, один раз — при старте программы, до игрового цикла. Дальше в течение сеанса истина живёт в переменной record, а файл — только её копия на случай закрытия окна.

Практика: рекорд, который не пропадает

Берём наш main.py таким, каким он получился в конце модуля про состояния: с меню, паузой и экраном «Игра окончена». Меняем logic.py (две функции) и восемь мест в main.py. Новые строки в примерах — те, что отсутствуют в вашем файле; остальные даны для ориентира.

Шаг 1. Функции в logic.py

В самый верх logic.py, к остальным импортам (рядом с import math), добавьте строку import json. А в конец файла — две функции из прошлого раздела:

Python · kometa/logic.py (добавить в конец файла)
def parse_record(text):
    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return 0
    if not isinstance(data, dict):
        return 0
    value = data.get("record")
    if isinstance(value, bool) or not isinstance(value, int):
        return 0
    return max(0, value)


def best_score(record, score):
    return max(record, score)

Шаг 2. Импорты в main.py

Замените блок импортов в начале main.py на этот. Появились import json, from pathlib import Path и два новых имени в списке из logic (по алфавиту: best_score встаёт после SpawnTimer, parse_record — после next_state):

Python · kometa/main.py (замените блок импортов)
import json
import random
from pathlib import Path

import pygame

from logic import (
    Cooldown,
    SpawnTimer,
    best_score,
    blink_visible,
    effect_volume,
    handle_key,
    level_for_score,
    menu_move,
    meteor_speed,
    next_state,
    parse_record,
    spawn_interval,
    wrap,
)

Шаг 3. Путь к файлу и две функции

Найдите строку MENU_ITEMS = ["Играть", "Выход"] — она последняя среди констант, перед pygame.init(). Вставьте под ней путь и две функции чтения и записи. Они стоят до создания окна: ведь рекорд мы прочитаем ещё до игрового цикла.

Python · kometa/main.py (после MENU_ITEMS, перед pygame.init())
RECORD_PATH = Path(__file__).parent / "record.json"


def load_record(path):
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return 0
    return parse_record(text)


def save_record(path, value):
    text = json.dumps({"record": value})
    try:
        path.write_text(text, encoding="utf-8")
    except OSError:
        print("Не удалось сохранить рекорд")

Разбор по строкам

  • RECORD_PATH = Path(__file__).parent / "record.json" — путь «рядом с main.py»: __file__ — это путь к самому файлу main.py, .parent — его папка, / "record.json" добавляет имя файла. Константа названа заглавными, потому что не меняется.
  • load_record(path) — читает файл. Если файла нет, нет прав или он не читается как UTF-8, возвращает 0 (план Б). Иначе отдаёт содержимое на проверку в parse_record из logic.py.
  • save_record(path, value) — собирает текст {"record": ...} через json.dumps и пишет его в файл. Если запись не удалась (например, папка только для чтения), игра не падает, а печатает сообщение в терминал.

Шаг 4. Две новые переменные

В блоке переменных состояния (там же, где state, score, level) найдите level_banner = 0.0 и добавьте две строки сразу после неё:

Python · kometa/main.py (блок переменных)
level_banner = 0.0
record = load_record(RECORD_PATH)
new_record = False
show_boxes = False

Разбор по строкам

  • record = load_record(RECORD_PATH) — рекорд читается один раз, при запуске программы. Дальше работаем с переменной, а не с файлом.
  • new_record = False — флаг «в этой партии рекорд побит». Он нужен экрану «Игра окончена», чтобы решить, какую надпись показывать.

Шаг 5. new_game() сбрасывает флаг

Новая партия не должна помнить про «Новый рекорд!» из прошлой. В new_game() добавьте имя в строку с global и сбросьте флаг. Вся функция теперь такая (меняются строка global и одна строка внизу):

Python · kometa/main.py (функция new_game целиком)
def new_game():
    global player, score, lives, invulnerable, level, level_banner, new_record
    all_sprites.empty()
    meteors.empty()
    crystals.empty()
    lasers.empty()
    player = Player(WIDTH // 2, HEIGHT - 80, SHIP_BOUNDS)
    spawn_timer.time = 0.0
    crystal_timer.time = 0.0
    score = 0
    lives = START_LIVES
    invulnerable = 0.0
    level = 1
    level_banner = 0.0
    new_record = False
    spawn_timer.interval = spawn_interval(level)

Шаг 6. Сохраняем при конце игры

Это главное место урока. В update_play(dt) добавьте record и new_record в строку global и замените последние две строки функции (проверку lives <= 0). Остальной код функции не трогаем, он показан многоточием:

Python · kometa/main.py (функция update_play)
def update_play(dt):
    global state, score, lives, invulnerable, level, level_banner, record, new_record
    ...
    if lives <= 0:
        state = next_state(state, "die")
        new_record = score > record
        if new_record:
            record = best_score(record, score)
            save_record(RECORD_PATH, record)

Разбор по строкам

  • global ... record, new_record — функция будет присваивать этим переменным, поэтому, как и с score, их нужно объявить глобальными. Иначе Python создал бы внутри функции одноимённые локальные.
  • if lives <= 0: — жизни кончились: просим у таблицы переход в "gameover", как и раньше.
  • new_record = score > record — сравнение даёт True или False. Делаем это до обновления record: иначе мы сравнивали бы рекорд с самим собой.
  • record = best_score(record, score) — новый рекорд: большее из двух. Раз new_record истинно, это будет именно score.
  • save_record(RECORD_PATH, record) — одна запись на диск, и только если рекорд побит. После смены состояния update_play больше не вызывается, поэтому второй раз эти строки не выполнятся.

Шаг 7. Показываем рекорд на экранах

Три небольших правки. В draw_menu() добавьте строку с рекордом под заголовком, в draw_play() — строку в табло под «Уровень», а draw_gameover() замените целиком:

Python · kometa/main.py (draw_menu, начало функции)
def draw_menu():
    draw_center(screen, "КОМЕТА", 170, font_big, TITLE_COLOR)
    draw_center(screen, "Рекорд: " + str(record), 235, font, GRAY)
    ...
Python · kometa/main.py (draw_play, табло)
def draw_play():
    ...
    draw_text(screen, "Уровень: " + str(level), (16, 76))
    draw_text(screen, "Рекорд: " + str(record), (16, 108))
    ...
Python · kometa/main.py (функция draw_gameover целиком)
def draw_gameover():
    draw_center(screen, "ИГРА ОКОНЧЕНА", 220, font_big, RED)
    draw_center(screen, "Очки: " + str(score), 300, font, WHITE)
    if new_record:
        draw_center(screen, "Новый рекорд!", 365, font_big, SELECT_COLOR)
    else:
        draw_center(screen, "Рекорд: " + str(record), 365, font, GRAY)
    draw_center(screen, "Enter - ещё раз, Q - в меню, Esc - выход", 445, font, GRAY)

Разбор по строкам

  • if new_record: — если флаг поднят, вместо обычной строки с рекордом крупно жёлтым пишем «Новый рекорд!».
  • else: — иначе показываем прошлый рекорд серым цветом, чтобы игрок видел, до чего не дотянул.
  • Подсказка про клавиши опустилась с 360 до 445, чтобы не налезать на крупную надпись.

Запуск и что должно получиться

Логику файла можно проверить и без окна: logic.py не использует pygame. Из папки kometa/, с активным виртуальным окружением:

Терминал
python -c "from logic import parse_record, best_score; print(parse_record('abc'), best_score(100, 250))"
Вывод
0 250

Теперь сама игра:

Терминал
python main.py

В меню под заголовком «КОМЕТА» стоит «Рекорд: 0» (файла ещё нет, и игра не упала). Сыграйте партию и дайте кораблю столкнуться три раза: на экране «Игра окончена» появится крупная жёлтая надпись «Новый рекорд!», а рядом с main.py — файл record.json. Закройте окно и запустите игру заново: в меню будет ваш рекорд. Во второй партии наберите меньше: вместо надписи появится серая строка «Рекорд: ...», и файл не изменится.

Теперь самое интересное — устроим игре проверку. Откройте record.json в редакторе и впишите вместо содержимого слово abc, сохраните и запустите игру: она стартует, в меню «Рекорд: 0». Затем впишите {"record": "500"} — снова 0. Удалите файл совсем — то же самое. Игра не падает ни разу.

Проверьте себя

  • После первой партии рядом с main.py появился record.json с текстом вида {"record": 120}.
  • После перезапуска игры рекорд в меню тот же.
  • Если в партии набрано меньше рекорда, надписи «Новый рекорд!» нет, а файл не меняется.
  • Если вписать в record.json мусор или удалить файл, игра запускается и показывает рекорд 0.
  • После рекордной партии начните новую и проиграйте с меньшим счётом: надписи «Новый рекорд!» на втором экране «Игра окончена» нет.

Частые ошибки

except: без типа прячет настоящие ошибки

Видно: после правки кода рекорд всегда равен 0, а красного сообщения нет. Причина: в load_record написали except: без имени, и он проглотил даже опечатку в названии переменной. Исправление: перечисляйте только те ошибки, которые ждёте, — (OSError, UnicodeDecodeError) и json.JSONDecodeError.

Запись на диск в каждом кадре

Видно: игра подтормаживает, а лампочка диска мигает. Причина: save_record вызвали в update_play или в цикле, а не один раз при переходе в "gameover". Исправление: сохраняйте только в момент смерти и только когда new_record истинно.

Число-строка и true в файле

Видно: в файле {"record": "500"}, а игра то падает на сравнении score > record, то показывает странные значения. Причина: JSON хранит строку и число по-разному, а проверки типа нет. Исправление: пропускайте через parse_record, которая отсеивает всё, кроме целого числа, и не забывайте исключать bool (он тоже int).

«Новый рекорд!» не появляется никогда

Видно: рекорд сохраняется, но надписи нет. Причина: record обновили раньше, чем вычислили new_record = score > record, и сравнение стало «очки больше самих себя». Исправление: сначала флаг, потом best_score.

Что важно запомнить

  • Переменные живут, пока открыто окно. Чтобы данные пережили закрытие, их записывают в файл.
  • JSON — это текст вроде {"record": 1234}. json.dumps превращает данные в строку, json.loads — строку в данные.
  • Файл читают и пишут через Path.read_text(encoding="utf-8") и Path.write_text(..., encoding="utf-8"); путь привязывают к main.py через Path(__file__).parent.
  • Всё, что может сломаться (файла нет, мусор, нечитаемые байты), заворачивают в try/except с конкретными типами ошибок. except: без типа прячет настоящие баги.
  • isinstance(значение, int) проверяет тип, но True тоже int, поэтому bool исключают отдельно. Любую странность в файле превращаем в рекорд 0.
  • Разбор текста — чистая функция parse_record(text) в logic.py, работа с диском — в main.py. Сохраняем один раз, при переходе в "gameover", и только если рекорд побит.

В следующем уроке наведём порядок: за время курса main.py сильно разросся, а числа лежат по разным файлам, поэтому соберём их в одном месте и научимся проверять логику одной командой.

На следующий урок