Compare commits

..
2 Commits
Author SHA1 Message Date
cacto 6ce29bb986 Merge feature/arcade-games в dev 2026-08-12 11:42:57 +05:00
cacto bb194ad33f Добавить две одиночные мини-игры: ./blocks и ./invaders
Тетрис- и 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 обновлён списком всех трёх.
2026-08-12 11:41:18 +05:00
10 changed files with 1073 additions and 3 deletions
+41
View File
@@ -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>
+206
View File
@@ -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;
}
}
+6
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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>
+41
View File
@@ -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>
+440
View File
@@ -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">&uarr;</button>
<button class="arcade-dpad__left" data-input="left">&larr;</button>
<button class="arcade-dpad__right" data-input="right">&rarr;</button>
<button class="arcade-dpad__down" data-input="down">&darr;</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);
+4
View File
@@ -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 {
+331
View File
@@ -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">&larr;</button>
<button data-input="right">&rarr;</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);
+2 -2
View File
@@ -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.',
}, },
}; };