- TypeScript 99.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| campaign-editor | ||
| campaigns | ||
| client | ||
| deploy | ||
| desktop | ||
| docs | ||
| editor | ||
| maps | ||
| server | ||
| shared | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| biome.json | ||
| Dockerfile | ||
| ecosystem.config.cjs | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tuning.json | ||
| tuning.schema.json | ||
Swarm 2D — кооп-шутер про волны (веб, 2–4 игрока)
Top-down кооперативный шутер с волнами: Phaser 3 (клиент) + Colyseus (авторитарный сервер, 30 Гц,
предикшн + реконнект) + общая TypeScript-логика (shared/). Работается на npm-workspaces.
Структура
game3/
├── PLAN.md # план закрытой беты (этапы 4–7)
├── maps/ # внешние JSON-карты (грузятся сервером, отдаются клиенту по API)
├── shared/ # общие константы/математика карты и оружия; формат+реестр карт
├── server/ # Colyseus-сервер: комнаты, бой, волны; прод-вход + статика
├── client/ # Phaser-клиент: лобби, игра, HUD
├── deploy/ # docker-compose + Caddyfile (TLS)
├── ecosystem.config.cjs # PM2-конфиг (альтернатива Docker)
└── Dockerfile # сервер + собранный клиент, один образ
Разработка
npm ci
npm run dev # сервер :2567 + vite-клиент :5173
Клиент доступен на http://localhost:5173, сервер — ws://localhost:2567.
Тесты сервера: npm test.
Деплой одной командой (Docker + Caddy)
На хосте с Docker и свободными 80/443:
SITE_ADDRESS=game.example.com docker compose -f deploy/docker-compose.yml up -d --build
- Один origin: Caddy терминирует TLS (wss автоматически) и проксирует на игровой сервер:
страница, matchmake HTTP (
/matchmake/*) и WebSocket — порт 2567, отдельных настроек ws-порта нет. - Клиент сам берёт адрес сервера из того же origin (
client/src/net.ts); для dev это :5173 → :2567, overrides —?server=ws://хост:портв адресной строке. - Если домен с Cloudflare/другим прокси — терминируйте TLS там и укажите
SITE_ADDRESS=:80.
Логи: docker compose -f deploy/docker-compose.yml logs -f game.
PM2 без Docker
npm ci && npm run build && npx pm2 start ecosystem.config.cjs
Сервер сам раздаёт client/dist и пишет логи в logs/server.log (LOG_FILE).
Перед ним нужен любой reverse-proxy с TLS на 127.0.0.1:2567.
Переменные окружения сервера
| Переменная | По умолчанию | Значение |
|---|---|---|
PORT |
2567 |
HTTP+WS порт |
HOST |
0.0.0.0 |
bind-адрес |
PUBLIC_ADDRESS |
авто | адрес процесса для matchmake (нужен только при >1 процессах) |
MAX_ROOMS |
8 |
лимит игровых комнат на процесс; сверх лимита создание отклоняется |
LOG_FILE |
— | дописывать логи в файл |
STATIC_DIR |
авто-поиск | папка статики (по умолчанию собранный client/dist) |
REDIS_URL |
— | Redis для matchmaker (driver+presence); без него — in-memory (один процесс) |
Один процесс Node держит несколько комнат (лимит MAX_ROOMS). Для горизонтального
масштаба — несколько процессов за балансировщиком с общим Redis (см. ниже).
Горизонтальный масштаб (Redis)
Matchmaker (список и резервирование комнат) переключается на Redis по env REDIS_URL
(напр. redis://127.0.0.1:6379); без него остаётся прежний in-memory (один процесс).
Старт — fail-fast: если Redis недоступен, сервер падает с [boot] fatal: Redis unreachable…,
а не работает полу-настроенным. В логе старта видно бэкенд:
[boot] matchmaker backend: redis (<host>) либо [boot] matchmaker backend: in-memory.
Каждому процессу — свой PORT и свой PUBLIC_ADDRESS (публичные хост:порт, куда
клиент реально ходит через балансировщик). Пример с PM2 (по мотивам ecosystem.config.cjs):
REDIS_URL=redis://127.0.0.1:6379 PORT=2567 PUBLIC_ADDRESS=game.example.com:2567 \
npx pm2 start server/dist/index.js --name g0
REDIS_URL=redis://127.0.0.1:6379 PORT=2568 PUBLIC_ADDRESS=game.example.com:2568 \
npx pm2 start server/dist/index.js --name g1
Балансировщик обязан держать WS-сессию на том же процессе (sticky по IP/cookie):
резервация matchmake и реконнект привязаны к процессу-владельцу комнаты. TLS терпит
Caddy/nginx перед процессами, а PUBLIC_ADDRESS = публичный адрес конкретного процесса.
Игрокам
Игрокам достаточно ссылки https://<SITE_ADDRESS>/ — клиент раздаётся тем же сервером,
ничего ставить не нужно; вход через лобби или по invite-ссылке ?join=<roomId>.
Как играть
- Лобби: список открытых комнат (название, игроки, волна), создание комнаты с названием.
- Ссылка-приглашение: в игре кнопка «КОПИРОВАТЬ ССЫЛКУ» (
?join=<roomId>) — игрок из другой сети открывается сразу комнату и сразу попадает в бой. - Управление: WASD — движение, мышь — прицел, ЛКМ — огонь, R — перезарядка, F — полноэкранный режим.
- Игра занимает всё окно браузера (масштаб RESIZE, HUD и меню перестраиваются при ресайзе).
- Классы: стрелок / тяжёлый / гранатомёт. Волны, терминал-цель, жизни и респауны.
- Миссия «Улей» (выбор карты в лобби): большой полигон без волн — непрерывное давление врагов нарастает со временем, высадка в одном углу, прорыв к терминалу и его финальная защита с боссом (25 с удержания) до победы.
- Миссия «Пепелище» (выбор карты в лобби): литейный двор с боссом-гигантом ЖАРОВИКОМ (r=118, 7200 HP). Он не догоняет — он отнимает территорию: огненный веер, кислотный навес, пылающий таран и токсичный вал оставляют на полу горящие и ядовитые зоны, так что арена под ногами кончается быстрее, чем у босса садится здоровье. Лобовая броня держит 80% пуль — бить надо во фланг, тыл или в открытое брюхо во время вала; на 66% и 33% HP босс впадает в ярость и вызывает подкрепление.
- Комната: до 4 игроков; комнаты создаются игроками и живут, пока в них кто-то есть; полная комната из лобби скрывается, пустая — удаляется сервером.
- Ретраггер связи: оверлей «Переподключение… N c» с попытками реконнекта, затем возврат в лобби с сохранёнными ником и классом. За флуд пакетами сервер кикает.
Формат и загрузка карт
Карты не захардкожены в коде. Они лежат во внешней папке maps/ (по одному JSON-файлу
на карту: maps/<id>.json) и редактируются отдельно — в текстовом редакторе или в
будущем в визуальном редакторе карт. Схема — maps/map.schema.json
("$schema": "./map.schema.json" в каждом файле даёт автодополнение и валидацию).
Поток данных:
- Сервер при старте сканирует
maps/(папка задаётся envMAPS_DIR), валидирует каждый файл и регистрирует карты в общем реестре (server/src/mapStore.ts::bootstrapMaps()). - HTTP API на том же порту:
GET /api/maps— лёгкие карточки для лобби (id, name, режим, тема, босс, брифинг, число вышек),GET /api/maps/:id— полное тело конкретной карты. - Клиент не знает список заранее: лобби тянет
GET /api/mapsи строит кнопки, а тело карты догружается поGET /api/maps/:idнепосредственно перед входом на неё (client/src/mapStore.ts::ensureMapLoaded()), затем кэшируется.
Формат formatVersion: 1:
id— стабильный идентификатор (совпадает с именем файла),name— отображаемое имя.order— позиция карты в реестре.size: {cols, rows}— размер в тайлах (по умолчанию 50×38).mode—"waves"(волны) |"mission"(непрерывное давление + защита терминала) |"sandbox"(тестовая арена).theme— визуальная тема ("snow"— холодная палитра + вьюга),darkness— кромешная тьма.boss— вид босса карты ("crusher","rotmaw","gloom","kraken","weaver", …).objective: {tile: [tx, ty], holdSeconds}— терминал цели и время удержания до победы.obstacles: [{x, y, w, h}, …]— прямоугольники стен в тайлах.enemySpawns/playerSpawns/generators/relays/lamps— списки точек[tx, ty].weapons: [{x, y, kind?}, …]— подбираемое оружие на карте;kind— основной ствол, без него случайный.stages: {gorge, switchboard, door, terminal}— якоря 4-этапной миссии.pressure: {squads: [[enemyKind, fromSeconds], …]}— кастомная кривая состава сквадов.
Валидация — shared/src/maps.ts::parseMapFile() (бросает ошибку с указанием поля при
несоответствии схеме). Чтобы добавить/поменять карту — правится JSON в maps/ и
перезапускается сервер; пересборки клиента не требуется.
Масштабирование и адаптивный UI
- Фиксированная виртуальная высота: canvas занимает 100% окна в реальных пикселях
(
Phaser.Scale.RESIZE; на 4K — честные 3840×2160), игровая камера масштабируется зумомzoom = высота окна / 1080(client/src/scale.ts). По вертикали всегда видно 1080 мировых единиц, горизонталь расширяется/сужается по соотношению сторон. Точка отсчёта 16:9 — 1920×1080. - UI — в отдельной сцене
UIScene(собственная камера, zoom = 1, screen space): текст рендерится в нативных пикселях экрана и остаётся чётким на 4K, в отличие от world-объектов, замасштабленных зумом. - Раскладка интерфейса пересчитывается на
Phaser.Scale.Events.RESIZE: базовая раскладка — 1080p, отступы от краёв экрана, размеры панелей и шрифты умножаются наh / 1080(client/src/ui/HUD.ts,client/src/ui/UIScene.ts; меню пересобирается целиком вMenuScene.ts, включая HTML-инпуты). - Мир:
cameras.main.setZoom()+setBoundsкарты; UI-сцена стартует/останавливается вместе с игровой сценой, камера мира не влияет на текст HUD.
Защита и лимиты
- Rate-limit сообщений: токен-бакет (~80 msg/s sustained, burst 160), кик за спам (код 4001).
- Лимит входа: 4 клиента на комнату (
maxClients), очереди ожидания нет — комната заполняется. - Враги расталкивают друг друга в толпе (pairwise-раздвижение с проверкой стен) — у цели не слипаются в одну точку.
- Логирование боевого ядра и киков — stdout сервера + опционально файл.
Диагностика и наблюдаемость
- Серверные логи миссий:
[mission] room=…— старт каждой волны, финалWON/LOST(время, киллы), итог при закрытии комнаты; краши комнаты (onUncaughtException) и процесса (uncaughtException/unhandledRejection) со стеком. СLOG_FILEвсё дублируется в файл. - Клиентские падения (
window.onerror,unhandledrejection) шлютсяPOST /diagна сервер (тот же origin, до 10 репортов на сессию) и пишутся в лог как[client-error]. Sentry при желании подключается поверх того же интерфейса (reportErrorвclient/src/diagnostics.ts).
Нагрузочный профиль и баланс (headless-прогоны)
npm run profile # 4 бота + волна 5 на 30 Гц: размеры снапшотов и стоимость тика
npm run balance # полная миссия 4 ботами: время зачистки волн, смерти — кривая сложности
Текущая база (из npm run profile, волна 5 = 27 врагов, 4 бота, 45 с):
полный снапшот ~2.2–2.3 KiB, дельта avg ~0.07–0.10 KiB/тик (p95 ~0.26–0.34 KiB,
≈2.3–3.1 KiB/s на клиента), симуляция ~0.10–0.11 мс/тик (p95 ~0.23–0.26 мс) —
на процесс с MAX_ROOMS запас большой.
Что изменилось в профиле (работа над 1000+ свармеров, docs/archive/perf-swarmer-tz.md):
- Синк врагов — per-client AOI (T7
@view) на 15 Гц с 1px-квантованием позиции/угла (T6mirrorEnemies):state.enemiesзакрыт@view(), клиент получает только своих врагов изStateView. Профилировщик иperf.test.tsмеряют именно VIEW-канал (encodeView/encodeAllView), а не мёртвый no-viewencode()(он больше не отдаёт врагов). На стресс-кольце дельта пер-клиента: ~2.4 KiB/тик @400, ~8.6 KiB/тик @1200, ~10.9 KiB/тик @2000 (против ~23.3 KiB/тик до T6). - Расталкивание удешевлено (T5):
separateEnemiesс капом соседей — ~0.2–0.45 мс на 400 врагов в куче (без капа было бы квадратично). - Клиентский рендер слоями (T1–T4): cull за экраном, HP-бары одним слоем, атлас роя, переиспользование вью из пула — ниже стоимость отрисовки роя.
Эти же метрики зафиксированы как регрессионный тест server/test/perf.test.ts
(входит в npm test): бюджет тика, размер снапшотов (view-канал) и стресс-
расталкивание 400 врагов с порогами с запасом ~2–5× от базы — новые механики не
должны пройти незамеченными. После каждого крупного босса прогонять npm run profile заново.
Константы баланса (shared/src/constants.ts/weapons.ts/enemies.ts: составы волн,
HP/speed-кривые, урон) крутятся по выводам
npm run balance; вражеские view и пули на клиенте переиспользуются из пула вместо
создания/уничтожения каждую волну.
Оптимизации сервера (см. ideas/02-performance.md):
separateEnemies— пространственный хеш (грид поTILE), ~O(n) вместо O(n²);pursue— flow-field от тайла цели (один BFS на всех, кэш с TTL 500 мс) вместо per-enemyfindTilePath.
Как устроены тесты
npm test поднимает реальный сервер и гоняет смоук-тесты: неткод/движение, бой,
волны, реконнект, лобби (метаданные, лимит комнат, join-by-id, кик за спам).