Compare commits
21
Commits
a9d2a6b15c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b48c01a93 | ||
|
|
a9e4828dd9 | ||
|
|
ddab8de27b | ||
|
|
d626a66ff7 | ||
|
|
b582bb7cf4 | ||
|
|
1bc94980f8 | ||
|
|
128c5cf16e | ||
|
|
684277ebff | ||
|
|
28f8deff5f | ||
|
|
a1c7cfdc9b | ||
|
|
3fe09de3c9 | ||
|
|
6b3ba11faf | ||
|
|
b5566ec73e | ||
|
|
85b2f006ae | ||
|
|
971ebdc11f | ||
|
|
c85300c751 | ||
|
|
fb4ccc351f | ||
|
|
5649961e81 | ||
|
|
864868de49 | ||
|
|
238d3d5d26 | ||
|
|
2036e909ea |
@@ -0,0 +1,32 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# actions/checkout скачивает свой код с github.com и берёт URL из
|
||||||
|
# внутреннего server_url Gitea — оба варианта требуют сети, которой
|
||||||
|
# у джоб-контейнера раннера нет. Клонируем напрямую по внутреннему
|
||||||
|
# адресу Gitea в docker-сети раннера.
|
||||||
|
- name: Checkout
|
||||||
|
run: git clone --depth=1 --branch main http://gitea:3000/cacto/cactoz.su.git repo
|
||||||
|
|
||||||
|
# Runner уже на cacto-homelab и имеет доступ к docker.sock хоста,
|
||||||
|
# поэтому образ собирается прямо на месте — без registry и без SSH.
|
||||||
|
- name: Sync to deploy directory
|
||||||
|
run: |
|
||||||
|
rm -rf /data/docker/compose/cactoz
|
||||||
|
mkdir -p /data/docker/compose/cactoz
|
||||||
|
cp -a repo/. /data/docker/compose/cactoz/
|
||||||
|
rm -rf /data/docker/compose/cactoz/.git
|
||||||
|
|
||||||
|
- name: Build and restart
|
||||||
|
working-directory: /data/docker/compose/cactoz
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
docker image prune -f
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
container_name: cactoz-nginx
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: nginx/Dockerfile
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- proxy
|
||||||
|
depends_on:
|
||||||
|
- gameserver
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
gameserver:
|
||||||
|
build: ./gameserver
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
# уже создана Caddy-стеком; сюда только подключаемся
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
|
||||||
|
<rect x="7" y="2" width="2" height="13" fill="#00ff41"/>
|
||||||
|
<rect x="3" y="4" width="2" height="5" fill="#00ff41"/>
|
||||||
|
<rect x="3" y="7" width="4" height="2" fill="#00ff41"/>
|
||||||
|
<rect x="11" y="5" width="2" height="6" fill="#00ff41"/>
|
||||||
|
<rect x="9" y="9" width="4" height="2" fill="#00ff41"/>
|
||||||
|
<rect x="1" y="15" width="14" height="1" fill="#9fa3a0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 449 B |
+90
-1
@@ -1 +1,90 @@
|
|||||||
/* TODO: лобби и игровое поле — этапы 3-6 */
|
.lobby-panel {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-panel__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-inline {
|
||||||
|
color: var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-panel input {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-panel button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-panel button:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 6px var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-panel button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-error {
|
||||||
|
color: var(--color-silver);
|
||||||
|
border-left: 2px solid var(--color-accent-dim);
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list,
|
||||||
|
.room-players {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list__name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list__count,
|
||||||
|
.room-list__state {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list__empty {
|
||||||
|
color: var(--color-silver);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-players li::before {
|
||||||
|
content: '- ';
|
||||||
|
color: var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,63 @@
|
|||||||
/* TODO: карточки проектов — этап 2 */
|
.project-grid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__bar span {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__body {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__desc {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__link {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card__link::before {
|
||||||
|
content: '$ ';
|
||||||
|
color: var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|||||||
+101
-1
@@ -1 +1,101 @@
|
|||||||
/* TODO: вёрстка резюме — этап 2 */
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2.5rem;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text h1 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text .role {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text .tagline {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: var(--color-silver);
|
||||||
|
max-width: 34ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section > h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about p {
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
border-left: var(--border);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 1.5rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -1.6rem;
|
||||||
|
top: 0.4rem;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
box-shadow: 0 0 6px var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item__meta {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item__role {
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin: 0.15rem 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item li::marker {
|
||||||
|
color: var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-list a::before {
|
||||||
|
content: '$ ';
|
||||||
|
color: var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+176
-4
@@ -1,10 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--color-bg: #0a0e0c;
|
--color-bg: #0a0e0c;
|
||||||
|
--color-bg-raised: #0f1512;
|
||||||
--color-fg: #c7c7c7;
|
--color-fg: #c7c7c7;
|
||||||
--color-accent: #00ff41;
|
--color-accent: #00ff41;
|
||||||
--color-accent-dim: #0a8f2c;
|
--color-accent-dim: #0a8f2c;
|
||||||
--color-silver: #9fa3a0;
|
--color-silver: #9fa3a0;
|
||||||
--font-mono: 'JetBrains Mono', 'Share Tech Mono', ui-monospace, monospace;
|
--font-mono: 'JetBrains Mono', 'Share Tech Mono', ui-monospace, monospace;
|
||||||
|
--border: 1px solid #163020;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -13,26 +15,196 @@
|
|||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-accent-dim);
|
||||||
|
color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 1px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- фон: canvas цифрового дождя --- */
|
||||||
|
#matrix-rain {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* лёгкие сканлайны поверх дождя, под контентом */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.15) 0px,
|
||||||
|
rgba(0, 0, 0, 0.15) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 3px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- шапка --- */
|
||||||
|
.site-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
filter: drop-shadow(0 0 4px var(--color-accent-dim));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--color-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover,
|
||||||
|
.nav-links a[aria-current='page'] {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- терминальный префикс перед заголовками --- */
|
||||||
.prompt::before {
|
.prompt::before {
|
||||||
content: 'cacto@matrix:~$ ';
|
content: 'cacto@matrix:~$ ';
|
||||||
color: var(--color-accent-dim);
|
color: var(--color-accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
#matrix-rain {
|
/* --- терминальное окно-обёртка для блоков контента --- */
|
||||||
position: fixed;
|
.term-window {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
margin: 1.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.term-window__bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.term-window__bar span {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.term-window__body {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- ASCII-арт --- */
|
||||||
|
.ascii-cactus {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 0 6px var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- glitch-эффект для акцентного текста --- */
|
||||||
|
.glitch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch::before,
|
||||||
|
.glitch::after {
|
||||||
|
content: attr(data-text);
|
||||||
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
overflow: hidden;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch::before {
|
||||||
|
color: var(--color-silver);
|
||||||
|
clip-path: inset(0 0 55% 0);
|
||||||
|
animation: glitch-shift 3.4s infinite steps(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch::after {
|
||||||
|
color: var(--color-accent-dim);
|
||||||
|
clip-path: inset(55% 0 0 0);
|
||||||
|
animation: glitch-shift 2.6s infinite steps(2) reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch-shift {
|
||||||
|
0%, 92%, 100% { transform: translate(0, 0); }
|
||||||
|
94% { transform: translate(-2px, 1px); }
|
||||||
|
96% { transform: translate(2px, -1px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- теги (скиллы, стек проекта) --- */
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-accent);
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -4,16 +4,35 @@
|
|||||||
<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>./battlecity — cacto</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
<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>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header class="site-header">
|
||||||
|
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||||
|
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||||
|
cacto
|
||||||
|
</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">whoami</a></li>
|
||||||
|
<li><a href="/projects.html">ls projects/</a></li>
|
||||||
|
<li><a href="/game.html" aria-current="page">./battlecity</a></li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main id="game-root">
|
<main id="game-root">
|
||||||
<!-- TODO: лобби (этап 3) и игровое поле на Quintus.js (этапы 4-5) -->
|
<!-- TODO: лобби (этап 3) и игровое поле на Quintus.js (этапы 4-5) -->
|
||||||
<h1 class="prompt">./battlecity</h1>
|
<h2 class="prompt">./battlecity</h2>
|
||||||
<div id="lobby"></div>
|
<div id="lobby"></div>
|
||||||
<div id="battlecity-canvas-container"></div>
|
<div id="battlecity-canvas-container"></div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/js/matrix-rain.js"></script>
|
||||||
<script type="module" src="/js/game/main.js"></script>
|
<script type="module" src="/js/game/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+124
-9
@@ -3,21 +3,136 @@
|
|||||||
<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 — Гаммель Дмитрий</title>
|
<title>cacto — Гаммель Дмитрий, PHP/Go backend</title>
|
||||||
|
<meta name="description" content="Гаммель Дмитрий — backend-разработчик PHP/Go. Резюме, проекты, эксперименты." />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
<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>
|
||||||
<main id="resume">
|
|
||||||
<!-- TODO: вёрстка резюме, ASCII-арт кактуса — этап 2 -->
|
<div class="container">
|
||||||
<h1 class="prompt">whoami</h1>
|
<header class="site-header">
|
||||||
<p>Гаммель Дмитрий Викторович — PHP/Go backend, опыт КИПиА/АСУТП</p>
|
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||||
<nav>
|
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||||
<a href="/projects.html">ls projects/</a>
|
cacto
|
||||||
<a href="/game.html">./battlecity</a>
|
</a>
|
||||||
</nav>
|
<ul class="nav-links">
|
||||||
|
<li><a href="/" aria-current="page">whoami</a></li>
|
||||||
|
<li><a href="/projects.html">ls projects/</a></li>
|
||||||
|
<li><a href="/game.html">./battlecity</a></li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<pre class="ascii-cactus" aria-hidden="true">
|
||||||
|
, ,
|
||||||
|
|\_/|
|
||||||
|
.--| |--.
|
||||||
|
( | | )
|
||||||
|
`--| |--'
|
||||||
|
| |
|
||||||
|
.--| |--.
|
||||||
|
( | | )
|
||||||
|
`--| |--'
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
__| |__
|
||||||
|
(_________)</pre>
|
||||||
|
<div class="hero-text">
|
||||||
|
<h1><span class="glitch" data-text="Гаммель Дмитрий">Гаммель Дмитрий</span></h1>
|
||||||
|
<p class="role">PHP / Go backend-разработчик</p>
|
||||||
|
<p class="tagline">Живучий, колючий, не требует много воды. Специализация — сложные запросы, legacy-код, архитектура.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about">
|
||||||
|
<h2 class="prompt">cat about.txt</h2>
|
||||||
|
<div class="term-window">
|
||||||
|
<div class="term-window__bar"><span></span><span></span><span></span></div>
|
||||||
|
<div class="term-window__body">
|
||||||
|
<p>
|
||||||
|
Backend-разработчик с 6-летним коммерческим опытом на PHP: сложные запросы к базам данных,
|
||||||
|
работа с legacy-кодом, архитектурные решения. Спроектировал аналитический контур на ClickHouse,
|
||||||
|
ускоривший формирование отчётности в 15–20 раз; неофициально возглавлял разработку модуля
|
||||||
|
документооборота в банковской IT-инфраструктуре.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Начинал с электротехники и АСУТП — писал управляющий софт для автоматизированных систем.
|
||||||
|
Путь «от электрического сигнала до строчки кода» помогает быстро разбираться в сложных
|
||||||
|
legacy-системах и нестандартных задачах.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Сейчас изучаю Go и продолжаю искать позицию Middle+/Senior backend-разработчика.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="experience">
|
||||||
|
<h2 class="prompt">ls -la experience/</h2>
|
||||||
|
<ol class="timeline">
|
||||||
|
<li class="timeline-item">
|
||||||
|
<div class="timeline-item__meta">июль 2022 — март 2026 · Екатеринбург</div>
|
||||||
|
<h3 class="timeline-item__role">PHP-разработчик — ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк»)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Поддерживал legacy на Zend Framework, участвовал в переходе на Symfony; PHP 7.4 → 8.0.</li>
|
||||||
|
<li>Спроектировал аналитический контур отчётности на ClickHouse — сократил время формирования отчётов с 3–4 минут до 10–15 секунд.</li>
|
||||||
|
<li>Интеграции с внутренними банковскими сервисами (проверка ЭП, машиночитаемые доверенности), очереди RabbitMQ.</li>
|
||||||
|
<li>Неофициально возглавлял разработку нового модуля документооборота: архитектура, код-ревью, MVP.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="timeline-item">
|
||||||
|
<div class="timeline-item__meta">март 2021 — июль 2022 · фриланс/субподряд</div>
|
||||||
|
<h3 class="timeline-item__role">PHP-разработчик — kupisever.ru</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Доработка бэкенда B2B-платформы (доска объявлений) на Yii2, стек PHP/PostgreSQL/RabbitMQ/Docker.</li>
|
||||||
|
<li>Модуль email-рассылок с очередями сообщений и конструктором писем.</li>
|
||||||
|
<li>Модуль категорий объявлений — админка и клиентское меню.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="timeline-item">
|
||||||
|
<div class="timeline-item__meta">июль 2020 — март 2021 · Курган</div>
|
||||||
|
<h3 class="timeline-item__role">PHP-разработчик (с обязанностями руководителя группы) — ИстВуд</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Интернет-магазины на 1С-Битрикс, интеграции с 1С и платёжными системами.</li>
|
||||||
|
<li>Фактически руководил командой из 3 разработчиков: задачи, код-ревью, технические собеседования.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="skills">
|
||||||
|
<h2 class="prompt">skills --list</h2>
|
||||||
|
<ul class="tag-list">
|
||||||
|
<li class="tag">PHP8</li>
|
||||||
|
<li class="tag">Symfony</li>
|
||||||
|
<li class="tag">Zend Framework</li>
|
||||||
|
<li class="tag">Yii2</li>
|
||||||
|
<li class="tag">PostgreSQL</li>
|
||||||
|
<li class="tag">MySQL</li>
|
||||||
|
<li class="tag">ClickHouse</li>
|
||||||
|
<li class="tag">RabbitMQ</li>
|
||||||
|
<li class="tag">Docker</li>
|
||||||
|
<li class="tag">REST API</li>
|
||||||
|
<li class="tag">PHPUnit</li>
|
||||||
|
<li class="tag">ООП</li>
|
||||||
|
<li class="tag">Go (изучаю)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="contact">
|
||||||
|
<h2 class="prompt">cat contact.txt</h2>
|
||||||
|
<ul class="contact-list">
|
||||||
|
<li><a href="mailto:cactozzz93@gmail.com">cactozzz93@gmail.com</a></li>
|
||||||
|
<li><a href="https://t.me/Cactozz">t.me/Cactozz</a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/js/matrix-rain.js"></script>
|
<script type="module" src="/js/matrix-rain.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+126
-11
@@ -1,21 +1,136 @@
|
|||||||
// TODO: полноценный UI лобби (список/создание/присоединение комнат) — этап 3.
|
|
||||||
// Пока — проверка связки клиент <-> gameserver (hello -> lobby.list_rooms).
|
|
||||||
import { Network, ServerMessage } from './network.js';
|
import { Network, ServerMessage } from './network.js';
|
||||||
|
|
||||||
export function connectAndProbe() {
|
const container = document.getElementById('lobby');
|
||||||
const net = new Network();
|
|
||||||
|
|
||||||
net.on('_open', () => net.hello('cacto'));
|
let net;
|
||||||
|
let rooms = [];
|
||||||
|
let currentRoom = null;
|
||||||
|
let errorMessage = '';
|
||||||
|
let connected = false;
|
||||||
|
let nickname = 'cacto-' + Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
|
function roomRow(room) {
|
||||||
|
const full = room.players >= room.max_players;
|
||||||
|
return `
|
||||||
|
<li class="room-list__item">
|
||||||
|
<span class="room-list__name">${room.name}</span>
|
||||||
|
<span class="tag">${room.mode}</span>
|
||||||
|
<span class="room-list__count">${room.players}/${room.max_players}</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>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorBanner() {
|
||||||
|
return errorMessage ? `<p class="lobby-error">! ${errorMessage}</p>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLobbyView() {
|
||||||
|
const dis = connected ? '' : 'disabled';
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="lobby-panel">
|
||||||
|
${errorBanner()}
|
||||||
|
${connected ? '' : '<p class="lobby-error">подключение...</p>'}
|
||||||
|
<div class="lobby-panel__row">
|
||||||
|
<label class="prompt-inline">nickname</label>
|
||||||
|
<input id="nickname-input" type="text" value="${nickname}" maxlength="20" ${dis} />
|
||||||
|
</div>
|
||||||
|
<div class="lobby-panel__row">
|
||||||
|
<button data-action="create" data-mode="coop" ${dis}>создать комнату (coop)</button>
|
||||||
|
<button data-action="create" data-mode="pvp" ${dis}>создать комнату (pvp)</button>
|
||||||
|
<button data-action="refresh" ${dis}>обновить список</button>
|
||||||
|
</div>
|
||||||
|
<ul class="room-list">
|
||||||
|
${rooms.length === 0 ? '<li class="room-list__empty">пока пусто — создай комнату</li>' : rooms.map(roomRow).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoomView() {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="lobby-panel">
|
||||||
|
${errorBanner()}
|
||||||
|
<h3 class="prompt">room ${currentRoom.name}</h3>
|
||||||
|
<p>режим: <span class="tag">${currentRoom.mode}</span></p>
|
||||||
|
<ul class="room-players">
|
||||||
|
${currentRoom.players.map((p) => `<li>${p.nickname}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
<button data-action="leave">выйти в лобби</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (currentRoom) {
|
||||||
|
renderRoomView();
|
||||||
|
} else {
|
||||||
|
renderLobbyView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container?.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
errorMessage = '';
|
||||||
|
switch (btn.dataset.action) {
|
||||||
|
case 'create':
|
||||||
|
net.createRoom(btn.dataset.mode);
|
||||||
|
break;
|
||||||
|
case 'join':
|
||||||
|
net.joinRoom(btn.dataset.roomId);
|
||||||
|
break;
|
||||||
|
case 'leave':
|
||||||
|
currentRoom = null;
|
||||||
|
net.leaveRoom();
|
||||||
|
render();
|
||||||
|
break;
|
||||||
|
case 'refresh':
|
||||||
|
net.listRooms();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container?.addEventListener('change', (e) => {
|
||||||
|
if (e.target.id === 'nickname-input') {
|
||||||
|
nickname = e.target.value.trim() || nickname;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function initLobby() {
|
||||||
|
net = new Network();
|
||||||
|
|
||||||
|
net.on('_open', () => net.hello(nickname));
|
||||||
net.on(ServerMessage.HELLO_ACK, () => {
|
net.on(ServerMessage.HELLO_ACK, () => {
|
||||||
console.log('[gameserver] hello.ack received');
|
connected = true;
|
||||||
net.listRooms();
|
net.listRooms();
|
||||||
});
|
});
|
||||||
net.on(ServerMessage.LOBBY_ROOMS, (payload) => {
|
net.on('_close', () => {
|
||||||
console.log('[gameserver] rooms:', payload.rooms);
|
connected = false;
|
||||||
});
|
render();
|
||||||
net.on(ServerMessage.ERROR, (payload) => {
|
|
||||||
console.error('[gameserver] error:', payload);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
net.on(ServerMessage.LOBBY_ROOMS, (payload) => {
|
||||||
|
rooms = payload.rooms;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
net.on(ServerMessage.LOBBY_ROOM_JOINED, (payload) => {
|
||||||
|
currentRoom = payload;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
net.on(ServerMessage.LOBBY_ROOM_UPDATED, (payload) => {
|
||||||
|
if (currentRoom) {
|
||||||
|
currentRoom.players = payload.players;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
net.on(ServerMessage.ERROR, (payload) => {
|
||||||
|
console.error('[gameserver] error:', payload);
|
||||||
|
errorMessage = payload.message;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
return net;
|
return net;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5
|
// TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5
|
||||||
import { connectAndProbe } from './lobby.js';
|
import { initLobby } from './lobby.js';
|
||||||
|
|
||||||
connectAndProbe();
|
initLobby();
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ export class Network {
|
|||||||
listRooms() {
|
listRooms() {
|
||||||
this.send(ClientMessage.LOBBY_LIST_ROOMS);
|
this.send(ClientMessage.LOBBY_LIST_ROOMS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createRoom(mode) {
|
||||||
|
this.send(ClientMessage.LOBBY_CREATE_ROOM, { mode });
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRoom(roomId) {
|
||||||
|
this.send(ClientMessage.LOBBY_JOIN_ROOM, { room_id: roomId });
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveRoom() {
|
||||||
|
this.send(ClientMessage.LOBBY_LEAVE_ROOM);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ServerMessage };
|
export { ServerMessage };
|
||||||
|
|||||||
@@ -1,11 +1,45 @@
|
|||||||
// TODO: полноценный canvas-эффект цифрового дождя — этап 2
|
|
||||||
const canvas = document.getElementById('matrix-rain');
|
const canvas = document.getElementById('matrix-rain');
|
||||||
if (canvas) {
|
|
||||||
|
if (canvas && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const resize = () => {
|
const chars = 'アカサタナハマヤラワ0123456789ABCDEFcacto$#@%&*'.split('');
|
||||||
|
const fontSize = 15;
|
||||||
|
const bg = getComputedStyle(document.documentElement).getPropertyValue('--color-bg').trim();
|
||||||
|
const accent = getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim();
|
||||||
|
const silver = getComputedStyle(document.documentElement).getPropertyValue('--color-silver').trim();
|
||||||
|
|
||||||
|
let columns = 0;
|
||||||
|
let drops = [];
|
||||||
|
|
||||||
|
function setup() {
|
||||||
canvas.width = window.innerWidth;
|
canvas.width = window.innerWidth;
|
||||||
canvas.height = window.innerHeight;
|
canvas.height = window.innerHeight;
|
||||||
};
|
columns = Math.floor(canvas.width / fontSize);
|
||||||
resize();
|
drops = new Array(columns).fill(0).map(() => Math.floor((Math.random() * canvas.height) / fontSize));
|
||||||
window.addEventListener('resize', resize);
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx.fillStyle = `${bg}cc`;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.font = `${fontSize}px monospace`;
|
||||||
|
|
||||||
|
for (let i = 0; i < columns; i++) {
|
||||||
|
const char = chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
const x = i * fontSize;
|
||||||
|
const y = drops[i] * fontSize;
|
||||||
|
|
||||||
|
ctx.fillStyle = Math.random() > 0.98 ? silver : accent;
|
||||||
|
ctx.fillText(char, x, y);
|
||||||
|
|
||||||
|
if (y > canvas.height && Math.random() > 0.975) {
|
||||||
|
drops[i] = 0;
|
||||||
|
} else {
|
||||||
|
drops[i]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setup();
|
||||||
|
window.addEventListener('resize', setup);
|
||||||
|
setInterval(draw, 50);
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-1
@@ -1 +1,39 @@
|
|||||||
// TODO: рендер карточек проектов из данных — этап 2
|
const projects = [
|
||||||
|
{
|
||||||
|
name: 'home_automatization',
|
||||||
|
title: 'Home Automation Platform',
|
||||||
|
description:
|
||||||
|
'Микросервисная платформа умного дома на Go и Laravel. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.',
|
||||||
|
tags: ['Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
|
||||||
|
url: 'https://git.cactoz.su/cacto/home_automatization',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cactoz.su',
|
||||||
|
title: 'cactoz.su — этот сайт',
|
||||||
|
description:
|
||||||
|
'Личный сайт-визитка: резюме, проекты и мультиплеерный клон BattleCity. Тема — Матрица.',
|
||||||
|
tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'],
|
||||||
|
url: 'https://git.cactoz.su/cacto/cactoz.su',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const list = document.getElementById('project-list');
|
||||||
|
|
||||||
|
if (list) {
|
||||||
|
list.innerHTML = projects
|
||||||
|
.map(
|
||||||
|
(p) => `
|
||||||
|
<li class="project-card">
|
||||||
|
<div class="project-card__bar"><span></span>${p.name}/README.md</div>
|
||||||
|
<div class="project-card__body">
|
||||||
|
<h3 class="project-card__title">${p.title}</h3>
|
||||||
|
<p class="project-card__desc">${p.description}</p>
|
||||||
|
<ul class="tag-list">
|
||||||
|
${p.tags.map((t) => `<li class="tag">${t}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
<a class="project-card__link" href="${p.url}" target="_blank" rel="noopener">${p.url.replace('https://', '')}</a>
|
||||||
|
</div>
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|||||||
+25
-8
@@ -4,19 +4,36 @@
|
|||||||
<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>ls projects/ — cacto</title>
|
<title>ls projects/ — cacto</title>
|
||||||
|
<meta name="description" content="Проекты Гаммеля Дмитрия: home automation platform и этот сайт." />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
<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>
|
||||||
<main id="projects">
|
<canvas id="matrix-rain"></canvas>
|
||||||
<!-- TODO: карточки проектов — этап 2 -->
|
|
||||||
<h1 class="prompt">ls projects/</h1>
|
<div class="container">
|
||||||
<ul id="project-list"></ul>
|
<header class="site-header">
|
||||||
<nav>
|
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||||
<a href="/">cd ~</a>
|
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||||
<a href="/game.html">./battlecity</a>
|
cacto
|
||||||
</nav>
|
</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">whoami</a></li>
|
||||||
|
<li><a href="/projects.html" aria-current="page">ls projects/</a></li>
|
||||||
|
<li><a href="/game.html">./battlecity</a></li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<h2 class="prompt">ls projects/</h2>
|
||||||
|
<ul id="project-list" class="project-grid"></ul>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/js/matrix-rain.js"></script>
|
||||||
<script type="module" src="/js/projects.js"></script>
|
<script type="module" src="/js/projects.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ target_compile_definitions(usockets PUBLIC LIBUS_NO_SSL LIBUS_USE_EPOLL)
|
|||||||
|
|
||||||
add_executable(gameserver
|
add_executable(gameserver
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
|
src/Lobby.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(gameserver PRIVATE
|
target_include_directories(gameserver PRIVATE
|
||||||
${uwebsockets_SOURCE_DIR}/src
|
${uwebsockets_SOURCE_DIR}/src
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
build-essential cmake git ca-certificates zlib1g-dev \
|
build-essential cmake git ca-certificates zlib1g-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Кэшируемый слой: конфигурация + сборка зависимостей (uSockets, json).
|
||||||
|
# Заглушки вместо src/, чтобы cmake configure не падал на несуществующих
|
||||||
|
# исходниках — они не компилируются, т.к. просим собрать только usockets.
|
||||||
COPY CMakeLists.txt ./
|
COPY CMakeLists.txt ./
|
||||||
|
RUN mkdir src && touch src/main.cpp src/Lobby.cpp src/Lobby.hpp
|
||||||
|
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --target usockets -j"$(nproc)"
|
||||||
|
|
||||||
|
# Реальный код поверх уже собранных зависимостей — пересобирается за секунды.
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$(nproc)"
|
RUN cmake --build build --target gameserver -j"$(nproc)"
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends zlib1g \
|
RUN apt-get update && apt-get install -y --no-install-recommends zlib1g \
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
#include "Lobby.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
void Lobby::send(WS *ws, const std::string &type, json payload) {
|
||||||
|
json msg = {{"type", type}, {"payload", std::move(payload)}};
|
||||||
|
ws->send(msg.dump(), uWS::OpCode::TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::sendError(WS *ws, const std::string &code, const std::string &message) {
|
||||||
|
send(ws, "error", {{"code", code}, {"message", message}});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Lobby::generateRoomId() {
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << "cactus-room-" << std::setw(2) << std::setfill('0') << next_room_id_++;
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
json Lobby::roomsSummary() const {
|
||||||
|
json rooms = json::array();
|
||||||
|
for (const auto &[id, room] : rooms_) {
|
||||||
|
rooms.push_back({
|
||||||
|
{"id", room.id},
|
||||||
|
{"name", room.id},
|
||||||
|
{"mode", room.mode},
|
||||||
|
{"players", room.players.size()},
|
||||||
|
{"max_players", kMaxPlayersPerRoom},
|
||||||
|
{"state", room.state},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
json Lobby::roomPlayers(const Room &room) const {
|
||||||
|
json players = json::array();
|
||||||
|
for (size_t i = 0; i < room.players.size(); i++) {
|
||||||
|
players.push_back({
|
||||||
|
{"id", room.players[i].player_id},
|
||||||
|
{"nickname", room.players[i].nickname},
|
||||||
|
{"slot", i},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::broadcastRoomsToLobby() {
|
||||||
|
json rooms = roomsSummary();
|
||||||
|
for (WS *client : sockets_) {
|
||||||
|
if (client->getUserData()->room_id.empty()) {
|
||||||
|
send(client, "lobby.rooms", {{"rooms", rooms}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::broadcastRoomUpdated(const Room &room, WS *exclude) {
|
||||||
|
json payload = {{"players", roomPlayers(room)}};
|
||||||
|
for (const auto &p : room.players) {
|
||||||
|
if (p.ws != exclude) {
|
||||||
|
send(p.ws, "lobby.room_updated", payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::onOpen(WS *ws) {
|
||||||
|
sockets_.insert(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::onClose(WS *ws) {
|
||||||
|
removePlayerFromRoom(ws);
|
||||||
|
sockets_.erase(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::onMessage(WS *ws, std::string_view message) {
|
||||||
|
json request;
|
||||||
|
std::string type;
|
||||||
|
try {
|
||||||
|
request = json::parse(message);
|
||||||
|
type = request.value("type", "");
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
sendError(ws, "bad_json", e.what());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json payload = request.value("payload", json::object());
|
||||||
|
|
||||||
|
if (type == "hello") {
|
||||||
|
handleHello(ws, payload);
|
||||||
|
} else if (type == "lobby.list_rooms") {
|
||||||
|
handleListRooms(ws);
|
||||||
|
} else if (type == "lobby.create_room") {
|
||||||
|
handleCreateRoom(ws, payload);
|
||||||
|
} else if (type == "lobby.join_room") {
|
||||||
|
handleJoinRoom(ws, payload);
|
||||||
|
} else if (type == "lobby.leave_room") {
|
||||||
|
handleLeaveRoom(ws);
|
||||||
|
} else {
|
||||||
|
sendError(ws, "unknown_type", "unknown message type: " + type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::handleHello(WS *ws, const json &payload) {
|
||||||
|
auto *data = ws->getUserData();
|
||||||
|
if (data->player_id.empty()) {
|
||||||
|
data->player_id = "player-" + std::to_string(next_player_id_++);
|
||||||
|
}
|
||||||
|
data->nickname = payload.value("nickname", "anon");
|
||||||
|
send(ws, "hello.ack", {{"player_id", data->player_id}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::handleListRooms(WS *ws) {
|
||||||
|
send(ws, "lobby.rooms", {{"rooms", roomsSummary()}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::handleCreateRoom(WS *ws, const json &payload) {
|
||||||
|
auto *data = ws->getUserData();
|
||||||
|
if (data->player_id.empty()) {
|
||||||
|
sendError(ws, "not_authenticated", "send hello first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data->room_id.empty()) {
|
||||||
|
sendError(ws, "already_in_room", "leave the current room first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string mode = payload.value("mode", "");
|
||||||
|
if (mode != "coop" && mode != "pvp") {
|
||||||
|
sendError(ws, "invalid_mode", "mode must be \"coop\" or \"pvp\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Room room;
|
||||||
|
room.id = generateRoomId();
|
||||||
|
room.mode = mode;
|
||||||
|
room.players.push_back({data->player_id, data->nickname, ws});
|
||||||
|
|
||||||
|
data->room_id = room.id;
|
||||||
|
std::string room_id = room.id;
|
||||||
|
rooms_[room_id] = std::move(room);
|
||||||
|
|
||||||
|
send(ws, "lobby.room_joined", {
|
||||||
|
{"room_id", room_id},
|
||||||
|
{"name", room_id},
|
||||||
|
{"mode", mode},
|
||||||
|
{"players", roomPlayers(rooms_[room_id])},
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastRoomsToLobby();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::handleJoinRoom(WS *ws, const json &payload) {
|
||||||
|
auto *data = ws->getUserData();
|
||||||
|
if (data->player_id.empty()) {
|
||||||
|
sendError(ws, "not_authenticated", "send hello first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data->room_id.empty()) {
|
||||||
|
sendError(ws, "already_in_room", "leave the current room first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string room_id = payload.value("room_id", "");
|
||||||
|
auto it = rooms_.find(room_id);
|
||||||
|
if (it == rooms_.end()) {
|
||||||
|
sendError(ws, "room_not_found", "no such room: " + room_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Room &room = it->second;
|
||||||
|
if (room.state != "waiting") {
|
||||||
|
sendError(ws, "room_in_progress", "room is not accepting new players");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (room.players.size() >= kMaxPlayersPerRoom) {
|
||||||
|
sendError(ws, "room_full", "room is full");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
room.players.push_back({data->player_id, data->nickname, ws});
|
||||||
|
data->room_id = room_id;
|
||||||
|
|
||||||
|
send(ws, "lobby.room_joined", {
|
||||||
|
{"room_id", room_id},
|
||||||
|
{"name", room_id},
|
||||||
|
{"mode", room.mode},
|
||||||
|
{"players", roomPlayers(room)},
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastRoomUpdated(room, ws);
|
||||||
|
broadcastRoomsToLobby();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::handleLeaveRoom(WS *ws) {
|
||||||
|
if (ws->getUserData()->room_id.empty()) {
|
||||||
|
sendError(ws, "not_in_room", "you are not in a room");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removePlayerFromRoom(ws);
|
||||||
|
broadcastRoomsToLobby();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Lobby::removePlayerFromRoom(WS *ws) {
|
||||||
|
auto *data = ws->getUserData();
|
||||||
|
if (data->room_id.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto it = rooms_.find(data->room_id);
|
||||||
|
data->room_id.clear();
|
||||||
|
if (it == rooms_.end()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Room &room = it->second;
|
||||||
|
room.players.erase(
|
||||||
|
std::remove_if(room.players.begin(), room.players.end(),
|
||||||
|
[ws](const RoomPlayer &p) { return p.ws == ws; }),
|
||||||
|
room.players.end());
|
||||||
|
|
||||||
|
if (room.players.empty()) {
|
||||||
|
rooms_.erase(it);
|
||||||
|
} else {
|
||||||
|
broadcastRoomUpdated(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <App.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
struct PerSocketData {
|
||||||
|
std::string player_id;
|
||||||
|
std::string nickname;
|
||||||
|
std::string room_id; // пусто, если игрок в лобби, а не в комнате
|
||||||
|
};
|
||||||
|
|
||||||
|
using WS = uWS::WebSocket<false, true, PerSocketData>;
|
||||||
|
|
||||||
|
struct RoomPlayer {
|
||||||
|
std::string player_id;
|
||||||
|
std::string nickname;
|
||||||
|
WS *ws;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Room {
|
||||||
|
std::string id;
|
||||||
|
std::string mode; // "coop" | "pvp"
|
||||||
|
std::string state = "waiting"; // waiting | playing | finished
|
||||||
|
std::vector<RoomPlayer> players;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Лобби и реестр комнат (этап 3 из TZ.md). Игровая логика — этапы 4-5.
|
||||||
|
class Lobby {
|
||||||
|
public:
|
||||||
|
void onOpen(WS *ws);
|
||||||
|
void onMessage(WS *ws, std::string_view message);
|
||||||
|
void onClose(WS *ws);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr size_t kMaxPlayersPerRoom = 2;
|
||||||
|
|
||||||
|
std::unordered_map<std::string, Room> rooms_;
|
||||||
|
std::unordered_set<WS *> sockets_;
|
||||||
|
int next_player_id_ = 1;
|
||||||
|
int next_room_id_ = 1;
|
||||||
|
|
||||||
|
void handleHello(WS *ws, const json &payload);
|
||||||
|
void handleListRooms(WS *ws);
|
||||||
|
void handleCreateRoom(WS *ws, const json &payload);
|
||||||
|
void handleJoinRoom(WS *ws, const json &payload);
|
||||||
|
void handleLeaveRoom(WS *ws);
|
||||||
|
void removePlayerFromRoom(WS *ws);
|
||||||
|
|
||||||
|
void broadcastRoomsToLobby();
|
||||||
|
void broadcastRoomUpdated(const Room &room, WS *exclude = nullptr);
|
||||||
|
|
||||||
|
json roomsSummary() const;
|
||||||
|
json roomPlayers(const Room &room) const;
|
||||||
|
std::string generateRoomId();
|
||||||
|
|
||||||
|
static void send(WS *ws, const std::string &type, json payload);
|
||||||
|
static void sendError(WS *ws, const std::string &code, const std::string &message);
|
||||||
|
};
|
||||||
+10
-28
@@ -1,41 +1,23 @@
|
|||||||
// Каркас (этап 1 из TZ.md): проверка связки nginx <-ws-> gameserver.
|
// Игровая логика (тик, коллизии) — этапы 4-5 из TZ.md.
|
||||||
// Лобби/комнаты/игровая логика — следующие этапы.
|
|
||||||
|
|
||||||
#include <App.h>
|
#include <App.h>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
|
|
||||||
using json = nlohmann::json;
|
#include "Lobby.hpp"
|
||||||
|
|
||||||
struct PerSocketData {};
|
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
|
Lobby lobby;
|
||||||
|
|
||||||
uWS::App()
|
uWS::App()
|
||||||
.ws<PerSocketData>("/ws", {
|
.ws<PerSocketData>("/ws", {
|
||||||
.open = [](auto *ws) {
|
.open = [&lobby](auto *ws) {
|
||||||
std::cout << "client connected\n";
|
lobby.onOpen(ws);
|
||||||
},
|
},
|
||||||
.message = [](auto *ws, std::string_view message, uWS::OpCode) {
|
.message = [&lobby](auto *ws, std::string_view message, uWS::OpCode) {
|
||||||
json response;
|
lobby.onMessage(ws, message);
|
||||||
try {
|
|
||||||
json request = json::parse(message);
|
|
||||||
std::string type = request.value("type", "");
|
|
||||||
|
|
||||||
if (type == "hello") {
|
|
||||||
response = {{"type", "hello.ack"}, {"payload", {{"player_id", "stub"}}}};
|
|
||||||
} else if (type == "lobby.list_rooms") {
|
|
||||||
response = {{"type", "lobby.rooms"}, {"payload", {{"rooms", json::array()}}}};
|
|
||||||
} else {
|
|
||||||
response = {{"type", "error"},
|
|
||||||
{"payload", {{"code", "unknown_type"}, {"message", "unknown message type: " + type}}}};
|
|
||||||
}
|
|
||||||
} catch (const std::exception &e) {
|
|
||||||
response = {{"type", "error"}, {"payload", {{"code", "bad_json"}, {"message", e.what()}}}};
|
|
||||||
}
|
|
||||||
ws->send(response.dump(), uWS::OpCode::TEXT);
|
|
||||||
},
|
},
|
||||||
.close = [](auto *ws, int, std::string_view) {
|
.close = [&lobby](auto *ws, int, std::string_view) {
|
||||||
std::cout << "client disconnected\n";
|
lobby.onClose(ws);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.listen(9001, [](auto *token) {
|
.listen(9001, [](auto *token) {
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ server {
|
|||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# без хешей в именах файлов — просим браузер всегда переспрашивать свежесть
|
||||||
|
location ~* \.(js|css)$ {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location /ws {
|
location /ws {
|
||||||
proxy_pass http://gameserver:9001;
|
proxy_pass http://gameserver:9001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
Reference in New Issue
Block a user