Найти тему

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

Оглавление

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

  • 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 нет) делегатов метод, в котором в зависимости от переданного состояния поворота возвращаем разные реальные координаты блоков. В общем-то, можно обойтись и без делегатов, если хранить в каждом элементе отсупы для каждого из режимов поворота.

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