From bb194ad33fa6ff2342142d92f33a503f5881365a Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Wed, 12 Aug 2026 11:41:18 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=B2=D0=B5=20=D0=BE=D0=B4=D0=B8=D0=BD=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D1=8B:=20./blocks=20=D0=B8=20./invaders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тетрис- и Space Invaders-подобные аркады на чистом canvas/JS, без мультиплеера и без сервера — в отличие от ./tanks это классические одиночные игры. Названия сайта — generic (не «Тетрис»/«Space Invaders»), та же логика, что и с переименованием ./battlecity в ./tanks: обе франшизы — активно защищаемые товарные знаки (The Tetris Company, Taito). ./blocks — 10x20 поле, 7 фигур, вращение с простым wall-kick, ускорение по уровням, next-piece превью, hard drop. ./invaders — волны врагов, стрельба игрока и врагов, жизни, ускорение с каждой волной. Обе — клавиатура + сенсорные кнопки, RU/EN (не сбрасывает партию при переключении языка — в отличие от бага, который чинили в ./tanks). Кросс-ссылки между всеми тремя играми на каждой игровой странице, подвал whoami обновлён списком всех трёх. --- frontend/blocks.html | 41 ++++ frontend/css/arcade.css | 206 ++++++++++++++++++ frontend/css/game.css | 6 + frontend/game.html | 1 + frontend/index.html | 2 +- frontend/invaders.html | 41 ++++ frontend/js/blocks.js | 440 ++++++++++++++++++++++++++++++++++++++ frontend/js/game/lobby.js | 4 + frontend/js/invaders.js | 331 ++++++++++++++++++++++++++++ frontend/js/resume.js | 4 +- 10 files changed, 1073 insertions(+), 3 deletions(-) create mode 100644 frontend/blocks.html create mode 100644 frontend/css/arcade.css create mode 100644 frontend/invaders.html create mode 100644 frontend/js/blocks.js create mode 100644 frontend/js/invaders.js 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 + + + + + + + + + + + + +
+
+

./blocks

+
+
+
+ + + + + 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

+
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 @@
-

P.S. есть ещё ./tanks — мультиплеерная мини-игра, если будет пара свободных минут.

+

P.S. есть ещё пара мини-игр — ./tanks (мультиплеер), ./blocks и ./invaders, если будет пара свободных минут.

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 + + + + + + + + + + + + +
+
+

./invaders

+
+
+
+ + + + + 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().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().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.', }, };