Тетрис- и 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 обновлён списком всех трёх.
441 lines
12 KiB
JavaScript
441 lines
12 KiB
JavaScript
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">↑</button>
|
|
<button class="arcade-dpad__left" data-input="left">←</button>
|
|
<button class="arcade-dpad__right" data-input="right">→</button>
|
|
<button class="arcade-dpad__down" data-input="down">↓</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);
|