Вчера я остро ощутил, что очень давно не писал ничего простого, но функционального и интересного. А ещё я тут всё про игры да про игры, поэтому решил: пора!
Последнее, что я писал подобного – это змейку, которую можно было наблюдать во время обновлений сайта раньше (помните её?).
Да и ту на половину, а то и на две трети написал за меня чатГПТ. Поэтому я решил, что возьму нового подопытного и реализую всё сам. Мозг не должен забывать, как делать штуки...
В качестве основы решил взять HTML5 и ванильный JS, как технологию с самым дешёвым вариантом рендера, да и повторить, при желании, этот же алгоритм можно на любом другом движке.
Начнём с простого: нам понадобится текстовый редактор любой. Хорошо, если с подсветкой синтаксиса. Для самого простого можно взять Notepad++, для случаев чуть серьёзнее Атом или ВС код.
Создаём html страничку, она будет нашей точкой входа, рядом с ней создаём файлик стилей и основной наш скриптец. Забегая вперёд: стили нам не то, чтобы прям сильно нужны.
Дожидаемся, пока вся страница загрузится, потом инициируем игру
Пока что к самому алгоритму игры мы не приступили, это всё предварительные ласки.
В первую очередь давайте посмотрим на класс GameField.
class GameField {
constructor(width, height) {
this.width = width;
this.height = height;
this.blocks = [];
for (let i = 0; i < this.height; i++) {
let line = [];
for (let j = 0; j < this.width; j++) {
line.push(0);
}
this.blocks.push(line); //Заполняем массив ячеек поля нулями. поле по умолчанию пустое
}
}
checkLines = async () => { // тут мы проверяем, а есть ли линии, которые надо сбросить
for (let y = this.height - 1; y >= 0; y--) {
if (this.currentLineIsFill(this.blocks[y])) {
currentScore++; //плюсуем очки
for (let x = 0; x < this.width; x++) {
this.blocks[y][x] = 0;
redraw();
await sleep(LINE_CLEAR_ANIMATION_DELAY); // это просто для красивой анимации исчезновения
}
}
}
currentScore = getComboPoints(currentScore); // этот метод отдельно, там считаем прирос за комбо
}
currentLineIsEmpty = (line) => {
for (let x = 0; x < line.length; x++) {
if (line[x] === 1) { // если хоть одна ячейка заполнена, то идёт нахер
return false;
}
}
return true;
};
currentLineIsFill = (line) => {
for (let x = 0; x < line.length; x++) {
if (line[x] === 0) { // если хоть одна ячейка пустая, строка идёт нахер
return false;
}
}
return true;
};
moveLines = () => { // сдвигаем после удаления
let notEmptyLines = [];
for (let y = 0; y < this.blocks.length; y++) {
if (!this.currentLineIsEmpty(this.blocks[y])) {
notEmptyLines.push([...this.blocks[y]]); // сначала заполняем массив непустымы строками
}
}
let emptyLine = [];
for (let x = 0; x < this.width; x++) {
emptyLine.push(0); // потом досоздаём массив пустых строк
}
let newLines = []; // запиххиваем их в новый массив
for (let x = 0; x < this.height - notEmptyLines.length; x++) {
newLines.push([...emptyLine]);
}
for (let x = 0; x < notEmptyLines.length; x++) {
newLines.push([...notEmptyLines[x]]);
}
this.blocks = newLines;
}
}
Когда же мы будем всё это дёргать? И почему?
В методе, который описывает игровой цикл. Я постарался программировать так, чтоб даже при современном подходе можно было бы реализовать близкий по сути алгоритм на реальном камне, где, как известно многопоточность лишь выдуманная.
Раз в какое-то количество миллисекунд мы вызываем геймЛуп. «Почему же не сетинтервал», – спросит неокрепших неофит. А всё по тому, что это чревато как раз внезапной неконтролируемой асинхронностью, которая приведёт к гонке за ресурсы и будут беды. Поэтому метод будет вызывать сам себя только после того, как точно закончит.
const gameLoop = async () => {
if (gameMode !== GameMode.PLAYING || isLoopRunning) {
return;
}
isLoopRunning = true;
if (currentFigure === null || typeof currentFigure === 'undefined') { // у нас нет фигуры? Так давай её сделаем из того, что стоит в очереди
currentFigure = new Figure();
currentFigure.cells = nextFigure.cells.map(row => [...row]);
currentFigure.type = nextFigure.type;
currentFigure.states = nextFigure.states.map(state => state.map(row => [...row]));
currentFigure.rotationState = nextFigure.rotationState;
currentFigure.setStartPosition();
nextFigure.fillRandom(); // следующую фигуру херакнули в какую-нибудь новую рандомную
redrawNextFigure();
}
currentFigure.moveDown(); //уронили я одну клеточку
if (currentFigure.checkCollision()) {//пересеклисьс чем-нибудь?
currentFigure.moveUp(); //подняли обратно и зафиксировали с мировом пространстве
const overflow = currentFigure.placeToField();
currentFigure = null;
if (overflow) {
finishGame('top_out');
redraw();
isLoopRunning = false;
return;
}
await field.checkLines(); // все фигуры на своих местах, можно проверить, как там у нас дела
score += currentScore;
scoreEl.innerText = score;
currentScore = 0;
field.moveLines(); // сдвинем, если надо сдвинуть
}
redraw();
isLoopRunning = false;
if (gameMode === GameMode.PLAYING) {
tickTimeout = setTimeout(gameLoop, getCurrentTickDelay()); // снова запускаем всё сначала
}
};
В целом, осталось только понять, что же такое фигура.
Сама геморная часть. Там больше всего буковок.
Фигура - это сущность, в которой хранится информация о том, какой она формы и как её крутить.
class Figure {
constructor() {
this.cells = [
[0],
];
this.x = 2;
this.y = -5;
this.rotationState = 0;
}
moveDown = () => {
this.y++;
};
moveUp = () => {
this.y--;
};
moveLeft = () => {
this.x--;
if (this.checkCollision()) {
this.x++;
}
}
moveRight = () => {
this.x++;
if (this.checkCollision()) {
this.x--;
}
}
fall = () => {
while (!this.checkCollision()) {
this.y++;
}
this.moveUp();
};
checkCollision = () => {
for (let i = 0; i < this.cells.length; i++) {
for (let j = 0; j < this.cells[i].length; j++) {
let cellX = j + this.x;
let cellY = i + this.y;
if (this.cells[i][j] === 0) {
continue;
}
// Проверяем границы по X всегда (независимо от Y)
if (cellX < 0 || cellX >= field.width) {
return true;
}
// Для клеток выше видимой области не проверяем коллизии с полем
if (cellY < 0) {
continue;
}
// Проверяем нижнюю границу и коллизии с заполненными клетками
if (cellY >= field.height) {
return true;
}
if (field.blocks[cellY][cellX] === 1) {
return true;
}
}
}
return false;
};
rotate(withCollisions = true) {
const from = this.rotationState;
const to = (from + 1) % this.states.length;
const key = `${from}>${to}`;
const kickSet = (this.type === "I")
? SRS_KICKS.I[key]
: (this.type === "O" ? SRS_KICKS.O[key] : SRS_KICKS.JLSTZ[key]);
const originalX = this.x;
const originalY = this.y;
let rotated = this.states[to];
if (!withCollisions) {
this.cells = rotated;
this.x = originalX;
this.y = originalY;
this.rotationState = to;
return;
}
for (const [dx, dy] of kickSet) {
this.cells = rotated;
this.x = originalX + dx;
this.y = originalY - dy;
if (!this.checkCollision()) {
this.rotationState = to;
return;
}
}
this.cells = this.states[from];
this.x = originalX;
this.y = originalY;
}
placeToField = () => {
let overflow = false;
for (let i = 0; i < this.cells.length; i++) {
for (let j = 0; j < this.cells[i].length; j++) {
let cellX = j + this.x;
let cellY = i + this.y;
if (this.cells[i][j] === 0) {
continue;
}
if (cellY < 0) {
overflow = true;
continue;
}
if (cellY >= field.height || cellX < 0 || cellX >= field.width) {
overflow = true;
continue;
}
field.blocks[cellY][cellX] = this.cells[i][j];
}
}
return overflow;
};
setStartPosition = () => {
this.x = Math.floor(field.width / 2) - Math.floor(this.cells[0].length / 2);
if (this.type === "I" && this.rotationState === 1) {
this.y = -2;
} else if (this.type === "I") {
this.y = -4;
} else if (this.type === "O") {
this.y = -2;
} else if (this.rotationState === 3) {
this.y = -2;
} else {
this.y = -3;
}
};
fillRandom = () => {
let figure = FIGURES[Math.floor(Math.random() * FIGURES.length)];
this.cells = figure.figure.states[0].map(row => [...row]);
this.states = figure.figure.states.map(state => state.map(row => [...row]));
this.type = figure.type;
this.rotationState = 0;
let rotateSteps = Math.floor(Math.random() * 4);
for (let i = 0; i < rotateSteps; i++) {
this.rotate(false);
}
};
}
Тут, конечно, кода дофига и надо объяснить, что тут происходит. Суть в том, что поворот в тетрисе – это прям отдельная задачка. Я её для себя упростил максимально, создав «спрайты» фигур во всех положениях заранее.
А ещё есть такая штука как SRS – это прям общепринятый стандарт вращения фигур. Специальные таблицы описывают как необходимо проверять смещение фигур в пространстве игрового поля при переходе из одного состояния в другое, на случай столкновения со стенами или существующими блоками в момент вращения. Я, опять-таки, эту часть упростил максимально, вырезав очень много из стандарта, так как у меня, как минимум, нет вращение против часовой стрелки.
Логика такая: после поворота к фигуре применяются смещения по иксу и игрику по очереди из массива, сначала 0-0 (не смещается), потом, к примеру 0-1 и так далее. За идеальное состояние, которое в данный момент всех устраивает, применяется то, после которого проверка коллизии фигуры показывает, что никто ни с кем не столкнулся. Если ни один из вариантов не подошёл, значит поворот не случился.
Классические таблицы подразумевают матрицы фигур одинакового размера и квадратные по своей сути. Я тут тоже отошёл от стандарта,
Как это в итоге играется
По коду там есть у меня есть усложнение с увеличением скорости падения фигур за каждые 30 полученных очков, сохранение рекорда и прочая мишура, которая к основному алгоритму отношения уже не имеет.
Как грицца: понятно, что нифига не понятно, так что спрашивайте, господа и дамы, отвечу на недостающие вопросы)
Этот снимок был получен аппаратом Марс Одиссей. Это - гора Арсия, один из высочайших вулканов Марса и одна из самых высоких гор Солнечной системы. Она поднимается на высоту почти 19 километров. Кальдера вулкана, видимая на снимке, имеет диаметр более 100 км
Не помогли портянки войлочные в рабочих кирзовых сапогах. Хотя было тепло -20 в тени , а на солнце -16. Однако ноги начали замерзать. От этого потом усталость появилась.
Руки у нас замерзали. Однако я опытный... было две пары перчаток. Не собираюсь экономить на своих руках как некоторые. Лекарства стоят дороже!..
Баф помогал очень хорошо. Закрывая нос и щеки. Можно надышать там ртом тепло и радоваться . После баф намокает , но его можно повернуть сухой строй к себе. Так становится теплее.. Зайдя в помещение он высыхает за пару часов.
_____
На веле быстрее чем пешком. Менее замерзать и надёжнее чем на машине. Даже у магазина не все могут завести машину. Морозы!
Шипы на месте. Хорошо вцепляются в лёд и снег твердый. Пока некогда их подрезать . Надеюсь успею до апреля 😒🤣.
Заднее колесо без шипов. Хватает токо переднего шипованного. В основном переднее колесо на льду выбивает из рук. Заднее колесо более устойчиво.
Зимой плавное руление с плавной скоростью = кости целее... И да утепляйте сиденья на веле ( хоть М или Ж велосипедисты)
_____
Завтра будет холоднее нам и сложнее. Досадую, что не купил валенки. Буду искать себе их по доступной цене.
Спасибо. Попробую. Вроде тыкала везде где могла, но может не там и не так.
хаюш)
ооооо!
а распакоука буит?))