Добавить две одиночные мини-игры: ./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 обновлён списком всех трёх.
This commit is contained in:
2026-08-12 11:41:18 +05:00
parent 3e14c8f381
commit bb194ad33f
10 changed files with 1073 additions and 3 deletions
+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);