Книга: FastAPI: веб-разработка на Python
Назад: Глава 17. Обнаружение и визуализация данных
Дальше: Приложение A. Дополнительная литература

Глава 18. Игры

Обзор

Игры бывают очень разными, от простых текстовых до многопользовательских 3D-феерий. В этой главе я продемонстрирую простую игру и то, как конечная точка веб-приложения может взаимодействовать с пользователем на нескольких этапах. Этот процесс отличается от привычных вам по этой книге одноразовых запросов-ответов конечных точек веб-приложения.

Игровые пакеты в Python

Если вы действительно хотите освоить 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 = "&#x1F600";

    }

}

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.

Назад: Глава 17. Обнаружение и визуализация данных
Дальше: Приложение A. Дополнительная литература