Compare commits
21
Commits
0a47ac9234
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6746d89233 | ||
|
|
6ce29bb986 | ||
|
|
bb194ad33f | ||
|
|
cb25497bb8 | ||
|
|
3e14c8f381 | ||
|
|
abd57e1cdf | ||
|
|
9c7caf50a7 | ||
|
|
c9de8da955 | ||
|
|
5e99bc8c45 | ||
|
|
df24edd0ab | ||
|
|
dc4305f840 | ||
|
|
d4655bda2b | ||
|
|
fb18a6f289 | ||
|
|
6f702eafcb | ||
|
|
556f5761fd | ||
|
|
01eec92d48 | ||
|
|
17fd3bae8d | ||
|
|
a1b123dd13 | ||
|
|
e1bfc2b5d7 | ||
|
|
201506bdbc | ||
|
|
20d63f0cee |
@@ -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:~$ ./battlecity`.
|
- Терминальные префиксы вместо обычных заголовков/навигации: `cacto@matrix:~$ whoami`, `cacto@matrix:~$ ls projects/`, `cacto@matrix:~$ ./tanks`.
|
||||||
- Названия комнат в лобби генерируются в формате `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` | BattleCity: лобби + игра |
|
| 3 | `game.html` | ./tanks: лобби + игра (танки, собственная разработка — без привязки к товарным знакам) |
|
||||||
|
|
||||||
## 3. Структура проекта
|
## 3. Структура проекта
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/* --- общие стили для одиночных аркад (./blocks, ./invaders) --- */
|
||||||
|
|
||||||
|
.arcade-crosslinks {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-canvas-slot {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-canvas-slot canvas {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-silver);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-stats b {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-next-slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-next-slot span {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-next-slot canvas {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-controls__hint {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-dpad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 44px);
|
||||||
|
grid-template-rows: repeat(2, 44px);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-dpad button {
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-dpad button:active {
|
||||||
|
background: var(--color-accent-dim);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-dpad__up { grid-column: 2; grid-row: 1; }
|
||||||
|
.arcade-dpad__left { grid-column: 1; grid-row: 2; }
|
||||||
|
.arcade-dpad__right { grid-column: 3; grid-row: 2; }
|
||||||
|
.arcade-dpad__down { grid-column: 2; grid-row: 2; }
|
||||||
|
|
||||||
|
/* --- пара кнопок влево/вправо (./invaders — вертикаль не нужна) --- */
|
||||||
|
.arcade-lr {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-lr button {
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
width: 62px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-lr button:active {
|
||||||
|
background: var(--color-accent-dim);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-action-btn {
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-action-btn:active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result-overlay {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(10, 14, 12, 0.92);
|
||||||
|
z-index: 5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result {
|
||||||
|
padding: 2rem;
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result h2 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-shadow: 0 0 16px var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result p {
|
||||||
|
color: var(--color-fg);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result__actions button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result__actions button:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 6px var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.arcade-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-controls {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -1,3 +1,9 @@
|
|||||||
|
.arcade-crosslinks {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.lobby-panel {
|
.lobby-panel {
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
background: var(--color-bg-raised);
|
background: var(--color-bg-raised);
|
||||||
@@ -89,7 +95,7 @@
|
|||||||
color: var(--color-accent-dim);
|
color: var(--color-accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
#battlecity-canvas-container {
|
#tanks-canvas-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
@@ -115,6 +121,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-controls__hint {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dpad {
|
.dpad {
|
||||||
|
|||||||
+33
-43
@@ -1,63 +1,53 @@
|
|||||||
|
.page-heading {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
text-shadow: 0 0 24px oklch(55% 0.19 142 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subheading {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.project-grid {
|
.project-grid {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card {
|
.project-card {
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
background: var(--color-bg-raised);
|
padding: 1.5rem;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card__bar {
|
.project-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 0.5rem;
|
justify-content: space-between;
|
||||||
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;
|
gap: 0.75rem;
|
||||||
flex: 1;
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card__title {
|
.project-card__name {
|
||||||
margin: 0;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__perms {
|
||||||
|
color: var(--color-accent-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card__desc {
|
.project-card__desc {
|
||||||
margin: 0;
|
margin: 0 0 0.75rem;
|
||||||
color: var(--color-fg);
|
color: var(--color-fg-dim);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
line-height: 1.6;
|
||||||
|
|
||||||
.project-card__link {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card__link::before {
|
|
||||||
content: '$ ';
|
|
||||||
color: var(--color-accent-dim);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-34
@@ -18,65 +18,113 @@
|
|||||||
|
|
||||||
.hero-text .tagline {
|
.hero-text .tagline {
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
color: var(--color-silver);
|
color: var(--color-accent-mid);
|
||||||
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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent-mid);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
box-shadow: 0 0 10px var(--color-accent);
|
||||||
|
animation: blink 1.6s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
margin: 3rem 0;
|
margin: 3rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
section > h2 {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about p {
|
.about p {
|
||||||
max-width: 68ch;
|
max-width: 68ch;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--color-fg);
|
||||||
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
border-left: var(--border);
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0 0 1.5rem;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item {
|
.timeline-item {
|
||||||
position: relative;
|
border-left: var(--border);
|
||||||
margin-bottom: 2rem;
|
padding-left: 1rem;
|
||||||
}
|
margin-bottom: 1.75rem;
|
||||||
|
|
||||||
.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 {
|
.timeline-item__meta {
|
||||||
color: var(--color-silver);
|
color: var(--color-silver-dim);
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item__role {
|
.timeline-item__role {
|
||||||
color: var(--color-accent);
|
color: var(--color-fg);
|
||||||
margin: 0.15rem 0 0.5rem;
|
font-weight: 600;
|
||||||
font-size: 1rem;
|
margin: 0 0 0.15rem;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item__company {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item ul {
|
.timeline-item ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.2rem;
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item li::marker {
|
.timeline-item li {
|
||||||
color: var(--color-accent-dim);
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item .dash {
|
||||||
|
color: var(--color-accent-mid);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-list {
|
.contact-list {
|
||||||
@@ -85,12 +133,73 @@ section > h2 {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-list a::before {
|
.contact-list__label {
|
||||||
content: '$ ';
|
color: var(--color-silver-dim);
|
||||||
color: var(--color-accent-dim);
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-group__title {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edu-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edu-item__year {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edu-item__degree {
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edu-item__school {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edu-item__detail {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
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) {
|
||||||
|
|||||||
+91
-45
@@ -1,12 +1,17 @@
|
|||||||
:root {
|
:root {
|
||||||
--color-bg: #0a0e0c;
|
--color-bg: #050705;
|
||||||
--color-bg-raised: #0f1512;
|
--color-bg-raised: #0d100d;
|
||||||
--color-fg: #c7c7c7;
|
--color-fg: oklch(84% 0.01 240);
|
||||||
--color-accent: #00ff41;
|
--color-fg-dim: oklch(76% 0.01 240);
|
||||||
--color-accent-dim: #0a8f2c;
|
--color-accent: oklch(80% 0.19 142);
|
||||||
--color-silver: #9fa3a0;
|
--color-accent-bright: oklch(88% 0.17 142);
|
||||||
--font-mono: 'JetBrains Mono', 'Share Tech Mono', ui-monospace, monospace;
|
--color-accent-mid: oklch(55% 0.13 142);
|
||||||
--border: 1px solid #163020;
|
--color-accent-dim: oklch(50% 0.13 142);
|
||||||
|
--color-silver: oklch(58% 0.01 240);
|
||||||
|
--color-silver-dim: oklch(48% 0.01 240);
|
||||||
|
--color-num: oklch(40% 0.02 240);
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
--border: 1px solid oklch(32% 0.06 142 / 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -25,9 +30,19 @@ body {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* лёгкий градиент глубины поверх дождя, под контентом */
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, rgba(4, 6, 4, 0.35), rgba(4, 6, 4, 0.55) 220px, rgba(4, 6, 4, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-accent-dim);
|
background: oklch(42% 0.14 142);
|
||||||
color: var(--color-bg);
|
color: #eafff0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -48,7 +63,7 @@ button:focus-visible {
|
|||||||
.container {
|
.container {
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2.5rem 1.5rem 4rem;
|
padding: 2rem 1.5rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- фон: canvas цифрового дождя --- */
|
/* --- фон: canvas цифрового дождя --- */
|
||||||
@@ -67,23 +82,33 @@ body::before {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: repeating-linear-gradient(
|
background: repeating-linear-gradient(
|
||||||
to bottom,
|
180deg,
|
||||||
rgba(0, 0, 0, 0.15) 0px,
|
rgba(0, 0, 0, 0) 0px,
|
||||||
rgba(0, 0, 0, 0.15) 1px,
|
rgba(0, 0, 0, 0) 2px,
|
||||||
transparent 1px,
|
rgba(80, 255, 140, 0.025) 3px
|
||||||
transparent 3px
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- шапка --- */
|
/* --- шапка: прилипает к верху, слегка размыта поверх дождя --- */
|
||||||
.site-header {
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
background: rgba(4, 6, 4, 0.55);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: var(--border);
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.1rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -101,8 +126,18 @@ body::before {
|
|||||||
filter: drop-shadow(0 0 4px var(--color-accent-dim));
|
filter: drop-shadow(0 0 4px var(--color-accent-dim));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 49% { opacity: 1; }
|
||||||
|
50%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo .cursor {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -118,38 +153,29 @@ body::before {
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 6px var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- терминальный префикс перед заголовками --- */
|
/* --- терминальный префикс перед заголовками --- */
|
||||||
.prompt::before {
|
.prompt::before {
|
||||||
content: 'cacto@matrix:~$ ';
|
content: 'cacto@matrix:~$ ';
|
||||||
color: var(--color-accent-dim);
|
color: var(--color-accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- терминальное окно-обёртка для блоков контента --- */
|
|
||||||
.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-арт --- */
|
||||||
.ascii-cactus {
|
.ascii-cactus {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
@@ -208,3 +234,23 @@ body::before {
|
|||||||
padding: 0.15rem 0.6rem;
|
padding: 0.15rem 0.6rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- нумерованный заголовок секции (01 / whoami, 02 / experience...) --- */
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--color-accent-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading__num {
|
||||||
|
color: var(--color-num);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|||||||
+12
-6
@@ -3,31 +3,37 @@
|
|||||||
<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>./battlecity — cacto</title>
|
<title>./tanks — cacto</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<link rel="stylesheet" href="/css/theme.css" />
|
||||||
<link rel="stylesheet" href="/css/game.css" />
|
<link rel="stylesheet" href="/css/game.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="matrix-rain"></canvas>
|
<canvas id="matrix-rain"></canvas>
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
<div class="site-header__inner">
|
||||||
<a href="/" class="logo" aria-label="cacto — на главную">
|
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||||
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||||
cacto
|
cacto<span class="cursor">_</span>
|
||||||
</a>
|
</a>
|
||||||
<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>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
<main id="game-root">
|
<main id="game-root">
|
||||||
<h2 class="prompt">./battlecity</h2>
|
<h2 class="prompt">./tanks</h2>
|
||||||
|
<p class="arcade-crosslinks"><span id="game-crosslinks-label"></span> <a href="/blocks">./blocks</a> · <a href="/invaders">./invaders</a></p>
|
||||||
<div id="lobby"></div>
|
<div id="lobby"></div>
|
||||||
<div id="battlecity-canvas-container" style="display: none"></div>
|
<div id="tanks-canvas-container" style="display: none"></div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+144
-51
@@ -3,32 +3,51 @@
|
|||||||
<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/Go backend</title>
|
<title>cacto — Гаммель Дмитрий, PHP backend-разработчик</title>
|
||||||
<meta name="description" content="Гаммель Дмитрий — backend-разработчик PHP/Go. Резюме, проекты, эксперименты." />
|
<meta name="description" content="Гаммель Дмитрий — backend-разработчик PHP. 6 лет коммерческого опыта: банковская IT-инфраструктура, ClickHouse-аналитика, legacy. Резюме, проекты." />
|
||||||
|
<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.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<link rel="stylesheet" href="/css/theme.css" />
|
||||||
<link rel="stylesheet" href="/css/resume.css" />
|
<link rel="stylesheet" href="/css/resume.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="matrix-rain"></canvas>
|
<canvas id="matrix-rain"></canvas>
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
<div class="site-header__inner">
|
||||||
<a href="/" class="logo" aria-label="cacto — на главную">
|
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||||
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||||
cacto
|
cacto<span class="cursor">_</span>
|
||||||
</a>
|
</a>
|
||||||
<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>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<div class="container">
|
||||||
|
<!--
|
||||||
|
Статика ниже — серверный 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">
|
<section class="hero">
|
||||||
<pre class="ascii-cactus" aria-hidden="true">
|
<pre class="ascii-cactus" aria-hidden="true"> , ,
|
||||||
, ,
|
|
||||||
|\_/|
|
|\_/|
|
||||||
.--| |--.
|
.--| |--.
|
||||||
( | | )
|
( | | )
|
||||||
@@ -42,97 +61,171 @@
|
|||||||
__| |__
|
__| |__
|
||||||
(_________)</pre>
|
(_________)</pre>
|
||||||
<div class="hero-text">
|
<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>
|
<h1><span class="glitch" data-text="Гаммель Дмитрий">Гаммель Дмитрий</span></h1>
|
||||||
<p class="role">PHP / Go backend-разработчик</p>
|
<p class="role">PHP-разработчик (Backend, Middle+/Senior)</p>
|
||||||
<p class="tagline">Живучий, колючий, не требует много воды. Специализация — сложные запросы, legacy-код, архитектура.</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="about">
|
<section class="about">
|
||||||
<h2 class="prompt">cat about.txt</h2>
|
<h2 class="section-heading"><span class="section-heading__num">01</span>cat about.txt</h2>
|
||||||
<div class="term-window">
|
<p>Backend-разработчик, 6 лет коммерческого опыта на PHP. Работаю с legacy, сложными запросами и архитектурными решениями — там, где «просто добавить фичу» не получается.</p>
|
||||||
<div class="term-window__bar"><span></span><span></span><span></span></div>
|
<p>Спроектировал аналитический контур на ClickHouse: отчёты формировались 3–4 минуты, стали за 10–15 секунд. Вёл разработку модуля документооборота в банковской IT-инфраструктуре — архитектура, декомпозиция, код-ревью, онбординг разработчиков.</p>
|
||||||
<div class="term-window__body">
|
<p>Начинал с электротехники и АСУТП: писал управляющий софт для промышленных систем на ST, поднимал SCADA. Путь «от электрического сигнала до строчки кода» помогает быстро вникать в чужие системы и в задачи на стыке железа и бэкенда.</p>
|
||||||
<p>
|
<p>Ищу позицию Middle+/Senior backend. Основной стек — PHP, параллельно пишу на Go.</p>
|
||||||
Backend-разработчик с 6-летним коммерческим опытом на PHP: сложные запросы к базам данных,
|
|
||||||
работа с legacy-кодом, архитектурные решения. Спроектировал аналитический контур на ClickHouse,
|
|
||||||
ускоривший формирование отчётности в 15–20 раз; неофициально возглавлял разработку модуля
|
|
||||||
документооборота в банковской IT-инфраструктуре.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Начинал с электротехники и АСУТП — писал управляющий софт для автоматизированных систем.
|
|
||||||
Путь «от электрического сигнала до строчки кода» помогает быстро разбираться в сложных
|
|
||||||
legacy-системах и нестандартных задачах.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Сейчас изучаю Go и продолжаю искать позицию Middle+/Senior backend-разработчика.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="experience">
|
<section class="experience">
|
||||||
<h2 class="prompt">ls -la experience/</h2>
|
<h2 class="section-heading"><span class="section-heading__num">02</span>ls -la experience/</h2>
|
||||||
<ol class="timeline">
|
<ol class="timeline">
|
||||||
<li class="timeline-item">
|
<li class="timeline-item">
|
||||||
<div class="timeline-item__meta">июль 2022 — март 2026 · Екатеринбург</div>
|
<div class="timeline-item__meta">март 2026 — настоящее время · 5 месяцев</div>
|
||||||
<h3 class="timeline-item__role">PHP-разработчик — ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк»)</h3>
|
<h3 class="timeline-item__role">Программист АСУТП и КИПиА</h3>
|
||||||
|
<div class="timeline-item__company">Уралэнергоаква · Екатеринбург</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Поддерживал legacy на Zend Framework, участвовал в переходе на Symfony; PHP 7.4 → 8.0.</li>
|
<li><span class="dash">-</span><span>Временный период вне основной специальности после завершения проекта в ЭТП ГПБ. Ищу позицию backend-разработчика; параллельно — пет-проекты и Go.</span></li>
|
||||||
<li>Спроектировал аналитический контур отчётности на ClickHouse — сократил время формирования отчётов с 3–4 минут до 10–15 секунд.</li>
|
|
||||||
<li>Интеграции с внутренними банковскими сервисами (проверка ЭП, машиночитаемые доверенности), очереди RabbitMQ.</li>
|
|
||||||
<li>Неофициально возглавлял разработку нового модуля документооборота: архитектура, код-ревью, MVP.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="timeline-item">
|
<li class="timeline-item">
|
||||||
<div class="timeline-item__meta">март 2021 — июль 2022 · фриланс/субподряд</div>
|
<div class="timeline-item__meta">июль 2022 — март 2026 · 3 года 9 месяцев</div>
|
||||||
<h3 class="timeline-item__role">PHP-разработчик — kupisever.ru</h3>
|
<h3 class="timeline-item__role">PHP-разработчик</h3>
|
||||||
|
<div class="timeline-item__company">ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк») · Екатеринбург</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Доработка бэкенда B2B-платформы (доска объявлений) на Yii2, стек PHP/PostgreSQL/RabbitMQ/Docker.</li>
|
<li><span class="dash">-</span><span>Разрабатывал и поддерживал внутреннюю систему электронного документооборота для согласования и контроля исполнения договоров.</span></li>
|
||||||
<li>Модуль email-рассылок с очередями сообщений и конструктором писем.</li>
|
<li><span class="dash">-</span><span>Поддерживал и развивал legacy-проект на Zend Framework, участвовал в архитектурном переходе на Symfony; PHP 7.4 → 8.0.</span></li>
|
||||||
<li>Модуль категорий объявлений — админка и клиентское меню.</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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="timeline-item">
|
<li class="timeline-item">
|
||||||
<div class="timeline-item__meta">июль 2020 — март 2021 · Курган</div>
|
<div class="timeline-item__meta">март 2021 — июль 2022 · 1 год 5 месяцев</div>
|
||||||
<h3 class="timeline-item__role">PHP-разработчик (с обязанностями руководителя группы) — ИстВуд</h3>
|
<h3 class="timeline-item__role">PHP-разработчик</h3>
|
||||||
|
<div class="timeline-item__company">фриланс/субподряд · kupisever.ru · удалённо</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Интернет-магазины на 1С-Битрикс, интеграции с 1С и платёжными системами.</li>
|
<li><span class="dash">-</span><span>Дорабатывал бэкенд действующей B2B-платформы (доска объявлений) на Yii2 в качестве субподрядчика, работал самостоятельно на удалённом проекте. Стек: PHP, Yii2, PostgreSQL, RabbitMQ, Docker.</span></li>
|
||||||
<li>Фактически руководил командой из 3 разработчиков: задачи, код-ревью, технические собеседования.</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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="skills">
|
<section class="skills">
|
||||||
<h2 class="prompt">skills --list</h2>
|
<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">
|
<ul class="tag-list">
|
||||||
<li class="tag">PHP8</li>
|
<li class="tag">PHP8</li>
|
||||||
|
<li class="tag">PHP7</li>
|
||||||
<li class="tag">Symfony</li>
|
<li class="tag">Symfony</li>
|
||||||
<li class="tag">Zend Framework</li>
|
<li class="tag">Zend Framework</li>
|
||||||
<li class="tag">Yii2</li>
|
<li class="tag">Yii2</li>
|
||||||
<li class="tag">PostgreSQL</li>
|
<li class="tag">PostgreSQL</li>
|
||||||
<li class="tag">MySQL</li>
|
<li class="tag">MySQL</li>
|
||||||
<li class="tag">ClickHouse</li>
|
<li class="tag">ClickHouse</li>
|
||||||
|
<li class="tag">SQL</li>
|
||||||
<li class="tag">RabbitMQ</li>
|
<li class="tag">RabbitMQ</li>
|
||||||
<li class="tag">Docker</li>
|
<li class="tag">Docker</li>
|
||||||
|
<li class="tag">Git</li>
|
||||||
<li class="tag">REST API</li>
|
<li class="tag">REST API</li>
|
||||||
<li class="tag">PHPUnit</li>
|
<li class="tag">PHPUnit</li>
|
||||||
|
<li class="tag">Composer</li>
|
||||||
<li class="tag">ООП</li>
|
<li class="tag">ООП</li>
|
||||||
<li class="tag">Go (изучаю)</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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="contact">
|
<section class="contact">
|
||||||
<h2 class="prompt">cat contact.txt</h2>
|
<h2 class="section-heading"><span class="section-heading__num">05</span>cat contact.txt</h2>
|
||||||
<ul class="contact-list">
|
<ul class="contact-list">
|
||||||
<li><a href="mailto:cactozzz93@gmail.com">cactozzz93@gmail.com</a></li>
|
<li><span class="contact-list__label">email</span><a href="mailto:cactozzz93@gmail.com">cactozzz93@gmail.com</a></li>
|
||||||
<li><a href="https://t.me/Cactozz">t.me/Cactozz</a></li>
|
<li><span class="contact-list__label">telegram</span><a href="https://t.me/Cactozz">t.me/Cactozz</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
||||||
|
<script type="module" src="/js/resume.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { getLang, initLangToggle } from './i18n.js';
|
||||||
|
|
||||||
|
const COLS = 10;
|
||||||
|
const ROWS = 20;
|
||||||
|
const CELL = 24;
|
||||||
|
const GRAVITY_START_MS = 800;
|
||||||
|
const GRAVITY_MIN_MS = 100;
|
||||||
|
|
||||||
|
const SHAPES = {
|
||||||
|
I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
|
||||||
|
J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
|
||||||
|
L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
|
||||||
|
O: [[1, 1], [1, 1]],
|
||||||
|
S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
|
||||||
|
T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
|
||||||
|
Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
I: '#00ff41',
|
||||||
|
O: '#9fa3a0',
|
||||||
|
T: '#39e075',
|
||||||
|
S: '#7cffb2',
|
||||||
|
Z: '#1fae56',
|
||||||
|
J: '#c7c7c7',
|
||||||
|
L: '#0a8f2c',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PIECE_TYPES = Object.keys(SHAPES);
|
||||||
|
|
||||||
|
const STR = {
|
||||||
|
ru: {
|
||||||
|
hint: 'стрелки — двигать, вверх — вращать, пробел — сброс',
|
||||||
|
score: 'счёт',
|
||||||
|
lines: 'линии',
|
||||||
|
level: 'уровень',
|
||||||
|
next: 'дальше',
|
||||||
|
hardDrop: 'сброс',
|
||||||
|
gameOver: 'ИГРА ОКОНЧЕНА',
|
||||||
|
finalScore: (s) => `счёт: ${s}`,
|
||||||
|
again: 'ещё раз',
|
||||||
|
crosslinks: 'ещё игры:',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
hint: 'arrows to move, up to rotate, space to hard-drop',
|
||||||
|
score: 'score',
|
||||||
|
lines: 'lines',
|
||||||
|
level: 'level',
|
||||||
|
next: 'next',
|
||||||
|
hardDrop: 'drop',
|
||||||
|
gameOver: 'GAME OVER',
|
||||||
|
finalScore: (s) => `score: ${s}`,
|
||||||
|
again: 'play again',
|
||||||
|
crosslinks: 'more games:',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t() {
|
||||||
|
return STR[getLang()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyBoard() {
|
||||||
|
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateMatrix(matrix) {
|
||||||
|
const n = matrix.length;
|
||||||
|
const result = Array.from({ length: n }, () => Array(n).fill(0));
|
||||||
|
for (let y = 0; y < n; y++) {
|
||||||
|
for (let x = 0; x < n; x++) {
|
||||||
|
result[x][n - 1 - y] = matrix[y][x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomType() {
|
||||||
|
return PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlocksGame {
|
||||||
|
constructor(canvas, nextCanvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.nextCanvas = nextCanvas;
|
||||||
|
this.nextCtx = nextCanvas.getContext('2d');
|
||||||
|
canvas.width = COLS * CELL;
|
||||||
|
canvas.height = ROWS * CELL;
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.board = emptyBoard();
|
||||||
|
this.score = 0;
|
||||||
|
this.lines = 0;
|
||||||
|
this.level = 1;
|
||||||
|
this.over = false;
|
||||||
|
this.nextType = randomType();
|
||||||
|
this.spawn();
|
||||||
|
this.dropTimer = 0;
|
||||||
|
this.lastTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn() {
|
||||||
|
const type = this.nextType;
|
||||||
|
this.nextType = randomType();
|
||||||
|
const matrix = SHAPES[type].map((row) => row.slice());
|
||||||
|
this.piece = {
|
||||||
|
type,
|
||||||
|
matrix,
|
||||||
|
x: Math.floor((COLS - matrix.length) / 2),
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
if (this.collides(this.piece.matrix, this.piece.x, this.piece.y)) {
|
||||||
|
this.over = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collides(matrix, offX, offY) {
|
||||||
|
for (let y = 0; y < matrix.length; y++) {
|
||||||
|
for (let x = 0; x < matrix[y].length; x++) {
|
||||||
|
if (!matrix[y][x]) continue;
|
||||||
|
const bx = offX + x;
|
||||||
|
const by = offY + y;
|
||||||
|
if (bx < 0 || bx >= COLS || by >= ROWS) return true;
|
||||||
|
if (by >= 0 && this.board[by][bx]) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
move(dx, dy) {
|
||||||
|
if (this.over) return false;
|
||||||
|
const p = this.piece;
|
||||||
|
if (!this.collides(p.matrix, p.x + dx, p.y + dy)) {
|
||||||
|
p.x += dx;
|
||||||
|
p.y += dy;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotate() {
|
||||||
|
if (this.over) return;
|
||||||
|
const p = this.piece;
|
||||||
|
const rotated = rotateMatrix(p.matrix);
|
||||||
|
const kicks = [0, 1, -1, 2, -2];
|
||||||
|
for (const kick of kicks) {
|
||||||
|
if (!this.collides(rotated, p.x + kick, p.y)) {
|
||||||
|
p.matrix = rotated;
|
||||||
|
p.x += kick;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hardDrop() {
|
||||||
|
if (this.over) return;
|
||||||
|
while (this.move(0, 1)) {
|
||||||
|
this.score += 1;
|
||||||
|
}
|
||||||
|
this.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
lock() {
|
||||||
|
const p = this.piece;
|
||||||
|
for (let y = 0; y < p.matrix.length; y++) {
|
||||||
|
for (let x = 0; x < p.matrix[y].length; x++) {
|
||||||
|
if (p.matrix[y][x]) {
|
||||||
|
const by = p.y + y;
|
||||||
|
const bx = p.x + x;
|
||||||
|
if (by >= 0) this.board[by][bx] = p.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.clearLines();
|
||||||
|
this.spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLines() {
|
||||||
|
let cleared = 0;
|
||||||
|
this.board = this.board.filter((row) => {
|
||||||
|
const full = row.every((cell) => cell);
|
||||||
|
if (full) cleared++;
|
||||||
|
return !full;
|
||||||
|
});
|
||||||
|
while (this.board.length < ROWS) {
|
||||||
|
this.board.unshift(Array(COLS).fill(null));
|
||||||
|
}
|
||||||
|
if (cleared > 0) {
|
||||||
|
const points = [0, 100, 300, 500, 800][cleared] || 800;
|
||||||
|
this.score += points * this.level;
|
||||||
|
this.lines += cleared;
|
||||||
|
this.level = 1 + Math.floor(this.lines / 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gravityInterval() {
|
||||||
|
return Math.max(GRAVITY_MIN_MS, GRAVITY_START_MS - (this.level - 1) * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(dtMs) {
|
||||||
|
if (this.over) return;
|
||||||
|
this.dropTimer += dtMs;
|
||||||
|
const interval = this.gravityInterval();
|
||||||
|
if (this.dropTimer >= interval) {
|
||||||
|
this.dropTimer = 0;
|
||||||
|
if (!this.move(0, 1)) {
|
||||||
|
this.lock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCell(ctx, x, y, color) {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
|
||||||
|
ctx.strokeStyle = '#0a0e0c';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.fillStyle = '#0a0e0c';
|
||||||
|
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (this.board[y][x]) {
|
||||||
|
this.drawCell(ctx, x, y, COLORS[this.board[y][x]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = this.piece;
|
||||||
|
if (p) {
|
||||||
|
for (let y = 0; y < p.matrix.length; y++) {
|
||||||
|
for (let x = 0; x < p.matrix[y].length; x++) {
|
||||||
|
if (p.matrix[y][x] && p.y + y >= 0) {
|
||||||
|
this.drawCell(ctx, p.x + x, p.y + y, COLORS[p.type]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// сетка поверх, лёгкая
|
||||||
|
ctx.strokeStyle = 'rgba(80, 255, 140, 0.06)';
|
||||||
|
for (let x = 1; x < COLS; x++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x * CELL, 0);
|
||||||
|
ctx.lineTo(x * CELL, this.canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 1; y < ROWS; y++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y * CELL);
|
||||||
|
ctx.lineTo(this.canvas.width, y * CELL);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawNext() {
|
||||||
|
const ctx = this.nextCtx;
|
||||||
|
const size = this.nextCanvas.width;
|
||||||
|
ctx.fillStyle = '#0f1512';
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
const matrix = SHAPES[this.nextType];
|
||||||
|
const cell = Math.floor(size / 4);
|
||||||
|
const offset = (4 - matrix.length) / 2;
|
||||||
|
for (let y = 0; y < matrix.length; y++) {
|
||||||
|
for (let x = 0; x < matrix[y].length; x++) {
|
||||||
|
if (matrix[y][x]) {
|
||||||
|
ctx.fillStyle = COLORS[this.nextType];
|
||||||
|
ctx.fillRect((x + offset) * cell + 1, (y + offset) * cell + 1, cell - 2, cell - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = null;
|
||||||
|
let rafId = null;
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
document.getElementById('arcade-score').textContent = game.score;
|
||||||
|
document.getElementById('arcade-lines').textContent = game.lines;
|
||||||
|
document.getElementById('arcade-level').textContent = game.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGameOver() {
|
||||||
|
const overlay = document.getElementById('arcade-result-overlay');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="arcade-result">
|
||||||
|
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
||||||
|
<p>${t().finalScore(game.score)}</p>
|
||||||
|
<div class="arcade-result__actions">
|
||||||
|
<button data-action="again">${t().again}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(time) {
|
||||||
|
if (game.lastTime === null) game.lastTime = time;
|
||||||
|
const dt = time - game.lastTime;
|
||||||
|
game.lastTime = time;
|
||||||
|
const wasOver = game.over;
|
||||||
|
game.tick(dt);
|
||||||
|
game.draw();
|
||||||
|
updateStats();
|
||||||
|
if (game.over && !wasOver) {
|
||||||
|
showGameOver();
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
const overlay = document.getElementById('arcade-result-overlay');
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
overlay.innerHTML = '';
|
||||||
|
game.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindKeyboard() {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (!game) return;
|
||||||
|
if (game.over) {
|
||||||
|
if (e.code === 'Space' || e.code === 'Enter') restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (e.code) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
game.move(-1, 0);
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
game.move(1, 0);
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
if (game.move(0, 1)) game.score += 1;
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
game.rotate();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'Space':
|
||||||
|
game.hardDrop();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTouchControls(root) {
|
||||||
|
const actions = {
|
||||||
|
left: () => game.move(-1, 0),
|
||||||
|
right: () => game.move(1, 0),
|
||||||
|
down: () => {
|
||||||
|
if (game.move(0, 1)) game.score += 1;
|
||||||
|
},
|
||||||
|
rotate: () => game.rotate(),
|
||||||
|
drop: () => game.hardDrop(),
|
||||||
|
};
|
||||||
|
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||||
|
const action = actions[btn.dataset.input];
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (game.over) return;
|
||||||
|
action();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShell(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/invaders">./invaders</a></p>
|
||||||
|
<div class="arcade-layout">
|
||||||
|
<div class="arcade-canvas-slot">
|
||||||
|
<canvas id="arcade-canvas"></canvas>
|
||||||
|
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arcade-controls">
|
||||||
|
<div class="arcade-stats">
|
||||||
|
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
||||||
|
<div><span id="arcade-lines-label">${t().lines.toUpperCase()}</span> <b id="arcade-lines">0</b></div>
|
||||||
|
<div><span id="arcade-level-label">${t().level.toUpperCase()}</span> <b id="arcade-level">1</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="arcade-next-slot">
|
||||||
|
<span id="arcade-next-label">${t().next}</span>
|
||||||
|
<canvas id="arcade-next" width="80" height="80"></canvas>
|
||||||
|
</div>
|
||||||
|
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
||||||
|
<div class="arcade-dpad">
|
||||||
|
<button class="arcade-dpad__up" data-input="rotate">↑</button>
|
||||||
|
<button class="arcade-dpad__left" data-input="left">←</button>
|
||||||
|
<button class="arcade-dpad__right" data-input="right">→</button>
|
||||||
|
<button class="arcade-dpad__down" data-input="down">↓</button>
|
||||||
|
</div>
|
||||||
|
<button class="arcade-action-btn" data-input="drop" id="arcade-drop-btn">${t().hardDrop}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляет статичные подписи без пересоздания игры — иначе переключение
|
||||||
|
// языка посреди партии сбрасывало бы прогресс, как уже было с ./tanks.
|
||||||
|
function updateStaticText() {
|
||||||
|
const byId = (id) => document.getElementById(id);
|
||||||
|
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
|
||||||
|
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
|
||||||
|
if (byId('arcade-lines-label')) byId('arcade-lines-label').textContent = t().lines.toUpperCase();
|
||||||
|
if (byId('arcade-level-label')) byId('arcade-level-label').textContent = t().level.toUpperCase();
|
||||||
|
if (byId('arcade-next-label')) byId('arcade-next-label').textContent = t().next;
|
||||||
|
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
|
||||||
|
if (byId('arcade-drop-btn')) byId('arcade-drop-btn').textContent = t().hardDrop;
|
||||||
|
if (game?.over) showGameOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const root = document.getElementById('arcade-root');
|
||||||
|
renderShell(root);
|
||||||
|
|
||||||
|
const canvas = document.getElementById('arcade-canvas');
|
||||||
|
const nextCanvas = document.getElementById('arcade-next');
|
||||||
|
game = new BlocksGame(canvas, nextCanvas);
|
||||||
|
|
||||||
|
bindTouchControls(root.querySelector('.arcade-controls'));
|
||||||
|
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-action="again"]')) restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindKeyboard();
|
||||||
|
init();
|
||||||
|
initLangToggle(updateStaticText);
|
||||||
+73
-18
@@ -1,6 +1,38 @@
|
|||||||
import { ClientMessage, ServerMessage } from './protocol.js';
|
import { ClientMessage, ServerMessage } from './protocol.js';
|
||||||
import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.js';
|
import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.js';
|
||||||
import { playShot, playWallHit, playExplosion, playWin, playLose } from './audio.js';
|
import { playShot, playWallHit, playExplosion, playWin, playLose } from './audio.js';
|
||||||
|
import { getLang } from '../i18n.js';
|
||||||
|
|
||||||
|
const STR = {
|
||||||
|
ru: {
|
||||||
|
controlsHint: 'WASD / стрелки + пробел тоже работают',
|
||||||
|
fire: 'огонь',
|
||||||
|
titles: { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' },
|
||||||
|
rematch: 'играть ещё',
|
||||||
|
toLobby: 'в лобби',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
controlsHint: 'WASD / arrow keys + space also work',
|
||||||
|
fire: 'fire',
|
||||||
|
titles: { win: 'VICTORY', lose: 'DEFEAT', draw: 'DRAW' },
|
||||||
|
rematch: 'play again',
|
||||||
|
toLobby: 'to lobby',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t() {
|
||||||
|
return STR[getLang()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрируем один раз на модуль: обновляем статичные подписи в уже
|
||||||
|
// отрисованной панели управления и оверлей победы/поражения, если он открыт.
|
||||||
|
window.addEventListener('cacto:langchange', () => {
|
||||||
|
const hint = document.querySelector('.battle-controls__hint');
|
||||||
|
if (hint) hint.textContent = t().controlsHint;
|
||||||
|
const fireBtn = document.querySelector('.fire-btn');
|
||||||
|
if (fireBtn) fireBtn.textContent = t().fire;
|
||||||
|
if (lastOverPayload) renderOverlay(lastOverPayload);
|
||||||
|
});
|
||||||
|
|
||||||
const TILE_PX = 32;
|
const TILE_PX = 32;
|
||||||
// Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform)
|
// Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform)
|
||||||
@@ -52,9 +84,13 @@ function setupEntities() {
|
|||||||
ctx.strokeStyle = '#0a0e0c';
|
ctx.strokeStyle = '#0a0e0c';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// ствол — явно торчит вперёд по направлению взгляда (локально «вверх»)
|
// ствол — явно торчит вперёд по направлению взгляда (локально «вверх»).
|
||||||
ctx.fillStyle = '#0a0e0c';
|
// Ярко-зелёный с тёмной обводкой — иначе на тёмном корпусе не виден.
|
||||||
|
ctx.fillStyle = '#00ff41';
|
||||||
|
ctx.strokeStyle = '#0a0e0c';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
ctx.fillRect(-2.5, -p.h / 2 - 12, 5, 16);
|
ctx.fillRect(-2.5, -p.h / 2 - 12, 5, 16);
|
||||||
|
ctx.strokeRect(-2.5, -p.h / 2 - 12, 5, 16);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,7 +152,9 @@ function ensureQuintus(cols, rows) {
|
|||||||
Q = window.Quintus().include('Sprites, Scenes, 2D, Input').setup({
|
Q = window.Quintus().include('Sprites, Scenes, 2D, Input').setup({
|
||||||
width: cols * TILE_PX,
|
width: cols * TILE_PX,
|
||||||
height: rows * TILE_PX,
|
height: rows * TILE_PX,
|
||||||
|
autoFocus: true,
|
||||||
});
|
});
|
||||||
|
Q.el.tabIndex = 1;
|
||||||
Q.input.keyboardControls({
|
Q.input.keyboardControls({
|
||||||
LEFT: 'left', RIGHT: 'right', UP: 'up', DOWN: 'down',
|
LEFT: 'left', RIGHT: 'right', UP: 'up', DOWN: 'down',
|
||||||
A: 'left', D: 'right', W: 'up', S: 'down',
|
A: 'left', D: 'right', W: 'up', S: 'down',
|
||||||
@@ -157,7 +195,12 @@ function bindControlButtons(root) {
|
|||||||
};
|
};
|
||||||
root.querySelectorAll('[data-input]').forEach((btn) => {
|
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||||
const input = btn.dataset.input;
|
const input = btn.dataset.input;
|
||||||
btn.addEventListener('mousedown', () => press(input, true));
|
// Клик по кнопке уводит фокус с канваса — а Quintus слушает клавиатуру
|
||||||
|
// именно на нём, так что после клика WASD/стрелки перестанут работать.
|
||||||
|
btn.addEventListener('mousedown', () => {
|
||||||
|
press(input, true);
|
||||||
|
Q?.el.focus();
|
||||||
|
});
|
||||||
btn.addEventListener('mouseup', () => press(input, false));
|
btn.addEventListener('mouseup', () => press(input, false));
|
||||||
btn.addEventListener('mouseleave', () => press(input, false));
|
btn.addEventListener('mouseleave', () => press(input, false));
|
||||||
btn.addEventListener('touchstart', (e) => {
|
btn.addEventListener('touchstart', (e) => {
|
||||||
@@ -271,6 +314,23 @@ function onState(payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastOverPayload = null;
|
||||||
|
|
||||||
|
function renderOverlay(payload) {
|
||||||
|
const title = t().titles[payload.result] || payload.result.toUpperCase();
|
||||||
|
const overlay = document.getElementById('battle-result-overlay');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="battle-result battle-result--${payload.result}">
|
||||||
|
<h2 class="glitch" data-text="${title}">${title}</h2>
|
||||||
|
<p>${payload.reason}</p>
|
||||||
|
<div class="battle-result__actions">
|
||||||
|
<button data-action="rematch">${t().rematch}</button>
|
||||||
|
<button data-action="to-lobby">${t().toLobby}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
function onOver(payload) {
|
function onOver(payload) {
|
||||||
polling = false;
|
polling = false;
|
||||||
if (payload.result === 'win') {
|
if (payload.result === 'win') {
|
||||||
@@ -278,19 +338,8 @@ function onOver(payload) {
|
|||||||
} else {
|
} else {
|
||||||
playLose();
|
playLose();
|
||||||
}
|
}
|
||||||
const titles = { win: 'ПОБЕДА', lose: 'ПОРАЖЕНИЕ', draw: 'НИЧЬЯ' };
|
lastOverPayload = payload;
|
||||||
const title = titles[payload.result] || payload.result.toUpperCase();
|
renderOverlay(payload);
|
||||||
const overlay = document.getElementById('battle-result-overlay');
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="battle-result battle-result--${payload.result}">
|
|
||||||
<h2 class="glitch" data-text="${title}">${title}</h2>
|
|
||||||
<p>${payload.reason}</p>
|
|
||||||
<div class="battle-result__actions">
|
|
||||||
<button data-action="rematch">играть ещё</button>
|
|
||||||
<button data-action="to-lobby">в лобби</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
overlay.style.display = 'flex';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOverlayClick(e) {
|
function onOverlayClick(e) {
|
||||||
@@ -299,6 +348,7 @@ function onOverlayClick(e) {
|
|||||||
const overlay = document.getElementById('battle-result-overlay');
|
const overlay = document.getElementById('battle-result-overlay');
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
overlay.innerHTML = '';
|
overlay.innerHTML = '';
|
||||||
|
lastOverPayload = null;
|
||||||
if (btn.dataset.action === 'rematch') {
|
if (btn.dataset.action === 'rematch') {
|
||||||
returnToLobbyContainer();
|
returnToLobbyContainer();
|
||||||
} else {
|
} else {
|
||||||
@@ -314,25 +364,30 @@ 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('battlecity-canvas-container');
|
const container = document.getElementById('tanks-canvas-container');
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="battle-layout">
|
<div class="battle-layout">
|
||||||
<div id="battle-canvas-slot"></div>
|
<div id="battle-canvas-slot"></div>
|
||||||
<div class="battle-controls">
|
<div class="battle-controls">
|
||||||
|
<p class="battle-controls__hint">${t().controlsHint}</p>
|
||||||
<div class="dpad">
|
<div class="dpad">
|
||||||
<button class="dpad__up" data-input="up">↑</button>
|
<button class="dpad__up" data-input="up">↑</button>
|
||||||
<button class="dpad__left" data-input="left">←</button>
|
<button class="dpad__left" data-input="left">←</button>
|
||||||
<button class="dpad__right" data-input="right">→</button>
|
<button class="dpad__right" data-input="right">→</button>
|
||||||
<button class="dpad__down" data-input="down">↓</button>
|
<button class="dpad__down" data-input="down">↓</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="fire-btn" data-input="fire">огонь</button>
|
<button class="fire-btn" data-input="fire">${t().fire}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="battle-result-overlay"></div>`;
|
<div id="battle-result-overlay"></div>`;
|
||||||
|
|
||||||
ensureQuintus(cols, rows);
|
ensureQuintus(cols, rows);
|
||||||
document.getElementById('battle-canvas-slot').appendChild(Q.el);
|
document.getElementById('battle-canvas-slot').appendChild(Q.el);
|
||||||
|
// Клавиатурные события Quintus слушает на самом канвасе — если фокус
|
||||||
|
// не на нём (а он теряется при каждом переприкреплении к DOM на реванш),
|
||||||
|
// WASD/стрелки/space просто никуда не долетают.
|
||||||
|
Q.el.focus();
|
||||||
bindControlButtons(container.querySelector('.battle-controls'));
|
bindControlButtons(container.querySelector('.battle-controls'));
|
||||||
container.querySelector('#battle-result-overlay').addEventListener('click', onOverlayClick);
|
container.querySelector('#battle-result-overlay').addEventListener('click', onOverlayClick);
|
||||||
|
|
||||||
|
|||||||
+61
-15
@@ -2,9 +2,51 @@ import { Network, ServerMessage } from './network.js';
|
|||||||
import { ClientMessage } from './protocol.js';
|
import { ClientMessage } from './protocol.js';
|
||||||
import { startBattle } from './battle.js';
|
import { startBattle } from './battle.js';
|
||||||
import { unlockAudio } from './audio.js';
|
import { unlockAudio } from './audio.js';
|
||||||
|
import { getLang } from '../i18n.js';
|
||||||
|
|
||||||
const container = document.getElementById('lobby');
|
const container = document.getElementById('lobby');
|
||||||
|
|
||||||
|
// Статичные подписи UI лобби переводим; данные из сервера (ники, room id,
|
||||||
|
// текст ошибок) остаются как есть — они генерируются на сервере только на русском.
|
||||||
|
const STR = {
|
||||||
|
ru: {
|
||||||
|
connecting: 'подключение...',
|
||||||
|
join: 'войти',
|
||||||
|
createCoop: 'создать комнату (coop)',
|
||||||
|
createPvp: 'создать комнату (pvp)',
|
||||||
|
refresh: 'обновить список',
|
||||||
|
empty: 'пока пусто — создай комнату',
|
||||||
|
ready: 'готов',
|
||||||
|
readySuffix: ' — готов',
|
||||||
|
waitingForSecond: 'жду второго игрока (1/2) — открой комнату во второй вкладке/другим человеком',
|
||||||
|
soloHint: 'можно начать соло, второй игрок сможет подключиться позже',
|
||||||
|
room: 'комната',
|
||||||
|
mode: 'режим:',
|
||||||
|
leave: 'выйти в лобби',
|
||||||
|
crosslinks: 'ещё игры:',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
connecting: 'connecting...',
|
||||||
|
join: 'join',
|
||||||
|
createCoop: 'create room (coop)',
|
||||||
|
createPvp: 'create room (pvp)',
|
||||||
|
refresh: 'refresh list',
|
||||||
|
empty: 'nothing here yet — create a room',
|
||||||
|
ready: 'ready',
|
||||||
|
readySuffix: ' — ready',
|
||||||
|
waitingForSecond: 'waiting for a second player (1/2) — open the room in another tab or have someone else join',
|
||||||
|
soloHint: 'you can start solo, a second player can join later',
|
||||||
|
room: 'room',
|
||||||
|
mode: 'mode:',
|
||||||
|
leave: 'leave to lobby',
|
||||||
|
crosslinks: 'more games:',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t() {
|
||||||
|
return STR[getLang()];
|
||||||
|
}
|
||||||
|
|
||||||
let net;
|
let net;
|
||||||
let myPlayerId = null;
|
let myPlayerId = null;
|
||||||
let rooms = [];
|
let rooms = [];
|
||||||
@@ -33,7 +75,7 @@ function roomRow(room) {
|
|||||||
<span class="tag">${room.mode}</span>
|
<span class="tag">${room.mode}</span>
|
||||||
<span class="room-list__count">${room.players}/${room.max_players}</span>
|
<span class="room-list__count">${room.players}/${room.max_players}</span>
|
||||||
<span class="room-list__state">${room.state}</span>
|
<span class="room-list__state">${room.state}</span>
|
||||||
<button data-action="join" data-room-id="${room.id}" ${full || room.state !== 'waiting' || !connected ? 'disabled' : ''}>войти</button>
|
<button data-action="join" data-room-id="${room.id}" ${full || room.state !== 'waiting' || !connected ? 'disabled' : ''}>${t().join}</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,18 +88,18 @@ function renderLobbyView() {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="lobby-panel">
|
<div class="lobby-panel">
|
||||||
${errorBanner()}
|
${errorBanner()}
|
||||||
${connected ? '' : '<p class="lobby-error">подключение...</p>'}
|
${connected ? '' : `<p class="lobby-error">${t().connecting}</p>`}
|
||||||
<div class="lobby-panel__row">
|
<div class="lobby-panel__row">
|
||||||
<label class="prompt-inline">nickname</label>
|
<label class="prompt-inline">nickname</label>
|
||||||
<input id="nickname-input" type="text" value="${escapeHtml(nickname)}" maxlength="20" ${dis} />
|
<input id="nickname-input" type="text" value="${escapeHtml(nickname)}" maxlength="20" ${dis} />
|
||||||
</div>
|
</div>
|
||||||
<div class="lobby-panel__row">
|
<div class="lobby-panel__row">
|
||||||
<button data-action="create" data-mode="coop" ${dis}>создать комнату (coop)</button>
|
<button data-action="create" data-mode="coop" ${dis}>${t().createCoop}</button>
|
||||||
<button data-action="create" data-mode="pvp" ${dis}>создать комнату (pvp)</button>
|
<button data-action="create" data-mode="pvp" ${dis}>${t().createPvp}</button>
|
||||||
<button data-action="refresh" ${dis}>обновить список</button>
|
<button data-action="refresh" ${dis}>${t().refresh}</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="room-list">
|
<ul class="room-list">
|
||||||
${rooms.length === 0 ? '<li class="room-list__empty">пока пусто — создай комнату</li>' : rooms.map(roomRow).join('')}
|
${rooms.length === 0 ? `<li class="room-list__empty">${t().empty}</li>` : rooms.map(roomRow).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -65,28 +107,30 @@ function renderLobbyView() {
|
|||||||
function renderRoomView() {
|
function renderRoomView() {
|
||||||
const full = currentRoom.players.length >= 2;
|
const full = currentRoom.players.length >= 2;
|
||||||
let statusLine = '';
|
let statusLine = '';
|
||||||
let readyBtn = '<button data-action="ready">готов</button>';
|
let readyBtn = `<button data-action="ready">${t().ready}</button>`;
|
||||||
if (currentRoom.mode === 'pvp' && !full) {
|
if (currentRoom.mode === 'pvp' && !full) {
|
||||||
statusLine = '<p class="lobby-error">жду второго игрока (1/2) — открой комнату во второй вкладке/другим человеком</p>';
|
statusLine = `<p class="lobby-error">${t().waitingForSecond}</p>`;
|
||||||
readyBtn = '<button data-action="ready" disabled>готов</button>';
|
readyBtn = `<button data-action="ready" disabled>${t().ready}</button>`;
|
||||||
} else if (currentRoom.mode === 'coop' && !full) {
|
} else if (currentRoom.mode === 'coop' && !full) {
|
||||||
statusLine = '<p class="lobby-error">можно начать соло, второй игрок сможет подключиться позже</p>';
|
statusLine = `<p class="lobby-error">${t().soloHint}</p>`;
|
||||||
}
|
}
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="lobby-panel">
|
<div class="lobby-panel">
|
||||||
${errorBanner()}
|
${errorBanner()}
|
||||||
<h3 class="prompt">room ${currentRoom.name}</h3>
|
<h3 class="prompt">${t().room} ${currentRoom.name}</h3>
|
||||||
<p>режим: <span class="tag">${currentRoom.mode}</span></p>
|
<p>${t().mode} <span class="tag">${currentRoom.mode}</span></p>
|
||||||
<ul class="room-players">
|
<ul class="room-players">
|
||||||
${currentRoom.players.map((p) => `<li>${escapeHtml(p.nickname)}${p.ready ? ' — готов' : ''}</li>`).join('')}
|
${currentRoom.players.map((p) => `<li>${escapeHtml(p.nickname)}${p.ready ? t().readySuffix : ''}</li>`).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
${statusLine}
|
${statusLine}
|
||||||
${readyBtn}
|
${readyBtn}
|
||||||
<button data-action="leave">выйти в лобби</button>
|
<button data-action="leave">${t().leave}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -96,7 +140,7 @@ function render() {
|
|||||||
|
|
||||||
// Вызывается из battle.js по кнопкам экрана победы/поражения.
|
// Вызывается из battle.js по кнопкам экрана победы/поражения.
|
||||||
export function returnToLobbyContainer() {
|
export function returnToLobbyContainer() {
|
||||||
document.getElementById('battlecity-canvas-container').style.display = 'none';
|
document.getElementById('tanks-canvas-container').style.display = 'none';
|
||||||
container.style.display = '';
|
container.style.display = '';
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -180,6 +224,8 @@ export function initLobby() {
|
|||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('cacto:langchange', render);
|
||||||
|
|
||||||
render();
|
render();
|
||||||
return net;
|
return net;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
// TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5
|
// TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5
|
||||||
import { initLobby } from './lobby.js';
|
import { initLobby } from './lobby.js';
|
||||||
|
import { initLangToggle } from '../i18n.js';
|
||||||
|
|
||||||
initLobby();
|
initLobby();
|
||||||
|
// lobby.js/battle.js сами слушают cacto:langchange и перерисовывают свои
|
||||||
|
// статичные подписи — здесь достаточно включить саму кнопку в шапке.
|
||||||
|
initLangToggle();
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const KEY = 'cacto-lang';
|
||||||
|
|
||||||
|
export function getLang() {
|
||||||
|
return localStorage.getItem(KEY) === 'en' ? 'en' : 'ru';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLang(lang) {
|
||||||
|
localStorage.setItem(KEY, lang);
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вешает обработчик на кнопку #lang-toggle в шапке и синхронизирует её
|
||||||
|
// подпись (показываем язык, НА который переключит клик, а не текущий).
|
||||||
|
export function initLangToggle(onChange) {
|
||||||
|
document.documentElement.lang = getLang();
|
||||||
|
const btn = document.getElementById('lang-toggle');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
btn.textContent = getLang() === 'ru' ? 'EN' : 'RU';
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
setLang(getLang() === 'ru' ? 'en' : 'ru');
|
||||||
|
sync();
|
||||||
|
window.dispatchEvent(new CustomEvent('cacto:langchange', { detail: getLang() }));
|
||||||
|
onChange?.(getLang());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { getLang, initLangToggle } from './i18n.js';
|
||||||
|
|
||||||
|
const CANVAS_W = 420;
|
||||||
|
const CANVAS_H = 520;
|
||||||
|
const PLAYER_Y = CANVAS_H - 40;
|
||||||
|
const PLAYER_W = 28;
|
||||||
|
const PLAYER_H = 14;
|
||||||
|
const PLAYER_SPEED = 220; // px/s
|
||||||
|
const BULLET_SPEED = 380;
|
||||||
|
const ENEMY_BULLET_SPEED = 180;
|
||||||
|
const FIRE_COOLDOWN_MS = 350;
|
||||||
|
const ENEMY_COLS = 8;
|
||||||
|
const ENEMY_ROWS = 4;
|
||||||
|
const ENEMY_W = 28;
|
||||||
|
const ENEMY_H = 18;
|
||||||
|
const ENEMY_GAP_X = 12;
|
||||||
|
const ENEMY_GAP_Y = 16;
|
||||||
|
const ENEMY_TOP = 50;
|
||||||
|
const ENEMY_STEP_DOWN = 18;
|
||||||
|
|
||||||
|
const STR = {
|
||||||
|
ru: {
|
||||||
|
hint: 'стрелки — двигать, пробел — огонь',
|
||||||
|
score: 'счёт',
|
||||||
|
lives: 'жизни',
|
||||||
|
wave: 'волна',
|
||||||
|
gameOver: 'ИГРА ОКОНЧЕНА',
|
||||||
|
finalScore: (s) => `счёт: ${s}`,
|
||||||
|
again: 'ещё раз',
|
||||||
|
crosslinks: 'ещё игры:',
|
||||||
|
fire: 'огонь',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
hint: 'arrows to move, space to fire',
|
||||||
|
score: 'score',
|
||||||
|
lives: 'lives',
|
||||||
|
wave: 'wave',
|
||||||
|
gameOver: 'GAME OVER',
|
||||||
|
finalScore: (s) => `score: ${s}`,
|
||||||
|
again: 'play again',
|
||||||
|
crosslinks: 'more games:',
|
||||||
|
fire: 'fire',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t() {
|
||||||
|
return STR[getLang()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectsOverlap(a, b) {
|
||||||
|
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvadersGame {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = CANVAS_W;
|
||||||
|
canvas.height = CANVAS_H;
|
||||||
|
this.input = { left: false, right: false, fire: false };
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.score = 0;
|
||||||
|
this.lives = 3;
|
||||||
|
this.wave = 1;
|
||||||
|
this.over = false;
|
||||||
|
this.player = { x: CANVAS_W / 2 - PLAYER_W / 2, y: PLAYER_Y };
|
||||||
|
this.bullets = [];
|
||||||
|
this.enemyBullets = [];
|
||||||
|
this.fireTimer = 0;
|
||||||
|
this.hitFlash = 0;
|
||||||
|
this.spawnWave();
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnWave() {
|
||||||
|
this.enemies = [];
|
||||||
|
const totalWidth = ENEMY_COLS * (ENEMY_W + ENEMY_GAP_X) - ENEMY_GAP_X;
|
||||||
|
const startX = (CANVAS_W - totalWidth) / 2;
|
||||||
|
for (let row = 0; row < ENEMY_ROWS; row++) {
|
||||||
|
for (let col = 0; col < ENEMY_COLS; col++) {
|
||||||
|
this.enemies.push({
|
||||||
|
x: startX + col * (ENEMY_W + ENEMY_GAP_X),
|
||||||
|
y: ENEMY_TOP + row * (ENEMY_H + ENEMY_GAP_Y),
|
||||||
|
w: ENEMY_W,
|
||||||
|
h: ENEMY_H,
|
||||||
|
alive: true,
|
||||||
|
points: (ENEMY_ROWS - row) * 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.enemyDir = 1;
|
||||||
|
this.enemySpeed = 30 + (this.wave - 1) * 12;
|
||||||
|
this.enemyShootTimer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aliveEnemies() {
|
||||||
|
return this.enemies.filter((e) => e.alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(dtMs) {
|
||||||
|
if (this.over) return;
|
||||||
|
const dt = dtMs / 1000;
|
||||||
|
|
||||||
|
if (this.input.left) this.player.x -= PLAYER_SPEED * dt;
|
||||||
|
if (this.input.right) this.player.x += PLAYER_SPEED * dt;
|
||||||
|
this.player.x = Math.max(0, Math.min(CANVAS_W - PLAYER_W, this.player.x));
|
||||||
|
|
||||||
|
this.fireTimer -= dtMs;
|
||||||
|
if (this.input.fire && this.fireTimer <= 0) {
|
||||||
|
this.bullets.push({ x: this.player.x + PLAYER_W / 2 - 2, y: this.player.y, w: 4, h: 10 });
|
||||||
|
this.fireTimer = FIRE_COOLDOWN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of this.bullets) b.y -= BULLET_SPEED * dt;
|
||||||
|
this.bullets = this.bullets.filter((b) => b.y + b.h > 0);
|
||||||
|
|
||||||
|
for (const b of this.enemyBullets) b.y += ENEMY_BULLET_SPEED * dt;
|
||||||
|
this.enemyBullets = this.enemyBullets.filter((b) => b.y < CANVAS_H);
|
||||||
|
|
||||||
|
const alive = this.aliveEnemies();
|
||||||
|
if (alive.length === 0) {
|
||||||
|
this.wave += 1;
|
||||||
|
this.spawnWave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hitEdge = false;
|
||||||
|
for (const e of alive) {
|
||||||
|
e.x += this.enemyDir * this.enemySpeed * dt;
|
||||||
|
if (e.x <= 0 || e.x + e.w >= CANVAS_W) hitEdge = true;
|
||||||
|
}
|
||||||
|
if (hitEdge) {
|
||||||
|
this.enemyDir *= -1;
|
||||||
|
for (const e of alive) {
|
||||||
|
e.y += ENEMY_STEP_DOWN;
|
||||||
|
if (e.y + e.h >= this.player.y) {
|
||||||
|
this.over = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enemyShootTimer -= dtMs;
|
||||||
|
if (this.enemyShootTimer <= 0 && alive.length > 0) {
|
||||||
|
const shooter = alive[Math.floor(Math.random() * alive.length)];
|
||||||
|
this.enemyBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
|
||||||
|
this.enemyShootTimer = Math.max(250, 900 - this.wave * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// пуля игрока vs враги
|
||||||
|
for (const bullet of this.bullets) {
|
||||||
|
for (const e of alive) {
|
||||||
|
if (!e.alive) continue;
|
||||||
|
if (rectsOverlap(bullet, e)) {
|
||||||
|
e.alive = false;
|
||||||
|
bullet.hit = true;
|
||||||
|
this.score += e.points;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.bullets = this.bullets.filter((b) => !b.hit);
|
||||||
|
|
||||||
|
// пуля врага vs игрок
|
||||||
|
const playerRect = { x: this.player.x, y: this.player.y, w: PLAYER_W, h: PLAYER_H };
|
||||||
|
for (const b of this.enemyBullets) {
|
||||||
|
if (rectsOverlap(b, playerRect)) {
|
||||||
|
b.hit = true;
|
||||||
|
this.lives -= 1;
|
||||||
|
this.hitFlash = 200;
|
||||||
|
if (this.lives <= 0) this.over = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.enemyBullets = this.enemyBullets.filter((b) => !b.hit);
|
||||||
|
|
||||||
|
if (this.hitFlash > 0) this.hitFlash -= dtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.fillStyle = '#0a0e0c';
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
|
||||||
|
for (const e of this.enemies) {
|
||||||
|
if (!e.alive) continue;
|
||||||
|
ctx.fillStyle = '#39e075';
|
||||||
|
ctx.fillRect(e.x, e.y, e.w, e.h);
|
||||||
|
ctx.fillStyle = '#0a0e0c';
|
||||||
|
ctx.fillRect(e.x + 4, e.y + 4, 4, 4);
|
||||||
|
ctx.fillRect(e.x + e.w - 8, e.y + 4, 4, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = this.hitFlash > 0 ? '#ffffff' : '#00ff41';
|
||||||
|
ctx.fillRect(this.player.x, this.player.y, PLAYER_W, PLAYER_H);
|
||||||
|
ctx.fillRect(this.player.x + PLAYER_W / 2 - 3, this.player.y - 6, 6, 6);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#c7c7c7';
|
||||||
|
for (const b of this.bullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||||
|
ctx.fillStyle = '#9fa3a0';
|
||||||
|
for (const b of this.enemyBullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = null;
|
||||||
|
let rafId = null;
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
document.getElementById('arcade-score').textContent = game.score;
|
||||||
|
document.getElementById('arcade-lives').textContent = game.lives;
|
||||||
|
document.getElementById('arcade-wave').textContent = game.wave;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGameOver() {
|
||||||
|
const overlay = document.getElementById('arcade-result-overlay');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="arcade-result">
|
||||||
|
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
||||||
|
<p>${t().finalScore(game.score)}</p>
|
||||||
|
<div class="arcade-result__actions">
|
||||||
|
<button data-action="again">${t().again}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(time) {
|
||||||
|
if (game.lastTime === undefined || game.lastTime === null) game.lastTime = time;
|
||||||
|
const dt = time - game.lastTime;
|
||||||
|
game.lastTime = time;
|
||||||
|
const wasOver = game.over;
|
||||||
|
game.tick(dt);
|
||||||
|
game.draw();
|
||||||
|
updateStats();
|
||||||
|
if (game.over && !wasOver) showGameOver();
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
const overlay = document.getElementById('arcade-result-overlay');
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
overlay.innerHTML = '';
|
||||||
|
game.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindKeyboard() {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (!game) return;
|
||||||
|
if (game.over) {
|
||||||
|
if (e.code === 'Space' || e.code === 'Enter') restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowLeft') { game.input.left = true; e.preventDefault(); }
|
||||||
|
if (e.code === 'ArrowRight') { game.input.right = true; e.preventDefault(); }
|
||||||
|
if (e.code === 'Space') { game.input.fire = true; e.preventDefault(); }
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', (e) => {
|
||||||
|
if (!game) return;
|
||||||
|
if (e.code === 'ArrowLeft') game.input.left = false;
|
||||||
|
if (e.code === 'ArrowRight') game.input.right = false;
|
||||||
|
if (e.code === 'Space') game.input.fire = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTouchControls(root) {
|
||||||
|
const press = (key, value) => {
|
||||||
|
if (game) game.input[key] = value;
|
||||||
|
};
|
||||||
|
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||||
|
const key = btn.dataset.input;
|
||||||
|
btn.addEventListener('mousedown', () => press(key, true));
|
||||||
|
btn.addEventListener('mouseup', () => press(key, false));
|
||||||
|
btn.addEventListener('mouseleave', () => press(key, false));
|
||||||
|
btn.addEventListener('touchstart', (e) => { e.preventDefault(); press(key, true); });
|
||||||
|
btn.addEventListener('touchend', (e) => { e.preventDefault(); press(key, false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShell(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/blocks">./blocks</a></p>
|
||||||
|
<div class="arcade-layout">
|
||||||
|
<div class="arcade-canvas-slot">
|
||||||
|
<canvas id="arcade-canvas"></canvas>
|
||||||
|
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arcade-controls">
|
||||||
|
<div class="arcade-stats">
|
||||||
|
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
||||||
|
<div><span id="arcade-lives-label">${t().lives.toUpperCase()}</span> <b id="arcade-lives">3</b></div>
|
||||||
|
<div><span id="arcade-wave-label">${t().wave.toUpperCase()}</span> <b id="arcade-wave">1</b></div>
|
||||||
|
</div>
|
||||||
|
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
||||||
|
<div class="arcade-lr">
|
||||||
|
<button data-input="left">←</button>
|
||||||
|
<button data-input="right">→</button>
|
||||||
|
</div>
|
||||||
|
<button class="arcade-action-btn" data-input="fire" id="arcade-fire-btn">${t().fire}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStaticText() {
|
||||||
|
const byId = (id) => document.getElementById(id);
|
||||||
|
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
|
||||||
|
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
|
||||||
|
if (byId('arcade-lives-label')) byId('arcade-lives-label').textContent = t().lives.toUpperCase();
|
||||||
|
if (byId('arcade-wave-label')) byId('arcade-wave-label').textContent = t().wave.toUpperCase();
|
||||||
|
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
|
||||||
|
if (byId('arcade-fire-btn')) byId('arcade-fire-btn').textContent = t().fire;
|
||||||
|
if (game?.over) showGameOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const root = document.getElementById('arcade-root');
|
||||||
|
renderShell(root);
|
||||||
|
|
||||||
|
const canvas = document.getElementById('arcade-canvas');
|
||||||
|
game = new InvadersGame(canvas);
|
||||||
|
|
||||||
|
bindTouchControls(root.querySelector('.arcade-controls'));
|
||||||
|
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-action="again"]')) restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindKeyboard();
|
||||||
|
init();
|
||||||
|
initLangToggle(updateStaticText);
|
||||||
+25
-22
@@ -2,44 +2,47 @@ const canvas = document.getElementById('matrix-rain');
|
|||||||
|
|
||||||
if (canvas && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
if (canvas && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const chars = 'アカサタナハマヤラワ0123456789ABCDEFcacto$#@%&*'.split('');
|
const chars = 'アカサタナハマヤラワ01アイウエオカキク';
|
||||||
const fontSize = 15;
|
const fontSize = 16;
|
||||||
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 columns = 0;
|
||||||
let drops = [];
|
let drops = [];
|
||||||
|
let frameCount = 0;
|
||||||
|
|
||||||
function setup() {
|
function resize() {
|
||||||
canvas.width = window.innerWidth;
|
canvas.width = window.innerWidth;
|
||||||
canvas.height = window.innerHeight;
|
canvas.height = window.innerHeight;
|
||||||
columns = Math.floor(canvas.width / fontSize);
|
columns = Math.floor(canvas.width / fontSize);
|
||||||
drops = new Array(columns).fill(0).map(() => Math.floor((Math.random() * canvas.height) / fontSize));
|
drops = new Array(columns).fill(0).map(() => Math.random() * -50);
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
ctx.fillStyle = `${bg}cc`;
|
ctx.fillStyle = 'rgba(3, 5, 3, 0.12)';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.font = `${fontSize}px monospace`;
|
ctx.font = `${fontSize}px monospace`;
|
||||||
|
|
||||||
for (let i = 0; i < columns; i++) {
|
for (let i = 0; i < drops.length; i++) {
|
||||||
const char = chars[Math.floor(Math.random() * chars.length)];
|
const text = chars[Math.floor(Math.random() * chars.length)];
|
||||||
const x = i * fontSize;
|
const isHead = Math.random() > 0.92;
|
||||||
const y = drops[i] * fontSize;
|
ctx.shadowColor = 'rgba(100, 255, 160, 1)';
|
||||||
|
ctx.shadowBlur = isHead ? 12 : 2;
|
||||||
ctx.fillStyle = Math.random() > 0.98 ? silver : accent;
|
ctx.fillStyle = isHead ? 'rgba(210, 255, 225, 1)' : 'rgba(80, 255, 140, 1)';
|
||||||
ctx.fillText(char, x, y);
|
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||||
|
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
|
||||||
if (y > canvas.height && Math.random() > 0.975) {
|
|
||||||
drops[i] = 0;
|
drops[i] = 0;
|
||||||
} else {
|
|
||||||
drops[i]++;
|
|
||||||
}
|
}
|
||||||
|
drops[i] += 0.4;
|
||||||
}
|
}
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
setup();
|
function tick() {
|
||||||
window.addEventListener('resize', setup);
|
frameCount++;
|
||||||
setInterval(draw, 50);
|
if (frameCount % 2 === 0) draw();
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-16
@@ -1,39 +1,100 @@
|
|||||||
const projects = [
|
import { getLang, initLangToggle } from './i18n.js';
|
||||||
|
|
||||||
|
const DATA = {
|
||||||
|
ru: {
|
||||||
|
heading: 'ls projects/',
|
||||||
|
subheading: 'Пара вещей, которые я собрал сам, от идеи до продакшена.',
|
||||||
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'home_automatization',
|
name: 'home_automatization',
|
||||||
title: 'Home Automation Platform',
|
|
||||||
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: 'открыть',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
title: '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: 'открыть',
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
heading: 'ls projects/',
|
||||||
|
subheading: 'A couple of things I built myself, start to finish.',
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'home_automatization',
|
||||||
|
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.',
|
||||||
|
tags: ['in progress', 'Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
|
||||||
|
url: 'https://git.cactoz.su/cacto/home_automatization',
|
||||||
|
linkLabel: 'open',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'home_automatization_controllers',
|
||||||
|
description:
|
||||||
|
'ESP32 firmware for the grow-box zone: a DHT22 sensor and three relay actuators (light, watering, ventilation), talking to the platform over MQTT.',
|
||||||
|
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
|
||||||
|
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
|
||||||
|
linkLabel: 'open',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cactoz.su',
|
||||||
|
description:
|
||||||
|
'A personal portfolio site: résumé, projects and a small multiplayer tank game. Matrix theme throughout.',
|
||||||
|
tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'],
|
||||||
|
url: 'https://git.cactoz.su/cacto/cactoz.su',
|
||||||
|
linkLabel: 'open',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const list = document.getElementById('project-list');
|
function render() {
|
||||||
|
const t = DATA[getLang()];
|
||||||
|
document.title = `${t.heading} — cacto`;
|
||||||
|
|
||||||
if (list) {
|
const heading = document.getElementById('projects-heading');
|
||||||
list.innerHTML = projects
|
const subheading = document.getElementById('projects-subheading');
|
||||||
|
if (heading) heading.textContent = t.heading;
|
||||||
|
if (subheading) subheading.textContent = t.subheading;
|
||||||
|
|
||||||
|
const list = document.getElementById('project-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = t.projects
|
||||||
.map(
|
.map(
|
||||||
(p) => `
|
(p) => `
|
||||||
<li class="project-card">
|
<li class="project-card">
|
||||||
<div class="project-card__bar"><span></span>${p.name}/README.md</div>
|
<div class="project-card__header">
|
||||||
<div class="project-card__body">
|
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> ${p.name}</div>
|
||||||
<h3 class="project-card__title">${p.title}</h3>
|
<a href="${p.url}" target="_blank" rel="noopener">${p.linkLabel} →</a>
|
||||||
|
</div>
|
||||||
<p class="project-card__desc">${p.description}</p>
|
<p class="project-card__desc">${p.description}</p>
|
||||||
<ul class="tag-list">
|
<ul class="tag-list">
|
||||||
${p.tags.map((t) => `<li class="tag">${t}</li>`).join('')}
|
${p.tags.map((tag) => `<li class="tag">${tag}</li>`).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
<a class="project-card__link" href="${p.url}" target="_blank" rel="noopener">${p.url.replace('https://', '')}</a>
|
|
||||||
</div>
|
|
||||||
</li>`
|
</li>`
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// index.html-паттерн: projects.html уже содержит серверный RU-рендер этой
|
||||||
|
// же разметки, перерисовываем через JS только при переключении на EN.
|
||||||
|
document.title = `${DATA[getLang()].heading} — cacto`;
|
||||||
|
if (getLang() !== 'ru') {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
initLangToggle(render);
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { getLang, initLangToggle } from './i18n.js';
|
||||||
|
|
||||||
|
const DATA = {
|
||||||
|
ru: {
|
||||||
|
name: 'Гаммель Дмитрий',
|
||||||
|
title: 'PHP-разработчик (Backend, Middle+/Senior)',
|
||||||
|
tagline: 'От электрического сигнала до строчки кода.',
|
||||||
|
statusLabel: 'STATUS: OPEN TO WORK',
|
||||||
|
aboutHeading: 'cat about.txt',
|
||||||
|
about: [
|
||||||
|
'Backend-разработчик, 6 лет коммерческого опыта на PHP. Работаю с legacy, сложными запросами и архитектурными решениями — там, где «просто добавить фичу» не получается.',
|
||||||
|
'Спроектировал аналитический контур на ClickHouse: отчёты формировались 3–4 минуты, стали за 10–15 секунд. Вёл разработку модуля документооборота в банковской IT-инфраструктуре — архитектура, декомпозиция, код-ревью, онбординг разработчиков.',
|
||||||
|
'Начинал с электротехники и АСУТП: писал управляющий софт для промышленных систем на ST, поднимал SCADA. Путь «от электрического сигнала до строчки кода» помогает быстро вникать в чужие системы и в задачи на стыке железа и бэкенда.',
|
||||||
|
'Ищу позицию Middle+/Senior backend. Основной стек — PHP, параллельно пишу на Go.',
|
||||||
|
],
|
||||||
|
experienceHeading: 'ls -la experience/',
|
||||||
|
experience: [
|
||||||
|
{
|
||||||
|
period: 'март 2026 — настоящее время', duration: '5 месяцев', location: 'Екатеринбург',
|
||||||
|
role: 'Программист АСУТП и КИПиА', company: 'Уралэнергоаква',
|
||||||
|
bullets: ['Временный период вне основной специальности после завершения проекта в ЭТП ГПБ. Ищу позицию backend-разработчика; параллельно — пет-проекты и Go.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'июль 2022 — март 2026', duration: '3 года 9 месяцев', location: 'Екатеринбург',
|
||||||
|
role: 'PHP-разработчик', company: 'ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк»)',
|
||||||
|
bullets: [
|
||||||
|
'Разрабатывал и поддерживал внутреннюю систему электронного документооборота для согласования и контроля исполнения договоров.',
|
||||||
|
'Поддерживал и развивал legacy-проект на Zend Framework, участвовал в архитектурном переходе на Symfony; PHP 7.4 → 8.0.',
|
||||||
|
'Переписывал построители запросов на «чистый» SQL с оптимизацией под PostgreSQL, внедрял сервисный слой архитектуры.',
|
||||||
|
'Спроектировал и реализовал аналитический контур отчётности на ClickHouse — сократил время формирования отчётов с 3–4 минут до 10–15 секунд.',
|
||||||
|
'Реализовал интеграции с внутренними банковскими сервисами, включая проверку электронной подписи и машиночитаемых доверенностей.',
|
||||||
|
'Работал с очередями сообщений (RabbitMQ) для асинхронной обработки задач.',
|
||||||
|
'Неофициально возглавлял разработку нового модуля документооборота: планировал архитектуру, проводил код-ревью, довёл проект до MVP.',
|
||||||
|
'Работал по Scrum: месячные спринты, таск-трекер YouTrack, оценка задач методом Planning Poker.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'март 2021 — июль 2022', duration: '1 год 5 месяцев', location: 'удалённо',
|
||||||
|
role: 'PHP-разработчик', company: 'фриланс/субподряд · kupisever.ru',
|
||||||
|
bullets: [
|
||||||
|
'Дорабатывал бэкенд действующей B2B-платформы (доска объявлений) на Yii2 в качестве субподрядчика, работал самостоятельно на удалённом проекте. Стек: PHP, Yii2, PostgreSQL, RabbitMQ, Docker.',
|
||||||
|
'Разработал модуль email-рассылок с очередями сообщений (RabbitMQ): веб-форма конструктора писем с текстовым редактором и гибкой настройкой параметров рассылки.',
|
||||||
|
'Реализовал модуль категорий объявлений — административную часть (бэкенд) и клиентское меню (фронтенд).',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'июль 2020 — март 2021', duration: '9 месяцев', location: 'Курган',
|
||||||
|
role: 'PHP-разработчик (с обязанностями руководителя группы)', company: 'ИстВуд',
|
||||||
|
bullets: [
|
||||||
|
'Интернет-магазины на 1С-Битрикс: вёрстка, интеграция готовых модулей, доработка бизнес-логики под задачи клиентов.',
|
||||||
|
'Реализовал интеграции с 1С (обмен товарными каталогами и заказами) и платёжными системами (эквайринг).',
|
||||||
|
'Реализовал нестандартный механизм автоматического получения и обновления каталога товаров (несколько тысяч позиций, ежесуточное обновление) для интернет-магазина на кастомном шаблоне вне типовых решений Bitrix.',
|
||||||
|
'Разработал функционал онлайн-записи на приём к врачу для сайта частной клиники; реализовал интеграцию с фискальным регистратором.',
|
||||||
|
'Участвовал во внедрении Битрикс24: настройка бизнес-процессов и цепочек согласования закупок.',
|
||||||
|
'Фактически руководил командой из 3 разработчиков: распределял задачи, контролировал качество реализации, проводил технические собеседования.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'октябрь 2019 — февраль 2020', duration: '5 месяцев', location: 'Курган',
|
||||||
|
role: 'Техник по телекоммуникациям', company: 'Урал-М',
|
||||||
|
bullets: ['Настройка и обслуживание систем СКУД, видеонаблюдения, радиоканальной связи; работа с 1С.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'апрель 2014 — июль 2018', duration: '4 года 4 месяца', location: 'Курган',
|
||||||
|
role: 'Техник-электрик', company: 'Кирпичный завод, Мясокомбинат, Хлебозавод',
|
||||||
|
bullets: ['Электромонтажные и наладочные работы на производственных предприятиях.'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skillsHeading: 'skills --list',
|
||||||
|
skillGroups: [
|
||||||
|
{ 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: 'soft', items: ['Оптимизация SQL-запросов', 'Обучение и адаптация junior-разработчиков', 'Быстрая обучаемость'] },
|
||||||
|
],
|
||||||
|
educationHeading: 'cat education.txt',
|
||||||
|
education: [
|
||||||
|
{ year: '2013', degree: 'Неоконченное высшее', school: 'Тюменский государственный нефтегазовый университет (ТюмГНГУ)', detail: 'Институт кибернетики, информатики и связи, Электроэнергетика и электротехника' },
|
||||||
|
{ year: '2011', degree: 'Свидетельство о квалификации «Электромонтёр 3 разряда»', school: 'ФГБОУ ВПО «Тюменский государственный университет»', detail: '' },
|
||||||
|
],
|
||||||
|
contactHeading: 'cat contact.txt',
|
||||||
|
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: {
|
||||||
|
name: 'Dmitry Gammel',
|
||||||
|
title: 'PHP Developer (Backend, Middle+/Senior)',
|
||||||
|
tagline: 'From electrical signal to line of code.',
|
||||||
|
statusLabel: 'STATUS: OPEN TO WORK',
|
||||||
|
aboutHeading: 'cat about.txt',
|
||||||
|
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.",
|
||||||
|
"Designed a ClickHouse analytics pipeline: reports went from 3–4 minutes to 10–15 seconds. Led development of a document-workflow module in a bank's IT infrastructure — architecture, decomposition, code review, onboarding developers.",
|
||||||
|
'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.',
|
||||||
|
'Looking for a Middle+/Senior backend position. Main stack is PHP, with Go on the side.',
|
||||||
|
],
|
||||||
|
experienceHeading: 'ls -la experience/',
|
||||||
|
experience: [
|
||||||
|
{
|
||||||
|
period: 'Mar 2026 — present', duration: '5 months', location: 'Yekaterinburg',
|
||||||
|
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."],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Jul 2022 — Mar 2026', duration: '3 years 9 months', location: 'Yekaterinburg',
|
||||||
|
role: 'PHP Developer', company: 'ETP GPB LLC (subsidiary of Gazprombank)',
|
||||||
|
bullets: [
|
||||||
|
'Built and maintained an internal document-workflow system for contract approval and execution tracking.',
|
||||||
|
'Maintained a legacy Zend Framework project and took part in the architectural move to Symfony; PHP 7.4 → 8.0.',
|
||||||
|
'Rewrote query builders as raw SQL optimized for PostgreSQL, introduced a service-layer architecture.',
|
||||||
|
'Designed and built a ClickHouse analytics pipeline — cut report generation time from 3–4 minutes to 10–15 seconds.',
|
||||||
|
'Built integrations with internal banking services, including e-signature and machine-readable power-of-attorney verification.',
|
||||||
|
'Worked with message queues (RabbitMQ) for async task processing.',
|
||||||
|
'Informally led development of a new document-workflow module: planned the architecture, ran code review, took it to MVP.',
|
||||||
|
'Worked in Scrum: monthly sprints, YouTrack, Planning Poker estimation.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Mar 2021 — Jul 2022', duration: '1 year 5 months', location: 'Remote',
|
||||||
|
role: 'PHP Developer', company: 'Freelance / subcontract · kupisever.ru',
|
||||||
|
bullets: [
|
||||||
|
'Worked as a subcontractor on the backend of a live B2B classifieds platform on Yii2, working independently on a remote project. Stack: PHP, Yii2, PostgreSQL, RabbitMQ, Docker.',
|
||||||
|
'Built an email-campaign module with message queues (RabbitMQ): a web-based email builder with a text editor and flexible send settings.',
|
||||||
|
'Built the listing-categories module — both the admin backend and the client-facing menu.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Jul 2020 — Mar 2021', duration: '9 months', location: 'Kurgan',
|
||||||
|
role: 'PHP Developer (acting team lead)', company: 'IstWood',
|
||||||
|
bullets: [
|
||||||
|
'Built and maintained online stores on 1C-Bitrix: markup, module integration, business-logic customization.',
|
||||||
|
'Built integrations with 1C (catalog and order exchange) and payment systems (acquiring).',
|
||||||
|
"Built a custom mechanism for automatically fetching and updating a product catalog (several thousand SKUs, daily updates) on a custom template outside Bitrix's standard tooling.",
|
||||||
|
'Built online doctor-appointment booking for a private clinic site; integrated a fiscal register for automatic receipts.',
|
||||||
|
'Took part in rolling out Bitrix24: configured business processes and procurement approval chains.',
|
||||||
|
'Acting lead of a 3-developer team: assigned tasks, reviewed quality, ran technical interviews.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Oct 2019 — Feb 2020', duration: '5 months', location: 'Kurgan',
|
||||||
|
role: 'Telecom Technician', company: 'Ural-M',
|
||||||
|
bullets: ['Set up and maintained access-control, video-surveillance and radio-link systems; worked with 1C.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Apr 2014 — Jul 2018', duration: '4 years 4 months', location: 'Kurgan',
|
||||||
|
role: 'Electrical Technician', company: 'Brick factory, meat plant, bakery',
|
||||||
|
bullets: ['Electrical installation and commissioning work at manufacturing plants.'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skillsHeading: 'skills --list',
|
||||||
|
skillGroups: [
|
||||||
|
{ title: 'backend', items: ['PHP8', 'PHP7', 'Symfony', 'Zend Framework', 'Yii2', 'PostgreSQL', 'MySQL', 'ClickHouse', 'SQL', 'RabbitMQ', 'Docker', 'Git', 'REST API', 'PHPUnit', 'Composer', 'OOP', 'Go'] },
|
||||||
|
{ 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'] },
|
||||||
|
],
|
||||||
|
educationHeading: 'cat education.txt',
|
||||||
|
education: [
|
||||||
|
{ year: '2013', degree: 'Incomplete higher education', school: 'Tyumen State Oil and Gas University', detail: 'Institute of Cybernetics, Informatics and Communications — Power Engineering and Electrical Engineering' },
|
||||||
|
{ year: '2011', degree: 'Qualification certificate, Electrician grade 3', school: 'Tyumen State University', detail: '' },
|
||||||
|
],
|
||||||
|
contactHeading: 'cat contact.txt',
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ASCII_CACTUS = ` , ,
|
||||||
|
|\\_/|
|
||||||
|
.--| |--.
|
||||||
|
( | | )
|
||||||
|
\`--| |--'
|
||||||
|
| |
|
||||||
|
.--| |--.
|
||||||
|
( | | )
|
||||||
|
\`--| |--'
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
__| |__
|
||||||
|
(_________)`;
|
||||||
|
|
||||||
|
function renderExperience(list) {
|
||||||
|
return list
|
||||||
|
.map(
|
||||||
|
(job) => `
|
||||||
|
<li class="timeline-item">
|
||||||
|
<div class="timeline-item__meta">${job.period} · ${job.duration}</div>
|
||||||
|
<h3 class="timeline-item__role">${job.role}</h3>
|
||||||
|
<div class="timeline-item__company">${job.company} · ${job.location}</div>
|
||||||
|
<ul>${job.bullets.map((b) => `<li><span class="dash">-</span><span>${b}</span></li>`).join('')}</ul>
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkillGroups(groups) {
|
||||||
|
return groups
|
||||||
|
.map(
|
||||||
|
(g) => `
|
||||||
|
<div class="skill-group">
|
||||||
|
<div class="skill-group__title">${g.title}</div>
|
||||||
|
<ul class="tag-list">${g.items.map((i) => `<li class="tag">${i}</li>`).join('')}</ul>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEducation(list) {
|
||||||
|
return list
|
||||||
|
.map(
|
||||||
|
(e) => `
|
||||||
|
<li class="edu-item">
|
||||||
|
<div class="edu-item__year">${e.year}</div>
|
||||||
|
<div class="edu-item__degree">${e.degree}</div>
|
||||||
|
<div class="edu-item__school">${e.school}</div>
|
||||||
|
${e.detail ? `<div class="edu-item__detail">${e.detail}</div>` : ''}
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const t = DATA[getLang()];
|
||||||
|
document.title = `cacto — ${t.name}, ${t.title}`;
|
||||||
|
|
||||||
|
document.getElementById('resume-root').innerHTML = `
|
||||||
|
<section class="hero">
|
||||||
|
<pre class="ascii-cactus" aria-hidden="true">${ASCII_CACTUS}</pre>
|
||||||
|
<div class="hero-text">
|
||||||
|
<div class="status-line"><span class="status-dot"></span>${t.statusLabel}</div>
|
||||||
|
<h1><span class="glitch" data-text="${t.name}">${t.name}</span></h1>
|
||||||
|
<p class="role">${t.title}</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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about">
|
||||||
|
<h2 class="section-heading"><span class="section-heading__num">01</span>${t.aboutHeading}</h2>
|
||||||
|
${t.about.map((p) => `<p>${p}</p>`).join('')}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="experience">
|
||||||
|
<h2 class="section-heading"><span class="section-heading__num">02</span>${t.experienceHeading}</h2>
|
||||||
|
<ol class="timeline">${renderExperience(t.experience)}</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="skills">
|
||||||
|
<h2 class="section-heading"><span class="section-heading__num">03</span>${t.skillsHeading}</h2>
|
||||||
|
${renderSkillGroups(t.skillGroups)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="education">
|
||||||
|
<h2 class="section-heading"><span class="section-heading__num">04</span>${t.educationHeading}</h2>
|
||||||
|
<ul class="edu-list">${renderEducation(t.education)}</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="contact">
|
||||||
|
<h2 class="section-heading"><span class="section-heading__num">05</span>${t.contactHeading}</h2>
|
||||||
|
<ul class="contact-list">
|
||||||
|
<li><span class="contact-list__label">email</span><a href="mailto:${t.contact.email}">${t.contact.email}</a></li>
|
||||||
|
<li><span class="contact-list__label">telegram</span><a href="${t.contact.telegramUrl}">${t.contact.telegram}</a></li>
|
||||||
|
</ul>
|
||||||
|
</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();
|
||||||
|
}
|
||||||
|
initLangToggle(render);
|
||||||
+65
-5
@@ -5,30 +5,90 @@
|
|||||||
<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.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<link rel="stylesheet" href="/css/theme.css" />
|
||||||
<link rel="stylesheet" href="/css/projects.css" />
|
<link rel="stylesheet" href="/css/projects.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="matrix-rain"></canvas>
|
<canvas id="matrix-rain"></canvas>
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
<div class="site-header__inner">
|
||||||
<a href="/" class="logo" aria-label="cacto — на главную">
|
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||||
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||||
cacto
|
cacto<span class="cursor">_</span>
|
||||||
</a>
|
</a>
|
||||||
<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>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!--
|
||||||
|
Статика ниже — серверный RU-рендер того же содержимого, что генерирует
|
||||||
|
frontend/js/projects.js (DATA.ru). projects.js не перерисовывает при
|
||||||
|
lang=ru — только при переключении на EN. При правке текста меняйте оба
|
||||||
|
места: DATA.ru в projects.js и разметку ниже.
|
||||||
|
-->
|
||||||
<main>
|
<main>
|
||||||
<section>
|
<section>
|
||||||
<h2 class="prompt">ls projects/</h2>
|
<h1 id="projects-heading" class="page-heading">ls projects/</h1>
|
||||||
<ul id="project-list" class="project-grid"></ul>
|
<p id="projects-subheading" class="page-subheading">Пара вещей, которые я собрал сам, от идеи до продакшена.</p>
|
||||||
|
<ul id="project-list" class="project-grid">
|
||||||
|
<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>
|
||||||
|
|||||||
+4
-1
@@ -10,10 +10,13 @@ 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;
|
||||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://cactoz.su wss://www.cactoz.su; img-src 'self' data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" always;
|
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://cactoz.su wss://www.cactoz.su; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; base-uri 'none'; frame-ancestors 'none'" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri.html $uri/ =404;
|
try_files $uri $uri.html $uri/ =404;
|
||||||
|
|||||||
Reference in New Issue
Block a user