Всем доброго времени суток. В предыдущей статье я затронул тему клиентской разработки браузерной игры. В этой же публикации попробую пролить свет на детали разработки мультиплеера, а именно - разработки игрового websocket-tcp сервера на Netty.

О себе

Меня зовут Бальцер Артем, я Java разработчик с более чем 8-летним опытом работы в различных коммерческих проектах на данный момент. Не смотря на то, что формально всю жизнь мне нравился Backend, на самом деле, являюсь фулстеком, очень люблю интерактивные высоконагруженные приложения, люблю программировать игры, компьютерную графику, создавать физические модели и много чего еще, а страсть к делу - залог качественного воплощения идей и крепкого фундамента знаний как это сделать.

  • Java fullstack разработчик

  • В свободное время - преподаватель, ментор

  • Огромный опыт в создании веб-сервисов, в том числе высоконагруженных, коммерческих API

  • Есть большой собственный проект на стадии разработки - 2D io-мультиплеер(Java 19, Netty4, Phaser3, React, Redux, Typescript, Webpack, Websocket, PostgreSQL). Готовность к релизу на момент написания статьи - 60%.

Концепт

Итак, без лишней воды начнем с концепта демо-проекта. Статья обещает быть длинной портянкой, не смотря на сильное упрощение, поэтому запаситесь терпением.

  1. Что мы имеем(на бэкенде, естественно)? Java; почти всю силу ООП; NIO-фреймворк Netty для работы с сетью, подключениями и сообщениями; Spring; Maven; многопоточность.

  2. Что мы хотим? Допустим, для начала - хотим авторизовываться, подключаться к игровой комнате, заставить кружок двигаться влево-вправо, вверх-вниз на игровом поле, а затем, по истечению определенного времени, закрыть игровую комнату.

  3. Как мы это сделаем? Опишу конфигурацию проекта и его зависимости, далее набросаю на коленке клиент на чистом JS, HTML, CSS. Затем поэтапно расскажу что происходит по ту сторону демки, разобрав основные узлы машины.

Частью режима Battle-royale здесь очевидно является Matchmaker, манипулирующий игровыми комнатами.

Стек

  1. Java 18

  2. Spring boot 3.1.5

  3. Netty 4.1.101.Final

Это все что нужно.

Сразу может возникнуть вопрос, почему я выбрал чистый Netty, а не их аналоги reactor-netty, webflux и т.д.? Потому что реактивное программирование для демки не подходит по многим причинам. Для подобного рода проектов всегда требуется более низкоуровневый подход с возможностью управления различными тонкими частями приложения с целью влияния на производительность. Чистый Netty позволяет напрямую контроллировать канал, его Pipeline и многие другие аспекты фреймворка.

Сборка

Исходник проекта здесь.

В качестве сборщика проекта и менеджера зависимостей используется Maven

pom.xml выглядит следующим образом:

<?xml version="1.0" encoding="ISO-8859-15"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <name>GameNettyServer</name>

    <groupId>com.tfkfan</groupId>
    <artifactId>game-netty-server</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <spring.boot.version>3.1.5</spring.boot.version>
        <netty.version>4.1.101.Final</netty.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.28</version>
        </dependency>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>${netty.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>${spring.boot.version}</version>
        </dependency>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.9</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring.boot.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <mainClass>com.tfkfan.nettywebgame.Application</mainClass>
                    <!--
                    Enable the line below to have remote debugging of your application on port 5005
                    <jvmArguments>-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005</jvmArguments>
                    -->
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.5.1</version>
                <configuration>
                    <optimize>true</optimize>
                    <source>18</source>
                    <target>18</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Все достаточно просто, все что нам нужно - netty-сервер и tomcat. Для удобства демо клиент доступен на этом же сервере в статических ресурсах - используется web стартер, который и будет служить этим контейнером. Разумеется вы всегда можете отделить web от этого проекта, закинув на отдельно поднятый инстанс Tomcat или любой другой, попутно убрав web-starter зависимость внутри демки.

Сборка и запуск

./mvnw clean verify spring-boot:run

Клиент

Клиентское приложение находится в статике /src/main/resources/static. Краткое описание:

  • index.html - страница клиентской части приложения

  • app.js - отрисовка объектов и обработка сообщений

  • network.js - функционал для работы с сетью (websocket)

  • types.js - справочник типов сообщений

  • styles.css - таблица стилей приложения

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

Сервер

Не смотря на то, что по факту запуска приложения, очевидно, стартуют 2 разных сервера по разным портам - http(8080), tcp-websocket(8081), наибольший интерес конечно же вызывает второй, он же и составляет почти 100% написанного кода.

Серверный код разделен на следующие пакеты:

  • config - Конфигурация приложения, пропертя, константы

  • event - Функционал для работы с внутриигровыми событиями

  • game - Внутрикомнатная игровая логика, игровые модели

  • networking - Работа с сетью, основной подкапотный функционал

  • service - Сервисы, отвечающие за конкретную функцию в приложении, например, авторизация

Рассмотрим работу с сетью и пакет networking.

Networking. Работа с сетью и конфигурация сервера

Работа сервера начинается разумеется с Application.java, который в свою очередь поднимает Websocket-netty-сервер:

@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketServer {
    private final ApplicationProperties applicationProperties;
    private final DefaultWebsocketInitializer customWebSocketServerInitializer;

    public void start() throws InterruptedException {
        log.info("WebSocketServer is starting...");
        log.info("Reserved {} threads", applicationProperties.getServer().getWorkerThreads()
                + applicationProperties.getServer().getEventLoopThreads()
                + applicationProperties.getServer().getGameThreads());
        EventLoopGroup boss = new NioEventLoopGroup(applicationProperties.getServer().getEventLoopThreads());
        EventLoopGroup worker = new NioEventLoopGroup(applicationProperties.getServer().getWorkerThreads());
        ServerBootstrap boot = new ServerBootstrap();
        boot.group(boss, worker)
                .channel(NioServerSocketChannel.class)
                .childHandler(customWebSocketServerInitializer);
        ChannelFuture future = boot.bind(applicationProperties.getServer().getPort()).sync();
        future.addListener(evt -> log.info("Started ws server, active port:{}", applicationProperties.getServer().getPort()));
        future.channel().closeFuture().addListener((evt) -> {
            log.info("WebSocketSocket is closing...");
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }).sync();
    }
}

По классике tcp-netty-сервера создается master-slave группа EventLoop, отвечающая за обработку входящих сообщений по сети, и, далее, объединяется в ServerBootstrap как полноценный TCP-сервер по выделенному порту. Стоит обратить, что строка 10 выводит кол-во зарезервированных потоков как для самого сервера, так и для внутреннего ExecutorService, отвечающего за обработку игровых комнат, но об этом позднее.

Пара слов о Netty и как этот фреймворк работает.

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

Упрощенная схема архитектуры netty
Упрощенная схема архитектуры netty

Соединение по сети, а если быть точнее неблокирующий поток ввода/вывода сообщений для соединения, называется каналом Channel. В канал можно как писать, так и читать из него сообщения, а также добавлять аттрибуты, которые держатся только на сервере, что очень похоже на сервлет-сессию пользователя.

Попадая на сервер, в рамках своего канала сообщение идет по трубе, так называемому ChannelPipeline, представляющему собой коллекцию обработчиков сообщений ChannelHandler, отсортированную строго в порядке добавления элемента в него (разработчик разумеется сам конфигурирует и добавляет элементы Pipeline). Одними из обработчиков являются парсеры, кодирующие/декодирующие входящие сообщения. Между ними вызываются обработчики бизнес логики (далее - ОБЛ). Взависимости от типа(класса) сообщения оно попадет в тот или иной ОБЛ. Будьте внимательны при парсинге.

ChannelPipeline
ChannelPipeline

Каждый из обработчиков в свой момент времени всегда выполняется одним из worker-thread, что закрывает потребность в синхронизации, при условии грамотно реализованной бизнес логики и отсутствия разделяемого состояния между обработчиками. Таким образом, netty очень хорошо экономит ресурсы и позволяет зачастую читать и обрабатывать миллионы сообщений в секунду.

Протоколы ChannelPipeline

При старте Bootstrap также имеет место стартовый инициализатор канала, который в том числе инициализирует и ChannelPipeline:

@Component
public class DefaultWebsocketInitializer extends ChannelInitializer<Channel> {
    private final InitialGameHandler webSocketHandlerMain;
    private final PingPongWebsocketHandler pingPongWebsocketHandler;
    private final TextWebsocketDecoder textWebsocketDecoder;

    public DefaultWebsocketInitializer(InitialGameHandler webSocketHandlerMain, PingPongWebsocketHandler pingPongWebsocketHandler, TextWebsocketDecoder textWebsocketDecoder) {
        this.webSocketHandlerMain = webSocketHandlerMain;
        this.pingPongWebsocketHandler = pingPongWebsocketHandler;
        this.textWebsocketDecoder = textWebsocketDecoder;
    }

    @Override
    protected void initChannel(Channel channel) {
        ChannelPipeline pipeline = channel.pipeline();
        pipeline.addLast("decoder", new HttpRequestDecoder());
        pipeline.addLast("aggregator", new HttpObjectAggregator(ServerConstants.DEFAULT_OBJECT_AGGREGATOR_CONTENT_LENGTH));
        pipeline.addLast("handler", new WebSocketServerProtocolHandler(ServerConstants.WEBSOCKET_PATH));
        pipeline.addLast(ServerConstants.PING_PONG_HANDLER_NAME, pingPongWebsocketHandler);
        pipeline.addLast(ServerConstants.TXT_WS_DECODER, textWebsocketDecoder);
        pipeline.addLast(ServerConstants.INIT_HANDLER_NAME, webSocketHandlerMain);
        pipeline.addLast("encoder", new HttpResponseEncoder());
    }
}

Что почти тоже самое по назначению, что и классы, имплиментирующие интерфейс pipeline-протокола GameChannelMode. Стартовый обработчик InitialGameHandler отвечает исключительно за авторизацию пользователя.

Протоколы необходимы для изменения структуры пайплайна отдельной сессии/канала в тот или иной момент времени.

public interface GameChannelMode {
    String GAME_CHANNEL_MODE = "GAME_CHANNEL_MODE";
    String OUT_OF_ROOM_CHANNEL_MODE = "OUT_OF_ROOM_CHANNEL_MODE";

    String getModeName();

    <T extends Session> void apply(T playerSession);

    <T extends Session> void apply(T playerSession,
               boolean clearExistingProtocolHandlers);

   <T extends Session> void apply(Collection<T> playerSessions);
}

Среди всех GameChannelMode выделены:

  1. MainGameChannelMode - главный игровой протокол, переводит сессию игрока на связанные с игровыми комнатами realtime обработчики.

  2. OutOfRoomChannelMode - протокол, переводящий на внеигровую логику (вне игровых комнат).

Сессия

Что такое сессия игрока PlayerSession? Это просто контейнер клиентских данных внутри аттрибутов канала, привязанный к отдельному клиенту/соединению и хранящий также объект Player и ключ комнаты, в которой этот игрок в данный момент находится.

Сообщения

Для парсинга TextWebsocketFrame используются кодер и декодер:

@Sharable
@Component
public class TextWebsocketDecoder extends MessageToMessageDecoder<TextWebSocketFrame> {
    private final Gson gson = new Gson();

    @Override
    protected void decode(ChannelHandlerContext ctx, TextWebSocketFrame frame, List<Object> out) {
        final String json = frame.text();
        final PlayerSession ps = PlayerSession.getPlayerSessionFromChannel(ctx.channel());
        if (Objects.nonNull(ps)) {
            PlayerMessage playerMsg = gson.fromJson(json, IncomingPlayerMessage.class);
            playerMsg.setSession(ps);
            out.add(playerMsg);
        } else {
            Message msg = gson.fromJson(json, IncomingMessage.class);
            out.add(msg);
        }
    }
}
@Sharable
@Component
public class TextWebsocketEncoder extends MessageToMessageEncoder<Message> {
    private final Gson gson = new Gson();

    @Override
    protected void encode(ChannelHandlerContext ctx, Message msg, List<Object> out) {
        out.add(new TextWebSocketFrame(gson.toJson(msg)));
    }
}

Аннотация @Sharable означает, что обработчик сообщений будет использован в одном экземпляре (Singleton). Можете убрать на ваше усмотрение взависимости от требований к производительности. Также для повышения производительности рекомендуется использовать бинарные данные BinaryWebsocketFrame и TypedArray в качестве формата сообщений вместо тяжеловесного JSON.

Что касается самих сообщений, которыми обмениваются адаптеры/обработчики, используются потомки класса Message, среди которых есть входящие сообщения IncomingMessage, IncomingPlayerMessage и исходящие OutcomingMessage, OutcomingPlayerMessage, которые в свою очередь наследуются от абстрактного сообщения:

@Data
@NoArgsConstructor
@AllArgsConstructor
public abstract class AbstractMessage implements Message, Serializable {
    protected int type;
    protected Object data;
}

Из структуры которого, видно что в нем есть всего 2 поля - тип сообщения и сами данные в виде объекта, формат которых может быть заранее неизвестен. Но если речь идет об исходящих данных, то здесь используются пакеты Pack. Это небольшие порции данных, помещаемые ввнутрь сообщения, разделенные по назначению:

  • InitPack - пакеты инициализации игровых объектов на поле боя

  • UpdatePack - пакеты обновления состояния объектов

  • SharedPack - пакеты отвечающие за кусок логики, не связанный с игровым процессом напрямую, в том числе и контейнер ошибки ExceptionPack.

  • RemovePack - пакеты очистки и удаления части состояния игровых объектов.

Обработчики

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

Начиная свою игровую сессию игрок попадает в стартовый websocket-обработчик InitialGameHandler:

@Slf4j
@ChannelHandler.Sharable
@Component
public class InitialGameHandler extends AbstractGameHandler<IncomingMessage> {
    public InitialGameHandler(OutOfRoomChannelMode outOfRoomChannelMode, AuthService authService) {
        super(new EventDispatcher<>());
        addEventListener(MessageType.AUTHENTICATION, AuthEvent.class, event -> {
            outOfRoomChannelMode.apply(authService.authenticate(event.getChannel(), event.getBearerToken()));
            send(event.getChannel(), new OutcomingMessage(MessageType.AUTHENTICATION));
        });
    }
}

В котором находится только функционал авторизации.

После успешной авторизации и применения соответствующего протокола, он попадает в OutOfRoomGameHandler:

@Slf4j
@Component
@ChannelHandler.Sharable
public class OutOfRoomGameHandler extends AbstractGameHandler<IncomingPlayerMessage> {
    protected GameRoomManagementService gameRoomManagementService;

    public OutOfRoomGameHandler() {
        super(new EventDispatcher<>());
        addEventListener(MessageType.JOIN, GameRoomJoinEvent.class,
                event -> gameRoomManagementService.addPlayerToWait(event));
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        gameRoomManagementService.removePlayerFromWaitQueue(PlayerSession.getPlayerSessionFromChannel(ctx));
    }

    @Autowired
    public void setGameRoomManager(@Lazy GameRoomManagementService gameRoomManagementService) {
        this.gameRoomManagementService = gameRoomManagementService;
    }
}

Где при получении события JOIN, сервис управления игровыми комнатами, являющийся одним из центральных сервисов, помещает сессию пользователя в очередь, либо удаляет из нее, если соединение оборвалось (channelInactive).

Разумеется, циклическая зависимость тут необходима и это архитектурно правильно, не нужно этого пугаться. Дабы избежать ошибки - использовалась Lazy-инициализация.

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

@Slf4j
@Component
@ChannelHandler.Sharable
public class MainGameHandler extends AbstractGameHandler<PlayerMessage> {
    private GameRoomManagementService gameRoomManagementService;

    public MainGameHandler() {
        super(new EventDispatcher<>());
        addEventListener(MessageType.PLAYER_KEY_DOWN, KeyDownPlayerEvent.class,
                event -> gameRoomManagementService.getRoomByKey(event.getSession().getRoomKey()).ifPresent(room -> room.onPlayerKeyDown(event)));
    }

    @Autowired
    public void setGameRoomService(@Lazy GameRoomManagementService gameRoomManagementService) {
        this.gameRoomManagementService = gameRoomManagementService;
    }
}

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

Далее рассмотрим пакет game.

Game. Игровая логика и комнаты

Схема работы игровой комнаты по протоколу MainChannelGameMode
Схема работы игровой комнаты по протоколу MainChannelGameMode

Основу игровой логики представляет игровая комната - GameRoom

public interface GameRoom extends Runnable {
    void start(long startDelay, long endDelay, long loopRate);

    void onRoomCreated(List<PlayerSession> playerSessions);

    void onRoomStarted();

    void onBattleStarted();

    void onBattleEnded();

    void onDestroy(List<PlayerSession> playerSessions);
  
    void onDestroy(List<PlayerSession> playerSessions, Consumer<PlayerSession> callback);

    void onDisconnect(PlayerSession session);

    void update();

    Collection<PlayerSession> sessions();

    int currentPlayersCount();

    Optional<PlayerSession> getPlayerSessionBySessionId(Session session);

    UUID key();

    void send(PlayerSession playerSession, Message message);

    void sendBroadcast(Message message);

    void sendBroadcast(Function<PlayerSession, Message> function);

    Collection<PlayerSession> close();

    ScheduledExecutorService getRoomExecutorService();
}

Что представляет собой в сущности stateful обертку вокруг Runnable-задачи, которая является итерацией внутриигрового цикла (см. метод update). Экземпляр комнаты отвечает за следующие функции:

  • Хранение, использование и утилизацию сессий игроков

  • Коммуникацию с менеджером комнат

  • Коммуникацию с клиентом

  • Вызов, обработку и планирование внутриигровых событий

  • Обработку игровых моделей

Логика работы игровой комнаты устроена на поочередном запуске узловых событий:

  1. onRoomCreated - вызывается сразу после создания комнаты и инициализации сессий игроков в ней.

  2. onRoomStarted - вызывается практически одновременно с onRoomCreated, когда игровая комната готова к работе.

  3. Старт игрового цикла и вызов onBattleStarted - по истечению отсрочки старта битвы, служащей для ожидания подключения всех игроков, ScheduledExecutorService запускает вызов метода onBattleStarted и периодическое выполнение метода run игровой комнаты, а он в свою очередь оборачивает вызов метода update. Интервал обновления состояния задается в конфиге application.room.loop-rate. Параметр отсрочки старта задается в конфиге application.room.start-delay.

  4. Завершение игрового цикла и вызов onBattleEnded - вызывается по истечению времени битвы (задается в конфиге application.room.end-delay).

Полностью готовый класс игровой комнаты выглядит так:

@Slf4j
public class DefaultGameRoom extends AbstractGameRoom {
    // Игровое поле и, по совместительству, контейнер игроков
    private final GameMap gameMap;
    // Флаг старта баталии
    private final AtomicBoolean started = new AtomicBoolean(false);
    // Параметры комнаты
    private final RoomProperties roomProperties;

    public DefaultGameRoom(GameMap gameMap, UUID gameRoomId,
                           GameRoomManagementService gameRoomManagementService, ScheduledExecutorService schedulerService,
                           RoomProperties roomProperties) {
        super(gameRoomId, gameRoomManagementService, schedulerService);
        this.gameMap = gameMap;
        this.roomProperties = roomProperties;
    }

    @Override
    // Событие создания комнаты
    public void onRoomCreated(List<PlayerSession> playerSessions) {
        if (playerSessions != null) {
            playerSessions.forEach(session -> {
                DefaultPlayer defaultPlayer = (DefaultPlayer) session.getPlayer();
                defaultPlayer.setPosition(new Vector(100.0, 100.0));
                gameMap.addPlayer(defaultPlayer);
            });
            super.onRoomCreated(playerSessions);
        }

        sendBroadcast(new OutcomingMessage(MessageType.JOIN_SUCCESS,
                new GameSettingsPack(roomProperties.getLoopRate())));
        log.debug("Room {} has been created", key());
    }

    @Override
    // Событие старта комнаты
    public void onRoomStarted() {
        this.started.set(false);
        sendBroadcast(new OutcomingMessage(MessageType.ROOM_START, new GameRoomStartPack()));
        log.debug("Room {} has been started", key());
    }

    @Override
    // Событие старта битвы
    public void onBattleStarted() {
        this.started.set(true);
        sendBroadcast(new OutcomingPlayerMessage(MessageType.BATTLE_START));
        log.debug("Room {}. Battle has been started", key());
    }

    @Override
    // Событие окончания битвы
    public void onBattleEnded() {
        sendBroadcast(new OutcomingMessage(MessageType.ROOM_CLOSE));
        log.debug("Room {} has been ended", key());
    }

    //Итерация внутриигрового цикла
    @Override
    public void update() {
        if (!started.get()) return;
        final List<PlayerUpdatePack> playerUpdatePackList =
                gameMap.getPlayers()
                        .stream()
                        .peek(DefaultPlayer::update)
                        .map(DefaultPlayer::getUpdatePack)
                        .collect(Collectors.toList());

        for (DefaultPlayer currentPlayer : gameMap.getPlayers()) {
            final PlayerSession session = currentPlayer.getSession();
            send(session, new OutcomingPlayerMessage(session, MessageType.UPDATE,
                    new GameUpdatePack(
                            currentPlayer.getPrivateUpdatePack(),
                            playerUpdatePackList)));
        }
    }

    // Событие нажатия клавиши
    public void onPlayerKeyDown(KeyDownPlayerEvent event) {
        if (!started.get()) return;
        DefaultPlayer player = (DefaultPlayer) event.getSession().getPlayer();
        if (!player.getIsAlive()) return;
        Direction direction = Direction.valueOf(event.getInputId());
        player.updateState(direction, event.getState());
    }

    @Override
    // Событие закрытия комнаты
    public void onDestroy(List<PlayerSession> playerSessions) {
        onDestroy(playerSessions, playerSession -> gameMap.removePlayer((DefaultPlayer) playerSession.getPlayer()));
    }
}

Обратите внимание на метод update:

@Override
public void update() {
    if (!started.get()) return;
    final List<PlayerUpdatePack> playerUpdatePackList =
            gameMap.getPlayers()
                    .stream()
                    .peek(DefaultPlayer::update)
                    .map(DefaultPlayer::getUpdatePack)
                    .collect(Collectors.toList());

    for (DefaultPlayer currentPlayer : gameMap.getPlayers()) {
        final PlayerSession session = currentPlayer.getSession();
        send(session, new OutcomingPlayerMessage(session, MessageType.UPDATE,
                new GameUpdatePack(
                        currentPlayer.getPrivateUpdatePack(),
                        playerUpdatePackList)));
    }
}

В нем, как стало ясно ранее, происходит цикличное обновление внутриигровых моделей. Разумеется, по смыслу, это должно происходить в объекте GameMap, отвечающем за игровое поле вцелом, но для удобства восприятия контента статьи вынесено в игровую комнату. В этом цикле вы можете добавить все что требуется для игры, в том числе и NPC, выстрелы(как отдельные сущности), умения, эффекты и т.д. В данном случае обновляется только состояние игроков в классе DefaultPlayer:

public void update() {
    velocity.setX(isMoving && movingState.get(Direction.RIGHT) ?
            Constants.ABS_PLAYER_SPEED : (isMoving && movingState.get(Direction.LEFT) ?
            -Constants.ABS_PLAYER_SPEED : 0.0));
    velocity.setY(isMoving && movingState.get(Direction.UP) ?
            -Constants.ABS_PLAYER_SPEED : (isMoving && movingState.get(Direction.DOWN) ?
            Constants.ABS_PLAYER_SPEED : 0.0));

    position.sum(velocity);
}

Затем из обновленного состояния собираются PlayerUpdatePack, пересылаемые клиенту в цикле ниже в формате GameUpdatePack (свое состояние + коллекция состояний остальных игроков).

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PlayerUpdatePack implements UpdatePack {
    private Long id;
    private Vector position;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PrivatePlayerUpdatePack implements PrivateUpdatePack {
    private Long id;
}

Matchmaker

Матчмейкером в проекте является GameRoomManagementService:

@Slf4j
@RequiredArgsConstructor
@Service
public class GameRoomManagementService implements WebsocketHandler{
    private final Map<UUID, DefaultGameRoom> gameRoomMap = new ConcurrentHashMap<>();
    private final Queue<GameRoomJoinEvent> sessionQueue = new ConcurrentLinkedQueue<>();

    private final MainGameChannelMode gameChannelMode;
    private final OutOfRoomChannelMode outOfRoomChannelMode;
    private final Player.PlayerFactory<GameRoomJoinEvent, DefaultPlayer, DefaultGameRoom> playerFactory;
    private final ApplicationProperties applicationProperties;
    private final ScheduledExecutorService schedulerService;

    public Optional<DefaultGameRoom> getRoomByKey(UUID key) {
        return Optional.ofNullable(gameRoomMap.get(key));
    }

    // Добавление игрока в очередь и создание комнаты, если кол-во игроков достигло необходимого числа
    public void addPlayerToWait(GameRoomJoinEvent event) {
        sessionQueue.add(event);
        send(event,new OutcomingMessage(MessageType.JOIN_WAIT));

        if (sessionQueue.size() < applicationProperties.getRoom().getMaxPlayers())
            return;

        final GameMap gameMap = new GameMap();
        final DefaultGameRoom room = new DefaultGameRoom(gameMap,
                UUID.randomUUID(), GameRoomManagementService.this, schedulerService, applicationProperties.getRoom());
        gameRoomMap.put(room.key(), room);

        // Инициализация игроков
        final List<PlayerSession> playerSessions = new ArrayList<>();
        while (playerSessions.size() != applicationProperties.getRoom().getMaxPlayers()) {
            final GameRoomJoinEvent evt = sessionQueue.remove();
            final PlayerSession ps = evt.getSession();
            ps.setRoomKey(room.key());
            ps.setPlayer(playerFactory.create(gameMap.nextPlayerId(), evt, room, ps));
            playerSessions.add(ps);
        }

        gameChannelMode.apply(playerSessions);
        // Вызов onRoomCreated
        room.onRoomCreated(playerSessions);
        // Старт игровой комнаты/цикла. LoopRate - периодичность выполнения в милисекундах
        room.start(applicationProperties.getRoom().getStartDelay(),
                applicationProperties.getRoom().getEndDelay(),
                applicationProperties.getRoom().getLoopRate());
    }
    // Обработка завершения работы комнаты
    public void onBattleEnd(GameRoom room) {
        gameRoomMap.remove(room.key());
        outOfRoomChannelMode.apply(room.close());
    }
    // Удаление игрока из очереди
    public void removePlayerFromWaitQueue(PlayerSession session) {
        sessionQueue.removeIf(event -> event.getSession().equals(session));
    }
}

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

При создании комнаты следует учесть, что все загруженные игровые ресурсы, используемые в комнате, должны быть продублированы/склонированы во избежание проблем конкурентного изменения. Все операции, коллекции, состояния, хранимые в этом сервисе, должны быть Thread-Safe, но при этом неблокирующие потоки, т.к. этот сервис связан напрямую с ChannelHandler и EventLoop внутри Netty.

Заключение

В результате запуска демки вы получите простенькое игровое поле с задатками на будущий мультиплеер в режиме Battle-royale.

Браузерный клиент приложения
Браузерный клиент приложения

Многим может показаться, что кода очень много для такого маленького приложения, но это всего лишь основа для возможно большего проекта. Большая часть подкапотных механизмов написана, вам остается обогатить свой сервер сугубо игровой логикой.

Будьте мощными специалистами, читайте, стремитесь к своим целям. Спасибо за внимание.

Комментарии (2)


  1. olegchir
    17.11.2023 00:29

    Круто! Есть вопрос. В шутере куча данных типа плавного поворота камеры. В авторитарном сервере с частичным повторением риплей лога при десинке, может оказаться нужным ворочить кусками памяти. Плюс, не очень ясно, как из Java просто работать с GPU чтобы скинуть туда высокопроизводительные вычисления на бэкенде. Не вызывает ли тут проблем GC и вообще джавовский объектный подход? Вы на этапе проектирования бэкенда думали про другие платформы, кроме Java? Rust, C++, что-нибудь ещё без GC и с прямым управлением всеми ресурсами.


    1. tfkfan Автор
      17.11.2023 00:29

      Поскольку я Java разработчик то именно она ближе ко мне, если нагрузка будет возрастать на сервер, то можно прилепить low-latency GC вроде shenandoah zgc и тд. Если и этого не хватает, то на помощь придет горизонтальное масштабирование и балансировка нагрузки. Если нужны GPU вычисления, то конечно же найдутся Java обертки вокруг OpenCL, Cuda и пр. Тем не менее, NodeJS сервера держат огромное кол-во игроков, а именно они используются в большинстве io мультиплееров, и джавовский будет ничуть не хуже. Уверен, что 10 000 игроков в момент времени обработать вполне возможно. Разумеется думал и про C++, это идеальный язык для написания подобного рода вещей, думаю, после того как взлетит мой текущий проект, второй проект будет именно на C++ (libuv, libevent).