Задание 32. Змейка и Мухоморы: визуализация
Общее описание игры здесь. Первая часть здесь. Третья часть здесь.
Коротко про классы в Java. (пишите/говорите мне, если еще что-то надо описать, или что-то описано плохо/поверхностно)
Обратите внимание
на статью про отладку приложения.
Дедлайны:
- 9-1: 32 января
- 10-1: 28 января
- 11-1: 28 января
-1) Создание проекта по исходникам в IDEA
Если вы отправили мне исходники первой части змейки (папку src в архиве) - то вы можете заново создать по ним проект следующим образом:
- Создайте пустую папку MyProjectName
- В нее положите папку src с исходниками (пример иерархии: MyProjectName->src->com->snake->…)
- В IDEA: File->New->Project from existing sources
- Укажите папку MyProjectName -> Ok
- Жмите несколько раз Next (по пути смотрите на все значения, но они должны и сами быть верно выставлены), в конце Finish
0) Отрисовка
Итак в первой части были сделаны базовые компоненты основной логики и небольшая часть ее функциональности. Перед тем как доделывать функциональность стоит визуализировать текущее состояние мира (клетчатое поле со змейкой и яблоком), т.к. тогда при реализации остальной логики будет возможность сразу ее тестировать.
Для того чтобы визуализировать мир змейки требуется:
- Создать окно приложения (MainFrame)
- По текущему состоянию мира научиться заполнять пиксели соответствующими цветами (Renderer)
- Реализовать цикл, который провоцирует обновление и отрисовку (UpdaterLoop)
- Добавить точку входа (main-функцию)
Обратите внимание
что пункты 2, 3 и 4 делать не обязательно строго последовательно. Скорее-всего разумно реализовать MainFrame, затем сделать набросок Renderer, после чего реализовав цикл и точку входа - начать цикл “что-то доделал - запустил (возможно под отладкой)”.
1) MainFrame - окно приложения
Как мы уже обсуждали в нашей архитектуре окном заведует класс MainFrame
(о чем можно вспомнить, вглянув на картинку архитектуры внизу этой страницы)
1.1) Начальный класс MainFrame
Создайте класс MainFrame
в пакете com.snake.gui
(так же как создавали Snake
и другие классы в пакете com.snake.logic
)
Обратите внимание
что чтобы можно было кликнуть по com.snake
- сначала надо нажать на шестеренку вверху Project View и там - снять галочку с Compact Empty Middle Packages
:
Чтобы создать в Java окно, достаточно отнаследовать наш MainFrame
от стандартного класса JFrame
(наследование осуществляется ключевым словом extends
):
public class MainFrame extends JFrame { // Объявляем наш класс MainFrame и указываем что он является наследником JFrame (окна)
public MainFrame() { // Объявляем конструктор нашего класса
setVisible(true); // который лишь вызывает метод setVisible(true), который достался в наследство от JFrame
// visible = видимый, т.е. setVisible делает окно видимым
}
public static void main(String[] args) { // это main-функция, которая выполняется при запуске приложения
MainFrame frame = new MainFrame(); // здесь мы просто создаем объект класса MainFrame, и т.к. он сразу в конструкторе становится видимым - окно появляется
}
}
Нужные импорты классов (например JFrame
) можно сделать поставив мышку на подсвеченный красным класс и последующим нажатием Alt+Enter
->Import class
(клик мышкой или Enter
).
1.2) Настройка окна
Но окно создается какого-то странного размера. Выставить размер явно можно вызвав метод void setPreferredSize(Dimension preferredSize)
, реализованный в классе JFrame, а значит и наследованный в MainFrame. Т.о. в конструктор надо добавить строку setPreferredSize(new Dimension(<WIDTH>, <HEIGHT>))
Заметим что отрисовка делается не в тот массив который прямо сейчас показывается пользователю, а в другой - временный. Т.е. на самом деле цветных массивов два - тот который показывается сейчас, и тот который будет показан в следующий момент (который сейчас заполняется алгоритмом). Это так называемая двойная буферизация.
Чтобы активировать двойную буферизацию нужно после setPreferredSize
вызвать метод pack();
и createBufferStrategy(2);
. Первый вызов нужен чтобы окно учло требуемые от него размеры и было готово конструировать буферы изображения. Второй вызов указывает число буферов, которые нам потребуются (нам нужно два буфера - один на показ, второй на подготовку).
Таким образом конструктор MainFrame принимает примерно такой вид (не копируйте отсюда, а напишите сами, пожалуйста):
P.S.:
Обратите внимание
что при закрытии окна программа не завершает исполнение, для того чтобы закрытие окна завершала исполнение достаточно настроить окно -setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE)
- Чтобы можно было изменять размер окна мышкой (и разворотом на весь экран) достаточно вызвать соотествтующий флаг методом
setResizable(boolean)
(этот метод реализован в JFrame, а значит наследуется и в MainFrame)
1.3) Метод paint в MainFrame
Добавим метод paint(BufferedImage image)
в MainFrame. Он будет отрисовывать картинку, которая ему передана извне.
Предлагаю это считать за технические детали - скопируйте этот метод в MainFrame:
public void paint(BufferedImage image) {
BufferStrategy bs = getBufferStrategy(); // получаем нашу двойную буферизацию
Graphics g = bs.getDrawGraphics(); // берем графический контекст для отрисовки ("графику")
g.drawImage(image, 0, 0, image.getWidth(), image.getHeight(), null); // говорим графике отрисовать переданный сюда image (в прямоугольнике указанного размера, с углом в позиции (0, 0))
g.dispose(); // сообщаем графике, что она может освободить все временные ресурсы
bs.show(); // говорим двойной буферизации показать текущий буфер отрисовки
}
Итого получилось примерно следующее (не копируйте пожалуйста отсюда, это условный код):
public class MainFrame extends JFrame (
publ!c MainFrame() (
setPreferredSize(new Dimension(800, 600))
pack()
createBufferStrategy(2)
setVisible(true)
)
publ!c void paint(BufferedImage img) (
BufferStrategy gbs = getBufferStrategy()
Graphics g = gbs.getDrawGraphics()
g.drawImage(img O O img.getWidth() img.getHeight() null)
g.dispose()
gbs.show()
)
publ!c static void main(String[] args) (
MainFrame frame = new MainFrame()
)
)
2) Renderer - визуализация мира
Общий вид класса com.snake.gui.Renderer
:
(Чтобы делать горизонтальную прокрутку для чтения комментариев - попробуйте стрелочки или Shift+КолесоМышки
)
public class Renderer {
public Renderer(World world, int tileSize) { // конструктор - нам достаточно иметь доступ к миру, и знать какой размер каждой клетки в этом поле (т.е. ширина и высота в пикселях)
...
}
public void render(int[] pixels) { // метод отрисовки в одномерный массив пикселей, где каждое число - цвет.
// TODO: на самом деле кроме одномерного массива сюда так же необходимо передать информацию о том, как этот массив интепретировать - т.е. ширину и высоту окна
renderBackground(world.getWidth(), world.getHeight(), pixels); // рисуем фон: клетки внутри поля - один цвет, остальные пиксели - другой
renderSnake(world.getSnake(), pixels); // рисуем змейку: голова - один цвет, остальные части - другой цвет
renderApple(world.getApple(), pixels); // рисуем яблоко: одна клетка какого-то цвета
}
public void renderBackground(...) { // рисуем фон: клетки внутри поля - один цвет, остальные пиксели - другой
// TODO
}
public void renderSnake(...) { // рисуем змейку: голова - один цвет, остальные части - другой цвет
// TODO
}
public void renderApple(...) { // рисуем яблоко: одна клетка какого-то цвета
// TODO
}
private static int color(int r, int g, int b) { // формируем число-цвет из трех компонент - r=red, g=green, b=blue (от 0 до 255)
return color(r, g, b, 255);
}
private static int color(int r, int g, int b, int a) {
return (a << 24) | (r << 16) | (g << 8) | b;
}
...
public int APPLE_COLOUR = color(0, 255, 0); // пример константы, описывающей цвет яблока
...
}
Вы можете реализовывать как хотите, но вот идеи:
- (простой путь) Рисовать все с предположением что левый верхний угол левой верхней клетки мира совпадает с левым верхним пикселем окна.
- (сложный путь) Рисовать все с предположением, что центр клетки-головы змейки совпадает с центром окна.
- (сложный путь+) Обратить внимание что сложный путь полезен лишь когда мир не влезает в окно, и придумать что-нибудь любопытное адаптивное (чтобы если мир влезает - не было движения центра окна вместе с головой змеи, но при этом случай “мир не влезает” обрабатывался как в сложном пути).
И наконец общая рекомендация реализации циклов (это условный код, в любом его месте вам может понадобится что-то править, поэтому воспринимайте его как идею и подсказку, а не как что-то, что надо скопировать и доделывать):
for (int tileY = ...) { // перебираем координату y интересующих нас клеток
for (int tileX = ...) { // перебираем координату x
for (int dy = 0; dy < tileHeight; ++dy) { // перебираем отступ dy пикселя внутри текущей клетки (tileY, tileX)
for (int dx = 0; dx < tileWidth; ++dx) { // перебираем отступ dx
int x = ... + tileX * tileWidth + dx; // вычисляем x и y - координаты пикселя в окне
int y = ... + tileY * tileHeight + dy;
pixels[y * pixelsWidth + x] = SOME_COLOR; // проставляем цвет
}
}
}
}
3) UpdaterLoop - цикл обновления всего
Как мы уже обсуждали - у нас будет UpdaterLoop - класс ответственный за постоянное обновление и отрисовку мира:
public class UpdateLoop {
public UpdateLoop(World world, Renderer renderer) {
...
}
public void start() {
while (true) {
// world.update(); // обновление мира в рамках второй части не обязательно - но обязательно будет в третьей части (пока эту строку можно оставить закомментированной)
renderer.render(); // отрисовываем состояние мира
}
}
}
Заметим, что у нас до сих пор нигде не создается массив pixels - его может создавать UpdateLoop (хотя это не логично, я не знаю почему я сделал это в нем - так что подумайте и перетащите его в более логичное место, будем считать что это так и было задумано - как бага в архитектуре, которую предложено поправить вам).
При этом массив должен быть понятен для графической составляющей Java - быть частью BufferedImage
который ожидается в методе MainFrame.paint(BufferedImage image)
.
Т.е. мы должны создать холст для рисования - BufferedImage
такого же размера, каким обладает наше окно. И передать массив пикселей из этого холста в Renderer.render
:
public class UpdateLoop {
private BufferedImage img = null;
public UpdateLoop(MainFrame frame, World world, Renderer renderer) {
...
}
public void start() {
while (true) {
// Обновление мира в рамках второй части не обязательно - но обязательно будет в третьей части (пока эту строку можно закомментировать)
// world.update();
// Если холст для рисования еще не готов или его размер отличается от размера окна - создаем новый холст (BufferedImage)
if (img == null || img.getWidth() != frame.getWidth() || img.getHeight() != frame.getHeight()) {
img = new BufferedImage(frame.getWidth(), frame.getHeight(), BufferedImage.TYPE_INT_ARGB);
}
// Получаем доступ (ввиде массива) к цветам пикселей из холста
int[] pixels = ((DataBufferInt) img.getRaster().getDataBuffer()).getData();
// Отрисовываем состояние мира
renderer.render(world, pixels, img.getWidth(), img.getHeight());
frame.paint(img);
}
}
}
4) Точка входа (main-функция)
Все что мы написали должно откуда-то вызываться - это происходит из функции main
в MainFrame
:
public static void main(String[] args) {
...
}
Заметим, что она должна сконструировать MainFrame, Renderer, UpdaterLoop. Затем нужно вызвать метод start() у созданного UpdaterLoop.
5) Тестирование и отправка задания
Чтобы запустить приложение нужно нажать правой кнопкой на класс с функцией main
(вероятно MainFrame
) и там нажать Run ...
(или Debug).
Обратите внимание
на статью про отладку приложения.
Ваше приложение должно показывать примерно следующее: мир, в нем змея стоит в изначальной позиции (например в углу или центре мира).
Отправляйте выполненное задание ввиде zip-архива src
папки, и пожалуйста:
- Тему письма называйте правильно (номер этого задания, как написано в самом верху - 32)
- zip-архив (или 7zip) называйте по тем же правилам, как назывались патчи (только расширение файла теперь не
.patch
, а.zip
)
Т.е. формат такой же как и про патч, только вместо .patch
вы шлете .zip
.