Files
cactoz.su/frontend/js/blocks.js
T
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

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">&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);