Compare commits
1
Commits
dev
..
b5566ec73e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5566ec73e |
@@ -8,21 +8,15 @@ 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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 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
|
||||
rsync -a --delete --exclude '.git' ./ /data/docker/compose/cactoz/
|
||||
|
||||
- name: Build and restart
|
||||
working-directory: /data/docker/compose/cactoz
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
**Ник `cacto` (кактус) — сквозной мотив:**
|
||||
- Пиксельный кактус (16×16 или 32×32 спрайт, зелёный/серебро на чёрном) как лого в шапке и favicon.
|
||||
- ASCII-арт кактуса в hero-блоке главной страницы.
|
||||
- Терминальные префиксы вместо обычных заголовков/навигации: `cacto@matrix:~$ whoami`, `cacto@matrix:~$ ls projects/`, `cacto@matrix:~$ ./tanks`.
|
||||
- Терминальные префиксы вместо обычных заголовков/навигации: `cacto@matrix:~$ whoami`, `cacto@matrix:~$ ls projects/`, `cacto@matrix:~$ ./battlecity`.
|
||||
- Названия комнат в лобби генерируются в формате `cactus-room-01`, `cactus-room-02`, ...
|
||||
- Характер, а не просто ник: живучесть, минимум ресурсов, "колючий" стиль текста в отдельных местах (например, error-сообщения).
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|---|------|------------|
|
||||
| 1 | `index.html` | Резюме — PHP/Go backend, опыт КИПиА/АСУТП |
|
||||
| 2 | `projects.html` | Карточки проектов со ссылками на репозитории |
|
||||
| 3 | `game.html` | ./tanks: лобби + игра (танки, собственная разработка — без привязки к товарным знакам) |
|
||||
| 3 | `game.html` | BattleCity: лобби + игра |
|
||||
|
||||
## 3. Структура проекта
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB |
@@ -1,41 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>./blocks — cacto</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/arcade.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="matrix-rain"></canvas>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||
cacto<span class="cursor">_</span>
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">whoami</a></li>
|
||||
<li><a href="/projects">ls projects/</a></li>
|
||||
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<main>
|
||||
<h2 class="prompt">./blocks</h2>
|
||||
<div id="arcade-root"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/matrix-rain.js"></script>
|
||||
<script type="module" src="/js/blocks.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,206 +0,0 @@
|
||||
/* --- общие стили для одиночных аркад (./blocks, ./invaders) --- */
|
||||
|
||||
.arcade-crosslinks {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.arcade-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.arcade-canvas-slot {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.arcade-canvas-slot canvas {
|
||||
border: var(--border);
|
||||
background: var(--color-bg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.arcade-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
padding-top: 0.5rem;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.arcade-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-silver);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arcade-stats b {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.arcade-next-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.arcade-next-slot span {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arcade-next-slot canvas {
|
||||
border: var(--border);
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
|
||||
.arcade-controls__hint {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.arcade-dpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 44px);
|
||||
grid-template-rows: repeat(2, 44px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.arcade-dpad button {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-accent-dim);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.arcade-dpad button:active {
|
||||
background: var(--color-accent-dim);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.arcade-dpad__up { grid-column: 2; grid-row: 1; }
|
||||
.arcade-dpad__left { grid-column: 1; grid-row: 2; }
|
||||
.arcade-dpad__right { grid-column: 3; grid-row: 2; }
|
||||
.arcade-dpad__down { grid-column: 2; grid-row: 2; }
|
||||
|
||||
/* --- пара кнопок влево/вправо (./invaders — вертикаль не нужна) --- */
|
||||
.arcade-lr {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.arcade-lr button {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-accent-dim);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
width: 62px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.arcade-lr button:active {
|
||||
background: var(--color-accent-dim);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.arcade-action-btn {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.arcade-action-btn:active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.arcade-result-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 14, 12, 0.92);
|
||||
z-index: 5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arcade-result {
|
||||
padding: 2rem;
|
||||
border: var(--border);
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
|
||||
.arcade-result h2 {
|
||||
font-size: 2.2rem;
|
||||
margin: 0 0 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 0 0 16px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
.arcade-result p {
|
||||
color: var(--color-fg);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.arcade-result__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.arcade-result__actions button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-accent-dim);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.5rem 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arcade-result__actions button:hover {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 6px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.arcade-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.arcade-controls {
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
.arcade-crosslinks {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.lobby-panel {
|
||||
border: var(--border);
|
||||
background: var(--color-bg-raised);
|
||||
@@ -94,147 +88,3 @@
|
||||
content: '- ';
|
||||
color: var(--color-accent-dim);
|
||||
}
|
||||
|
||||
#tanks-canvas-container {
|
||||
position: relative;
|
||||
max-width: 900px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.battle-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#battle-canvas-slot canvas {
|
||||
border: var(--border);
|
||||
background: var(--color-bg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* --- блок управления справа от поля --- */
|
||||
.battle-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.battle-controls__hint {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 44px);
|
||||
grid-template-rows: repeat(2, 44px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.dpad button:active {
|
||||
background: var(--color-accent-dim);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.dpad__up { grid-column: 2; grid-row: 1; }
|
||||
.dpad__left { grid-column: 1; grid-row: 2; }
|
||||
.dpad__right { grid-column: 3; grid-row: 2; }
|
||||
.dpad__down { grid-column: 2; grid-row: 2; }
|
||||
|
||||
.fire-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.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.fire-btn:active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
/* --- экран победы/поражения --- */
|
||||
#battle-result-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 14, 12, 0.9);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.battle-result {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
border: var(--border);
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
|
||||
.battle-result h2 {
|
||||
font-size: 3rem;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.battle-result--win h2 {
|
||||
color: var(--color-accent);
|
||||
text-shadow: 0 0 16px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
.battle-result--lose h2 {
|
||||
color: var(--color-silver);
|
||||
}
|
||||
|
||||
.battle-result--draw h2 {
|
||||
color: var(--color-silver);
|
||||
}
|
||||
|
||||
.battle-result p {
|
||||
color: var(--color-fg);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.battle-result__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.battle-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;
|
||||
}
|
||||
|
||||
.battle-result__actions button:hover {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 6px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
+45
-35
@@ -1,53 +1,63 @@
|
||||
.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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
border: var(--border);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.project-card__header {
|
||||
background: var(--color-bg-raised);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
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-wrap: wrap;
|
||||
margin-bottom: 0.6rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-card__name {
|
||||
.project-card__title {
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
.project-card__perms {
|
||||
color: var(--color-accent-mid);
|
||||
}
|
||||
|
||||
.project-card__desc {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-fg-dim);
|
||||
margin: 0;
|
||||
color: var(--color-fg);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.project-card__link {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.project-card__link::before {
|
||||
content: '$ ';
|
||||
color: var(--color-accent-dim);
|
||||
}
|
||||
|
||||
+34
-143
@@ -18,113 +18,65 @@
|
||||
|
||||
.hero-text .tagline {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--color-accent-mid);
|
||||
color: var(--color-silver);
|
||||
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 {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
section > h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.about p {
|
||||
max-width: 68ch;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.7;
|
||||
color: var(--color-fg);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
border-left: var(--border);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 0 0 0 1.5rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
border-left: var(--border);
|
||||
padding-left: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
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-dim);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0.2rem;
|
||||
color: var(--color-silver);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.timeline-item__role {
|
||||
color: var(--color-fg);
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.15rem;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.timeline-item__company {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--color-accent);
|
||||
margin: 0.15rem 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.timeline-item ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.timeline-item li {
|
||||
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;
|
||||
.timeline-item li::marker {
|
||||
color: var(--color-accent-dim);
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
@@ -133,73 +85,12 @@ section {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.contact-list__label {
|
||||
color: var(--color-silver-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;
|
||||
.contact-list a::before {
|
||||
content: '$ ';
|
||||
color: var(--color-accent-dim);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
+45
-91
@@ -1,17 +1,12 @@
|
||||
:root {
|
||||
--color-bg: #050705;
|
||||
--color-bg-raised: #0d100d;
|
||||
--color-fg: oklch(84% 0.01 240);
|
||||
--color-fg-dim: oklch(76% 0.01 240);
|
||||
--color-accent: oklch(80% 0.19 142);
|
||||
--color-accent-bright: oklch(88% 0.17 142);
|
||||
--color-accent-mid: oklch(55% 0.13 142);
|
||||
--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);
|
||||
--color-bg: #0a0e0c;
|
||||
--color-bg-raised: #0f1512;
|
||||
--color-fg: #c7c7c7;
|
||||
--color-accent: #00ff41;
|
||||
--color-accent-dim: #0a8f2c;
|
||||
--color-silver: #9fa3a0;
|
||||
--font-mono: 'JetBrains Mono', 'Share Tech Mono', ui-monospace, monospace;
|
||||
--border: 1px solid #163020;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -30,19 +25,9 @@ body {
|
||||
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 {
|
||||
background: oklch(42% 0.14 142);
|
||||
color: #eafff0;
|
||||
background: var(--color-accent-dim);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -63,7 +48,7 @@ button:focus-visible {
|
||||
.container {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
/* --- фон: canvas цифрового дождя --- */
|
||||
@@ -82,33 +67,23 @@ body::before {
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0px,
|
||||
rgba(0, 0, 0, 0) 2px,
|
||||
rgba(80, 255, 140, 0.025) 3px
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.15) 0px,
|
||||
rgba(0, 0, 0, 0.15) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* --- шапка: прилипает к верху, слегка размыта поверх дождя --- */
|
||||
/* --- шапка --- */
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: var(--border);
|
||||
flex-wrap: wrap;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 1.1rem 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -126,18 +101,8 @@ body::before {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -153,29 +118,38 @@ body::before {
|
||||
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 {
|
||||
content: 'cacto@matrix:~$ ';
|
||||
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-cactus {
|
||||
color: var(--color-accent);
|
||||
@@ -234,23 +208,3 @@ body::before {
|
||||
padding: 0.15rem 0.6rem;
|
||||
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;
|
||||
}
|
||||
|
||||
+8
-18
@@ -3,45 +3,35 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>./tanks — cacto</title>
|
||||
<title>./battlecity — cacto</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||
<link rel="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/game.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="matrix-rain"></canvas>
|
||||
|
||||
<div class="container">
|
||||
<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>
|
||||
cacto
|
||||
</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>
|
||||
<li><a href="/projects.html">ls projects/</a></li>
|
||||
<li><a href="/game.html" aria-current="page">./battlecity</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<main id="game-root">
|
||||
<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>
|
||||
<!-- TODO: лобби (этап 3) и игровое поле на Quintus.js (этапы 4-5) -->
|
||||
<h2 class="prompt">./battlecity</h2>
|
||||
<div id="lobby"></div>
|
||||
<div id="tanks-canvas-container" style="display: none"></div>
|
||||
<div id="battlecity-canvas-container"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/vendor/quintus/quintus.js"></script>
|
||||
<script src="/vendor/quintus/quintus_2d.js"></script>
|
||||
<script src="/vendor/quintus/quintus_sprites.js"></script>
|
||||
<script src="/vendor/quintus/quintus_scenes.js"></script>
|
||||
<script src="/vendor/quintus/quintus_input.js"></script>
|
||||
<script type="module" src="/js/matrix-rain.js"></script>
|
||||
<script type="module" src="/js/game/main.js"></script>
|
||||
</body>
|
||||
|
||||
+52
-145
@@ -3,51 +3,32 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cacto — Гаммель Дмитрий, PHP backend-разработчик</title>
|
||||
<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" />
|
||||
<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="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/resume.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="matrix-rain"></canvas>
|
||||
|
||||
<div class="container">
|
||||
<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>
|
||||
cacto
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/" aria-current="page">whoami</a></li>
|
||||
<li><a href="/projects">ls projects/</a></li>
|
||||
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
|
||||
<li><a href="/projects.html">ls projects/</a></li>
|
||||
<li><a href="/game.html">./battlecity</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<main>
|
||||
<section class="hero">
|
||||
<pre class="ascii-cactus" aria-hidden="true"> , ,
|
||||
<pre class="ascii-cactus" aria-hidden="true">
|
||||
, ,
|
||||
|\_/|
|
||||
.--| |--.
|
||||
( | | )
|
||||
@@ -61,171 +42,97 @@
|
||||
__| |__
|
||||
(_________)</pre>
|
||||
<div class="hero-text">
|
||||
<div class="status-line"><span class="status-dot"></span>STATUS: OPEN TO WORK</div>
|
||||
<h1><span class="glitch" data-text="Гаммель Дмитрий">Гаммель Дмитрий</span></h1>
|
||||
<p class="role">PHP-разработчик (Backend, Middle+/Senior)</p>
|
||||
<p class="tagline">От электрического сигнала до строчки кода.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="hero-actions__btn" href="/assets/dmitry-gammel-cv.pdf" download>скачать резюме (PDF)</a>
|
||||
<a class="hero-actions__btn" href="https://git.cactoz.su/cacto" target="_blank" rel="noopener">git.cactoz.su/cacto</a>
|
||||
</div>
|
||||
<p class="role">PHP / Go backend-разработчик</p>
|
||||
<p class="tagline">Живучий, колючий, не требует много воды. Специализация — сложные запросы, legacy-код, архитектура.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about">
|
||||
<h2 class="section-heading"><span class="section-heading__num">01</span>cat about.txt</h2>
|
||||
<p>Backend-разработчик, 6 лет коммерческого опыта на PHP. Работаю с legacy, сложными запросами и архитектурными решениями — там, где «просто добавить фичу» не получается.</p>
|
||||
<p>Спроектировал аналитический контур на ClickHouse: отчёты формировались 3–4 минуты, стали за 10–15 секунд. Вёл разработку модуля документооборота в банковской IT-инфраструктуре — архитектура, декомпозиция, код-ревью, онбординг разработчиков.</p>
|
||||
<p>Начинал с электротехники и АСУТП: писал управляющий софт для промышленных систем на ST, поднимал SCADA. Путь «от электрического сигнала до строчки кода» помогает быстро вникать в чужие системы и в задачи на стыке железа и бэкенда.</p>
|
||||
<p>Ищу позицию Middle+/Senior backend. Основной стек — PHP, параллельно пишу на Go.</p>
|
||||
<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="section-heading"><span class="section-heading__num">02</span>ls -la experience/</h2>
|
||||
<h2 class="prompt">ls -la experience/</h2>
|
||||
<ol class="timeline">
|
||||
<li class="timeline-item">
|
||||
<div class="timeline-item__meta">март 2026 — настоящее время · 5 месяцев</div>
|
||||
<h3 class="timeline-item__role">Программист АСУТП и КИПиА</h3>
|
||||
<div class="timeline-item__company">Уралэнергоаква · Екатеринбург</div>
|
||||
<div class="timeline-item__meta">июль 2022 — март 2026 · Екатеринбург</div>
|
||||
<h3 class="timeline-item__role">PHP-разработчик — ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк»)</h3>
|
||||
<ul>
|
||||
<li><span class="dash">-</span><span>Временный период вне основной специальности после завершения проекта в ЭТП ГПБ. Ищу позицию backend-разработчика; параллельно — пет-проекты и Go.</span></li>
|
||||
<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">июль 2022 — март 2026 · 3 года 9 месяцев</div>
|
||||
<h3 class="timeline-item__role">PHP-разработчик</h3>
|
||||
<div class="timeline-item__company">ООО «ЭТП ГПБ» (дочернее АО «Газпромбанк») · Екатеринбург</div>
|
||||
<div class="timeline-item__meta">март 2021 — июль 2022 · фриланс/субподряд</div>
|
||||
<h3 class="timeline-item__role">PHP-разработчик — kupisever.ru</h3>
|
||||
<ul>
|
||||
<li><span class="dash">-</span><span>Разрабатывал и поддерживал внутреннюю систему электронного документооборота для согласования и контроля исполнения договоров.</span></li>
|
||||
<li><span class="dash">-</span><span>Поддерживал и развивал legacy-проект на Zend Framework, участвовал в архитектурном переходе на Symfony; PHP 7.4 → 8.0.</span></li>
|
||||
<li><span class="dash">-</span><span>Переписывал построители запросов на «чистый» SQL с оптимизацией под PostgreSQL, внедрял сервисный слой архитектуры.</span></li>
|
||||
<li><span class="dash">-</span><span>Спроектировал и реализовал аналитический контур отчётности на ClickHouse — сократил время формирования отчётов с 3–4 минут до 10–15 секунд.</span></li>
|
||||
<li><span class="dash">-</span><span>Реализовал интеграции с внутренними банковскими сервисами, включая проверку электронной подписи и машиночитаемых доверенностей.</span></li>
|
||||
<li><span class="dash">-</span><span>Работал с очередями сообщений (RabbitMQ) для асинхронной обработки задач.</span></li>
|
||||
<li><span class="dash">-</span><span>Неофициально возглавлял разработку нового модуля документооборота: планировал архитектуру, проводил код-ревью, довёл проект до MVP.</span></li>
|
||||
<li><span class="dash">-</span><span>Работал по Scrum: месячные спринты, таск-трекер YouTrack, оценка задач методом Planning Poker.</span></li>
|
||||
<li>Доработка бэкенда B2B-платформы (доска объявлений) на Yii2, стек PHP/PostgreSQL/RabbitMQ/Docker.</li>
|
||||
<li>Модуль email-рассылок с очередями сообщений и конструктором писем.</li>
|
||||
<li>Модуль категорий объявлений — админка и клиентское меню.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="timeline-item">
|
||||
<div class="timeline-item__meta">март 2021 — июль 2022 · 1 год 5 месяцев</div>
|
||||
<h3 class="timeline-item__role">PHP-разработчик</h3>
|
||||
<div class="timeline-item__company">фриланс/субподряд · kupisever.ru · удалённо</div>
|
||||
<div class="timeline-item__meta">июль 2020 — март 2021 · Курган</div>
|
||||
<h3 class="timeline-item__role">PHP-разработчик (с обязанностями руководителя группы) — ИстВуд</h3>
|
||||
<ul>
|
||||
<li><span class="dash">-</span><span>Дорабатывал бэкенд действующей B2B-платформы (доска объявлений) на Yii2 в качестве субподрядчика, работал самостоятельно на удалённом проекте. Стек: PHP, Yii2, PostgreSQL, RabbitMQ, Docker.</span></li>
|
||||
<li><span class="dash">-</span><span>Разработал модуль email-рассылок с очередями сообщений (RabbitMQ): веб-форма конструктора писем с текстовым редактором и гибкой настройкой параметров рассылки.</span></li>
|
||||
<li><span class="dash">-</span><span>Реализовал модуль категорий объявлений — административную часть (бэкенд) и клиентское меню (фронтенд).</span></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="timeline-item">
|
||||
<div class="timeline-item__meta">июль 2020 — март 2021 · 9 месяцев</div>
|
||||
<h3 class="timeline-item__role">PHP-разработчик (с обязанностями руководителя группы)</h3>
|
||||
<div class="timeline-item__company">ИстВуд · Курган</div>
|
||||
<ul>
|
||||
<li><span class="dash">-</span><span>Интернет-магазины на 1С-Битрикс: вёрстка, интеграция готовых модулей, доработка бизнес-логики под задачи клиентов.</span></li>
|
||||
<li><span class="dash">-</span><span>Реализовал интеграции с 1С (обмен товарными каталогами и заказами) и платёжными системами (эквайринг).</span></li>
|
||||
<li><span class="dash">-</span><span>Реализовал нестандартный механизм автоматического получения и обновления каталога товаров (несколько тысяч позиций, ежесуточное обновление) для интернет-магазина на кастомном шаблоне вне типовых решений Bitrix.</span></li>
|
||||
<li><span class="dash">-</span><span>Разработал функционал онлайн-записи на приём к врачу для сайта частной клиники; реализовал интеграцию с фискальным регистратором.</span></li>
|
||||
<li><span class="dash">-</span><span>Участвовал во внедрении Битрикс24: настройка бизнес-процессов и цепочек согласования закупок.</span></li>
|
||||
<li><span class="dash">-</span><span>Фактически руководил командой из 3 разработчиков: распределял задачи, контролировал качество реализации, проводил технические собеседования.</span></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="timeline-item">
|
||||
<div class="timeline-item__meta">октябрь 2019 — февраль 2020 · 5 месяцев</div>
|
||||
<h3 class="timeline-item__role">Техник по телекоммуникациям</h3>
|
||||
<div class="timeline-item__company">Урал-М · Курган</div>
|
||||
<ul>
|
||||
<li><span class="dash">-</span><span>Настройка и обслуживание систем СКУД, видеонаблюдения, радиоканальной связи; работа с 1С.</span></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="timeline-item">
|
||||
<div class="timeline-item__meta">апрель 2014 — июль 2018 · 4 года 4 месяца</div>
|
||||
<h3 class="timeline-item__role">Техник-электрик</h3>
|
||||
<div class="timeline-item__company">Кирпичный завод, Мясокомбинат, Хлебозавод · Курган</div>
|
||||
<ul>
|
||||
<li><span class="dash">-</span><span>Электромонтажные и наладочные работы на производственных предприятиях.</span></li>
|
||||
<li>Интернет-магазины на 1С-Битрикс, интеграции с 1С и платёжными системами.</li>
|
||||
<li>Фактически руководил командой из 3 разработчиков: задачи, код-ревью, технические собеседования.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="skills">
|
||||
<h2 class="section-heading"><span class="section-heading__num">03</span>skills --list</h2>
|
||||
<div class="skill-group">
|
||||
<div class="skill-group__title">backend</div>
|
||||
<h2 class="prompt">skills --list</h2>
|
||||
<ul class="tag-list">
|
||||
<li class="tag">PHP8</li>
|
||||
<li class="tag">PHP7</li>
|
||||
<li class="tag">Symfony</li>
|
||||
<li class="tag">Zend Framework</li>
|
||||
<li class="tag">Yii2</li>
|
||||
<li class="tag">PostgreSQL</li>
|
||||
<li class="tag">MySQL</li>
|
||||
<li class="tag">ClickHouse</li>
|
||||
<li class="tag">SQL</li>
|
||||
<li class="tag">RabbitMQ</li>
|
||||
<li class="tag">Docker</li>
|
||||
<li class="tag">Git</li>
|
||||
<li class="tag">REST API</li>
|
||||
<li class="tag">PHPUnit</li>
|
||||
<li class="tag">Composer</li>
|
||||
<li class="tag">ООП</li>
|
||||
<li class="tag">Go</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="skill-group">
|
||||
<div class="skill-group__title">electrical / automation</div>
|
||||
<ul class="tag-list">
|
||||
<li class="tag">АСУТП</li>
|
||||
<li class="tag">КИПиА</li>
|
||||
<li class="tag">СКУД</li>
|
||||
<li class="tag">Видеонаблюдение</li>
|
||||
<li class="tag">Радиоканальная связь</li>
|
||||
<li class="tag">Электромонтаж</li>
|
||||
<li class="tag">1С</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="skill-group">
|
||||
<div class="skill-group__title">soft</div>
|
||||
<ul class="tag-list">
|
||||
<li class="tag">Оптимизация SQL-запросов</li>
|
||||
<li class="tag">Обучение и адаптация junior-разработчиков</li>
|
||||
<li class="tag">Быстрая обучаемость</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="education">
|
||||
<h2 class="section-heading"><span class="section-heading__num">04</span>cat education.txt</h2>
|
||||
<ul class="edu-list">
|
||||
<li class="edu-item">
|
||||
<div class="edu-item__year">2013</div>
|
||||
<div class="edu-item__degree">Неоконченное высшее</div>
|
||||
<div class="edu-item__school">Тюменский государственный нефтегазовый университет (ТюмГНГУ)</div>
|
||||
<div class="edu-item__detail">Институт кибернетики, информатики и связи, Электроэнергетика и электротехника</div>
|
||||
</li>
|
||||
<li class="edu-item">
|
||||
<div class="edu-item__year">2011</div>
|
||||
<div class="edu-item__degree">Свидетельство о квалификации «Электромонтёр 3 разряда»</div>
|
||||
<div class="edu-item__school">ФГБОУ ВПО «Тюменский государственный университет»</div>
|
||||
</li>
|
||||
<li class="tag">Go (изучаю)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="contact">
|
||||
<h2 class="section-heading"><span class="section-heading__num">05</span>cat contact.txt</h2>
|
||||
<h2 class="prompt">cat contact.txt</h2>
|
||||
<ul class="contact-list">
|
||||
<li><span class="contact-list__label">email</span><a href="mailto:cactozzz93@gmail.com">cactozzz93@gmail.com</a></li>
|
||||
<li><span class="contact-list__label">telegram</span><a href="https://t.me/Cactozz">t.me/Cactozz</a></li>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<script type="module" src="/js/matrix-rain.js"></script>
|
||||
<script type="module" src="/js/resume.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>./invaders — cacto</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/arcade.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="matrix-rain"></canvas>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||
cacto<span class="cursor">_</span>
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">whoami</a></li>
|
||||
<li><a href="/projects">ls projects/</a></li>
|
||||
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<main>
|
||||
<h2 class="prompt">./invaders</h2>
|
||||
<div id="arcade-root"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/matrix-rain.js"></script>
|
||||
<script type="module" src="/js/invaders.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,440 +0,0 @@
|
||||
import { getLang, initLangToggle } from './i18n.js';
|
||||
|
||||
const COLS = 10;
|
||||
const ROWS = 20;
|
||||
const CELL = 24;
|
||||
const GRAVITY_START_MS = 800;
|
||||
const GRAVITY_MIN_MS = 100;
|
||||
|
||||
const SHAPES = {
|
||||
I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
|
||||
J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
|
||||
L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
|
||||
O: [[1, 1], [1, 1]],
|
||||
S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
|
||||
T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
|
||||
Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
|
||||
};
|
||||
|
||||
const COLORS = {
|
||||
I: '#00ff41',
|
||||
O: '#9fa3a0',
|
||||
T: '#39e075',
|
||||
S: '#7cffb2',
|
||||
Z: '#1fae56',
|
||||
J: '#c7c7c7',
|
||||
L: '#0a8f2c',
|
||||
};
|
||||
|
||||
const PIECE_TYPES = Object.keys(SHAPES);
|
||||
|
||||
const STR = {
|
||||
ru: {
|
||||
hint: 'стрелки — двигать, вверх — вращать, пробел — сброс',
|
||||
score: 'счёт',
|
||||
lines: 'линии',
|
||||
level: 'уровень',
|
||||
next: 'дальше',
|
||||
hardDrop: 'сброс',
|
||||
gameOver: 'ИГРА ОКОНЧЕНА',
|
||||
finalScore: (s) => `счёт: ${s}`,
|
||||
again: 'ещё раз',
|
||||
crosslinks: 'ещё игры:',
|
||||
},
|
||||
en: {
|
||||
hint: 'arrows to move, up to rotate, space to hard-drop',
|
||||
score: 'score',
|
||||
lines: 'lines',
|
||||
level: 'level',
|
||||
next: 'next',
|
||||
hardDrop: 'drop',
|
||||
gameOver: 'GAME OVER',
|
||||
finalScore: (s) => `score: ${s}`,
|
||||
again: 'play again',
|
||||
crosslinks: 'more games:',
|
||||
},
|
||||
};
|
||||
|
||||
function t() {
|
||||
return STR[getLang()];
|
||||
}
|
||||
|
||||
function emptyBoard() {
|
||||
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
||||
}
|
||||
|
||||
function rotateMatrix(matrix) {
|
||||
const n = matrix.length;
|
||||
const result = Array.from({ length: n }, () => Array(n).fill(0));
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
result[x][n - 1 - y] = matrix[y][x];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function randomType() {
|
||||
return PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)];
|
||||
}
|
||||
|
||||
class BlocksGame {
|
||||
constructor(canvas, nextCanvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.nextCanvas = nextCanvas;
|
||||
this.nextCtx = nextCanvas.getContext('2d');
|
||||
canvas.width = COLS * CELL;
|
||||
canvas.height = ROWS * CELL;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.board = emptyBoard();
|
||||
this.score = 0;
|
||||
this.lines = 0;
|
||||
this.level = 1;
|
||||
this.over = false;
|
||||
this.nextType = randomType();
|
||||
this.spawn();
|
||||
this.dropTimer = 0;
|
||||
this.lastTime = null;
|
||||
}
|
||||
|
||||
spawn() {
|
||||
const type = this.nextType;
|
||||
this.nextType = randomType();
|
||||
const matrix = SHAPES[type].map((row) => row.slice());
|
||||
this.piece = {
|
||||
type,
|
||||
matrix,
|
||||
x: Math.floor((COLS - matrix.length) / 2),
|
||||
y: 0,
|
||||
};
|
||||
if (this.collides(this.piece.matrix, this.piece.x, this.piece.y)) {
|
||||
this.over = true;
|
||||
}
|
||||
}
|
||||
|
||||
collides(matrix, offX, offY) {
|
||||
for (let y = 0; y < matrix.length; y++) {
|
||||
for (let x = 0; x < matrix[y].length; x++) {
|
||||
if (!matrix[y][x]) continue;
|
||||
const bx = offX + x;
|
||||
const by = offY + y;
|
||||
if (bx < 0 || bx >= COLS || by >= ROWS) return true;
|
||||
if (by >= 0 && this.board[by][bx]) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
move(dx, dy) {
|
||||
if (this.over) return false;
|
||||
const p = this.piece;
|
||||
if (!this.collides(p.matrix, p.x + dx, p.y + dy)) {
|
||||
p.x += dx;
|
||||
p.y += dy;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
rotate() {
|
||||
if (this.over) return;
|
||||
const p = this.piece;
|
||||
const rotated = rotateMatrix(p.matrix);
|
||||
const kicks = [0, 1, -1, 2, -2];
|
||||
for (const kick of kicks) {
|
||||
if (!this.collides(rotated, p.x + kick, p.y)) {
|
||||
p.matrix = rotated;
|
||||
p.x += kick;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hardDrop() {
|
||||
if (this.over) return;
|
||||
while (this.move(0, 1)) {
|
||||
this.score += 1;
|
||||
}
|
||||
this.lock();
|
||||
}
|
||||
|
||||
lock() {
|
||||
const p = this.piece;
|
||||
for (let y = 0; y < p.matrix.length; y++) {
|
||||
for (let x = 0; x < p.matrix[y].length; x++) {
|
||||
if (p.matrix[y][x]) {
|
||||
const by = p.y + y;
|
||||
const bx = p.x + x;
|
||||
if (by >= 0) this.board[by][bx] = p.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.clearLines();
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
clearLines() {
|
||||
let cleared = 0;
|
||||
this.board = this.board.filter((row) => {
|
||||
const full = row.every((cell) => cell);
|
||||
if (full) cleared++;
|
||||
return !full;
|
||||
});
|
||||
while (this.board.length < ROWS) {
|
||||
this.board.unshift(Array(COLS).fill(null));
|
||||
}
|
||||
if (cleared > 0) {
|
||||
const points = [0, 100, 300, 500, 800][cleared] || 800;
|
||||
this.score += points * this.level;
|
||||
this.lines += cleared;
|
||||
this.level = 1 + Math.floor(this.lines / 10);
|
||||
}
|
||||
}
|
||||
|
||||
gravityInterval() {
|
||||
return Math.max(GRAVITY_MIN_MS, GRAVITY_START_MS - (this.level - 1) * 60);
|
||||
}
|
||||
|
||||
tick(dtMs) {
|
||||
if (this.over) return;
|
||||
this.dropTimer += dtMs;
|
||||
const interval = this.gravityInterval();
|
||||
if (this.dropTimer >= interval) {
|
||||
this.dropTimer = 0;
|
||||
if (!this.move(0, 1)) {
|
||||
this.lock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawCell(ctx, x, y, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
|
||||
ctx.strokeStyle = '#0a0e0c';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
|
||||
}
|
||||
|
||||
draw() {
|
||||
const ctx = this.ctx;
|
||||
ctx.fillStyle = '#0a0e0c';
|
||||
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
if (this.board[y][x]) {
|
||||
this.drawCell(ctx, x, y, COLORS[this.board[y][x]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const p = this.piece;
|
||||
if (p) {
|
||||
for (let y = 0; y < p.matrix.length; y++) {
|
||||
for (let x = 0; x < p.matrix[y].length; x++) {
|
||||
if (p.matrix[y][x] && p.y + y >= 0) {
|
||||
this.drawCell(ctx, p.x + x, p.y + y, COLORS[p.type]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// сетка поверх, лёгкая
|
||||
ctx.strokeStyle = 'rgba(80, 255, 140, 0.06)';
|
||||
for (let x = 1; x < COLS; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * CELL, 0);
|
||||
ctx.lineTo(x * CELL, this.canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 1; y < ROWS; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * CELL);
|
||||
ctx.lineTo(this.canvas.width, y * CELL);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
this.drawNext();
|
||||
}
|
||||
|
||||
drawNext() {
|
||||
const ctx = this.nextCtx;
|
||||
const size = this.nextCanvas.width;
|
||||
ctx.fillStyle = '#0f1512';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const matrix = SHAPES[this.nextType];
|
||||
const cell = Math.floor(size / 4);
|
||||
const offset = (4 - matrix.length) / 2;
|
||||
for (let y = 0; y < matrix.length; y++) {
|
||||
for (let x = 0; x < matrix[y].length; x++) {
|
||||
if (matrix[y][x]) {
|
||||
ctx.fillStyle = COLORS[this.nextType];
|
||||
ctx.fillRect((x + offset) * cell + 1, (y + offset) * cell + 1, cell - 2, cell - 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let game = null;
|
||||
let rafId = null;
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('arcade-score').textContent = game.score;
|
||||
document.getElementById('arcade-lines').textContent = game.lines;
|
||||
document.getElementById('arcade-level').textContent = game.level;
|
||||
}
|
||||
|
||||
function showGameOver() {
|
||||
const overlay = document.getElementById('arcade-result-overlay');
|
||||
overlay.innerHTML = `
|
||||
<div class="arcade-result">
|
||||
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
||||
<p>${t().finalScore(game.score)}</p>
|
||||
<div class="arcade-result__actions">
|
||||
<button data-action="again">${t().again}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
function loop(time) {
|
||||
if (game.lastTime === null) game.lastTime = time;
|
||||
const dt = time - game.lastTime;
|
||||
game.lastTime = time;
|
||||
const wasOver = game.over;
|
||||
game.tick(dt);
|
||||
game.draw();
|
||||
updateStats();
|
||||
if (game.over && !wasOver) {
|
||||
showGameOver();
|
||||
}
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function restart() {
|
||||
const overlay = document.getElementById('arcade-result-overlay');
|
||||
overlay.style.display = 'none';
|
||||
overlay.innerHTML = '';
|
||||
game.reset();
|
||||
}
|
||||
|
||||
function bindKeyboard() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!game) return;
|
||||
if (game.over) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') restart();
|
||||
return;
|
||||
}
|
||||
switch (e.code) {
|
||||
case 'ArrowLeft':
|
||||
game.move(-1, 0);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
game.move(1, 0);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (game.move(0, 1)) game.score += 1;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
game.rotate();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'Space':
|
||||
game.hardDrop();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindTouchControls(root) {
|
||||
const actions = {
|
||||
left: () => game.move(-1, 0),
|
||||
right: () => game.move(1, 0),
|
||||
down: () => {
|
||||
if (game.move(0, 1)) game.score += 1;
|
||||
},
|
||||
rotate: () => game.rotate(),
|
||||
drop: () => game.hardDrop(),
|
||||
};
|
||||
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||
const action = actions[btn.dataset.input];
|
||||
btn.addEventListener('click', () => {
|
||||
if (game.over) return;
|
||||
action();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(root) {
|
||||
root.innerHTML = `
|
||||
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/invaders">./invaders</a></p>
|
||||
<div class="arcade-layout">
|
||||
<div class="arcade-canvas-slot">
|
||||
<canvas id="arcade-canvas"></canvas>
|
||||
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
||||
</div>
|
||||
<div class="arcade-controls">
|
||||
<div class="arcade-stats">
|
||||
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
||||
<div><span id="arcade-lines-label">${t().lines.toUpperCase()}</span> <b id="arcade-lines">0</b></div>
|
||||
<div><span id="arcade-level-label">${t().level.toUpperCase()}</span> <b id="arcade-level">1</b></div>
|
||||
</div>
|
||||
<div class="arcade-next-slot">
|
||||
<span id="arcade-next-label">${t().next}</span>
|
||||
<canvas id="arcade-next" width="80" height="80"></canvas>
|
||||
</div>
|
||||
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
||||
<div class="arcade-dpad">
|
||||
<button class="arcade-dpad__up" data-input="rotate">↑</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);
|
||||
@@ -1,76 +0,0 @@
|
||||
// Простой синтез звука через Web Audio API — без внешних аудиофайлов.
|
||||
let ctx = null;
|
||||
|
||||
function ensureCtx() {
|
||||
if (!ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
ctx = new AudioCtx();
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Вызывать из обработчика реального пользовательского клика/нажатия —
|
||||
// браузеры блокируют автозапуск звука без явного жеста.
|
||||
export function unlockAudio() {
|
||||
ensureCtx();
|
||||
}
|
||||
|
||||
function beep({ freq, duration = 0.08, type = 'square', gain = 0.1, freqEnd = null }) {
|
||||
const c = ensureCtx();
|
||||
const osc = c.createOscillator();
|
||||
const g = c.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, c.currentTime);
|
||||
if (freqEnd !== null) {
|
||||
osc.frequency.linearRampToValueAtTime(freqEnd, c.currentTime + duration);
|
||||
}
|
||||
g.gain.setValueAtTime(gain, c.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + duration);
|
||||
osc.connect(g).connect(c.destination);
|
||||
osc.start();
|
||||
osc.stop(c.currentTime + duration);
|
||||
}
|
||||
|
||||
function noiseBurst({ duration = 0.2, gain = 0.18 } = {}) {
|
||||
const c = ensureCtx();
|
||||
const size = Math.max(1, Math.floor(c.sampleRate * duration));
|
||||
const buffer = c.createBuffer(1, size, c.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < size; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * (1 - i / size);
|
||||
}
|
||||
const source = c.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const g = c.createGain();
|
||||
g.gain.setValueAtTime(gain, c.currentTime);
|
||||
source.connect(g).connect(c.destination);
|
||||
source.start();
|
||||
}
|
||||
|
||||
export function playShot() {
|
||||
beep({ freq: 660, freqEnd: 220, duration: 0.06, type: 'square', gain: 0.08 });
|
||||
}
|
||||
|
||||
export function playWallHit() {
|
||||
noiseBurst({ duration: 0.08, gain: 0.1 });
|
||||
}
|
||||
|
||||
export function playExplosion() {
|
||||
noiseBurst({ duration: 0.25, gain: 0.18 });
|
||||
beep({ freq: 120, freqEnd: 40, duration: 0.25, type: 'sawtooth', gain: 0.1 });
|
||||
}
|
||||
|
||||
export function playWin() {
|
||||
[523, 659, 784, 1047].forEach((freq, i) =>
|
||||
setTimeout(() => beep({ freq, duration: 0.15, type: 'square', gain: 0.1 }), i * 110)
|
||||
);
|
||||
}
|
||||
|
||||
export function playLose() {
|
||||
[392, 330, 262, 196].forEach((freq, i) =>
|
||||
setTimeout(() => beep({ freq, duration: 0.2, type: 'sawtooth', gain: 0.1 }), i * 130)
|
||||
);
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
import { ClientMessage, ServerMessage } from './protocol.js';
|
||||
import { returnToLobbyContainer, leaveRoomAndReturn } from './lobby.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;
|
||||
// Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform)
|
||||
// на основе p.x/p.y/p.angle — draw() получает уже трансформированный контекст
|
||||
// и должен рисовать спрайт центрированным в (0,0), без своих translate/rotate.
|
||||
const DIR_DEGREES = { up: 0, right: 90, down: 180, left: 270 };
|
||||
|
||||
let Q = null;
|
||||
let net = null;
|
||||
let myPlayerId = null;
|
||||
let stage = null;
|
||||
let tankSprites = new Map();
|
||||
let bulletSprites = new Map();
|
||||
let wallSprites = new Map(); // "x,y" (тайл) -> спрайт, для синка разрушенных стен
|
||||
let baseSprite = null;
|
||||
let lastSentInput = null;
|
||||
let seq = 0;
|
||||
let polling = false;
|
||||
|
||||
function tankColor(p) {
|
||||
if (p.isAi) return '#0a8f2c';
|
||||
return p.isMe ? '#00ff41' : '#9fa3a0';
|
||||
}
|
||||
|
||||
function setupEntities() {
|
||||
Q.Sprite.extend('Tank', {
|
||||
init(p) {
|
||||
this._super(p, { w: 24, h: 24, renderAlways: true });
|
||||
},
|
||||
draw(ctx) {
|
||||
const p = this.p;
|
||||
const color = tankColor(p);
|
||||
|
||||
// корпус
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
|
||||
// гусеницы по бокам
|
||||
ctx.fillStyle = '#0a0e0c';
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, 3, p.h);
|
||||
ctx.fillRect(p.w / 2 - 3, -p.h / 2, 3, p.h);
|
||||
|
||||
// башня
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.w / 2 - 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = '#0a0e0c';
|
||||
ctx.stroke();
|
||||
|
||||
// ствол — явно торчит вперёд по направлению взгляда (локально «вверх»).
|
||||
// Ярко-зелёный с тёмной обводкой — иначе на тёмном корпусе не виден.
|
||||
ctx.fillStyle = '#00ff41';
|
||||
ctx.strokeStyle = '#0a0e0c';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillRect(-2.5, -p.h / 2 - 12, 5, 16);
|
||||
ctx.strokeRect(-2.5, -p.h / 2 - 12, 5, 16);
|
||||
},
|
||||
});
|
||||
|
||||
Q.Sprite.extend('Bullet', {
|
||||
init(p) {
|
||||
this._super(p, { w: 8, h: 8, renderAlways: true });
|
||||
},
|
||||
draw(ctx) {
|
||||
ctx.fillStyle = '#c7c7c7';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, this.p.w / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
},
|
||||
});
|
||||
|
||||
Q.Sprite.extend('Wall', {
|
||||
init(p) {
|
||||
this._super(p, { w: TILE_PX, h: TILE_PX });
|
||||
},
|
||||
draw(ctx) {
|
||||
const p = this.p;
|
||||
if (p.tile === 2) {
|
||||
ctx.fillStyle = '#9fa3a0';
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
} else {
|
||||
ctx.fillStyle = '#0a8f2c';
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
ctx.strokeStyle = '#0a0e0c';
|
||||
ctx.strokeRect(-p.w / 2 + 1, -p.h / 2 + 1, p.w - 2, p.h - 2);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Q.Sprite.extend('Base', {
|
||||
init(p) {
|
||||
this._super(p, { w: TILE_PX * 0.85, h: TILE_PX * 0.85, renderAlways: true });
|
||||
},
|
||||
draw(ctx) {
|
||||
const p = this.p;
|
||||
ctx.fillStyle = p.alive ? '#9fa3a0' : '#2a2d2b';
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
ctx.strokeStyle = '#0a0e0c';
|
||||
ctx.strokeRect(-p.w / 2 + 1, -p.h / 2 + 1, p.w - 2, p.h - 2);
|
||||
ctx.fillStyle = p.alive ? '#00ff41' : '#0a0e0c';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -p.h / 2);
|
||||
ctx.lineTo(-p.w / 4, 0);
|
||||
ctx.lineTo(p.w / 4, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function ensureQuintus(cols, rows) {
|
||||
if (Q) {
|
||||
return;
|
||||
}
|
||||
Q = window.Quintus().include('Sprites, Scenes, 2D, Input').setup({
|
||||
width: cols * TILE_PX,
|
||||
height: rows * TILE_PX,
|
||||
autoFocus: true,
|
||||
});
|
||||
Q.el.tabIndex = 1;
|
||||
Q.input.keyboardControls({
|
||||
LEFT: 'left', RIGHT: 'right', UP: 'up', DOWN: 'down',
|
||||
A: 'left', D: 'right', W: 'up', S: 'down',
|
||||
SPACE: 'fire',
|
||||
});
|
||||
setupEntities();
|
||||
Q.scene('battle', (stageRef) => {
|
||||
stage = stageRef;
|
||||
});
|
||||
}
|
||||
|
||||
function buildMap(map) {
|
||||
for (let y = 0; y < map.length; y++) {
|
||||
for (let x = 0; x < map[y].length; x++) {
|
||||
const tile = map[y][x];
|
||||
if (tile === 0) {
|
||||
continue;
|
||||
}
|
||||
const sprite = new Q.Wall({
|
||||
x: x * TILE_PX + TILE_PX / 2,
|
||||
y: y * TILE_PX + TILE_PX / 2,
|
||||
tile,
|
||||
});
|
||||
stage.insert(sprite);
|
||||
if (tile === 1) {
|
||||
// только кирпич может быть разрушен — только его отслеживаем
|
||||
wallSprites.set(`${x},${y}`, sprite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindControlButtons(root) {
|
||||
const press = (input, active) => {
|
||||
if (Q) {
|
||||
Q.inputs[input] = active;
|
||||
}
|
||||
};
|
||||
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||
const input = btn.dataset.input;
|
||||
// Клик по кнопке уводит фокус с канваса — а Quintus слушает клавиатуру
|
||||
// именно на нём, так что после клика WASD/стрелки перестанут работать.
|
||||
btn.addEventListener('mousedown', () => {
|
||||
press(input, true);
|
||||
Q?.el.focus();
|
||||
});
|
||||
btn.addEventListener('mouseup', () => press(input, false));
|
||||
btn.addEventListener('mouseleave', () => press(input, false));
|
||||
btn.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
press(input, true);
|
||||
});
|
||||
btn.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
press(input, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pollInput() {
|
||||
if (!polling) {
|
||||
return;
|
||||
}
|
||||
const input = {
|
||||
seq: seq++,
|
||||
up: !!Q.inputs.up,
|
||||
down: !!Q.inputs.down,
|
||||
left: !!Q.inputs.left,
|
||||
right: !!Q.inputs.right,
|
||||
fire: !!Q.inputs.fire,
|
||||
};
|
||||
const key = `${input.up}${input.down}${input.left}${input.right}${input.fire}`;
|
||||
if (key !== lastSentInput) {
|
||||
lastSentInput = key;
|
||||
net.send(ClientMessage.GAME_INPUT, input);
|
||||
}
|
||||
requestAnimationFrame(pollInput);
|
||||
}
|
||||
|
||||
function onState(payload) {
|
||||
const seenTanks = new Set();
|
||||
for (const t of payload.tanks) {
|
||||
seenTanks.add(t.id);
|
||||
let sprite = tankSprites.get(t.id);
|
||||
if (!sprite) {
|
||||
sprite = new Q.Tank({
|
||||
x: t.x * TILE_PX,
|
||||
y: t.y * TILE_PX,
|
||||
isMe: t.id === myPlayerId,
|
||||
isAi: !!t.is_ai,
|
||||
});
|
||||
stage.insert(sprite);
|
||||
tankSprites.set(t.id, sprite);
|
||||
} else if (!sprite.p.hidden && !t.alive) {
|
||||
playExplosion();
|
||||
}
|
||||
sprite.p.x = t.x * TILE_PX;
|
||||
sprite.p.y = t.y * TILE_PX;
|
||||
sprite.p.angle = DIR_DEGREES[t.direction] || 0;
|
||||
sprite.p.hidden = !t.alive;
|
||||
}
|
||||
for (const [id, sprite] of tankSprites) {
|
||||
if (!seenTanks.has(id)) {
|
||||
sprite.destroy();
|
||||
tankSprites.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const seenBullets = new Set();
|
||||
for (const b of payload.bullets) {
|
||||
seenBullets.add(b.id);
|
||||
let sprite = bulletSprites.get(b.id);
|
||||
if (!sprite) {
|
||||
sprite = new Q.Bullet({ x: b.x * TILE_PX, y: b.y * TILE_PX });
|
||||
stage.insert(sprite);
|
||||
bulletSprites.set(b.id, sprite);
|
||||
playShot();
|
||||
}
|
||||
sprite.p.x = b.x * TILE_PX;
|
||||
sprite.p.y = b.y * TILE_PX;
|
||||
}
|
||||
for (const [id, sprite] of bulletSprites) {
|
||||
if (!seenBullets.has(id)) {
|
||||
sprite.destroy();
|
||||
bulletSprites.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.destroyed_tiles) {
|
||||
for (const [tx, ty] of payload.destroyed_tiles) {
|
||||
const key = `${tx},${ty}`;
|
||||
const sprite = wallSprites.get(key);
|
||||
if (sprite) {
|
||||
sprite.destroy();
|
||||
wallSprites.delete(key);
|
||||
}
|
||||
}
|
||||
if (payload.destroyed_tiles.length > 0) {
|
||||
playWallHit();
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.base) {
|
||||
if (!baseSprite) {
|
||||
baseSprite = new Q.Base({
|
||||
x: payload.base.x * TILE_PX,
|
||||
y: payload.base.y * TILE_PX,
|
||||
alive: payload.base.alive,
|
||||
});
|
||||
stage.insert(baseSprite);
|
||||
} else {
|
||||
if (baseSprite.p.alive && !payload.base.alive) {
|
||||
playExplosion();
|
||||
}
|
||||
baseSprite.p.alive = payload.base.alive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
polling = false;
|
||||
if (payload.result === 'win') {
|
||||
playWin();
|
||||
} else {
|
||||
playLose();
|
||||
}
|
||||
lastOverPayload = payload;
|
||||
renderOverlay(payload);
|
||||
}
|
||||
|
||||
function onOverlayClick(e) {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const overlay = document.getElementById('battle-result-overlay');
|
||||
overlay.style.display = 'none';
|
||||
overlay.innerHTML = '';
|
||||
lastOverPayload = null;
|
||||
if (btn.dataset.action === 'rematch') {
|
||||
returnToLobbyContainer();
|
||||
} else {
|
||||
leaveRoomAndReturn();
|
||||
}
|
||||
}
|
||||
|
||||
export function startBattle(networkInstance, payload, ownPlayerId) {
|
||||
net = networkInstance;
|
||||
myPlayerId = ownPlayerId;
|
||||
|
||||
const rows = payload.map.length;
|
||||
const cols = payload.map[0].length;
|
||||
|
||||
document.getElementById('lobby').style.display = 'none';
|
||||
const container = document.getElementById('tanks-canvas-container');
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = `
|
||||
<div class="battle-layout">
|
||||
<div id="battle-canvas-slot"></div>
|
||||
<div class="battle-controls">
|
||||
<p class="battle-controls__hint">${t().controlsHint}</p>
|
||||
<div class="dpad">
|
||||
<button class="dpad__up" data-input="up">↑</button>
|
||||
<button class="dpad__left" data-input="left">←</button>
|
||||
<button class="dpad__right" data-input="right">→</button>
|
||||
<button class="dpad__down" data-input="down">↓</button>
|
||||
</div>
|
||||
<button class="fire-btn" data-input="fire">${t().fire}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="battle-result-overlay"></div>`;
|
||||
|
||||
ensureQuintus(cols, rows);
|
||||
document.getElementById('battle-canvas-slot').appendChild(Q.el);
|
||||
// Клавиатурные события Quintus слушает на самом канвасе — если фокус
|
||||
// не на нём (а он теряется при каждом переприкреплении к DOM на реванш),
|
||||
// WASD/стрелки/space просто никуда не долетают.
|
||||
Q.el.focus();
|
||||
bindControlButtons(container.querySelector('.battle-controls'));
|
||||
container.querySelector('#battle-result-overlay').addEventListener('click', onOverlayClick);
|
||||
|
||||
Q.stageScene('battle', 0);
|
||||
tankSprites = new Map();
|
||||
bulletSprites = new Map();
|
||||
wallSprites = new Map();
|
||||
baseSprite = null;
|
||||
|
||||
buildMap(payload.map);
|
||||
|
||||
net.on(ServerMessage.GAME_STATE, onState);
|
||||
net.on(ServerMessage.GAME_OVER, onOver);
|
||||
|
||||
lastSentInput = null;
|
||||
seq = 0;
|
||||
polling = true;
|
||||
requestAnimationFrame(pollInput);
|
||||
}
|
||||
+12
-107
@@ -1,72 +1,14 @@
|
||||
import { Network, ServerMessage } from './network.js';
|
||||
import { ClientMessage } from './protocol.js';
|
||||
import { startBattle } from './battle.js';
|
||||
import { unlockAudio } from './audio.js';
|
||||
import { getLang } from '../i18n.js';
|
||||
|
||||
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 myPlayerId = null;
|
||||
let rooms = [];
|
||||
let currentRoom = null;
|
||||
let errorMessage = '';
|
||||
let connected = false;
|
||||
let nickname = 'cacto-' + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Ники приходят от других игроков (сервер их только режет по длине, не
|
||||
// экранирует) и попадают сюда через innerHTML — без экранирования это XSS.
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function roomRow(room) {
|
||||
const full = room.players >= room.max_players;
|
||||
return `
|
||||
@@ -75,7 +17,7 @@ function roomRow(room) {
|
||||
<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' : ''}>${t().join}</button>
|
||||
<button data-action="join" data-room-id="${room.id}" ${full || room.state !== 'waiting' || !connected ? 'disabled' : ''}>войти</button>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
@@ -88,49 +30,36 @@ function renderLobbyView() {
|
||||
container.innerHTML = `
|
||||
<div class="lobby-panel">
|
||||
${errorBanner()}
|
||||
${connected ? '' : `<p class="lobby-error">${t().connecting}</p>`}
|
||||
${connected ? '' : '<p class="lobby-error">подключение...</p>'}
|
||||
<div class="lobby-panel__row">
|
||||
<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="${nickname}" maxlength="20" ${dis} />
|
||||
</div>
|
||||
<div class="lobby-panel__row">
|
||||
<button data-action="create" data-mode="coop" ${dis}>${t().createCoop}</button>
|
||||
<button data-action="create" data-mode="pvp" ${dis}>${t().createPvp}</button>
|
||||
<button data-action="refresh" ${dis}>${t().refresh}</button>
|
||||
<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">${t().empty}</li>` : rooms.map(roomRow).join('')}
|
||||
${rooms.length === 0 ? '<li class="room-list__empty">пока пусто — создай комнату</li>' : rooms.map(roomRow).join('')}
|
||||
</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRoomView() {
|
||||
const full = currentRoom.players.length >= 2;
|
||||
let statusLine = '';
|
||||
let readyBtn = `<button data-action="ready">${t().ready}</button>`;
|
||||
if (currentRoom.mode === 'pvp' && !full) {
|
||||
statusLine = `<p class="lobby-error">${t().waitingForSecond}</p>`;
|
||||
readyBtn = `<button data-action="ready" disabled>${t().ready}</button>`;
|
||||
} else if (currentRoom.mode === 'coop' && !full) {
|
||||
statusLine = `<p class="lobby-error">${t().soloHint}</p>`;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="lobby-panel">
|
||||
${errorBanner()}
|
||||
<h3 class="prompt">${t().room} ${currentRoom.name}</h3>
|
||||
<p>${t().mode} <span class="tag">${currentRoom.mode}</span></p>
|
||||
<h3 class="prompt">room ${currentRoom.name}</h3>
|
||||
<p>режим: <span class="tag">${currentRoom.mode}</span></p>
|
||||
<ul class="room-players">
|
||||
${currentRoom.players.map((p) => `<li>${escapeHtml(p.nickname)}${p.ready ? t().readySuffix : ''}</li>`).join('')}
|
||||
${currentRoom.players.map((p) => `<li>${p.nickname}</li>`).join('')}
|
||||
</ul>
|
||||
${statusLine}
|
||||
${readyBtn}
|
||||
<button data-action="leave">${t().leave}</button>
|
||||
<button data-action="leave">выйти в лобби</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const crosslinksLabel = document.getElementById('game-crosslinks-label');
|
||||
if (crosslinksLabel) crosslinksLabel.textContent = t().crosslinks;
|
||||
if (currentRoom) {
|
||||
renderRoomView();
|
||||
} else {
|
||||
@@ -138,19 +67,6 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Вызывается из battle.js по кнопкам экрана победы/поражения.
|
||||
export function returnToLobbyContainer() {
|
||||
document.getElementById('tanks-canvas-container').style.display = 'none';
|
||||
container.style.display = '';
|
||||
render();
|
||||
}
|
||||
|
||||
export function leaveRoomAndReturn() {
|
||||
currentRoom = null;
|
||||
net.leaveRoom();
|
||||
returnToLobbyContainer();
|
||||
}
|
||||
|
||||
container?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
@@ -162,10 +78,6 @@ container?.addEventListener('click', (e) => {
|
||||
case 'join':
|
||||
net.joinRoom(btn.dataset.roomId);
|
||||
break;
|
||||
case 'ready':
|
||||
unlockAudio();
|
||||
net.send(ClientMessage.GAME_READY);
|
||||
break;
|
||||
case 'leave':
|
||||
currentRoom = null;
|
||||
net.leaveRoom();
|
||||
@@ -187,9 +99,8 @@ export function initLobby() {
|
||||
net = new Network();
|
||||
|
||||
net.on('_open', () => net.hello(nickname));
|
||||
net.on(ServerMessage.HELLO_ACK, (payload) => {
|
||||
net.on(ServerMessage.HELLO_ACK, () => {
|
||||
connected = true;
|
||||
myPlayerId = payload.player_id;
|
||||
net.listRooms();
|
||||
});
|
||||
net.on('_close', () => {
|
||||
@@ -214,18 +125,12 @@ export function initLobby() {
|
||||
}
|
||||
});
|
||||
|
||||
net.on(ServerMessage.GAME_START, (payload) => {
|
||||
startBattle(net, payload, myPlayerId);
|
||||
});
|
||||
|
||||
net.on(ServerMessage.ERROR, (payload) => {
|
||||
console.error('[gameserver] error:', payload);
|
||||
errorMessage = payload.message;
|
||||
render();
|
||||
});
|
||||
|
||||
window.addEventListener('cacto:langchange', render);
|
||||
|
||||
render();
|
||||
return net;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
// TODO: инициализация Quintus и игрового цикла рендера — этапы 4-5
|
||||
import { initLobby } from './lobby.js';
|
||||
import { initLangToggle } from '../i18n.js';
|
||||
|
||||
initLobby();
|
||||
// lobby.js/battle.js сами слушают cacto:langchange и перерисовывают свои
|
||||
// статичные подписи — здесь достаточно включить саму кнопку в шапке.
|
||||
initLangToggle();
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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());
|
||||
});
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { getLang, initLangToggle } from './i18n.js';
|
||||
|
||||
const CANVAS_W = 420;
|
||||
const CANVAS_H = 520;
|
||||
const PLAYER_Y = CANVAS_H - 40;
|
||||
const PLAYER_W = 28;
|
||||
const PLAYER_H = 14;
|
||||
const PLAYER_SPEED = 220; // px/s
|
||||
const BULLET_SPEED = 380;
|
||||
const ENEMY_BULLET_SPEED = 180;
|
||||
const FIRE_COOLDOWN_MS = 350;
|
||||
const ENEMY_COLS = 8;
|
||||
const ENEMY_ROWS = 4;
|
||||
const ENEMY_W = 28;
|
||||
const ENEMY_H = 18;
|
||||
const ENEMY_GAP_X = 12;
|
||||
const ENEMY_GAP_Y = 16;
|
||||
const ENEMY_TOP = 50;
|
||||
const ENEMY_STEP_DOWN = 18;
|
||||
|
||||
const STR = {
|
||||
ru: {
|
||||
hint: 'стрелки — двигать, пробел — огонь',
|
||||
score: 'счёт',
|
||||
lives: 'жизни',
|
||||
wave: 'волна',
|
||||
gameOver: 'ИГРА ОКОНЧЕНА',
|
||||
finalScore: (s) => `счёт: ${s}`,
|
||||
again: 'ещё раз',
|
||||
crosslinks: 'ещё игры:',
|
||||
fire: 'огонь',
|
||||
},
|
||||
en: {
|
||||
hint: 'arrows to move, space to fire',
|
||||
score: 'score',
|
||||
lives: 'lives',
|
||||
wave: 'wave',
|
||||
gameOver: 'GAME OVER',
|
||||
finalScore: (s) => `score: ${s}`,
|
||||
again: 'play again',
|
||||
crosslinks: 'more games:',
|
||||
fire: 'fire',
|
||||
},
|
||||
};
|
||||
|
||||
function t() {
|
||||
return STR[getLang()];
|
||||
}
|
||||
|
||||
function rectsOverlap(a, b) {
|
||||
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
}
|
||||
|
||||
class InvadersGame {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
canvas.width = CANVAS_W;
|
||||
canvas.height = CANVAS_H;
|
||||
this.input = { left: false, right: false, fire: false };
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.score = 0;
|
||||
this.lives = 3;
|
||||
this.wave = 1;
|
||||
this.over = false;
|
||||
this.player = { x: CANVAS_W / 2 - PLAYER_W / 2, y: PLAYER_Y };
|
||||
this.bullets = [];
|
||||
this.enemyBullets = [];
|
||||
this.fireTimer = 0;
|
||||
this.hitFlash = 0;
|
||||
this.spawnWave();
|
||||
}
|
||||
|
||||
spawnWave() {
|
||||
this.enemies = [];
|
||||
const totalWidth = ENEMY_COLS * (ENEMY_W + ENEMY_GAP_X) - ENEMY_GAP_X;
|
||||
const startX = (CANVAS_W - totalWidth) / 2;
|
||||
for (let row = 0; row < ENEMY_ROWS; row++) {
|
||||
for (let col = 0; col < ENEMY_COLS; col++) {
|
||||
this.enemies.push({
|
||||
x: startX + col * (ENEMY_W + ENEMY_GAP_X),
|
||||
y: ENEMY_TOP + row * (ENEMY_H + ENEMY_GAP_Y),
|
||||
w: ENEMY_W,
|
||||
h: ENEMY_H,
|
||||
alive: true,
|
||||
points: (ENEMY_ROWS - row) * 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.enemyDir = 1;
|
||||
this.enemySpeed = 30 + (this.wave - 1) * 12;
|
||||
this.enemyShootTimer = 0;
|
||||
}
|
||||
|
||||
aliveEnemies() {
|
||||
return this.enemies.filter((e) => e.alive);
|
||||
}
|
||||
|
||||
tick(dtMs) {
|
||||
if (this.over) return;
|
||||
const dt = dtMs / 1000;
|
||||
|
||||
if (this.input.left) this.player.x -= PLAYER_SPEED * dt;
|
||||
if (this.input.right) this.player.x += PLAYER_SPEED * dt;
|
||||
this.player.x = Math.max(0, Math.min(CANVAS_W - PLAYER_W, this.player.x));
|
||||
|
||||
this.fireTimer -= dtMs;
|
||||
if (this.input.fire && this.fireTimer <= 0) {
|
||||
this.bullets.push({ x: this.player.x + PLAYER_W / 2 - 2, y: this.player.y, w: 4, h: 10 });
|
||||
this.fireTimer = FIRE_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
for (const b of this.bullets) b.y -= BULLET_SPEED * dt;
|
||||
this.bullets = this.bullets.filter((b) => b.y + b.h > 0);
|
||||
|
||||
for (const b of this.enemyBullets) b.y += ENEMY_BULLET_SPEED * dt;
|
||||
this.enemyBullets = this.enemyBullets.filter((b) => b.y < CANVAS_H);
|
||||
|
||||
const alive = this.aliveEnemies();
|
||||
if (alive.length === 0) {
|
||||
this.wave += 1;
|
||||
this.spawnWave();
|
||||
return;
|
||||
}
|
||||
|
||||
let hitEdge = false;
|
||||
for (const e of alive) {
|
||||
e.x += this.enemyDir * this.enemySpeed * dt;
|
||||
if (e.x <= 0 || e.x + e.w >= CANVAS_W) hitEdge = true;
|
||||
}
|
||||
if (hitEdge) {
|
||||
this.enemyDir *= -1;
|
||||
for (const e of alive) {
|
||||
e.y += ENEMY_STEP_DOWN;
|
||||
if (e.y + e.h >= this.player.y) {
|
||||
this.over = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.enemyShootTimer -= dtMs;
|
||||
if (this.enemyShootTimer <= 0 && alive.length > 0) {
|
||||
const shooter = alive[Math.floor(Math.random() * alive.length)];
|
||||
this.enemyBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
|
||||
this.enemyShootTimer = Math.max(250, 900 - this.wave * 60);
|
||||
}
|
||||
|
||||
// пуля игрока vs враги
|
||||
for (const bullet of this.bullets) {
|
||||
for (const e of alive) {
|
||||
if (!e.alive) continue;
|
||||
if (rectsOverlap(bullet, e)) {
|
||||
e.alive = false;
|
||||
bullet.hit = true;
|
||||
this.score += e.points;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.bullets = this.bullets.filter((b) => !b.hit);
|
||||
|
||||
// пуля врага vs игрок
|
||||
const playerRect = { x: this.player.x, y: this.player.y, w: PLAYER_W, h: PLAYER_H };
|
||||
for (const b of this.enemyBullets) {
|
||||
if (rectsOverlap(b, playerRect)) {
|
||||
b.hit = true;
|
||||
this.lives -= 1;
|
||||
this.hitFlash = 200;
|
||||
if (this.lives <= 0) this.over = true;
|
||||
}
|
||||
}
|
||||
this.enemyBullets = this.enemyBullets.filter((b) => !b.hit);
|
||||
|
||||
if (this.hitFlash > 0) this.hitFlash -= dtMs;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const ctx = this.ctx;
|
||||
ctx.fillStyle = '#0a0e0c';
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
for (const e of this.enemies) {
|
||||
if (!e.alive) continue;
|
||||
ctx.fillStyle = '#39e075';
|
||||
ctx.fillRect(e.x, e.y, e.w, e.h);
|
||||
ctx.fillStyle = '#0a0e0c';
|
||||
ctx.fillRect(e.x + 4, e.y + 4, 4, 4);
|
||||
ctx.fillRect(e.x + e.w - 8, e.y + 4, 4, 4);
|
||||
}
|
||||
|
||||
ctx.fillStyle = this.hitFlash > 0 ? '#ffffff' : '#00ff41';
|
||||
ctx.fillRect(this.player.x, this.player.y, PLAYER_W, PLAYER_H);
|
||||
ctx.fillRect(this.player.x + PLAYER_W / 2 - 3, this.player.y - 6, 6, 6);
|
||||
|
||||
ctx.fillStyle = '#c7c7c7';
|
||||
for (const b of this.bullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||
ctx.fillStyle = '#9fa3a0';
|
||||
for (const b of this.enemyBullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||
}
|
||||
}
|
||||
|
||||
let game = null;
|
||||
let rafId = null;
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('arcade-score').textContent = game.score;
|
||||
document.getElementById('arcade-lives').textContent = game.lives;
|
||||
document.getElementById('arcade-wave').textContent = game.wave;
|
||||
}
|
||||
|
||||
function showGameOver() {
|
||||
const overlay = document.getElementById('arcade-result-overlay');
|
||||
overlay.innerHTML = `
|
||||
<div class="arcade-result">
|
||||
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
||||
<p>${t().finalScore(game.score)}</p>
|
||||
<div class="arcade-result__actions">
|
||||
<button data-action="again">${t().again}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
function loop(time) {
|
||||
if (game.lastTime === undefined || game.lastTime === null) game.lastTime = time;
|
||||
const dt = time - game.lastTime;
|
||||
game.lastTime = time;
|
||||
const wasOver = game.over;
|
||||
game.tick(dt);
|
||||
game.draw();
|
||||
updateStats();
|
||||
if (game.over && !wasOver) showGameOver();
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function restart() {
|
||||
const overlay = document.getElementById('arcade-result-overlay');
|
||||
overlay.style.display = 'none';
|
||||
overlay.innerHTML = '';
|
||||
game.reset();
|
||||
}
|
||||
|
||||
function bindKeyboard() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!game) return;
|
||||
if (game.over) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') restart();
|
||||
return;
|
||||
}
|
||||
if (e.code === 'ArrowLeft') { game.input.left = true; e.preventDefault(); }
|
||||
if (e.code === 'ArrowRight') { game.input.right = true; e.preventDefault(); }
|
||||
if (e.code === 'Space') { game.input.fire = true; e.preventDefault(); }
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (!game) return;
|
||||
if (e.code === 'ArrowLeft') game.input.left = false;
|
||||
if (e.code === 'ArrowRight') game.input.right = false;
|
||||
if (e.code === 'Space') game.input.fire = false;
|
||||
});
|
||||
}
|
||||
|
||||
function bindTouchControls(root) {
|
||||
const press = (key, value) => {
|
||||
if (game) game.input[key] = value;
|
||||
};
|
||||
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||
const key = btn.dataset.input;
|
||||
btn.addEventListener('mousedown', () => press(key, true));
|
||||
btn.addEventListener('mouseup', () => press(key, false));
|
||||
btn.addEventListener('mouseleave', () => press(key, false));
|
||||
btn.addEventListener('touchstart', (e) => { e.preventDefault(); press(key, true); });
|
||||
btn.addEventListener('touchend', (e) => { e.preventDefault(); press(key, false); });
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(root) {
|
||||
root.innerHTML = `
|
||||
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/blocks">./blocks</a></p>
|
||||
<div class="arcade-layout">
|
||||
<div class="arcade-canvas-slot">
|
||||
<canvas id="arcade-canvas"></canvas>
|
||||
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
||||
</div>
|
||||
<div class="arcade-controls">
|
||||
<div class="arcade-stats">
|
||||
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
||||
<div><span id="arcade-lives-label">${t().lives.toUpperCase()}</span> <b id="arcade-lives">3</b></div>
|
||||
<div><span id="arcade-wave-label">${t().wave.toUpperCase()}</span> <b id="arcade-wave">1</b></div>
|
||||
</div>
|
||||
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
||||
<div class="arcade-lr">
|
||||
<button data-input="left">←</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);
|
||||
+22
-25
@@ -2,47 +2,44 @@ const canvas = document.getElementById('matrix-rain');
|
||||
|
||||
if (canvas && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const chars = 'アカサタナハマヤラワ01アイウエオカキク';
|
||||
const fontSize = 16;
|
||||
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 = [];
|
||||
let frameCount = 0;
|
||||
|
||||
function resize() {
|
||||
function setup() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
columns = Math.floor(canvas.width / fontSize);
|
||||
drops = new Array(columns).fill(0).map(() => Math.random() * -50);
|
||||
drops = new Array(columns).fill(0).map(() => Math.floor((Math.random() * canvas.height) / fontSize));
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.fillStyle = 'rgba(3, 5, 3, 0.12)';
|
||||
ctx.fillStyle = `${bg}cc`;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = `${fontSize}px monospace`;
|
||||
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
const text = chars[Math.floor(Math.random() * chars.length)];
|
||||
const isHead = Math.random() > 0.92;
|
||||
ctx.shadowColor = 'rgba(100, 255, 160, 1)';
|
||||
ctx.shadowBlur = isHead ? 12 : 2;
|
||||
ctx.fillStyle = isHead ? 'rgba(210, 255, 225, 1)' : 'rgba(80, 255, 140, 1)';
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
|
||||
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]++;
|
||||
}
|
||||
drops[i] += 0.4;
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
frameCount++;
|
||||
if (frameCount % 2 === 0) draw();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
requestAnimationFrame(tick);
|
||||
setup();
|
||||
window.addEventListener('resize', setup);
|
||||
setInterval(draw, 50);
|
||||
}
|
||||
|
||||
+16
-77
@@ -1,100 +1,39 @@
|
||||
import { getLang, initLangToggle } from './i18n.js';
|
||||
|
||||
const DATA = {
|
||||
ru: {
|
||||
heading: 'ls projects/',
|
||||
subheading: 'Пара вещей, которые я собрал сам, от идеи до продакшена.',
|
||||
projects: [
|
||||
const projects = [
|
||||
{
|
||||
name: 'home_automatization',
|
||||
title: 'Home Automation Platform',
|
||||
description:
|
||||
'В разработке: архитектура и ТЗ готовы, микросервисы на Go и Laravel собираются. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.',
|
||||
tags: ['в разработке', 'Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
|
||||
'Микросервисная платформа умного дома на Go и Laravel. Первая зона — гроубокс (полив, свет, климат), дальше — весь дом.',
|
||||
tags: ['Go', 'Laravel', 'Docker', 'RabbitMQ', 'ClickHouse', 'gRPC', 'MQTT'],
|
||||
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',
|
||||
title: 'cactoz.su — этот сайт',
|
||||
description:
|
||||
'Личный сайт-визитка: резюме, проекты и мультиплеерная мини-игра про танки. Тема — Матрица.',
|
||||
'Личный сайт-визитка: резюме, проекты и мультиплеерный клон BattleCity. Тема — Матрица.',
|
||||
tags: ['HTML/CSS/JS', 'C++', 'uWebSockets', 'Quintus.js', 'nginx', 'Docker'],
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
function render() {
|
||||
const t = DATA[getLang()];
|
||||
document.title = `${t.heading} — cacto`;
|
||||
const list = document.getElementById('project-list');
|
||||
|
||||
const heading = document.getElementById('projects-heading');
|
||||
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
|
||||
if (list) {
|
||||
list.innerHTML = projects
|
||||
.map(
|
||||
(p) => `
|
||||
<li class="project-card">
|
||||
<div class="project-card__header">
|
||||
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> ${p.name}</div>
|
||||
<a href="${p.url}" target="_blank" rel="noopener">${p.linkLabel} →</a>
|
||||
</div>
|
||||
<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((tag) => `<li class="tag">${tag}</li>`).join('')}
|
||||
${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('');
|
||||
}
|
||||
|
||||
// index.html-паттерн: projects.html уже содержит серверный RU-рендер этой
|
||||
// же разметки, перерисовываем через JS только при переключении на EN.
|
||||
document.title = `${DATA[getLang()].heading} — cacto`;
|
||||
if (getLang() !== 'ru') {
|
||||
render();
|
||||
}
|
||||
initLangToggle(render);
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
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);
|
||||
+6
-66
@@ -5,90 +5,30 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ls projects/ — cacto</title>
|
||||
<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="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/projects.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="matrix-rain"></canvas>
|
||||
|
||||
<div class="container">
|
||||
<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>
|
||||
cacto
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">whoami</a></li>
|
||||
<li><a href="/projects" aria-current="page">ls projects/</a></li>
|
||||
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
|
||||
<li><a href="/projects.html" aria-current="page">ls projects/</a></li>
|
||||
<li><a href="/game.html">./battlecity</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!--
|
||||
Статика ниже — серверный RU-рендер того же содержимого, что генерирует
|
||||
frontend/js/projects.js (DATA.ru). projects.js не перерисовывает при
|
||||
lang=ru — только при переключении на EN. При правке текста меняйте оба
|
||||
места: DATA.ru в projects.js и разметку ниже.
|
||||
-->
|
||||
<main>
|
||||
<section>
|
||||
<h1 id="projects-heading" class="page-heading">ls projects/</h1>
|
||||
<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>
|
||||
<h2 class="prompt">ls projects/</h2>
|
||||
<ul id="project-list" class="project-grid"></ul>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
Copyright (c) 2011 Cykod LLC, http://coderdeck.com/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Vendored
-2292
File diff suppressed because it is too large
Load Diff
-550
@@ -1,550 +0,0 @@
|
||||
/*global Quintus:false, module:false */
|
||||
|
||||
var quintus2D = function(Quintus) {
|
||||
"use strict";
|
||||
|
||||
Quintus["2D"] = function(Q) {
|
||||
|
||||
Q.component('viewport',{
|
||||
added: function() {
|
||||
this.entity.on('prerender',this,'prerender');
|
||||
this.entity.on('render',this,'postrender');
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.offsetX = 0;
|
||||
this.offsetY = 0;
|
||||
this.centerX = Q.width/2;
|
||||
this.centerY = Q.height/2;
|
||||
this.scale = 1;
|
||||
},
|
||||
|
||||
extend: {
|
||||
follow: function(sprite,directions,boundingBox) {
|
||||
this.off('poststep',this.viewport,'follow');
|
||||
this.viewport.directions = directions || { x: true, y: true };
|
||||
this.viewport.following = sprite;
|
||||
if(Q._isUndefined(boundingBox) && this.lists.TileLayer !== undefined) {
|
||||
this.viewport.boundingBox = Q._detect(this.lists.TileLayer, function(layer) {
|
||||
return layer.p.boundingBox ? { minX: 0, maxX: layer.p.w, minY: 0, maxY: layer.p.h } : null;
|
||||
});
|
||||
} else {
|
||||
this.viewport.boundingBox = boundingBox;
|
||||
}
|
||||
this.on('poststep',this.viewport,'follow');
|
||||
this.viewport.follow(true);
|
||||
},
|
||||
|
||||
unfollow: function() {
|
||||
this.off('poststep',this.viewport,'follow');
|
||||
},
|
||||
|
||||
centerOn: function(x,y) {
|
||||
this.viewport.centerOn(x,y);
|
||||
},
|
||||
|
||||
moveTo: function(x,y) {
|
||||
return this.viewport.moveTo(x,y);
|
||||
}
|
||||
},
|
||||
|
||||
follow: function(first) {
|
||||
var followX = Q._isFunction(this.directions.x) ? this.directions.x(this.following) : this.directions.x;
|
||||
var followY = Q._isFunction(this.directions.y) ? this.directions.y(this.following) : this.directions.y;
|
||||
|
||||
this[first === true ? 'centerOn' : 'softCenterOn'](
|
||||
followX ?
|
||||
this.following.p.x - this.offsetX :
|
||||
undefined,
|
||||
followY ?
|
||||
this.following.p.y - this.offsetY :
|
||||
undefined
|
||||
);
|
||||
},
|
||||
|
||||
offset: function(x,y) {
|
||||
this.offsetX = x;
|
||||
this.offsetY = y;
|
||||
},
|
||||
|
||||
softCenterOn: function(x,y) {
|
||||
if(x !== void 0) {
|
||||
var dx = (x - Q.width / 2 / this.scale - this.x)/3;
|
||||
if(this.boundingBox) {
|
||||
if(this.x + dx < this.boundingBox.minX) {
|
||||
this.x = this.boundingBox.minX / this.scale;
|
||||
}
|
||||
else if(this.x + dx > (this.boundingBox.maxX - Q.width) / this.scale) {
|
||||
this.x = Math.max(this.boundingBox.maxX - Q.width, this.boundingBox.minX) / this.scale;
|
||||
}
|
||||
else {
|
||||
this.x += dx;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.x += dx;
|
||||
}
|
||||
}
|
||||
if(y !== void 0) {
|
||||
var dy = (y - Q.height / 2 / this.scale - this.y)/3;
|
||||
if(this.boundingBox) {
|
||||
if(this.y + dy < this.boundingBox.minY) {
|
||||
this.y = this.boundingBox.minY / this.scale;
|
||||
}
|
||||
else if(this.y + dy > (this.boundingBox.maxY - Q.height) / this.scale) {
|
||||
this.y = Math.max(this.boundingBox.maxY - Q.height, this.boundingBox.minY) / this.scale;
|
||||
}
|
||||
else {
|
||||
this.y += dy;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.y += dy;
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
centerOn: function(x,y) {
|
||||
if(x !== void 0) {
|
||||
this.x = x - Q.width / 2 / this.scale;
|
||||
}
|
||||
if(y !== void 0) {
|
||||
this.y = y - Q.height / 2 / this.scale;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
moveTo: function(x,y) {
|
||||
if(x !== void 0) {
|
||||
this.x = x;
|
||||
}
|
||||
if(y !== void 0) {
|
||||
this.y = y;
|
||||
}
|
||||
return this.entity;
|
||||
|
||||
},
|
||||
|
||||
prerender: function() {
|
||||
this.centerX = this.x + Q.width / 2 /this.scale;
|
||||
this.centerY = this.y + Q.height / 2 /this.scale;
|
||||
Q.ctx.save();
|
||||
Q.ctx.translate(Math.floor(Q.width/2),Math.floor(Q.height/2));
|
||||
Q.ctx.scale(this.scale,this.scale);
|
||||
Q.ctx.translate(-Math.floor(this.centerX), -Math.floor(this.centerY));
|
||||
},
|
||||
|
||||
postrender: function() {
|
||||
Q.ctx.restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Q.Sprite.extend("TileLayer",{
|
||||
|
||||
init: function(props) {
|
||||
this._super(props,{
|
||||
tileW: 32,
|
||||
tileH: 32,
|
||||
blockTileW: 10,
|
||||
blockTileH: 10,
|
||||
type: 1,
|
||||
renderAlways: true
|
||||
});
|
||||
if(this.p.dataAsset) {
|
||||
this.load(this.p.dataAsset);
|
||||
}
|
||||
|
||||
this.setDimensions();
|
||||
|
||||
this.blocks = [];
|
||||
this.p.blockW = this.p.tileW * this.p.blockTileW;
|
||||
this.p.blockH = this.p.tileH * this.p.blockTileH;
|
||||
this.colBounds = {};
|
||||
this.directions = [ 'top','left','right','bottom'];
|
||||
this.tileProperties = {};
|
||||
|
||||
this.collisionObject = {
|
||||
p: {
|
||||
w: this.p.tileW,
|
||||
h: this.p.tileH,
|
||||
cx: this.p.tileW/2,
|
||||
cy: this.p.tileH/2
|
||||
}
|
||||
};
|
||||
|
||||
this.tileCollisionObjects = {};
|
||||
|
||||
this.collisionNormal = { separate: []};
|
||||
|
||||
this._generateCollisionObjects();
|
||||
},
|
||||
|
||||
// Generate the tileCollisionObject overrides where needed
|
||||
_generateCollisionObjects: function() {
|
||||
var self = this;
|
||||
|
||||
function returnPoint(pt) {
|
||||
return [ pt[0] * self.p.tileW - self.p.tileW/2,
|
||||
pt[1] * self.p.tileH - self.p.tileH/2
|
||||
];
|
||||
}
|
||||
|
||||
if(this.sheet() && this.sheet().frameProperties) {
|
||||
var frameProperties = this.sheet().frameProperties;
|
||||
for(var k in frameProperties) {
|
||||
var colObj = this.tileCollisionObjects[k] = { p: Q._clone(this.collisionObject.p) };
|
||||
Q._extend(colObj.p,frameProperties[k]);
|
||||
|
||||
if(colObj.p.points) {
|
||||
colObj.p.points = Q._map(colObj.p.points, returnPoint);
|
||||
}
|
||||
|
||||
this.tileCollisionObjects[k] = colObj;
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
load: function(dataAsset) {
|
||||
var fileParts = dataAsset.split("."),
|
||||
fileExt = fileParts[fileParts.length-1].toLowerCase(),
|
||||
data;
|
||||
|
||||
if (fileExt === "json") {
|
||||
data = Q._isString(dataAsset) ? Q.asset(dataAsset) : dataAsset;
|
||||
}
|
||||
else {
|
||||
throw "file type not supported";
|
||||
}
|
||||
this.p.tiles = data;
|
||||
},
|
||||
|
||||
setDimensions: function() {
|
||||
var tiles = this.p.tiles;
|
||||
|
||||
if(tiles) {
|
||||
this.p.rows = tiles.length;
|
||||
this.p.cols = tiles[0].length;
|
||||
this.p.w = this.p.cols * this.p.tileW;
|
||||
this.p.h = this.p.rows * this.p.tileH;
|
||||
}
|
||||
},
|
||||
|
||||
getTile: function(tileX,tileY) {
|
||||
return this.p.tiles[tileY] && this.p.tiles[tileY][tileX];
|
||||
},
|
||||
|
||||
getTileProperty: function(tile, prop) {
|
||||
if(this.tileProperties[tile] !== undefined) {
|
||||
return this.tileProperties[tile][prop];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
getTileProperties: function(tile) {
|
||||
if(this.tileProperties[tile] !== undefined) {
|
||||
return this.tileProperties[tile];
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
getTilePropertyAt: function(tileX, tileY, prop) {
|
||||
return this.getTileProperty(this.getTile(tileX, tileY), prop);
|
||||
},
|
||||
|
||||
getTilePropertiesAt: function(tileX, tileY) {
|
||||
return this.getTileProperties(this.getTile(tileX, tileY));
|
||||
},
|
||||
|
||||
tileHasProperty: function(tile, prop) {
|
||||
return(this.getTileProperty(tile, prop) !== undefined);
|
||||
},
|
||||
|
||||
setTile: function(x,y,tile) {
|
||||
var p = this.p,
|
||||
blockX = Math.floor(x/p.blockTileW),
|
||||
blockY = Math.floor(y/p.blockTileH);
|
||||
|
||||
if(x >= 0 && x < this.p.cols &&
|
||||
y >= 0 && y < this.p.rows) {
|
||||
|
||||
this.p.tiles[y][x] = tile;
|
||||
|
||||
if(this.blocks[blockY]) {
|
||||
this.blocks[blockY][blockX] = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
tilePresent: function(tileX,tileY) {
|
||||
return this.p.tiles[tileY] && this.collidableTile(this.p.tiles[tileY][tileX]);
|
||||
},
|
||||
|
||||
// Overload this method to draw tiles at frame 0 or not draw
|
||||
// tiles at higher number frames
|
||||
drawableTile: function(tileNum) {
|
||||
return tileNum > 0;
|
||||
},
|
||||
|
||||
// Overload this method to control which tiles trigger a collision
|
||||
// (defaults to all tiles > number 0)
|
||||
collidableTile: function(tileNum) {
|
||||
return tileNum > 0;
|
||||
},
|
||||
|
||||
getCollisionObject: function(tileX, tileY) {
|
||||
var p = this.p,
|
||||
tile = this.getTile(tileX, tileY),
|
||||
colObj;
|
||||
|
||||
colObj = (this.tileCollisionObjects[tile] !== undefined) ?
|
||||
this.tileCollisionObjects[tile] : this.collisionObject;
|
||||
|
||||
colObj.p.x = tileX * p.tileW + p.x + p.tileW/2;
|
||||
colObj.p.y = tileY * p.tileH + p.y + p.tileH/2;
|
||||
|
||||
return colObj;
|
||||
},
|
||||
|
||||
collide: function(obj) {
|
||||
var p = this.p,
|
||||
objP = obj.c || obj.p,
|
||||
tileStartX = Math.floor((objP.x - objP.cx - p.x) / p.tileW),
|
||||
tileStartY = Math.floor((objP.y - objP.cy - p.y) / p.tileH),
|
||||
tileEndX = Math.ceil((objP.x - objP.cx + objP.w - p.x) / p.tileW),
|
||||
tileEndY = Math.ceil((objP.y - objP.cy + objP.h - p.y) / p.tileH),
|
||||
normal = this.collisionNormal,
|
||||
col, colObj;
|
||||
|
||||
normal.collided = false;
|
||||
|
||||
for(var tileY = tileStartY; tileY<=tileEndY; tileY++) {
|
||||
for(var tileX = tileStartX; tileX<=tileEndX; tileX++) {
|
||||
if(this.tilePresent(tileX,tileY)) {
|
||||
colObj = this.getCollisionObject(tileX, tileY);
|
||||
|
||||
col = Q.collision(obj,colObj);
|
||||
|
||||
if(col && col.magnitude > 0) {
|
||||
if(colObj.p.sensor) {
|
||||
colObj.tile = this.getTile(tileX,tileY);
|
||||
if(obj.trigger) {
|
||||
obj.trigger('sensor.tile',colObj);
|
||||
}
|
||||
} else if(!normal.collided || normal.magnitude < col.magnitude ) {
|
||||
normal.collided = true;
|
||||
normal.separate[0] = col.separate[0];
|
||||
normal.separate[1] = col.separate[1];
|
||||
normal.magnitude = col.magnitude;
|
||||
normal.distance = col.distance;
|
||||
normal.normalX = col.normalX;
|
||||
normal.normalY = col.normalY;
|
||||
normal.tileX = tileX;
|
||||
normal.tileY = tileY;
|
||||
normal.tile = this.getTile(tileX,tileY);
|
||||
if(obj.p.collisions !== undefined) {
|
||||
obj.p.collisions.push(normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normal.collided ? normal : false;
|
||||
},
|
||||
|
||||
prerenderBlock: function(blockX,blockY) {
|
||||
var p = this.p,
|
||||
tiles = p.tiles,
|
||||
sheet = this.sheet(),
|
||||
blockOffsetX = blockX*p.blockTileW,
|
||||
blockOffsetY = blockY*p.blockTileH;
|
||||
|
||||
if(blockOffsetX < 0 || blockOffsetX >= this.p.cols ||
|
||||
blockOffsetY < 0 || blockOffsetY >= this.p.rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
var canvas = document.createElement('canvas'),
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = p.blockW;
|
||||
canvas.height= p.blockH;
|
||||
this.blocks[blockY] = this.blocks[blockY] || {};
|
||||
this.blocks[blockY][blockX] = canvas;
|
||||
|
||||
for(var y=0;y<p.blockTileH;y++) {
|
||||
if(tiles[y+blockOffsetY]) {
|
||||
for(var x=0;x<p.blockTileW;x++) {
|
||||
if(this.drawableTile(tiles[y+blockOffsetY][x+blockOffsetX])) {
|
||||
sheet.draw(ctx,
|
||||
x*p.tileW,
|
||||
y*p.tileH,
|
||||
tiles[y+blockOffsetY][x+blockOffsetX]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
drawBlock: function(ctx, blockX, blockY) {
|
||||
var p = this.p,
|
||||
startX = Math.floor(blockX * p.blockW + p.x),
|
||||
startY = Math.floor(blockY * p.blockH + p.y);
|
||||
|
||||
if(!this.blocks[blockY] || !this.blocks[blockY][blockX]) {
|
||||
this.prerenderBlock(blockX,blockY);
|
||||
}
|
||||
|
||||
if(this.blocks[blockY] && this.blocks[blockY][blockX]) {
|
||||
ctx.drawImage(this.blocks[blockY][blockX],startX,startY);
|
||||
}
|
||||
},
|
||||
|
||||
draw: function(ctx) {
|
||||
var p = this.p,
|
||||
viewport = this.stage.viewport,
|
||||
scale = viewport ? viewport.scale : 1,
|
||||
x = viewport ? viewport.x : 0,
|
||||
y = viewport ? viewport.y : 0,
|
||||
viewW = Q.width / scale,
|
||||
viewH = Q.height / scale,
|
||||
startBlockX = Math.floor((x - p.x) / p.blockW),
|
||||
startBlockY = Math.floor((y - p.y) / p.blockH),
|
||||
endBlockX = Math.floor((x + viewW - p.x) / p.blockW),
|
||||
endBlockY = Math.floor((y + viewH - p.y) / p.blockH);
|
||||
|
||||
for(var iy=startBlockY;iy<=endBlockY;iy++) {
|
||||
for(var ix=startBlockX;ix<=endBlockX;ix++) {
|
||||
this.drawBlock(ctx,ix,iy);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Q.gravityY = 9.8*100;
|
||||
Q.gravityX = 0;
|
||||
|
||||
Q.component('2d',{
|
||||
added: function() {
|
||||
var entity = this.entity;
|
||||
Q._defaults(entity.p,{
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
ax: 0,
|
||||
ay: 0,
|
||||
gravity: 1,
|
||||
collisionMask: Q.SPRITE_DEFAULT
|
||||
});
|
||||
entity.on('step',this,"step");
|
||||
entity.on('hit',this,'collision');
|
||||
},
|
||||
|
||||
collision: function(col,last) {
|
||||
var entity = this.entity,
|
||||
p = entity.p,
|
||||
magnitude = 0;
|
||||
|
||||
if(col.obj.p && col.obj.p.sensor) {
|
||||
col.obj.trigger("sensor",entity);
|
||||
return;
|
||||
}
|
||||
|
||||
col.impact = 0;
|
||||
var impactX = Math.abs(p.vx);
|
||||
var impactY = Math.abs(p.vy);
|
||||
|
||||
p.x -= col.separate[0];
|
||||
p.y -= col.separate[1];
|
||||
|
||||
// Top collision
|
||||
if(col.normalY < -0.3) {
|
||||
if(!p.skipCollide && p.vy > 0) { p.vy = 0; }
|
||||
col.impact = impactY;
|
||||
entity.trigger("bump.bottom",col);
|
||||
entity.trigger("bump",col);
|
||||
}
|
||||
if(col.normalY > 0.3) {
|
||||
if(!p.skipCollide && p.vy < 0) { p.vy = 0; }
|
||||
col.impact = impactY;
|
||||
|
||||
entity.trigger("bump.top",col);
|
||||
entity.trigger("bump",col);
|
||||
}
|
||||
|
||||
if(col.normalX < -0.3) {
|
||||
if(!p.skipCollide && p.vx > 0) { p.vx = 0; }
|
||||
col.impact = impactX;
|
||||
entity.trigger("bump.right",col);
|
||||
entity.trigger("bump",col);
|
||||
}
|
||||
if(col.normalX > 0.3) {
|
||||
if(!p.skipCollide && p.vx < 0) { p.vx = 0; }
|
||||
col.impact = impactX;
|
||||
|
||||
entity.trigger("bump.left",col);
|
||||
entity.trigger("bump",col);
|
||||
}
|
||||
},
|
||||
|
||||
step: function(dt) {
|
||||
var p = this.entity.p,
|
||||
dtStep = dt;
|
||||
// TODO: check the entity's magnitude of vx and vy,
|
||||
// reduce the max dtStep if necessary to prevent
|
||||
// skipping through objects.
|
||||
while(dtStep > 0) {
|
||||
dt = Math.min(1/30,dtStep);
|
||||
// Updated based on the velocity and acceleration
|
||||
p.vx += p.ax * dt + (p.gravityX === void 0 ? Q.gravityX : p.gravityX) * dt * p.gravity;
|
||||
p.vy += p.ay * dt + (p.gravityY === void 0 ? Q.gravityY : p.gravityY) * dt * p.gravity;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
|
||||
this.entity.stage.collide(this.entity);
|
||||
dtStep -= dt;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Q.component('aiBounce', {
|
||||
added: function() {
|
||||
this.entity.on("bump.right",this,"goLeft");
|
||||
this.entity.on("bump.left",this,"goRight");
|
||||
},
|
||||
|
||||
goLeft: function(col) {
|
||||
this.entity.p.vx = -col.impact;
|
||||
if(this.entity.p.defaultDirection === 'right') {
|
||||
this.entity.p.flip = 'x';
|
||||
}
|
||||
else {
|
||||
this.entity.p.flip = false;
|
||||
}
|
||||
},
|
||||
|
||||
goRight: function(col) {
|
||||
this.entity.p.vx = col.impact;
|
||||
if(this.entity.p.defaultDirection === 'left') {
|
||||
this.entity.p.flip = 'x';
|
||||
}
|
||||
else {
|
||||
this.entity.p.flip = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
if(typeof Quintus === 'undefined') {
|
||||
module.exports = quintus2D;
|
||||
} else {
|
||||
quintus2D(Quintus);
|
||||
}
|
||||
-986
@@ -1,986 +0,0 @@
|
||||
/*global Quintus:false, module:false */
|
||||
|
||||
/**
|
||||
Quintus HTML5 Game Engine - Input Module
|
||||
|
||||
The code in `quintus_input.js` defines the `Quintus.Input` module, which
|
||||
concerns itself with game-type (pretty anything besides touchscreen input)
|
||||
|
||||
@module Quintus.Input
|
||||
*/
|
||||
|
||||
|
||||
var quintusInput = function(Quintus) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Quintus Input Module
|
||||
*
|
||||
* @class Quintus.Input
|
||||
*/
|
||||
Quintus.Input = function(Q) {
|
||||
/**
|
||||
* Provided key names mapped to key codes - add more names and key codes as necessary
|
||||
*
|
||||
* @for Quintus.Input
|
||||
* @property KEY_NAMES
|
||||
* @type Object
|
||||
* @static
|
||||
*/
|
||||
var KEY_NAMES = Q.KEY_NAMES = {
|
||||
LEFT: 37, RIGHT: 39,
|
||||
UP: 38, DOWN: 40,
|
||||
|
||||
ZERO : 48, ONE : 49, TWO : 50,
|
||||
THREE : 51, FOUR : 52, FIVE : 53,
|
||||
SIX : 54, SEVEN : 55, EIGHT : 56,
|
||||
NINE : 57,
|
||||
|
||||
A : 65, B : 66, C : 67,
|
||||
D : 68, E : 69, F : 70,
|
||||
G : 71, H : 72, I : 73,
|
||||
J : 74, K : 75, L : 76,
|
||||
M : 77, N : 78, O : 79,
|
||||
P : 80, Q : 81, R : 82,
|
||||
S : 83, T : 84, U : 85,
|
||||
V : 86, W : 87, X : 88,
|
||||
Y : 89, Z : 90,
|
||||
|
||||
ENTER: 13,
|
||||
ESC: 27,
|
||||
BACKSPACE : 8,
|
||||
TAB : 9,
|
||||
SHIFT : 16,
|
||||
CTRL : 17,
|
||||
ALT : 18,
|
||||
SPACE: 32,
|
||||
|
||||
HOME : 36, END : 35,
|
||||
PGGUP : 33, PGDOWN : 34
|
||||
};
|
||||
|
||||
var DEFAULT_KEYS = {
|
||||
LEFT: 'left', RIGHT: 'right',
|
||||
UP: 'up', DOWN: 'down',
|
||||
SPACE: 'fire',
|
||||
Z: 'fire',
|
||||
X: 'action',
|
||||
ENTER: 'confirm',
|
||||
ESC: 'esc',
|
||||
P: 'P',
|
||||
S: 'S'
|
||||
};
|
||||
|
||||
var DEFAULT_TOUCH_CONTROLS = [ ['left','<' ],
|
||||
['right','>' ],
|
||||
[],
|
||||
['action','b'],
|
||||
['fire', 'a' ]];
|
||||
|
||||
// Clockwise from midnight (a la CSS)
|
||||
var DEFAULT_JOYPAD_INPUTS = [ 'up','right','down','left'];
|
||||
|
||||
/**
|
||||
* Current state of bound inputs
|
||||
*
|
||||
* @for Quintus.Input
|
||||
* @property Q.inputs
|
||||
* @type Object
|
||||
*/
|
||||
Q.inputs = {};
|
||||
Q.joypad = {};
|
||||
|
||||
var hasTouch = !!('ontouchstart' in window);
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* Convert a canvas point to a stage point, x dimension
|
||||
*
|
||||
* @method Q.canvasToStageX
|
||||
* @for Quintus.Input
|
||||
* @param {Float} x
|
||||
* @param {Q.Stage} stage
|
||||
* @returns {Integer} x
|
||||
*/
|
||||
Q.canvasToStageX = function(x,stage) {
|
||||
x = x / Q.cssWidth * Q.width;
|
||||
if(stage.viewport) {
|
||||
x /= stage.viewport.scale;
|
||||
x += stage.viewport.x;
|
||||
}
|
||||
|
||||
return x;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Convert a canvas point to a stage point, y dimension
|
||||
*
|
||||
* @method Q.canvasToStageY
|
||||
* @param {Float} y
|
||||
* @param {Q.Stage} stage
|
||||
* @returns {Integer} y
|
||||
*/
|
||||
Q.canvasToStageY = function(y,stage) {
|
||||
y = y / Q.cssWidth * Q.width;
|
||||
if(stage.viewport) {
|
||||
y /= stage.viewport.scale;
|
||||
y += stage.viewport.y;
|
||||
}
|
||||
|
||||
return y;
|
||||
};
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* Button and mouse input subsystem for Quintus.
|
||||
* An instance of this class is auto-created as {{#crossLink "Q.input"}}{{/crossLink}}
|
||||
*
|
||||
* @class Q.InputSystem
|
||||
* @extends Q.Evented
|
||||
* @for Quintus.Input
|
||||
*/
|
||||
Q.InputSystem = Q.Evented.extend({
|
||||
keys: {},
|
||||
keypad: {},
|
||||
keyboardEnabled: false,
|
||||
touchEnabled: false,
|
||||
joypadEnabled: false,
|
||||
|
||||
/**
|
||||
* Bind a key name or keycode to an action name (used by `keyboardControls`)
|
||||
*
|
||||
* @method bindKey
|
||||
* @for Q.InputSystem
|
||||
* @param {String or Integer} key - name or integer keycode for to bind
|
||||
* @param {String} name - name of action to bind to
|
||||
*/
|
||||
bindKey: function(key,name) {
|
||||
Q.input.keys[KEY_NAMES[key] || key] = name;
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable keyboard controls by binding to events
|
||||
*
|
||||
* @for Q.InputSystem
|
||||
* @method enableKeyboard
|
||||
*/
|
||||
enableKeyboard: function() {
|
||||
if(this.keyboardEnabled) { return false; }
|
||||
|
||||
// Make selectable and remove an :focus outline
|
||||
Q.el.tabIndex = 0;
|
||||
Q.el.style.outline = 0;
|
||||
|
||||
Q.el.addEventListener("keydown",function(e) {
|
||||
if(Q.input.keys[e.keyCode]) {
|
||||
var actionName = Q.input.keys[e.keyCode];
|
||||
Q.inputs[actionName] = true;
|
||||
Q.input.trigger(actionName);
|
||||
Q.input.trigger('keydown',e.keyCode);
|
||||
}
|
||||
if(!e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},false);
|
||||
|
||||
Q.el.addEventListener("keyup",function(e) {
|
||||
if(Q.input.keys[e.keyCode]) {
|
||||
var actionName = Q.input.keys[e.keyCode];
|
||||
Q.inputs[actionName] = false;
|
||||
Q.input.trigger(actionName + "Up");
|
||||
Q.input.trigger('keyup',e.keyCode);
|
||||
}
|
||||
e.preventDefault();
|
||||
},false);
|
||||
|
||||
if(Q.options.autoFocus) { Q.el.focus(); }
|
||||
this.keyboardEnabled = true;
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Convenience method to activate keyboard controls (call `bindKey` and `enableKeyboard` internally)
|
||||
*
|
||||
* @method keyboardControls
|
||||
* @for Q.InputSystem
|
||||
* @param {Object} [keys] - hash of key names or codes to actions
|
||||
*/
|
||||
keyboardControls: function(keys) {
|
||||
keys = keys || DEFAULT_KEYS;
|
||||
Q._each(keys,function(name,key) {
|
||||
this.bindKey(key,name);
|
||||
},Q.input);
|
||||
this.enableKeyboard();
|
||||
},
|
||||
|
||||
_containerOffset: function() {
|
||||
Q.input.offsetX = 0;
|
||||
Q.input.offsetY = 0;
|
||||
var el = Q.el;
|
||||
do {
|
||||
Q.input.offsetX += el.offsetLeft;
|
||||
Q.input.offsetY += el.offsetTop;
|
||||
} while(el = el.offsetParent);
|
||||
},
|
||||
|
||||
touchLocation: function(touch) {
|
||||
var el = Q.el,
|
||||
posX = touch.offsetX,
|
||||
posY = touch.offsetY,
|
||||
touchX, touchY;
|
||||
|
||||
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
|
||||
posX = touch.layerX;
|
||||
posY = touch.layerY;
|
||||
}
|
||||
|
||||
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
|
||||
if(Q.input.offsetX === void 0) { Q.input._containerOffset(); }
|
||||
posX = touch.pageX - Q.input.offsetX;
|
||||
posY = touch.pageY - Q.input.offsetY;
|
||||
}
|
||||
|
||||
touchX = Q.width * posX / Q.cssWidth;
|
||||
touchY = Q.height * posY / Q.cssHeight;
|
||||
|
||||
|
||||
return { x: touchX, y: touchY };
|
||||
},
|
||||
|
||||
/**
|
||||
* Activate touch button controls - pass in an options hash to override
|
||||
*
|
||||
* Default Options:
|
||||
*
|
||||
* {
|
||||
* left: 0,
|
||||
* gutter:10,
|
||||
* controls: DEFAULT_TOUCH_CONTROLS,
|
||||
* width: Q.width,
|
||||
* bottom: Q.height
|
||||
* }
|
||||
*
|
||||
* Default controls are left and right buttons, a space, and 'a' and 'b' buttons, as defined as an Array of Arrays below:
|
||||
*
|
||||
* [ ['left','<' ],
|
||||
* ['right','>' ],
|
||||
* [], // use an empty array as a spacer
|
||||
* ['action','b'],
|
||||
* ['fire', 'a' ]]
|
||||
*
|
||||
* @method touchControls
|
||||
* @for Q.InputSystem
|
||||
* @param {Object} [opts] - Options hash
|
||||
*/
|
||||
touchControls: function(opts) {
|
||||
if(this.touchEnabled) { return false; }
|
||||
if(!hasTouch) { return false; }
|
||||
|
||||
Q.input.keypad = opts = Q._extend({
|
||||
left: 0,
|
||||
gutter:10,
|
||||
controls: DEFAULT_TOUCH_CONTROLS,
|
||||
width: Q.width,
|
||||
bottom: Q.height,
|
||||
fullHeight: false
|
||||
},opts);
|
||||
|
||||
opts.unit = (opts.width / opts.controls.length);
|
||||
opts.size = opts.unit - (opts.gutter * 2);
|
||||
|
||||
function getKey(touch) {
|
||||
var pos = Q.input.touchLocation(touch),
|
||||
minY = opts.bottom - opts.unit;
|
||||
for(var i=0,len=opts.controls.length;i<len;i++) {
|
||||
var minX = i * opts.unit + opts.gutter;
|
||||
if(pos.x >= minX && pos.x <= (minX+opts.size) && (opts.fullHeight || (pos.y >= minY + opts.gutter && pos.y <= (minY+opts.unit - opts.gutter))))
|
||||
{
|
||||
return opts.controls[i][0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function touchDispatch(event) {
|
||||
var wasOn = {},
|
||||
i, len, tch, key, actionName;
|
||||
|
||||
// Reset all the actions bound to controls
|
||||
// but keep track of all the actions that were on
|
||||
for(i=0,len = opts.controls.length;i<len;i++) {
|
||||
actionName = opts.controls[i][0];
|
||||
if(Q.inputs[actionName]) { wasOn[actionName] = true; }
|
||||
Q.inputs[actionName] = false;
|
||||
}
|
||||
|
||||
var touches = event.touches ? event.touches : [ event ];
|
||||
|
||||
for(i=0,len=touches.length;i<len;i++) {
|
||||
tch = touches[i];
|
||||
key = getKey(tch);
|
||||
|
||||
if(key) {
|
||||
// Mark this input as on
|
||||
Q.inputs[key] = true;
|
||||
|
||||
// Either trigger a new action
|
||||
// or remove from wasOn list
|
||||
if(!wasOn[key]) {
|
||||
Q.input.trigger(key);
|
||||
} else {
|
||||
delete wasOn[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any remaining were on the last frame
|
||||
// and need to trigger an up action
|
||||
for(actionName in wasOn) {
|
||||
Q.input.trigger(actionName + "Up");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
this.touchDispatchHandler = function(e) {
|
||||
touchDispatch(e);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
|
||||
Q._each(["touchstart","touchend","touchmove","touchcancel"],function(evt) {
|
||||
Q.el.addEventListener(evt,this.touchDispatchHandler);
|
||||
},this);
|
||||
|
||||
this.touchEnabled = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Turn off touch (button and joypad) controls and remove event listeners
|
||||
*
|
||||
* @method disableTouchControls
|
||||
* @for Q.InputSystem
|
||||
*/
|
||||
disableTouchControls: function() {
|
||||
Q._each(["touchstart","touchend","touchmove","touchcancel"],function(evt) {
|
||||
Q.el.removeEventListener(evt,this.touchDispatchHandler);
|
||||
},this);
|
||||
|
||||
Q.el.removeEventListener('touchstart',this.joypadStart);
|
||||
Q.el.removeEventListener('touchmove',this.joypadMove);
|
||||
Q.el.removeEventListener('touchend',this.joypadEnd);
|
||||
Q.el.removeEventListener('touchcancel',this.joypadEnd);
|
||||
this.touchEnabled = false;
|
||||
|
||||
// clear existing inputs
|
||||
for(var input in Q.inputs) {
|
||||
Q.inputs[input] = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Activate joypad controls (i.e. 4-way touch controls)
|
||||
*
|
||||
* Lots of options, defaults are:
|
||||
*
|
||||
* {
|
||||
* size: 50,
|
||||
* trigger: 20,
|
||||
* center: 25,
|
||||
* color: "#CCC",
|
||||
* background: "#000",
|
||||
* alpha: 0.5,
|
||||
* zone: Q.width / 2,
|
||||
* inputs: DEFAULT_JOYPAD_INPUTS
|
||||
* }
|
||||
*
|
||||
* Default joypad controls is an array that defines the inputs to bind to:
|
||||
*
|
||||
* // Clockwise from midnight (a la CSS)
|
||||
* var DEFAULT_JOYPAD_INPUTS = [ 'up','right','down','left'];
|
||||
*
|
||||
* @method joypadControls
|
||||
* @for Q.InputSystem
|
||||
* @param {Object} [opts] - joypad options
|
||||
*/
|
||||
joypadControls: function(opts) {
|
||||
if(this.joypadEnabled) { return false; }
|
||||
if(!hasTouch) { return false; }
|
||||
|
||||
var joypad = Q.joypad = Q._defaults(opts || {},{
|
||||
size: 50,
|
||||
trigger: 20,
|
||||
center: 25,
|
||||
color: "#CCC",
|
||||
background: "#000",
|
||||
alpha: 0.5,
|
||||
zone: Q.width / 2,
|
||||
joypadTouch: null,
|
||||
inputs: DEFAULT_JOYPAD_INPUTS,
|
||||
triggers: []
|
||||
});
|
||||
|
||||
this.joypadStart = function(evt) {
|
||||
if(joypad.joypadTouch === null) {
|
||||
var touch = evt.changedTouches[0],
|
||||
loc = Q.input.touchLocation(touch);
|
||||
|
||||
if(loc.x < joypad.zone) {
|
||||
joypad.joypadTouch = touch.identifier;
|
||||
joypad.centerX = loc.x;
|
||||
joypad.centerY = loc.y;
|
||||
joypad.x = null;
|
||||
joypad.y = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
this.joypadMove = function(e) {
|
||||
if(joypad.joypadTouch !== null) {
|
||||
var evt = e;
|
||||
|
||||
for(var i=0,len=evt.changedTouches.length;i<len;i++) {
|
||||
var touch = evt.changedTouches[i];
|
||||
|
||||
if(touch.identifier === joypad.joypadTouch) {
|
||||
var loc = Q.input.touchLocation(touch),
|
||||
dx = loc.x - joypad.centerX,
|
||||
dy = loc.y - joypad.centerY,
|
||||
dist = Math.sqrt(dx * dx + dy * dy),
|
||||
overage = Math.max(1,dist / joypad.size),
|
||||
ang = Math.atan2(dx,dy);
|
||||
|
||||
if(overage > 1) {
|
||||
dx /= overage;
|
||||
dy /= overage;
|
||||
dist /= overage;
|
||||
}
|
||||
|
||||
var triggers = [
|
||||
dy < -joypad.trigger,
|
||||
dx > joypad.trigger,
|
||||
dy > joypad.trigger,
|
||||
dx < -joypad.trigger
|
||||
];
|
||||
|
||||
for(var k=0;k<triggers.length;k++) {
|
||||
var actionName = joypad.inputs[k];
|
||||
if(triggers[k]) {
|
||||
Q.inputs[actionName] = true;
|
||||
|
||||
if(!joypad.triggers[k]) {
|
||||
Q.input.trigger(actionName);
|
||||
}
|
||||
} else {
|
||||
Q.inputs[actionName] = false;
|
||||
if(joypad.triggers[k]) {
|
||||
Q.input.trigger(actionName + "Up");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Q._extend(joypad, {
|
||||
dx: dx, dy: dy,
|
||||
x: joypad.centerX + dx,
|
||||
y: joypad.centerY + dy,
|
||||
dist: dist,
|
||||
ang: ang,
|
||||
triggers: triggers
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
this.joypadEnd = function(e) {
|
||||
var evt = e;
|
||||
|
||||
if(joypad.joypadTouch !== null) {
|
||||
for(var i=0,len=evt.changedTouches.length;i<len;i++) {
|
||||
var touch = evt.changedTouches[i];
|
||||
if(touch.identifier === joypad.joypadTouch) {
|
||||
for(var k=0;k<joypad.triggers.length;k++) {
|
||||
var actionName = joypad.inputs[k];
|
||||
Q.inputs[actionName] = false;
|
||||
if(joypad.triggers[k]) {
|
||||
Q.input.trigger(actionName + "Up");
|
||||
}
|
||||
}
|
||||
joypad.joypadTouch = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
Q.el.addEventListener("touchstart",this.joypadStart);
|
||||
Q.el.addEventListener("touchmove",this.joypadMove);
|
||||
Q.el.addEventListener("touchend",this.joypadEnd);
|
||||
Q.el.addEventListener("touchcancel",this.joypadEnd);
|
||||
|
||||
this.joypadEnabled = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Activate mouse controls - mouse controls don't trigger events, but just set `Q.inputs['mouseX']` & `Q.inputs['mouseY']` on each frame.
|
||||
*
|
||||
* Default options:
|
||||
*
|
||||
* {
|
||||
* stageNum: 0,
|
||||
* mouseX: "mouseX",
|
||||
* mouseY: "mouseY",
|
||||
* cursor: "off"
|
||||
* }
|
||||
*
|
||||
* @method mouseControls
|
||||
* @for Q.InputSystem
|
||||
* @param {Object} [options] - override default options
|
||||
*/
|
||||
mouseControls: function(options) {
|
||||
options = options || {};
|
||||
|
||||
var stageNum = options.stageNum || 0;
|
||||
var mouseInputX = options.mouseX || "mouseX";
|
||||
var mouseInputY = options.mouseY || "mouseY";
|
||||
var cursor = options.cursor || "off";
|
||||
|
||||
var mouseMoveObj = {};
|
||||
|
||||
if(cursor !== "on") {
|
||||
if(cursor === "off") {
|
||||
Q.el.style.cursor = 'none';
|
||||
}
|
||||
else {
|
||||
Q.el.style.cursor = cursor;
|
||||
}
|
||||
}
|
||||
|
||||
Q.inputs[mouseInputX] = 0;
|
||||
Q.inputs[mouseInputY] = 0;
|
||||
|
||||
Q._mouseMove = function(e) {
|
||||
e.preventDefault();
|
||||
var touch = e.touches ? e.touches[0] : e;
|
||||
var el = Q.el,
|
||||
rect = el.getBoundingClientRect(),
|
||||
style = window.getComputedStyle(el),
|
||||
posX = touch.clientX - rect.left - parseInt(style.paddingLeft, 10),
|
||||
posY = touch.clientY - rect.top - parseInt(style.paddingTop, 10);
|
||||
|
||||
var stage = Q.stage(stageNum);
|
||||
|
||||
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
|
||||
posX = touch.offsetX;
|
||||
posY = touch.offsetY;
|
||||
}
|
||||
|
||||
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
|
||||
posX = touch.layerX;
|
||||
posY = touch.layerY;
|
||||
}
|
||||
|
||||
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
|
||||
if(Q.input.offsetX === void 0) { Q.input._containerOffset(); }
|
||||
posX = touch.pageX - Q.input.offsetX;
|
||||
posY = touch.pageY - Q.input.offsetY;
|
||||
}
|
||||
|
||||
if(stage) {
|
||||
mouseMoveObj.x= Q.canvasToStageX(posX,stage);
|
||||
mouseMoveObj.y= Q.canvasToStageY(posY,stage);
|
||||
|
||||
Q.inputs[mouseInputX] = mouseMoveObj.x;
|
||||
Q.inputs[mouseInputY] = mouseMoveObj.y;
|
||||
|
||||
Q.input.trigger('mouseMove',mouseMoveObj);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fired when the user scrolls the mouse wheel up or down
|
||||
* Anyone subscribing to the "mouseWheel" event will receive an event with one numeric parameter
|
||||
* indicating the scroll direction. -1 for down, 1 for up.
|
||||
* @private
|
||||
*/
|
||||
Q._mouseWheel = function(e) {
|
||||
// http://www.sitepoint.com/html5-javascript-mouse-wheel/
|
||||
// cross-browser wheel delta
|
||||
e = window.event || e; // old IE support
|
||||
var delta = Math.max(-1, Math.min(1, (e.wheelDelta || -e.detail)));
|
||||
Q.input.trigger('mouseWheel', delta);
|
||||
};
|
||||
|
||||
Q.el.addEventListener('mousemove',Q._mouseMove,true);
|
||||
Q.el.addEventListener('touchstart',Q._mouseMove,true);
|
||||
Q.el.addEventListener('touchmove',Q._mouseMove,true);
|
||||
Q.el.addEventListener('mousewheel',Q._mouseWheel,true);
|
||||
Q.el.addEventListener('DOMMouseScroll',Q._mouseWheel,true);
|
||||
},
|
||||
|
||||
/**
|
||||
* Turn off mouse controls
|
||||
*
|
||||
* @method disableMouseControls
|
||||
* @for Q.InputSystem
|
||||
*/
|
||||
disableMouseControls: function() {
|
||||
if(Q._mouseMove) {
|
||||
Q.el.removeEventListener("mousemove",Q._mouseMove, true);
|
||||
Q.el.removeEventListener("mousewheel",Q._mouseWheel, true);
|
||||
Q.el.removeEventListener("DOMMouseScroll",Q._mouseWheel, true);
|
||||
Q.el.style.cursor = 'inherit';
|
||||
Q._mouseMove = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw the touch buttons on the screen
|
||||
*
|
||||
* overload this to change how buttons are drawn
|
||||
*
|
||||
* @method drawButtons
|
||||
* @for Q.InputSystem
|
||||
*/
|
||||
drawButtons: function() {
|
||||
var keypad = Q.input.keypad,
|
||||
ctx = Q.ctx;
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
for(var i=0;i<keypad.controls.length;i++) {
|
||||
var control = keypad.controls[i];
|
||||
|
||||
if(control[0]) {
|
||||
ctx.font = "bold " + (keypad.size/2) + "px arial";
|
||||
var x = keypad.left + i * keypad.unit + keypad.gutter,
|
||||
y = keypad.bottom - keypad.unit,
|
||||
key = Q.inputs[control[0]];
|
||||
|
||||
ctx.fillStyle = keypad.color || "#FFFFFF";
|
||||
ctx.globalAlpha = key ? 1.0 : 0.5;
|
||||
ctx.fillRect(x,y,keypad.size,keypad.size);
|
||||
|
||||
ctx.fillStyle = keypad.text || "#000000";
|
||||
ctx.fillText(control[1],
|
||||
x+keypad.size/2,
|
||||
y+keypad.size/2);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
drawCircle: function(x,y,color,size) {
|
||||
var ctx = Q.ctx,
|
||||
joypad = Q.joypad;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.globalAlpha=joypad.alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.arc(x, y, size, 0, Math.PI*2, true);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Draw the joypad on the screen
|
||||
*
|
||||
* overload this to change how joypad is drawn
|
||||
*
|
||||
* @method drawJoypad
|
||||
* @for Q.InputSystem
|
||||
*/
|
||||
drawJoypad: function() {
|
||||
var joypad = Q.joypad;
|
||||
if(joypad.joypadTouch !== null) {
|
||||
Q.input.drawCircle(joypad.centerX,
|
||||
joypad.centerY,
|
||||
joypad.background,
|
||||
joypad.size);
|
||||
|
||||
if(joypad.x !== null) {
|
||||
Q.input.drawCircle(joypad.x,
|
||||
joypad.y,
|
||||
joypad.color,
|
||||
joypad.center);
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Called each frame by the stage game loop to render any onscreen UI
|
||||
*
|
||||
* calls `drawJoypad` and `drawButtons` if enabled
|
||||
*
|
||||
* @method drawCanvas
|
||||
* @for Q.InputSystem
|
||||
*/
|
||||
drawCanvas: function() {
|
||||
if(this.touchEnabled) {
|
||||
this.drawButtons();
|
||||
}
|
||||
|
||||
if(this.joypadEnabled) {
|
||||
this.drawJoypad();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* Instance of the input subsytem that is actually used during gameplay
|
||||
*
|
||||
* @property Q.input
|
||||
* @for Quintus.Input
|
||||
* @type Q.InputSystem
|
||||
*/
|
||||
Q.input = new Q.InputSystem();
|
||||
|
||||
/**
|
||||
* Helper method to activate controls with default options
|
||||
*
|
||||
* @for Quintus.Input
|
||||
* @method Q.controls
|
||||
* @param {Boolean} joypad - enable 4-way joypad (true) or just left, right controls (false, undefined)
|
||||
*/
|
||||
Q.controls = function(joypad) {
|
||||
Q.input.keyboardControls();
|
||||
|
||||
if(joypad) {
|
||||
Q.input.touchControls({
|
||||
controls: [ [],[],[],['action','b'],['fire','a']]
|
||||
});
|
||||
Q.input.joypadControls();
|
||||
} else {
|
||||
Q.input.touchControls();
|
||||
}
|
||||
|
||||
return Q;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Platformer Control Component
|
||||
*
|
||||
* Adds 2D platformer controls onto a Sprite
|
||||
*
|
||||
* Platformer controls bind to left, and right and allow the player to jump.
|
||||
*
|
||||
* Adds the following properties to the entity to control speed and jumping:
|
||||
*
|
||||
* {
|
||||
* speed: 200,
|
||||
* jumpSpeed: -300
|
||||
* }
|
||||
*
|
||||
*
|
||||
* @class platformerControls
|
||||
* @for Quintus.Input
|
||||
*/
|
||||
Q.component("platformerControls", {
|
||||
defaults: {
|
||||
speed: 200,
|
||||
jumpSpeed: -300,
|
||||
collisions: []
|
||||
},
|
||||
|
||||
added: function() {
|
||||
var p = this.entity.p;
|
||||
|
||||
Q._defaults(p,this.defaults);
|
||||
|
||||
this.entity.on("step",this,"step");
|
||||
this.entity.on("bump.bottom",this,"landed");
|
||||
|
||||
p.landed = 0;
|
||||
p.direction ='right';
|
||||
},
|
||||
|
||||
landed: function(col) {
|
||||
var p = this.entity.p;
|
||||
p.landed = 1/5;
|
||||
},
|
||||
|
||||
step: function(dt) {
|
||||
var p = this.entity.p;
|
||||
|
||||
if(p.ignoreControls === undefined || !p.ignoreControls) {
|
||||
var collision = null;
|
||||
|
||||
// Follow along the current slope, if possible.
|
||||
if(p.collisions !== undefined && p.collisions.length > 0 && (Q.inputs['left'] || Q.inputs['right'] || p.landed > 0)) {
|
||||
if(p.collisions.length === 1) {
|
||||
collision = p.collisions[0];
|
||||
} else {
|
||||
// If there's more than one possible slope, follow slope with negative Y normal
|
||||
collision = null;
|
||||
|
||||
for(var i = 0; i < p.collisions.length; i++) {
|
||||
if(p.collisions[i].normalY < 0) {
|
||||
collision = p.collisions[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't climb up walls.
|
||||
if(collision !== null && collision.normalY > -0.3 && collision.normalY < 0.3) {
|
||||
collision = null;
|
||||
}
|
||||
}
|
||||
|
||||
if(Q.inputs['left']) {
|
||||
p.direction = 'left';
|
||||
if(collision && p.landed > 0) {
|
||||
p.vx = p.speed * collision.normalY;
|
||||
p.vy = -p.speed * collision.normalX;
|
||||
} else {
|
||||
p.vx = -p.speed;
|
||||
}
|
||||
} else if(Q.inputs['right']) {
|
||||
p.direction = 'right';
|
||||
if(collision && p.landed > 0) {
|
||||
p.vx = -p.speed * collision.normalY;
|
||||
p.vy = p.speed * collision.normalX;
|
||||
} else {
|
||||
p.vx = p.speed;
|
||||
}
|
||||
} else {
|
||||
p.vx = 0;
|
||||
if(collision && p.landed > 0) {
|
||||
p.vy = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if(p.landed > 0 && (Q.inputs['up'] || Q.inputs['action']) && !p.jumping) {
|
||||
p.vy = p.jumpSpeed;
|
||||
p.landed = -dt;
|
||||
p.jumping = true;
|
||||
} else if(Q.inputs['up'] || Q.inputs['action']) {
|
||||
this.entity.trigger('jump', this.entity);
|
||||
p.jumping = true;
|
||||
}
|
||||
|
||||
if(p.jumping && !(Q.inputs['up'] || Q.inputs['action'])) {
|
||||
p.jumping = false;
|
||||
this.entity.trigger('jumped', this.entity);
|
||||
if(p.vy < p.jumpSpeed / 3) {
|
||||
p.vy = p.jumpSpeed / 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
p.landed -= dt;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Step Controls component
|
||||
*
|
||||
* Adds Step (square grid based) 4-ways controls onto a Sprite
|
||||
*
|
||||
* Adds the following properties to the entity:
|
||||
*
|
||||
* {
|
||||
* stepDistance: 32, // should be tile size
|
||||
* stepDelay: 0.2 // seconds to delay before next step
|
||||
* }
|
||||
*
|
||||
*
|
||||
* @class stepControls
|
||||
* @for Quintus.Input
|
||||
*/
|
||||
Q.component("stepControls", {
|
||||
|
||||
added: function() {
|
||||
var p = this.entity.p;
|
||||
|
||||
if(!p.stepDistance) { p.stepDistance = 32; }
|
||||
if(!p.stepDelay) { p.stepDelay = 0.2; }
|
||||
|
||||
p.stepWait = 0;
|
||||
this.entity.on("step",this,"step");
|
||||
this.entity.on("hit", this,"collision");
|
||||
},
|
||||
|
||||
collision: function(col) {
|
||||
var p = this.entity.p;
|
||||
|
||||
if(p.stepping) {
|
||||
p.stepping = false;
|
||||
p.x = p.origX;
|
||||
p.y = p.origY;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
step: function(dt) {
|
||||
var p = this.entity.p,
|
||||
moved = false;
|
||||
p.stepWait -= dt;
|
||||
|
||||
if(p.stepping) {
|
||||
p.x += p.diffX * dt / p.stepDelay;
|
||||
p.y += p.diffY * dt / p.stepDelay;
|
||||
}
|
||||
|
||||
if(p.stepWait > 0) { return; }
|
||||
if(p.stepping) {
|
||||
p.x = p.destX;
|
||||
p.y = p.destY;
|
||||
}
|
||||
p.stepping = false;
|
||||
|
||||
p.diffX = 0;
|
||||
p.diffY = 0;
|
||||
|
||||
if(Q.inputs['left']) {
|
||||
p.diffX = -p.stepDistance;
|
||||
} else if(Q.inputs['right']) {
|
||||
p.diffX = p.stepDistance;
|
||||
}
|
||||
|
||||
if(Q.inputs['up']) {
|
||||
p.diffY = -p.stepDistance;
|
||||
} else if(Q.inputs['down']) {
|
||||
p.diffY = p.stepDistance;
|
||||
}
|
||||
|
||||
if(p.diffY || p.diffX ) {
|
||||
p.stepping = true;
|
||||
p.origX = p.x;
|
||||
p.origY = p.y;
|
||||
p.destX = p.x + p.diffX;
|
||||
p.destY = p.y + p.diffY;
|
||||
p.stepWait = p.stepDelay;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
|
||||
if(typeof Quintus === 'undefined') {
|
||||
module.exports = quintusInput;
|
||||
} else {
|
||||
quintusInput(Quintus);
|
||||
}
|
||||
-1172
File diff suppressed because it is too large
Load Diff
-770
@@ -1,770 +0,0 @@
|
||||
/*global Quintus:false, module:false */
|
||||
|
||||
/**
|
||||
Quintus HTML5 Game Engine - Sprites Module
|
||||
|
||||
The code in `quintus_sprites.js` defines the `Quintus.Sprites` module, which
|
||||
add support for sprite sheets and the base sprite class.
|
||||
|
||||
Most games will include at a minimum `Quintus.Sprites` and `Quintus.Scenes`
|
||||
|
||||
@module Quintus.Sprites
|
||||
*/
|
||||
|
||||
|
||||
var quintusSprites = function(Quintus) {
|
||||
"use strict";
|
||||
|
||||
|
||||
/**
|
||||
* Quintus Sprites Module Class
|
||||
*
|
||||
* @class Quintus.Sprites
|
||||
*/
|
||||
Quintus.Sprites = function(Q) {
|
||||
|
||||
/**
|
||||
|
||||
Sprite sheet class - generally instantiated with `Q.sheet` new `new`
|
||||
|
||||
|
||||
@class Q.SpriteSheet
|
||||
@extends Q.Class
|
||||
@for Quintus.Sprites
|
||||
*/
|
||||
Q.Class.extend("SpriteSheet",{
|
||||
|
||||
/**
|
||||
constructor
|
||||
|
||||
Options:
|
||||
|
||||
* tileW - tile width
|
||||
* tileH - tile height
|
||||
* w - width of the sprite block
|
||||
* h - height of the sprite block
|
||||
* sx - start x
|
||||
* sy - start y
|
||||
* spacingX - spacing between each tile x (after 1st)
|
||||
* spacingY - spacing between each tile y
|
||||
* marginX - margin around each tile x
|
||||
* marginY - margin around each tile y
|
||||
* cols - number of columns per row
|
||||
|
||||
@constructor
|
||||
@for Q.SpriteSheet
|
||||
@method init
|
||||
@param {String} name
|
||||
@param {String} asset
|
||||
@param {Object} options
|
||||
*/
|
||||
init: function(name, asset,options) {
|
||||
if(!Q.asset(asset)) { throw "Invalid Asset:" + asset; }
|
||||
Q._extend(this,{
|
||||
name: name,
|
||||
asset: asset,
|
||||
w: Q.asset(asset).width,
|
||||
h: Q.asset(asset).height,
|
||||
tileW: 64,
|
||||
tileH: 64,
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
spacingX: 0,
|
||||
spacingY: 0,
|
||||
frameProperties: {}
|
||||
});
|
||||
if(options) { Q._extend(this,options); }
|
||||
// fix for old tilew instead of tileW
|
||||
if(this.tilew) {
|
||||
this.tileW = this.tilew;
|
||||
delete this['tilew'];
|
||||
}
|
||||
if(this.tileh) {
|
||||
this.tileH = this.tileh;
|
||||
delete this['tileh'];
|
||||
}
|
||||
|
||||
this.cols = this.cols ||
|
||||
Math.floor((this.w + this.spacingX) / (this.tileW + this.spacingX));
|
||||
|
||||
this.frames = this.cols * (Math.floor(this.h/(this.tileH + this.spacingY)));
|
||||
},
|
||||
|
||||
/**
|
||||
Returns the starting x position of a single frame
|
||||
|
||||
@method fx
|
||||
@for Q.SpriteSheet
|
||||
@param {Integer} frame
|
||||
*/
|
||||
fx: function(frame) {
|
||||
return Math.floor((frame % this.cols) * (this.tileW + this.spacingX) + this.sx);
|
||||
},
|
||||
|
||||
/**
|
||||
Returns the starting y position of a single frame
|
||||
|
||||
@method fy
|
||||
@for Q.SpriteSheet
|
||||
@param {Integer} frame
|
||||
*/
|
||||
fy: function(frame) {
|
||||
return Math.floor(Math.floor(frame / this.cols) * (this.tileH + this.spacingY) + this.sy);
|
||||
},
|
||||
|
||||
/**
|
||||
Draw a single frame at x,y on the provided context
|
||||
|
||||
@method draw
|
||||
@for Q.SpriteSheet
|
||||
@param {Context2D} ctx
|
||||
@param {Float} x
|
||||
@param {Float} y
|
||||
@param {Integer} frame
|
||||
*/
|
||||
draw: function(ctx, x, y, frame) {
|
||||
if(!ctx) { ctx = Q.ctx; }
|
||||
ctx.drawImage(Q.asset(this.asset),
|
||||
this.fx(frame),this.fy(frame),
|
||||
this.tileW, this.tileH,
|
||||
Math.floor(x),Math.floor(y),
|
||||
this.tileW, this.tileH);
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
Q.sheets = {};
|
||||
|
||||
/**
|
||||
Return a `Q.SpriteSheet` or create a new sprite sheet
|
||||
|
||||
@method Q.sheet
|
||||
@for Quintus.Sprites
|
||||
@param {String} name - name of sheet to return or create
|
||||
@param {String} [asset] - if provided, will create a sprite sheet using this asset
|
||||
@param {Object} [options] - if provided, will be passed as options to `Q.SpriteSheet`
|
||||
*/
|
||||
Q.sheet = function(name,asset,options) {
|
||||
if(asset) {
|
||||
Q.sheets[name] = new Q.SpriteSheet(name,asset,options);
|
||||
} else {
|
||||
return Q.sheets[name];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
Create a number of `Q.SpriteSheet` objects from an image asset and a sprite data JSON asset
|
||||
|
||||
@method Q.compileSheets
|
||||
@for Quintus.Sprites
|
||||
@param {String} imageAsset
|
||||
@param {String spriteDataAsset
|
||||
*/
|
||||
Q.compileSheets = function(imageAsset,spriteDataAsset) {
|
||||
var data = Q.asset(spriteDataAsset);
|
||||
Q._each(data,function(spriteData,name) {
|
||||
Q.sheet(name,imageAsset,spriteData);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
Bitmask 0 to indicate no sprites
|
||||
|
||||
@property Q.SPRITE_NONE
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_NONE = 0;
|
||||
|
||||
/**
|
||||
default sprite type 1
|
||||
|
||||
@property Q.SPRITE_DEFAULT
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_DEFAULT = 1;
|
||||
|
||||
/**
|
||||
particle sprite type 2
|
||||
|
||||
@property Q.SPRITE_PARTICLE
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_PARTICLE = 2;
|
||||
|
||||
/**
|
||||
active sprite type 4
|
||||
|
||||
@property Q.SPRITE_ACTIVE
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_ACTIVE = 4;
|
||||
|
||||
/**
|
||||
friendly sprite type 8
|
||||
|
||||
@property Q.SPRITE_FRIENDLY
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_FRIENDLY = 8;
|
||||
|
||||
/**
|
||||
enemy sprite type 16
|
||||
|
||||
@property Q.SPRITE_ENEMY
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_ENEMY = 16;
|
||||
|
||||
|
||||
/**
|
||||
powerup sprite type 32
|
||||
|
||||
@property Q.SPRITE_POWERUP
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_POWERUP = 32;
|
||||
|
||||
|
||||
/**
|
||||
UI sprite type 64
|
||||
|
||||
@property Q.SPRITE_UI
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_UI = 64;
|
||||
|
||||
/**
|
||||
all sprite type - 0xFFFF
|
||||
|
||||
@property Q.SPRITE_ALL
|
||||
@for Quintus.Sprites
|
||||
@final
|
||||
*/
|
||||
Q.SPRITE_ALL = 0xFFFF;
|
||||
|
||||
|
||||
/**
|
||||
generate a square set of `p.points` on an object from `p.w` and `p.h`
|
||||
|
||||
`p.points` represent the collision points for an object in object coordinates.
|
||||
|
||||
|
||||
@method q._generatePoints
|
||||
@for Quintus.Sprites
|
||||
@param {Q.Sprite} obj - object to add points to
|
||||
@param {Boolean} force - if set to true, will regenerate `p.points` even if it already exists, otherwise if p.points exist it'll be left alone
|
||||
*/
|
||||
Q._generatePoints = function(obj,force) {
|
||||
if(obj.p.points && !force) { return; }
|
||||
var p = obj.p,
|
||||
halfW = p.w/2,
|
||||
halfH = p.h/2;
|
||||
|
||||
p.points = [
|
||||
[ -halfW, -halfH ],
|
||||
[ halfW, -halfH ],
|
||||
[ halfW, halfH ],
|
||||
[ -halfW, halfH ]
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
Generate a square set of `c.points` on an object from the object transform matrix and `p.points`
|
||||
|
||||
`c.points` represents the collision points of an sprite in world coordinates, scaled, rotate and taking into account any parent transforms.
|
||||
|
||||
|
||||
@method Q._generateCollisionPoints
|
||||
@for Quintus.Sprites
|
||||
@param {q.sprite} obj - object to add collision points to
|
||||
*/
|
||||
Q._generateCollisionPoints = function(obj) {
|
||||
if(!obj.matrix && !obj.refreshMatrix) { return; }
|
||||
if(!obj.c) { obj.c = { points: [] }; }
|
||||
var p = obj.p, c = obj.c;
|
||||
|
||||
if(!p.moved &&
|
||||
c.origX === p.x &&
|
||||
c.origY === p.y &&
|
||||
c.origScale === p.scale &&
|
||||
c.origAngle === p.angle) {
|
||||
return;
|
||||
}
|
||||
|
||||
c.origX = p.x;
|
||||
c.origY = p.y;
|
||||
c.origScale = p.scale;
|
||||
c.origAngle = p.angle;
|
||||
|
||||
obj.refreshMatrix();
|
||||
|
||||
var i;
|
||||
|
||||
// Early out if we don't need to rotate / scale / deal with a container
|
||||
if(!obj.container && (!p.scale || p.scale === 1) && p.angle === 0) {
|
||||
for(i=0;i<obj.p.points.length;i++) {
|
||||
obj.c.points[i] = obj.c.points[i] || [];
|
||||
obj.c.points[i][0] = p.x + obj.p.points[i][0];
|
||||
obj.c.points[i][1] = p.y + obj.p.points[i][1];
|
||||
}
|
||||
c.x = p.x; c.y = p.y;
|
||||
c.cx = p.cx; c.cy = p.cy;
|
||||
c.w = p.w; c.h = p.h;
|
||||
} else {
|
||||
var container = obj.container || Q._nullContainer;
|
||||
|
||||
c.x = container.matrix.transformX(p.x,p.y);
|
||||
c.y = container.matrix.transformY(p.x,p.y);
|
||||
c.angle = p.angle + container.c.angle;
|
||||
c.scale = (container.c.scale || 1) * (p.scale || 1);
|
||||
|
||||
var minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
for(i=0;i<obj.p.points.length;i++) {
|
||||
if(!obj.c.points[i]) {
|
||||
obj.c.points[i] = [];
|
||||
}
|
||||
obj.matrix.transformArr(obj.p.points[i],obj.c.points[i]);
|
||||
var x = obj.c.points[i][0],
|
||||
y = obj.c.points[i][1];
|
||||
|
||||
if(x < minX) { minX = x; }
|
||||
if(x > maxX) { maxX = x; }
|
||||
if(y < minY) { minY = y; }
|
||||
if(y > maxY) { maxY = y; }
|
||||
}
|
||||
|
||||
if(minX === maxX) { maxX+=1; }
|
||||
if(minY === maxY) { maxY+=1; }
|
||||
|
||||
c.cx = c.x - minX;
|
||||
c.cy = c.y - minY;
|
||||
|
||||
c.w = maxX - minX;
|
||||
c.h = maxY - minY;
|
||||
}
|
||||
|
||||
p.moved = false;
|
||||
|
||||
// TODO: Invoke moved on children
|
||||
if(obj.children && obj.children.length > 0) {
|
||||
Q._invoke(obj.children,"moved");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
|
||||
Basic sprite class - will render either and asset or a frame from a sprite sheet.
|
||||
|
||||
Auto sets the width and height (`p.w` and `p.h`) from the provided image asset and
|
||||
centers the sprite so 0,0 is the center of the provide image.
|
||||
|
||||
Most of the times you'll sub-class `Q.Sprite`
|
||||
|
||||
@extends Q.GameObject
|
||||
@class Q.Sprite
|
||||
@for Quintus.Sprites
|
||||
*/
|
||||
Q.GameObject.extend("Sprite",{
|
||||
|
||||
/**
|
||||
|
||||
Default sprite constructor, takes in a set of properties and a set of default properties (useful when you create a subclass of sprite)
|
||||
|
||||
Default properties:
|
||||
|
||||
{
|
||||
asset: null, // asset to use
|
||||
sheet: null, // sprite sheet to use (overrides asset)
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
w: 0, // width, set from p.asset or p.sheet
|
||||
h: 0, // height, set from p.asset or p.sheet
|
||||
cx: w/2, // center x, defaults to center of the asset or sheet
|
||||
cy: h/2, // center y, default same as cx
|
||||
// points defines the collision shape, override to customer the collision shape,
|
||||
// must be a convex polygon in clockwise order
|
||||
points: [ [ -w/2, -h/2 ], [ w/2, -h/2 ], [ w/2, h/2 ], [ -w/2, h/2 ] ],
|
||||
opacity: 1,
|
||||
angle: 0,
|
||||
frame: 0
|
||||
type: Q.SPRITE_DEFAULT | Q.SPRITE_ACTIVE,
|
||||
name: '',
|
||||
sort: false, // set to true to force children to be sorted by theier p.z,
|
||||
hidden: false, // set to true to hide the sprite
|
||||
flip: "" // set to "x", "y", or "xy" to flip sprite over that dimension
|
||||
}
|
||||
|
||||
@method init
|
||||
@for Q.Sprite
|
||||
@param {Object} props - property has that will be turned into `p`
|
||||
@param {Object} [defaultProps] - default properties that are assigned only if there's not a corresponding value in `props`
|
||||
*/
|
||||
init: function(props,defaultProps) {
|
||||
this.p = Q._extend({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
opacity: 1,
|
||||
angle: 0,
|
||||
frame: 0,
|
||||
type: Q.SPRITE_DEFAULT | Q.SPRITE_ACTIVE,
|
||||
name: '',
|
||||
spriteProperties: {}
|
||||
},defaultProps);
|
||||
|
||||
this.matrix = new Q.Matrix2D();
|
||||
this.children = [];
|
||||
|
||||
Q._extend(this.p,props);
|
||||
|
||||
this.size();
|
||||
this.p.id = this.p.id || Q._uniqueId();
|
||||
|
||||
this.refreshMatrix();
|
||||
},
|
||||
|
||||
/**
|
||||
Resets the width, height and center based on the
|
||||
asset or sprite sheet
|
||||
|
||||
@method size
|
||||
@for Q.Sprite
|
||||
@param {Boolean} force - force a reset (call if w or h changes)
|
||||
*/
|
||||
size: function(force) {
|
||||
if(force || (!this.p.w || !this.p.h)) {
|
||||
if(this.asset()) {
|
||||
this.p.w = this.asset().width;
|
||||
this.p.h = this.asset().height;
|
||||
} else if(this.sheet()) {
|
||||
this.p.w = this.sheet().tileW;
|
||||
this.p.h = this.sheet().tileH;
|
||||
}
|
||||
}
|
||||
|
||||
this.p.cx = (force || this.p.cx === void 0) ? (this.p.w / 2) : this.p.cx;
|
||||
this.p.cy = (force || this.p.cy === void 0) ? (this.p.h / 2) : this.p.cy;
|
||||
},
|
||||
|
||||
/**
|
||||
Get or set the asset associate with this sprite
|
||||
|
||||
@method asset
|
||||
@for Q.Sprite
|
||||
@param {String} [name] - leave empty to return the asset, add to set the asset
|
||||
@param {Boolean} [resize] - force a call to `size()` and `_generatePoints`
|
||||
*/
|
||||
asset: function(name,resize) {
|
||||
if(!name) { return Q.asset(this.p.asset); }
|
||||
|
||||
this.p.asset = name;
|
||||
if(resize) {
|
||||
this.size(true);
|
||||
Q._generatePoints(this,true);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Get or set the sheet associate with this sprite
|
||||
|
||||
@method sheet
|
||||
@for Q.Sprite
|
||||
@param {String} [name] - leave empty to return the sprite sheet, add to resize
|
||||
@param {Boolean} [resize] - force a resize
|
||||
*/
|
||||
sheet: function(name,resize) {
|
||||
if(!name) { return Q.sheet(this.p.sheet); }
|
||||
|
||||
this.p.sheet = name;
|
||||
if(resize) {
|
||||
this.size(true);
|
||||
Q._generatePoints(this,true);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
Hide the sprite (render returns without rendering)
|
||||
|
||||
@method hide
|
||||
@for Q.Sprite
|
||||
*/
|
||||
hide: function() {
|
||||
this.p.hidden = true;
|
||||
},
|
||||
|
||||
/**
|
||||
Show the sprite
|
||||
|
||||
@method show
|
||||
@for Q.Sprite
|
||||
*/
|
||||
show: function() {
|
||||
this.p.hidden = false;
|
||||
},
|
||||
|
||||
/**
|
||||
Set a set of `p` properties on a Sprite
|
||||
|
||||
@method set
|
||||
@for Q.Sprite
|
||||
@param {Object} properties - hash of properties to set
|
||||
*/
|
||||
set: function(properties) {
|
||||
Q._extend(this.p,properties);
|
||||
return this;
|
||||
},
|
||||
|
||||
_sortChild: function(a,b) {
|
||||
return ((a.p && a.p.z) || -1) - ((b.p && b.p.z) || -1);
|
||||
},
|
||||
|
||||
_flipArgs: {
|
||||
"x": [ -1, 1],
|
||||
"y": [ 1, -1],
|
||||
"xy": [ -1, -1]
|
||||
},
|
||||
|
||||
/**
|
||||
Default render method for the sprite. Don't overload this unless you want to
|
||||
handle all the transform and scale stuff yourself. Rather overload the `draw` method.
|
||||
|
||||
@method render
|
||||
@for Q.Sprite
|
||||
@param {Context2D} ctx - context to render to
|
||||
*/
|
||||
render: function(ctx) {
|
||||
var p = this.p;
|
||||
|
||||
if(p.hidden || p.opacity === 0) { return; }
|
||||
if(!ctx) { ctx = Q.ctx; }
|
||||
|
||||
this.trigger('predraw',ctx);
|
||||
|
||||
ctx.save();
|
||||
|
||||
if(this.p.opacity !== void 0 && this.p.opacity !== 1) {
|
||||
ctx.globalAlpha = this.p.opacity;
|
||||
}
|
||||
|
||||
this.matrix.setContextTransform(ctx);
|
||||
|
||||
if(this.p.flip) { ctx.scale.apply(ctx,this._flipArgs[this.p.flip]); }
|
||||
|
||||
this.trigger('beforedraw',ctx);
|
||||
this.draw(ctx);
|
||||
this.trigger('draw',ctx);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Children set up their own complete matrix
|
||||
// from the base stage matrix
|
||||
if(this.p.sort) { this.children.sort(this._sortChild); }
|
||||
Q._invoke(this.children,"render",ctx);
|
||||
|
||||
this.trigger('postdraw',ctx);
|
||||
|
||||
if(Q.debug) { this.debugRender(ctx); }
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
Center sprite inside of it's container (or the stage)
|
||||
|
||||
@method center
|
||||
@for Q.Sprite
|
||||
*/
|
||||
center: function() {
|
||||
if(this.container) {
|
||||
this.p.x = 0;
|
||||
this.p.y = 0;
|
||||
} else {
|
||||
this.p.x = Q.width / 2;
|
||||
this.p.y = Q.height / 2;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
Draw the asset on the stage. the context passed in is alreay transformed.
|
||||
|
||||
All you need to do is a draw the sprite centered at 0,0
|
||||
|
||||
@method draw
|
||||
@for Q.Sprite
|
||||
@param {Context2D} ctx
|
||||
*/
|
||||
draw: function(ctx) {
|
||||
var p = this.p;
|
||||
if(p.sheet) {
|
||||
this.sheet().draw(ctx,-p.cx,-p.cy,p.frame);
|
||||
} else if(p.asset) {
|
||||
ctx.drawImage(Q.asset(p.asset),-p.cx,-p.cy);
|
||||
} else if(p.color) {
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(-p.cx,-p.cy,p.w,p.h);
|
||||
}
|
||||
},
|
||||
|
||||
debugRender: function(ctx) {
|
||||
if(!this.p.points) {
|
||||
Q._generatePoints(this);
|
||||
}
|
||||
ctx.save();
|
||||
this.matrix.setContextTransform(ctx);
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = this.p.hit ? "blue" : "red";
|
||||
ctx.strokeStyle = "#FF0000";
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||
|
||||
ctx.moveTo(this.p.points[0][0],this.p.points[0][1]);
|
||||
for(var i=0;i<this.p.points.length;i++) {
|
||||
ctx.lineTo(this.p.points[i][0],this.p.points[i][1]);
|
||||
}
|
||||
ctx.lineTo(this.p.points[0][0],this.p.points[0][1]);
|
||||
ctx.stroke();
|
||||
if(Q.debugFill) { ctx.fill(); }
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if(this.c) {
|
||||
var c = this.c;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = "#FF00FF";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c.x - c.cx, c.y - c.cy);
|
||||
ctx.lineTo(c.x - c.cx + c.w, c.y - c.cy);
|
||||
ctx.lineTo(c.x - c.cx + c.w, c.y - c.cy + c.h);
|
||||
ctx.lineTo(c.x - c.cx , c.y - c.cy + c.h);
|
||||
ctx.lineTo(c.x - c.cx, c.y - c.cy);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
Update method is called each step with the time elapsed since the last step.
|
||||
|
||||
Doesn't do anything other than trigger events, call a `step` method if defined
|
||||
and run update on all its children.
|
||||
|
||||
Generally leave this method alone and define a `step` method that will be called
|
||||
|
||||
@method update
|
||||
@for Q.Sprite
|
||||
@param {Float} dt - time elapsed since last call
|
||||
*/
|
||||
update: function(dt) {
|
||||
this.trigger('prestep',dt);
|
||||
if(this.step) { this.step(dt); }
|
||||
this.trigger('step',dt);
|
||||
Q._generateCollisionPoints(this);
|
||||
|
||||
// Ugly coupling to stage - workaround?
|
||||
if(this.stage && this.children.length > 0) {
|
||||
this.stage.updateSprites(this.children,dt,true);
|
||||
}
|
||||
|
||||
// Reset collisions if we're tracking them
|
||||
if(this.p.collisions) { this.p.collisions = []; }
|
||||
},
|
||||
|
||||
/*
|
||||
Regenerates this sprite's transformation matrix
|
||||
|
||||
@method refreshMatrix
|
||||
@for Q.Sprite
|
||||
*/
|
||||
refreshMatrix: function() {
|
||||
var p = this.p;
|
||||
this.matrix.identity();
|
||||
|
||||
if(this.container) { this.matrix.multiply(this.container.matrix); }
|
||||
|
||||
this.matrix.translate(p.x,p.y);
|
||||
|
||||
if(p.scale) { this.matrix.scale(p.scale,p.scale); }
|
||||
|
||||
this.matrix.rotateDeg(p.angle);
|
||||
},
|
||||
|
||||
/*
|
||||
Marks a sprite as having been moved
|
||||
|
||||
@method moved
|
||||
@for Q.Sprite
|
||||
*/
|
||||
moved: function() {
|
||||
this.p.moved = true;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
Simple sprite that adds in basic newtonian physics on each step:
|
||||
|
||||
p.vx += p.ax * dt;
|
||||
p.vy += p.ay * dt;
|
||||
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
|
||||
@class Q.MovingSprite
|
||||
@extends Q.Sprite
|
||||
@for Quintus.Sprites
|
||||
*/
|
||||
Q.Sprite.extend("MovingSprite",{
|
||||
init: function(props,defaultProps) {
|
||||
this._super(Q._extend({
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
ax: 0,
|
||||
ay: 0
|
||||
},props),defaultProps);
|
||||
},
|
||||
|
||||
step: function(dt) {
|
||||
var p = this.p;
|
||||
|
||||
p.vx += p.ax * dt;
|
||||
p.vy += p.ay * dt;
|
||||
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
return Q;
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
if(typeof Quintus === 'undefined') {
|
||||
module.exports = quintusSprites;
|
||||
} else {
|
||||
quintusSprites(Quintus);
|
||||
}
|
||||
@@ -40,7 +40,6 @@ target_compile_definitions(usockets PUBLIC LIBUS_NO_SSL LIBUS_USE_EPOLL)
|
||||
add_executable(gameserver
|
||||
src/main.cpp
|
||||
src/Lobby.cpp
|
||||
src/Game.cpp
|
||||
)
|
||||
target_include_directories(gameserver PRIVATE
|
||||
${uwebsockets_SOURCE_DIR}/src
|
||||
|
||||
@@ -8,7 +8,7 @@ WORKDIR /app
|
||||
# Заглушки вместо src/, чтобы cmake configure не падал на несуществующих
|
||||
# исходниках — они не компилируются, т.к. просим собрать только usockets.
|
||||
COPY CMakeLists.txt ./
|
||||
RUN mkdir src && touch src/main.cpp src/Lobby.cpp src/Lobby.hpp src/Game.cpp src/Game.hpp
|
||||
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)"
|
||||
|
||||
# Реальный код поверх уже собранных зависимостей — пересобирается за секунды.
|
||||
@@ -17,9 +17,7 @@ RUN cmake --build build --target gameserver -j"$(nproc)"
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --no-create-home --shell /usr/sbin/nologin gameserver
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/build/gameserver /usr/local/bin/gameserver
|
||||
USER gameserver
|
||||
EXPOSE 9001
|
||||
CMD ["gameserver"]
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
#include "Game.hpp"
|
||||
|
||||
#include <App.h>
|
||||
#include <libusockets.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
constexpr double kTankHalf = 0.4;
|
||||
constexpr double kTankSpeed = 4.0; // тайлов/сек
|
||||
constexpr double kBulletSpeed = 8.0; // тайлов/сек
|
||||
constexpr double kBulletHalf = 0.08;
|
||||
constexpr int kFireCooldownTicks = 10; // 0.5с при 20 Гц
|
||||
constexpr double kDt = Game::kTickMs / 1000.0;
|
||||
constexpr double kBaseHalf = 0.45;
|
||||
} // namespace
|
||||
|
||||
Game::Game(Room &room, std::function<void()> onFinished)
|
||||
: mode_(room.mode), onFinished_(std::move(onFinished)) {
|
||||
if (mode_ == "coop") {
|
||||
buildCoopMap();
|
||||
} else {
|
||||
buildPvpMap();
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < room.players.size(); i++) {
|
||||
Tank t;
|
||||
t.id = room.players[i].player_id;
|
||||
t.ws = room.players[i].ws;
|
||||
if (mode_ == "coop") {
|
||||
// оба человека стартуют у базы, чуть в стороны друг от друга
|
||||
t.x = kMapWidth / 2.0 - 1.0 + i * 2.0;
|
||||
t.y = kMapHeight - 2.5;
|
||||
t.dir = Dir::Up;
|
||||
} else if (i == 0) {
|
||||
t.x = 1.5;
|
||||
t.y = kMapHeight - 2.5;
|
||||
t.dir = Dir::Up;
|
||||
} else {
|
||||
t.x = kMapWidth - 2.5;
|
||||
t.y = 1.5;
|
||||
t.dir = Dir::Down;
|
||||
}
|
||||
tanks_.push_back(t);
|
||||
}
|
||||
|
||||
// На coop-карте укрытие у базы (в т.ч. кирпичная стена над ней) кладётся
|
||||
// фиксированным блоком независимо от того, что уже занято, и один из
|
||||
// вариантов раскладки перекрывал стартовый тайл игрока — танк рождался
|
||||
// внутри кирпича и не мог выехать (rectHitsSolid блокировал любой шаг,
|
||||
// т.к. новая позиция всё ещё пересекала тот же тайл). Расчищаем тайл под
|
||||
// каждым стартовым танком уже после того, как вся карта построена, чтобы
|
||||
// спавн был гарантированно проходим при любой раскладке.
|
||||
for (const auto &t : tanks_) {
|
||||
int tx = (int)std::floor(t.x);
|
||||
int ty = (int)std::floor(t.y);
|
||||
if (ty >= 0 && ty < kMapHeight && tx >= 0 && tx < kMapWidth) {
|
||||
map_[ty][tx] = kEmpty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Game::~Game() {
|
||||
if (timer_) {
|
||||
us_timer_close(timer_);
|
||||
}
|
||||
}
|
||||
|
||||
void Game::buildPvpMap() {
|
||||
map_.assign(kMapHeight, std::vector<int>(kMapWidth, kEmpty));
|
||||
for (int y = 0; y < kMapHeight; y++) {
|
||||
for (int x = 0; x < kMapWidth; x++) {
|
||||
if (x == 0 || y == 0 || x == kMapWidth - 1 || y == kMapHeight - 1) {
|
||||
map_[y][x] = kSteel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto placeMirrored = [this](int x, int y, int w, int h, int tile) {
|
||||
for (int dy = 0; dy < h; dy++) {
|
||||
for (int dx = 0; dx < w; dx++) {
|
||||
map_[y + dy][x + dx] = tile;
|
||||
map_[kMapHeight - 1 - (y + dy)][kMapWidth - 1 - (x + dx)] = tile;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
placeMirrored(3, 3, 2, 2, kBrick);
|
||||
placeMirrored(6, 3, 1, 2, kBrick);
|
||||
placeMirrored(3, 6, 2, 1, kSteel);
|
||||
placeMirrored(6, 6, 3, 3, kBrick);
|
||||
}
|
||||
|
||||
void Game::buildCoopMap() {
|
||||
map_.assign(kMapHeight, std::vector<int>(kMapWidth, kEmpty));
|
||||
for (int y = 0; y < kMapHeight; y++) {
|
||||
for (int x = 0; x < kMapWidth; x++) {
|
||||
if (x == 0 || y == 0 || x == kMapWidth - 1 || y == kMapHeight - 1) {
|
||||
map_[y][x] = kSteel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// немного укрытий по полю — несколько вариантов раскладки для разнообразия
|
||||
auto block = [this](int x, int y, int w, int h, int tile) {
|
||||
for (int dy = 0; dy < h; dy++) {
|
||||
for (int dx = 0; dx < w; dx++) {
|
||||
map_[y + dy][x + dx] = tile;
|
||||
}
|
||||
}
|
||||
};
|
||||
switch (std::rand() % 3) {
|
||||
case 0:
|
||||
block(2, 5, 2, 2, kBrick);
|
||||
block(11, 5, 2, 2, kBrick);
|
||||
block(6, 4, 3, 1, kSteel);
|
||||
block(4, 8, 2, 2, kBrick);
|
||||
block(9, 8, 2, 2, kBrick);
|
||||
break;
|
||||
case 1:
|
||||
block(1, 4, 3, 1, kSteel);
|
||||
block(11, 4, 3, 1, kSteel);
|
||||
block(6, 6, 3, 3, kBrick);
|
||||
block(2, 9, 2, 2, kBrick);
|
||||
block(11, 9, 2, 2, kBrick);
|
||||
break;
|
||||
default:
|
||||
block(4, 3, 1, 4, kBrick);
|
||||
block(10, 3, 1, 4, kBrick);
|
||||
block(6, 5, 3, 2, kSteel);
|
||||
block(3, 9, 3, 1, kBrick);
|
||||
block(9, 9, 3, 1, kBrick);
|
||||
break;
|
||||
}
|
||||
|
||||
// база внизу по центру, укрыта кирпичом с трёх сторон
|
||||
int bx = kMapWidth / 2;
|
||||
int by = kMapHeight - 2;
|
||||
base_ = Base{bx + 0.5, by + 0.5, true};
|
||||
block(bx - 1, by - 1, 3, 1, kBrick); // сверху
|
||||
map_[by][bx - 1] = kBrick; // слева
|
||||
map_[by][bx + 1] = kBrick; // справа
|
||||
map_[by][bx] = kEmpty; // сама база — не стена
|
||||
|
||||
enemy_spawn_points_ = {
|
||||
{3.5, 1.5},
|
||||
{kMapWidth / 2.0 + 0.5, 1.5},
|
||||
{kMapWidth - 3.5, 1.5},
|
||||
};
|
||||
}
|
||||
|
||||
void Game::start() {
|
||||
timer_ = us_create_timer((us_loop_t *)uWS::Loop::get(), 0, sizeof(Game *));
|
||||
*(Game **)us_timer_ext(timer_) = this;
|
||||
us_timer_set(
|
||||
timer_,
|
||||
[](us_timer_t *t) {
|
||||
Game *self = *(Game **)us_timer_ext(t);
|
||||
self->tick();
|
||||
},
|
||||
kTickMs, kTickMs);
|
||||
}
|
||||
|
||||
void Game::handleInput(const std::string &player_id, const json &payload) {
|
||||
for (auto &t : tanks_) {
|
||||
if (!t.is_ai && t.id == player_id) {
|
||||
t.up = payload.value("up", false);
|
||||
t.down = payload.value("down", false);
|
||||
t.left = payload.value("left", false);
|
||||
t.right = payload.value("right", false);
|
||||
t.fire = payload.value("fire", false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Game::tileSolid(int tx, int ty) const {
|
||||
if (tx < 0 || ty < 0 || ty >= kMapHeight || tx >= kMapWidth) {
|
||||
return true;
|
||||
}
|
||||
return map_[ty][tx] != kEmpty;
|
||||
}
|
||||
|
||||
bool Game::rectHitsSolid(double x, double y) const {
|
||||
int minX = (int)std::floor(x - kTankHalf);
|
||||
int maxX = (int)std::floor(x + kTankHalf - 1e-6);
|
||||
int minY = (int)std::floor(y - kTankHalf);
|
||||
int maxY = (int)std::floor(y + kTankHalf - 1e-6);
|
||||
for (int ty = minY; ty <= maxY; ty++) {
|
||||
for (int tx = minX; tx <= maxX; tx++) {
|
||||
if (tileSolid(tx, ty)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Game::rectHitsTank(double x, double y, const Tank &self) const {
|
||||
for (const auto &t : tanks_) {
|
||||
if (&t == &self || !t.alive) {
|
||||
continue;
|
||||
}
|
||||
if (std::abs(x - t.x) < 2 * kTankHalf && std::abs(y - t.y) < 2 * kTankHalf) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Game::rectHitsBase(double x, double y, double half) const {
|
||||
if (!base_ || !base_->alive) {
|
||||
return false;
|
||||
}
|
||||
return std::abs(x - base_->x) < half + kBaseHalf && std::abs(y - base_->y) < half + kBaseHalf;
|
||||
}
|
||||
|
||||
void Game::applyInput(Tank &tank) {
|
||||
if (!tank.alive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tank.fire_cooldown > 0) {
|
||||
tank.fire_cooldown--;
|
||||
}
|
||||
|
||||
Dir moveDir = tank.dir;
|
||||
bool moving = false;
|
||||
if (tank.up) {
|
||||
moveDir = Dir::Up;
|
||||
moving = true;
|
||||
} else if (tank.down) {
|
||||
moveDir = Dir::Down;
|
||||
moving = true;
|
||||
} else if (tank.left) {
|
||||
moveDir = Dir::Left;
|
||||
moving = true;
|
||||
} else if (tank.right) {
|
||||
moveDir = Dir::Right;
|
||||
moving = true;
|
||||
}
|
||||
|
||||
tank.dir = moveDir;
|
||||
|
||||
if (moving) {
|
||||
double nx = tank.x, ny = tank.y;
|
||||
double step = kTankSpeed * kDt;
|
||||
switch (moveDir) {
|
||||
case Dir::Up: ny -= step; break;
|
||||
case Dir::Down: ny += step; break;
|
||||
case Dir::Left: nx -= step; break;
|
||||
case Dir::Right: nx += step; break;
|
||||
}
|
||||
if (!rectHitsSolid(nx, ny) && !rectHitsTank(nx, ny, tank) && !rectHitsBase(nx, ny, kTankHalf)) {
|
||||
tank.x = nx;
|
||||
tank.y = ny;
|
||||
}
|
||||
}
|
||||
|
||||
if (tank.fire) {
|
||||
fireBullet(tank);
|
||||
}
|
||||
}
|
||||
|
||||
void Game::updateAi(Tank &tank) {
|
||||
if (tank.ai_redecide_ticks > 0) {
|
||||
tank.ai_redecide_ticks--;
|
||||
} else {
|
||||
double tx = base_ ? base_->x : tank.x;
|
||||
double ty = base_ ? base_->y : tank.y;
|
||||
double dx = tx - tank.x, dy = ty - tank.y;
|
||||
|
||||
if (std::rand() % 5 == 0) {
|
||||
// изредка идём в случайную сторону, чтобы враги не были предсказуемы
|
||||
tank.ai_dir = static_cast<Dir>(std::rand() % 4);
|
||||
} else if (std::abs(dx) > std::abs(dy)) {
|
||||
tank.ai_dir = dx > 0 ? Dir::Right : Dir::Left;
|
||||
} else {
|
||||
tank.ai_dir = dy > 0 ? Dir::Down : Dir::Up;
|
||||
}
|
||||
tank.ai_redecide_ticks = 30 + std::rand() % 30; // 1.5-3с
|
||||
}
|
||||
|
||||
tank.up = tank.ai_dir == Dir::Up;
|
||||
tank.down = tank.ai_dir == Dir::Down;
|
||||
tank.left = tank.ai_dir == Dir::Left;
|
||||
tank.right = tank.ai_dir == Dir::Right;
|
||||
tank.fire = (std::rand() % 15) == 0;
|
||||
}
|
||||
|
||||
void Game::spawnEnemiesIfNeeded() {
|
||||
if (enemies_spawned_total_ >= kMaxEnemiesTotal) {
|
||||
return;
|
||||
}
|
||||
if (enemy_spawn_cooldown_ > 0) {
|
||||
enemy_spawn_cooldown_--;
|
||||
return;
|
||||
}
|
||||
|
||||
int concurrent = 0;
|
||||
for (const auto &t : tanks_) {
|
||||
if (t.is_ai && t.alive) {
|
||||
concurrent++;
|
||||
}
|
||||
}
|
||||
if (concurrent >= kMaxConcurrentEnemies) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto &sp = enemy_spawn_points_[enemies_spawned_total_ % enemy_spawn_points_.size()];
|
||||
Tank t;
|
||||
t.id = "ai-" + std::to_string(next_enemy_id_++);
|
||||
t.is_ai = true;
|
||||
t.ws = nullptr;
|
||||
t.x = sp.first;
|
||||
t.y = sp.second;
|
||||
t.dir = Dir::Down;
|
||||
t.lives = 1;
|
||||
tanks_.push_back(t);
|
||||
|
||||
enemies_spawned_total_++;
|
||||
enemy_spawn_cooldown_ = kEnemySpawnCooldownTicks;
|
||||
}
|
||||
|
||||
void Game::fireBullet(Tank &tank) {
|
||||
if (tank.fire_cooldown > 0) {
|
||||
return;
|
||||
}
|
||||
bool hasOwnBullet = std::any_of(bullets_.begin(), bullets_.end(),
|
||||
[&](const Bullet &b) { return b.owner_id == tank.id; });
|
||||
if (hasOwnBullet) {
|
||||
return;
|
||||
}
|
||||
|
||||
Bullet b;
|
||||
b.id = next_bullet_id_++;
|
||||
b.owner_id = tank.id;
|
||||
b.owner_is_ai = tank.is_ai;
|
||||
b.dir = tank.dir;
|
||||
double offset = kTankHalf + kBulletHalf + 0.01;
|
||||
b.x = tank.x;
|
||||
b.y = tank.y;
|
||||
switch (tank.dir) {
|
||||
case Dir::Up: b.y -= offset; break;
|
||||
case Dir::Down: b.y += offset; break;
|
||||
case Dir::Left: b.x -= offset; break;
|
||||
case Dir::Right: b.x += offset; break;
|
||||
}
|
||||
bullets_.push_back(b);
|
||||
tank.fire_cooldown = kFireCooldownTicks;
|
||||
}
|
||||
|
||||
void Game::updateBullets() {
|
||||
destroyed_tiles_.clear();
|
||||
double step = kBulletSpeed * kDt;
|
||||
|
||||
for (auto &b : bullets_) {
|
||||
switch (b.dir) {
|
||||
case Dir::Up: b.y -= step; break;
|
||||
case Dir::Down: b.y += step; break;
|
||||
case Dir::Left: b.x -= step; break;
|
||||
case Dir::Right: b.x += step; break;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<bool> dead(bullets_.size(), false);
|
||||
for (size_t i = 0; i < bullets_.size(); i++) {
|
||||
if (dead[i]) continue;
|
||||
for (size_t j = i + 1; j < bullets_.size(); j++) {
|
||||
if (dead[j] || bullets_[i].owner_id == bullets_[j].owner_id) continue;
|
||||
if (std::abs(bullets_[i].x - bullets_[j].x) < 2 * kBulletHalf &&
|
||||
std::abs(bullets_[i].y - bullets_[j].y) < 2 * kBulletHalf) {
|
||||
dead[i] = dead[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int pvpLoserIndex = -1;
|
||||
|
||||
for (size_t i = 0; i < bullets_.size(); i++) {
|
||||
if (dead[i]) continue;
|
||||
Bullet &b = bullets_[i];
|
||||
|
||||
int tx = (int)std::floor(b.x);
|
||||
int ty = (int)std::floor(b.y);
|
||||
if (tx < 0 || ty < 0 || tx >= kMapWidth || ty >= kMapHeight) {
|
||||
dead[i] = true;
|
||||
continue;
|
||||
}
|
||||
if (map_[ty][tx] == kSteel) {
|
||||
dead[i] = true;
|
||||
continue;
|
||||
}
|
||||
if (map_[ty][tx] == kBrick) {
|
||||
map_[ty][tx] = kEmpty;
|
||||
destroyed_tiles_.push_back({tx, ty});
|
||||
dead[i] = true;
|
||||
continue;
|
||||
}
|
||||
if (rectHitsBase(b.x, b.y, kBulletHalf)) {
|
||||
dead[i] = true;
|
||||
if (base_) {
|
||||
base_->alive = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (size_t ti = 0; ti < tanks_.size(); ti++) {
|
||||
Tank &t = tanks_[ti];
|
||||
if (!t.alive || t.id == b.owner_id) continue;
|
||||
if (mode_ == "coop" && t.is_ai == b.owner_is_ai) continue; // без дружественного огня
|
||||
|
||||
if (std::abs(b.x - t.x) < kTankHalf + kBulletHalf && std::abs(b.y - t.y) < kTankHalf + kBulletHalf) {
|
||||
dead[i] = true;
|
||||
t.lives--;
|
||||
if (t.lives <= 0) {
|
||||
t.alive = false;
|
||||
if (mode_ == "pvp") {
|
||||
pvpLoserIndex = (int)ti;
|
||||
}
|
||||
} else {
|
||||
// респаун на стартовой позиции
|
||||
if (mode_ == "coop") {
|
||||
size_t humanIdx = 0;
|
||||
for (auto &tt : tanks_) {
|
||||
if (&tt == &t) break;
|
||||
if (!tt.is_ai) humanIdx++;
|
||||
}
|
||||
t.x = kMapWidth / 2.0 - 1.0 + humanIdx * 2.0;
|
||||
t.y = kMapHeight - 2.5;
|
||||
} else {
|
||||
t.x = (ti == 0) ? 1.5 : kMapWidth - 2.5;
|
||||
t.y = (ti == 0) ? kMapHeight - 2.5 : 1.5;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Bullet> alive;
|
||||
for (size_t i = 0; i < bullets_.size(); i++) {
|
||||
if (!dead[i]) alive.push_back(bullets_[i]);
|
||||
}
|
||||
bullets_ = std::move(alive);
|
||||
|
||||
if (mode_ == "pvp" && pvpLoserIndex != -1) {
|
||||
endGamePvp(1 - pvpLoserIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void Game::checkCoopEndConditions() {
|
||||
if (finished_ || mode_ != "coop") {
|
||||
return;
|
||||
}
|
||||
if (base_ && !base_->alive) {
|
||||
endGameCoop(false, "база уничтожена");
|
||||
return;
|
||||
}
|
||||
bool anyHumanAlive = std::any_of(tanks_.begin(), tanks_.end(),
|
||||
[](const Tank &t) { return !t.is_ai && t.alive; });
|
||||
if (!anyHumanAlive) {
|
||||
endGameCoop(false, "все танки уничтожены");
|
||||
return;
|
||||
}
|
||||
bool anyEnemyAlive = std::any_of(tanks_.begin(), tanks_.end(),
|
||||
[](const Tank &t) { return t.is_ai && t.alive; });
|
||||
if (enemies_spawned_total_ >= kMaxEnemiesTotal && !anyEnemyAlive) {
|
||||
endGameCoop(true, "волна противника уничтожена");
|
||||
}
|
||||
}
|
||||
|
||||
void Game::tick() {
|
||||
if (finished_) {
|
||||
return;
|
||||
}
|
||||
tick_count_++;
|
||||
|
||||
for (auto &t : tanks_) {
|
||||
if (!t.alive) continue;
|
||||
if (t.is_ai) {
|
||||
updateAi(t);
|
||||
}
|
||||
double beforeX = t.x, beforeY = t.y;
|
||||
applyInput(t);
|
||||
// Врезались в стену/базу — не долбим её до конца таймера, перерешаем сразу.
|
||||
if (t.is_ai && t.x == beforeX && t.y == beforeY && (t.up || t.down || t.left || t.right)) {
|
||||
t.ai_redecide_ticks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode_ == "coop") {
|
||||
spawnEnemiesIfNeeded();
|
||||
}
|
||||
|
||||
updateBullets();
|
||||
checkCoopEndConditions();
|
||||
|
||||
if (!finished_) {
|
||||
broadcastState();
|
||||
}
|
||||
}
|
||||
|
||||
json Game::dirToJson(Dir d) {
|
||||
switch (d) {
|
||||
case Dir::Up: return "up";
|
||||
case Dir::Down: return "down";
|
||||
case Dir::Left: return "left";
|
||||
case Dir::Right: return "right";
|
||||
}
|
||||
return "up";
|
||||
}
|
||||
|
||||
json Game::serializeTank(const Tank &t) const {
|
||||
return {
|
||||
{"id", t.id},
|
||||
{"x", t.x},
|
||||
{"y", t.y},
|
||||
{"direction", dirToJson(t.dir)},
|
||||
{"lives", t.lives},
|
||||
{"alive", t.alive},
|
||||
{"is_ai", t.is_ai},
|
||||
};
|
||||
}
|
||||
|
||||
json Game::serializeBullet(const Bullet &b) const {
|
||||
return {
|
||||
{"id", b.id},
|
||||
{"owner_id", b.owner_id},
|
||||
{"x", b.x},
|
||||
{"y", b.y},
|
||||
{"direction", dirToJson(b.dir)},
|
||||
};
|
||||
}
|
||||
|
||||
void Game::sendTo(WS *ws, const std::string &type, json payload) {
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
json msg = {{"type", type}, {"payload", std::move(payload)}};
|
||||
ws->send(msg.dump(), uWS::OpCode::TEXT);
|
||||
}
|
||||
|
||||
void Game::broadcastState() {
|
||||
json tanks = json::array();
|
||||
for (const auto &t : tanks_) tanks.push_back(serializeTank(t));
|
||||
json bullets = json::array();
|
||||
for (const auto &b : bullets_) bullets.push_back(serializeBullet(b));
|
||||
|
||||
json payload = {{"tick", tick_count_}, {"tanks", tanks}, {"bullets", bullets}};
|
||||
if (base_) {
|
||||
payload["base"] = {{"x", base_->x}, {"y", base_->y}, {"alive", base_->alive}};
|
||||
}
|
||||
if (!destroyed_tiles_.empty()) {
|
||||
json destroyed = json::array();
|
||||
for (const auto &[tx, ty] : destroyed_tiles_) {
|
||||
destroyed.push_back({tx, ty});
|
||||
}
|
||||
payload["destroyed_tiles"] = destroyed;
|
||||
}
|
||||
for (const auto &t : tanks_) {
|
||||
if (!t.is_ai) {
|
||||
sendTo(t.ws, "game.state", payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json Game::buildStartPayload(const std::string &mode) const {
|
||||
json mapJson = json::array();
|
||||
for (const auto &row : map_) {
|
||||
mapJson.push_back(row);
|
||||
}
|
||||
json players = json::array();
|
||||
for (const auto &t : tanks_) {
|
||||
players.push_back({{"id", t.id}});
|
||||
}
|
||||
json payload = {
|
||||
{"map", mapJson},
|
||||
{"mode", mode},
|
||||
{"players", players},
|
||||
{"tick_rate", 1000 / kTickMs},
|
||||
};
|
||||
if (base_) {
|
||||
payload["base"] = {{"x", base_->x}, {"y", base_->y}};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
void Game::handleDisconnect(WS *ws) {
|
||||
if (finished_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode_ == "pvp") {
|
||||
for (size_t i = 0; i < tanks_.size(); i++) {
|
||||
if (tanks_[i].ws == ws) {
|
||||
int winnerIndex = 1 - (int)i;
|
||||
finished_ = true;
|
||||
if (timer_) {
|
||||
us_timer_close(timer_);
|
||||
timer_ = nullptr;
|
||||
}
|
||||
sendTo(tanks_[winnerIndex].ws, "game.over", {{"result", "win"}, {"reason", "соперник отключился"}});
|
||||
if (onFinished_) {
|
||||
onFinished_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// coop
|
||||
for (auto &t : tanks_) {
|
||||
if (!t.is_ai && t.ws == ws) {
|
||||
t.alive = false;
|
||||
t.ws = nullptr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool anyHumanAlive = std::any_of(tanks_.begin(), tanks_.end(),
|
||||
[](const Tank &t) { return !t.is_ai && t.alive; });
|
||||
if (!anyHumanAlive) {
|
||||
endGameCoop(false, "все игроки отключились");
|
||||
}
|
||||
}
|
||||
|
||||
void Game::endGamePvp(int winnerIndex) {
|
||||
finished_ = true;
|
||||
if (timer_) {
|
||||
us_timer_close(timer_);
|
||||
timer_ = nullptr;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < tanks_.size(); i++) {
|
||||
std::string result = (winnerIndex == -1) ? "draw" : ((int)i == winnerIndex ? "win" : "lose");
|
||||
std::string reason = (winnerIndex == -1) ? "оба танка уничтожены одновременно"
|
||||
: ((int)i == winnerIndex) ? "противник уничтожен"
|
||||
: "танк уничтожен";
|
||||
sendTo(tanks_[i].ws, "game.over", {{"result", result}, {"reason", reason}});
|
||||
}
|
||||
|
||||
if (onFinished_) {
|
||||
onFinished_();
|
||||
}
|
||||
}
|
||||
|
||||
void Game::endGameCoop(bool win, const std::string &reason) {
|
||||
finished_ = true;
|
||||
if (timer_) {
|
||||
us_timer_close(timer_);
|
||||
timer_ = nullptr;
|
||||
}
|
||||
|
||||
for (const auto &t : tanks_) {
|
||||
if (!t.is_ai && t.ws) {
|
||||
sendTo(t.ws, "game.over", {{"result", win ? "win" : "lose"}, {"reason", reason}});
|
||||
}
|
||||
}
|
||||
|
||||
if (onFinished_) {
|
||||
onFinished_();
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "Lobby.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct us_timer_t;
|
||||
|
||||
// Authoritative-игра для одной комнаты: pvp (этап 4) и coop с AI (этап 5).
|
||||
class Game {
|
||||
public:
|
||||
static constexpr int kTickMs = 50; // 20 Гц
|
||||
static constexpr int kMapWidth = 15;
|
||||
static constexpr int kMapHeight = 15;
|
||||
static constexpr int kStartLives = 3;
|
||||
static constexpr int kMaxEnemiesTotal = 8; // размер волны в coop
|
||||
static constexpr int kMaxConcurrentEnemies = 3;
|
||||
static constexpr int kEnemySpawnCooldownTicks = 60; // 3с
|
||||
|
||||
enum Tile { kEmpty = 0, kBrick = 1, kSteel = 2 };
|
||||
enum class Dir { Up, Right, Down, Left };
|
||||
|
||||
struct Tank {
|
||||
std::string id; // player_id для людей, "ai-N" для AI
|
||||
WS *ws = nullptr; // nullptr для AI
|
||||
bool is_ai = false;
|
||||
double x, y;
|
||||
Dir dir = Dir::Up;
|
||||
int lives = kStartLives;
|
||||
int fire_cooldown = 0;
|
||||
bool up = false, down = false, left = false, right = false, fire = false;
|
||||
bool alive = true;
|
||||
// состояние простого AI (используется только если is_ai)
|
||||
Dir ai_dir = Dir::Down;
|
||||
int ai_redecide_ticks = 0;
|
||||
};
|
||||
|
||||
struct Bullet {
|
||||
int id;
|
||||
std::string owner_id;
|
||||
bool owner_is_ai;
|
||||
double x, y;
|
||||
Dir dir;
|
||||
};
|
||||
|
||||
struct Base {
|
||||
double x, y;
|
||||
bool alive = true;
|
||||
};
|
||||
|
||||
// room.players.size() — 2 для pvp, 1 или 2 для coop.
|
||||
Game(Room &room, std::function<void()> onFinished);
|
||||
~Game();
|
||||
|
||||
void start();
|
||||
void handleInput(const std::string &player_id, const json &payload);
|
||||
// pvp: технический проигрыш отключившегося, второй побеждает.
|
||||
// coop: игрок выбывает; если людей не осталось — поражение.
|
||||
void handleDisconnect(WS *ws);
|
||||
json buildStartPayload(const std::string &mode) const;
|
||||
|
||||
private:
|
||||
std::string mode_;
|
||||
std::vector<std::vector<int>> map_;
|
||||
std::vector<Tank> tanks_; // сначала люди, затем (в coop) появляющиеся AI
|
||||
std::vector<Bullet> bullets_;
|
||||
std::optional<Base> base_;
|
||||
std::vector<std::pair<double, double>> enemy_spawn_points_;
|
||||
std::vector<std::pair<int, int>> destroyed_tiles_; // за последний тик — для звука/синка стен на клиенте
|
||||
int enemies_spawned_total_ = 0;
|
||||
int enemy_spawn_cooldown_ = 0;
|
||||
int next_bullet_id_ = 1;
|
||||
int next_enemy_id_ = 1;
|
||||
int tick_count_ = 0;
|
||||
bool finished_ = false;
|
||||
us_timer_t *timer_ = nullptr;
|
||||
std::function<void()> onFinished_;
|
||||
|
||||
void buildPvpMap();
|
||||
void buildCoopMap();
|
||||
void tick();
|
||||
void applyInput(Tank &tank);
|
||||
void updateAi(Tank &tank);
|
||||
void spawnEnemiesIfNeeded();
|
||||
void updateBullets();
|
||||
bool tileSolid(int tx, int ty) const;
|
||||
bool rectHitsSolid(double x, double y) const;
|
||||
bool rectHitsTank(double x, double y, const Tank &self) const;
|
||||
bool rectHitsBase(double x, double y, double half) const;
|
||||
void fireBullet(Tank &tank);
|
||||
void endGamePvp(int winnerIndex); // -1 = ничья
|
||||
void endGameCoop(bool win, const std::string &reason);
|
||||
void checkCoopEndConditions();
|
||||
void broadcastState();
|
||||
void sendTo(WS *ws, const std::string &type, json payload);
|
||||
|
||||
static json dirToJson(Dir d);
|
||||
json serializeTank(const Tank &t) const;
|
||||
json serializeBullet(const Bullet &b) const;
|
||||
};
|
||||
+1
-139
@@ -1,13 +1,9 @@
|
||||
#include "Lobby.hpp"
|
||||
#include "Game.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
Lobby::Lobby() = default;
|
||||
Lobby::~Lobby() = default;
|
||||
|
||||
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);
|
||||
@@ -45,7 +41,6 @@ json Lobby::roomPlayers(const Room &room) const {
|
||||
{"id", room.players[i].player_id},
|
||||
{"nickname", room.players[i].nickname},
|
||||
{"slot", i},
|
||||
{"ready", room.players[i].ready},
|
||||
});
|
||||
}
|
||||
return players;
|
||||
@@ -74,7 +69,6 @@ void Lobby::onOpen(WS *ws) {
|
||||
}
|
||||
|
||||
void Lobby::onClose(WS *ws) {
|
||||
endActiveGameIfAny(ws->getUserData()->room_id, ws);
|
||||
removePlayerFromRoom(ws);
|
||||
sockets_.erase(ws);
|
||||
}
|
||||
@@ -92,7 +86,6 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
|
||||
|
||||
json payload = request.value("payload", json::object());
|
||||
|
||||
try {
|
||||
if (type == "hello") {
|
||||
handleHello(ws, payload);
|
||||
} else if (type == "lobby.list_rooms") {
|
||||
@@ -103,52 +96,17 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
|
||||
handleJoinRoom(ws, payload);
|
||||
} else if (type == "lobby.leave_room") {
|
||||
handleLeaveRoom(ws);
|
||||
} else if (type == "game.ready") {
|
||||
handleGameReady(ws);
|
||||
} else if (type == "game.input") {
|
||||
handleGameInput(ws, payload);
|
||||
} else {
|
||||
sendError(ws, "unknown_type", "unknown message type: " + type);
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
// payload может быть не тем типом, что ожидает хендлер (например,
|
||||
// payload.value<T>() на не-объекте/не том типе поля) — это кидает
|
||||
// json::type_error. Не даём одному кривому сообщению уронить процесс
|
||||
// со всеми активными играми.
|
||||
sendError(ws, "bad_payload", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Ник рассылается другим игрокам и рендерится на клиенте как текст — режем
|
||||
// длину и выкидываем управляющие/разметочные символы, чтобы кривой или
|
||||
// специально вредоносный ник от одного игрока не ломал UI остальных.
|
||||
std::string sanitizeNickname(std::string nickname) {
|
||||
constexpr size_t kMaxLen = 20;
|
||||
if (nickname.empty()) {
|
||||
return "anon";
|
||||
}
|
||||
std::string out;
|
||||
out.reserve(std::min(nickname.size(), kMaxLen));
|
||||
for (unsigned char c : nickname) {
|
||||
if (out.size() >= kMaxLen) {
|
||||
break;
|
||||
}
|
||||
if (c == '<' || c == '>' || c == '&' || c == '"' || c == '\'' || c < 0x20) {
|
||||
continue;
|
||||
}
|
||||
out.push_back(static_cast<char>(c));
|
||||
}
|
||||
return out.empty() ? "anon" : out;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
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 = sanitizeNickname(payload.value("nickname", "anon"));
|
||||
data->nickname = payload.value("nickname", "anon");
|
||||
send(ws, "hello.ack", {{"player_id", data->player_id}});
|
||||
}
|
||||
|
||||
@@ -172,10 +130,6 @@ void Lobby::handleCreateRoom(WS *ws, const json &payload) {
|
||||
sendError(ws, "invalid_mode", "mode must be \"coop\" or \"pvp\"");
|
||||
return;
|
||||
}
|
||||
if (rooms_.size() >= kMaxRooms) {
|
||||
sendError(ws, "too_many_rooms", "server is at capacity, try again later");
|
||||
return;
|
||||
}
|
||||
|
||||
Room room;
|
||||
room.id = generateRoomId();
|
||||
@@ -243,102 +197,10 @@ void Lobby::handleLeaveRoom(WS *ws) {
|
||||
sendError(ws, "not_in_room", "you are not in a room");
|
||||
return;
|
||||
}
|
||||
endActiveGameIfAny(ws->getUserData()->room_id, ws);
|
||||
removePlayerFromRoom(ws);
|
||||
broadcastRoomsToLobby();
|
||||
}
|
||||
|
||||
void Lobby::handleGameReady(WS *ws) {
|
||||
auto *data = ws->getUserData();
|
||||
if (data->room_id.empty()) {
|
||||
sendError(ws, "not_in_room", "you are not in a room");
|
||||
return;
|
||||
}
|
||||
|
||||
auto it = rooms_.find(data->room_id);
|
||||
if (it == rooms_.end()) {
|
||||
return;
|
||||
}
|
||||
for (auto &p : it->second.players) {
|
||||
if (p.ws == ws) {
|
||||
p.ready = true;
|
||||
}
|
||||
}
|
||||
broadcastRoomUpdated(it->second);
|
||||
maybeStartGame(data->room_id);
|
||||
}
|
||||
|
||||
void Lobby::handleGameInput(WS *ws, const json &payload) {
|
||||
auto *data = ws->getUserData();
|
||||
if (data->room_id.empty()) {
|
||||
return;
|
||||
}
|
||||
auto it = games_.find(data->room_id);
|
||||
if (it == games_.end()) {
|
||||
return;
|
||||
}
|
||||
it->second->handleInput(data->player_id, payload);
|
||||
}
|
||||
|
||||
void Lobby::maybeStartGame(const std::string &room_id) {
|
||||
auto it = rooms_.find(room_id);
|
||||
if (it == rooms_.end()) {
|
||||
return;
|
||||
}
|
||||
Room &room = it->second;
|
||||
if (room.state != "waiting" || room.players.empty()) {
|
||||
return;
|
||||
}
|
||||
// pvp — строго вдвоём; coop — можно соло, второй подключается позже.
|
||||
if (room.mode == "pvp" && room.players.size() != kMaxPlayersPerRoom) {
|
||||
return;
|
||||
}
|
||||
bool allReady = std::all_of(room.players.begin(), room.players.end(),
|
||||
[](const RoomPlayer &p) { return p.ready; });
|
||||
if (!allReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
room.state = "playing";
|
||||
|
||||
auto game = std::make_unique<Game>(room, [this, room_id]() {
|
||||
auto rit = rooms_.find(room_id);
|
||||
if (rit != rooms_.end()) {
|
||||
rit->second.state = "waiting";
|
||||
for (auto &p : rit->second.players) {
|
||||
p.ready = false;
|
||||
}
|
||||
broadcastRoomUpdated(rit->second);
|
||||
}
|
||||
broadcastRoomsToLobby();
|
||||
// Мы всё ещё внутри вызова метода Game (tick/handleDisconnect), который
|
||||
// и вызвал этот колбэк — удалять объект прямо сейчас было бы use-after-free
|
||||
// на возврате из этого вызова. Откладываем erase на следующий тик луп'а.
|
||||
uWS::Loop::get()->defer([this, room_id]() { games_.erase(room_id); });
|
||||
});
|
||||
|
||||
json startPayload = game->buildStartPayload(room.mode);
|
||||
for (const auto &p : room.players) {
|
||||
send(p.ws, "game.start", startPayload);
|
||||
}
|
||||
game->start();
|
||||
games_[room_id] = std::move(game);
|
||||
|
||||
broadcastRoomsToLobby();
|
||||
}
|
||||
|
||||
void Lobby::endActiveGameIfAny(const std::string &room_id, WS *leavingWs) {
|
||||
if (room_id.empty()) {
|
||||
return;
|
||||
}
|
||||
auto it = games_.find(room_id);
|
||||
if (it == games_.end()) {
|
||||
return;
|
||||
}
|
||||
it->second->handleDisconnect(leavingWs);
|
||||
games_.erase(it);
|
||||
}
|
||||
|
||||
void Lobby::removePlayerFromRoom(WS *ws) {
|
||||
auto *data = ws->getUserData();
|
||||
if (data->room_id.empty()) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <App.h>
|
||||
#include <memory>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
@@ -18,13 +17,10 @@ struct PerSocketData {
|
||||
|
||||
using WS = uWS::WebSocket<false, true, PerSocketData>;
|
||||
|
||||
class Game;
|
||||
|
||||
struct RoomPlayer {
|
||||
std::string player_id;
|
||||
std::string nickname;
|
||||
WS *ws;
|
||||
bool ready = false;
|
||||
};
|
||||
|
||||
struct Room {
|
||||
@@ -37,21 +33,14 @@ struct Room {
|
||||
// Лобби и реестр комнат (этап 3 из TZ.md). Игровая логика — этапы 4-5.
|
||||
class Lobby {
|
||||
public:
|
||||
Lobby();
|
||||
~Lobby();
|
||||
|
||||
void onOpen(WS *ws);
|
||||
void onMessage(WS *ws, std::string_view message);
|
||||
void onClose(WS *ws);
|
||||
|
||||
private:
|
||||
static constexpr size_t kMaxPlayersPerRoom = 2;
|
||||
// Верхняя граница на общее число комнат — без неё клиент, создающий и не
|
||||
// покидающий комнаты, мог бы разогнать rooms_ до исчерпания памяти.
|
||||
static constexpr size_t kMaxRooms = 200;
|
||||
|
||||
std::unordered_map<std::string, Room> rooms_;
|
||||
std::unordered_map<std::string, std::unique_ptr<Game>> games_;
|
||||
std::unordered_set<WS *> sockets_;
|
||||
int next_player_id_ = 1;
|
||||
int next_room_id_ = 1;
|
||||
@@ -61,11 +50,7 @@ private:
|
||||
void handleCreateRoom(WS *ws, const json &payload);
|
||||
void handleJoinRoom(WS *ws, const json &payload);
|
||||
void handleLeaveRoom(WS *ws);
|
||||
void handleGameReady(WS *ws);
|
||||
void handleGameInput(WS *ws, const json &payload);
|
||||
void removePlayerFromRoom(WS *ws);
|
||||
void maybeStartGame(const std::string &room_id);
|
||||
void endActiveGameIfAny(const std::string &room_id, WS *leavingWs);
|
||||
|
||||
void broadcastRoomsToLobby();
|
||||
void broadcastRoomUpdated(const Room &room, WS *exclude = nullptr);
|
||||
|
||||
+1
-18
@@ -1,25 +1,11 @@
|
||||
# на IP: не более 5 новых /ws соединений в секунду, всплеск до 10 без задержки
|
||||
limit_req_zone $binary_remote_addr zone=ws_connect:10m rate=5r/s;
|
||||
# на IP: не более 20 одновременных /ws соединений — не даёт одному клиенту
|
||||
# открыть тысячи сокетов и завалить lobby.create_room комнатами до OOM
|
||||
limit_conn_zone $binary_remote_addr zone=ws_conn:10m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name cactoz.su www.cactoz.su;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# не светим точную версию nginx в заголовке Server и в error-страницах
|
||||
server_tokens off;
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://cactoz.su wss://www.cactoz.su; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; base-uri 'none'; frame-ancestors 'none'" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/ =404;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# без хешей в именах файлов — просим браузер всегда переспрашивать свежесть
|
||||
@@ -29,9 +15,6 @@ server {
|
||||
}
|
||||
|
||||
location /ws {
|
||||
limit_req zone=ws_connect burst=10 nodelay;
|
||||
limit_conn ws_conn 20;
|
||||
|
||||
proxy_pass http://gameserver:9001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
Reference in New Issue
Block a user