Модуль: Flask для школьников


Практикум: захват территории


Flask для школьниковГлава 9 · Принципы построения сетевых онлайн игр
SilverTests.ru · Курс веб-разработкиTerritory — собираем сетевую игру
Принципы построения сетевых онлайн игр

Пошаговая разработка игры  в стиле Захват терртории "Regex территория"


Применяем знания Flask + SQLite для написания сетевой игры
Цель главы

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

Правила: прочитай задание → подумай 5 минут → попробуй написать сам → если  не получается, открой решение и разберись как оно работает. Если заглянул сразу — обманул только себя.

Прежде чем писать код, разберёмся в трёх вещах: где хранится игра, кто принимает решения, и как игроки видят ходы друг друга. Если эти идеи понятны — игра собирается за вечер. Если нет — код будет работать, но магией.

1Где живёт игра

Главный вопрос любой сетевой игры: где физически хранятся данные? Кто помнит, какую клетку занял Вася? Существуют три варианта:

На сервере
 
На клиенте
 
На обоих
  • На сервере — в памяти процесса Flask и в файле базы данных. Все игроки спрашивают одного и того же. (наш путь).
  • На клиенте — в браузере у каждого. Тогда у Васи свои данные, у Пети свои — они никогда не встретятся в одной игре. (не подходит)
  • На обоих — клиент кэширует для скорости, сервер хранит истину. (идеально для больших игр)

В нашем практикуме захват клеток хранятся в переменной game на сервере, а сами вопросы — в файле базы данных puzzles.db. Все игроки подключаются к одному процессу Python — у всех одна общая память и одна общая база.

HTTP ничего не помнит

Тут есть подвох. HTTP — это протокол без памяти. Каждый запрос как первое знакомство: «Привет, дай состояние». Сервер ответил и забыл. Как же игра помнит, что Вася уже занял клетку B3?

Ответ: память хранит не HTTP, а Python и база. Переменная game живёт в процессе Flask, пока он запущен. База puzzles.db живёт на диске и переживает перезапуски.

Браузер
HTTP-запрос
Flask + game + БД
JSON-ответ
запрос приходит, читает данные, уходит — данные остаются
Что переживает перезапуск, а что нет: переменная game в памяти — не переживает, захваты пропадут. База данных в файле puzzles.dbпереживает, она лежит на диске. 
2Сервер — единственный судья

Главное правило любой сетевой игры: никогда не доверяй клиенту.

Проверку regex можно было бы написать прямо в JavaScript — было бы быстрее, не нужен запрос на сервер. Но представь: хитрый игрок открывает DevTools (F12), находит функцию захвата и в консоли пишет:

// в консоли браузера у недобросовестного игрока:
captureCell({ ok: true });   // все клетки мои!

Готово. Игра сломана за 30 секунд. Поэтому мы делаем так:

Клиент
«можно занять?»
Сервер: проверяет
«да» / «нет»
клиент просит — сервер решает

Клиент показывает интерфейс и отправляет запросы. Сервер проверяет regex, решает, можно ли захватить, и записывает результат. Клиент в этой схеме — почтальон, а не судья.

Что в нашей игре проверяет именно сервер

1. Синтаксис regex (re.compile может бросить ошибку).

2. Все «yes»-строки проходят fullmatch.

3. Ни одна «no»-строка не проходит fullmatch.

4. Клетка ещё не захвачена другой командой.

Это правило работает везде, не только в играх. Банки не верят браузеру, сколько у тебя денег на счёте. Магазины не верят клиенту, какая цена у товара. Если решение принимает клиент — кто-то это решение подделает.

3Как игроки видят ходы друг друга

Вася нажал «Захватить» — клетка стала красной у него на экране. Как об этом узнает Петя у себя в браузере? Сам браузер Пети не догадается — никто ему не сказал. Есть четыре способа доставить новость:

Способ Как работает Когда нужен
Polling Браузер каждые N секунд сам спрашивает «что нового?» Игры с редкими ходами, чаты — наш случай
Long polling Запрос висит, сервер отвечает только при изменении Меньше трафика, но всё ещё на обычном HTTP
WebSockets Постоянное соединение, сервер сам пушит сообщения Шутеры, чаты, биржи, реалтайм
SSE Поток событий от сервера к клиенту Уведомления, ленты обновлений

Мы выбрали polling с интервалом 1.5 секунды. Почему именно его:

  • Ходы в нашей игре редкие — никто не кликает 10 раз в секунду.
  • 1.5 секунды задержки никто не заметит — глаз и так не различает.
  • Работает на простом fetch.

Для шутера такой подход был бы катастрофой: за 1.5 секунды тебя пять раз убьют из-за угла. Там нужны WebSockets с задержкой 20–50 мс.

Сколько времени проходит между Васиным кликом и реакцией Пети
# путь одного хода
клик в браузере Васи         →    0 мс
запрос летит на сервер        →   30 мс
сервер проверяет regex        →    5 мс
ответ Васе                    →   30 мс
─────────────────────────────────────────
Вася увидел захват             ≈   65 мс  # быстро

# а вот Петя ещё ничего не знает
ждём следующего polling Пети  →  до 1500 мс
запрос Пети на сервер         →   30 мс
─────────────────────────────────────────
Петя увидел захват             ≈ 1595 мс  # почти 1.6 сек

Полторы секунды между кликом и тем, что увидит другой игрок. Для нашего варианта игры это отлично. Для биржевой торговли — полный провал, там борются за миллисекунды.

Что почитать дальше: когда полтора секунды покажутся слишком долгими, ищи в поисковиках «Flask SocketIO» — это библиотека для WebSockets. Принципы из этой главы остаются теми же: сервер хранит истину, сервер судит. Меняется только то, как доставляются обновления.

Теперь, когда понятны три главных идеи, можно переходить к практике — собирать игру по шагам.

4Что мы строим

Сетка 6×6 из клеток. Каждая клетка — задача на регулярное выражение, взятая из базы данных. Игрок пишет regex, который подходит под все «проходные» строки и не подходит под «непроходные». Правильный ответ захватывает клетку для команды игрока. Побеждает команда с большим числом очков.

Архитектура: Flask-сервер + SQLite-база с задачами + HTML-страница + JavaScript с polling.

Что нужно написать

app.py — Flask-сервер с 5 маршрутами (главная + 4 API) и инициализацией базы.

puzzles.db — база SQLite, создаётся автоматически при первом запуске.

templates/game.html — страница с сеткой, модалкой и JavaScript.

Задание 1

Структура проекта

Создай новую папку regex_territory (отдельный проект). Внутри — файл app.py и папку templates с файлом game.html. Файл puzzles.db сам появится после первого запуска — вручную создавать не надо.

структура проекта
regex_territory/
├── app.py
├── puzzles.db          (появится автоматически)
└── templates/
    └── game.html
Задание 2

Скелет Flask-приложения

В app.py необходимо создать минимальный Flask-сервер. Для этого нужно:

  • Импортировать Flask, render_template, request, jsonify, re, sqlite3, json.
  • Создать приложение app = Flask(__name__).
  • Определить путь к базе: DB_PATH = 'puzzles.db'.
  • Запустить с host='0.0.0.0' на порту 5050, чтобы желающие поиграть могли подключиться по локальной сети.
💡 host='0.0.0.0', иначе твой сервер будет доступен только тебе. 
Посмотреть решение
from flask import Flask, render_template, request, jsonify
import re
import sqlite3
import json

app = Flask(__name__)
DB_PATH = 'puzzles.db'

if __name__ == '__main__':
    init_db()   # определим в задании 4
    app.run(debug=True, host='0.0.0.0', port=5050)

Почему host='0.0.0.0': обычно Flask слушает только 127.0.0.1 (свой компьютер). Чтобы все желающие могли зайти с других устройств, нужен 0.0.0.0 — «слушать всех в сети». Если сейчас попробуешь запустить — будет ошибка, что init_db не найдена. Это нормально, добавим в задании 4.

Задание 3

Команды

Создай словарь TEAMS на уровне модуля (не внутри функций) — четыре команды (red, blue, green, yellow), у каждой поля name и color. Команды живут прямо в коде — они не меняются от игры к игре, и их немного, поэтому в базу данных мы их не выносим.

Посмотреть решение
TEAMS = {
    "red":    {"name": "Красные", "color": "#e74c3c"},
    "blue":   {"name": "Синие",   "color": "#3498db"},
    "green":  {"name": "Зелёные", "color": "#2ecc71"},
    "yellow": {"name": "Жёлтые",  "color": "#f1c40f"},
}

Почему команды в коде, а вопросы — в базе? Простое правило: что часто меняется или растёт — в базу. Что фиксировано и небольшое — можно в коде. Команд всегда четыре, цвета подобраны заранее. Вопросов хочется добавлять без перезапуска и без правки кода.

Задание 4

База данных задач

Напишем функцию init_db(), которая:

  • Создаёт таблицу puzzles, если её ещё нет.
  • Если таблица пустая — засевает её четырьмя стартовыми задачами.
  • Если таблица не пустая — ничего не делает (мы не хотим терять задачи учителя при перезапуске).

Также нужно вызвать init_db() в блоке if __name__ == '__main__', прямо перед app.run() — теперь ошибка из задания 2 должна исчезнуть.

Колонки таблицы puzzles

id — уникальный номер задачи (автоинкремент).

position — место на сетке от 0 до 35 (UNIQUE, чтобы не было двух задач на одной клетке).

name, description — название и описание задачи.

yes_strings — JSON-массив проходных строк (например, ["aab", "bba"]).

no_strings — JSON-массив непроходных строк.

points — очки за захват.

💡 В одну ячейку SQLite нельзя положить список напрямую — только число, строку или NULL. Поэтому списки превращаем в JSON-строки через json.dumps([...]), а обратно — через json.loads(s).
Посмотреть решение
def init_db():
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute('''
        CREATE TABLE IF NOT EXISTS puzzles (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            position INTEGER UNIQUE NOT NULL,
            name TEXT NOT NULL,
            description TEXT NOT NULL,
            yes_strings TEXT NOT NULL,
            no_strings TEXT NOT NULL,
            points INTEGER NOT NULL DEFAULT 1
        )
    ''')

    cur.execute('SELECT COUNT(*) FROM puzzles')
    if cur.fetchone()[0] == 0:
        # Таблица пустая — засеваем стартовыми задачами
        seeds = [
            (0, "Буквы a и b", "Только буквы a и b",
                ["aab", "bba"], ["abc", "a1"], 1),
            (1, "Одни нули", "Строка из нулей",
                ["0", "000"], ["01", ""], 1),
            (2, "4 цифры", "Ровно 4 цифры",
                ["1234", "0000"], ["123", "abcd"], 2),
            (3, "Email", "user@domain",
                ["a@b.ru"], ["@mail"], 3),
        ]
        for pos, name, desc, yes, no, pts in seeds:
            cur.execute(
                '''INSERT INTO puzzles
                   (position, name, description, yes_strings, no_strings, points)
                   VALUES (?, ?, ?, ?, ?, ?)''',
                (pos, name, desc, json.dumps(yes), json.dumps(no), pts)
            )

    conn.commit()
    conn.close()

Что такое json.dumps: функция превращает список Python вроде ["aab", "bba"] в строку '["aab", "bba"]'. Эту строку можно сохранить в текстовую колонку SQLite. Обратно превращаем в список через json.loads(s) в задании 5.

Зачем проверка COUNT(*): если таблица уже есть и в ней лежат задачи учителя — не надо заново засевать. Засеваем только пустую таблицу.

Можно добавить задачи руками, например через DB Browser for SQLite: открываешь файл puzzles.db, добавляешь строку в таблицу puzzles — задача появится в игре после следующего polling, без перезапуска сервера.

Задание 5

Состояние игры и чтение задач

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

  • Быстро проверить, захвачена ли клетка (row, col).
  • Узнать, какая команда её захватила и кто конкретно.

Также напиши функцию get_puzzle(r, c), которая возвращает задачу по координатам клетки — читая её из базы данных. Должна вернуть словарь с полями name, desc, yes, no, pts, или None, если такой задачи нет.

Два способа адресовать клетку

В нашей программе клетку называют двумя способами, и важно не путаться:

В коде Python и JavaScript — пара (r, c): строка и столбец, оба от 0 до 5. Так удобно для сетки 6×6.

В базе данных — одно число position от 0 до 35. Так удобно для UNIQUE-проверки в SQL.

Преобразование между ними: position = r * 6 + c, обратно — r, c = divmod(position, 6).

💡 Ключ словаря game["cells"] — строка вида '2,3'. Если такого ключа нет — клетка свободна.
Посмотреть решение
game = {
    "cells": {},  # "r,c" -> {"team": "red", "player": "Вася"}
    "log": [],
}

def get_puzzle(r, c):
    position = r * 6 + c       # (r, c) -> линейный индекс 0..35
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute(
        '''SELECT name, description, yes_strings, no_strings, points
           FROM puzzles WHERE position = ?''',
        (position,)
    )
    row = cur.fetchone()
    conn.close()

    if row is None:
        return None

    name, desc, yes_str, no_str, pts = row
    return {
        "name": name,
        "desc": desc,
        "yes": json.loads(yes_str),
        "no": json.loads(no_str),
        "pts": pts,
    }

Заметь: функция возвращает словарь с теми же полями, что и раньше, когда задачи лежали в коде. Поэтому остальные части программы (api_state, api_capture, api_puzzle) менять не нужно — они продолжат работать. Это называется «стабильный интерфейс»: внутри функции мы поменяли источник данных, снаружи всё то же самое.

Минус подхода: на каждый вызов get_puzzle открываем-закрываем соединение с БД. В api_state get_puzzle зовётся 36 раз — это 36 коротких запросов на каждый polling. SQLite справляется, но в большой системе так делать нельзя — там используют пулы соединений или Flask.g. Нам пока хватит.

Задание 6

Главная страница

Добавь маршрут /, который возвращает шаблон game.html. Передай в него словарь TEAMS, чтобы можно было отрисовать выбор команды.

Посмотреть решение
@app.route('/')
def index():
    return render_template('game.html', teams=TEAMS)
Задание 7

API /api/state — сердце игры

Это главный маршрут. JavaScript будет дёргать его у всех игроков каждые 1.5 секунды. Он должен возвращать JSON с полной картиной игры:

  • grid — массив 6×6 с данными каждой клетки (name, pts, team, player)
  • scores — словарь очков каждой команды
  • teams — данные команд (name, color)

Логика: пройти по всем клеткам 6×6, получить задачу через get_puzzle, добавить информацию о захвате (если есть). Посчитать очки — сумма pts захваченных клеток для каждой команды.

💡 Два вложенных for по r и c. Если puzzle None — добавь None в row, иначе собери словарь. Этот код не меняется относительно версии без БД — интерфейс get_puzzle тот же.
Посмотреть решение
@app.route('/api/state')
def api_state():
    scores = {t: 0 for t in TEAMS}
    for key, cell in game["cells"].items():
        r, c = map(int, key.split(","))
        puzzle = get_puzzle(r, c)
        if puzzle:
            scores[cell["team"]] += puzzle["pts"]

    grid = []
    for r in range(6):
        row = []
        for c in range(6):
            puzzle = get_puzzle(r, c)
            if puzzle is None:
                row.append(None)
                continue
            key = f"{r},{c}"
            data = {"name": puzzle["name"], "pts": puzzle["pts"]}
            if key in game["cells"]:
                data["team"] = game["cells"][key]["team"]
                data["player"] = game["cells"][key]["player"]
            row.append(data)
        grid.append(row)

    return jsonify({"grid": grid, "scores": scores, "teams": TEAMS})

Про None и «дырки» в сетке: если в задании 4 ты засеял базу только 4 задачами, для остальных 32 клеток get_puzzle вернёт None, и в grid попадёт None. На странице эти места просто не отрисуются (см. задание 12). Это фича: учитель может постепенно пополнять базу, и новые задачи появляются на сетке без перезапуска сервера.

Задание 8

API /api/capture — проверка regex

Самый сложный маршрут. Принимает POST с JSON: {r, c, regex, team, player}. Должен:

  • Вернуть ошибку, если клетка уже захвачена.
  • Попробовать скомпилировать regex через re.compile. Если синтаксис кривой — поймать re.error и вернуть понятное сообщение.
  • Проверить, что каждая строка из yes проходит re.fullmatch.
  • Проверить, что ни одна строка из no не проходит.
  • Если всё ок — записать захват в game["cells"] и вернуть успех.

Важно: используй fullmatch, а не search. Почему? Подумай сам.

💡 Сначала все проверки-отказы, только потом записывай захват. Иначе можно занять клетку при ошибке.
Посмотреть решение
@app.route('/api/capture', methods=['POST'])
def api_capture():
    data = request.json
    r, c = data.get("r"), data.get("c")
    regex_str = data.get("regex", "")
    team = data.get("team", "")
    player = data.get("player", "Аноним")

    puzzle = get_puzzle(r, c)
    if puzzle is None:
        return jsonify({"ok": False, "msg": "Нет клетки"})
    key = f"{r},{c}"
    if key in game["cells"]:
        return jsonify({"ok": False, "msg": "Уже захвачена"})

    try:
        pattern = re.compile(regex_str)
    except re.error as e:
        return jsonify({"ok": False, "msg": f"Ошибка regex: {e}"})

    for s in puzzle["yes"]:
        if not pattern.fullmatch(s):
            return jsonify({"ok": False, "msg": f'{s} должна пройти'})
    for s in puzzle["no"]:
        if pattern.fullmatch(s):
            return jsonify({"ok": False, "msg": f'{s} НЕ должна пройти'})

    game["cells"][key] = {"team": team, "player": player}
    return jsonify({"ok": True, "msg": "Захвачено!"})

Про fullmatch vs search: fullmatch требует совпадения всей строки. search ищет подстроку. Нам нужно именно полное — иначе regex a пройдёт строку "abc", что неправильно.

Задание 9

HTML-каркас страницы

В game.html создай структуру с такими элементами (пока пустыми, содержимое заполнит JavaScript):

  • Заголовок H1 «Regex Territory»
  • <input id="player"> — поле для имени
  • <select id="team"> — выбор команды (4 option)
  • <div id="scores"></div> — очки команд
  • <div id="grid"></div> — сетка 6×6
  • <div id="modal" style="display:none"></div> — модалка задачи

И два блока: <style> для CSS и <script> для JavaScript.

💡 Элементы пустые — JavaScript сам заполнит их после получения данных с сервера.
Посмотреть решение
<!DOCTYPE html>
<html><head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Regex Territory</title>
  <style> /* CSS */ </style>
</head><body>
  <h1>Regex Territory</h1>
  <input id="player" placeholder="Имя">
  <select id="team">
    <option value="red">Красные</option>
    <option value="blue">Синие</option>
    <option value="green">Зелёные</option>
    <option value="yellow">Жёлтые</option>
  </select>
  <div id="scores"></div>
  <div id="grid"></div>
  <div id="modal" style="display:none"></div>
  <script> /* JavaScript */ </script>
</body></html>

Заметь meta viewport: без неё страница на телефоне будет показана как уменьшенная копия десктопа. С ней браузер использует реальную ширину экрана, и медиа-запросы из CSS (увидишь в задании 10) сработают.

Задание 10

CSS для сетки 6×6

Используй CSS-свойство display: grid, чтобы разложить клетки в сетку без ручного позиционирования. Нужно:

  • На широких экранах: 6 колонок по 80 пикселей, клетки 80×80.
  • На узких (телефонах): 6 колонок, которые сами делят ширину поровну — чтобы вся сетка влезала на экран без горизонтальной прокрутки.
  • Промежутки между клетками 4px, центрированный текст, курсор pointer.
  • Классы .red, .blue, .green, .yellow — фон и рамка цвета команды.
💡 repeat(6, 80px) — шесть фиксированных колонок. repeat(6, 1fr) — шесть растягивающихся колонок, делят доступную ширину поровну. Между ними переключаемся через @media (max-width: 600px).
Посмотреть решение
#grid {
  display: grid;
  grid-template-columns: repeat(6, 80px);
  gap: 4px;
}
.cell {
  width: 80px; height: 80px;
  border: 2px solid #999;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; text-align: center; font-size: 12px;
  background: #fff;
}
.cell.red    { background: #ffcdd2; border-color: #e74c3c; }
.cell.blue   { background: #bbdefb; border-color: #3498db; }
.cell.green  { background: #c8e6c9; border-color: #2ecc71; }
.cell.yellow { background: #fff9c4; border-color: #f1c40f; }

/* Адаптация под телефоны: сетка тянется по ширине экрана */
@media (max-width: 600px) {
  #grid {
    grid-template-columns: repeat(6, 1fr);
    max-width: 100%;
    gap: 2px;
  }
  .cell {
    width: auto;
    height: 14vw;     /* высота через ширину окна, чтобы клетка была квадратной */
    font-size: 9px;
    border-width: 1px;
  }
}

Как это работает на телефоне: 6 колонок по 1fr делят ширину поровну. Если экран 360px, а отступы по бокам 20px — на сетку остаётся 320px, на одну клетку выходит ~53px. Высота 14vw = 14% ширины экрана, чтобы клетки оставались примерно квадратными при любом размере.

Почему 600px: это типичная граница «телефон vs планшет». Можно поставить 768px — тогда адаптив включится и на маленьких планшетах.

Задание 11

Загрузка состояния с polling

Напиши JavaScript-функцию loadState(), которая:

  1. Делает fetch('/api/state').
  2. Парсит ответ как JSON.
  3. Вызывает renderGrid(data.grid) и renderScores(data.scores, data.teams) (их напишешь на следующем шаге).

Затем вызови эту функцию сразу при загрузке и настрой автообновление каждые 1500 мс.

💡 Это то же самое, что ты делал в трекере привычек с кнопкой «Обновить», только автоматически.
Посмотреть решение
function loadState() {
  fetch('/api/state')
    .then(r => r.json())
    .then(data => {
      renderScores(data.scores, data.teams);
      renderGrid(data.grid);
    })
    .catch(err => console.error('polling failed:', err));
}

loadState();
setInterval(loadState, 1500);

Эти 2 строки в конце — сердце сетевой части. Все игроки у всех браузеров видят одну карту с задержкой 1.5 сек.

Про .catch(): если сеть пропала или сервер упал, без catch ошибка просто «улетает» в консоль молча и следующие polling ничего не сделают. С catch мы хотя бы увидим, что что-то не так. Для пользовательского интерфейса позже можно показывать индикатор «нет связи».

Задание 12

Отрисовка сетки

Функция renderGrid(grid) должна:

  1. Очистить содержимое #grid.
  2. Пройти по всем строкам и столбцам (6×6).
  3. Для каждой клетки создать <div class="cell">.
  4. Если клетка захвачена — добавить класс с именем команды.
  5. Показать внутри имя задачи и очки.
  6. Повесить обработчик клика, который вызовет openPuzzle(r, c).

Тоже напиши renderScores(scores, teams) — просто выводит очки команд цветным текстом.

💡 innerHTML = '' очищает перед отрисовкой — иначе клетки будут накапливаться при каждом polling.
Посмотреть решение
function renderGrid(grid) {
  const container = document.getElementById('grid');
  container.innerHTML = '';
  for (let r = 0; r < 6; r++) {
    for (let c = 0; c < 6; c++) {
      const cell = grid[r][c];
      if (!cell) continue;     // пустая клетка - задачи нет в БД
      const div = document.createElement('div');
      div.className = 'cell';
      if (cell.team) div.classList.add(cell.team);
      div.innerHTML = `<b>${cell.name}</b><br>${cell.pts} pts`;
      div.addEventListener('click', () => openPuzzle(r, c));
      container.appendChild(div);
    }
  }
}

function renderScores(scores, teams) {
  let html = '';
  for (const t in teams) {
    html += `<span style="color:${teams[t].color}">${teams[t].name}: ${scores[t]||0}</span> `;
  }
  document.getElementById('scores').innerHTML = html;
}

Про if (!cell) continue;: если в БД задача только для 4 клеток — остальные 32 не появятся на странице. Сетка будет с «дырками». Это нормально и удобно: учитель добавляет задачи постепенно. Хочешь видеть пустые клетки как заглушки — вместо continue создавай <div class="cell empty"> с серым фоном и без обработчика клика.

Задание 13

Модалка и захват

Нужны две функции:

openPuzzle(r, c) — запоминает координаты кликнутой клетки в глобальную переменную currentCell, загружает задачу через fetch('/api/puzzle/...') (добавь простой API для этого), показывает модалку с описанием, полем ввода regex и кнопками.

captureCell() — читает regex, имя игрока, команду, отправляет POST на /api/capture с JSON. После успеха — закрывает модалку и вызывает loadState().

💡 currentCell — глобальная переменная. Её устанавливает openPuzzle, а читает captureCell. Маршрут /api/puzzle тоже использует get_puzzle — так что данные тянутся из БД.
Посмотреть решение

Сначала добавь в app.py маршрут для деталей задачи:

@app.route('/api/puzzle/<int:r>/<int:c>')
def api_puzzle(r, c):
    p = get_puzzle(r, c)
    if p is None: return jsonify({}), 404
    return jsonify(p)

Теперь JavaScript:

let currentCell = null;

function openPuzzle(r, c) {
  currentCell = {r, c};
  fetch(`/api/puzzle/${r}/${c}`)
    .then(res => res.json())
    .then(p => {
      const m = document.getElementById('modal');
      m.innerHTML = `
        <h3>${p.name}</h3><p>${p.desc}</p>
        <p>Да: ${p.yes.join(', ')}</p>
        <p>Нет: ${p.no.join(', ')}</p>
        <input id="regex-input" placeholder="^[a-z]+$">
        <button onclick="captureCell()">Захватить</button>
        <button onclick="document.getElementById('modal').style.display='none'">Отмена</button>
        <div id="result"></div>`;
      m.style.display = 'block';
    })
    .catch(err => alert('Не удалось загрузить задачу'));
}

function captureCell() {
  const result = document.getElementById('result');
  fetch('/api/capture', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      r: currentCell.r, c: currentCell.c,
      regex: document.getElementById('regex-input').value,
      team: document.getElementById('team').value,
      player: document.getElementById('player').value || 'Аноним',
    })
  })
  .then(r => r.json())
  .then(d => {
    result.innerHTML = d.msg;
    if (d.ok) {
      document.getElementById('modal').style.display = 'none';
      loadState();
    }
  })
  .catch(err => {
    result.innerHTML = '⚠ Ошибка соединения с сервером';
    console.error(err);
  });
}

Про .catch() в captureCell: без него, если интернет пропадёт прямо в момент захвата, игрок нажмёт «Захватить» и просто увидит... ничего. Никакой обратной связи, никакой ошибки. С catch мы покажем сообщение, и игрок поймёт, что надо повторить попытку. Маленькая деталь — огромная разница в ощущениях.

Задание 14

Запуск на весь класс

Запусти сервер и дай доступ одноклассникам. Два сценария:

  • Одна WiFi-сеть — узнай свой локальный IP и продиктуй его как http://192.168.x.x:5050.
  • Разные сети — используй ngrok, он даст публичный URL.

При первом запуске рядом с app.py появится файл puzzles.db — это твоя база. Можешь открыть её в DB Browser for SQLite и посмотреть, что внутри.

Проверь игру сам: открой две вкладки браузера, в одной захвати клетку, во второй увидишь изменение через 1.5 сек.

Посмотреть решение
# macOS/Linux
ifconfig | grep inet

# Windows
ipconfig

# Запуск Flask (puzzles.db создастся автоматически)
python app.py

# Доступ для одноклассников
http://192.168.1.42:5050   # подставь свой IP

# Если нужен публичный URL
ngrok http 5050

# Чтобы посмотреть/добавить задачи в базу:
# качаешь DB Browser for SQLite, открываешь puzzles.db

Правила игры: 3–4 команды по 5–8 человек, раунд 10 минут, побеждает команда с большим числом очков.

5Что улучшить самостоятельно

Базовая версия работает. Теперь попробуй развить:

  • Таймер раунда — обратный отсчёт, блокировка захватов после нуля.
  • История событий — лента последних захватов на странице.
  • Бонус соседей — +1 очко за каждую соседнюю клетку своего цвета.
  • Больше задач — добавь в базу до 36 разных регулярок разного уровня сложности. Можно через DB Browser, можно скриптом.
  • Сброс игры — маршрут /api/reset и кнопка для учителя (очищает game["cells"], базу не трогает).
  • Анимации — плавное изменение цвета при захвате (transition в CSS).
  • Защита от двойного клика — если два игрока одновременно нажмут на одну свободную клетку, оба запроса увидят key not in cells и оба запишут захват. Последний перезатрёт первого. Решение: threading.Lock вокруг записи в game["cells"], либо транзакция в БД, если перенесёшь захваты туда.
  • Захваты в базу — сейчас game["cells"] теряется при перезапуске. Перенеси и его в SQLite — будет полноценная сохранность игры.
  • Админ панель — страница, через которую можно добавлять, редактировать и удалять задачи в базе прямо из браузера.
  • Выбор задач для игры из базы.
© SilverTests.ru · Курс Flask для школьников · Глава 9 из 10