В предыдущих статьях этой серии мы уже успели написать сапёра, змейку и десктопный клон игры 2048. Попробуем теперь написать свой Тетрис.
Нам, как обычно, понадобятся:
30 минут свободного времени;
Настроенная рабочая среда, т.е. JDK и IDE (например, Eclipse);
Библиотека LWJGL (версии 2.x.x) для работы с графикой (опционально). Обратите внимание, что для LWJGL версий выше 3 потребуется написать код, отличающийся от того, что приведён в статье;
Спрайты, т.е. картинки плиток всех возможных состояний (пустая, и со степенями двойки до 2048). Можно нарисовать самому, или скачать использовавшиеся при написании статьи.
Прежде чем задавать вопрос в комментариях, не забудьте заглянуть в предыдущие статьи, возможно там на него уже давался ответ. Исходный код готового проекта традиционно можно найти на GitHub.
С чего начать?
Начать стоит с главного управляющего класса, который в нашем проекте находится выше остальных по уровню абстракции. Вообще отличный совет — в начале работы всегда пишите код вида if (getKeyPressed()) doSomething(), так вы быстро определите фронт работ.
Это наш 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 было сказано, что он “пробует сдвинуть фигуру”. Почему пробует? Потому что если фигура находится вплотную к стене, а пользователь пытается её сдвинуть в направлении этой стены, никакого сдвига в реальности происходить не должно. Так же нельзя сдвинуть фигуру в статические ячейки на поле.
Что мы сделали в этом методе? Мы запросили у фигуры ячейки, которые бы она заняла в случае сдвига. А затем для каждой из этих ячеек мы проверяем, не выходит ли она за пределы поля, и не находится ли по её координатам в сетке статичный блок. Если хоть одна ячейка фигуры выходит за пределы или пытается встать на место блока — сдвига не происходит. Coord здесь — класс-оболочка с двумя публичными числовыми полями (x и y координаты).
Однако теперь, в случае, если фигура дальше падать не может, нам необходимо перенести её ячейки («кубики») в 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 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.
Храним для каждой фигуры маску координат (которая определяет, насколько каждый реальный блок должен отстоять от “мнимой” координаты фигуры) и цвет:
/**
* Массив со всеми объектами этого 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.
Ученые из Стэнфорда представили необычную новинку: гуманоидного робота HumanPlus с открытым исходным кодом и способного обучаться по обычной веб-камере
Oracle выпустила Java 23, которая включает новые примитивы в шаблонах, модульные импорты и улучшения производительности благодаря добавлению компилятора GraalVM JIT