diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..2c5541e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Runner уже на cacto-homelab и имеет доступ к docker.sock хоста, + # поэтому образ собирается прямо на месте — без registry и без SSH. + - name: Sync to deploy directory + run: | + mkdir -p /data/docker/compose/cactoz + rsync -a --delete --exclude '.git' ./ /data/docker/compose/cactoz/ + + - name: Build and restart + working-directory: /data/docker/compose/cactoz + run: | + docker compose -f docker-compose.prod.yml build + docker compose -f docker-compose.prod.yml up -d + docker image prune -f diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..43233e8 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,24 @@ +services: + nginx: + container_name: cactoz-nginx + build: + context: . + dockerfile: nginx/Dockerfile + networks: + - internal + - proxy + depends_on: + - gameserver + restart: unless-stopped + + gameserver: + build: ./gameserver + networks: + - internal + restart: unless-stopped + +networks: + internal: + # уже создана Caddy-стеком; сюда только подключаемся + proxy: + external: true diff --git a/frontend/assets/cactus-logo.svg b/frontend/assets/cactus-logo.svg new file mode 100644 index 0000000..efb6fd1 --- /dev/null +++ b/frontend/assets/cactus-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/css/game.css b/frontend/css/game.css index 4e54213..b58023e 100644 --- a/frontend/css/game.css +++ b/frontend/css/game.css @@ -1 +1,90 @@ -/* TODO: лобби и игровое поле — этапы 3-6 */ +.lobby-panel { + border: var(--border); + background: var(--color-bg-raised); + padding: 1.25rem 1.5rem; + margin: 1.5rem 0; + max-width: 640px; +} + +.lobby-panel__row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.prompt-inline { + color: var(--color-accent-dim); +} + +.lobby-panel input { + background: var(--color-bg); + border: 1px solid var(--color-accent-dim); + color: var(--color-fg); + font-family: var(--font-mono); + padding: 0.3rem 0.5rem; +} + +.lobby-panel button { + background: transparent; + border: 1px solid var(--color-accent-dim); + color: var(--color-accent); + font-family: var(--font-mono); + padding: 0.35rem 0.75rem; + cursor: pointer; +} + +.lobby-panel button:hover:not(:disabled) { + border-color: var(--color-accent); + box-shadow: 0 0 6px var(--color-accent-dim); +} + +.lobby-panel button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.lobby-error { + color: var(--color-silver); + border-left: 2px solid var(--color-accent-dim); + padding-left: 0.6rem; + margin: 0 0 1rem; +} + +.room-list, +.room-players { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.room-list__item { + display: flex; + align-items: center; + gap: 0.75rem; + border-bottom: var(--border); + padding-bottom: 0.5rem; +} + +.room-list__name { + flex: 1; +} + +.room-list__count, +.room-list__state { + color: var(--color-silver); + font-size: 0.85rem; +} + +.room-list__empty { + color: var(--color-silver); +} + +.room-players li::before { + content: '- '; + color: var(--color-accent-dim); +} diff --git a/frontend/css/projects.css b/frontend/css/projects.css index 56b3df1..1630e1e 100644 --- a/frontend/css/projects.css +++ b/frontend/css/projects.css @@ -1 +1,63 @@ -/* TODO: карточки проектов — этап 2 */ +.project-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.project-card { + border: var(--border); + background: var(--color-bg-raised); + display: flex; + flex-direction: column; +} + +.project-card__bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: var(--border); + color: var(--color-silver); + font-size: 0.8rem; +} + +.project-card__bar span { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--color-accent-dim); + opacity: 0.6; + flex-shrink: 0; +} + +.project-card__body { + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + flex: 1; +} + +.project-card__title { + margin: 0; + color: var(--color-accent); + font-size: 1.05rem; +} + +.project-card__desc { + margin: 0; + color: var(--color-fg); + font-size: 0.9rem; +} + +.project-card__link { + margin-top: auto; +} + +.project-card__link::before { + content: '$ '; + color: var(--color-accent-dim); +} diff --git a/frontend/css/resume.css b/frontend/css/resume.css index 076bfc4..5715efc 100644 --- a/frontend/css/resume.css +++ b/frontend/css/resume.css @@ -1 +1,101 @@ -/* TODO: вёрстка резюме — этап 2 */ +.hero { + display: flex; + align-items: center; + gap: 2.5rem; + padding: 2.5rem 0; + flex-wrap: wrap; +} + +.hero-text h1 { + margin: 0 0 0.4rem; + font-size: 1.8rem; +} + +.hero-text .role { + margin: 0; + color: var(--color-accent); +} + +.hero-text .tagline { + margin: 0.75rem 0 0; + color: var(--color-silver); + max-width: 34ch; +} + +section { + margin: 3rem 0; +} + +section > h2 { + font-size: 1rem; + font-weight: 400; + margin-bottom: 1.25rem; +} + +.about p { + max-width: 68ch; +} + +.timeline { + border-left: var(--border); + margin: 0; + padding: 0 0 0 1.5rem; + list-style: none; +} + +.timeline-item { + position: relative; + margin-bottom: 2rem; +} + +.timeline-item::before { + content: ''; + position: absolute; + left: -1.6rem; + top: 0.4rem; + width: 8px; + height: 8px; + background: var(--color-accent); + box-shadow: 0 0 6px var(--color-accent-dim); +} + +.timeline-item__meta { + color: var(--color-silver); + font-size: 0.85rem; +} + +.timeline-item__role { + color: var(--color-accent); + margin: 0.15rem 0 0.5rem; + font-size: 1rem; +} + +.timeline-item ul { + margin: 0; + padding-left: 1.2rem; +} + +.timeline-item li::marker { + color: var(--color-accent-dim); +} + +.contact-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.contact-list a::before { + content: '$ '; + color: var(--color-accent-dim); +} + +@media (max-width: 640px) { + .hero { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/css/theme.css b/frontend/css/theme.css index 60895b6..6c6207b 100644 --- a/frontend/css/theme.css +++ b/frontend/css/theme.css @@ -1,10 +1,12 @@ :root { --color-bg: #0a0e0c; + --color-bg-raised: #0f1512; --color-fg: #c7c7c7; --color-accent: #00ff41; --color-accent-dim: #0a8f2c; --color-silver: #9fa3a0; --font-mono: 'JetBrains Mono', 'Share Tech Mono', ui-monospace, monospace; + --border: 1px solid #163020; } * { @@ -13,26 +15,196 @@ html, body { margin: 0; - height: 100%; + min-height: 100%; } body { background: var(--color-bg); color: var(--color-fg); font-family: var(--font-mono); + line-height: 1.5; +} + +::selection { + background: var(--color-accent-dim); + color: var(--color-bg); } a { color: var(--color-accent); + text-decoration: none; } +a:hover { + text-decoration: underline; +} + +a:focus-visible, +button:focus-visible { + outline: 1px solid var(--color-accent); + outline-offset: 2px; +} + +.container { + max-width: 860px; + margin: 0 auto; + padding: 2.5rem 1.5rem 4rem; +} + +/* --- фон: canvas цифрового дождя --- */ +#matrix-rain { + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; +} + +/* лёгкие сканлайны поверх дождя, под контентом */ +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, 0.15) 0px, + rgba(0, 0, 0, 0.15) 1px, + transparent 1px, + transparent 3px + ); +} + +/* --- шапка --- */ +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-bottom: 1.5rem; + border-bottom: var(--border); + flex-wrap: wrap; +} + +.logo { + display: flex; + align-items: center; + gap: 0.6rem; + color: var(--color-accent); + font-weight: 600; + letter-spacing: 0.02em; +} + +.logo svg { + width: 20px; + height: 20px; + filter: drop-shadow(0 0 4px var(--color-accent-dim)); +} + +.nav-links { + display: flex; + gap: 1.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.nav-links a { + color: var(--color-fg); +} + +.nav-links a:hover, +.nav-links a[aria-current='page'] { + color: var(--color-accent); +} + +/* --- терминальный префикс перед заголовками --- */ .prompt::before { content: 'cacto@matrix:~$ '; color: var(--color-accent-dim); } -#matrix-rain { - position: fixed; +/* --- терминальное окно-обёртка для блоков контента --- */ +.term-window { + border: var(--border); + background: var(--color-bg-raised); + margin: 1.75rem 0; +} + +.term-window__bar { + display: flex; + gap: 0.4rem; + padding: 0.5rem 0.75rem; + border-bottom: var(--border); +} + +.term-window__bar span { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--color-accent-dim); + opacity: 0.6; +} + +.term-window__body { + padding: 1.25rem 1.5rem; +} + +/* --- ASCII-арт --- */ +.ascii-cactus { + color: var(--color-accent); + font-size: 0.85rem; + line-height: 1.15; + margin: 0; + text-shadow: 0 0 6px var(--color-accent-dim); +} + +/* --- glitch-эффект для акцентного текста --- */ +.glitch { + position: relative; + display: inline-block; +} + +.glitch::before, +.glitch::after { + content: attr(data-text); + position: absolute; inset: 0; - z-index: -1; + overflow: hidden; + opacity: 0.7; +} + +.glitch::before { + color: var(--color-silver); + clip-path: inset(0 0 55% 0); + animation: glitch-shift 3.4s infinite steps(2); +} + +.glitch::after { + color: var(--color-accent-dim); + clip-path: inset(55% 0 0 0); + animation: glitch-shift 2.6s infinite steps(2) reverse; +} + +@keyframes glitch-shift { + 0%, 92%, 100% { transform: translate(0, 0); } + 94% { transform: translate(-2px, 1px); } + 96% { transform: translate(2px, -1px); } +} + +/* --- теги (скиллы, стек проекта) --- */ +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.tag { + border: 1px solid var(--color-accent-dim); + color: var(--color-accent); + padding: 0.15rem 0.6rem; + font-size: 0.8rem; } diff --git a/frontend/game.html b/frontend/game.html index 98aaaaa..5c55e48 100644 --- a/frontend/game.html +++ b/frontend/game.html @@ -4,16 +4,35 @@ ./battlecity — cacto + -
- -

./battlecity

-
-
-
+ + +
+ + +
+ +

./battlecity

+
+
+
+
+ + diff --git a/frontend/index.html b/frontend/index.html index 30cc291..f9b4488 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,21 +3,136 @@ - cacto — Гаммель Дмитрий + cacto — Гаммель Дмитрий, PHP/Go backend + + -
- -

whoami

-

Гаммель Дмитрий Викторович — PHP/Go backend, опыт КИПиА/АСУТП

- -
+ +
+ + +
+
+ +
+

Гаммель Дмитрий

+

PHP / Go backend-разработчик

+

Живучий, колючий, не требует много воды. Специализация — сложные запросы, legacy-код, архитектура.

+
+
+ +
+

cat about.txt

+
+
+
+

+ Backend-разработчик с 6-летним коммерческим опытом на PHP: сложные запросы к базам данных, + работа с legacy-кодом, архитектурные решения. Спроектировал аналитический контур на ClickHouse, + ускоривший формирование отчётности в 15–20 раз; неофициально возглавлял разработку модуля + документооборота в банковской IT-инфраструктуре. +

+

+ Начинал с электротехники и АСУТП — писал управляющий софт для автоматизированных систем. + Путь «от электрического сигнала до строчки кода» помогает быстро разбираться в сложных + legacy-системах и нестандартных задачах. +

+

+ Сейчас изучаю Go и продолжаю искать позицию Middle+/Senior backend-разработчика. +

+
+
+
+ +
+

ls -la experience/

+
    +
  1. +
    июль 2022 — март 2026 · Екатеринбург
    +

    PHP-разработчик — ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк»)

    +
      +
    • Поддерживал legacy на Zend Framework, участвовал в переходе на Symfony; PHP 7.4 → 8.0.
    • +
    • Спроектировал аналитический контур отчётности на ClickHouse — сократил время формирования отчётов с 3–4 минут до 10–15 секунд.
    • +
    • Интеграции с внутренними банковскими сервисами (проверка ЭП, машиночитаемые доверенности), очереди RabbitMQ.
    • +
    • Неофициально возглавлял разработку нового модуля документооборота: архитектура, код-ревью, MVP.
    • +
    +
  2. +
  3. +
    март 2021 — июль 2022 · фриланс/субподряд
    +

    PHP-разработчик — kupisever.ru

    +
      +
    • Доработка бэкенда B2B-платформы (доска объявлений) на Yii2, стек PHP/PostgreSQL/RabbitMQ/Docker.
    • +
    • Модуль email-рассылок с очередями сообщений и конструктором писем.
    • +
    • Модуль категорий объявлений — админка и клиентское меню.
    • +
    +
  4. +
  5. +
    июль 2020 — март 2021 · Курган
    +

    PHP-разработчик (с обязанностями руководителя группы) — ИстВуд

    +
      +
    • Интернет-магазины на 1С-Битрикс, интеграции с 1С и платёжными системами.
    • +
    • Фактически руководил командой из 3 разработчиков: задачи, код-ревью, технические собеседования.
    • +
    +
  6. +
+
+ +
+

skills --list

+
    +
  • PHP8
  • +
  • Symfony
  • +
  • Zend Framework
  • +
  • Yii2
  • +
  • PostgreSQL
  • +
  • MySQL
  • +
  • ClickHouse
  • +
  • RabbitMQ
  • +
  • Docker
  • +
  • REST API
  • +
  • PHPUnit
  • +
  • ООП
  • +
  • Go (изучаю)
  • +
+
+ +
+

cat contact.txt

+ +
+
+
+ diff --git a/frontend/js/game/lobby.js b/frontend/js/game/lobby.js index f90c003..043d364 100644 --- a/frontend/js/game/lobby.js +++ b/frontend/js/game/lobby.js @@ -1,21 +1,136 @@ -// TODO: полноценный UI лобби (список/создание/присоединение комнат) — этап 3. -// Пока — проверка связки клиент <-> gameserver (hello -> lobby.list_rooms). import { Network, ServerMessage } from './network.js'; -export function connectAndProbe() { - const net = new Network(); +const container = document.getElementById('lobby'); - net.on('_open', () => net.hello('cacto')); +let net; +let rooms = []; +let currentRoom = null; +let errorMessage = ''; +let connected = false; +let nickname = 'cacto-' + Math.floor(Math.random() * 1000); + +function roomRow(room) { + const full = room.players >= room.max_players; + return ` +
  • + ${room.name} + ${room.mode} + ${room.players}/${room.max_players} + ${room.state} + +
  • `; +} + +function errorBanner() { + return errorMessage ? `

    ! ${errorMessage}

    ` : ''; +} + +function renderLobbyView() { + const dis = connected ? '' : 'disabled'; + container.innerHTML = ` +
    + ${errorBanner()} + ${connected ? '' : '

    подключение...

    '} +
    + + +
    +
    + + + +
    + +
    `; +} + +function renderRoomView() { + container.innerHTML = ` +
    + ${errorBanner()} +

    room ${currentRoom.name}

    +

    режим: ${currentRoom.mode}

    + + +
    `; +} + +function render() { + if (currentRoom) { + renderRoomView(); + } else { + renderLobbyView(); + } +} + +container?.addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + errorMessage = ''; + switch (btn.dataset.action) { + case 'create': + net.createRoom(btn.dataset.mode); + break; + case 'join': + net.joinRoom(btn.dataset.roomId); + break; + case 'leave': + currentRoom = null; + net.leaveRoom(); + render(); + break; + case 'refresh': + net.listRooms(); + break; + } +}); + +container?.addEventListener('change', (e) => { + if (e.target.id === 'nickname-input') { + nickname = e.target.value.trim() || nickname; + } +}); + +export function initLobby() { + net = new Network(); + + net.on('_open', () => net.hello(nickname)); net.on(ServerMessage.HELLO_ACK, () => { - console.log('[gameserver] hello.ack received'); + connected = true; net.listRooms(); }); - net.on(ServerMessage.LOBBY_ROOMS, (payload) => { - console.log('[gameserver] rooms:', payload.rooms); - }); - net.on(ServerMessage.ERROR, (payload) => { - console.error('[gameserver] error:', payload); + net.on('_close', () => { + connected = false; + render(); }); + net.on(ServerMessage.LOBBY_ROOMS, (payload) => { + rooms = payload.rooms; + render(); + }); + + net.on(ServerMessage.LOBBY_ROOM_JOINED, (payload) => { + currentRoom = payload; + render(); + }); + + net.on(ServerMessage.LOBBY_ROOM_UPDATED, (payload) => { + if (currentRoom) { + currentRoom.players = payload.players; + render(); + } + }); + + net.on(ServerMessage.ERROR, (payload) => { + console.error('[gameserver] error:', payload); + errorMessage = payload.message; + render(); + }); + + render(); return net; } diff --git a/frontend/js/game/main.js b/frontend/js/game/main.js index 107031b..47baf7a 100644 --- a/frontend/js/game/main.js +++ b/frontend/js/game/main.js @@ -1,4 +1,4 @@ // TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5 -import { connectAndProbe } from './lobby.js'; +import { initLobby } from './lobby.js'; -connectAndProbe(); +initLobby(); diff --git a/frontend/js/game/network.js b/frontend/js/game/network.js index fed83f2..fe6784e 100644 --- a/frontend/js/game/network.js +++ b/frontend/js/game/network.js @@ -33,6 +33,18 @@ export class Network { listRooms() { this.send(ClientMessage.LOBBY_LIST_ROOMS); } + + createRoom(mode) { + this.send(ClientMessage.LOBBY_CREATE_ROOM, { mode }); + } + + joinRoom(roomId) { + this.send(ClientMessage.LOBBY_JOIN_ROOM, { room_id: roomId }); + } + + leaveRoom() { + this.send(ClientMessage.LOBBY_LEAVE_ROOM); + } } export { ServerMessage }; diff --git a/frontend/js/matrix-rain.js b/frontend/js/matrix-rain.js index 39ce165..814b197 100644 --- a/frontend/js/matrix-rain.js +++ b/frontend/js/matrix-rain.js @@ -1,11 +1,45 @@ -// TODO: полноценный canvas-эффект цифрового дождя — этап 2 const canvas = document.getElementById('matrix-rain'); -if (canvas) { + +if (canvas && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { const ctx = canvas.getContext('2d'); - const resize = () => { + const chars = 'アカサタナハマヤラワ0123456789ABCDEFcacto$#@%&*'.split(''); + const fontSize = 15; + const bg = getComputedStyle(document.documentElement).getPropertyValue('--color-bg').trim(); + const accent = getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(); + const silver = getComputedStyle(document.documentElement).getPropertyValue('--color-silver').trim(); + + let columns = 0; + let drops = []; + + function setup() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; - }; - resize(); - window.addEventListener('resize', resize); + columns = Math.floor(canvas.width / fontSize); + drops = new Array(columns).fill(0).map(() => Math.floor((Math.random() * canvas.height) / fontSize)); + } + + function draw() { + ctx.fillStyle = `${bg}cc`; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.font = `${fontSize}px monospace`; + + for (let i = 0; i < columns; i++) { + const char = chars[Math.floor(Math.random() * chars.length)]; + const x = i * fontSize; + const y = drops[i] * fontSize; + + ctx.fillStyle = Math.random() > 0.98 ? silver : accent; + ctx.fillText(char, x, y); + + if (y > canvas.height && Math.random() > 0.975) { + drops[i] = 0; + } else { + drops[i]++; + } + } + } + + setup(); + window.addEventListener('resize', setup); + setInterval(draw, 50); } diff --git a/frontend/js/projects.js b/frontend/js/projects.js index b439d30..b056dc2 100644 --- a/frontend/js/projects.js +++ b/frontend/js/projects.js @@ -1 +1,39 @@ -// TODO: рендер карточек проектов из данных — этап 2 +const projects = [ + { + name: 'home_automatization', + title: 'Home Automation Platform', + description: + 'Микросервисная платформа умного дома на Go и Laravel. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.', + tags: ['Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'], + url: 'https://git.cactoz.su/cacto/home_automatization', + }, + { + name: 'cactoz.su', + title: 'cactoz.su — этот сайт', + description: + 'Личный сайт-визитка: резюме, проекты и мультиплеерный клон BattleCity. Тема — Матрица.', + tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'], + url: 'https://git.cactoz.su/cacto/cactoz.su', + }, +]; + +const list = document.getElementById('project-list'); + +if (list) { + list.innerHTML = projects + .map( + (p) => ` +
  • +
    ${p.name}/README.md
    +
    +

    ${p.title}

    +

    ${p.description}

    + + ${p.url.replace('https://', '')} +
    +
  • ` + ) + .join(''); +} diff --git a/frontend/projects.html b/frontend/projects.html index 378c96d..01703af 100644 --- a/frontend/projects.html +++ b/frontend/projects.html @@ -4,19 +4,36 @@ ls projects/ — cacto + + -
    - -

    ls projects/

    - - -
    + + +
    + + +
    +
    +

    ls projects/

    +
      +
      +
      +
      + + diff --git a/gameserver/CMakeLists.txt b/gameserver/CMakeLists.txt index d2566eb..ae5c26d 100644 --- a/gameserver/CMakeLists.txt +++ b/gameserver/CMakeLists.txt @@ -39,6 +39,7 @@ target_compile_definitions(usockets PUBLIC LIBUS_NO_SSL LIBUS_USE_EPOLL) add_executable(gameserver src/main.cpp + src/Lobby.cpp ) target_include_directories(gameserver PRIVATE ${uwebsockets_SOURCE_DIR}/src diff --git a/gameserver/Dockerfile b/gameserver/Dockerfile index 701566d..ff3aaa9 100644 --- a/gameserver/Dockerfile +++ b/gameserver/Dockerfile @@ -3,9 +3,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential cmake git ca-certificates zlib1g-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /app + +# Кэшируемый слой: конфигурация + сборка зависимостей (uSockets, json). +# Заглушки вместо src/, чтобы cmake configure не падал на несуществующих +# исходниках — они не компилируются, т.к. просим собрать только usockets. COPY CMakeLists.txt ./ +RUN mkdir src && touch src/main.cpp src/Lobby.cpp src/Lobby.hpp +RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --target usockets -j"$(nproc)" + +# Реальный код поверх уже собранных зависимостей — пересобирается за секунды. COPY src ./src -RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$(nproc)" +RUN cmake --build build --target gameserver -j"$(nproc)" FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends zlib1g \ diff --git a/gameserver/src/Lobby.cpp b/gameserver/src/Lobby.cpp new file mode 100644 index 0000000..eb19f01 --- /dev/null +++ b/gameserver/src/Lobby.cpp @@ -0,0 +1,227 @@ +#include "Lobby.hpp" + +#include +#include +#include + +void Lobby::send(WS *ws, const std::string &type, json payload) { + json msg = {{"type", type}, {"payload", std::move(payload)}}; + ws->send(msg.dump(), uWS::OpCode::TEXT); +} + +void Lobby::sendError(WS *ws, const std::string &code, const std::string &message) { + send(ws, "error", {{"code", code}, {"message", message}}); +} + +std::string Lobby::generateRoomId() { + std::ostringstream oss; + oss << "cactus-room-" << std::setw(2) << std::setfill('0') << next_room_id_++; + return oss.str(); +} + +json Lobby::roomsSummary() const { + json rooms = json::array(); + for (const auto &[id, room] : rooms_) { + rooms.push_back({ + {"id", room.id}, + {"name", room.id}, + {"mode", room.mode}, + {"players", room.players.size()}, + {"max_players", kMaxPlayersPerRoom}, + {"state", room.state}, + }); + } + return rooms; +} + +json Lobby::roomPlayers(const Room &room) const { + json players = json::array(); + for (size_t i = 0; i < room.players.size(); i++) { + players.push_back({ + {"id", room.players[i].player_id}, + {"nickname", room.players[i].nickname}, + {"slot", i}, + }); + } + return players; +} + +void Lobby::broadcastRoomsToLobby() { + json rooms = roomsSummary(); + for (WS *client : sockets_) { + if (client->getUserData()->room_id.empty()) { + send(client, "lobby.rooms", {{"rooms", rooms}}); + } + } +} + +void Lobby::broadcastRoomUpdated(const Room &room, WS *exclude) { + json payload = {{"players", roomPlayers(room)}}; + for (const auto &p : room.players) { + if (p.ws != exclude) { + send(p.ws, "lobby.room_updated", payload); + } + } +} + +void Lobby::onOpen(WS *ws) { + sockets_.insert(ws); +} + +void Lobby::onClose(WS *ws) { + removePlayerFromRoom(ws); + sockets_.erase(ws); +} + +void Lobby::onMessage(WS *ws, std::string_view message) { + json request; + std::string type; + try { + request = json::parse(message); + type = request.value("type", ""); + } catch (const std::exception &e) { + sendError(ws, "bad_json", e.what()); + return; + } + + json payload = request.value("payload", json::object()); + + if (type == "hello") { + handleHello(ws, payload); + } else if (type == "lobby.list_rooms") { + handleListRooms(ws); + } else if (type == "lobby.create_room") { + handleCreateRoom(ws, payload); + } else if (type == "lobby.join_room") { + handleJoinRoom(ws, payload); + } else if (type == "lobby.leave_room") { + handleLeaveRoom(ws); + } else { + sendError(ws, "unknown_type", "unknown message type: " + type); + } +} + +void Lobby::handleHello(WS *ws, const json &payload) { + auto *data = ws->getUserData(); + if (data->player_id.empty()) { + data->player_id = "player-" + std::to_string(next_player_id_++); + } + data->nickname = payload.value("nickname", "anon"); + send(ws, "hello.ack", {{"player_id", data->player_id}}); +} + +void Lobby::handleListRooms(WS *ws) { + send(ws, "lobby.rooms", {{"rooms", roomsSummary()}}); +} + +void Lobby::handleCreateRoom(WS *ws, const json &payload) { + auto *data = ws->getUserData(); + if (data->player_id.empty()) { + sendError(ws, "not_authenticated", "send hello first"); + return; + } + if (!data->room_id.empty()) { + sendError(ws, "already_in_room", "leave the current room first"); + return; + } + + std::string mode = payload.value("mode", ""); + if (mode != "coop" && mode != "pvp") { + sendError(ws, "invalid_mode", "mode must be \"coop\" or \"pvp\""); + return; + } + + Room room; + room.id = generateRoomId(); + room.mode = mode; + room.players.push_back({data->player_id, data->nickname, ws}); + + data->room_id = room.id; + std::string room_id = room.id; + rooms_[room_id] = std::move(room); + + send(ws, "lobby.room_joined", { + {"room_id", room_id}, + {"name", room_id}, + {"mode", mode}, + {"players", roomPlayers(rooms_[room_id])}, + }); + + broadcastRoomsToLobby(); +} + +void Lobby::handleJoinRoom(WS *ws, const json &payload) { + auto *data = ws->getUserData(); + if (data->player_id.empty()) { + sendError(ws, "not_authenticated", "send hello first"); + return; + } + if (!data->room_id.empty()) { + sendError(ws, "already_in_room", "leave the current room first"); + return; + } + + std::string room_id = payload.value("room_id", ""); + auto it = rooms_.find(room_id); + if (it == rooms_.end()) { + sendError(ws, "room_not_found", "no such room: " + room_id); + return; + } + + Room &room = it->second; + if (room.state != "waiting") { + sendError(ws, "room_in_progress", "room is not accepting new players"); + return; + } + if (room.players.size() >= kMaxPlayersPerRoom) { + sendError(ws, "room_full", "room is full"); + return; + } + + room.players.push_back({data->player_id, data->nickname, ws}); + data->room_id = room_id; + + send(ws, "lobby.room_joined", { + {"room_id", room_id}, + {"name", room_id}, + {"mode", room.mode}, + {"players", roomPlayers(room)}, + }); + + broadcastRoomUpdated(room, ws); + broadcastRoomsToLobby(); +} + +void Lobby::handleLeaveRoom(WS *ws) { + if (ws->getUserData()->room_id.empty()) { + sendError(ws, "not_in_room", "you are not in a room"); + return; + } + removePlayerFromRoom(ws); + broadcastRoomsToLobby(); +} + +void Lobby::removePlayerFromRoom(WS *ws) { + auto *data = ws->getUserData(); + if (data->room_id.empty()) { + return; + } + + auto it = rooms_.find(data->room_id); + data->room_id.clear(); + if (it == rooms_.end()) { + return; + } + + Room &room = it->second; + room.players.erase( + std::remove_if(room.players.begin(), room.players.end(), + [ws](const RoomPlayer &p) { return p.ws == ws; }), + room.players.end()); + + if (room.players.empty()) { + rooms_.erase(it); + } else { + broadcastRoomUpdated(room); + } +} diff --git a/gameserver/src/Lobby.hpp b/gameserver/src/Lobby.hpp new file mode 100644 index 0000000..87a2fdd --- /dev/null +++ b/gameserver/src/Lobby.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +struct PerSocketData { + std::string player_id; + std::string nickname; + std::string room_id; // пусто, если игрок в лобби, а не в комнате +}; + +using WS = uWS::WebSocket; + +struct RoomPlayer { + std::string player_id; + std::string nickname; + WS *ws; +}; + +struct Room { + std::string id; + std::string mode; // "coop" | "pvp" + std::string state = "waiting"; // waiting | playing | finished + std::vector players; +}; + +// Лобби и реестр комнат (этап 3 из TZ.md). Игровая логика — этапы 4-5. +class Lobby { +public: + void onOpen(WS *ws); + void onMessage(WS *ws, std::string_view message); + void onClose(WS *ws); + +private: + static constexpr size_t kMaxPlayersPerRoom = 2; + + std::unordered_map rooms_; + std::unordered_set sockets_; + int next_player_id_ = 1; + int next_room_id_ = 1; + + void handleHello(WS *ws, const json &payload); + void handleListRooms(WS *ws); + void handleCreateRoom(WS *ws, const json &payload); + void handleJoinRoom(WS *ws, const json &payload); + void handleLeaveRoom(WS *ws); + void removePlayerFromRoom(WS *ws); + + void broadcastRoomsToLobby(); + void broadcastRoomUpdated(const Room &room, WS *exclude = nullptr); + + json roomsSummary() const; + json roomPlayers(const Room &room) const; + std::string generateRoomId(); + + static void send(WS *ws, const std::string &type, json payload); + static void sendError(WS *ws, const std::string &code, const std::string &message); +}; diff --git a/gameserver/src/main.cpp b/gameserver/src/main.cpp index c9990bb..ea65b6b 100644 --- a/gameserver/src/main.cpp +++ b/gameserver/src/main.cpp @@ -1,41 +1,23 @@ -// Каркас (этап 1 из TZ.md): проверка связки nginx <-ws-> gameserver. -// Лобби/комнаты/игровая логика — следующие этапы. +// Игровая логика (тик, коллизии) — этапы 4-5 из TZ.md. #include #include -#include -using json = nlohmann::json; - -struct PerSocketData {}; +#include "Lobby.hpp" int main() { + Lobby lobby; + uWS::App() .ws("/ws", { - .open = [](auto *ws) { - std::cout << "client connected\n"; + .open = [&lobby](auto *ws) { + lobby.onOpen(ws); }, - .message = [](auto *ws, std::string_view message, uWS::OpCode) { - json response; - try { - json request = json::parse(message); - std::string type = request.value("type", ""); - - if (type == "hello") { - response = {{"type", "hello.ack"}, {"payload", {{"player_id", "stub"}}}}; - } else if (type == "lobby.list_rooms") { - response = {{"type", "lobby.rooms"}, {"payload", {{"rooms", json::array()}}}}; - } else { - response = {{"type", "error"}, - {"payload", {{"code", "unknown_type"}, {"message", "unknown message type: " + type}}}}; - } - } catch (const std::exception &e) { - response = {{"type", "error"}, {"payload", {{"code", "bad_json"}, {"message", e.what()}}}}; - } - ws->send(response.dump(), uWS::OpCode::TEXT); + .message = [&lobby](auto *ws, std::string_view message, uWS::OpCode) { + lobby.onMessage(ws, message); }, - .close = [](auto *ws, int, std::string_view) { - std::cout << "client disconnected\n"; + .close = [&lobby](auto *ws, int, std::string_view) { + lobby.onClose(ws); }, }) .listen(9001, [](auto *token) { diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 8657086..1c08e14 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -8,6 +8,12 @@ server { try_files $uri $uri/ =404; } + # без хешей в именах файлов — просим браузер всегда переспрашивать свежесть + location ~* \.(js|css)$ { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + location /ws { proxy_pass http://gameserver:9001; proxy_http_version 1.1;