Compare commits
21
Commits
dc4305f840
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6746d89233 | ||
|
|
6ce29bb986 | ||
|
|
bb194ad33f | ||
|
|
cb25497bb8 | ||
|
|
3e14c8f381 | ||
|
|
abd57e1cdf | ||
|
|
9c7caf50a7 | ||
|
|
c9de8da955 | ||
|
|
5e99bc8c45 | ||
|
|
df24edd0ab | ||
|
|
fb18a6f289 | ||
|
|
01eec92d48 | ||
|
|
e1bfc2b5d7 | ||
|
|
0a47ac9234 | ||
|
|
de6f0438c5 | ||
|
|
01cefbbe77 | ||
|
|
7b48c01a93 | ||
|
|
d626a66ff7 | ||
|
|
128c5cf16e | ||
|
|
a1c7cfdc9b | ||
|
|
b5566ec73e |
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>./blocks — cacto</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/theme.css" />
|
||||||
|
<link rel="stylesheet" href="/css/arcade.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="matrix-rain"></canvas>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="site-header__inner">
|
||||||
|
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||||
|
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||||
|
cacto<span class="cursor">_</span>
|
||||||
|
</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">whoami</a></li>
|
||||||
|
<li><a href="/projects">ls projects/</a></li>
|
||||||
|
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<main>
|
||||||
|
<h2 class="prompt">./blocks</h2>
|
||||||
|
<div id="arcade-root"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/js/matrix-rain.js"></script>
|
||||||
|
<script type="module" src="/js/blocks.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/* --- общие стили для одиночных аркад (./blocks, ./invaders) --- */
|
||||||
|
|
||||||
|
.arcade-crosslinks {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-canvas-slot {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-canvas-slot canvas {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-silver);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-stats b {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-next-slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-next-slot span {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-next-slot canvas {
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-controls__hint {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-dpad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 44px);
|
||||||
|
grid-template-rows: repeat(2, 44px);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-dpad button {
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-dpad button:active {
|
||||||
|
background: var(--color-accent-dim);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-dpad__up { grid-column: 2; grid-row: 1; }
|
||||||
|
.arcade-dpad__left { grid-column: 1; grid-row: 2; }
|
||||||
|
.arcade-dpad__right { grid-column: 3; grid-row: 2; }
|
||||||
|
.arcade-dpad__down { grid-column: 2; grid-row: 2; }
|
||||||
|
|
||||||
|
/* --- пара кнопок влево/вправо (./invaders — вертикаль не нужна) --- */
|
||||||
|
.arcade-lr {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-lr button {
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
width: 62px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-lr button:active {
|
||||||
|
background: var(--color-accent-dim);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-action-btn {
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-action-btn:active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result-overlay {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(10, 14, 12, 0.92);
|
||||||
|
z-index: 5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result {
|
||||||
|
padding: 2rem;
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--color-bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result h2 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-shadow: 0 0 16px var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result p {
|
||||||
|
color: var(--color-fg);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result__actions button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-accent-dim);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-result__actions button:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 6px var(--color-accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.arcade-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcade-controls {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
.arcade-crosslinks {
|
||||||
|
color: var(--color-silver);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.lobby-panel {
|
.lobby-panel {
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
background: var(--color-bg-raised);
|
background: var(--color-bg-raised);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<main id="game-root">
|
<main id="game-root">
|
||||||
<h2 class="prompt">./tanks</h2>
|
<h2 class="prompt">./tanks</h2>
|
||||||
|
<p class="arcade-crosslinks"><span id="game-crosslinks-label"></span> <a href="/blocks">./blocks</a> · <a href="/invaders">./invaders</a></p>
|
||||||
<div id="lobby"></div>
|
<div id="lobby"></div>
|
||||||
<div id="tanks-canvas-container" style="display: none"></div>
|
<div id="tanks-canvas-container" style="display: none"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+1
-1
@@ -221,7 +221,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer id="site-footer" class="site-footer">
|
<footer id="site-footer" class="site-footer">
|
||||||
<p>P.S. есть ещё <a href="/game">./tanks</a> — мультиплеерная мини-игра, если будет пара свободных минут.</p>
|
<p>P.S. есть ещё пара мини-игр — <a href="/game">./tanks</a> (мультиплеер), <a href="/blocks">./blocks</a> и <a href="/invaders">./invaders</a>, если будет пара свободных минут.</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>./invaders — cacto</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/theme.css" />
|
||||||
|
<link rel="stylesheet" href="/css/arcade.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="matrix-rain"></canvas>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="site-header__inner">
|
||||||
|
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||||
|
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||||
|
cacto<span class="cursor">_</span>
|
||||||
|
</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">whoami</a></li>
|
||||||
|
<li><a href="/projects">ls projects/</a></li>
|
||||||
|
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<main>
|
||||||
|
<h2 class="prompt">./invaders</h2>
|
||||||
|
<div id="arcade-root"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/js/matrix-rain.js"></script>
|
||||||
|
<script type="module" src="/js/invaders.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { getLang, initLangToggle } from './i18n.js';
|
||||||
|
|
||||||
|
const COLS = 10;
|
||||||
|
const ROWS = 20;
|
||||||
|
const CELL = 24;
|
||||||
|
const GRAVITY_START_MS = 800;
|
||||||
|
const GRAVITY_MIN_MS = 100;
|
||||||
|
|
||||||
|
const SHAPES = {
|
||||||
|
I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
|
||||||
|
J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
|
||||||
|
L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
|
||||||
|
O: [[1, 1], [1, 1]],
|
||||||
|
S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
|
||||||
|
T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
|
||||||
|
Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
I: '#00ff41',
|
||||||
|
O: '#9fa3a0',
|
||||||
|
T: '#39e075',
|
||||||
|
S: '#7cffb2',
|
||||||
|
Z: '#1fae56',
|
||||||
|
J: '#c7c7c7',
|
||||||
|
L: '#0a8f2c',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PIECE_TYPES = Object.keys(SHAPES);
|
||||||
|
|
||||||
|
const STR = {
|
||||||
|
ru: {
|
||||||
|
hint: 'стрелки — двигать, вверх — вращать, пробел — сброс',
|
||||||
|
score: 'счёт',
|
||||||
|
lines: 'линии',
|
||||||
|
level: 'уровень',
|
||||||
|
next: 'дальше',
|
||||||
|
hardDrop: 'сброс',
|
||||||
|
gameOver: 'ИГРА ОКОНЧЕНА',
|
||||||
|
finalScore: (s) => `счёт: ${s}`,
|
||||||
|
again: 'ещё раз',
|
||||||
|
crosslinks: 'ещё игры:',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
hint: 'arrows to move, up to rotate, space to hard-drop',
|
||||||
|
score: 'score',
|
||||||
|
lines: 'lines',
|
||||||
|
level: 'level',
|
||||||
|
next: 'next',
|
||||||
|
hardDrop: 'drop',
|
||||||
|
gameOver: 'GAME OVER',
|
||||||
|
finalScore: (s) => `score: ${s}`,
|
||||||
|
again: 'play again',
|
||||||
|
crosslinks: 'more games:',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t() {
|
||||||
|
return STR[getLang()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyBoard() {
|
||||||
|
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateMatrix(matrix) {
|
||||||
|
const n = matrix.length;
|
||||||
|
const result = Array.from({ length: n }, () => Array(n).fill(0));
|
||||||
|
for (let y = 0; y < n; y++) {
|
||||||
|
for (let x = 0; x < n; x++) {
|
||||||
|
result[x][n - 1 - y] = matrix[y][x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomType() {
|
||||||
|
return PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlocksGame {
|
||||||
|
constructor(canvas, nextCanvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.nextCanvas = nextCanvas;
|
||||||
|
this.nextCtx = nextCanvas.getContext('2d');
|
||||||
|
canvas.width = COLS * CELL;
|
||||||
|
canvas.height = ROWS * CELL;
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.board = emptyBoard();
|
||||||
|
this.score = 0;
|
||||||
|
this.lines = 0;
|
||||||
|
this.level = 1;
|
||||||
|
this.over = false;
|
||||||
|
this.nextType = randomType();
|
||||||
|
this.spawn();
|
||||||
|
this.dropTimer = 0;
|
||||||
|
this.lastTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn() {
|
||||||
|
const type = this.nextType;
|
||||||
|
this.nextType = randomType();
|
||||||
|
const matrix = SHAPES[type].map((row) => row.slice());
|
||||||
|
this.piece = {
|
||||||
|
type,
|
||||||
|
matrix,
|
||||||
|
x: Math.floor((COLS - matrix.length) / 2),
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
if (this.collides(this.piece.matrix, this.piece.x, this.piece.y)) {
|
||||||
|
this.over = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collides(matrix, offX, offY) {
|
||||||
|
for (let y = 0; y < matrix.length; y++) {
|
||||||
|
for (let x = 0; x < matrix[y].length; x++) {
|
||||||
|
if (!matrix[y][x]) continue;
|
||||||
|
const bx = offX + x;
|
||||||
|
const by = offY + y;
|
||||||
|
if (bx < 0 || bx >= COLS || by >= ROWS) return true;
|
||||||
|
if (by >= 0 && this.board[by][bx]) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
move(dx, dy) {
|
||||||
|
if (this.over) return false;
|
||||||
|
const p = this.piece;
|
||||||
|
if (!this.collides(p.matrix, p.x + dx, p.y + dy)) {
|
||||||
|
p.x += dx;
|
||||||
|
p.y += dy;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotate() {
|
||||||
|
if (this.over) return;
|
||||||
|
const p = this.piece;
|
||||||
|
const rotated = rotateMatrix(p.matrix);
|
||||||
|
const kicks = [0, 1, -1, 2, -2];
|
||||||
|
for (const kick of kicks) {
|
||||||
|
if (!this.collides(rotated, p.x + kick, p.y)) {
|
||||||
|
p.matrix = rotated;
|
||||||
|
p.x += kick;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hardDrop() {
|
||||||
|
if (this.over) return;
|
||||||
|
while (this.move(0, 1)) {
|
||||||
|
this.score += 1;
|
||||||
|
}
|
||||||
|
this.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
lock() {
|
||||||
|
const p = this.piece;
|
||||||
|
for (let y = 0; y < p.matrix.length; y++) {
|
||||||
|
for (let x = 0; x < p.matrix[y].length; x++) {
|
||||||
|
if (p.matrix[y][x]) {
|
||||||
|
const by = p.y + y;
|
||||||
|
const bx = p.x + x;
|
||||||
|
if (by >= 0) this.board[by][bx] = p.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.clearLines();
|
||||||
|
this.spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLines() {
|
||||||
|
let cleared = 0;
|
||||||
|
this.board = this.board.filter((row) => {
|
||||||
|
const full = row.every((cell) => cell);
|
||||||
|
if (full) cleared++;
|
||||||
|
return !full;
|
||||||
|
});
|
||||||
|
while (this.board.length < ROWS) {
|
||||||
|
this.board.unshift(Array(COLS).fill(null));
|
||||||
|
}
|
||||||
|
if (cleared > 0) {
|
||||||
|
const points = [0, 100, 300, 500, 800][cleared] || 800;
|
||||||
|
this.score += points * this.level;
|
||||||
|
this.lines += cleared;
|
||||||
|
this.level = 1 + Math.floor(this.lines / 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gravityInterval() {
|
||||||
|
return Math.max(GRAVITY_MIN_MS, GRAVITY_START_MS - (this.level - 1) * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(dtMs) {
|
||||||
|
if (this.over) return;
|
||||||
|
this.dropTimer += dtMs;
|
||||||
|
const interval = this.gravityInterval();
|
||||||
|
if (this.dropTimer >= interval) {
|
||||||
|
this.dropTimer = 0;
|
||||||
|
if (!this.move(0, 1)) {
|
||||||
|
this.lock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCell(ctx, x, y, color) {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
|
||||||
|
ctx.strokeStyle = '#0a0e0c';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.fillStyle = '#0a0e0c';
|
||||||
|
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
for (let y = 0; y < ROWS; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
if (this.board[y][x]) {
|
||||||
|
this.drawCell(ctx, x, y, COLORS[this.board[y][x]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = this.piece;
|
||||||
|
if (p) {
|
||||||
|
for (let y = 0; y < p.matrix.length; y++) {
|
||||||
|
for (let x = 0; x < p.matrix[y].length; x++) {
|
||||||
|
if (p.matrix[y][x] && p.y + y >= 0) {
|
||||||
|
this.drawCell(ctx, p.x + x, p.y + y, COLORS[p.type]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// сетка поверх, лёгкая
|
||||||
|
ctx.strokeStyle = 'rgba(80, 255, 140, 0.06)';
|
||||||
|
for (let x = 1; x < COLS; x++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x * CELL, 0);
|
||||||
|
ctx.lineTo(x * CELL, this.canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 1; y < ROWS; y++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y * CELL);
|
||||||
|
ctx.lineTo(this.canvas.width, y * CELL);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawNext() {
|
||||||
|
const ctx = this.nextCtx;
|
||||||
|
const size = this.nextCanvas.width;
|
||||||
|
ctx.fillStyle = '#0f1512';
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
const matrix = SHAPES[this.nextType];
|
||||||
|
const cell = Math.floor(size / 4);
|
||||||
|
const offset = (4 - matrix.length) / 2;
|
||||||
|
for (let y = 0; y < matrix.length; y++) {
|
||||||
|
for (let x = 0; x < matrix[y].length; x++) {
|
||||||
|
if (matrix[y][x]) {
|
||||||
|
ctx.fillStyle = COLORS[this.nextType];
|
||||||
|
ctx.fillRect((x + offset) * cell + 1, (y + offset) * cell + 1, cell - 2, cell - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = null;
|
||||||
|
let rafId = null;
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
document.getElementById('arcade-score').textContent = game.score;
|
||||||
|
document.getElementById('arcade-lines').textContent = game.lines;
|
||||||
|
document.getElementById('arcade-level').textContent = game.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGameOver() {
|
||||||
|
const overlay = document.getElementById('arcade-result-overlay');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="arcade-result">
|
||||||
|
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
||||||
|
<p>${t().finalScore(game.score)}</p>
|
||||||
|
<div class="arcade-result__actions">
|
||||||
|
<button data-action="again">${t().again}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(time) {
|
||||||
|
if (game.lastTime === null) game.lastTime = time;
|
||||||
|
const dt = time - game.lastTime;
|
||||||
|
game.lastTime = time;
|
||||||
|
const wasOver = game.over;
|
||||||
|
game.tick(dt);
|
||||||
|
game.draw();
|
||||||
|
updateStats();
|
||||||
|
if (game.over && !wasOver) {
|
||||||
|
showGameOver();
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
const overlay = document.getElementById('arcade-result-overlay');
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
overlay.innerHTML = '';
|
||||||
|
game.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindKeyboard() {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (!game) return;
|
||||||
|
if (game.over) {
|
||||||
|
if (e.code === 'Space' || e.code === 'Enter') restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (e.code) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
game.move(-1, 0);
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
game.move(1, 0);
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
if (game.move(0, 1)) game.score += 1;
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
game.rotate();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'Space':
|
||||||
|
game.hardDrop();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTouchControls(root) {
|
||||||
|
const actions = {
|
||||||
|
left: () => game.move(-1, 0),
|
||||||
|
right: () => game.move(1, 0),
|
||||||
|
down: () => {
|
||||||
|
if (game.move(0, 1)) game.score += 1;
|
||||||
|
},
|
||||||
|
rotate: () => game.rotate(),
|
||||||
|
drop: () => game.hardDrop(),
|
||||||
|
};
|
||||||
|
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||||
|
const action = actions[btn.dataset.input];
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (game.over) return;
|
||||||
|
action();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShell(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/invaders">./invaders</a></p>
|
||||||
|
<div class="arcade-layout">
|
||||||
|
<div class="arcade-canvas-slot">
|
||||||
|
<canvas id="arcade-canvas"></canvas>
|
||||||
|
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arcade-controls">
|
||||||
|
<div class="arcade-stats">
|
||||||
|
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
||||||
|
<div><span id="arcade-lines-label">${t().lines.toUpperCase()}</span> <b id="arcade-lines">0</b></div>
|
||||||
|
<div><span id="arcade-level-label">${t().level.toUpperCase()}</span> <b id="arcade-level">1</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="arcade-next-slot">
|
||||||
|
<span id="arcade-next-label">${t().next}</span>
|
||||||
|
<canvas id="arcade-next" width="80" height="80"></canvas>
|
||||||
|
</div>
|
||||||
|
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
||||||
|
<div class="arcade-dpad">
|
||||||
|
<button class="arcade-dpad__up" data-input="rotate">↑</button>
|
||||||
|
<button class="arcade-dpad__left" data-input="left">←</button>
|
||||||
|
<button class="arcade-dpad__right" data-input="right">→</button>
|
||||||
|
<button class="arcade-dpad__down" data-input="down">↓</button>
|
||||||
|
</div>
|
||||||
|
<button class="arcade-action-btn" data-input="drop" id="arcade-drop-btn">${t().hardDrop}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляет статичные подписи без пересоздания игры — иначе переключение
|
||||||
|
// языка посреди партии сбрасывало бы прогресс, как уже было с ./tanks.
|
||||||
|
function updateStaticText() {
|
||||||
|
const byId = (id) => document.getElementById(id);
|
||||||
|
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
|
||||||
|
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
|
||||||
|
if (byId('arcade-lines-label')) byId('arcade-lines-label').textContent = t().lines.toUpperCase();
|
||||||
|
if (byId('arcade-level-label')) byId('arcade-level-label').textContent = t().level.toUpperCase();
|
||||||
|
if (byId('arcade-next-label')) byId('arcade-next-label').textContent = t().next;
|
||||||
|
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
|
||||||
|
if (byId('arcade-drop-btn')) byId('arcade-drop-btn').textContent = t().hardDrop;
|
||||||
|
if (game?.over) showGameOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const root = document.getElementById('arcade-root');
|
||||||
|
renderShell(root);
|
||||||
|
|
||||||
|
const canvas = document.getElementById('arcade-canvas');
|
||||||
|
const nextCanvas = document.getElementById('arcade-next');
|
||||||
|
game = new BlocksGame(canvas, nextCanvas);
|
||||||
|
|
||||||
|
bindTouchControls(root.querySelector('.arcade-controls'));
|
||||||
|
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-action="again"]')) restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindKeyboard();
|
||||||
|
init();
|
||||||
|
initLangToggle(updateStaticText);
|
||||||
@@ -23,6 +23,7 @@ const STR = {
|
|||||||
room: 'комната',
|
room: 'комната',
|
||||||
mode: 'режим:',
|
mode: 'режим:',
|
||||||
leave: 'выйти в лобби',
|
leave: 'выйти в лобби',
|
||||||
|
crosslinks: 'ещё игры:',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
connecting: 'connecting...',
|
connecting: 'connecting...',
|
||||||
@@ -38,6 +39,7 @@ const STR = {
|
|||||||
room: 'room',
|
room: 'room',
|
||||||
mode: 'mode:',
|
mode: 'mode:',
|
||||||
leave: 'leave to lobby',
|
leave: 'leave to lobby',
|
||||||
|
crosslinks: 'more games:',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,6 +129,8 @@ function renderRoomView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
const crosslinksLabel = document.getElementById('game-crosslinks-label');
|
||||||
|
if (crosslinksLabel) crosslinksLabel.textContent = t().crosslinks;
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
renderRoomView();
|
renderRoomView();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { getLang, initLangToggle } from './i18n.js';
|
||||||
|
|
||||||
|
const CANVAS_W = 420;
|
||||||
|
const CANVAS_H = 520;
|
||||||
|
const PLAYER_Y = CANVAS_H - 40;
|
||||||
|
const PLAYER_W = 28;
|
||||||
|
const PLAYER_H = 14;
|
||||||
|
const PLAYER_SPEED = 220; // px/s
|
||||||
|
const BULLET_SPEED = 380;
|
||||||
|
const ENEMY_BULLET_SPEED = 180;
|
||||||
|
const FIRE_COOLDOWN_MS = 350;
|
||||||
|
const ENEMY_COLS = 8;
|
||||||
|
const ENEMY_ROWS = 4;
|
||||||
|
const ENEMY_W = 28;
|
||||||
|
const ENEMY_H = 18;
|
||||||
|
const ENEMY_GAP_X = 12;
|
||||||
|
const ENEMY_GAP_Y = 16;
|
||||||
|
const ENEMY_TOP = 50;
|
||||||
|
const ENEMY_STEP_DOWN = 18;
|
||||||
|
|
||||||
|
const STR = {
|
||||||
|
ru: {
|
||||||
|
hint: 'стрелки — двигать, пробел — огонь',
|
||||||
|
score: 'счёт',
|
||||||
|
lives: 'жизни',
|
||||||
|
wave: 'волна',
|
||||||
|
gameOver: 'ИГРА ОКОНЧЕНА',
|
||||||
|
finalScore: (s) => `счёт: ${s}`,
|
||||||
|
again: 'ещё раз',
|
||||||
|
crosslinks: 'ещё игры:',
|
||||||
|
fire: 'огонь',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
hint: 'arrows to move, space to fire',
|
||||||
|
score: 'score',
|
||||||
|
lives: 'lives',
|
||||||
|
wave: 'wave',
|
||||||
|
gameOver: 'GAME OVER',
|
||||||
|
finalScore: (s) => `score: ${s}`,
|
||||||
|
again: 'play again',
|
||||||
|
crosslinks: 'more games:',
|
||||||
|
fire: 'fire',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function t() {
|
||||||
|
return STR[getLang()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectsOverlap(a, b) {
|
||||||
|
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvadersGame {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = CANVAS_W;
|
||||||
|
canvas.height = CANVAS_H;
|
||||||
|
this.input = { left: false, right: false, fire: false };
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.score = 0;
|
||||||
|
this.lives = 3;
|
||||||
|
this.wave = 1;
|
||||||
|
this.over = false;
|
||||||
|
this.player = { x: CANVAS_W / 2 - PLAYER_W / 2, y: PLAYER_Y };
|
||||||
|
this.bullets = [];
|
||||||
|
this.enemyBullets = [];
|
||||||
|
this.fireTimer = 0;
|
||||||
|
this.hitFlash = 0;
|
||||||
|
this.spawnWave();
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnWave() {
|
||||||
|
this.enemies = [];
|
||||||
|
const totalWidth = ENEMY_COLS * (ENEMY_W + ENEMY_GAP_X) - ENEMY_GAP_X;
|
||||||
|
const startX = (CANVAS_W - totalWidth) / 2;
|
||||||
|
for (let row = 0; row < ENEMY_ROWS; row++) {
|
||||||
|
for (let col = 0; col < ENEMY_COLS; col++) {
|
||||||
|
this.enemies.push({
|
||||||
|
x: startX + col * (ENEMY_W + ENEMY_GAP_X),
|
||||||
|
y: ENEMY_TOP + row * (ENEMY_H + ENEMY_GAP_Y),
|
||||||
|
w: ENEMY_W,
|
||||||
|
h: ENEMY_H,
|
||||||
|
alive: true,
|
||||||
|
points: (ENEMY_ROWS - row) * 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.enemyDir = 1;
|
||||||
|
this.enemySpeed = 30 + (this.wave - 1) * 12;
|
||||||
|
this.enemyShootTimer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aliveEnemies() {
|
||||||
|
return this.enemies.filter((e) => e.alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(dtMs) {
|
||||||
|
if (this.over) return;
|
||||||
|
const dt = dtMs / 1000;
|
||||||
|
|
||||||
|
if (this.input.left) this.player.x -= PLAYER_SPEED * dt;
|
||||||
|
if (this.input.right) this.player.x += PLAYER_SPEED * dt;
|
||||||
|
this.player.x = Math.max(0, Math.min(CANVAS_W - PLAYER_W, this.player.x));
|
||||||
|
|
||||||
|
this.fireTimer -= dtMs;
|
||||||
|
if (this.input.fire && this.fireTimer <= 0) {
|
||||||
|
this.bullets.push({ x: this.player.x + PLAYER_W / 2 - 2, y: this.player.y, w: 4, h: 10 });
|
||||||
|
this.fireTimer = FIRE_COOLDOWN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of this.bullets) b.y -= BULLET_SPEED * dt;
|
||||||
|
this.bullets = this.bullets.filter((b) => b.y + b.h > 0);
|
||||||
|
|
||||||
|
for (const b of this.enemyBullets) b.y += ENEMY_BULLET_SPEED * dt;
|
||||||
|
this.enemyBullets = this.enemyBullets.filter((b) => b.y < CANVAS_H);
|
||||||
|
|
||||||
|
const alive = this.aliveEnemies();
|
||||||
|
if (alive.length === 0) {
|
||||||
|
this.wave += 1;
|
||||||
|
this.spawnWave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hitEdge = false;
|
||||||
|
for (const e of alive) {
|
||||||
|
e.x += this.enemyDir * this.enemySpeed * dt;
|
||||||
|
if (e.x <= 0 || e.x + e.w >= CANVAS_W) hitEdge = true;
|
||||||
|
}
|
||||||
|
if (hitEdge) {
|
||||||
|
this.enemyDir *= -1;
|
||||||
|
for (const e of alive) {
|
||||||
|
e.y += ENEMY_STEP_DOWN;
|
||||||
|
if (e.y + e.h >= this.player.y) {
|
||||||
|
this.over = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enemyShootTimer -= dtMs;
|
||||||
|
if (this.enemyShootTimer <= 0 && alive.length > 0) {
|
||||||
|
const shooter = alive[Math.floor(Math.random() * alive.length)];
|
||||||
|
this.enemyBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
|
||||||
|
this.enemyShootTimer = Math.max(250, 900 - this.wave * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// пуля игрока vs враги
|
||||||
|
for (const bullet of this.bullets) {
|
||||||
|
for (const e of alive) {
|
||||||
|
if (!e.alive) continue;
|
||||||
|
if (rectsOverlap(bullet, e)) {
|
||||||
|
e.alive = false;
|
||||||
|
bullet.hit = true;
|
||||||
|
this.score += e.points;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.bullets = this.bullets.filter((b) => !b.hit);
|
||||||
|
|
||||||
|
// пуля врага vs игрок
|
||||||
|
const playerRect = { x: this.player.x, y: this.player.y, w: PLAYER_W, h: PLAYER_H };
|
||||||
|
for (const b of this.enemyBullets) {
|
||||||
|
if (rectsOverlap(b, playerRect)) {
|
||||||
|
b.hit = true;
|
||||||
|
this.lives -= 1;
|
||||||
|
this.hitFlash = 200;
|
||||||
|
if (this.lives <= 0) this.over = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.enemyBullets = this.enemyBullets.filter((b) => !b.hit);
|
||||||
|
|
||||||
|
if (this.hitFlash > 0) this.hitFlash -= dtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.fillStyle = '#0a0e0c';
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
|
||||||
|
for (const e of this.enemies) {
|
||||||
|
if (!e.alive) continue;
|
||||||
|
ctx.fillStyle = '#39e075';
|
||||||
|
ctx.fillRect(e.x, e.y, e.w, e.h);
|
||||||
|
ctx.fillStyle = '#0a0e0c';
|
||||||
|
ctx.fillRect(e.x + 4, e.y + 4, 4, 4);
|
||||||
|
ctx.fillRect(e.x + e.w - 8, e.y + 4, 4, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = this.hitFlash > 0 ? '#ffffff' : '#00ff41';
|
||||||
|
ctx.fillRect(this.player.x, this.player.y, PLAYER_W, PLAYER_H);
|
||||||
|
ctx.fillRect(this.player.x + PLAYER_W / 2 - 3, this.player.y - 6, 6, 6);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#c7c7c7';
|
||||||
|
for (const b of this.bullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||||
|
ctx.fillStyle = '#9fa3a0';
|
||||||
|
for (const b of this.enemyBullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = null;
|
||||||
|
let rafId = null;
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
document.getElementById('arcade-score').textContent = game.score;
|
||||||
|
document.getElementById('arcade-lives').textContent = game.lives;
|
||||||
|
document.getElementById('arcade-wave').textContent = game.wave;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGameOver() {
|
||||||
|
const overlay = document.getElementById('arcade-result-overlay');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="arcade-result">
|
||||||
|
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
||||||
|
<p>${t().finalScore(game.score)}</p>
|
||||||
|
<div class="arcade-result__actions">
|
||||||
|
<button data-action="again">${t().again}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(time) {
|
||||||
|
if (game.lastTime === undefined || game.lastTime === null) game.lastTime = time;
|
||||||
|
const dt = time - game.lastTime;
|
||||||
|
game.lastTime = time;
|
||||||
|
const wasOver = game.over;
|
||||||
|
game.tick(dt);
|
||||||
|
game.draw();
|
||||||
|
updateStats();
|
||||||
|
if (game.over && !wasOver) showGameOver();
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
const overlay = document.getElementById('arcade-result-overlay');
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
overlay.innerHTML = '';
|
||||||
|
game.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindKeyboard() {
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (!game) return;
|
||||||
|
if (game.over) {
|
||||||
|
if (e.code === 'Space' || e.code === 'Enter') restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.code === 'ArrowLeft') { game.input.left = true; e.preventDefault(); }
|
||||||
|
if (e.code === 'ArrowRight') { game.input.right = true; e.preventDefault(); }
|
||||||
|
if (e.code === 'Space') { game.input.fire = true; e.preventDefault(); }
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', (e) => {
|
||||||
|
if (!game) return;
|
||||||
|
if (e.code === 'ArrowLeft') game.input.left = false;
|
||||||
|
if (e.code === 'ArrowRight') game.input.right = false;
|
||||||
|
if (e.code === 'Space') game.input.fire = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTouchControls(root) {
|
||||||
|
const press = (key, value) => {
|
||||||
|
if (game) game.input[key] = value;
|
||||||
|
};
|
||||||
|
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||||
|
const key = btn.dataset.input;
|
||||||
|
btn.addEventListener('mousedown', () => press(key, true));
|
||||||
|
btn.addEventListener('mouseup', () => press(key, false));
|
||||||
|
btn.addEventListener('mouseleave', () => press(key, false));
|
||||||
|
btn.addEventListener('touchstart', (e) => { e.preventDefault(); press(key, true); });
|
||||||
|
btn.addEventListener('touchend', (e) => { e.preventDefault(); press(key, false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShell(root) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/blocks">./blocks</a></p>
|
||||||
|
<div class="arcade-layout">
|
||||||
|
<div class="arcade-canvas-slot">
|
||||||
|
<canvas id="arcade-canvas"></canvas>
|
||||||
|
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arcade-controls">
|
||||||
|
<div class="arcade-stats">
|
||||||
|
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
||||||
|
<div><span id="arcade-lives-label">${t().lives.toUpperCase()}</span> <b id="arcade-lives">3</b></div>
|
||||||
|
<div><span id="arcade-wave-label">${t().wave.toUpperCase()}</span> <b id="arcade-wave">1</b></div>
|
||||||
|
</div>
|
||||||
|
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
||||||
|
<div class="arcade-lr">
|
||||||
|
<button data-input="left">←</button>
|
||||||
|
<button data-input="right">→</button>
|
||||||
|
</div>
|
||||||
|
<button class="arcade-action-btn" data-input="fire" id="arcade-fire-btn">${t().fire}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStaticText() {
|
||||||
|
const byId = (id) => document.getElementById(id);
|
||||||
|
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
|
||||||
|
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
|
||||||
|
if (byId('arcade-lives-label')) byId('arcade-lives-label').textContent = t().lives.toUpperCase();
|
||||||
|
if (byId('arcade-wave-label')) byId('arcade-wave-label').textContent = t().wave.toUpperCase();
|
||||||
|
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
|
||||||
|
if (byId('arcade-fire-btn')) byId('arcade-fire-btn').textContent = t().fire;
|
||||||
|
if (game?.over) showGameOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const root = document.getElementById('arcade-root');
|
||||||
|
renderShell(root);
|
||||||
|
|
||||||
|
const canvas = document.getElementById('arcade-canvas');
|
||||||
|
game = new InvadersGame(canvas);
|
||||||
|
|
||||||
|
bindTouchControls(root.querySelector('.arcade-controls'));
|
||||||
|
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-action="again"]')) restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindKeyboard();
|
||||||
|
init();
|
||||||
|
initLangToggle(updateStaticText);
|
||||||
@@ -13,6 +13,14 @@ const DATA = {
|
|||||||
url: 'https://git.cactoz.su/cacto/home_automatization',
|
url: 'https://git.cactoz.su/cacto/home_automatization',
|
||||||
linkLabel: 'открыть',
|
linkLabel: 'открыть',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'home_automatization_controllers',
|
||||||
|
description:
|
||||||
|
'Прошивка ESP32 для зоны «Гроубокс»: датчик DHT22 и три реле-актуатора (свет, полив, вентиляция), общается с платформой по MQTT.',
|
||||||
|
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
|
||||||
|
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
|
||||||
|
linkLabel: 'открыть',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'cactoz.su',
|
name: 'cactoz.su',
|
||||||
description:
|
description:
|
||||||
@@ -35,6 +43,14 @@ const DATA = {
|
|||||||
url: 'https://git.cactoz.su/cacto/home_automatization',
|
url: 'https://git.cactoz.su/cacto/home_automatization',
|
||||||
linkLabel: 'open',
|
linkLabel: 'open',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'home_automatization_controllers',
|
||||||
|
description:
|
||||||
|
'ESP32 firmware for the grow-box zone: a DHT22 sensor and three relay actuators (light, watering, ventilation), talking to the platform over MQTT.',
|
||||||
|
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
|
||||||
|
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
|
||||||
|
linkLabel: 'open',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'cactoz.su',
|
name: 'cactoz.su',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const DATA = {
|
|||||||
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
||||||
resumeLabel: 'скачать резюме (PDF)',
|
resumeLabel: 'скачать резюме (PDF)',
|
||||||
giteaLabel: 'git.cactoz.su/cacto',
|
giteaLabel: 'git.cactoz.su/cacto',
|
||||||
footerHtml: 'P.S. есть ещё <a href="/game">./tanks</a> — мультиплеерная мини-игра, если будет пара свободных минут.',
|
footerHtml: 'P.S. есть ещё пара мини-игр — <a href="/game">./tanks</a> (мультиплеер), <a href="/blocks">./blocks</a> и <a href="/invaders">./invaders</a>, если будет пара свободных минут.',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
name: 'Dmitry Gammel',
|
name: 'Dmitry Gammel',
|
||||||
@@ -163,7 +163,7 @@ const DATA = {
|
|||||||
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
||||||
resumeLabel: 'download résumé (PDF)',
|
resumeLabel: 'download résumé (PDF)',
|
||||||
giteaLabel: 'git.cactoz.su/cacto',
|
giteaLabel: 'git.cactoz.su/cacto',
|
||||||
footerHtml: 'P.S. there\'s also <a href="/game">./tanks</a> — a small multiplayer game, if you have a couple of minutes.',
|
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.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,20 @@
|
|||||||
<li class="tag">MQTT</li>
|
<li class="tag">MQTT</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</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">
|
<li class="project-card">
|
||||||
<div class="project-card__header">
|
<div class="project-card__header">
|
||||||
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> cactoz.su</div>
|
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> cactoz.su</div>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# не светим точную версию nginx в заголовке Server и в error-страницах
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
|||||||
Reference in New Issue
Block a user