В игре уже есть меню, пауза и экран «Игра окончена» с очками. Но закройте окно — и всё набранное исчезнет: при следующем запуске игра ничего не помнит. Чтобы рекорд пережил выключение программы, его нужно положить в файл, а при старте прочитать обратно. Заодно научимся не бояться, что файл окажется пустым или испорченным.
Зачем игре файл
Переменные игры — score, lives, state — похожи на записи маркером на белой доске. Пока окно открыто, запись на месте. Закрыли окно — доску стёрли начисто. Если хочется, чтобы рекорд дожил до завтра, его надо переписать в тетрадь, то есть сохранить на диск.
Данные, которые переживают закрытие программы, называют постоянными (по-английски persistent), а запись их на диск — сохранением. Диск хранит всё в файлах, поэтому наша «тетрадь» — маленький файл record.json рядом с main.py.
Осталось решить, в каком виде писать число. Можно просто «1234», но когда захочется хранить рядом ещё что-нибудь (скажем, громкость), придётся изобретать правила. Для этого давно придуман готовый формат — JSON.
JSON: рекорд обычным текстом
Представьте записку на холодильнике: «молоко: 2, хлеб: 1». Любой человек поймёт её с первого взгляда. JSON — такая же записка, только по строгим правилам, чтобы её одинаково понимали люди и программы. Название расшифровывается как JavaScript Object Notation, но формат давно используется в любых языках, и Python тоже умеет с ним работать.
Выглядит JSON почти как знакомый вам словарь: фигурные скобки, ключ в двойных кавычках, двоеточие и значение. Наш файл с рекордом будет таким:
{"record": 1234}
Превращение данных Python в такой текст официально называется сериализацией, обратное превращение — десериализацией. В Python за это отвечает встроенный модуль json (подключается строкой import json) с двумя нужными нам функциями: json.dumps превращает данные в строку, а json.loads — строку обратно в данные. Запомнить просто: буква s — от слова string, «строка».
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 файл читается одинаково везде.
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 с таким же именем его перехватывает.
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. Перечислить несколько ошибок можно в круглых скобках через запятую:
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:
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. А в конец файла — две функции из прошлого раздела:
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):
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(). Вставьте под ней путь и две функции чтения и записи. Они стоят до создания окна: ведь рекорд мы прочитаем ещё до игрового цикла.
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 и добавьте две строки сразу после неё:
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 и одна строка внизу):
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). Остальной код функции не трогаем, он показан многоточием:
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() замените целиком:
def draw_menu():
draw_center(screen, "КОМЕТА", 170, font_big, TITLE_COLOR)
draw_center(screen, "Рекорд: " + str(record), 235, font, GRAY)
...
def draw_play():
...
draw_text(screen, "Уровень: " + str(level), (16, 76))
draw_text(screen, "Рекорд: " + str(record), (16, 108))
...
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 сильно разросся, а числа лежат по разным файлам, поэтому соберём их в одном месте и научимся проверять логику одной командой.