Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47391b6db | ||
|
|
6746d89233 | ||
|
|
6ce29bb986 | ||
|
|
bb194ad33f |
@@ -29,4 +29,27 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker compose -f docker-compose.prod.yml build
|
docker compose -f docker-compose.prod.yml build
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
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
|
||||||
|
|||||||
@@ -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 — их
|
||||||
|
конфиги не входят в этот репозиторий и бэкапятся отдельно.
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# ТЗ: cactoz.su
|
# ТЗ: cactoz.su
|
||||||
|
|
||||||
|
> ⚠️ Это ТЗ исходного замысла (MVP). Реальность разошлась: появились мини-игры
|
||||||
|
> `./blocks` и `./invaders`, самостоятельная страница `./tanks` со своим JS,
|
||||||
|
> шрифты self-hosted, healthcheck'и в compose, структура `gameserver/src` компактнее
|
||||||
|
> описанной. Актуальная архитектура деплоя и runbook — в [README.md](README.md).
|
||||||
|
|
||||||
## 1. Общее
|
## 1. Общее
|
||||||
|
|
||||||
Личный сайт-визитка на домене `cactoz.su`. Три страницы, единая Matrix-стилистика.
|
Личный сайт-визитка на домене `cactoz.su`. Три страницы, единая Matrix-стилистика.
|
||||||
|
|||||||
@@ -10,12 +10,54 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- gameserver
|
- gameserver
|
||||||
restart: unless-stopped
|
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:
|
gameserver:
|
||||||
build: ./gameserver
|
build: ./gameserver
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
restart: unless-stopped
|
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:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
.arcade-crosslinks {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.lobby-panel {
|
.lobby-panel {
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
background: var(--color-bg-raised);
|
background: var(--color-bg-raised);
|
||||||
|
|||||||
+2
-3
@@ -5,9 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>./tanks — cacto</title>
|
<title>./tanks — cacto</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="stylesheet" href="/css/fonts.css" />
|
||||||
<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/theme.css" />
|
<link rel="stylesheet" href="/css/theme.css" />
|
||||||
<link rel="stylesheet" href="/css/game.css" />
|
<link rel="stylesheet" href="/css/game.css" />
|
||||||
</head>
|
</head>
|
||||||
@@ -31,6 +29,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<main id="game-root">
|
<main id="game-root">
|
||||||
<h2 class="prompt">./tanks</h2>
|
<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="lobby"></div>
|
||||||
<div id="tanks-canvas-container" style="display: none"></div>
|
<div id="tanks-canvas-container" style="display: none"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+2
-4
@@ -13,9 +13,7 @@
|
|||||||
<meta property="og:image" content="https://cactoz.su/assets/og.png" />
|
<meta property="og:image" content="https://cactoz.su/assets/og.png" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="stylesheet" href="/css/fonts.css" />
|
||||||
<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/theme.css" />
|
<link rel="stylesheet" href="/css/theme.css" />
|
||||||
<link rel="stylesheet" href="/css/resume.css" />
|
<link rel="stylesheet" href="/css/resume.css" />
|
||||||
</head>
|
</head>
|
||||||
@@ -221,7 +219,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer id="site-footer" class="site-footer">
|
<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>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">↑</button>
|
||||||
|
<button class="arcade-dpad__left" data-input="left">←</button>
|
||||||
|
<button class="arcade-dpad__right" data-input="right">→</button>
|
||||||
|
<button class="arcade-dpad__down" data-input="down">↓</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);
|
||||||
@@ -23,6 +23,7 @@ const STR = {
|
|||||||
room: 'комната',
|
room: 'комната',
|
||||||
mode: 'режим:',
|
mode: 'режим:',
|
||||||
leave: 'выйти в лобби',
|
leave: 'выйти в лобби',
|
||||||
|
crosslinks: 'ещё игры:',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
connecting: 'connecting...',
|
connecting: 'connecting...',
|
||||||
@@ -38,6 +39,7 @@ const STR = {
|
|||||||
room: 'room',
|
room: 'room',
|
||||||
mode: 'mode:',
|
mode: 'mode:',
|
||||||
leave: 'leave to lobby',
|
leave: 'leave to lobby',
|
||||||
|
crosslinks: 'more games:',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,6 +129,8 @@ function renderRoomView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
const crosslinksLabel = document.getElementById('game-crosslinks-label');
|
||||||
|
if (crosslinksLabel) crosslinksLabel.textContent = t().crosslinks;
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
renderRoomView();
|
renderRoomView();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ export class Network {
|
|||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
this._emit(msg.type, msg.payload);
|
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) {
|
on(type, handler) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Типы сообщений из TZ.md, раздел 5.
|
// Типы сообщений из TZ.md, раздел 5.
|
||||||
export const ClientMessage = {
|
export const ClientMessage = {
|
||||||
HELLO: 'hello',
|
HELLO: 'hello',
|
||||||
|
PING: 'ping',
|
||||||
LOBBY_LIST_ROOMS: 'lobby.list_rooms',
|
LOBBY_LIST_ROOMS: 'lobby.list_rooms',
|
||||||
LOBBY_CREATE_ROOM: 'lobby.create_room',
|
LOBBY_CREATE_ROOM: 'lobby.create_room',
|
||||||
LOBBY_JOIN_ROOM: 'lobby.join_room',
|
LOBBY_JOIN_ROOM: 'lobby.join_room',
|
||||||
@@ -11,6 +12,7 @@ export const ClientMessage = {
|
|||||||
|
|
||||||
export const ServerMessage = {
|
export const ServerMessage = {
|
||||||
HELLO_ACK: 'hello.ack',
|
HELLO_ACK: 'hello.ack',
|
||||||
|
PONG: 'pong',
|
||||||
ERROR: 'error',
|
ERROR: 'error',
|
||||||
LOBBY_ROOMS: 'lobby.rooms',
|
LOBBY_ROOMS: 'lobby.rooms',
|
||||||
LOBBY_ROOM_JOINED: 'lobby.room_joined',
|
LOBBY_ROOM_JOINED: 'lobby.room_joined',
|
||||||
|
|||||||
@@ -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">←</button>
|
||||||
|
<button data-input="right">→</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);
|
||||||
@@ -81,7 +81,7 @@ const DATA = {
|
|||||||
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
||||||
resumeLabel: 'скачать резюме (PDF)',
|
resumeLabel: 'скачать резюме (PDF)',
|
||||||
giteaLabel: 'git.cactoz.su/cacto',
|
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: {
|
en: {
|
||||||
name: 'Dmitry Gammel',
|
name: 'Dmitry Gammel',
|
||||||
@@ -163,7 +163,7 @@ const DATA = {
|
|||||||
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
||||||
resumeLabel: 'download résumé (PDF)',
|
resumeLabel: 'download résumé (PDF)',
|
||||||
giteaLabel: 'git.cactoz.su/cacto',
|
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.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
<meta name="description" content="Проекты Гаммеля Дмитрия: home automation platform и этот сайт." />
|
<meta name="description" content="Проекты Гаммеля Дмитрия: home automation platform и этот сайт." />
|
||||||
<link rel="canonical" href="https://cactoz.su/projects" />
|
<link rel="canonical" href="https://cactoz.su/projects" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="stylesheet" href="/css/fonts.css" />
|
||||||
<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/theme.css" />
|
<link rel="stylesheet" href="/css/theme.css" />
|
||||||
<link rel="stylesheet" href="/css/projects.css" />
|
<link rel="stylesheet" href="/css/projects.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
#include "Lobby.hpp"
|
#include "Lobby.hpp"
|
||||||
#include "Game.hpp"
|
#include "Game.hpp"
|
||||||
|
#include "Log.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
@@ -74,6 +75,10 @@ void Lobby::onOpen(WS *ws) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Lobby::onClose(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);
|
endActiveGameIfAny(ws->getUserData()->room_id, ws);
|
||||||
removePlayerFromRoom(ws);
|
removePlayerFromRoom(ws);
|
||||||
sockets_.erase(ws);
|
sockets_.erase(ws);
|
||||||
@@ -107,6 +112,8 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
|
|||||||
handleGameReady(ws);
|
handleGameReady(ws);
|
||||||
} else if (type == "game.input") {
|
} else if (type == "game.input") {
|
||||||
handleGameInput(ws, payload);
|
handleGameInput(ws, payload);
|
||||||
|
} else if (type == "ping") {
|
||||||
|
send(ws, "pong", {});
|
||||||
} else {
|
} else {
|
||||||
sendError(ws, "unknown_type", "unknown message type: " + type);
|
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;
|
std::string room_id = room.id;
|
||||||
rooms_[room_id] = std::move(room);
|
rooms_[room_id] = std::move(room);
|
||||||
|
|
||||||
|
logLine("room created: " + room_id + " mode=" + mode + " by=" + data->nickname);
|
||||||
|
|
||||||
send(ws, "lobby.room_joined", {
|
send(ws, "lobby.room_joined", {
|
||||||
{"room_id", room_id},
|
{"room_id", room_id},
|
||||||
{"name", 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});
|
room.players.push_back({data->player_id, data->nickname, ws});
|
||||||
data->room_id = room_id;
|
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", {
|
send(ws, "lobby.room_joined", {
|
||||||
{"room_id", room_id},
|
{"room_id", room_id},
|
||||||
{"name", 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");
|
sendError(ws, "not_in_room", "you are not in a room");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logLine("player left: " + ws->getUserData()->room_id + " " + ws->getUserData()->nickname);
|
||||||
endActiveGameIfAny(ws->getUserData()->room_id, ws);
|
endActiveGameIfAny(ws->getUserData()->room_id, ws);
|
||||||
removePlayerFromRoom(ws);
|
removePlayerFromRoom(ws);
|
||||||
broadcastRoomsToLobby();
|
broadcastRoomsToLobby();
|
||||||
@@ -301,7 +314,11 @@ void Lobby::maybeStartGame(const std::string &room_id) {
|
|||||||
|
|
||||||
room.state = "playing";
|
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]() {
|
auto game = std::make_unique<Game>(room, [this, room_id]() {
|
||||||
|
logLine("game finished: " + room_id);
|
||||||
auto rit = rooms_.find(room_id);
|
auto rit = rooms_.find(room_id);
|
||||||
if (rit != rooms_.end()) {
|
if (rit != rooms_.end()) {
|
||||||
rit->second.state = "waiting";
|
rit->second.state = "waiting";
|
||||||
@@ -358,6 +375,7 @@ void Lobby::removePlayerFromRoom(WS *ws) {
|
|||||||
room.players.end());
|
room.players.end());
|
||||||
|
|
||||||
if (room.players.empty()) {
|
if (room.players.empty()) {
|
||||||
|
logLine("room removed: " + it->first + " (empty)");
|
||||||
rooms_.erase(it);
|
rooms_.erase(it);
|
||||||
} else {
|
} else {
|
||||||
broadcastRoomUpdated(room);
|
broadcastRoomUpdated(room);
|
||||||
|
|||||||
@@ -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
@@ -4,12 +4,23 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
||||||
#include "Lobby.hpp"
|
#include "Lobby.hpp"
|
||||||
|
#include "Log.hpp"
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
Lobby lobby;
|
Lobby lobby;
|
||||||
|
|
||||||
uWS::App()
|
uWS::App()
|
||||||
.ws<PerSocketData>("/ws", {
|
.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) {
|
.open = [&lobby](auto *ws) {
|
||||||
lobby.onOpen(ws);
|
lobby.onOpen(ws);
|
||||||
},
|
},
|
||||||
@@ -22,7 +33,7 @@ int main() {
|
|||||||
})
|
})
|
||||||
.listen(9001, [](auto *token) {
|
.listen(9001, [](auto *token) {
|
||||||
if (token) {
|
if (token) {
|
||||||
std::cout << "gameserver listening on :9001\n";
|
logLine("gameserver listening on :9001");
|
||||||
} else {
|
} else {
|
||||||
std::cerr << "failed to listen on :9001\n";
|
std::cerr << "failed to listen on :9001\n";
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -13,10 +13,16 @@ server {
|
|||||||
# не светим точную версию nginx в заголовке Server и в error-страницах
|
# не светим точную версию nginx в заголовке Server и в error-страницах
|
||||||
server_tokens off;
|
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-Content-Type-Options nosniff always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header Referrer-Policy strict-origin-when-cross-origin 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 / {
|
location / {
|
||||||
try_files $uri $uri.html $uri/ =404;
|
try_files $uri $uri.html $uri/ =404;
|
||||||
@@ -28,6 +34,13 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# статичные ассеты меняются редко (лого, og-картинка, CV) — час кэша
|
||||||
|
# компромисс между скоростью и актуальностью без хешей в именах
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=3600";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location /ws {
|
location /ws {
|
||||||
limit_req zone=ws_connect burst=10 nodelay;
|
limit_req zone=ws_connect burst=10 nodelay;
|
||||||
limit_conn ws_conn 20;
|
limit_conn ws_conn 20;
|
||||||
|
|||||||
Reference in New Issue
Block a user