Изучите объектно-ориентированное программирование на JavaScript, создав тетрис. (7)

В этом раунде мы рефакторим MinoGfx и понимаем, что можем безопасно вносить изменения в рамках ООП.

Вот ссылка на статьи из этой серии: Предыдущая статья / Следующая статья

Во-первых, мы сможем определить, ударялся ли повернутый Мино о стены. Для этого мы вводим массив _fposRotates, который содержит формы Мино в каждом из четырех направлений. Например, если бы это был T-Mino, все было бы примерно так.

_fposRotates[0] = [1, 12, 13, 14]
_fposRotates[1] = [14, 1, 13, 25]
_fposRotates[2] = [25, 14, 13, 12]
_fposRotates[3] = [12, 25, 13, 1]

Мы меняем L76, 77, 81, 89 из Mino и добавляем createRotates и this.rotate в виде следующего списка. Весь список я прикреплю внизу этой статьи.

this.rotate = (dir) => {
  const dirUpdating = (_fposDir + dir + 4) % 4;
  if (gFieldJdg.chkToPut(_fposLeftTop, _fposRotates[dirUpdating]) == false) {
    return;
  }
  _fposDir = dirUpdating;

  _minoGfx.erase();
  (dir > 0) ? _minoGfx.rotateR() : _minoGfx.rotateL();
  _minoGfx.draw();
}

function createRotates(blkpos8) {
  const ret_fpos = [];
  for (let r = 0; r < 4; r++) { 
    const fpos4 = [];
    for (let idx = 0; idx < 8; idx += 2) {
      fpos4.push(blkpos8[idx] + blkpos8[idx + 1] * g.PCS_FIELD_COL);
    }
    ret_fpos.push(fpos4);

    for (let idx = 0; idx < 8; idx += 2) {
      const old_x = blkpos8[idx];
      blkpos8[idx] = 2 - blkpos8[idx + 1];
      blkpos8[idx + 1] = old_x;
    }
  }
  return ret_fpos;
}

rotate принимает 1 для правого вращения и -1 для левого вращения в качестве аргумента. При вычислении dirUpdating во 2-й строке сверху мы хотели бы использовать просто (_fposDir + dir) % 4, но результатом -1 % 4 является -1, поэтому мы исправляем его, добавляя 4.

createRotates создает массив _fposRotates, описанный выше, и он не вызывается снаружи Mino, поэтому он определяется с помощью function, а не формы this.createRotates. Метод расчета значений, которые будут храниться в массиве, будет использовать тот же метод расчета, который описан в четвертой статье.

Далее мы добавляем 6 строк к gGame следующим образом. Затем мы можем повернуть Mino влево с помощью клавиши z и вправо с помощью клавиши x.

На первый взгляд кажется, что программа работает правильно, когда мы вносим некоторые изменения выше.

Однако, как только Мино упадет на дно, а следующий Мино появится сверху, программа будет вести себя неправильно. Причина ошибки в том, что значения координат, хранящиеся в _pxpos8 из MinoGfx, все еще поворачиваются, когда следующий Мино появляется сверху.

Теперь давайте рефакторим MinoGfx, чтобы _pxposRotates было похоже на _fposRotates из Mino. Если массив _pxposRotates получен аргументом при построении объекта MinoGfx, его инициализация упрощается следующим образом.

Изменяем MinoGfx.draw и MinoGfx.erase следующим образом.

Мы можем преобразовать два метода MinoGfx.rotateR и MinoGfx.rotateL в один метод MinoGfx.rotate следующим образом.

Приведенный выше рефакторинг делает MinoGfx очень простым следующим образом.

Даже с такими значительными изменениями для MinoGfx влияние ограничено только в пределах Mino. Это иллюстрирует важность инкапсуляции ООП.

Наконец, в ответ на изменения, внесенные в MinoGfx, мы внесем некоторые изменения в Mino. Изменяем инициализацию Mino следующим образом.

createRotates изменяется следующим образом.

Mino.rotate изменяет только одну строку.

С указанными выше изменениями программа теперь будет работать корректно. Я рекомендую вам запустить вашу программу и подтвердить ее работу.

В этой статье мы предприняли попытку серьезного рефакторинга программы, и я надеюсь, что вы обнаружили, что можете безопасно вносить изменения. Спасибо, что прочитали эту статью.

// tetris.js
'use strict';
{ 
  const divTitle = document.createElement('div');
  divTitle.textContent = "TETRIS";
  document.body.appendChild(divTitle);
}

const g = {
  Px_BLOCK: 30,
  Px_BLOCK_INNER: 28,

  PCS_COL: 10,
  PCS_ROW: 20,
  PCS_FIELD_COL: 12,
  
  MSEC_GAME_INTERVAL: 1000,
}

const gFieldGfx = new function() {
  const pxWidthField = g.Px_BLOCK * g.PCS_FIELD_COL;
  const pxHeightField = g.Px_BLOCK * (g.PCS_ROW + 1);

  const canvas = document.createElement('canvas');        
  canvas.width = pxWidthField;
  canvas.height = pxHeightField;
  document.body.appendChild(canvas);

  const _ctx = canvas.getContext('2d');
  _ctx.fillStyle = "black";
  _ctx.fillRect(0, 0, pxWidthField, pxHeightField);

  const yBtmBlk = g.Px_BLOCK * g.PCS_ROW;
  const xRightBlk = pxWidthField - g.Px_BLOCK + 1;

  _ctx.fillStyle = 'gray';
  for (let y = 1; y < yBtmBlk; y += g.Px_BLOCK) {
    _ctx.fillRect(1, y, g.Px_BLOCK_INNER, g.Px_BLOCK_INNER);
    _ctx.fillRect(xRightBlk, y, g.Px_BLOCK_INNER, g.Px_BLOCK_INNER);
  }

  for (let x = 1; x < pxWidthField; x += g.Px_BLOCK) {
    _ctx.fillRect(x, yBtmBlk + 1, g.Px_BLOCK_INNER, g.Px_BLOCK_INNER);
  }

  this.context2d = _ctx;
  this.canvas = canvas;
}

const gFieldJdg = new function() {
  const _field = [];
  
  for (let y = 0; y < 20; y++) {
    _field.push(true);
    for (let x = 0; x < 10; x++) {
      _field.push(false);
    }
    _field.push(true);
  }
  for (let x = 0; x < 12; x++) {
    _field.push(true);
  }

  this.chkToPut = (fposLeftTop, fpos4) => {
    for (let i = 0; i < 4; i++) {
      if (_field[fposLeftTop + fpos4[i]]) { return false; }
    }
    return true;
  }
}

function Mino(color, blkpos8) {
  const [_fposRotates, pxposRotates] = createRotates(blkpos8);
  const _minoGfx = new MinoGfx(color, pxposRotates);

  let _fposLeftTop = 0;
  let _fposDir = 0;

  this.drawAtStartPos = () => {
    _fposLeftTop = 4;
    _fposDir = 0;

    _minoGfx.setToStartPos();
    _minoGfx.draw();
  };

  this.move = (dx, dy) => {
    const posUpdating = _fposLeftTop + dx + dy * g.PCS_FIELD_COL;
    if (gFieldJdg.chkToPut(posUpdating, _fposRotates[_fposDir]) == false) {
      return false;
    }
    _fposLeftTop = posUpdating;

    _minoGfx.erase();
    _minoGfx.move(dx, dy);
    _minoGfx.draw();
    return true;
  }
  
  this.rotate = (dir) => {
    const dirUpdating = (_fposDir + dir + 4) % 4;
    if (gFieldJdg.chkToPut(_fposLeftTop, _fposRotates[dirUpdating]) == false) {
      return;
    }
    _fposDir = dirUpdating;

    _minoGfx.erase();
    _minoGfx.rotate(dirUpdating);
    _minoGfx.draw();
  }

  function createRotates(blkpos8) {
    const ret_fpos = [];
    const ret_pxpos = [];
    
    for (let r = 0; r < 4; r++) { 
      const fpos4 = [];
      for (let idx = 0; idx < 8; idx += 2) {
        fpos4.push(blkpos8[idx] + blkpos8[idx + 1] * g.PCS_FIELD_COL);
      }
      ret_fpos.push(fpos4);
      ret_pxpos.push([...blkpos8].map(x => x * g.Px_BLOCK));

      for (let idx = 0; idx < 8; idx += 2) {
        const old_x = blkpos8[idx];
        blkpos8[idx] = 2 - blkpos8[idx + 1];
        blkpos8[idx + 1] = old_x;
      }
    }
    return [ret_fpos, ret_pxpos];
  }
  
  function MinoGfx(color, pxposRotates) {
    const _ctx = gFieldGfx.context2d;
    const _color = color;
    const _pxposRotates = pxposRotates;
    let _x, _y, _pxposCur;

    this.setToStartPos = () => {
      _x = 4 * g.Px_BLOCK;
      _y = 0;
      _pxposCur = _pxposRotates[0];
    }
        
    this.move = (dx, dy) => {
      _x += dx * g.Px_BLOCK;
      _y += dy * g.Px_BLOCK;
    }

    this.rotate = (dir) => {
      _pxposCur = _pxposRotates[dir];
    }
    
    this.draw = () => drawIn(_color);
    this.erase = () => drawIn('black');

    function drawIn(color) {
      _ctx.fillStyle = color;
      for (let idx = 0; idx < 8; idx += 2) {
        _ctx.fillRect(_x + _pxposCur[idx] + 1, _y + _pxposCur[idx + 1] + 1
                          , g.Px_BLOCK_INNER, g.Px_BLOCK_INNER);
      }
    }
  }
}


const gGame = new function() { 
  let _curMino = new Mino('magenta', [1, 0, 0, 1, 1, 1, 2, 1]);
  _curMino.drawAtStartPos();
  
  document.onkeydown = (e) => {
    switch (e.key)
    {
      case 'z':
        _curMino.rotate(-1);
        break;

      case 'x':
        _curMino.rotate(1);
        break;
            
      case 'ArrowLeft':
        _curMino.move(-1, 0);
        break;

      case 'ArrowRight':
        _curMino.move(1, 0);
        break;

      case 'ArrowDown':
        if (_curMino.move(0, 1)) {
          _timeNextDown = Date.now() + g.MSEC_GAME_INTERVAL;
        }
        break;
    }
  }
  
  let _timeNextDown;
  let _isQuit = false;

  this.run = async ()  => {
    _timeNextDown = Date.now() + g.MSEC_GAME_INTERVAL;
    
    for (;;) {
      await new Promise(r => setTimeout(r, _timeNextDown - Date.now()));
      
      if (_isQuit) { break; }      
      if (Date.now() < _timeNextDown) { continue; }

      if (_curMino.move(0, 1) == false) {
        _curMino.drawAtStartPos();
      }
      _timeNextDown += g.MSEC_GAME_INTERVAL;
    }
  }
    
  this.quit = () => {
    _isQuit = true;
  }
}

gFieldGfx.canvas.onclick = gGame.quit;
gGame.run();