diff --git a/frontend/blocks.html b/frontend/blocks.html
new file mode 100644
index 0000000..0cb894c
--- /dev/null
+++ b/frontend/blocks.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ ./blocks — cacto
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/css/arcade.css b/frontend/css/arcade.css
new file mode 100644
index 0000000..d429fb2
--- /dev/null
+++ b/frontend/css/arcade.css
@@ -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;
+ }
+}
diff --git a/frontend/css/game.css b/frontend/css/game.css
index 6e43c75..9625807 100644
--- a/frontend/css/game.css
+++ b/frontend/css/game.css
@@ -1,3 +1,9 @@
+.arcade-crosslinks {
+ color: var(--color-silver);
+ font-size: 0.85rem;
+ margin: 0 0 1rem;
+}
+
.lobby-panel {
border: var(--border);
background: var(--color-bg-raised);
diff --git a/frontend/game.html b/frontend/game.html
index b265145..ad5fb35 100644
--- a/frontend/game.html
+++ b/frontend/game.html
@@ -31,6 +31,7 @@
./tanks
+ ./blocks · ./invaders
diff --git a/frontend/index.html b/frontend/index.html
index d18f4f4..75298e7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -221,7 +221,7 @@
diff --git a/frontend/invaders.html b/frontend/invaders.html
new file mode 100644
index 0000000..9e88467
--- /dev/null
+++ b/frontend/invaders.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ ./invaders — cacto
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/js/blocks.js b/frontend/js/blocks.js
new file mode 100644
index 0000000..73f371a
--- /dev/null
+++ b/frontend/js/blocks.js
@@ -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 = `
+
+
${t().gameOver}
+
${t().finalScore(game.score)}
+
+
+
+
`;
+ 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 = `
+ ${t().crosslinks} ./tanks · ./invaders
+
+
+
+
+
${t().score.toUpperCase()} 0
+
${t().lines.toUpperCase()} 0
+
${t().level.toUpperCase()} 1
+
+
+ ${t().next}
+
+
+
${t().hint}
+
+
+
+
+
+
+
+
+
`;
+}
+
+// Обновляет статичные подписи без пересоздания игры — иначе переключение
+// языка посреди партии сбрасывало бы прогресс, как уже было с ./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);
diff --git a/frontend/js/game/lobby.js b/frontend/js/game/lobby.js
index e768388..93e6eec 100644
--- a/frontend/js/game/lobby.js
+++ b/frontend/js/game/lobby.js
@@ -23,6 +23,7 @@ const STR = {
room: 'комната',
mode: 'режим:',
leave: 'выйти в лобби',
+ crosslinks: 'ещё игры:',
},
en: {
connecting: 'connecting...',
@@ -38,6 +39,7 @@ const STR = {
room: 'room',
mode: 'mode:',
leave: 'leave to lobby',
+ crosslinks: 'more games:',
},
};
@@ -127,6 +129,8 @@ function renderRoomView() {
}
function render() {
+ const crosslinksLabel = document.getElementById('game-crosslinks-label');
+ if (crosslinksLabel) crosslinksLabel.textContent = t().crosslinks;
if (currentRoom) {
renderRoomView();
} else {
diff --git a/frontend/js/invaders.js b/frontend/js/invaders.js
new file mode 100644
index 0000000..cb358b8
--- /dev/null
+++ b/frontend/js/invaders.js
@@ -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 = `
+
+
${t().gameOver}
+
${t().finalScore(game.score)}
+
+
+
+
`;
+ 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 = `
+ ${t().crosslinks} ./tanks · ./blocks
+
+
+
+
+
${t().score.toUpperCase()} 0
+
${t().lives.toUpperCase()} 3
+
${t().wave.toUpperCase()} 1
+
+
${t().hint}
+
+
+
+
+
+
+
`;
+}
+
+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);
diff --git a/frontend/js/resume.js b/frontend/js/resume.js
index 415d82a..7d4ef8a 100644
--- a/frontend/js/resume.js
+++ b/frontend/js/resume.js
@@ -81,7 +81,7 @@ const DATA = {
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
resumeLabel: 'скачать резюме (PDF)',
giteaLabel: 'git.cactoz.su/cacto',
- footerHtml: 'P.S. есть ещё ./tanks — мультиплеерная мини-игра, если будет пара свободных минут.',
+ footerHtml: 'P.S. есть ещё пара мини-игр — ./tanks (мультиплеер), ./blocks и ./invaders, если будет пара свободных минут.',
},
en: {
name: 'Dmitry Gammel',
@@ -163,7 +163,7 @@ const DATA = {
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\'s also ./tanks — a small multiplayer game, if you have a couple of minutes.',
+ footerHtml: 'P.S. there are also a couple of mini-games — ./tanks (multiplayer), ./blocks and ./invaders, if you have a couple of minutes.',
},
};