Compare commits

..
11 Commits
Author SHA1 Message Date
cacto fb18a6f289 Merge dev в main: полный перенос дизайна и текстов из макета
Deploy / deploy (push) Failing after 1s
2026-08-10 00:42:11 +05:00
cacto 01eec92d48 Merge dev в main: редизайн + RU/EN
Deploy / deploy (push) Failing after 1s
2026-08-09 21:56:44 +05:00
cacto e1bfc2b5d7 Слияние dev: фикс клавиатуры, зелёное дуло
Deploy / deploy (push) Failing after 0s
2026-08-06 18:51:44 +05:00
cacto 0a47ac9234 Слияние dev: security-хардening, спавн-фикс
Deploy / deploy (push) Failing after 1s
2026-08-06 14:01:49 +05:00
cacto de6f0438c5 Слияние dev: coop с AI, звук, чистые URL
Deploy / deploy (push) Failing after 1s
2026-08-06 12:44:59 +05:00
cacto 01cefbbe77 Слияние dev: PvP-логика
Deploy / deploy (push) Failing after 1s
2026-08-04 12:26:30 +05:00
cacto 7b48c01a93 Merge pull request 'Dev' (#3) from dev into main
Deploy / deploy (push) Failing after 0s
Reviewed-on: #3
2026-07-26 18:25:56 +00:00
cacto d626a66ff7 Merge pull request 'Dev' (#2) from dev into main
Deploy / deploy (push) Failing after 0s
Reviewed-on: #2
2026-07-26 18:19:47 +00:00
cacto 128c5cf16e Merge pull request 'Dev' (#1) from dev into main
Deploy / deploy (push) Failing after 1s
Reviewed-on: #1
2026-07-26 18:03:36 +00:00
cacto a1c7cfdc9b Слияние dev: фикс DNS в деплое
Deploy / deploy (push) Failing after 2m15s
2026-07-26 22:31:04 +05:00
cacto b5566ec73e Слияние dev: каркас, резюме/проекты, лобби, автодеплой
Deploy / deploy (push) Failing after 2m40s
2026-07-26 22:06:56 +05:00
18 changed files with 35 additions and 1426 deletions
+2 -2
View File
@@ -9,7 +9,7 @@
**Ник `cacto` (кактус) — сквозной мотив:** **Ник `cacto` (кактус) — сквозной мотив:**
- Пиксельный кактус (16×16 или 32×32 спрайт, зелёный/серебро на чёрном) как лого в шапке и favicon. - Пиксельный кактус (16×16 или 32×32 спрайт, зелёный/серебро на чёрном) как лого в шапке и favicon.
- ASCII-арт кактуса в hero-блоке главной страницы. - ASCII-арт кактуса в hero-блоке главной страницы.
- Терминальные префиксы вместо обычных заголовков/навигации: `cacto@matrix:~$ whoami`, `cacto@matrix:~$ ls projects/`, `cacto@matrix:~$ ./tanks`. - Терминальные префиксы вместо обычных заголовков/навигации: `cacto@matrix:~$ whoami`, `cacto@matrix:~$ ls projects/`, `cacto@matrix:~$ ./battlecity`.
- Названия комнат в лобби генерируются в формате `cactus-room-01`, `cactus-room-02`, ... - Названия комнат в лобби генерируются в формате `cactus-room-01`, `cactus-room-02`, ...
- Характер, а не просто ник: живучесть, минимум ресурсов, "колючий" стиль текста в отдельных местах (например, error-сообщения). - Характер, а не просто ник: живучесть, минимум ресурсов, "колючий" стиль текста в отдельных местах (например, error-сообщения).
@@ -19,7 +19,7 @@
|---|------|------------| |---|------|------------|
| 1 | `index.html` | Резюме — PHP/Go backend, опыт КИПиА/АСУТП | | 1 | `index.html` | Резюме — PHP/Go backend, опыт КИПиА/АСУТП |
| 2 | `projects.html` | Карточки проектов со ссылками на репозитории | | 2 | `projects.html` | Карточки проектов со ссылками на репозитории |
| 3 | `game.html` | ./tanks: лобби + игра (танки, собственная разработка — без привязки к товарным знакам) | | 3 | `game.html` | BattleCity: лобби + игра |
## 3. Структура проекта ## 3. Структура проекта
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

-41
View File
@@ -1,41 +0,0 @@
<!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="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/arcade.css" />
</head>
<body>
<canvas id="matrix-rain"></canvas>
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="logo" aria-label="cacto — на главную">
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
cacto<span class="cursor">_</span>
</a>
<ul class="nav-links">
<li><a href="/">whoami</a></li>
<li><a href="/projects">ls projects/</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul>
</div>
</header>
<div class="container">
<main>
<h2 class="prompt">./blocks</h2>
<div id="arcade-root"></div>
</main>
</div>
<script type="module" src="/js/matrix-rain.js"></script>
<script type="module" src="/js/blocks.js"></script>
</body>
</html>
-206
View File
@@ -1,206 +0,0 @@
/* --- общие стили для одиночных аркад (./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;
}
}
+1 -7
View File
@@ -1,9 +1,3 @@
.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);
@@ -95,7 +89,7 @@
color: var(--color-accent-dim); color: var(--color-accent-dim);
} }
#tanks-canvas-container { #battlecity-canvas-container {
position: relative; position: relative;
max-width: 900px; max-width: 900px;
margin: 1.5rem 0; margin: 1.5rem 0;
-33
View File
@@ -22,27 +22,6 @@
max-width: 34ch; max-width: 34ch;
} }
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
.hero-actions__btn {
border: 1px solid var(--color-accent-dim);
color: var(--color-fg);
font-size: 0.85rem;
padding: 0.45rem 0.9rem;
}
.hero-actions__btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
box-shadow: 0 0 6px var(--color-accent-dim);
text-decoration: none;
}
.status-line { .status-line {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -190,18 +169,6 @@ section {
margin-top: 0.1rem; margin-top: 0.1rem;
} }
.site-footer {
border-top: var(--border);
margin-top: 1rem;
padding: 1.5rem 0 0;
}
.site-footer p {
color: var(--color-silver);
font-size: 0.85rem;
margin: 0;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.hero { .hero {
flex-direction: column; flex-direction: column;
+4 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<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>./battlecity — 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="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -23,6 +23,7 @@
<ul class="nav-links"> <ul class="nav-links">
<li><a href="/">whoami</a></li> <li><a href="/">whoami</a></li>
<li><a href="/projects">ls projects/</a></li> <li><a href="/projects">ls projects/</a></li>
<li><a href="/game" aria-current="page">./battlecity</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li> <li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul> </ul>
</div> </div>
@@ -30,10 +31,9 @@
<div class="container"> <div class="container">
<main id="game-root"> <main id="game-root">
<h2 class="prompt">./tanks</h2> <h2 class="prompt">./battlecity</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="battlecity-canvas-container" style="display: none"></div>
</main> </main>
</div> </div>
+4 -195
View File
@@ -3,15 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cacto — Гаммель Дмитрий, PHP backend-разработчик</title> <title>cacto — Гаммель Дмитрий, PHP/Go backend</title>
<meta name="description" content="Гаммель Дмитрий — backend-разработчик PHP. 6 лет коммерческого опыта: банковская IT-инфраструктура, ClickHouse-аналитика, legacy. Резюме, проекты." /> <meta name="description" content="Гаммель Дмитрий — backend-разработчик PHP/Go. Резюме, проекты, эксперименты." />
<link rel="canonical" href="https://cactoz.su/" />
<meta property="og:type" content="profile" />
<meta property="og:url" content="https://cactoz.su/" />
<meta property="og:title" content="Дмитрий Гаммель — backend-разработчик PHP" />
<meta property="og:description" content="6 лет коммерческого PHP: банковская IT-инфраструктура, ClickHouse-аналитика, legacy. До этого — АСУТП и электротехника." />
<meta property="og:image" content="https://cactoz.su/assets/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -31,198 +24,14 @@
<ul class="nav-links"> <ul class="nav-links">
<li><a href="/" aria-current="page">whoami</a></li> <li><a href="/" aria-current="page">whoami</a></li>
<li><a href="/projects">ls projects/</a></li> <li><a href="/projects">ls projects/</a></li>
<li><a href="/game">./battlecity</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li> <li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul> </ul>
</div> </div>
</header> </header>
<div class="container"> <div class="container">
<!-- <main id="resume-root"></main>
Статика ниже — серверный RU-рендер того же содержимого, что генерирует
frontend/js/resume.js (DATA.ru). Сделано так, чтобы curl/поисковики без
исполнения JS видели текст резюме, а не пустой #resume-root. resume.js
не перерисовывает при lang=ru (по умолчанию) — только при переключении
на EN. При правке текста резюме меняйте оба места: DATA.ru в resume.js
и разметку ниже.
-->
<main id="resume-root">
<section class="hero">
<pre class="ascii-cactus" aria-hidden="true"> , ,
|\_/|
.--| |--.
( | | )
&#96;--| |--&#39;
| |
.--| |--.
( | | )
&#96;--| |--&#39;
| |
| |
__| |__
(_________)</pre>
<div class="hero-text">
<div class="status-line"><span class="status-dot"></span>STATUS: OPEN TO WORK</div>
<h1><span class="glitch" data-text="Гаммель Дмитрий">Гаммель Дмитрий</span></h1>
<p class="role">PHP-разработчик (Backend, Middle+/Senior)</p>
<p class="tagline">От электрического сигнала до строчки кода.</p>
<div class="hero-actions">
<a class="hero-actions__btn" href="/assets/dmitry-gammel-cv.pdf" download>скачать резюме (PDF)</a>
<a class="hero-actions__btn" href="https://git.cactoz.su/cacto" target="_blank" rel="noopener">git.cactoz.su/cacto</a>
</div>
</div>
</section>
<section class="about">
<h2 class="section-heading"><span class="section-heading__num">01</span>cat about.txt</h2>
<p>Backend-разработчик, 6 лет коммерческого опыта на PHP. Работаю с legacy, сложными запросами и архитектурными решениями — там, где «просто добавить фичу» не получается.</p>
<p>Спроектировал аналитический контур на ClickHouse: отчёты формировались 3–4 минуты, стали за 10–15 секунд. Вёл разработку модуля документооборота в банковской IT-инфраструктуре — архитектура, декомпозиция, код-ревью, онбординг разработчиков.</p>
<p>Начинал с электротехники и АСУТП: писал управляющий софт для промышленных систем на ST, поднимал SCADA. Путь «от электрического сигнала до строчки кода» помогает быстро вникать в чужие системы и в задачи на стыке железа и бэкенда.</p>
<p>Ищу позицию Middle+/Senior backend. Основной стек — PHP, параллельно пишу на Go.</p>
</section>
<section class="experience">
<h2 class="section-heading"><span class="section-heading__num">02</span>ls -la experience/</h2>
<ol class="timeline">
<li class="timeline-item">
<div class="timeline-item__meta">март 2026 — настоящее время · 5 месяцев</div>
<h3 class="timeline-item__role">Программист АСУТП и КИПиА</h3>
<div class="timeline-item__company">Уралэнергоаква · Екатеринбург</div>
<ul>
<li><span class="dash">-</span><span>Временный период вне основной специальности после завершения проекта в ЭТП ГПБ. Ищу позицию backend-разработчика; параллельно — пет-проекты и Go.</span></li>
</ul>
</li>
<li class="timeline-item">
<div class="timeline-item__meta">июль 2022 — март 2026 · 3 года 9 месяцев</div>
<h3 class="timeline-item__role">PHP-разработчик</h3>
<div class="timeline-item__company">ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк») · Екатеринбург</div>
<ul>
<li><span class="dash">-</span><span>Разрабатывал и поддерживал внутреннюю систему электронного документооборота для согласования и контроля исполнения договоров.</span></li>
<li><span class="dash">-</span><span>Поддерживал и развивал legacy-проект на Zend Framework, участвовал в архитектурном переходе на Symfony; PHP 7.4 → 8.0.</span></li>
<li><span class="dash">-</span><span>Переписывал построители запросов на «чистый» SQL с оптимизацией под PostgreSQL, внедрял сервисный слой архитектуры.</span></li>
<li><span class="dash">-</span><span>Спроектировал и реализовал аналитический контур отчётности на ClickHouse — сократил время формирования отчётов с 3–4 минут до 10–15 секунд.</span></li>
<li><span class="dash">-</span><span>Реализовал интеграции с внутренними банковскими сервисами, включая проверку электронной подписи и машиночитаемых доверенностей.</span></li>
<li><span class="dash">-</span><span>Работал с очередями сообщений (RabbitMQ) для асинхронной обработки задач.</span></li>
<li><span class="dash">-</span><span>Неофициально возглавлял разработку нового модуля документооборота: планировал архитектуру, проводил код-ревью, довёл проект до MVP.</span></li>
<li><span class="dash">-</span><span>Работал по Scrum: месячные спринты, таск-трекер YouTrack, оценка задач методом Planning Poker.</span></li>
</ul>
</li>
<li class="timeline-item">
<div class="timeline-item__meta">март 2021 — июль 2022 · 1 год 5 месяцев</div>
<h3 class="timeline-item__role">PHP-разработчик</h3>
<div class="timeline-item__company">фриланс/субподряд · kupisever.ru · удалённо</div>
<ul>
<li><span class="dash">-</span><span>Дорабатывал бэкенд действующей B2B-платформы (доска объявлений) на Yii2 в качестве субподрядчика, работал самостоятельно на удалённом проекте. Стек: PHP, Yii2, PostgreSQL, RabbitMQ, Docker.</span></li>
<li><span class="dash">-</span><span>Разработал модуль email-рассылок с очередями сообщений (RabbitMQ): веб-форма конструктора писем с текстовым редактором и гибкой настройкой параметров рассылки.</span></li>
<li><span class="dash">-</span><span>Реализовал модуль категорий объявлений — административную часть (бэкенд) и клиентское меню (фронтенд).</span></li>
</ul>
</li>
<li class="timeline-item">
<div class="timeline-item__meta">июль 2020 — март 2021 · 9 месяцев</div>
<h3 class="timeline-item__role">PHP-разработчик (с обязанностями руководителя группы)</h3>
<div class="timeline-item__company">ИстВуд · Курган</div>
<ul>
<li><span class="dash">-</span><span>Интернет-магазины на 1С-Битрикс: вёрстка, интеграция готовых модулей, доработка бизнес-логики под задачи клиентов.</span></li>
<li><span class="dash">-</span><span>Реализовал интеграции с 1С (обмен товарными каталогами и заказами) и платёжными системами (эквайринг).</span></li>
<li><span class="dash">-</span><span>Реализовал нестандартный механизм автоматического получения и обновления каталога товаров (несколько тысяч позиций, ежесуточное обновление) для интернет-магазина на кастомном шаблоне вне типовых решений Bitrix.</span></li>
<li><span class="dash">-</span><span>Разработал функционал онлайн-записи на приём к врачу для сайта частной клиники; реализовал интеграцию с фискальным регистратором.</span></li>
<li><span class="dash">-</span><span>Участвовал во внедрении Битрикс24: настройка бизнес-процессов и цепочек согласования закупок.</span></li>
<li><span class="dash">-</span><span>Фактически руководил командой из 3 разработчиков: распределял задачи, контролировал качество реализации, проводил технические собеседования.</span></li>
</ul>
</li>
<li class="timeline-item">
<div class="timeline-item__meta">октябрь 2019 — февраль 2020 · 5 месяцев</div>
<h3 class="timeline-item__role">Техник по телекоммуникациям</h3>
<div class="timeline-item__company">Урал-М · Курган</div>
<ul>
<li><span class="dash">-</span><span>Настройка и обслуживание систем СКУД, видеонаблюдения, радиоканальной связи; работа с 1С.</span></li>
</ul>
</li>
<li class="timeline-item">
<div class="timeline-item__meta">апрель 2014 — июль 2018 · 4 года 4 месяца</div>
<h3 class="timeline-item__role">Техник-электрик</h3>
<div class="timeline-item__company">Кирпичный завод, Мясокомбинат, Хлебозавод · Курган</div>
<ul>
<li><span class="dash">-</span><span>Электромонтажные и наладочные работы на производственных предприятиях.</span></li>
</ul>
</li>
</ol>
</section>
<section class="skills">
<h2 class="section-heading"><span class="section-heading__num">03</span>skills --list</h2>
<div class="skill-group">
<div class="skill-group__title">backend</div>
<ul class="tag-list">
<li class="tag">PHP8</li>
<li class="tag">PHP7</li>
<li class="tag">Symfony</li>
<li class="tag">Zend Framework</li>
<li class="tag">Yii2</li>
<li class="tag">PostgreSQL</li>
<li class="tag">MySQL</li>
<li class="tag">ClickHouse</li>
<li class="tag">SQL</li>
<li class="tag">RabbitMQ</li>
<li class="tag">Docker</li>
<li class="tag">Git</li>
<li class="tag">REST API</li>
<li class="tag">PHPUnit</li>
<li class="tag">Composer</li>
<li class="tag">ООП</li>
<li class="tag">Go</li>
</ul>
</div>
<div class="skill-group">
<div class="skill-group__title">electrical / automation</div>
<ul class="tag-list">
<li class="tag">АСУТП</li>
<li class="tag">КИПиА</li>
<li class="tag">СКУД</li>
<li class="tag">Видеонаблюдение</li>
<li class="tag">Радиоканальная связь</li>
<li class="tag">Электромонтаж</li>
<li class="tag">1С</li>
</ul>
</div>
<div class="skill-group">
<div class="skill-group__title">soft</div>
<ul class="tag-list">
<li class="tag">Оптимизация SQL-запросов</li>
<li class="tag">Обучение и адаптация junior-разработчиков</li>
<li class="tag">Быстрая обучаемость</li>
</ul>
</div>
</section>
<section class="education">
<h2 class="section-heading"><span class="section-heading__num">04</span>cat education.txt</h2>
<ul class="edu-list">
<li class="edu-item">
<div class="edu-item__year">2013</div>
<div class="edu-item__degree">Неоконченное высшее</div>
<div class="edu-item__school">Тюменский государственный нефтегазовый университет (ТюмГНГУ)</div>
<div class="edu-item__detail">Институт кибернетики, информатики и связи, Электроэнергетика и электротехника</div>
</li>
<li class="edu-item">
<div class="edu-item__year">2011</div>
<div class="edu-item__degree">Свидетельство о квалификации «Электромонтёр 3 разряда»</div>
<div class="edu-item__school">ФГБОУ ВПО «Тюменский государственный университет»</div>
</li>
</ul>
</section>
<section class="contact">
<h2 class="section-heading"><span class="section-heading__num">05</span>cat contact.txt</h2>
<ul class="contact-list">
<li><span class="contact-list__label">email</span><a href="mailto:cactozzz93@gmail.com">cactozzz93@gmail.com</a></li>
<li><span class="contact-list__label">telegram</span><a href="https://t.me/Cactozz">t.me/Cactozz</a></li>
</ul>
</section>
</main>
<footer id="site-footer" class="site-footer">
<p>P.S. есть ещё пара мини-игр — <a href="/game">./tanks</a> (мультиплеер), <a href="/blocks">./blocks</a> и <a href="/invaders">./invaders</a>, если будет пара свободных минут.</p>
</footer>
</div> </div>
<script type="module" src="/js/matrix-rain.js"></script> <script type="module" src="/js/matrix-rain.js"></script>
-41
View File
@@ -1,41 +0,0 @@
<!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="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/arcade.css" />
</head>
<body>
<canvas id="matrix-rain"></canvas>
<header class="site-header">
<div class="site-header__inner">
<a href="/" class="logo" aria-label="cacto — на главную">
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
cacto<span class="cursor">_</span>
</a>
<ul class="nav-links">
<li><a href="/">whoami</a></li>
<li><a href="/projects">ls projects/</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul>
</div>
</header>
<div class="container">
<main>
<h2 class="prompt">./invaders</h2>
<div id="arcade-root"></div>
</main>
</div>
<script type="module" src="/js/matrix-rain.js"></script>
<script type="module" src="/js/invaders.js"></script>
</body>
</html>
-440
View File
@@ -1,440 +0,0 @@
import { getLang, initLangToggle } from './i18n.js';
const COLS = 10;
const ROWS = 20;
const CELL = 24;
const GRAVITY_START_MS = 800;
const GRAVITY_MIN_MS = 100;
const SHAPES = {
I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
O: [[1, 1], [1, 1]],
S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
};
const COLORS = {
I: '#00ff41',
O: '#9fa3a0',
T: '#39e075',
S: '#7cffb2',
Z: '#1fae56',
J: '#c7c7c7',
L: '#0a8f2c',
};
const PIECE_TYPES = Object.keys(SHAPES);
const STR = {
ru: {
hint: 'стрелки — двигать, вверх — вращать, пробел — сброс',
score: 'счёт',
lines: 'линии',
level: 'уровень',
next: 'дальше',
hardDrop: 'сброс',
gameOver: 'ИГРА ОКОНЧЕНА',
finalScore: (s) => `счёт: ${s}`,
again: 'ещё раз',
crosslinks: 'ещё игры:',
},
en: {
hint: 'arrows to move, up to rotate, space to hard-drop',
score: 'score',
lines: 'lines',
level: 'level',
next: 'next',
hardDrop: 'drop',
gameOver: 'GAME OVER',
finalScore: (s) => `score: ${s}`,
again: 'play again',
crosslinks: 'more games:',
},
};
function t() {
return STR[getLang()];
}
function emptyBoard() {
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
}
function rotateMatrix(matrix) {
const n = matrix.length;
const result = Array.from({ length: n }, () => Array(n).fill(0));
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
result[x][n - 1 - y] = matrix[y][x];
}
}
return result;
}
function randomType() {
return PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)];
}
class BlocksGame {
constructor(canvas, nextCanvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.nextCanvas = nextCanvas;
this.nextCtx = nextCanvas.getContext('2d');
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
this.reset();
}
reset() {
this.board = emptyBoard();
this.score = 0;
this.lines = 0;
this.level = 1;
this.over = false;
this.nextType = randomType();
this.spawn();
this.dropTimer = 0;
this.lastTime = null;
}
spawn() {
const type = this.nextType;
this.nextType = randomType();
const matrix = SHAPES[type].map((row) => row.slice());
this.piece = {
type,
matrix,
x: Math.floor((COLS - matrix.length) / 2),
y: 0,
};
if (this.collides(this.piece.matrix, this.piece.x, this.piece.y)) {
this.over = true;
}
}
collides(matrix, offX, offY) {
for (let y = 0; y < matrix.length; y++) {
for (let x = 0; x < matrix[y].length; x++) {
if (!matrix[y][x]) continue;
const bx = offX + x;
const by = offY + y;
if (bx < 0 || bx >= COLS || by >= ROWS) return true;
if (by >= 0 && this.board[by][bx]) return true;
}
}
return false;
}
move(dx, dy) {
if (this.over) return false;
const p = this.piece;
if (!this.collides(p.matrix, p.x + dx, p.y + dy)) {
p.x += dx;
p.y += dy;
return true;
}
return false;
}
rotate() {
if (this.over) return;
const p = this.piece;
const rotated = rotateMatrix(p.matrix);
const kicks = [0, 1, -1, 2, -2];
for (const kick of kicks) {
if (!this.collides(rotated, p.x + kick, p.y)) {
p.matrix = rotated;
p.x += kick;
return;
}
}
}
hardDrop() {
if (this.over) return;
while (this.move(0, 1)) {
this.score += 1;
}
this.lock();
}
lock() {
const p = this.piece;
for (let y = 0; y < p.matrix.length; y++) {
for (let x = 0; x < p.matrix[y].length; x++) {
if (p.matrix[y][x]) {
const by = p.y + y;
const bx = p.x + x;
if (by >= 0) this.board[by][bx] = p.type;
}
}
}
this.clearLines();
this.spawn();
}
clearLines() {
let cleared = 0;
this.board = this.board.filter((row) => {
const full = row.every((cell) => cell);
if (full) cleared++;
return !full;
});
while (this.board.length < ROWS) {
this.board.unshift(Array(COLS).fill(null));
}
if (cleared > 0) {
const points = [0, 100, 300, 500, 800][cleared] || 800;
this.score += points * this.level;
this.lines += cleared;
this.level = 1 + Math.floor(this.lines / 10);
}
}
gravityInterval() {
return Math.max(GRAVITY_MIN_MS, GRAVITY_START_MS - (this.level - 1) * 60);
}
tick(dtMs) {
if (this.over) return;
this.dropTimer += dtMs;
const interval = this.gravityInterval();
if (this.dropTimer >= interval) {
this.dropTimer = 0;
if (!this.move(0, 1)) {
this.lock();
}
}
}
drawCell(ctx, x, y, color) {
ctx.fillStyle = color;
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
ctx.strokeStyle = '#0a0e0c';
ctx.lineWidth = 1;
ctx.strokeRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
}
draw() {
const ctx = this.ctx;
ctx.fillStyle = '#0a0e0c';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (this.board[y][x]) {
this.drawCell(ctx, x, y, COLORS[this.board[y][x]]);
}
}
}
const p = this.piece;
if (p) {
for (let y = 0; y < p.matrix.length; y++) {
for (let x = 0; x < p.matrix[y].length; x++) {
if (p.matrix[y][x] && p.y + y >= 0) {
this.drawCell(ctx, p.x + x, p.y + y, COLORS[p.type]);
}
}
}
}
// сетка поверх, лёгкая
ctx.strokeStyle = 'rgba(80, 255, 140, 0.06)';
for (let x = 1; x < COLS; x++) {
ctx.beginPath();
ctx.moveTo(x * CELL, 0);
ctx.lineTo(x * CELL, this.canvas.height);
ctx.stroke();
}
for (let y = 1; y < ROWS; y++) {
ctx.beginPath();
ctx.moveTo(0, y * CELL);
ctx.lineTo(this.canvas.width, y * CELL);
ctx.stroke();
}
this.drawNext();
}
drawNext() {
const ctx = this.nextCtx;
const size = this.nextCanvas.width;
ctx.fillStyle = '#0f1512';
ctx.fillRect(0, 0, size, size);
const matrix = SHAPES[this.nextType];
const cell = Math.floor(size / 4);
const offset = (4 - matrix.length) / 2;
for (let y = 0; y < matrix.length; y++) {
for (let x = 0; x < matrix[y].length; x++) {
if (matrix[y][x]) {
ctx.fillStyle = COLORS[this.nextType];
ctx.fillRect((x + offset) * cell + 1, (y + offset) * cell + 1, cell - 2, cell - 2);
}
}
}
}
}
let game = null;
let rafId = null;
function updateStats() {
document.getElementById('arcade-score').textContent = game.score;
document.getElementById('arcade-lines').textContent = game.lines;
document.getElementById('arcade-level').textContent = game.level;
}
function showGameOver() {
const overlay = document.getElementById('arcade-result-overlay');
overlay.innerHTML = `
<div class="arcade-result">
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
<p>${t().finalScore(game.score)}</p>
<div class="arcade-result__actions">
<button data-action="again">${t().again}</button>
</div>
</div>`;
overlay.style.display = 'flex';
}
function loop(time) {
if (game.lastTime === null) game.lastTime = time;
const dt = time - game.lastTime;
game.lastTime = time;
const wasOver = game.over;
game.tick(dt);
game.draw();
updateStats();
if (game.over && !wasOver) {
showGameOver();
}
rafId = requestAnimationFrame(loop);
}
function restart() {
const overlay = document.getElementById('arcade-result-overlay');
overlay.style.display = 'none';
overlay.innerHTML = '';
game.reset();
}
function bindKeyboard() {
window.addEventListener('keydown', (e) => {
if (!game) return;
if (game.over) {
if (e.code === 'Space' || e.code === 'Enter') restart();
return;
}
switch (e.code) {
case 'ArrowLeft':
game.move(-1, 0);
e.preventDefault();
break;
case 'ArrowRight':
game.move(1, 0);
e.preventDefault();
break;
case 'ArrowDown':
if (game.move(0, 1)) game.score += 1;
e.preventDefault();
break;
case 'ArrowUp':
game.rotate();
e.preventDefault();
break;
case 'Space':
game.hardDrop();
e.preventDefault();
break;
}
});
}
function bindTouchControls(root) {
const actions = {
left: () => game.move(-1, 0),
right: () => game.move(1, 0),
down: () => {
if (game.move(0, 1)) game.score += 1;
},
rotate: () => game.rotate(),
drop: () => game.hardDrop(),
};
root.querySelectorAll('[data-input]').forEach((btn) => {
const action = actions[btn.dataset.input];
btn.addEventListener('click', () => {
if (game.over) return;
action();
});
});
}
function renderShell(root) {
root.innerHTML = `
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/invaders">./invaders</a></p>
<div class="arcade-layout">
<div class="arcade-canvas-slot">
<canvas id="arcade-canvas"></canvas>
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
</div>
<div class="arcade-controls">
<div class="arcade-stats">
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
<div><span id="arcade-lines-label">${t().lines.toUpperCase()}</span> <b id="arcade-lines">0</b></div>
<div><span id="arcade-level-label">${t().level.toUpperCase()}</span> <b id="arcade-level">1</b></div>
</div>
<div class="arcade-next-slot">
<span id="arcade-next-label">${t().next}</span>
<canvas id="arcade-next" width="80" height="80"></canvas>
</div>
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
<div class="arcade-dpad">
<button class="arcade-dpad__up" data-input="rotate">&uarr;</button>
<button class="arcade-dpad__left" data-input="left">&larr;</button>
<button class="arcade-dpad__right" data-input="right">&rarr;</button>
<button class="arcade-dpad__down" data-input="down">&darr;</button>
</div>
<button class="arcade-action-btn" data-input="drop" id="arcade-drop-btn">${t().hardDrop}</button>
</div>
</div>`;
}
// Обновляет статичные подписи без пересоздания игры — иначе переключение
// языка посреди партии сбрасывало бы прогресс, как уже было с ./tanks.
function updateStaticText() {
const byId = (id) => document.getElementById(id);
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
if (byId('arcade-lines-label')) byId('arcade-lines-label').textContent = t().lines.toUpperCase();
if (byId('arcade-level-label')) byId('arcade-level-label').textContent = t().level.toUpperCase();
if (byId('arcade-next-label')) byId('arcade-next-label').textContent = t().next;
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
if (byId('arcade-drop-btn')) byId('arcade-drop-btn').textContent = t().hardDrop;
if (game?.over) showGameOver();
}
function init() {
const root = document.getElementById('arcade-root');
renderShell(root);
const canvas = document.getElementById('arcade-canvas');
const nextCanvas = document.getElementById('arcade-next');
game = new BlocksGame(canvas, nextCanvas);
bindTouchControls(root.querySelector('.arcade-controls'));
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
if (e.target.closest('[data-action="again"]')) restart();
});
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
}
bindKeyboard();
init();
initLangToggle(updateStaticText);
+1 -1
View File
@@ -364,7 +364,7 @@ export function startBattle(networkInstance, payload, ownPlayerId) {
const cols = payload.map[0].length; const cols = payload.map[0].length;
document.getElementById('lobby').style.display = 'none'; document.getElementById('lobby').style.display = 'none';
const container = document.getElementById('tanks-canvas-container'); const container = document.getElementById('battlecity-canvas-container');
container.style.display = 'block'; container.style.display = 'block';
container.innerHTML = ` container.innerHTML = `
<div class="battle-layout"> <div class="battle-layout">
+1 -5
View File
@@ -23,7 +23,6 @@ const STR = {
room: 'комната', room: 'комната',
mode: 'режим:', mode: 'режим:',
leave: 'выйти в лобби', leave: 'выйти в лобби',
crosslinks: 'ещё игры:',
}, },
en: { en: {
connecting: 'connecting...', connecting: 'connecting...',
@@ -39,7 +38,6 @@ const STR = {
room: 'room', room: 'room',
mode: 'mode:', mode: 'mode:',
leave: 'leave to lobby', leave: 'leave to lobby',
crosslinks: 'more games:',
}, },
}; };
@@ -129,8 +127,6 @@ 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 {
@@ -140,7 +136,7 @@ function render() {
// Вызывается из battle.js по кнопкам экрана победы/поражения. // Вызывается из battle.js по кнопкам экрана победы/поражения.
export function returnToLobbyContainer() { export function returnToLobbyContainer() {
document.getElementById('tanks-canvas-container').style.display = 'none'; document.getElementById('battlecity-canvas-container').style.display = 'none';
container.style.display = ''; container.style.display = '';
render(); render();
} }
-331
View File
@@ -1,331 +0,0 @@
import { getLang, initLangToggle } from './i18n.js';
const CANVAS_W = 420;
const CANVAS_H = 520;
const PLAYER_Y = CANVAS_H - 40;
const PLAYER_W = 28;
const PLAYER_H = 14;
const PLAYER_SPEED = 220; // px/s
const BULLET_SPEED = 380;
const ENEMY_BULLET_SPEED = 180;
const FIRE_COOLDOWN_MS = 350;
const ENEMY_COLS = 8;
const ENEMY_ROWS = 4;
const ENEMY_W = 28;
const ENEMY_H = 18;
const ENEMY_GAP_X = 12;
const ENEMY_GAP_Y = 16;
const ENEMY_TOP = 50;
const ENEMY_STEP_DOWN = 18;
const STR = {
ru: {
hint: 'стрелки — двигать, пробел — огонь',
score: 'счёт',
lives: 'жизни',
wave: 'волна',
gameOver: 'ИГРА ОКОНЧЕНА',
finalScore: (s) => `счёт: ${s}`,
again: 'ещё раз',
crosslinks: 'ещё игры:',
fire: 'огонь',
},
en: {
hint: 'arrows to move, space to fire',
score: 'score',
lives: 'lives',
wave: 'wave',
gameOver: 'GAME OVER',
finalScore: (s) => `score: ${s}`,
again: 'play again',
crosslinks: 'more games:',
fire: 'fire',
},
};
function t() {
return STR[getLang()];
}
function rectsOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
class InvadersGame {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
canvas.width = CANVAS_W;
canvas.height = CANVAS_H;
this.input = { left: false, right: false, fire: false };
this.reset();
}
reset() {
this.score = 0;
this.lives = 3;
this.wave = 1;
this.over = false;
this.player = { x: CANVAS_W / 2 - PLAYER_W / 2, y: PLAYER_Y };
this.bullets = [];
this.enemyBullets = [];
this.fireTimer = 0;
this.hitFlash = 0;
this.spawnWave();
}
spawnWave() {
this.enemies = [];
const totalWidth = ENEMY_COLS * (ENEMY_W + ENEMY_GAP_X) - ENEMY_GAP_X;
const startX = (CANVAS_W - totalWidth) / 2;
for (let row = 0; row < ENEMY_ROWS; row++) {
for (let col = 0; col < ENEMY_COLS; col++) {
this.enemies.push({
x: startX + col * (ENEMY_W + ENEMY_GAP_X),
y: ENEMY_TOP + row * (ENEMY_H + ENEMY_GAP_Y),
w: ENEMY_W,
h: ENEMY_H,
alive: true,
points: (ENEMY_ROWS - row) * 10,
});
}
}
this.enemyDir = 1;
this.enemySpeed = 30 + (this.wave - 1) * 12;
this.enemyShootTimer = 0;
}
aliveEnemies() {
return this.enemies.filter((e) => e.alive);
}
tick(dtMs) {
if (this.over) return;
const dt = dtMs / 1000;
if (this.input.left) this.player.x -= PLAYER_SPEED * dt;
if (this.input.right) this.player.x += PLAYER_SPEED * dt;
this.player.x = Math.max(0, Math.min(CANVAS_W - PLAYER_W, this.player.x));
this.fireTimer -= dtMs;
if (this.input.fire && this.fireTimer <= 0) {
this.bullets.push({ x: this.player.x + PLAYER_W / 2 - 2, y: this.player.y, w: 4, h: 10 });
this.fireTimer = FIRE_COOLDOWN_MS;
}
for (const b of this.bullets) b.y -= BULLET_SPEED * dt;
this.bullets = this.bullets.filter((b) => b.y + b.h > 0);
for (const b of this.enemyBullets) b.y += ENEMY_BULLET_SPEED * dt;
this.enemyBullets = this.enemyBullets.filter((b) => b.y < CANVAS_H);
const alive = this.aliveEnemies();
if (alive.length === 0) {
this.wave += 1;
this.spawnWave();
return;
}
let hitEdge = false;
for (const e of alive) {
e.x += this.enemyDir * this.enemySpeed * dt;
if (e.x <= 0 || e.x + e.w >= CANVAS_W) hitEdge = true;
}
if (hitEdge) {
this.enemyDir *= -1;
for (const e of alive) {
e.y += ENEMY_STEP_DOWN;
if (e.y + e.h >= this.player.y) {
this.over = true;
}
}
}
this.enemyShootTimer -= dtMs;
if (this.enemyShootTimer <= 0 && alive.length > 0) {
const shooter = alive[Math.floor(Math.random() * alive.length)];
this.enemyBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
this.enemyShootTimer = Math.max(250, 900 - this.wave * 60);
}
// пуля игрока vs враги
for (const bullet of this.bullets) {
for (const e of alive) {
if (!e.alive) continue;
if (rectsOverlap(bullet, e)) {
e.alive = false;
bullet.hit = true;
this.score += e.points;
}
}
}
this.bullets = this.bullets.filter((b) => !b.hit);
// пуля врага vs игрок
const playerRect = { x: this.player.x, y: this.player.y, w: PLAYER_W, h: PLAYER_H };
for (const b of this.enemyBullets) {
if (rectsOverlap(b, playerRect)) {
b.hit = true;
this.lives -= 1;
this.hitFlash = 200;
if (this.lives <= 0) this.over = true;
}
}
this.enemyBullets = this.enemyBullets.filter((b) => !b.hit);
if (this.hitFlash > 0) this.hitFlash -= dtMs;
}
draw() {
const ctx = this.ctx;
ctx.fillStyle = '#0a0e0c';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
for (const e of this.enemies) {
if (!e.alive) continue;
ctx.fillStyle = '#39e075';
ctx.fillRect(e.x, e.y, e.w, e.h);
ctx.fillStyle = '#0a0e0c';
ctx.fillRect(e.x + 4, e.y + 4, 4, 4);
ctx.fillRect(e.x + e.w - 8, e.y + 4, 4, 4);
}
ctx.fillStyle = this.hitFlash > 0 ? '#ffffff' : '#00ff41';
ctx.fillRect(this.player.x, this.player.y, PLAYER_W, PLAYER_H);
ctx.fillRect(this.player.x + PLAYER_W / 2 - 3, this.player.y - 6, 6, 6);
ctx.fillStyle = '#c7c7c7';
for (const b of this.bullets) ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.fillStyle = '#9fa3a0';
for (const b of this.enemyBullets) ctx.fillRect(b.x, b.y, b.w, b.h);
}
}
let game = null;
let rafId = null;
function updateStats() {
document.getElementById('arcade-score').textContent = game.score;
document.getElementById('arcade-lives').textContent = game.lives;
document.getElementById('arcade-wave').textContent = game.wave;
}
function showGameOver() {
const overlay = document.getElementById('arcade-result-overlay');
overlay.innerHTML = `
<div class="arcade-result">
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
<p>${t().finalScore(game.score)}</p>
<div class="arcade-result__actions">
<button data-action="again">${t().again}</button>
</div>
</div>`;
overlay.style.display = 'flex';
}
function loop(time) {
if (game.lastTime === undefined || game.lastTime === null) game.lastTime = time;
const dt = time - game.lastTime;
game.lastTime = time;
const wasOver = game.over;
game.tick(dt);
game.draw();
updateStats();
if (game.over && !wasOver) showGameOver();
rafId = requestAnimationFrame(loop);
}
function restart() {
const overlay = document.getElementById('arcade-result-overlay');
overlay.style.display = 'none';
overlay.innerHTML = '';
game.reset();
}
function bindKeyboard() {
window.addEventListener('keydown', (e) => {
if (!game) return;
if (game.over) {
if (e.code === 'Space' || e.code === 'Enter') restart();
return;
}
if (e.code === 'ArrowLeft') { game.input.left = true; e.preventDefault(); }
if (e.code === 'ArrowRight') { game.input.right = true; e.preventDefault(); }
if (e.code === 'Space') { game.input.fire = true; e.preventDefault(); }
});
window.addEventListener('keyup', (e) => {
if (!game) return;
if (e.code === 'ArrowLeft') game.input.left = false;
if (e.code === 'ArrowRight') game.input.right = false;
if (e.code === 'Space') game.input.fire = false;
});
}
function bindTouchControls(root) {
const press = (key, value) => {
if (game) game.input[key] = value;
};
root.querySelectorAll('[data-input]').forEach((btn) => {
const key = btn.dataset.input;
btn.addEventListener('mousedown', () => press(key, true));
btn.addEventListener('mouseup', () => press(key, false));
btn.addEventListener('mouseleave', () => press(key, false));
btn.addEventListener('touchstart', (e) => { e.preventDefault(); press(key, true); });
btn.addEventListener('touchend', (e) => { e.preventDefault(); press(key, false); });
});
}
function renderShell(root) {
root.innerHTML = `
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/blocks">./blocks</a></p>
<div class="arcade-layout">
<div class="arcade-canvas-slot">
<canvas id="arcade-canvas"></canvas>
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
</div>
<div class="arcade-controls">
<div class="arcade-stats">
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
<div><span id="arcade-lives-label">${t().lives.toUpperCase()}</span> <b id="arcade-lives">3</b></div>
<div><span id="arcade-wave-label">${t().wave.toUpperCase()}</span> <b id="arcade-wave">1</b></div>
</div>
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
<div class="arcade-lr">
<button data-input="left">&larr;</button>
<button data-input="right">&rarr;</button>
</div>
<button class="arcade-action-btn" data-input="fire" id="arcade-fire-btn">${t().fire}</button>
</div>
</div>`;
}
function updateStaticText() {
const byId = (id) => document.getElementById(id);
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
if (byId('arcade-lives-label')) byId('arcade-lives-label').textContent = t().lives.toUpperCase();
if (byId('arcade-wave-label')) byId('arcade-wave-label').textContent = t().wave.toUpperCase();
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
if (byId('arcade-fire-btn')) byId('arcade-fire-btn').textContent = t().fire;
if (game?.over) showGameOver();
}
function init() {
const root = document.getElementById('arcade-root');
renderShell(root);
const canvas = document.getElementById('arcade-canvas');
game = new InvadersGame(canvas);
bindTouchControls(root.querySelector('.arcade-controls'));
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
if (e.target.closest('[data-action="again"]')) restart();
});
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
}
bindKeyboard();
init();
initLangToggle(updateStaticText);
+6 -27
View File
@@ -8,23 +8,15 @@ const DATA = {
{ {
name: 'home_automatization', name: 'home_automatization',
description: description:
'В разработке: архитектура и ТЗ готовы, микросервисы на Go и Laravel собираются. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.', 'Микросервисная платформа умного дома на Go и Laravel. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.',
tags: ['в разработке', 'Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'], tags: ['Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
url: 'https://git.cactoz.su/cacto/home_automatization', url: 'https://git.cactoz.su/cacto/home_automatization',
linkLabel: 'открыть', linkLabel: 'открыть',
}, },
{
name: 'home_automatization_controllers',
description:
'Прошивка ESP32 для зоны «Гроубокс»: датчик DHT22 и три реле-актуатора (свет, полив, вентиляция), общается с платформой по MQTT.',
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
linkLabel: 'открыть',
},
{ {
name: 'cactoz.su', name: 'cactoz.su',
description: description:
'Личный сайт-визитка: резюме, проекты и мультиплеерная мини-игра про танки. Тема — Матрица.', 'Личный сайт-визитка: резюме, проекты и мультиплеерный клон BattleCity. Тема — Матрица.',
tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'], tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'],
url: 'https://git.cactoz.su/cacto/cactoz.su', url: 'https://git.cactoz.su/cacto/cactoz.su',
linkLabel: 'открыть', linkLabel: 'открыть',
@@ -38,23 +30,15 @@ const DATA = {
{ {
name: 'home_automatization', name: 'home_automatization',
description: description:
'Work in progress: architecture and spec are done, Go and Laravel microservices are being built. First zone is a grow box (watering, lighting, climate), the whole house comes next.', 'A microservice smart-home platform on Go and Laravel. First zone is a grow box (watering, lighting, climate), the whole house comes next.',
tags: ['in progress', 'Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'], tags: ['Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
url: 'https://git.cactoz.su/cacto/home_automatization', url: 'https://git.cactoz.su/cacto/home_automatization',
linkLabel: 'open', linkLabel: 'open',
}, },
{
name: 'home_automatization_controllers',
description:
'ESP32 firmware for the grow-box zone: a DHT22 sensor and three relay actuators (light, watering, ventilation), talking to the platform over MQTT.',
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
linkLabel: 'open',
},
{ {
name: 'cactoz.su', name: 'cactoz.su',
description: description:
'A personal portfolio site: résumé, projects and a small multiplayer tank game. Matrix theme throughout.', 'A personal portfolio site: résumé, projects and a multiplayer BattleCity clone. Matrix theme throughout.',
tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'], tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'],
url: 'https://git.cactoz.su/cacto/cactoz.su', url: 'https://git.cactoz.su/cacto/cactoz.su',
linkLabel: 'open', linkLabel: 'open',
@@ -91,10 +75,5 @@ function render() {
.join(''); .join('');
} }
// index.html-паттерн: projects.html уже содержит серверный RU-рендер этой
// же разметки, перерисовываем через JS только при переключении на EN.
document.title = `${DATA[getLang()].heading} — cacto`;
if (getLang() !== 'ru') {
render(); render();
}
initLangToggle(render); initLangToggle(render);
+10 -31
View File
@@ -8,17 +8,16 @@ const DATA = {
statusLabel: 'STATUS: OPEN TO WORK', statusLabel: 'STATUS: OPEN TO WORK',
aboutHeading: 'cat about.txt', aboutHeading: 'cat about.txt',
about: [ about: [
'Backend-разработчик, 6 лет коммерческого опыта на PHP. Работаю с legacy, сложными запросами и архитектурными решениями — там, где «просто добавить фичу» не получается.', 'PHP-разработчик с 6-летним коммерческим опытом, специализация — backend, сложные запросы к базам данных, работа с legacy-кодом. Прошёл путь от рядового разработчика до архитектурных и лидерских задач: спроектировал аналитический контур на ClickHouse, ускоривший формирование отчётности в 15-20 раз, неофициально возглавлял разработку модуля документооборота в банковской IT-инфраструктуре, вырос от специалиста без опыта в PHP до фактического руководителя команды за 3 месяца работы в digital-агентстве.',
'Спроектировал аналитический контур на ClickHouse: отчёты формировались 3–4 минуты, стали за 10–15 секунд. Вёл разработку модуля документооборота в банковской IT-инфраструктуре — архитектура, декомпозиция, код-ревью, онбординг разработчиков.', 'Разносторонний технический бэкграунд: начинал с электротехники и АСУТП, писал управляющий софт для автоматизированных систем — например, для фонтана, который до сих пор работает в центре Кургана под моей программой. Такой путь даёт понимание логики «от электрического сигнала до строчки кода» — это помогает быстро разбираться в сложных legacy-системах и нестандартных задачах.',
'Начинал с электротехники и АСУТП: писал управляющий софт для промышленных систем на ST, поднимал SCADA. Путь «от электрического сигнала до строчки кода» помогает быстро вникать в чужие системы и в задачи на стыке железа и бэкенда.', 'Разностороннее образование — от электротехники до начального музыкального и театрального — помогает находить нестандартные решения и легко находить общий язык с людьми разного склада.',
'Ищу позицию Middle+/Senior backend. Основной стек — PHP, параллельно пишу на Go.',
], ],
experienceHeading: 'ls -la experience/', experienceHeading: 'ls -la experience/',
experience: [ experience: [
{ {
period: 'март 2026 — настоящее время', duration: '5 месяцев', location: 'Екатеринбург', period: 'март 2026 — настоящее время', duration: '5 месяцев', location: 'Екатеринбург',
role: 'Программист АСУТП и КИПиА', company: 'Уралэнергоаква', role: 'Программист АСУТП и КИПиА', company: 'Уралэнергоаква',
bullets: ['Временный период вне основной специальности после завершения проекта в ЭТП ГПБ. Ищу позицию backend-разработчика; параллельно — пет-проекты и Go.'], bullets: ['Временный период вне основной специальности после завершения проекта в ЭТП ГПБ. Продолжаю развиваться как PHP-разработчик, активно ищу позицию по профилю backend-разработки.'],
}, },
{ {
period: 'июль 2022 — март 2026', duration: '3 года 9 месяцев', location: 'Екатеринбург', period: 'июль 2022 — март 2026', duration: '3 года 9 месяцев', location: 'Екатеринбург',
@@ -68,7 +67,7 @@ const DATA = {
], ],
skillsHeading: 'skills --list', skillsHeading: 'skills --list',
skillGroups: [ skillGroups: [
{ title: 'backend', items: ['PHP8', 'PHP7', 'Symfony', 'Zend Framework', 'Yii2', 'PostgreSQL', 'MySQL', 'ClickHouse', 'SQL', 'RabbitMQ', 'Docker', 'Git', 'REST API', 'PHPUnit', 'Composer', 'ООП', 'Go'] }, { title: 'backend', items: ['PHP8', 'PHP7', 'Symfony', 'Zend Framework', 'Yii2', 'PostgreSQL', 'MySQL', 'ClickHouse', 'SQL', 'RabbitMQ', 'Docker', 'Git', 'REST API', 'PHPUnit', 'Composer', 'ООП', 'Go (изучаю)'] },
{ title: 'electrical / automation', items: ['АСУТП', 'КИПиА', 'СКУД', 'Видеонаблюдение', 'Радиоканальная связь', 'Электромонтаж', '1С'] }, { title: 'electrical / automation', items: ['АСУТП', 'КИПиА', 'СКУД', 'Видеонаблюдение', 'Радиоканальная связь', 'Электромонтаж', '1С'] },
{ title: 'soft', items: ['Оптимизация SQL-запросов', 'Обучение и адаптация junior-разработчиков', 'Быстрая обучаемость'] }, { title: 'soft', items: ['Оптимизация SQL-запросов', 'Обучение и адаптация junior-разработчиков', 'Быстрая обучаемость'] },
], ],
@@ -79,9 +78,6 @@ const DATA = {
], ],
contactHeading: 'cat contact.txt', contactHeading: 'cat contact.txt',
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)',
giteaLabel: 'git.cactoz.su/cacto',
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',
@@ -90,17 +86,16 @@ const DATA = {
statusLabel: 'STATUS: OPEN TO WORK', statusLabel: 'STATUS: OPEN TO WORK',
aboutHeading: 'cat about.txt', aboutHeading: 'cat about.txt',
about: [ about: [
"Backend developer, 6 years of commercial PHP experience. I work with legacy code, complex queries, and architecture — the cases where \"just add a feature\" doesn't cut it.", "PHP developer with 6 years of commercial experience, specializing in backend: complex database queries, legacy code, architecture. Grew from a line developer into architecture and leadership work — designed a ClickHouse analytics pipeline that sped up reporting 15-20x, informally led a new document-workflow module in a bank's IT infrastructure, and went from a PHP beginner to de facto team lead within 3 months at a digital agency.",
"Designed a ClickHouse analytics pipeline: reports went from 34 minutes to 1015 seconds. Led development of a document-workflow module in a bank's IT infrastructure — architecture, decomposition, code review, onboarding developers.", "Broad technical background: started in electrical engineering and industrial automation (ACS/KIPiA), writing control software for automated systems — including a fountain that still runs in the center of Kurgan on my program. That path gives an understanding of the full chain, from electrical signal to line of code, which helps me get up to speed fast in complex legacy systems and unfamiliar problems.",
'Started in electrical engineering and industrial automation: wrote control software for industrial systems in ST, built SCADA systems. The path from electrical signal to line of code helps me get up to speed fast in unfamiliar systems and problems at the intersection of hardware and backend.', 'A varied education — from electrical engineering to early music and theater — helps find unconventional solutions and get along with people of very different backgrounds.',
'Looking for a Middle+/Senior backend position. Main stack is PHP, with Go on the side.',
], ],
experienceHeading: 'ls -la experience/', experienceHeading: 'ls -la experience/',
experience: [ experience: [
{ {
period: 'Mar 2026 — present', duration: '5 months', location: 'Yekaterinburg', period: 'Mar 2026 — present', duration: '5 months', location: 'Yekaterinburg',
role: 'ACS/KIPiA Programmer (industrial automation)', company: 'Uralenergoakva', role: 'ACS/KIPiA Programmer (industrial automation)', company: 'Uralenergoakva',
bullets: ["A temporary role outside my core specialty after wrapping up the ETP GPB project. Looking for a backend position; alongside that — side projects and Go."], bullets: ["A temporary role outside my core specialty after wrapping up the ETP GPB project. Keep growing as a PHP developer and actively looking for a backend position."],
}, },
{ {
period: 'Jul 2022 — Mar 2026', duration: '3 years 9 months', location: 'Yekaterinburg', period: 'Jul 2022 — Mar 2026', duration: '3 years 9 months', location: 'Yekaterinburg',
@@ -150,7 +145,7 @@ const DATA = {
], ],
skillsHeading: 'skills --list', skillsHeading: 'skills --list',
skillGroups: [ skillGroups: [
{ title: 'backend', items: ['PHP8', 'PHP7', 'Symfony', 'Zend Framework', 'Yii2', 'PostgreSQL', 'MySQL', 'ClickHouse', 'SQL', 'RabbitMQ', 'Docker', 'Git', 'REST API', 'PHPUnit', 'Composer', 'OOP', 'Go'] }, { title: 'backend', items: ['PHP8', 'PHP7', 'Symfony', 'Zend Framework', 'Yii2', 'PostgreSQL', 'MySQL', 'ClickHouse', 'SQL', 'RabbitMQ', 'Docker', 'Git', 'REST API', 'PHPUnit', 'Composer', 'OOP', 'Go (learning)'] },
{ title: 'electrical / automation', items: ['Industrial automation (ACS)', 'KIPiA', 'Access control', 'Video surveillance', 'Radio links', 'Electrical installation', '1C'] }, { title: 'electrical / automation', items: ['Industrial automation (ACS)', 'KIPiA', 'Access control', 'Video surveillance', 'Radio links', 'Electrical installation', '1C'] },
{ title: 'soft', items: ['SQL query optimization', 'Mentoring junior developers', 'Fast learner'] }, { title: 'soft', items: ['SQL query optimization', 'Mentoring junior developers', 'Fast learner'] },
], ],
@@ -161,9 +156,6 @@ const DATA = {
], ],
contactHeading: 'cat contact.txt', contactHeading: 'cat contact.txt',
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)',
giteaLabel: 'git.cactoz.su/cacto',
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.',
}, },
}; };
@@ -233,10 +225,6 @@ function render() {
<h1><span class="glitch" data-text="${t.name}">${t.name}</span></h1> <h1><span class="glitch" data-text="${t.name}">${t.name}</span></h1>
<p class="role">${t.title}</p> <p class="role">${t.title}</p>
<p class="tagline">${t.tagline}</p> <p class="tagline">${t.tagline}</p>
<div class="hero-actions">
<a class="hero-actions__btn" href="/assets/dmitry-gammel-cv.pdf" download>${t.resumeLabel}</a>
<a class="hero-actions__btn" href="https://git.cactoz.su/cacto" target="_blank" rel="noopener">${t.giteaLabel}</a>
</div>
</div> </div>
</section> </section>
@@ -267,16 +255,7 @@ function render() {
<li><span class="contact-list__label">telegram</span><a href="${t.contact.telegramUrl}">${t.contact.telegram}</a></li> <li><span class="contact-list__label">telegram</span><a href="${t.contact.telegramUrl}">${t.contact.telegram}</a></li>
</ul> </ul>
</section>`; </section>`;
const footer = document.getElementById('site-footer');
if (footer) footer.innerHTML = `<p>${t.footerHtml}</p>`;
} }
// index.html уже содержит серверный RU-рендер этой же разметки (для
// поисковиков и curl без JS) — перерисовываем через JS только когда
// нужен другой язык, чтобы не терять эту статику зря на каждой загрузке.
document.title = `cacto — ${DATA[getLang()].name}, ${DATA[getLang()].title}`;
if (getLang() !== 'ru') {
render(); render();
}
initLangToggle(render); initLangToggle(render);
+4 -57
View File
@@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ls projects/ — cacto</title> <title>ls projects/ — cacto</title>
<meta name="description" content="Проекты Гаммеля Дмитрия: home automation platform и этот сайт." /> <meta name="description" content="Проекты Гаммеля Дмитрия: home automation platform и этот сайт." />
<link rel="canonical" href="https://cactoz.su/projects" />
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -25,70 +24,18 @@
<ul class="nav-links"> <ul class="nav-links">
<li><a href="/">whoami</a></li> <li><a href="/">whoami</a></li>
<li><a href="/projects" aria-current="page">ls projects/</a></li> <li><a href="/projects" aria-current="page">ls projects/</a></li>
<li><a href="/game">./battlecity</a></li>
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li> <li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
</ul> </ul>
</div> </div>
</header> </header>
<div class="container"> <div class="container">
<!--
Статика ниже — серверный RU-рендер того же содержимого, что генерирует
frontend/js/projects.js (DATA.ru). projects.js не перерисовывает при
lang=ru — только при переключении на EN. При правке текста меняйте оба
места: DATA.ru в projects.js и разметку ниже.
-->
<main> <main>
<section> <section>
<h1 id="projects-heading" class="page-heading">ls projects/</h1> <h1 id="projects-heading" class="page-heading"></h1>
<p id="projects-subheading" class="page-subheading">Пара вещей, которые я собрал сам, от идеи до продакшена.</p> <p id="projects-subheading" class="page-subheading"></p>
<ul id="project-list" class="project-grid"> <ul id="project-list" class="project-grid"></ul>
<li class="project-card">
<div class="project-card__header">
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> home_automatization</div>
<a href="https://git.cactoz.su/cacto/home_automatization" target="_blank" rel="noopener">открыть →</a>
</div>
<p class="project-card__desc">В разработке: архитектура и ТЗ готовы, микросервисы на Go и Laravel собираются. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.</p>
<ul class="tag-list">
<li class="tag">в разработке</li>
<li class="tag">Go</li>
<li class="tag">Laravel</li>
<li class="tag">Docker</li>
<li class="tag">RabbitMQ</li>
<li class="tag">ClickHouse</li>
<li class="tag">gRPC</li>
<li class="tag">MQTT</li>
</ul>
</li>
<li class="project-card">
<div class="project-card__header">
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> home_automatization_controllers</div>
<a href="https://git.cactoz.su/cacto/home_automatization_controllers" target="_blank" rel="noopener">открыть →</a>
</div>
<p class="project-card__desc">Прошивка ESP32 для зоны «Гроубокс»: датчик DHT22 и три реле-актуатора (свет, полив, вентиляция), общается с платформой по MQTT.</p>
<ul class="tag-list">
<li class="tag">C++</li>
<li class="tag">ESP32</li>
<li class="tag">Arduino</li>
<li class="tag">MQTT</li>
<li class="tag">DHT22</li>
</ul>
</li>
<li class="project-card">
<div class="project-card__header">
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> cactoz.su</div>
<a href="https://git.cactoz.su/cacto/cactoz.su" target="_blank" rel="noopener">открыть →</a>
</div>
<p class="project-card__desc">Личный сайт-визитка: резюме, проекты и мультиплеерная мини-игра про танки. Тема — Матрица.</p>
<ul class="tag-list">
<li class="tag">HTML/CSS/JS</li>
<li class="tag">C++</li>
<li class="tag">uWebSockets</li>
<li class="tag">Quintus.js</li>
<li class="tag">nginx</li>
<li class="tag">Docker</li>
</ul>
</li>
</ul>
</section> </section>
</main> </main>
</div> </div>
-3
View File
@@ -10,9 +10,6 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# не светим точную версию nginx в заголовке Server и в error-страницах
server_tokens off;
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;