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(), которая:
- Делает
fetch('/api/state').
- Парсит ответ как JSON.
- Вызывает
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) должна:
- Очистить содержимое
#grid.
- Пройти по всем строкам и столбцам (6×6).
- Для каждой клетки создать
<div class="cell">.
- Если клетка захвачена — добавить класс с именем команды.
- Показать внутри имя задачи и очки.
- Повесить обработчик клика, который вызовет
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 — будет полноценная сохранность игры.
- Админ панель — страница, через которую можно добавлять, редактировать и удалять задачи в базе прямо из браузера.
- Выбор задач для игры из базы.