Compare commits

Author SHA1 Message Date
cacto c47391b6db Укрепление эксплуатации: healthcheck'и, лимиты ресурсов, логи, self-hosted шрифты, smoke-проверка в CI
Deploy / deploy (push) Failing after 0s
2026-11-10 00:59:14 +05:00
cacto 6746d89233 Merge dev в main: две новые мини-игры ./blocks и ./invaders
Deploy / deploy (push) Failing after 0s
2026-08-12 12:13:38 +05:00
cacto 6ce29bb986 Merge feature/arcade-games в dev 2026-08-12 11:42:57 +05:00
cacto bb194ad33f Добавить две одиночные мини-игры: ./blocks и ./invaders
Тетрис- и Space Invaders-подобные аркады на чистом canvas/JS, без
мультиплеера и без сервера — в отличие от ./tanks это классические
одиночные игры. Названия сайта — generic (не «Тетрис»/«Space Invaders»),
та же логика, что и с переименованием ./battlecity в ./tanks: обе
франшизы — активно защищаемые товарные знаки (The Tetris Company,
Taito).

./blocks — 10x20 поле, 7 фигур, вращение с простым wall-kick, ускорение
по уровням, next-piece превью, hard drop.
./invaders — волны врагов, стрельба игрока и врагов, жизни, ускорение
с каждой волной.

Обе — клавиатура + сенсорные кнопки, RU/EN (не сбрасывает партию при
переключении языка — в отличие от бага, который чинили в ./tanks).
Кросс-ссылки между всеми тремя играми на каждой игровой странице,
подвал whoami обновлён списком всех трёх.
2026-08-12 11:41:18 +05:00
cacto cb25497bb8 Merge dev в main: home_automatization_controllers в списке проектов
Deploy / deploy (push) Failing after 0s
2026-08-12 00:52:57 +05:00
cacto 3e14c8f381 Merge feature/update-projects-list в dev 2026-08-12 00:52:13 +05:00
cacto abd57e1cdf Добавить home_automatization_controllers в список проектов
Новый репозиторий на git.cactoz.su — прошивка ESP32 для зоны «Гроубокс»
(DHT22 + 3 реле, MQTT). home_automation_mobile пока не добавляю — репозиторий
пустой (0 коммитов).
2026-08-12 00:51:56 +05:00
cacto 9c7caf50a7 Merge dev в main: server_tokens off
Deploy / deploy (push) Failing after 0s
2026-08-12 00:36:17 +05:00
cacto df24edd0ab Merge dev в main: аудит сайта (og-теги, whoami, prerender, ./tanks)
Deploy / deploy (push) Failing after 0s
2026-08-10 20:44:52 +05:00
cacto fb18a6f289 Merge dev в main: полный перенос дизайна и текстов из макета
Deploy / deploy (push) Failing after 1s
2026-08-10 00:42:11 +05:00
cacto 01eec92d48 Merge dev в main: редизайн + RU/EN
Deploy / deploy (push) Failing after 1s
2026-08-09 21:56:44 +05:00
cacto e1bfc2b5d7 Слияние dev: фикс клавиатуры, зелёное дуло
Deploy / deploy (push) Failing after 0s
2026-08-06 18:51:44 +05:00
cacto 0a47ac9234 Слияние dev: security-хардening, спавн-фикс
Deploy / deploy (push) Failing after 1s
2026-08-06 14:01:49 +05:00
cacto de6f0438c5 Слияние dev: coop с AI, звук, чистые URL
Deploy / deploy (push) Failing after 1s
2026-08-06 12:44:59 +05:00
cacto 01cefbbe77 Слияние dev: PvP-логика
Deploy / deploy (push) Failing after 1s
2026-08-04 12:26:30 +05:00
cacto 7b48c01a93 Merge pull request 'Dev' (#3) from dev into main
Deploy / deploy (push) Failing after 0s
Reviewed-on: #3
2026-07-26 18:25:56 +00:00
cacto d626a66ff7 Merge pull request 'Dev' (#2) from dev into main
Deploy / deploy (push) Failing after 0s
Reviewed-on: #2
2026-07-26 18:19:47 +00:00
cacto 128c5cf16e Merge pull request 'Dev' (#1) from dev into main
Deploy / deploy (push) Failing after 1s
Reviewed-on: #1
2026-07-26 18:03:36 +00:00
cacto a1c7cfdc9b Слияние dev: фикс DNS в деплое
Deploy / deploy (push) Failing after 2m15s
2026-07-26 22:31:04 +05:00
cacto b5566ec73e Слияние dev: каркас, резюме/проекты, лобби, автодеплой
Deploy / deploy (push) Failing after 2m40s
2026-07-26 22:06:56 +05:00
39 changed files with 1472 additions and 15 deletions
+24 -1
View File
@@ -29,4 +29,27 @@ jobs:
run: |
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
docker image prune -f
# Если сборка/подъём упали — шаг упадёт, а старые контейнеры продолжат
# работать (up -d не выполнился). Проверяем, что новое поднялось по
# healthcheck'ам обоих сервисов, и что nginx реально отдаёт главную.
- name: Smoke check
working-directory: /data/docker/compose/cactoz
run: |
n=starting
g=starting
for i in $(seq 1 30); do
n=$(docker inspect -f '{{.State.Health.Status}}' cactoz-nginx 2>/dev/null || echo starting)
g_id=$(docker compose -f docker-compose.prod.yml ps -q gameserver)
g=$([ -n "$g_id" ] && docker inspect -f '{{.State.Health.Status}}' "$g_id" 2>/dev/null || echo starting)
if [ "$n" = "healthy" ] && [ "$g" = "healthy" ]; then break; fi
sleep 3
done
docker compose -f docker-compose.prod.yml ps
[ "$n" = "healthy" ]
[ "$g" = "healthy" ]
docker exec cactoz-nginx wget -qO /dev/null http://127.0.0.1/
- name: Prune dangling images
working-directory: /data/docker/compose/cactoz
run: docker image prune -f
+77
View File
@@ -0,0 +1,77 @@
# cactoz.su
Личный сайт-визитка на `cactoz.su` в Matrix-стилистике: резюме, проекты и
браузерные игры. Три страницы статики + мультиплеерный сервер для `./tanks`.
Исходный замысел и игровой протокол — в [TZ.md](TZ.md) (⚠️ местами устарел).
## Архитектура
```
┌────────── homelab (cacto-homelab) ──────────┐
push в main │ │
в Gitea ────────────► │ gitea-actions runner (docker.sock хоста) │
git.cactoz.su │ │ клонирует, синкает, собирает │
│ ▼ │
│ /data/docker/compose/cactoz │
│ docker compose -f docker-compose.prod.yml │
│ ├── nginx (статика + прокси /ws) │
│ └── gameserver (C++/uWebSockets, :9001) │
│ │ │
│ ▼ (external docker-сеть proxy) │
│ Caddy-стек (TLS-терминация 80/443) │
└──────────────────────────────────────────────┘
```
- **Деплой**: push в `main` → workflow `.gitea/workflows/deploy.yml` → build +
`up -d` + smoke-проверка по healthcheck'ам. Отката нет: если сборка/подъём
падают, шлётся уведомление, старые контейнеры продолжают работать.
- **TLS** терминирует отдельный Caddy-стек, сеть `proxy` создаётся там
(`external: true`) — на этом хосте только подключаемся.
- Gameserver изнутри доступен только по docker-сети `internal`; наружу
смотрит только nginx (через Caddy).
## Локальный запуск
```bash
docker compose up --build # http://localhost, /ws на ws://localhost/ws
```
Сборка gameserver тянет зависимости через CMake FetchContent (uWebSockets,
uSockets, nlohmann/json) — первый билд долгий, дальше кэшируется слоями Docker.
## Структура
```
frontend/ чистый HTML/CSS/JS, без сборки
vendor/fonts/ self-hosted JetBrains Mono (OFL), 4 сабсета × 4 насыщенности
js/game/ клиент ./tanks: network (WS + ping 25с), lobby, battle
js/blocks.js, js/invaders.js одиночные мини-игры (canvas, без сервера)
gameserver/
src/main.cpp uWS::App, /ws, лимиты фреймов/таймауты
src/Lobby.* лобби: комнаты, broadcast, ping/pong, события в stdout
src/Game.* авторитетная симуляция (20 Гц), pvp и coop с AI
nginx/ статика + проксирование /ws (rate-limit на соединения)
```
## Runbook
```bash
# на homelab:
cd /data/docker/compose/cactoz
docker compose -f docker-compose.prod.yml ps # статус + healthcheck'и
docker logs -f cactoz-gameserver-... 2>&1 | less # события лобби/игр
docker compose -f docker-compose.prod.yml up -d --build # ручной перезапуск
```
- Логи ротируются (`max-size 10m × 3`), лимиты ресурсов: nginx 128M/0.5cpu,
gameserver 256M/1cpu.
- Gameserver не умеет graceful shutdown — при рестарте активные матчи обрываются
(комнаты сами поднимаются заново).
- Статика без хешей в именах: js/css отдаются с `no-cache`, `/assets/` — час.
## Доступ и безопасность
- SSH на homelab: ключи, вход только с доверенных сетей; sudo — по необходимости.
- Gitea (`git.cactoz.su`) — источник истины для кода; секретов в репо нет.
- За пределами compose на хосте живут Caddy-стек, Gitea и её runner — их
конфиги не входят в этот репозиторий и бэкапятся отдельно.
+5
View File
@@ -1,5 +1,10 @@
# ТЗ: cactoz.su
> ⚠️ Это ТЗ исходного замысла (MVP). Реальность разошлась: появились мини-игры
> `./blocks` и `./invaders`, самостоятельная страница `./tanks` со своим JS,
> шрифты self-hosted, healthcheck'и в compose, структура `gameserver/src` компактнее
> описанной. Актуальная архитектура деплоя и runbook — в [README.md](README.md).
## 1. Общее
Личный сайт-визитка на домене `cactoz.su`. Три страницы, единая Matrix-стилистика.
+42
View File
@@ -10,12 +10,54 @@ services:
depends_on:
- gameserver
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 128M
cpus: "0.5"
gameserver:
build: ./gameserver
networks:
- internal
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/9001'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 256M
cpus: "1.0"
networks:
internal:
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>./blocks — cacto</title>
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="stylesheet" href="/css/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/arcade.css" />
</head>
<body>
<canvas id="matrix-rain"></canvas>
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="logo" aria-label="cacto — на главную">
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
cacto<span class="cursor">_</span>
</a>
<ul class="nav-links">
<li><a href="/">whoami</a></li>
<li><a href="/projects">ls projects/</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul>
</div>
</header>
<div class="container">
<main>
<h2 class="prompt">./blocks</h2>
<div id="arcade-root"></div>
</main>
</div>
<script type="module" src="/js/matrix-rain.js"></script>
<script type="module" src="/js/blocks.js"></script>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
/* --- общие стили для одиночных аркад (./blocks, ./invaders) --- */
.arcade-crosslinks {
color: var(--color-silver);
font-size: 0.85rem;
margin: 0 0 1.5rem;
}
.arcade-layout {
display: flex;
align-items: flex-start;
gap: 1.5rem;
flex-wrap: wrap;
margin: 1.5rem 0;
}
.arcade-canvas-slot {
position: relative;
}
.arcade-canvas-slot canvas {
border: var(--border);
background: var(--color-bg);
display: block;
}
.arcade-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.1rem;
padding-top: 0.5rem;
width: 200px;
}
.arcade-stats {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: var(--color-silver);
text-align: center;
}
.arcade-stats b {
color: var(--color-accent);
font-weight: 600;
}
.arcade-next-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
.arcade-next-slot span {
color: var(--color-silver);
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.arcade-next-slot canvas {
border: var(--border);
background: var(--color-bg-raised);
}
.arcade-controls__hint {
color: var(--color-silver);
font-size: 0.75rem;
text-align: center;
margin: 0;
}
.arcade-dpad {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(2, 44px);
gap: 4px;
}
.arcade-dpad button {
background: var(--color-bg-raised);
border: 1px solid var(--color-accent-dim);
color: var(--color-accent);
font-family: var(--font-mono);
font-size: 1.1rem;
cursor: pointer;
user-select: none;
touch-action: none;
}
.arcade-dpad button:active {
background: var(--color-accent-dim);
color: var(--color-bg);
}
.arcade-dpad__up { grid-column: 2; grid-row: 1; }
.arcade-dpad__left { grid-column: 1; grid-row: 2; }
.arcade-dpad__right { grid-column: 3; grid-row: 2; }
.arcade-dpad__down { grid-column: 2; grid-row: 2; }
/* --- пара кнопок влево/вправо (./invaders — вертикаль не нужна) --- */
.arcade-lr {
display: flex;
gap: 4px;
}
.arcade-lr button {
background: var(--color-bg-raised);
border: 1px solid var(--color-accent-dim);
color: var(--color-accent);
font-family: var(--font-mono);
font-size: 1.1rem;
cursor: pointer;
user-select: none;
touch-action: none;
width: 62px;
height: 44px;
}
.arcade-lr button:active {
background: var(--color-accent-dim);
color: var(--color-bg);
}
.arcade-action-btn {
background: var(--color-bg-raised);
border: 1px solid var(--color-accent);
color: var(--color-accent);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.6rem 1.2rem;
cursor: pointer;
user-select: none;
touch-action: none;
width: 100%;
}
.arcade-action-btn:active {
background: var(--color-accent);
color: var(--color-bg);
}
.arcade-result-overlay {
display: none;
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
background: rgba(10, 14, 12, 0.92);
z-index: 5;
text-align: center;
}
.arcade-result {
padding: 2rem;
border: var(--border);
background: var(--color-bg-raised);
}
.arcade-result h2 {
font-size: 2.2rem;
margin: 0 0 0.75rem;
letter-spacing: 0.05em;
color: var(--color-accent);
text-shadow: 0 0 16px var(--color-accent-dim);
}
.arcade-result p {
color: var(--color-fg);
margin: 0 0 1.5rem;
}
.arcade-result__actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.arcade-result__actions button {
background: transparent;
border: 1px solid var(--color-accent-dim);
color: var(--color-accent);
font-family: var(--font-mono);
padding: 0.5rem 1.25rem;
cursor: pointer;
}
.arcade-result__actions button:hover {
border-color: var(--color-accent);
box-shadow: 0 0 6px var(--color-accent-dim);
}
@media (max-width: 640px) {
.arcade-layout {
flex-direction: column;
}
.arcade-controls {
width: 100%;
max-width: 260px;
}
}
+149
View File
@@ -0,0 +1,149 @@
/* Self-hosted JetBrains Mono (сабсеты cyrillic/cyrillic-ext/latin/latin-ext).
Источник: Google Fonts, OFL-лицензия. Заменяет fonts.googleapis.com —
убирает внешнюю зависимость и лишние хосты из CSP. */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/vendor/fonts/jbmono-400-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/vendor/fonts/jbmono-400-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/vendor/fonts/jbmono-400-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/vendor/fonts/jbmono-400-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/vendor/fonts/jbmono-500-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/vendor/fonts/jbmono-500-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/vendor/fonts/jbmono-500-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/vendor/fonts/jbmono-500-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/vendor/fonts/jbmono-600-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/vendor/fonts/jbmono-600-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/vendor/fonts/jbmono-600-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/vendor/fonts/jbmono-600-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/vendor/fonts/jbmono-700-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/vendor/fonts/jbmono-700-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/vendor/fonts/jbmono-700-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/vendor/fonts/jbmono-700-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+6
View File
@@ -1,3 +1,9 @@
.arcade-crosslinks {
color: var(--color-silver);
font-size: 0.85rem;
margin: 0 0 1rem;
}
.lobby-panel {
border: var(--border);
background: var(--color-bg-raised);
+2 -3
View File
@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>./tanks — cacto</title>
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/game.css" />
</head>
@@ -31,6 +29,7 @@
<div class="container">
<main id="game-root">
<h2 class="prompt">./tanks</h2>
<p class="arcade-crosslinks"><span id="game-crosslinks-label"></span> <a href="/blocks">./blocks</a> · <a href="/invaders">./invaders</a></p>
<div id="lobby"></div>
<div id="tanks-canvas-container" style="display: none"></div>
</main>
+2 -4
View File
@@ -13,9 +13,7 @@
<meta property="og:image" content="https://cactoz.su/assets/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/resume.css" />
</head>
@@ -221,7 +219,7 @@
</main>
<footer id="site-footer" class="site-footer">
<p>P.S. есть ещё <a href="/game">./tanks</a> — мультиплеерная мини-игра, если будет пара свободных минут.</p>
<p>P.S. есть ещё пара мини-игр — <a href="/game">./tanks</a> (мультиплеер), <a href="/blocks">./blocks</a> и <a href="/invaders">./invaders</a>, если будет пара свободных минут.</p>
</footer>
</div>
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>./invaders — cacto</title>
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="stylesheet" href="/css/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/arcade.css" />
</head>
<body>
<canvas id="matrix-rain"></canvas>
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="logo" aria-label="cacto — на главную">
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
cacto<span class="cursor">_</span>
</a>
<ul class="nav-links">
<li><a href="/">whoami</a></li>
<li><a href="/projects">ls projects/</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul>
</div>
</header>
<div class="container">
<main>
<h2 class="prompt">./invaders</h2>
<div id="arcade-root"></div>
</main>
</div>
<script type="module" src="/js/matrix-rain.js"></script>
<script type="module" src="/js/invaders.js"></script>
</body>
</html>
+440
View File
@@ -0,0 +1,440 @@
import { getLang, initLangToggle } from './i18n.js';
const COLS = 10;
const ROWS = 20;
const CELL = 24;
const GRAVITY_START_MS = 800;
const GRAVITY_MIN_MS = 100;
const SHAPES = {
I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
O: [[1, 1], [1, 1]],
S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
};
const COLORS = {
I: '#00ff41',
O: '#9fa3a0',
T: '#39e075',
S: '#7cffb2',
Z: '#1fae56',
J: '#c7c7c7',
L: '#0a8f2c',
};
const PIECE_TYPES = Object.keys(SHAPES);
const STR = {
ru: {
hint: 'стрелки — двигать, вверх — вращать, пробел — сброс',
score: 'счёт',
lines: 'линии',
level: 'уровень',
next: 'дальше',
hardDrop: 'сброс',
gameOver: 'ИГРА ОКОНЧЕНА',
finalScore: (s) => `счёт: ${s}`,
again: 'ещё раз',
crosslinks: 'ещё игры:',
},
en: {
hint: 'arrows to move, up to rotate, space to hard-drop',
score: 'score',
lines: 'lines',
level: 'level',
next: 'next',
hardDrop: 'drop',
gameOver: 'GAME OVER',
finalScore: (s) => `score: ${s}`,
again: 'play again',
crosslinks: 'more games:',
},
};
function t() {
return STR[getLang()];
}
function emptyBoard() {
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
}
function rotateMatrix(matrix) {
const n = matrix.length;
const result = Array.from({ length: n }, () => Array(n).fill(0));
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
result[x][n - 1 - y] = matrix[y][x];
}
}
return result;
}
function randomType() {
return PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)];
}
class BlocksGame {
constructor(canvas, nextCanvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.nextCanvas = nextCanvas;
this.nextCtx = nextCanvas.getContext('2d');
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
this.reset();
}
reset() {
this.board = emptyBoard();
this.score = 0;
this.lines = 0;
this.level = 1;
this.over = false;
this.nextType = randomType();
this.spawn();
this.dropTimer = 0;
this.lastTime = null;
}
spawn() {
const type = this.nextType;
this.nextType = randomType();
const matrix = SHAPES[type].map((row) => row.slice());
this.piece = {
type,
matrix,
x: Math.floor((COLS - matrix.length) / 2),
y: 0,
};
if (this.collides(this.piece.matrix, this.piece.x, this.piece.y)) {
this.over = true;
}
}
collides(matrix, offX, offY) {
for (let y = 0; y < matrix.length; y++) {
for (let x = 0; x < matrix[y].length; x++) {
if (!matrix[y][x]) continue;
const bx = offX + x;
const by = offY + y;
if (bx < 0 || bx >= COLS || by >= ROWS) return true;
if (by >= 0 && this.board[by][bx]) return true;
}
}
return false;
}
move(dx, dy) {
if (this.over) return false;
const p = this.piece;
if (!this.collides(p.matrix, p.x + dx, p.y + dy)) {
p.x += dx;
p.y += dy;
return true;
}
return false;
}
rotate() {
if (this.over) return;
const p = this.piece;
const rotated = rotateMatrix(p.matrix);
const kicks = [0, 1, -1, 2, -2];
for (const kick of kicks) {
if (!this.collides(rotated, p.x + kick, p.y)) {
p.matrix = rotated;
p.x += kick;
return;
}
}
}
hardDrop() {
if (this.over) return;
while (this.move(0, 1)) {
this.score += 1;
}
this.lock();
}
lock() {
const p = this.piece;
for (let y = 0; y < p.matrix.length; y++) {
for (let x = 0; x < p.matrix[y].length; x++) {
if (p.matrix[y][x]) {
const by = p.y + y;
const bx = p.x + x;
if (by >= 0) this.board[by][bx] = p.type;
}
}
}
this.clearLines();
this.spawn();
}
clearLines() {
let cleared = 0;
this.board = this.board.filter((row) => {
const full = row.every((cell) => cell);
if (full) cleared++;
return !full;
});
while (this.board.length < ROWS) {
this.board.unshift(Array(COLS).fill(null));
}
if (cleared > 0) {
const points = [0, 100, 300, 500, 800][cleared] || 800;
this.score += points * this.level;
this.lines += cleared;
this.level = 1 + Math.floor(this.lines / 10);
}
}
gravityInterval() {
return Math.max(GRAVITY_MIN_MS, GRAVITY_START_MS - (this.level - 1) * 60);
}
tick(dtMs) {
if (this.over) return;
this.dropTimer += dtMs;
const interval = this.gravityInterval();
if (this.dropTimer >= interval) {
this.dropTimer = 0;
if (!this.move(0, 1)) {
this.lock();
}
}
}
drawCell(ctx, x, y, color) {
ctx.fillStyle = color;
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
ctx.strokeStyle = '#0a0e0c';
ctx.lineWidth = 1;
ctx.strokeRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
}
draw() {
const ctx = this.ctx;
ctx.fillStyle = '#0a0e0c';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (this.board[y][x]) {
this.drawCell(ctx, x, y, COLORS[this.board[y][x]]);
}
}
}
const p = this.piece;
if (p) {
for (let y = 0; y < p.matrix.length; y++) {
for (let x = 0; x < p.matrix[y].length; x++) {
if (p.matrix[y][x] && p.y + y >= 0) {
this.drawCell(ctx, p.x + x, p.y + y, COLORS[p.type]);
}
}
}
}
// сетка поверх, лёгкая
ctx.strokeStyle = 'rgba(80, 255, 140, 0.06)';
for (let x = 1; x < COLS; x++) {
ctx.beginPath();
ctx.moveTo(x * CELL, 0);
ctx.lineTo(x * CELL, this.canvas.height);
ctx.stroke();
}
for (let y = 1; y < ROWS; y++) {
ctx.beginPath();
ctx.moveTo(0, y * CELL);
ctx.lineTo(this.canvas.width, y * CELL);
ctx.stroke();
}
this.drawNext();
}
drawNext() {
const ctx = this.nextCtx;
const size = this.nextCanvas.width;
ctx.fillStyle = '#0f1512';
ctx.fillRect(0, 0, size, size);
const matrix = SHAPES[this.nextType];
const cell = Math.floor(size / 4);
const offset = (4 - matrix.length) / 2;
for (let y = 0; y < matrix.length; y++) {
for (let x = 0; x < matrix[y].length; x++) {
if (matrix[y][x]) {
ctx.fillStyle = COLORS[this.nextType];
ctx.fillRect((x + offset) * cell + 1, (y + offset) * cell + 1, cell - 2, cell - 2);
}
}
}
}
}
let game = null;
let rafId = null;
function updateStats() {
document.getElementById('arcade-score').textContent = game.score;
document.getElementById('arcade-lines').textContent = game.lines;
document.getElementById('arcade-level').textContent = game.level;
}
function showGameOver() {
const overlay = document.getElementById('arcade-result-overlay');
overlay.innerHTML = `
<div class="arcade-result">
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
<p>${t().finalScore(game.score)}</p>
<div class="arcade-result__actions">
<button data-action="again">${t().again}</button>
</div>
</div>`;
overlay.style.display = 'flex';
}
function loop(time) {
if (game.lastTime === null) game.lastTime = time;
const dt = time - game.lastTime;
game.lastTime = time;
const wasOver = game.over;
game.tick(dt);
game.draw();
updateStats();
if (game.over && !wasOver) {
showGameOver();
}
rafId = requestAnimationFrame(loop);
}
function restart() {
const overlay = document.getElementById('arcade-result-overlay');
overlay.style.display = 'none';
overlay.innerHTML = '';
game.reset();
}
function bindKeyboard() {
window.addEventListener('keydown', (e) => {
if (!game) return;
if (game.over) {
if (e.code === 'Space' || e.code === 'Enter') restart();
return;
}
switch (e.code) {
case 'ArrowLeft':
game.move(-1, 0);
e.preventDefault();
break;
case 'ArrowRight':
game.move(1, 0);
e.preventDefault();
break;
case 'ArrowDown':
if (game.move(0, 1)) game.score += 1;
e.preventDefault();
break;
case 'ArrowUp':
game.rotate();
e.preventDefault();
break;
case 'Space':
game.hardDrop();
e.preventDefault();
break;
}
});
}
function bindTouchControls(root) {
const actions = {
left: () => game.move(-1, 0),
right: () => game.move(1, 0),
down: () => {
if (game.move(0, 1)) game.score += 1;
},
rotate: () => game.rotate(),
drop: () => game.hardDrop(),
};
root.querySelectorAll('[data-input]').forEach((btn) => {
const action = actions[btn.dataset.input];
btn.addEventListener('click', () => {
if (game.over) return;
action();
});
});
}
function renderShell(root) {
root.innerHTML = `
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/invaders">./invaders</a></p>
<div class="arcade-layout">
<div class="arcade-canvas-slot">
<canvas id="arcade-canvas"></canvas>
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
</div>
<div class="arcade-controls">
<div class="arcade-stats">
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
<div><span id="arcade-lines-label">${t().lines.toUpperCase()}</span> <b id="arcade-lines">0</b></div>
<div><span id="arcade-level-label">${t().level.toUpperCase()}</span> <b id="arcade-level">1</b></div>
</div>
<div class="arcade-next-slot">
<span id="arcade-next-label">${t().next}</span>
<canvas id="arcade-next" width="80" height="80"></canvas>
</div>
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
<div class="arcade-dpad">
<button class="arcade-dpad__up" data-input="rotate">&uarr;</button>
<button class="arcade-dpad__left" data-input="left">&larr;</button>
<button class="arcade-dpad__right" data-input="right">&rarr;</button>
<button class="arcade-dpad__down" data-input="down">&darr;</button>
</div>
<button class="arcade-action-btn" data-input="drop" id="arcade-drop-btn">${t().hardDrop}</button>
</div>
</div>`;
}
// Обновляет статичные подписи без пересоздания игры — иначе переключение
// языка посреди партии сбрасывало бы прогресс, как уже было с ./tanks.
function updateStaticText() {
const byId = (id) => document.getElementById(id);
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
if (byId('arcade-lines-label')) byId('arcade-lines-label').textContent = t().lines.toUpperCase();
if (byId('arcade-level-label')) byId('arcade-level-label').textContent = t().level.toUpperCase();
if (byId('arcade-next-label')) byId('arcade-next-label').textContent = t().next;
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
if (byId('arcade-drop-btn')) byId('arcade-drop-btn').textContent = t().hardDrop;
if (game?.over) showGameOver();
}
function init() {
const root = document.getElementById('arcade-root');
renderShell(root);
const canvas = document.getElementById('arcade-canvas');
const nextCanvas = document.getElementById('arcade-next');
game = new BlocksGame(canvas, nextCanvas);
bindTouchControls(root.querySelector('.arcade-controls'));
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
if (e.target.closest('[data-action="again"]')) restart();
});
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
}
bindKeyboard();
init();
initLangToggle(updateStaticText);
+4
View File
@@ -23,6 +23,7 @@ const STR = {
room: 'комната',
mode: 'режим:',
leave: 'выйти в лобби',
crosslinks: 'ещё игры:',
},
en: {
connecting: 'connecting...',
@@ -38,6 +39,7 @@ const STR = {
room: 'room',
mode: 'mode:',
leave: 'leave to lobby',
crosslinks: 'more games:',
},
};
@@ -127,6 +129,8 @@ function renderRoomView() {
}
function render() {
const crosslinksLabel = document.getElementById('game-crosslinks-label');
if (crosslinksLabel) crosslinksLabel.textContent = t().crosslinks;
if (currentRoom) {
renderRoomView();
} else {
+9
View File
@@ -12,6 +12,15 @@ export class Network {
const msg = JSON.parse(event.data);
this._emit(msg.type, msg.payload);
});
// NAT-таблицы на промежуточных роутерах гаснут раньше, чем серверный
// idleTimeout (300с) — application-ping раз в 25с держит соединение живым
// и на случай обрыва держим интервал заметно меньше таймаута.
setInterval(() => {
if (this.socket.readyState === WebSocket.OPEN) {
this.send(ClientMessage.PING);
}
}, 25000);
}
on(type, handler) {
+2
View File
@@ -1,6 +1,7 @@
// Типы сообщений из TZ.md, раздел 5.
export const ClientMessage = {
HELLO: 'hello',
PING: 'ping',
LOBBY_LIST_ROOMS: 'lobby.list_rooms',
LOBBY_CREATE_ROOM: 'lobby.create_room',
LOBBY_JOIN_ROOM: 'lobby.join_room',
@@ -11,6 +12,7 @@ export const ClientMessage = {
export const ServerMessage = {
HELLO_ACK: 'hello.ack',
PONG: 'pong',
ERROR: 'error',
LOBBY_ROOMS: 'lobby.rooms',
LOBBY_ROOM_JOINED: 'lobby.room_joined',
+331
View File
@@ -0,0 +1,331 @@
import { getLang, initLangToggle } from './i18n.js';
const CANVAS_W = 420;
const CANVAS_H = 520;
const PLAYER_Y = CANVAS_H - 40;
const PLAYER_W = 28;
const PLAYER_H = 14;
const PLAYER_SPEED = 220; // px/s
const BULLET_SPEED = 380;
const ENEMY_BULLET_SPEED = 180;
const FIRE_COOLDOWN_MS = 350;
const ENEMY_COLS = 8;
const ENEMY_ROWS = 4;
const ENEMY_W = 28;
const ENEMY_H = 18;
const ENEMY_GAP_X = 12;
const ENEMY_GAP_Y = 16;
const ENEMY_TOP = 50;
const ENEMY_STEP_DOWN = 18;
const STR = {
ru: {
hint: 'стрелки — двигать, пробел — огонь',
score: 'счёт',
lives: 'жизни',
wave: 'волна',
gameOver: 'ИГРА ОКОНЧЕНА',
finalScore: (s) => `счёт: ${s}`,
again: 'ещё раз',
crosslinks: 'ещё игры:',
fire: 'огонь',
},
en: {
hint: 'arrows to move, space to fire',
score: 'score',
lives: 'lives',
wave: 'wave',
gameOver: 'GAME OVER',
finalScore: (s) => `score: ${s}`,
again: 'play again',
crosslinks: 'more games:',
fire: 'fire',
},
};
function t() {
return STR[getLang()];
}
function rectsOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
class InvadersGame {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
canvas.width = CANVAS_W;
canvas.height = CANVAS_H;
this.input = { left: false, right: false, fire: false };
this.reset();
}
reset() {
this.score = 0;
this.lives = 3;
this.wave = 1;
this.over = false;
this.player = { x: CANVAS_W / 2 - PLAYER_W / 2, y: PLAYER_Y };
this.bullets = [];
this.enemyBullets = [];
this.fireTimer = 0;
this.hitFlash = 0;
this.spawnWave();
}
spawnWave() {
this.enemies = [];
const totalWidth = ENEMY_COLS * (ENEMY_W + ENEMY_GAP_X) - ENEMY_GAP_X;
const startX = (CANVAS_W - totalWidth) / 2;
for (let row = 0; row < ENEMY_ROWS; row++) {
for (let col = 0; col < ENEMY_COLS; col++) {
this.enemies.push({
x: startX + col * (ENEMY_W + ENEMY_GAP_X),
y: ENEMY_TOP + row * (ENEMY_H + ENEMY_GAP_Y),
w: ENEMY_W,
h: ENEMY_H,
alive: true,
points: (ENEMY_ROWS - row) * 10,
});
}
}
this.enemyDir = 1;
this.enemySpeed = 30 + (this.wave - 1) * 12;
this.enemyShootTimer = 0;
}
aliveEnemies() {
return this.enemies.filter((e) => e.alive);
}
tick(dtMs) {
if (this.over) return;
const dt = dtMs / 1000;
if (this.input.left) this.player.x -= PLAYER_SPEED * dt;
if (this.input.right) this.player.x += PLAYER_SPEED * dt;
this.player.x = Math.max(0, Math.min(CANVAS_W - PLAYER_W, this.player.x));
this.fireTimer -= dtMs;
if (this.input.fire && this.fireTimer <= 0) {
this.bullets.push({ x: this.player.x + PLAYER_W / 2 - 2, y: this.player.y, w: 4, h: 10 });
this.fireTimer = FIRE_COOLDOWN_MS;
}
for (const b of this.bullets) b.y -= BULLET_SPEED * dt;
this.bullets = this.bullets.filter((b) => b.y + b.h > 0);
for (const b of this.enemyBullets) b.y += ENEMY_BULLET_SPEED * dt;
this.enemyBullets = this.enemyBullets.filter((b) => b.y < CANVAS_H);
const alive = this.aliveEnemies();
if (alive.length === 0) {
this.wave += 1;
this.spawnWave();
return;
}
let hitEdge = false;
for (const e of alive) {
e.x += this.enemyDir * this.enemySpeed * dt;
if (e.x <= 0 || e.x + e.w >= CANVAS_W) hitEdge = true;
}
if (hitEdge) {
this.enemyDir *= -1;
for (const e of alive) {
e.y += ENEMY_STEP_DOWN;
if (e.y + e.h >= this.player.y) {
this.over = true;
}
}
}
this.enemyShootTimer -= dtMs;
if (this.enemyShootTimer <= 0 && alive.length > 0) {
const shooter = alive[Math.floor(Math.random() * alive.length)];
this.enemyBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
this.enemyShootTimer = Math.max(250, 900 - this.wave * 60);
}
// пуля игрока vs враги
for (const bullet of this.bullets) {
for (const e of alive) {
if (!e.alive) continue;
if (rectsOverlap(bullet, e)) {
e.alive = false;
bullet.hit = true;
this.score += e.points;
}
}
}
this.bullets = this.bullets.filter((b) => !b.hit);
// пуля врага vs игрок
const playerRect = { x: this.player.x, y: this.player.y, w: PLAYER_W, h: PLAYER_H };
for (const b of this.enemyBullets) {
if (rectsOverlap(b, playerRect)) {
b.hit = true;
this.lives -= 1;
this.hitFlash = 200;
if (this.lives <= 0) this.over = true;
}
}
this.enemyBullets = this.enemyBullets.filter((b) => !b.hit);
if (this.hitFlash > 0) this.hitFlash -= dtMs;
}
draw() {
const ctx = this.ctx;
ctx.fillStyle = '#0a0e0c';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
for (const e of this.enemies) {
if (!e.alive) continue;
ctx.fillStyle = '#39e075';
ctx.fillRect(e.x, e.y, e.w, e.h);
ctx.fillStyle = '#0a0e0c';
ctx.fillRect(e.x + 4, e.y + 4, 4, 4);
ctx.fillRect(e.x + e.w - 8, e.y + 4, 4, 4);
}
ctx.fillStyle = this.hitFlash > 0 ? '#ffffff' : '#00ff41';
ctx.fillRect(this.player.x, this.player.y, PLAYER_W, PLAYER_H);
ctx.fillRect(this.player.x + PLAYER_W / 2 - 3, this.player.y - 6, 6, 6);
ctx.fillStyle = '#c7c7c7';
for (const b of this.bullets) ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.fillStyle = '#9fa3a0';
for (const b of this.enemyBullets) ctx.fillRect(b.x, b.y, b.w, b.h);
}
}
let game = null;
let rafId = null;
function updateStats() {
document.getElementById('arcade-score').textContent = game.score;
document.getElementById('arcade-lives').textContent = game.lives;
document.getElementById('arcade-wave').textContent = game.wave;
}
function showGameOver() {
const overlay = document.getElementById('arcade-result-overlay');
overlay.innerHTML = `
<div class="arcade-result">
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
<p>${t().finalScore(game.score)}</p>
<div class="arcade-result__actions">
<button data-action="again">${t().again}</button>
</div>
</div>`;
overlay.style.display = 'flex';
}
function loop(time) {
if (game.lastTime === undefined || game.lastTime === null) game.lastTime = time;
const dt = time - game.lastTime;
game.lastTime = time;
const wasOver = game.over;
game.tick(dt);
game.draw();
updateStats();
if (game.over && !wasOver) showGameOver();
rafId = requestAnimationFrame(loop);
}
function restart() {
const overlay = document.getElementById('arcade-result-overlay');
overlay.style.display = 'none';
overlay.innerHTML = '';
game.reset();
}
function bindKeyboard() {
window.addEventListener('keydown', (e) => {
if (!game) return;
if (game.over) {
if (e.code === 'Space' || e.code === 'Enter') restart();
return;
}
if (e.code === 'ArrowLeft') { game.input.left = true; e.preventDefault(); }
if (e.code === 'ArrowRight') { game.input.right = true; e.preventDefault(); }
if (e.code === 'Space') { game.input.fire = true; e.preventDefault(); }
});
window.addEventListener('keyup', (e) => {
if (!game) return;
if (e.code === 'ArrowLeft') game.input.left = false;
if (e.code === 'ArrowRight') game.input.right = false;
if (e.code === 'Space') game.input.fire = false;
});
}
function bindTouchControls(root) {
const press = (key, value) => {
if (game) game.input[key] = value;
};
root.querySelectorAll('[data-input]').forEach((btn) => {
const key = btn.dataset.input;
btn.addEventListener('mousedown', () => press(key, true));
btn.addEventListener('mouseup', () => press(key, false));
btn.addEventListener('mouseleave', () => press(key, false));
btn.addEventListener('touchstart', (e) => { e.preventDefault(); press(key, true); });
btn.addEventListener('touchend', (e) => { e.preventDefault(); press(key, false); });
});
}
function renderShell(root) {
root.innerHTML = `
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/blocks">./blocks</a></p>
<div class="arcade-layout">
<div class="arcade-canvas-slot">
<canvas id="arcade-canvas"></canvas>
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
</div>
<div class="arcade-controls">
<div class="arcade-stats">
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
<div><span id="arcade-lives-label">${t().lives.toUpperCase()}</span> <b id="arcade-lives">3</b></div>
<div><span id="arcade-wave-label">${t().wave.toUpperCase()}</span> <b id="arcade-wave">1</b></div>
</div>
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
<div class="arcade-lr">
<button data-input="left">&larr;</button>
<button data-input="right">&rarr;</button>
</div>
<button class="arcade-action-btn" data-input="fire" id="arcade-fire-btn">${t().fire}</button>
</div>
</div>`;
}
function updateStaticText() {
const byId = (id) => document.getElementById(id);
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
if (byId('arcade-lives-label')) byId('arcade-lives-label').textContent = t().lives.toUpperCase();
if (byId('arcade-wave-label')) byId('arcade-wave-label').textContent = t().wave.toUpperCase();
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
if (byId('arcade-fire-btn')) byId('arcade-fire-btn').textContent = t().fire;
if (game?.over) showGameOver();
}
function init() {
const root = document.getElementById('arcade-root');
renderShell(root);
const canvas = document.getElementById('arcade-canvas');
game = new InvadersGame(canvas);
bindTouchControls(root.querySelector('.arcade-controls'));
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
if (e.target.closest('[data-action="again"]')) restart();
});
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
}
bindKeyboard();
init();
initLangToggle(updateStaticText);
+16
View File
@@ -13,6 +13,14 @@ const DATA = {
url: 'https://git.cactoz.su/cacto/home_automatization',
linkLabel: 'открыть',
},
{
name: 'home_automatization_controllers',
description:
'Прошивка ESP32 для зоны «Гроубокс»: датчик DHT22 и три реле-актуатора (свет, полив, вентиляция), общается с платформой по MQTT.',
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
linkLabel: 'открыть',
},
{
name: 'cactoz.su',
description:
@@ -35,6 +43,14 @@ const DATA = {
url: 'https://git.cactoz.su/cacto/home_automatization',
linkLabel: 'open',
},
{
name: 'home_automatization_controllers',
description:
'ESP32 firmware for the grow-box zone: a DHT22 sensor and three relay actuators (light, watering, ventilation), talking to the platform over MQTT.',
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
linkLabel: 'open',
},
{
name: 'cactoz.su',
description:
+2 -2
View File
@@ -81,7 +81,7 @@ const DATA = {
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
resumeLabel: 'скачать резюме (PDF)',
giteaLabel: 'git.cactoz.su/cacto',
footerHtml: 'P.S. есть ещё <a href="/game">./tanks</a> — мультиплеерная мини-игра, если будет пара свободных минут.',
footerHtml: 'P.S. есть ещё пара мини-игр — <a href="/game">./tanks</a> (мультиплеер), <a href="/blocks">./blocks</a> и <a href="/invaders">./invaders</a>, если будет пара свободных минут.',
},
en: {
name: 'Dmitry Gammel',
@@ -163,7 +163,7 @@ const DATA = {
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
resumeLabel: 'download résumé (PDF)',
giteaLabel: 'git.cactoz.su/cacto',
footerHtml: 'P.S. there\'s also <a href="/game">./tanks</a> — a small multiplayer game, if you have a couple of minutes.',
footerHtml: 'P.S. there are also a couple of mini-games — <a href="/game">./tanks</a> (multiplayer), <a href="/blocks">./blocks</a> and <a href="/invaders">./invaders</a>, if you have a couple of minutes.',
},
};
+15 -3
View File
@@ -7,9 +7,7 @@
<meta name="description" content="Проекты Гаммеля Дмитрия: home automation platform и этот сайт." />
<link rel="canonical" href="https://cactoz.su/projects" />
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/projects.css" />
</head>
@@ -59,6 +57,20 @@
<li class="tag">MQTT</li>
</ul>
</li>
<li class="project-card">
<div class="project-card__header">
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> home_automatization_controllers</div>
<a href="https://git.cactoz.su/cacto/home_automatization_controllers" target="_blank" rel="noopener">открыть →</a>
</div>
<p class="project-card__desc">Прошивка ESP32 для зоны «Гроубокс»: датчик DHT22 и три реле-актуатора (свет, полив, вентиляция), общается с платформой по MQTT.</p>
<ul class="tag-list">
<li class="tag">C++</li>
<li class="tag">ESP32</li>
<li class="tag">Arduino</li>
<li class="tag">MQTT</li>
<li class="tag">DHT22</li>
</ul>
</li>
<li class="project-card">
<div class="project-card__header">
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> cactoz.su</div>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -1,5 +1,6 @@
#include "Lobby.hpp"
#include "Game.hpp"
#include "Log.hpp"
#include <algorithm>
#include <iomanip>
@@ -74,6 +75,10 @@ void Lobby::onOpen(WS *ws) {
}
void Lobby::onClose(WS *ws) {
if (!ws->getUserData()->room_id.empty()) {
logLine("connection closed in-room: " + ws->getUserData()->room_id + " " +
ws->getUserData()->nickname);
}
endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws);
sockets_.erase(ws);
@@ -107,6 +112,8 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
handleGameReady(ws);
} else if (type == "game.input") {
handleGameInput(ws, payload);
} else if (type == "ping") {
send(ws, "pong", {});
} else {
sendError(ws, "unknown_type", "unknown message type: " + type);
}
@@ -186,6 +193,8 @@ void Lobby::handleCreateRoom(WS *ws, const json &payload) {
std::string room_id = room.id;
rooms_[room_id] = std::move(room);
logLine("room created: " + room_id + " mode=" + mode + " by=" + data->nickname);
send(ws, "lobby.room_joined", {
{"room_id", room_id},
{"name", room_id},
@@ -227,6 +236,9 @@ void Lobby::handleJoinRoom(WS *ws, const json &payload) {
room.players.push_back({data->player_id, data->nickname, ws});
data->room_id = room_id;
logLine("player joined: " + room_id + " " + data->nickname + " (" +
std::to_string(room.players.size()) + "/" + std::to_string(kMaxPlayersPerRoom) + ")");
send(ws, "lobby.room_joined", {
{"room_id", room_id},
{"name", room_id},
@@ -243,6 +255,7 @@ void Lobby::handleLeaveRoom(WS *ws) {
sendError(ws, "not_in_room", "you are not in a room");
return;
}
logLine("player left: " + ws->getUserData()->room_id + " " + ws->getUserData()->nickname);
endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws);
broadcastRoomsToLobby();
@@ -301,7 +314,11 @@ void Lobby::maybeStartGame(const std::string &room_id) {
room.state = "playing";
logLine("game started: " + room_id + " mode=" + room.mode + " players=" +
std::to_string(room.players.size()));
auto game = std::make_unique<Game>(room, [this, room_id]() {
logLine("game finished: " + room_id);
auto rit = rooms_.find(room_id);
if (rit != rooms_.end()) {
rit->second.state = "waiting";
@@ -358,6 +375,7 @@ void Lobby::removePlayerFromRoom(WS *ws) {
room.players.end());
if (room.players.empty()) {
logLine("room removed: " + it->first + " (empty)");
rooms_.erase(it);
} else {
broadcastRoomUpdated(room);
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <ctime>
#include <iostream>
#include <string>
// События лобби/игр пишутся в stdout — их забирает `docker logs`, это единственный
// способ понять в проде, что происходило с комнатами, не заходя в контейнер.
inline void logLine(const std::string &message) {
std::time_t now = std::time(nullptr);
std::tm tm{};
localtime_r(&now, &tm);
char ts[20];
std::strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm);
// endl, а не "\n": stdout в контейнере идёт в пайп и полностью
// буферизуется — без сброса docker logs видит строки только на exit
std::cout << "[" << ts << "] " << message << std::endl;
}
+12 -1
View File
@@ -4,12 +4,23 @@
#include <iostream>
#include "Lobby.hpp"
#include "Log.hpp"
int main() {
Lobby lobby;
uWS::App()
.ws<PerSocketData>("/ws", {
// сообщения у нас мелкие (input/lobby) — 16 МиБ дефолта не нужен,
// лимит режет попытки завалить сервер гигантскими фреймами
.compression = uWS::DISABLED,
.maxPayloadLength = 8 * 1024,
// сервер сам шлёт WS-ping в пределах idleTimeout; клиент дополнительно
// шлёт application-ping раз в 25с — соединение переживает NAT-таймауты
.idleTimeout = 300,
.maxBackpressure = 16 * 1024,
.closeOnBackpressureLimit = true,
.sendPingsAutomatically = true,
.open = [&lobby](auto *ws) {
lobby.onOpen(ws);
},
@@ -22,7 +33,7 @@ int main() {
})
.listen(9001, [](auto *token) {
if (token) {
std::cout << "gameserver listening on :9001\n";
logLine("gameserver listening on :9001");
} else {
std::cerr << "failed to listen on :9001\n";
}
+14 -1
View File
@@ -13,10 +13,16 @@ server {
# не светим точную версию nginx в заголовке Server и в error-страницах
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_comp_level 5;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://cactoz.su wss://www.cactoz.su; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; base-uri 'none'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://cactoz.su wss://www.cactoz.su; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'" always;
location / {
try_files $uri $uri.html $uri/ =404;
@@ -28,6 +34,13 @@ server {
try_files $uri =404;
}
# статичные ассеты меняются редко (лого, og-картинка, CV) — час кэша
# компромисс между скоростью и актуальностью без хешей в именах
location /assets/ {
add_header Cache-Control "public, max-age=3600";
try_files $uri =404;
}
location /ws {
limit_req zone=ws_connect burst=10 nodelay;
limit_conn ws_conn 20;