Как написать свой Тетрис на Java за полчаса

В предыдущих статьях этой серии мы уже успели написать сапёра, змейку и десктопный клон игры 2048. Попробуем теперь написать свой Тетрис.

Нам, как обычно, понадобятся:

  • 30 минут свободного времени;
  • Настроенная рабочая среда, т.е. JDK и IDE (например, Eclipse);
  • Библиотека LWJGL (версии 2.x.x) для работы с графикой (опционально). Обратите внимание, что для LWJGL версий выше 3 потребуется написать код, отличающийся от того, что приведён в статье;
  • Спрайты, т.е. картинки плиток всех возможных состояний (пустая, и со степенями двойки до 2048). Можно нарисовать самому, или скачать использовавшиеся при написании статьи.

Прежде чем задавать вопрос в комментариях, не забудьте заглянуть в предыдущие статьи, возможно там на него уже давался ответ. Исходный код готового проекта традиционно можно найти на GitHub.

С чего начать?

Начать стоит с главного управляющего класса, который в нашем проекте находится выше остальных по уровню абстракции. Вообще отличный совет – в начале работы всегда пишите код вида if (getKeyPressed()) doSomething(), так вы быстро определите фронт работ.

public static void main(String[] args) {
    initFields();

    while(!endOfGame){
        input();
        logic();

        graphicsModule.draw(gameField);
        graphicsModule.sync(FPS);
    }

    graphicsModule.destroy();
}

Это наш main(). Он ничем принципиально не отличается от тех, что мы писали в предыдущих статьях – мы всё так же инициализируем поля и, пока игра не закончится, осуществляем по очереди: ввод пользовательских данных (input()), основные игровые действия (logic()) и вызов метода отрисовки у графического модуля (graphicsModule.draw()), в который передаём текущее игровое поле (gameField). Из нового разве что метод sync – метод, который должен будет гарантировать нам определённую частоту выполнения итераций. С его помощью мы сможем задать скорость падения фигуры в клетках-в-секунду.

Вы могли заметить, что в коде использована константа FPS. Все константы удобно определять в классе с public static final полями. Полный список констант, который нам потребуется в ходе разработки, можно посмотреть в классе Constants на GitHub.

Оставим пока инициализацию полей на потом (мы же ещё не знаем, какие нам вообще понадобятся поля). Разберёмся сначала с input() и logic().

Получение данных от пользователя

Код, честно говоря, достаточно капитанский:

private static void input(){
    /// Обновляем данные модуля ввода
    keyboardModule.update();

    /// Считываем из модуля ввода направление для сдвига падающей фигурки
    shiftDirection = keyboardModule.getShiftDirection();

    /// Считываем из модуля ввода, хочет ли пользователь повернуть фигурку
    isRotateRequested = keyboardModule.wasRotateRequested();

    /// Считываем из модуля ввода, хочет ли пользователь "уронить" фигурку вниз
    isBoostRequested = keyboardModule.wasBoostRequested();

    /// Если был нажат ESC или "крестик" окна, завершаем игру
    endOfGame = endOfGame || keyboardModule.wasEscPressed() || graphicsModule.isCloseRequested();
}

Все данные от ввода мы просто сохраняем в соответствующие поля, действия на основе них будет выполнять метод logic().

Теперь уже потихоньку становится понятно, что нам необходимо. Во-первых, нам нужны клавиатурный и графический модули. Во-вторых, нужно как-то хранить направление, которое игрок выбрал для сдвига. Вторая задача решается просто – создадим enum с тремя состояниями: AWAITING, LEFT, RIGHT. Зачем нужен AWAITING? Чтобы хранить информацию о том, что сдвиг не требуется (использования в программе null следует всеми силами избегать). Перейдём к интерфейсам.

Интерфейсы для клавиатурного и графического модулей

Так как многим не нравится, что я пишу эти модули на LWJGL, я решил в статье уделить время только интерфейсам этих классов. Каждый может написать их с помощью той GUI-библиотеки, которая ему нравится (или вообще сделать консольный вариант). Я же по старинке реализовал их на LWJGL, код можно посмотреть здесь в папках graphics/lwjglmodule и keyboard/lwjglmodule.

Интерфейсы же, после добавления в них всех упомянутых выше методов, будут выглядеть следующим образом:

public interface GraphicsModule {

    /**
     * Отрисовывает переданное игровое поле
     *
     * @param field Игровое поле, которое необходимо отрисовать
     */
    void draw(GameField field);

    /**
     * @return Возвращает true, если в окне нажат "крестик"
     */
    boolean isCloseRequested();

    /**
     * Заключительные действия, на случай, если модулю нужно подчистить за собой.
     */
    void destroy();

    /**
     * Заставляет программу немного поспать, если последний раз метод вызывался
     * менее чем 1/fps секунд назад
     */
    void sync(int fps);
}
public interface KeyboardHandleModule {

    /**
     * Считывание последних данных из стека событий, если модулю это необходимо
     */
    void update();

    /**
     * @return Возвращает информацию о том, был ли нажат ESCAPE за последнюю итерацию
     */
    boolean wasEscPressed();

    /**
     * @return Возвращает направление, в котором пользователь хочет сдвинуть фигуру.
     * Если пользователь не пытался сдвинуть фигуру, возвращает ShiftDirection.AWAITING.
     */
    ShiftDirection getShiftDirection();

    /**
     * @return Возвращает true, если пользователь хочет повернуть фигуру.
     */
    boolean wasRotateRequested();

    /**
     * @return Возвращает true, если пользователь хочет ускорить падение фигуры.
     */
    boolean wasBoostRequested();
}

Отлично, мы получили от пользователя данные. Что дальше?

А дальше мы должны эти данные обработать и что-то сделать с игровым полем. Если пользователь сказал сдвинуть фигуру куда-то, то передаём полю, что нужно сдвинуть фигуру в таком-то направлении. Если пользователь сказал, что нужно фигуру повернуть, поворачиваем, и так далее. Кроме этого нельзя забывать, что 1 раз в FRAMES_PER_MOVE (вы же открывали файл с константами?) итераций нам необходимо сдвигать падающую фигурку вниз.

private static void logic(){
    if(shiftDirection != ShiftDirection.AWAITING){ // Если есть запрос на сдвиг фигуры

       /* Пробуем сдвинуть */
       gameField.tryShiftFigure(shiftDirection);

       /* Ожидаем нового запроса */
       shiftDirection = ShiftDirection.AWAITING;
    }

    if(isRotateRequested){ // Если есть запрос на поворот фигуры

       /* Пробуем повернуть */
       gameField.tryRotateFigure();

       /* Ожидаем нового запроса */
       isRotateRequested = false;
    }

    /* Падение фигуры вниз происходит если loopNumber % FRAMES_PER_MOVE == 0
     * Т.е. 1 раз за FRAMES_PER_MOVE итераций.
     */
    if( (loopNumber % (FRAMES_PER_MOVE / (isBoostRequested ? BOOST_MULTIPLIER : 1)) ) == 0) gameField.letFallDown();

    /* Увеличение номера итерации (по модулю FPM)*/
    loopNumber = (loopNumber+1)% (FRAMES_PER_MOVE);

Сюда же добавим проверку на переполнение поля (в Тетрисе игра завершается, когда фигурам некуда падать):

/* Если поле переполнено, игра закончена */
       endOfGame = endOfGame || gameField.isOverfilled();
    }

Так, а теперь мы напишем класс для того магического gameField, в который мы всё это передаём, да?

Не совсем. Сначала мы пропишем поля класса Main и метод initFields(), чтобы совсем с ним закончить. Вот все поля, которые мы использовали:

/** Флаг для завершения основного цикла программы */
    private static boolean endOfGame;

    /** Графический модуль игры*/
    private static GraphicsModule graphicsModule;

    /** "Клавиатурный" модуль игры, т.е. модуль для чтения запросов с клавиатуры*/
    private static KeyboardHandleModule keyboardModule;

    /** Игровое поле. См. документацию GameField */
    private static GameField gameField;

    /** Направление для сдвига, полученное за последнюю итерацию */
    private static ShiftDirection shiftDirection;

    /** Был ли за последнюю итерацию запрошен поворот фигуры */
    private static boolean isRotateRequested;

    /** Было ли за последнюю итерацию запрошено ускорение падения*/
    private static boolean isBoostRequested;

    /** Номер игровой итерации по модулю FRAMES_PER_MOVE.
     *  Падение фигуры вниз происходит если loopNumber % FRAMES_PER_MOVE == 0
     *  Т.е. 1 раз за FRAMES_PER_MOVE итераций.
     */
    private static int loopNumber;

А инициализировать мы их будем так:

private static void initFields() {
    loopNumber = 0;
    endOfGame = false;
    shiftDirection = ShiftDirection.AWAITING;
    isRotateRequested = false;
    graphicsModule = new LwjglGraphicsModule();
    keyboardModule = new LwjglKeyboardHandleModule();
    gameField = new GameField();
}

Если вы решили не использовать LWJGL и написали свои классы, реализующие GraphicsModule и KeyboardHandleModule, то здесь нужно указать их конструкторы вместо, соответственно new LwjglGraphicsModule() и new LwjglKeyboardHandleModule().

А вот теперь мы переходим к классу, который отвечает за хранение информации об игровом поле и её обновление.

Класс GameField

Этот класс должен, во-первых, хранить информацию о поле и о падающей фигуре, а во-вторых, содержать методы для их обновления, и получения о них информации – кроме тех, которые мы уже использовали, необходимо написать метод, возвращающий цвет ячейки по координатам, чтобы графический модуль мог отрисовать поле.

Начнём по порядку.

Хранить информацию о поле…

/**  Цвета ячеек поля. Для пустых ячеек используется константа EMPTINESS_COLOR */
    private TpReadableColor[][] theField;

    /** Количество непустых ячеек строки.
     *  Можно было бы получать динамически из theField, но это дольше.
     */
    private int[] countFilledCellsInLine;

…и о падающей фигуре

    /**  Информация о падающей в данный момент фигуре   */
    private Figure figure;

TpReadableColor — простой enum, содержащий элементы с говорящими названиями (RED, ORANGE и т.п.) и метод, позволяющий получить случайным образом один из этих элементов. Ничего особенного в нём нет, код можно посмотреть тут.

Это все поля, которые нам понадобятся. Как известно, поля любят быть инициализированными.
Сделать это следует в конструкторе.

Конструктор и инициализация полей

public GameField(){
    spawnNewFigure();

    theField = new TpReadableColor[COUNT_CELLS_X][COUNT_CELLS_Y+OFFSET_TOP];
    countFilledCellsInLine = new int[COUNT_CELLS_Y+OFFSET_TOP];

“Что это за OFFSET_TOP?” – спросите вы. OFFSET_TOP это количество неотображаемых ячеек сверху, в которых создаются падающие фигуры. Если фигуре не сможет “выпасть” из этого пространства, и хоть одна ячеек theField выше уровня COUNT_CELLS_Y будет заполнена, это будет обозначать, что поле переполнено и пользователь проиграл, поэтому OFFSET_TOP должен быть строго больше нуля.

Далее в конструкторе стоит заполнить массив theField значениями константы EMPTINESS_COLOR , а countFilledCellsInLine – нулями (второе в Java не требуется, при инициализации массива все int‘ы равны 0). Или можно создать несколько слоёв уже заполненных ячейкам — на GitHub вы можете увидеть реализацию именно второго варианта.

А что это там за spawnNewFigure()? Почему инициализация фигуры вынесена в отдельный метод?

Вы правильно догадались, spawnNewFigure() действительно инициализирует поле figure. А в отдельный метод это вынесено, потому что нам придётся делать инициализацию каждый раз, когда будет создаваться новая фигура.

/**
    * Создаёт новую фигуру в невидимой зоне
    * X-координата для генерации не должна быть ближе к правому краю,
    * чем максимальная ширина фигуры (MAX_FIGURE_WIDTH), чтобы влезть в экран
    */
    private void spawnNewFigure(){
        int randomX = new Random().nextInt(COUNT_CELLS_X - MAX_FIGURE_WIDTH);

        this.figure = new Figure(new Coord(randomX, COUNT_CELLS_Y + OFFSET_TOP - 1));
    }

На этом с хранением данных мы закончили. Переходим к методам, которые отдают информацию о поле другим классам.

Методы, передающие информацию об игровом поле

Таких метода всего два. Первый возвращает цвет ячейки (для графического модуля):

public TpReadableColor getColor(int x, int y) {
    return theField[x][y];
}

А второй сообщает, переполнено ли поле (как это происходит, мы разобрали выше):

public boolean isOverfilled(){
    for(int i = 0; i < OFFSET_TOP; i++){
        if(countFilledCellsInLine[COUNT_CELLS_Y+i] != 0) return true;
    }

    return false;
}

Методы, обновляющие фигуру и игровое поле

Начнём реализовывать методы, которые мы вызывали из Main.logic().

Сдвиг фигуры

За это отвечает метод tryShiftFigure(). В комментариях к его вызову из Main было сказано, что он “пробует сдвинуть фигуру”. Почему пробует? Потому что если фигура находится вплотную к стене, а пользователь пытается её сдвинуть в направлении этой стены, никакого сдвига в реальности происходить не должно. Так же нельзя сдвинуть фигуру в статические ячейки на поле.

public void tryShiftFigure(ShiftDirection shiftDirection) {
    Coord[] shiftedCoords = figure.getShiftedCoords(shiftDirection);

    boolean canShift = true;

    for(Coord coord: shiftedCoords) {
        if((coord.y<0 || coord.y>=COUNT_CELLS_Y+OFFSET_TOP)
         ||(coord.x<0 || coord.x>=COUNT_CELLS_X)
         || ! isEmpty(coord.x, coord.y)){
            canShift = false;
        }
    }

    if(canShift){
        figure.shift(shiftDirection);
    }
}

Что мы сделали в этом методе? Мы запросили у фигуры ячейки, которые бы она заняла в случае сдвига. А затем для каждой из этих ячеек мы проверяем, не выходит ли она за пределы поля, и не находится ли по её координатам в сетке статичный блок. Если хоть одна ячейка фигуры выходит за пределы или пытается встать на место блока – сдвига не происходит. Coord здесь – класс-оболочка с двумя публичными числовыми полями (x и y координаты).

Поворот фигуры

Логика аналогична сдвигу:

Coord[] rotatedCoords = figure.getRotatedCoords();

    boolean canRotate = true;

    for(Coord coord: rotatedCoords) {
        if((coord.y<0 || coord.y>=COUNT_CELLS_Y+OFFSET_TOP)
                ||(coord.x<0 || coord.x>=COUNT_CELLS_X)
                ||! isEmpty(coord.x, coord.y)){
            canRotate = false;
        }
    }

    if(canRotate){
        figure.rotate();
        }

Падение фигуры

Сначала код в точности повторяет предыдущие два метода:

public void letFallDown() {
    Coord[] fallenCoords = figure.getFallenCoords();

    boolean canFall = true;

    for(Coord coord: fallenCoords) {
        if((coord.y<0 || coord.y>=COUNT_CELLS_Y+OFFSET_TOP)
                ||(coord.x<0 || coord.x>=COUNT_CELLS_X)
                ||! isEmpty(coord.x, coord.y)){
            canFall = false;
        }
    }

    if(canFall) {
        figure.fall();

Однако теперь, в случае, если фигура дальше падать не может, нам необходимо перенести её ячейки («кубики») в theField, т.е. в разряд статичных блоков, после чего создать новую фигуру:

    } else {
        Coord[] figureCoords = figure.getCoords();

        /* Флаг, говорящий о том, что после будет необходимо сместить линии вниз
         * (т.е. какая-то линия была уничтожена)
         */
        boolean haveToShiftLinesDown = false;

        for(Coord coord: figureCoords) {
            theField[coord.x][coord.y] = figure.getColor();

            /* Увеличиваем информацию о количестве статичных блоков в линии*/
            countFilledCellsInLine[coord.y]++;

            /* Проверяем, полностью ли заполнена строка Y
             * Если заполнена полностью, устанавливаем  haveToShiftLinesDown в true
             */
            haveToShiftLinesDown = tryDestroyLine(coord.y) || haveToShiftLinesDown;
        }

        /* Если это необходимо, смещаем линии на образовавшееся пустое место */
        if(haveToShiftLinesDown) shiftLinesDown();

        /* Создаём новую фигуру взамен той, которую мы перенесли*/
        spawnNewFigure();
    }

Так как в результате переноса ячеек какая-то линия может заполниться полностью, после каждого добавления ячейки мы проверяем линию, в которую мы её добавили, на полноту:

private boolean tryDestroyLine(int y) {
    if(countFilledCellsInLine[y] < COUNT_CELLS_X){
        return false;
    }

    for(int x = 0; x < COUNT_CELLS_X; x++){
        theField[x][y] = EMPTINESS_COLOR;
    }

    /* Не забываем обновить мета-информацию! */
    countFilledCellsInLine[y] = 0;

    return true;
}

Этот метод возвращает истину, если линию удалось уничтожить. После добавления всех кирпичиков фигуры в сетку (и удаления всех заполненных линий), мы, при необходимости, запускаем метод, который сдвигает на место пустых линий непустые:

private void shiftLinesDown() {

    /* Номер обнаруженной пустой линии (-1, если не обнаружена) */
    int fallTo = -1;

    /* Проверяем линии снизу вверх*/
    for(int y = 0; y < COUNT_CELLS_Y; y++){
        if(fallTo == -1){ //Если пустот ещё не обнаружено
            if(countFilledCellsInLine[y] == 0) fallTo = y; //...пытаемся обнаружить (._.)
        } else { //А если обнаружено
            if(countFilledCellsInLine[y] != 0){ // И текущую линию есть смысл сдвигать...

                /* Сдвигаем... */
                for(int x = 0; x < COUNT_CELLS_X; x++){
                    theField[x][fallTo] = theField[x][y];
                    theField[x][y] = EMPTINESS_COLOR;
                }

                /* Не забываем обновить мета-информацию*/
                countFilledCellsInLine[fallTo] = countFilledCellsInLine[y];
                countFilledCellsInLine[y] = 0;

                /*
                 * В любом случае линия сверху от предыдущей пустоты пустая.
                 * Если раньше она не была пустой, то сейчас мы её сместили вниз.
                 * Если раньше она была пустой, то и сейчас пустая -- мы её ещё не заполняли.
                 */
                fallTo++;
            }
        }
    }
}

Теперь GameField реализован почти полностью — за исключением геттера для фигуры. Её ведь графическому модулю тоже придётся отрисовывать:

public Figure getFigure() {
    return figure;
}

Теперь нам нужно написать алгоритмы, по которым фигура определяет свои координаты в разных состояниях. Да и вообще весь класс фигуры.

Класс фигуры

Реализовать это всё я предлагаю следующим образом – хранить для фигуры (1) «мнимую» координату, такую, что все реальные блоки находятся ниже и правее неё, (2) состояние поворота (их всего 4, после 4-х поворотов фигура всегда возвращается в начальное положение) и (3) маску, которая по первым двум параметрам будет определять положение реальных блоков:

/**
     * Мнимая координата фигуры. По этой координате
     * через маску генерируются координаты реальных
     * блоков фигуры.
     */
    private Coord metaPointCoords;

    /**
     * Текущее состояние поворота фигуры.
     */
    private RotationMode currentRotation;

    /**
     * Форма фигуры.
     */
    private FigureForm form;

Rotation мод здесь будет выглядеть таким образом:

public enum RotationMode {
    /** Начальное положение */
    NORMAL(0),

    /** Положение, соответствующее повороту против часовой стрелки*/
    FLIP_CCW(1),

    /** Положение, соответствующее зеркальному отражению*/
    INVERT(2),

    /** Положение, соответствующее повороту по часовой стрелке (или трём поворотам против)*/
    FLIP_CW(3);



    /** Количество поворотов против часовой стрелки, необходимое для принятия положения*/
    private int number;

    /**
     * Конструктор.
     *
     * @param number Количество поворотов против часовой стрелки, необходимое для принятия положения
     */
    RotationMode(int number){
        this.number = number;
    }

    /** Хранит объекты enum'а. Индекс в массиве соответствует полю number.
     *  Для более удобной работы getNextRotationForm().
     */
    private static RotationMode[] rotationByNumber = {NORMAL, FLIP_CCW, INVERT, FLIP_CW};

    /**
     * Возвращает положение, образованое в результате поворота по часовой стрелке
     * из положения perviousRotation
     *
     * @param perviousRotation Положение из которого был совершён поворот
     * @return Положение, образованное в результате поворота
     */
    public static RotationMode getNextRotationFrom(RotationMode perviousRotation) {
        int newRotationIndex = (perviousRotation.number + 1) % rotationByNumber.length;
        return rotationByNumber[newRotationIndex];
    }
}

Соответственно, от самого класса Figure нам нужен только конструктор, инициализирующий поля:

/**
     * Конструктор. 
     * Состояние поворота по умолчанию: RotationMode.NORMAL 
     * Форма задаётся случайная.
     *
     * @param metaPointCoords Мнимая координата фигуры. См. документацию одноимённого поля
     */
    public Figure(Coord metaPointCoords){
        this(metaPointCoords, RotationMode.NORMAL, FigureForm.getRandomForm());
    }

    public Figure(Coord metaPointCoords, RotationMode rotation, FigureForm form){
        this.metaPointCoords = metaPointCoords;
        this.currentRotation = rotation;
        this.form = form;
    }
    }

И методы, которыми мы пользовались в GameField следующего вида:

/**
     * @return Координаты реальных ячеек фигуры в текущем состоянии
     */
    public Coord[] getCoords(){
        return form.getMask().generateFigure(metaPointCoords, currentRotation);
    }

    /**
     * @return Координаты ячеек фигуры, как если бы
     * она была повёрнута проти часовой стрелки от текущего положения
     */
    public Coord[] getRotatedCoords(){
        return form.getMask().generateFigure(metaPointCoords, RotationMode.getNextRotationFrom(currentRotation));
    }

    /**
     * Поворачивает фигуру против часовой стрелки
     */
    public void rotate(){
        this.currentRotation = RotationMode.getNextRotationFrom(currentRotation);
    }

    /**
     * @param direction Направление сдвига
     * @return Координаты ячеек фигуры, как если бы
     * она была сдвинута в указано направлении
     */
    public Coord[] getShiftedCoords(ShiftDirection direction){
        Coord newFirstCell = null;

        switch (direction){
            case LEFT:
                newFirstCell = new Coord(metaPointCoords.x - 1, metaPointCoords.y);
                break;
            case RIGHT:
                newFirstCell = new Coord(metaPointCoords.x + 1, metaPointCoords.y);
                break;
            default:
                ErrorCatcher.wrongParameter("direction (for getShiftedCoords)", "Figure");
        }

        return form.getMask().generateFigure(newFirstCell, currentRotation);
    }

    /**
     * Меняет мнимую X-координату фигуры
     * для сдвига в указаном направлении
     *
     * @param direction Направление сдвига
     */
    public void shift(ShiftDirection direction){
        switch (direction){
            case LEFT:
                metaPointCoords.x--;
                break;
            case RIGHT:
                metaPointCoords.x++;
                break;
            default:
                ErrorCatcher.wrongParameter("direction (for shift)", "Figure");
        }
    }

    /**
     * @return Координаты ячеек фигуры, как если бы
     * она была сдвинута вниз на одну ячейку
     */
    public Coord[] getFallenCoords(){
        Coord newFirstCell = new Coord(metaPointCoords.x, metaPointCoords.y - 1);

        return form.getMask().generateFigure(newFirstCell, currentRotation);
    }

    /**
     * Меняет мнимую Y-координаты фигуры
     * для сдвига на одну ячейку вниз
     */
    public void fall(){
        metaPointCoords.y--;
    }

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

public TpReadableColor getColor() {
    return form.getColor();
}

Форма фигуры и маски координат

Чтобы не занимать лишнее место, здесь я приведу реализацию только для двух форм: I-образной и J-образной. Код для остальных фигур принципиально не отличается и выложен на GitHub.

Храним для каждой фигуры маску координат (которая определяет, насколько каждый реальный блок должен отстоять от “мнимой” координаты фигуры) и цвет:

public enum FigureForm {

    I_FORM (CoordMask.I_FORM, TpReadableColor.BLUE),
    J_FORM (CoordMask.J_FORM, TpReadableColor.ORANGE);

/** Маска координат (задаёт геометрическую форму) */
    private CoordMask mask;

    /** Цвет, характерный для этой формы */
    private TpReadableColor color;

    FigureForm(CoordMask mask, TpReadableColor color){
        this.mask = mask;
        this.color = color;
    }

Реализуем методы, которые использовали выше:

/**
     * Массив со всеми объектами этого enum'а (для удобной реализации getRandomForm() )
     */
    private static final FigureForm[] formByNumber = {I_FORM, J_FORM, L_FORM, O_FORM, S_FORM, Z_FORM, T_FORM,};

    /**
     * @return Маска координат данной формы
     */
    public CoordMask getMask(){
        return this.mask;
    }

    /**
     * @return Цвет, специфичный для этой формы
     */
    public TpReadableColor getColor(){
        return this.color;
    }

    /**
     * @return Случайный объект этого enum'а, т.е. случайная форма
     */
    public static FigureForm getRandomForm() {
        int formNumber = new Random().nextInt(formByNumber.length);
        return formByNumber[formNumber];
    }

Ну а сами маски координат я предлагаю просто захардкодить следующим образом:

/**
 * Каждая маска -- шаблон, который по мнимой координате фигуры и
 * состоянию её поворота возвращает 4 координаты реальных блоков
 * фигуры, которые должны отображаться.
 * Т.е. маска задаёт геометрическую форму фигуры.
 *
 * @author DoKel
 * @version 1.0
 */
public enum CoordMask {
    I_FORM(
            new GenerationDelegate() {
                @Override
                public Coord[] generateFigure(Coord initialCoord, RotationMode rotation) {
                    Coord[] ret = new Coord[4];

                    switch (rotation){
                        case NORMAL:
                        case INVERT:
                            ret[0] = initialCoord;
                            ret[1] = new Coord(initialCoord.x , initialCoord.y - 1);
                            ret[2] = new Coord(initialCoord.x, initialCoord.y - 2);
                            ret[3] = new Coord(initialCoord.x, initialCoord.y - 3);
                            break;
                        case FLIP_CCW:
                        case FLIP_CW:
                            ret[0] = initialCoord;
                            ret[1] = new Coord(initialCoord.x + 1, initialCoord.y);
                            ret[2] = new Coord(initialCoord.x + 2, initialCoord.y);
                            ret[3] = new Coord(initialCoord.x + 3, initialCoord.y);
                            break;
                    }

                    return ret;
                }
            }
    ),
    J_FORM(
            new GenerationDelegate() {
                @Override
                public Coord[] generateFigure(Coord initialCoord, RotationMode rotation) {
                    Coord[] ret = new Coord[4];

                    switch (rotation){
                        case NORMAL:
                            ret[0] = new Coord(initialCoord.x + 1 , initialCoord.y);
                            ret[1] = new Coord(initialCoord.x + 1, initialCoord.y - 1);
                            ret[2] = new Coord(initialCoord.x + 1, initialCoord.y - 2);
                            ret[3] = new Coord(initialCoord.x, initialCoord.y - 2);
                            break;
                        case INVERT:
                            ret[0] = new Coord(initialCoord.x + 1 , initialCoord.y);
                            ret[1] = initialCoord;
                            ret[2] = new Coord(initialCoord.x, initialCoord.y - 1);
                            ret[3] = new Coord(initialCoord.x, initialCoord.y - 2);
                            break;
                        case FLIP_CCW:
                            ret[0] = initialCoord;
                            ret[1] = new Coord(initialCoord.x + 1, initialCoord.y);
                            ret[2] = new Coord(initialCoord.x + 2, initialCoord.y);
                            ret[3] = new Coord(initialCoord.x + 2, initialCoord.y - 1);
                            break;
                        case FLIP_CW:
                            ret[0] = initialCoord;
                            ret[1] = new Coord(initialCoord.x, initialCoord.y - 1);
                            ret[2] = new Coord(initialCoord.x + 1, initialCoord.y - 1);
                            ret[3] = new Coord(initialCoord.x + 2, initialCoord.y - 1);
                            break;
                    }

                    return ret;
                }
            }
    );

/**
     * Делегат, содержащий метод,
     * который должен определять алгоритм для generateFigure()
     */
    private interface GenerationDelegate{

        /**
         * По мнимой координате фигуры и состоянию её поворота
         * возвращает 4 координаты реальных блоков фигуры, которые должны отображаться
         *
         * @param initialCoord Мнимая координата
         * @param rotation Состояние поворота
         * @return 4 реальные координаты
         */
        Coord[] generateFigure(Coord initialCoord,  RotationMode rotation);
    }

    private GenerationDelegate forms;

    CoordMask(GenerationDelegate forms){
        this.forms = forms;
    }

    /**
     * По мнимой координате фигуры и состоянию её поворота
     * возвращает 4 координаты реальных блоков фигуры, которые должны отображаться.
     *
     * Запрос передаётся делегату, спецефичному для каждого объекта enum'а.
     *
     * @param initialCoord Мнимая координата
     * @param rotation Состояние поворота
     * @return 4 реальные координаты
     */
    public Coord[] generateFigure(Coord initialCoord, RotationMode rotation){
        return this.forms.generateFigure(initialCoord, rotation);
    }

}

Т.е. для каждого объекта enum‘а мы передаём с помощью импровизированных (других в Java нет) делегатов метод, в котором в зависимости от переданного состояния поворота возвращаем разные реальные координаты блоков. В общем-то, можно обойтись и без делегатов, если хранить в каждом элементе отсупы для каждого из режимов поворота.

Наслаждаемся результатом

Работающая программа

Работающая программа

P.S. Ещё раз напомню, что исходники готового проекта доступны на GitHub.