Игры бывают очень разными, от простых текстовых до многопользовательских 3D-феерий. В этой главе я продемонстрирую простую игру и то, как конечная точка веб-приложения может взаимодействовать с пользователем на нескольких этапах. Этот процесс отличается от привычных вам по этой книге одноразовых запросов-ответов конечных точек веб-приложения.
Если вы действительно хотите освоить Python для игр, вот несколько полезных инструментов:
• текст — Adventurelib (https://adventurelib.readthedocs.io);
• графика:
• PyGame (https://www.pygame.org), primer (https://realpython.com/pygame-a-primer);
• pyglet (https://pyglet.org);
• Python Arcade (https://api.arcade.academy);
• HARFANG (https://www.harfang3d.com);
• Panda3D (https://docs.panda3d.org).
Но в этой главе я не буду использовать ни один из них. Код примеров может стать настолько большим и сложным, что отодвинет на второй план цель этой книги — максимально упрощенное создание сайтов (API и традиционного контента) с помощью FastAPI.
Существует так много способов написать игру. Но как определить, кто что делает и кто где хранит данные? У веб-приложения нет статистики, и каждый раз, когда клиент обращается к серверу, того поражает полная амнезия и он клянется, что никогда не видел этого клиента раньше. Поэтому нам нужно где-то хранить состояние — данные, сохраняемые на всех этапах игры, чтобы связать их воедино.
Мы могли бы написать игру полностью на JavaScript на стороне клиента и хранить все состояния там. Если вы хорошо знаете JavaScript, это хорошее решение, но если не знаете (что вполне возможно, ведь вы читаете книгу по языку Python), дадим Python тоже кое-что сделать.
В то же время мы могли бы написать слишком тяжелое для сервера приложение. Допустим, генерировать некий отдельный идентификатор для конкретной игры при первом веб-вызове, передавать этот идентификатор вместе с другими данными серверу на последующих этапах игры и хранить все изменяющиеся состояния в каком-нибудь хранилище данных на стороне сервера, например в базе данных.
Наконец, мы могли бы структурировать игру как последовательность вызовов клиент-серверной конечной точки веб-приложения в так называемом одностраничном приложении (Single-Page Application, SPA). При написании SPA обычно JavaScript выполняет Ajax-вызовы на сервер и нацеливает веб-ответы на обновление отдельных частей страницы, а не всего экрана. Клиентские JavaScript и HTML выполняют часть работы, а сервер обрабатывает часть логики и данных.
Во-первых, что это за игра? Мы создадим простую игру, похожую на Wordle (https://oreil.ly/PuD-Y), но в ней будут использоваться только названия существ из базы данных cryptid.db. Это намного проще, чем Wordle, особенно если схитрить и заглянуть в приложение Б. Применим окончательный сбалансированный дизайнерский подход, описанный ранее.
1. Задействуем ванильный JavaScript в клиенте вместо известных библиотек JavaScript, таких как React, Angular или даже jQuery.
2. Новая конечная точка FastAPI, GET в каталоге /game, инициализирует игру. Она получает имя случайного существа из нашей базы криптидов и возвращает его, встроенное в качестве скрытого значения в файл шаблона Jinja, состоящий из HTML, CSS и JavaScript.
3. На стороне клиента вновь созданные HTML и JavaScript отображают интерфейс типа Wordle. Появится последовательность окошек, по одному на каждую букву в названии скрытого существа.
4. Игрок вводит букву в каждое поле, а затем отправляет свою догадку и скрытое истинное имя на сервер. Это происходит в Ajax-вызове с помощью функции JavaScript fetch().
5. Вторая новая конечная точка FastAPI, POST /game, принимает эту догадку и реальное секретное имя и в сравнении с ним оценивает догадку. Она возвращает клиенту угаданное значение и результат.
6. Клиентская часть отображает отгадку и результат соответствующими цветами CSS во вновь созданной строке таблицы: зеленый — буква в правильном месте, желтый — буква в имени, но в другой позиции и серый — буква, не встречающаяся в скрытом имени. Результат — строка одиночных символов, используемых как имена классов CSS для отображения правильных цветов букв угадайки.
7. Если все буквы зеленые, можно праздновать. В противном случае клиент отображает новую последовательность полей ввода текста для следующей отгадки и повторяет шаг 4 и последующие до тех пор, пока имя не будет угадано или вы не сдадитесь. Большинство названий криптидов — это не общеупотребительные слова, поэтому при необходимости сверьтесь с приложением Б.
Эти правила немного отличаются от официального варианта игры Wordle, где разрешены только словарные слова из пяти букв и есть ограничение в шесть ходов.
Не надейтесь на это. Как и в большинстве примеров в книге, логика и дизайн игры минимальны — их достаточно, чтобы части работали совместно. Вы можете придать игре гораздо больше стиля и изящества, если у вас есть рабочая база.
Нам нужны две новые конечные точки веб-приложения. Мы используем имена существ, поэтому можно назвать конечные точки следующим образом: GET /creature/game и POST /creature/game. Но это не сработает, потому что у нас уже есть похожие конечные точки — GET /creature/{name} и POST /creature/{name} и FastAPI выберет их в качестве совпадения в первую очередь. Поэтому давайте создадим новое пространство имен маршрутизации верхнего уровня /game и поместим в него обе новые конечные точки.
Первая конечная точка в примере 18.1 выполняет инициализацию игры. Она должна получить случайное имя существа из базы данных и вернуть его вместе со всем клиентским кодом для реализации многоходовой игровой логики. Для этого мы используем шаблон Jinja (он приведен в главе 16), содержащий HTML, CSS и JavaScript.
Пример 18.1. Инициализация веб-игры (web/game.py)
from pathlib import Path
from fastapi import APIRouter, Body, Request
from fastapi.templating import Jinja2Templates
from service import game as service
router = APIRouter(prefix = "/game")
# Первоначальный запрос на игру
@router.get("")
def game_start(request: Request):
name = service.get_word()
top = Path(__file__).resolve().parents[1] # прародитель
templates = Jinja2Templates(directory=f"{top}/template")
return templates.TemplateResponse("game.html",
{"request": request, "word": name})
# Последующие игровые запросы
@router.post("")
async def game_step(word: str = Body(), guess: str = Body()):
score = service.get_score(word, guess)
return score
FastAPI требуется функция пути game_start(), чтобы получить параметр request и передать его в шаблон в качестве аргумента.
Далее, в примере 18.2, подключите субмаршрут /game к основному модулю, контролирующему маршруты /explorer и /creature.
Пример 18.2. Добавление субмаршрута /game (web/main.py)
from fastapi import FastAPI
from web import creature, explorer, game
app = FastAPI()
app.include_router(explorer.router)
app.include_router(creature.router)
app.include_router(game.router)
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app",
host="localhost", port=8000, reload=True)
Самый крупный компонент шаблона на стороне клиента (HTML, CSS и JavaScript) показан в примере 18.3.
Пример 18.3. Рабочий файл шаблона Jinja (template/game.html)
<head>
<style>
html * {
font-size: 20pt;
font-family: Courier, sans-serif;
}
body {
margin: 0 auto;
max-width: 700px;
}
input[type=text] {
width: 30px;
margin: 1px;
padding: 0px;
border: 1px solid black;
}
td, th {
cell-spacing: 4pt;
cell-padding: 4pt;
border: 1px solid black;
}
.H { background-color: #00EE00; } /* hit (green) */
.C { background-color: #EEEE00; } /* close (yellow) */
.M { background-color: #EEEEEE; } /* miss (gray) */
</style>
</head>
<body>
<script>
function show_score(guess, score){
var table = document.getElementById("guesses");
var row = table.insertRow(row);
for (var i = 0; i < guess.length; i++) {
var cell = row.insertCell(i);
cell.innerHTML = guess[i];
cell.classList.add(score[i]);
}
var word = document.getElementById("word").value;
if (guess.toLowerCase() == word.toLowerCase()) {
document.getElementById("status").innerHTML = "😀";
}
}
async function post_guess() {
var word = document.getElementById("word").value;
var vals = document.getElementsByName("guess");
var guess = "";
for (var i = 0; i < vals.length; i++) {
guess += vals[i].value;
}
var req = new Request("http://localhost:8000/game", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({"guess": guess, "word": word})
}
)
fetch(req)
.then((resp) => resp.json())
.then((score) => {
show_score(guess, score);
for (var i = 0; i < vals.length; i++) {
vals[i].value = "";
}
});
}
</script>
<h2>Cryptonomicon</h2>
<table id="guesses">
</table>
<span id="status"></span>
<hr>
<div>
{% for letter in word %}<input type=text name="guess">{% endfor %}
<input type=hidden id="word" value="{{word}}">
<br><br>
<input type=submit onclick="post_guess()">
</div>
</body>
В примере 18.4 показан сервисный код для связи функции запуска игры на веб-уровне с функцией предоставления случайного имени существа на уровне данных.
Пример 18.4. Расчет результата (service/game.py)
import data.game as data
def get_word() -> str:
return data.get_word()
Добавьте код из примера 18.5 к коду из примера 18.4. Результат представляет собой строку одиночных символов, указывающих, совпала ли введенная буква с правильной позицией, с другой позицией или указана неверно. Отгадываемые буквы и слово преобразуются в нижний регистр, чтобы сопоставление не зависело от регистра. Если длина отгадки не совпадает с длиной скрытого слова, возвращается пустая строка.
Пример 18.5. Расчет результата (service/game.py)
from collections import Counter, defaultdict
HIT = "H"
MISS = "M"
CLOSE = "C" # (буква находится в слове, но в другой позиции)
def get_score(actual: str, guess: str) -> str:
length: int = len(actual)
if len(guess) != length:
return ERROR
actual_counter = Counter(actual) # {буква: подсчет, ...}
guess_counter = defaultdict(int)
result = [MISS] * длина
for pos, letter in enumerate(guess):
if letter == actual[pos]:
result[pos] = HIT
guess_counter[letter] += 1
for pos, letter in enumerate(guess):
if result[pos] == HIT:
continue
guess_counter[letter] += 1
if (letter in actual and
guess_counter[letter] <= actual_counter[letter]):
result[pos] = CLOSE
result = ''.join(result)
return result
Пример 18.6 содержит несколько упражнений pytest для расчета оценки сервиса. В коде используем функциональную возможность pytest под названием parametrize для передачи последовательности тестов, вместо того чтобы писать цикл внутри самой тестовой функции. Помните из примера 18.5, что H — точное попадание, C — близко (неверная позиция) и M означает, что игрок вообще не угадал.
Пример 18.6. Тестирование расчета результата (test/unit/service/test_game.py)
import pytest
from service import game
word = "bigfoot"
guesses = [
("bigfoot", "HHHHHHH"),
("abcdefg", "MCMMMCC"),
("toofgib", "CCCHCCC"),
("wronglength", ""),
("", ""),
]
@pytest.mark.parametrize("guess,score", guesses)
def test_match(guess, score):
assert game.get_score(word, guess) == score
Запускаем:
$ pytest -q test_game.py
..... [100%]
5 passed in 0.05s
В новом модуле data/game.py потребуется только одна функция, показанная в примере 18.7.
Пример 18.7. Получение случайного имени существа (data/game.py)
from .init import curs
def get_word() -> str:
qry = "select name from creature order by random() limit 1"
curs.execute(qry)
row = curs.fetchone()
if row:
name = row[0]
else:
name = "bigfoot"
return name
(Кто-нибудь, пожалуйста, придумайте название получше.)
В браузере перейдите по адресу http://localhost:8000/game. На экране должно появиться следующее изображение.
Введем несколько букв и отправим их в качестве отгадки, чтобы посмотреть, что получится.
Буквы b, f и g выделены желтым (если вы не видите это в цвете, вам придется поверить мне на слово!). Это говорит о том, что они есть в скрытом имени, но стоят не на своих местах.
Попробуем придумать название, но изменим последнюю букву. Во второй строке мы видим много зеленого цвета. Ого, так близко!
Исправим последнюю букву и ради интереса сделаем некоторые буквы прописными, чтобы убедиться, что сопоставление работает без учета регистра. Подтверждаем отправку, ну и дела!
Мы использовали HTML, JavaScript, CSS и FastAPI, чтобы создать простую (очень!) интерактивную игру в стиле Wordle. В этом разделе было показано, как управлять многопоточным взаимодействием между веб-клиентом и сервером с помощью JSON и Ajax.