Skip to main content

2D игра*

Дополнительный блок

Выполнять это задание необязательно, оно добавлено для тех, кто планирует писать свою игру.

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

w

Исходники игры можно скачать здесь

Прототип

Для начала сделаем форк приложения, созданного в предыдущих главах. Переходим в его и жмём

w

Дождёмся, пока репозиторийй скопируется

w

Репозиторий скопировался

w

Чтобы поменять название, зайдите в настройки репозитория Settings

w

Название меняется в разделе General

w

Теперь откроем этот репозиторий в Idea

w

Пока что дерево проекта выглядит так

w

Добавим в пакет panels класс PanelGame

w


package panels;


import controls.Label;
import controls.MultiLineLabel;
import io.github.humbleui.jwm.Window;
import io.github.humbleui.skija.Canvas;
import misc.CoordinateSystem2i;

/**
* Панель игры
*/
public class PanelGame extends Panel {
/**
* Заголовок с информацией
*/
private final MultiLineLabel infoLabel;
/**
* Заголовок подсказки
*/
private final Label hintLabel;

/**
* Конструктор панели
*
* @param window окно
* @param drawBG нужно ли рисовать подложку
* @param backgroundColor цвет фона
* @param padding отступы
*/
public PanelGame(Window window, boolean drawBG, int backgroundColor, int padding) {
super(window, drawBG, backgroundColor, padding);
// создаём заголовок
infoLabel = new MultiLineLabel(window, false, backgroundColor, 0,
7, 7, 6, 0, 1, 1,
"Информация", true, true);
// создаём заголовок
hintLabel = new Label(window, false, backgroundColor, 0,
7, 7, 0, 0, 3, 1,
"Для прыжка нажмите ПРОБЕЛ", true, true);
}


/**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// рисуем заголовок информации
infoLabel.paint(canvas, windowCS);
// рисуем заголовок подсказки
hintLabel.paint(canvas, windowCS);
}

}

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

Из панелей прошлого задания нам понадобятся только Panel и GridPanel. Поэтому удалим остальные. Из пакета dialogs нам будет нужна только PanelInfo. Поэтому также удалим класс PanelSelectFile. Ещё нам не пригодится классы Task и Point из пакета app. Их тоже удалим.

w

Idea предупредит нас, что удаляемые нами классы где-то задействованы. Жмём Delete Anyway

w

Теперь у нас два класса с ошибками

w

Очистим класс Application от всех лишних элементов.

package app;

import controls.InputFactory;
import dialogs.PanelInfo;
import io.github.humbleui.jwm.*;
import io.github.humbleui.jwm.skija.EventFrameSkija;
import io.github.humbleui.skija.Canvas;
import io.github.humbleui.skija.Surface;
import misc.CoordinateSystem2i;
import panels.PanelGame;

import java.io.File;
import java.util.Calendar;
import java.util.Date;
import java.util.function.Consumer;

import static app.Colors.*;

/**
* Класс окна приложения
*/
public class Application implements Consumer<Event> {
/**
* Режимы работы приложения
*/
public enum Mode {
/**
* Основной режим работы
*/
WORK,
/**
* Окно информации
*/
INFO
}

/**
* окно приложения
*/
private final Window window;
/**
* отступ приложения
*/
public static final int PANEL_PADDING = 5;
/**
* радиус скругления элементов
*/
public static final int C_RAD_IN_PX = 4;
/**
* кнопка изменений: у мака - это `Command`, у windows - `Ctrl`
*/
public static final KeyModifier MODIFIER = Platform.CURRENT == Platform.MACOS ? KeyModifier.MAC_COMMAND : KeyModifier.CONTROL;
/**
* время последнего нажатия клавиши мыши
*/
Date prevEventMouseButtonTime;
/**
* флаг того, что окно развёрнуто на весь экран
*/
private boolean maximizedWindow;
/**
* Панель информации
*/
private final PanelInfo panelInfo;
/**
* Текущий режим(по умолчанию рабочий)
*/
public static Mode currentMode = Mode.WORK;
/**
* Панель игры
*/
private final PanelGame panelGame;

/**
* Конструктор окна приложения
*/
public Application() {
// создаём окно
window = App.makeWindow();

// панель информации
panelInfo = new PanelInfo(window, true, DIALOG_BACKGROUND_COLOR, PANEL_PADDING);

// панель игры
panelGame = new PanelGame(window, true, DIALOG_BACKGROUND_COLOR, PANEL_PADDING);

// задаём обработчиком событий текущий объект
window.setEventListener(this);
// задаём заголовок
window.setTitle("Java 2D");
// задаём размер окна
window.setWindowSize(900, 900);
// задаём его положение
window.setWindowPosition(100, 100);

// задаём иконку
switch (Platform.CURRENT) {
case WINDOWS -> window.setIcon(new File("src/main/resources/windows.ico"));
case MACOS -> window.setIcon(new File("src/main/resources/macos.icns"));
}

// названия слоёв, которые будем перебирать
String[] layerNames = new String[]{
"LayerGLSkija", "LayerRasterSkija"
};

// перебираем слои
for (String layerName : layerNames) {
String className = "io.github.humbleui.jwm.skija." + layerName;
try {
Layer layer = (Layer) Class.forName(className).getDeclaredConstructor().newInstance();
window.setLayer(layer);
break;
} catch (Exception e) {
System.out.println("Ошибка создания слоя " + className);
}
}

// если окну не присвоен ни один из слоёв
if (window._layer == null)
throw new RuntimeException("Нет доступных слоёв для создания");

// делаем окно видимым
window.setVisible(true);
}

/**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// если событие кнопка мыши
if (e instanceof EventMouseButton) {
// получаем текущие дату и время
Date now = Calendar.getInstance().getTime();
// если уже было нажатие
if (prevEventMouseButtonTime != null) {
// если между ними прошло больше 200 мс
long delta = now.getTime() - prevEventMouseButtonTime.getTime();
if (delta < 200)
return;
}
// сохраняем время последнего события
prevEventMouseButtonTime = now;
}
// кнопки клавиатуры
else if (e instanceof EventKey eventKey) {
// кнопка нажата с Ctrl
if (eventKey.isPressed()) {
if (eventKey.isModifierDown(MODIFIER))
// разбираем, какую именно кнопку нажали
switch (eventKey.getKey()) {
case W -> window.close();
case H -> window.minimize();
case DIGIT1 -> {
if (maximizedWindow)
window.restore();
else
window.maximize();
maximizedWindow = !maximizedWindow;
}
case DIGIT2 -> window.setOpacity(window.getOpacity() == 1f ? 0.5f : 1f);
}
else
switch (eventKey.getKey()) {
case ESCAPE -> {
if (currentMode.equals(Mode.WORK)) {
window.close();
// завершаем обработку, иначе уже разрушенный контекст
// будет передан панелям
return;
}
}
case TAB -> InputFactory.nextTab();
}
}
}
// если событие - это закрытие окна
else if (e instanceof EventWindowClose) {
// завершаем работу приложения
App.terminate();
// закрытие окна
} else if (e instanceof EventWindowCloseRequest) {
window.close();
} else if (e instanceof EventFrameSkija ee) {
Surface s = ee.getSurface();
paint(s.getCanvas(), new CoordinateSystem2i(0, 0, s.getWidth(), s.getHeight())
);
} else if (e instanceof EventFrame) {
// запускаем рисование кадра
window.requestFrame();
}

switch (currentMode) {
case INFO -> panelInfo.accept(e);
case WORK -> panelGame.accept(e);
}
}

/**
* Рисование
*
* @param canvas низкоуровневый инструмент рисования примитивов от Skija
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// запоминаем изменения (пока что там просто заливка цветом)
canvas.save();
// очищаем канвас
canvas.clear(APP_BACKGROUND_COLOR);
// рисуем панели
panelGame.paint(canvas, windowCS);
canvas.restore();

// рисуем диалоги
switch (currentMode) {
case INFO -> panelInfo.paint(canvas, windowCS);
}
}
}

В классе CoordinateSystem2d заменим PanelLog.warning() на System.out.println()

    /**
* Масштабировать СК пропорционально
*
* @param s коэффициент
* @param center центр масштабирования
*/
public void scale(double s, Vector2d center) {
// если центр масштабирования находится вне СК
if (!checkCoords(center)) {
System.out.println("центр масштабирования находится вне области");
return;
}
...

Теперь очистим Unit-тесты

import org.junit.Test;

/**
* Класс тестирования
*/
public class UnitTest {

/**
* Первый тест
*/
@Test
public void test1() {
assert true;
}

/**
* Второй тест
*/
@Test
public void test2() {
assert true;
}

}

Запустим игру

w

Класс игры

Напишем теперь класс игры. Создадим пакет games и добавим в него класс Game. Т.к. игра у нас может быть всего одна, то логику работы этого класса мы построем на основе паттерна проектирования Singleton (синглтон).

Для того чтобы создать синглтон, нужно создать закрытое статическое поле с объектом самого класса, а также закрытый конструктор. Этот конструктор вызывается из статического метода get() в котором если поле, хранящее объект этого класса, не инициализировано, то создаётся новый объект класса, сохраняется в это поле и возвращается. Если поле уже инициализировано, то оно просто возвращается.

package game;

import io.github.humbleui.skija.Canvas;
import misc.CoordinateSystem2d;
import misc.CoordinateSystem2i;

import java.util.*;

/**
* Синглтон класс игры
*/
public class Game {
/**
* СК игры
*/
private final CoordinateSystem2d ownCS;
/**
* Результат игры
*/
private double score;
/**
* Последняя полученная СК окна
*/
private CoordinateSystem2i lastWindowCS;
/**
* Предыдущее время обработки
*/
private static Date prevTime;
/**
* Флаг, остановлена ли игра
*/
private boolean paused;
/**
* сама игра
*/
private static Game thisGame;

/**
* Конструктор игры
*/
private Game() {
// инициализируем СК игры
ownCS = new CoordinateSystem2d(-50, -300, 1000, 1000);
// перезапускаем игру
restart();
}

/**
* Получить ссылку на игру
*
* @return синглтон игры
*/
public static Game getGame() {
if (thisGame == null)
thisGame = new Game();
return thisGame;
}

/**
* Перезапустить игру
*/
public void restart() {
// результат равен 0
score = 0;
// игра запущена
paused = false;
}

/**
* Получить время с прошлого вызова
*
* @return сколько времени прошло с момента прошлого вызова этого метода
*/
private static double getDeltaTime() {
// если время ещё ни разу не сохранялось
if (prevTime == null) {
// сохраняем его
prevTime = Calendar.getInstance().getTime();
// возвращаем 0
return 0;
}
// получаем текущие дату и время
Date now = Calendar.getInstance().getTime();
// получаем, сколько времени прошло с прошлого запуска в миллисекундах
long delta = now.getTime() - prevTime.getTime();
// сохраняем время запуска
prevTime = now;
// возвращаем время в секундах
return (double) delta / 1e3;
}


/**
* Обработка игры
*/
public void process() {
// если игра на паузе
if (paused)
// останавливаем её обработку
return;

// рассчитываем, сколько времени прошло с прошлой обработки
double dt = getDeltaTime();
}

/**
* Получить результат
*
* @return результат
*/
public double getScore() {
return score;
}

/**
* Рисование задачи
*
* @param canvas область рисования
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// сохраняем область рисования
canvas.save();

// восстанавливаем область рисования
canvas.restore();
// сохраняем СК экрана
lastWindowCS = windowCS;
}


/**
* Завершение игры
*/
private void gameOver() {
// останавливаем игру
paused = true;
}

}

Часть методов пока что ничего не делают, это просто заглушки, чтобы потом их дописать.

Почти вся инициализация перенесена в метод restart(), чтобы потом было проще перезапускать игру.

Об архитектуре игр

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

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

Теперь перейдём в класс PanelGame и добавим ему поле игры

    /**
* Игра
*/
public static final Game game = Game.getGame();

и добавим вызов р игры

    /**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// рисуем заголовок информации
infoLabel.paint(canvas, windowCS);
// рисуем заголовок подсказки
hintLabel.paint(canvas, windowCS);
// обрабатываем игру
game.process();
// рисуем игру
game.paint(canvas, windowCS);
}

Пока что ничего не изменилось

w

Для работы с игровыми объектами нам понадобится умножение вектора на скаляр. Для этого в классе Vector2d допишем метод mult()

    /**
* Умножение вектора на число
*
* @param s число
* @return новый вектор
*/
public Vector2d mult(double s) {
return new Vector2d(s * x, s * y);
}

Теперь в пакете game пропишем игровой объект GameObject. От него потом мы будем наследоваться в классах игрока и врагов.

package game;

import io.github.humbleui.skija.Canvas;
import io.github.humbleui.skija.Paint;
import io.github.humbleui.skija.RRect;
import misc.CoordinateSystem2d;
import misc.CoordinateSystem2i;
import misc.Vector2d;
import misc.Vector2i;

/**
* Игровой объект
*/
public class GameObject {
/**
* Положение
*/
public Vector2d pos;
/**
* Скорость
*/
public Vector2d speed;
/**
* Ускорение
*/
public Vector2d acc;
/**
* Размер
*/
private final float size;
/**
* Цвет
*/
private final int color;

/**
* Конструктор игрового объекта
*
* @param pos положение
* @param speed скорость
* @param acc ускорение
* @param size размер
* @param color цвет
*/
public GameObject(Vector2d pos, Vector2d speed, Vector2d acc, float size, int color) {
this.pos = pos;
this.speed = speed;
this.acc = acc;
this.size = size;
this.color = color;
}

/**
* Метод обработки
* <p>
* При наследовании необходимо вызывать обработку предка super.process(dt)
*
* @param dt изменение времени
*/
public void process(double dt) {
pos.add(speed.mult(dt));
speed.add(acc.mult(dt));
}

/**
* Рисование
*
* @param canvas область рисования
* @param windowCS СК окна
* @param ownCS СК игры
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS, CoordinateSystem2d ownCS) {
try (Paint paint = new Paint().setColor(color)) {
Vector2i windowPos = windowCS.getCoords(pos, ownCS);
canvas.drawRRect(RRect.makeXYWH(
windowPos.x - size / 2,
windowCS.getMax().y - windowPos.y - size / 2,
size, size, 4), paint);
}
}
}

Теперь добавим простейшие классы игрока и врагов

package game;

import misc.Misc;
import misc.Vector2d;

/**
* Класс игрока
*/
public class Player extends GameObject {
/**
* Конструктор игрового объекта
*
* @param pos положение
* @param speed скорость
* @param acc ускорение
* @param size размер
*/
public Player(Vector2d pos, Vector2d speed, Vector2d acc, float size) {
super(pos, speed, acc, size, Misc.getColor(150, 50, 200, 50));
}

}

Игрок

Добавим теперь поле игрока в класс игры Game:

    /**
* Игрок
*/
private Player player;

Также добавим константу размера игрока

    /**
* Размер игрока
*/
private static final float PLAYER_SIZE = 40;

Теперь инициализируем игрока в методе restart

        ...
// создаём игрока, на которого действует гравитация по оси X
player = new Player(
new Vector2d(0, 0), new Vector2d(0, 0), new Vector2d(0, -9000), PLAYER_SIZE
);
...

Также добавим рисование игрока:

    /**
* Рисование задачи
*
* @param canvas область рисования
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// сохраняем область рисования
canvas.save();
// рисуем игрока
player.paint(canvas, windowCS, ownCS);
// восстанавливаем область рисования
canvas.restore();
// сохраняем СК экрана
lastWindowCS = windowCS;
}

Теперь на экране появился игрок

w

Чтобы игрок начал прыгать, добавим метод up() в класс игры, который будет просто сообщать игроку вертикальную скорость.

Для этого сначала введём соответствующую константу

    /**
* Начальная скорость игрока при прыжке
*/
private static final float PLAYER_JUMP_SPEED = 3000;

а потом пропишем метод up()

    /**
* Прыжок
*/
public void up() {
if (player.pos.y == 0)
player.speed.y = PLAYER_JUMP_SPEED;
}

Также пропишем метод обработки игрока procssPlayer() и вызовем его из process()

   /**
* Обработка игры
*/
public void process() {
// если игра на паузе
if (paused)
// останавливаем её обработку
return;

// рассчитываем, сколько времени прошло с прошлой обработки
double dt = getDeltaTime();
// обработка игрока
processPlayer(dt);
}

/**
* Обработка игрока
*
* @param dT сколько времени прошло, с прошлой обработки в секундах
*/
private void processPlayer(double dT) {
// обрабатываем игрока
player.process(dT);
// если игрок должен "уйти под землю"
if (player.pos.y <= 0) {
// обнуляем его положение
player.pos.y = 0;
// обнуляем скорость
player.speed = new Vector2d(0, 0);
}
}

Теперь вызовем его из класса Application

        ...
else if (e instanceof EventKey eventKey) {
// кнопка нажата с Ctrl
if (eventKey.isPressed()) {
if (eventKey.isModifierDown(MODIFIER))
// разбираем, какую именно кнопку нажали
switch (eventKey.getKey()) {
case W -> window.close();
case H -> window.minimize();
}
else
switch (eventKey.getKey()) {
case W, SPACE -> PanelGame.game.up();
case ESCAPE -> window.close();
}
}
}
...

Прыжок заработал

w

Враги

Добавим константы врагов

    /**
* Размер противника
*/
private static final float ENEMY_SIZE = 40;
/**
* Ускорение врагов
*/
private static final float ENEMY_ACC = 5.0f;
/**
* Ускорение врагов
*/
private static final float ENEMY_START_SPEED = 300.0f;

Теперь добавим переменные врагов

    /**
* Враги
* хранятся в LinkedList, потому что часто удаляются и добавляются
*/
private final List<Enemy> enemies = new LinkedList<>();
/**
* Скорость у новых врагов при появлении
*/
private float newEnemySpeed;

В restart() добавим инициализацию врагов

        ...
// скорость врага берётся со знаком минус, т.к. враги
// формируются на правом краю экрана и движутся влево,
// т.е. против оси X
newEnemySpeed = -ENEMY_START_SPEED;
// очищаем список врагов
enemies.clear();
...

Допишем геттер

    /**
* Получить скорость нового врага
*
* @return скорость нового врага
*/
public float getNewEnemySpeed() {
return Math.abs(newEnemySpeed);
}

Также пропишем обработку врагов

    /**
* Обрабатываем врагов
*
* @param dT время, прошедшее с предыдущей обработки в секундах
*/
private void processEnemies(double dT) {
// составляем список врагов, которых уже можно удалить, потому что
// удалять элементы из того же списка, который читаем нельзя
List<Enemy> enemiesToRemove = new ArrayList<>();
// перебираем врагов
for (Enemy enemy : enemies) {
// если враг выходит за СК игры
if (!ownCS.checkCoords(enemy.pos)) {
// добавляем его в список на удаление
enemiesToRemove.add(enemy);
}
}
// удаляем всех игроков
enemies.removeAll(enemiesToRemove);

// запускаем обработку всех оставшихся врагов
for (Enemy enemy : enemies)
enemy.process(dT);

// довольно редко срабатывающий рандом
if (Math.random() < 0.01)
// добавляем нового врага
addEnemy();
// увеличиваем скорость нового врага на
newEnemySpeed -= ENEMY_ACC * dT;
}

/**
* Добавить врага
*/
private void addEnemy() {
if (lastWindowCS == null)
return;
if (!enemies.isEmpty() && enemies.get(enemies.size() - 1).pos.x > ownCS.getSize().x / 2) {
return;
}
enemies.add(new Enemy(
new Vector2d(ownCS.getMax().x - 20, 0),
new Vector2d(newEnemySpeed, 0),
new Vector2d(ENEMY_ACC, 0),
ENEMY_SIZE
));
}

Добавим обработку игроков в process()

   /**
* Обработка игры
*/
public void process() {
// если игра на паузе
if (paused)
// останавливаем её обработку
return;

// рассчитываем, сколько времени прошло с прошлой обработки
double dt = getDeltaTime();
// обработка игрока
processPlayer(dt);
processEnemies(dt);
}

Теперь нам осталось добавить рисование врагов

    /**
* Рисование задачи
*
* @param canvas область рисования
* @param windowCS СК окна
*/
public void paint(Canvas canvas, CoordinateSystem2i windowCS) {
// сохраняем область рисования
canvas.save();
// рисуем игрока
player.paint(canvas, windowCS, ownCS);
// рисуем врагов
for (Enemy enemy : enemies) {
enemy.paint(canvas, windowCS, ownCS);
}
// восстанавливаем область рисования
canvas.restore();
// сохраняем СК экрана
lastWindowCS = windowCS;
}

w

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

Панель информации

Переименуем теперь класс PanelInfo в PanelGameInfo и немного модифицируем его. Панель информации из прошлого приложения просто скрывалась при клике по кнопке, а эта панель будет выполнять те действия, которые будут переданы в анонимном классе onClick

package dialogs;

import app.Application;
import controls.Button;
import controls.MultiLineLabel;
import io.github.humbleui.jwm.*;
import io.github.humbleui.skija.Canvas;
import misc.CoordinateSystem2i;
import misc.Vector2i;
import panels.Panel;

import static app.Colors.BUTTON_COLOR;


/**
* Панель управления
*/
public class PanelGameInfo extends Panel {
/**
* Отступы в панели управления
*/
private static final int CONTROL_PADDING = 5;

/**
* Кнопка принять
*/
private final Button accept;
/**
* заголовок
*/
private final MultiLineLabel infoLabel;
/**
* текст заголовка делаем статическим, чтобы можно было менять его
* из любого места, каждый экземпляр панели информации будет обращаться
* к этому полю при рисовании, но логика работы программы
* не предполагает создания нескольких экземпляров, так что всё ок
*/
private static String labelText;

/**
* Обработчик нажатия кнопки ОК на панели
*/
Runnable onClick;


/**
* Панель управления
*
* @param window окно
* @param drawBG флаг, нужно ли рисовать подложку
* @param color цвет подложки
* @param padding отступы
*/
public PanelGameInfo(Window window, boolean drawBG, int color, int padding,
Runnable onClick) {
super(window, drawBG, color, padding);

// добавление вручную
infoLabel = new MultiLineLabel(window, false, backgroundColor, CONTROL_PADDING,
1, 2, 0, 0, 1, 1, "",
true, true);

accept = new Button(
window, false, BUTTON_COLOR, CONTROL_PADDING,
1, 2, 0, 1, 1, 1, "ОК",
true, true);
accept.setOnClick(onClick);
this.onClick = onClick;
}


/**
* Вывести информацию
*
* @param text текст
*/
public static void show(String text) {
// задаём новый текст
labelText = text;
// переключаем вывод приложения на режим информации
Application.currentMode = Application.Mode.INFO;
}

/**
* Обработчик событий
*
* @param e событие
*/
@Override
public void accept(Event e) {
// вызываем обработчик предка
super.accept(e);
// событие движения мыши
if (e instanceof EventMouseMove ee) {
accept.checkOver(lastWindowCS.getRelativePos(new Vector2i(ee)));
// событие нажатия мыши
} else if (e instanceof EventMouseButton) {
if (!lastInside)
return;

Vector2i relPos = lastWindowCS.getRelativePos(lastMove);
accept.click(relPos);
// перерисовываем окно
window.requestFrame();
// обработчик ввода текста
} else if (e instanceof EventKey ee) {
if (ee.isPressed()) {
// получаем код клавиши
Key key = ee.getKey();
// перебираем варианты
switch (key) {
// если esc
case ESCAPE -> onClick.run();
// если enter
case ENTER -> onClick.run();
}
}
}
}

/**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
infoLabel.text = labelText;
accept.paint(canvas, windowCS);
infoLabel.paint(canvas, windowCS);
}
}

Мы будем показывать панель информации, когда игрок проиграл.

Для начала нам нужно написать в классе Vectord2d метод, рассчитывающий расстояние между двумя точками.

    /**
* Получить расстояние между векторами
*
* @param a первый вектор
* @param b второй вектор
* @return расстояние
*/
public static double distance(Vector2d a, Vector2d b) {
double dx = b.x - a.x;
double dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
}

Теперь в классе игры Game в обработке игрока processPlayer() добавим проверку, не сталкивается ли он с тем или иным врагом

        ...
// перебираем врагов
for (Enemy enemy : enemies)
// если есть контакт с врагом
if (Vector2d.distance(player.pos, enemy.pos) < (PLAYER_SIZE + ENEMY_SIZE) / 2)
// оканчиваем игру
gameOver();

// вычитаем из результата пройденное расстояние самым быстрым врагом
// просто само расстояние как функция от скорости отрицательное
// поэтому для увеличения положительного результата мы
// вычетаем его
score -= newEnemySpeed * dT;
...

Также добавим вывод информации на панель в методе gameOver()

    /**
* Завершение игры
*/
private void gameOver() {
// останавливаем игру
paused = true;
// выводим панель информации
PanelGameInfo.show("Игра окончена\nВаш результат: " + String.format("%.1f", score));
}

Панель информации работает

w

Теперь нам осталось только дописать вывод информации во время игры, для этого будем задавать текст заголовка прямо в рисовании панели

/**
* Метод под рисование в конкретной реализации
*
* @param canvas область рисования
* @param windowCS СК окна
*/
@Override
public void paintImpl(Canvas canvas, CoordinateSystem2i windowCS) {
// задаём текст заголовка
infoLabel.text = String.format(
"Пройдено %.1f\n Скорость: %.1f", game.getScore(), game.getNewEnemySpeed()
);
// рисуем заголовок информации
infoLabel.paint(canvas, windowCS);
// рисуем заголовок подсказки
hintLabel.paint(canvas, windowCS);
// обрабатываем игру
game.process();
// рисуем игру
game.paint(canvas, windowCS);
}

Всё готово

w

Исходники игры можно скачать здесь