Тетрис- и 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 обновлён списком всех трёх.
332 lines
10 KiB
JavaScript
332 lines
10 KiB
JavaScript
import { getLang, initLangToggle } from './i18n.js';
|
|
|
|
const CANVAS_W = 420;
|
|
const CANVAS_H = 520;
|
|
const PLAYER_Y = CANVAS_H - 40;
|
|
const PLAYER_W = 28;
|
|
const PLAYER_H = 14;
|
|
const PLAYER_SPEED = 220; // px/s
|
|
const BULLET_SPEED = 380;
|
|
const ENEMY_BULLET_SPEED = 180;
|
|
const FIRE_COOLDOWN_MS = 350;
|
|
const ENEMY_COLS = 8;
|
|
const ENEMY_ROWS = 4;
|
|
const ENEMY_W = 28;
|
|
const ENEMY_H = 18;
|
|
const ENEMY_GAP_X = 12;
|
|
const ENEMY_GAP_Y = 16;
|
|
const ENEMY_TOP = 50;
|
|
const ENEMY_STEP_DOWN = 18;
|
|
|
|
const STR = {
|
|
ru: {
|
|
hint: 'стрелки — двигать, пробел — огонь',
|
|
score: 'счёт',
|
|
lives: 'жизни',
|
|
wave: 'волна',
|
|
gameOver: 'ИГРА ОКОНЧЕНА',
|
|
finalScore: (s) => `счёт: ${s}`,
|
|
again: 'ещё раз',
|
|
crosslinks: 'ещё игры:',
|
|
fire: 'огонь',
|
|
},
|
|
en: {
|
|
hint: 'arrows to move, space to fire',
|
|
score: 'score',
|
|
lives: 'lives',
|
|
wave: 'wave',
|
|
gameOver: 'GAME OVER',
|
|
finalScore: (s) => `score: ${s}`,
|
|
again: 'play again',
|
|
crosslinks: 'more games:',
|
|
fire: 'fire',
|
|
},
|
|
};
|
|
|
|
function t() {
|
|
return STR[getLang()];
|
|
}
|
|
|
|
function rectsOverlap(a, b) {
|
|
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
}
|
|
|
|
class InvadersGame {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.ctx = canvas.getContext('2d');
|
|
canvas.width = CANVAS_W;
|
|
canvas.height = CANVAS_H;
|
|
this.input = { left: false, right: false, fire: false };
|
|
this.reset();
|
|
}
|
|
|
|
reset() {
|
|
this.score = 0;
|
|
this.lives = 3;
|
|
this.wave = 1;
|
|
this.over = false;
|
|
this.player = { x: CANVAS_W / 2 - PLAYER_W / 2, y: PLAYER_Y };
|
|
this.bullets = [];
|
|
this.enemyBullets = [];
|
|
this.fireTimer = 0;
|
|
this.hitFlash = 0;
|
|
this.spawnWave();
|
|
}
|
|
|
|
spawnWave() {
|
|
this.enemies = [];
|
|
const totalWidth = ENEMY_COLS * (ENEMY_W + ENEMY_GAP_X) - ENEMY_GAP_X;
|
|
const startX = (CANVAS_W - totalWidth) / 2;
|
|
for (let row = 0; row < ENEMY_ROWS; row++) {
|
|
for (let col = 0; col < ENEMY_COLS; col++) {
|
|
this.enemies.push({
|
|
x: startX + col * (ENEMY_W + ENEMY_GAP_X),
|
|
y: ENEMY_TOP + row * (ENEMY_H + ENEMY_GAP_Y),
|
|
w: ENEMY_W,
|
|
h: ENEMY_H,
|
|
alive: true,
|
|
points: (ENEMY_ROWS - row) * 10,
|
|
});
|
|
}
|
|
}
|
|
this.enemyDir = 1;
|
|
this.enemySpeed = 30 + (this.wave - 1) * 12;
|
|
this.enemyShootTimer = 0;
|
|
}
|
|
|
|
aliveEnemies() {
|
|
return this.enemies.filter((e) => e.alive);
|
|
}
|
|
|
|
tick(dtMs) {
|
|
if (this.over) return;
|
|
const dt = dtMs / 1000;
|
|
|
|
if (this.input.left) this.player.x -= PLAYER_SPEED * dt;
|
|
if (this.input.right) this.player.x += PLAYER_SPEED * dt;
|
|
this.player.x = Math.max(0, Math.min(CANVAS_W - PLAYER_W, this.player.x));
|
|
|
|
this.fireTimer -= dtMs;
|
|
if (this.input.fire && this.fireTimer <= 0) {
|
|
this.bullets.push({ x: this.player.x + PLAYER_W / 2 - 2, y: this.player.y, w: 4, h: 10 });
|
|
this.fireTimer = FIRE_COOLDOWN_MS;
|
|
}
|
|
|
|
for (const b of this.bullets) b.y -= BULLET_SPEED * dt;
|
|
this.bullets = this.bullets.filter((b) => b.y + b.h > 0);
|
|
|
|
for (const b of this.enemyBullets) b.y += ENEMY_BULLET_SPEED * dt;
|
|
this.enemyBullets = this.enemyBullets.filter((b) => b.y < CANVAS_H);
|
|
|
|
const alive = this.aliveEnemies();
|
|
if (alive.length === 0) {
|
|
this.wave += 1;
|
|
this.spawnWave();
|
|
return;
|
|
}
|
|
|
|
let hitEdge = false;
|
|
for (const e of alive) {
|
|
e.x += this.enemyDir * this.enemySpeed * dt;
|
|
if (e.x <= 0 || e.x + e.w >= CANVAS_W) hitEdge = true;
|
|
}
|
|
if (hitEdge) {
|
|
this.enemyDir *= -1;
|
|
for (const e of alive) {
|
|
e.y += ENEMY_STEP_DOWN;
|
|
if (e.y + e.h >= this.player.y) {
|
|
this.over = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.enemyShootTimer -= dtMs;
|
|
if (this.enemyShootTimer <= 0 && alive.length > 0) {
|
|
const shooter = alive[Math.floor(Math.random() * alive.length)];
|
|
this.enemyBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
|
|
this.enemyShootTimer = Math.max(250, 900 - this.wave * 60);
|
|
}
|
|
|
|
// пуля игрока vs враги
|
|
for (const bullet of this.bullets) {
|
|
for (const e of alive) {
|
|
if (!e.alive) continue;
|
|
if (rectsOverlap(bullet, e)) {
|
|
e.alive = false;
|
|
bullet.hit = true;
|
|
this.score += e.points;
|
|
}
|
|
}
|
|
}
|
|
this.bullets = this.bullets.filter((b) => !b.hit);
|
|
|
|
// пуля врага vs игрок
|
|
const playerRect = { x: this.player.x, y: this.player.y, w: PLAYER_W, h: PLAYER_H };
|
|
for (const b of this.enemyBullets) {
|
|
if (rectsOverlap(b, playerRect)) {
|
|
b.hit = true;
|
|
this.lives -= 1;
|
|
this.hitFlash = 200;
|
|
if (this.lives <= 0) this.over = true;
|
|
}
|
|
}
|
|
this.enemyBullets = this.enemyBullets.filter((b) => !b.hit);
|
|
|
|
if (this.hitFlash > 0) this.hitFlash -= dtMs;
|
|
}
|
|
|
|
draw() {
|
|
const ctx = this.ctx;
|
|
ctx.fillStyle = '#0a0e0c';
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
for (const e of this.enemies) {
|
|
if (!e.alive) continue;
|
|
ctx.fillStyle = '#39e075';
|
|
ctx.fillRect(e.x, e.y, e.w, e.h);
|
|
ctx.fillStyle = '#0a0e0c';
|
|
ctx.fillRect(e.x + 4, e.y + 4, 4, 4);
|
|
ctx.fillRect(e.x + e.w - 8, e.y + 4, 4, 4);
|
|
}
|
|
|
|
ctx.fillStyle = this.hitFlash > 0 ? '#ffffff' : '#00ff41';
|
|
ctx.fillRect(this.player.x, this.player.y, PLAYER_W, PLAYER_H);
|
|
ctx.fillRect(this.player.x + PLAYER_W / 2 - 3, this.player.y - 6, 6, 6);
|
|
|
|
ctx.fillStyle = '#c7c7c7';
|
|
for (const b of this.bullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
|
ctx.fillStyle = '#9fa3a0';
|
|
for (const b of this.enemyBullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
|
}
|
|
}
|
|
|
|
let game = null;
|
|
let rafId = null;
|
|
|
|
function updateStats() {
|
|
document.getElementById('arcade-score').textContent = game.score;
|
|
document.getElementById('arcade-lives').textContent = game.lives;
|
|
document.getElementById('arcade-wave').textContent = game.wave;
|
|
}
|
|
|
|
function showGameOver() {
|
|
const overlay = document.getElementById('arcade-result-overlay');
|
|
overlay.innerHTML = `
|
|
<div class="arcade-result">
|
|
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
|
<p>${t().finalScore(game.score)}</p>
|
|
<div class="arcade-result__actions">
|
|
<button data-action="again">${t().again}</button>
|
|
</div>
|
|
</div>`;
|
|
overlay.style.display = 'flex';
|
|
}
|
|
|
|
function loop(time) {
|
|
if (game.lastTime === undefined || game.lastTime === null) game.lastTime = time;
|
|
const dt = time - game.lastTime;
|
|
game.lastTime = time;
|
|
const wasOver = game.over;
|
|
game.tick(dt);
|
|
game.draw();
|
|
updateStats();
|
|
if (game.over && !wasOver) showGameOver();
|
|
rafId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
function restart() {
|
|
const overlay = document.getElementById('arcade-result-overlay');
|
|
overlay.style.display = 'none';
|
|
overlay.innerHTML = '';
|
|
game.reset();
|
|
}
|
|
|
|
function bindKeyboard() {
|
|
window.addEventListener('keydown', (e) => {
|
|
if (!game) return;
|
|
if (game.over) {
|
|
if (e.code === 'Space' || e.code === 'Enter') restart();
|
|
return;
|
|
}
|
|
if (e.code === 'ArrowLeft') { game.input.left = true; e.preventDefault(); }
|
|
if (e.code === 'ArrowRight') { game.input.right = true; e.preventDefault(); }
|
|
if (e.code === 'Space') { game.input.fire = true; e.preventDefault(); }
|
|
});
|
|
window.addEventListener('keyup', (e) => {
|
|
if (!game) return;
|
|
if (e.code === 'ArrowLeft') game.input.left = false;
|
|
if (e.code === 'ArrowRight') game.input.right = false;
|
|
if (e.code === 'Space') game.input.fire = false;
|
|
});
|
|
}
|
|
|
|
function bindTouchControls(root) {
|
|
const press = (key, value) => {
|
|
if (game) game.input[key] = value;
|
|
};
|
|
root.querySelectorAll('[data-input]').forEach((btn) => {
|
|
const key = btn.dataset.input;
|
|
btn.addEventListener('mousedown', () => press(key, true));
|
|
btn.addEventListener('mouseup', () => press(key, false));
|
|
btn.addEventListener('mouseleave', () => press(key, false));
|
|
btn.addEventListener('touchstart', (e) => { e.preventDefault(); press(key, true); });
|
|
btn.addEventListener('touchend', (e) => { e.preventDefault(); press(key, false); });
|
|
});
|
|
}
|
|
|
|
function renderShell(root) {
|
|
root.innerHTML = `
|
|
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/blocks">./blocks</a></p>
|
|
<div class="arcade-layout">
|
|
<div class="arcade-canvas-slot">
|
|
<canvas id="arcade-canvas"></canvas>
|
|
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
|
</div>
|
|
<div class="arcade-controls">
|
|
<div class="arcade-stats">
|
|
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
|
<div><span id="arcade-lives-label">${t().lives.toUpperCase()}</span> <b id="arcade-lives">3</b></div>
|
|
<div><span id="arcade-wave-label">${t().wave.toUpperCase()}</span> <b id="arcade-wave">1</b></div>
|
|
</div>
|
|
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
|
<div class="arcade-lr">
|
|
<button data-input="left">←</button>
|
|
<button data-input="right">→</button>
|
|
</div>
|
|
<button class="arcade-action-btn" data-input="fire" id="arcade-fire-btn">${t().fire}</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function updateStaticText() {
|
|
const byId = (id) => document.getElementById(id);
|
|
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
|
|
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
|
|
if (byId('arcade-lives-label')) byId('arcade-lives-label').textContent = t().lives.toUpperCase();
|
|
if (byId('arcade-wave-label')) byId('arcade-wave-label').textContent = t().wave.toUpperCase();
|
|
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
|
|
if (byId('arcade-fire-btn')) byId('arcade-fire-btn').textContent = t().fire;
|
|
if (game?.over) showGameOver();
|
|
}
|
|
|
|
function init() {
|
|
const root = document.getElementById('arcade-root');
|
|
renderShell(root);
|
|
|
|
const canvas = document.getElementById('arcade-canvas');
|
|
game = new InvadersGame(canvas);
|
|
|
|
bindTouchControls(root.querySelector('.arcade-controls'));
|
|
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
|
|
if (e.target.closest('[data-action="again"]')) restart();
|
|
});
|
|
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
rafId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
bindKeyboard();
|
|
init();
|
|
initLangToggle(updateStaticText);
|