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

Почему именно Nadron?


Прежде всего в нём реализованы все популярные протоколы для передачи данных, а в рамках одной игры можно использовать любое количество протоколов, к тому же вы можете создавать и добавлять свои собственные варианты. Движок позволяет писать линейный код внутри игровых комнат и реализует базовые команды. Для клиента на js, as3, java или dart написаны готовые библиотеки для работы с Nadron.

Что мы делаем?


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

Пример для этой статьи разрабатывался под WebSocket, но это не имеет особого значения, так как различия для любого другого протокола минимальны. Но для начала советую ознакомиться с примером от автора Nadron.

Конфигурация


Сперва о зависимостях. Нам потребуются Nadron, Spring, Flyway и c3p0, для Gradle проекта это будет выглядеть следующим образом:

build.gradle
// Apply the java plugin to add support for Java
apply plugin: 'java'
apply plugin: 'org.flywaydb.flyway'

// In this section you declare where to find the dependencies of your project
repositories {
    // Use 'jcenter' for resolving your dependencies.
    // You can declare any Maven/Ivy/file repository here.
    jcenter()
}

buildscript {
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath "org.flywaydb:flyway-gradle-plugin:4.0.3"
    
  }
}

flyway {
  url = 'jdbc:postgresql://localhost:5432/game_db'
  user = 'postgres'
  password = 'postgres'
}

def spring_version = '5.0.1.RELEASE'

// In this section you declare the dependencies for your production and test code
dependencies {
    // The production code uses the SLF4J logging API at compile time
   	compile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.25'
    
    compile group: 'org.springframework', name: 'spring-core', 	  version: spring_version
    compile group: 'org.springframework', name: 'spring-context', version: spring_version
    compile group: 'org.springframework', name: 'spring-jdbc', 	  version: spring_version
    compile group: 'org.springframework', name: 'spring-tx', 	  version: spring_version

    compile group: 'io.javaslang', name: 'javaslang', 	  version: '2.0.5'    
    compile group: 'com.github.menacher', name: 'nadron', version: '0.7'    
    compile group: 'org.json', name: 'json', version: '20160810'
        
    compile 'com.mchange:c3p0:0.9.5.2'
  	compile 'org.flywaydb:flyway-core:4.0.3'
  	
  	runtime("org.postgresql:postgresql:9.4.1212")
    testCompile 'junit:junit:4.12'
}


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

Аутентификация


Готовые решения в Nadron простые: по умолчанию для аутентификации пользователей передаются логин, пароль и код комнаты, к которой нужно подключится. Для нашего примера нужно переопределить обработчик логина, в который будем передавать данные пользователя ВК и добавим автоматическое определение всех игроков в лобби. Основные бины на сервере опишем в классе, а те, которые нужно переопределить, придется вписать в xml (из класса не переопределяются):

beans.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:context="http://www.springframework.org/schema/context"
	xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.2.xsd
		http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd">

	<import resource="classpath:/nadron/beans/server-beans.xml"></import>
	<context:annotation-config />

	<bean id="webSocketLoginHandler" class="com.bbl.app.handlers.GameLoginHandler">
		<property name="lookupService" ref="lookupService" />
		<property name="idGeneratorService" ref="simpleUniqueIdGenerator" />
		<property name="reconnectRegistry" ref="reconnectSessionRegistry" />
		<property name="jackson" ref="jackson" />
	</bean>
</beans>


Дальше мы создаем класс GameLoginHandler и наследуем его от стандартной реализации WebSocketLoginHandler. У фреймворка есть возможность маппить пользовательские классы для десериализации json сообщений. Для этого нужно просто отправить имя класса с клиента примерно так:

session.send(nad.CNameEvent("com.bbl.app.events.CustomEvent"));

Но для авторизации это не подойдет, т.к. сообщение с данными отправляется сразу после подключения к серверу. Поэтому в качестве сообщения для логина мы будем передавать ассоциативный массив, для этого в nad-0.1.js заменим метод создания сообщения логина на:

nad.LoginEvent = function (config) {
        return nad.NEvent(nad.LOG_IN, config);
}

Где объект config выступает вместо старого массива данных, в который можно добавить данные в будущем, не меняя библиотеку. Это будет выглядеть так:


var config = {
        user:"user",
        pass:"pass",		
        uid:"1234567",
        key:"1234567",
        picture:"",
        sex:1,
};
// Создание сессии и подключение к серверу
nad.sessionFactory("ws://localhost:18090/nadsocket", config, loginHandler);
function loginHandler(session){
	session.onmessage = messageHandler; 
	session.send(nad.CNameEvent("com.bbl.app.events.CustomEvent"));
}
// Обработчик сообщений
function messageHandler(e){
	console.log(JSON.stringify(e.source));
}

Реализация обработчика на сервере:

GameLoginHandler.java
public class GameLoginHandler extends WebSocketLoginHandler {

	private static final Logger LOG = LoggerFactory.getLogger(GameLoginHandler.class);
	
	@Override
	public void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception {
		Channel channel = ctx.channel();
		String data = frame.text();
		Event event = getJackson().readValue(data, DefaultEvent.class);
		int type = event.getType();
		if (Events.LOG_IN == type) {
			LOG.trace("Login attempt from {}", channel.remoteAddress());
			@SuppressWarnings("unchecked")
			Player player = lookupPlayer((LinkedHashMap<String, Object>) event.getSource());
			handleLogin(player, channel);
			if(null != player)
				handleGameRoomJoin(player, channel, MyGame.LOBBY_NAME);
		} else if (type == Events.RECONNECT) {
			LOG.debug("Reconnect attempt from {}", channel.remoteAddress());
			PlayerSession playerSession = lookupSession((String) event.getSource());
			handleReconnect(playerSession, channel);
		} else {
			LOG.error("Invalid event {} sent from remote address {}. " + "Going to close channel {}",
					new Object[] { event.getType(), channel.remoteAddress(), channel });
			closeChannelWithLoginFailure(channel);
		}
	}

	public Player lookupPlayer(Map<String, Object> source) throws Exception {
		String user = String.valueOf(source.get("user"));
		String vkUid = String.valueOf(source.get("uid"));
		String vkKey = String.valueOf(source.get("key"));
		
		GameCredentials credentials = new GameCredentials(user, vkUid, vkKey);		
		credentials.setPicture(String.valueOf(source.get("picture")));
		credentials.setSex(Integer.valueOf(String.valueOf(source.get("sex"))));
		credentials.setHash(String.valueOf(source.get("hash")));	
		Player player = getLookupService().playerLookup(credentials);
		if (null == player) {
			LOG.error("Invalid credentials provided by user: {}", credentials);
		}
		return player;
	}
}


Класс кредлов унаследован от библиотечного Credentials с добавлением всего нам нужного. Дальше передаем его в LookupService для авторизации или регистрации аккаунта и возвращаем созданный экземпляр класса игрока. Класс игрока (в примере он называется GamePlayer) также нужно унаследовать от стандартного Player. Для проверки ключа соц. сети нужно иметь защитный ключ и id приложения, их удобнее всего записать в класс игры. Класс MyGame предназначен для загрузки различных данных из БД или файлов, нужных для игры: карты, предметы и т.п.

GameLookupService.java
public class GameLookupService extends SimpleLookupService{
	
	@Autowired
	private MyGame myGame;	
	@Autowired
	private GameDao gameDao;	
	@Autowired
	private LobbyRoom lobby;	
	@Autowired
	private GameManager gameManager;
	
	@Override
	public Player playerLookup(Credentials loginDetail) {
		Optional<GamePlayer> player = Optional.empty();
		GameCredentials credentials = (GameCredentials) loginDetail;
		
		String authKey = myGame.getAppId() + '_' + credentials.getVkUid() + '_' + myGame.getAppSecret();
		try {
			// Проверка ключа
			if (Objects.equals(credentials.getVkKey().toUpperCase(), MD5.encode(authKey))) {
				player = gameDao.fetchPlayerByVk(credentials.getVkUid(), credentials.getVkKey());
				if(!player.isPresent()){
					// Создаем аккаунт
					player = Optional.of(cratePlayer((GameCredentials) loginDetail));
					gameDao.createPlayer(player.get());
				}
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
			
		return player.orElse(null);
	}

	private GamePlayer cratePlayer(GameCredentials loginDetail) {
		GamePlayer player = new GamePlayer();
		player.setVkUid(loginDetail.getVkUid());
		player.setVkKey(loginDetail.getVkKey());
		player.setName(loginDetail.getUsername());
		player.setPicture(loginDetail.getPicture());
		player.setSex(loginDetail.getSex());
		player.setRef(loginDetail.getHash());
		player.setMail("");
		player.setCreated(LocalDateTime.now());		
		player.setRating(0);
		player.setMoney(10);
		player.setClanId(0L);
		return player;
	}

	@Override
	public GameRoom gameRoomLookup(Object gameContextKey) {
		return lobby;
	}
}


Лобби — это одна из базовых механик, которая требуется в большинстве игр.
Здесь вместо комнаты по ключу из метода gameRoomLookup, как в официальном примере, мы всегда отдаем экземпляр LobbyRoom. Таким образом все подключившиеся игроки автоматически попадут в эту комнату.

Работа с базой данных


Давайте чуть отвлечемся и рассмотрим вариант работы с базой данных. Библиотека Flyway вместе со своим плагином позволяет автоматически при запуске выполнять последовательность sql файлов для базы данных с учетом версионности. Это подходит для того, чтобы описать однажды структуру БД и забыть про нее в будущем. По умолчанию файлы должны располагаться в папке src/main/resources/db/migration вашего проекта, а сами файлы начинаться с V[n]__name.sql. Файл создания таблицы пользователей будет выглядеть так:

V1__create_game_tables.sql
CREATE TABLE public.players
(
  id 		bigserial NOT NULL,
  mail      character varying,
  name	    character varying,
  password  character varying,
  vk_uid    character varying,
  vk_key    character varying,
  ref		character varying,
  sex		integer NOT NULL,
  money		integer NOT NULL,
  rating	integer NOT NULL,
  clan_id   bigint NOT NULL,
  created   timestamp without time zone NOT NULL,
  CONSTRAINT players_pkey PRIMARY KEY (id)
)
WITH (
  OIDS=FALSE
);

CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE players ALTER COLUMN vk_uid TYPE citext; 


Чтобы все работало быстро, нужен пул соединений для БД, — для этого у нас есть c3p0 библиотека с уже готовым кешированием запросов. Выше в GameLookupService уже используется база для поиска данных игрока или для его создания.

События


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

CustomEvent.java
@SuppressWarnings("serial")
public class CustomEvent extends DefaultEvent {

	private EventData source;

	@Override
	public EventData getSource() {
		return source;
	}

	public void setSource(EventData source) {
		this.source = source;
	}

	public static NetworkEvent networkEvent(GameCmd cmd, Object data) throws JSONException {
		EventData source = new EventData();
		source.setCmd(cmd.getCode());
		source.setData(data);
		return Events.networkEvent(source);
	}
}


Лобби и обработка команд


Реализация лобби — это, наверное, не такая уж и простая задача (во всяком случае на официальном форуме есть об этом вопрос). В нашем примере лобби это обычная комната со своим состоянием (state) и в единственном экземпляре для игры. При входе в лобби мы отправляем всем данные игрока. Для того, чтобы было понятно, я приведу код всего класса:

LobbyRoom.java
public class LobbyRoom extends GameRoomSession {

	private static final Logger LOG = LoggerFactory.getLogger(LobbyRoom.class);

	private RoomFactory roomFactory;

	public LobbyRoom(GameRoomSessionBuilder gameRoomSessionBuilder) {
		super(gameRoomSessionBuilder);
		this.addHandler(new LobbySessionHandler(this));
		getStateManager().setState(new LobbyState());
	}

	@Override
	public void onLogin(PlayerSession playerSession) {
		LOG.info("sessions size: " + getSessions().size());
		playerSession.addHandler(new PlayerSessionHandler(playerSession));
		
		try {
			playerSession.onEvent(CustomEvent.networkEvent(GameCmd.PLAYER_DATA, playerSession.getPlayer()));
		} catch (JSONException e) {
			LOG.error(e.getMessage());
			e.printStackTrace();
		}
	}

	public RoomFactory getRoomFactory() {
		return roomFactory;
	}

	public void setRoomFactory(RoomFactory roomFactory) {
		this.roomFactory = roomFactory;
	}
}


В Nadron есть два вида обработчиков: один — для сесии игрока, второй же предназначен для обработки команд самой комнаты.

Чтобы в комнате обрабатывать входящие команды от клиента, нужно отправлять события из обработчика сессии игрока. Новый метод будет выглядеть так:

@Override
protected void onDataIn(Event event) {
	if (null != event.getSource()) {
		event.setEventContext(new DefaultEventContext(playerSession, null));
		playerSession.getGameRoom().send(event);
	}
}

Таким образом, события будут уходить в текущую комнату, в которой находится игрок. Обрабатывать их будет метод onEvent, ниже пример такой обработки. Заметьте, что при отсутствии команды выбрасываем специальное исключение InvalidCommandException. Все комнаты будут выполнять команды последовательно, но для отправки лучшей практикой будет клонирование объектов.

LobbySessionHandler.java
@Override
public void onEvent(Event event) {
	CustomEvent customEvent = (CustomEvent) event;
	GameCmd cmd = GameCmd.CommandsEnum.fromInt(customEvent.getSource().getCmd());
	try {
		switch (cmd) {
		case CREATE_GAME:
			createRoom(customEvent);
			break;
		case GET_OPEN_ROOMS:
			broadcastRoomList(customEvent);
			break;
		case JOIN_ROOM:
			connectToRoom(customEvent);
			break;
		default:
			LOG.error("Received invalid command {}", cmd);
			throw new InvalidCommandException("Received invalid command" + cmd);
		}
	} catch (InvalidCommandException e) {
		e.printStackTrace();
		LOG.error("{}", e);
	}
}


Игровые комнаты


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

GameWebsocketProtocol.java
public class GameWebsocketProtocol extends AbstractNettyProtocol {

	private static final Logger LOG = LoggerFactory.getLogger(WebSocketProtocol.class);

	private static final String TEXT_WEBSOCKET_DECODER = "textWebsocketDecoder";
	private static final String TEXT_WEBSOCKET_ENCODER = "textWebsocketEncoder";
	private static final String EVENT_HANDLER = "eventHandler";

	private TextWebsocketDecoder textWebsocketDecoder;
	private TextWebsocketEncoder textWebsocketEncoder;

	public GameWebsocketProtocol() {
		super("GAME_WEB_SOCKET_PROTOCOL");
	}

	@Override
	public void applyProtocol(PlayerSession playerSession, boolean clearExistingProtocolHandlers) {

		applyProtocol(playerSession);

		if (clearExistingProtocolHandlers) {
			ChannelPipeline pipeline = NettyUtils.getPipeLineOfConnection(playerSession);
			if (pipeline.get(LoginProtocol.LOGIN_HANDLER_NAME) != null)
				pipeline.remove(LoginProtocol.LOGIN_HANDLER_NAME);
			if (pipeline.get(AbstractNettyProtocol.IDLE_STATE_CHECK_HANDLER) != null)
				pipeline.remove(AbstractNettyProtocol.IDLE_STATE_CHECK_HANDLER);
		}
	}

	@Override
	public void applyProtocol(PlayerSession playerSession) {

		LOG.trace("Going to apply {} on session: {}", getProtocolName(), playerSession);
		ChannelPipeline pipeline = NettyUtils.getPipeLineOfConnection(playerSession);
		if (pipeline.get(TEXT_WEBSOCKET_DECODER) == null)
			pipeline.addLast(TEXT_WEBSOCKET_DECODER, textWebsocketDecoder);
		if (pipeline.get(EVENT_HANDLER) == null)
			pipeline.addLast(EVENT_HANDLER, new DefaultToServerHandler(playerSession));
		if (pipeline.get(TEXT_WEBSOCKET_ENCODER) == null)
			pipeline.addLast(TEXT_WEBSOCKET_ENCODER, textWebsocketEncoder);
	}

	public TextWebsocketDecoder getTextWebsocketDecoder() {
		return textWebsocketDecoder;
	}

	public void setTextWebsocketDecoder(TextWebsocketDecoder textWebsocketDecoder) {
		this.textWebsocketDecoder = textWebsocketDecoder;
	}

	public TextWebsocketEncoder getTextWebsocketEncoder() {
		return textWebsocketEncoder;
	}

	public void setTextWebsocketEncoder(TextWebsocketEncoder textWebsocketEncoder) {
		this.textWebsocketEncoder = textWebsocketEncoder;
	}
}


Само же отключение от текущей комнаты и подключение к другой будут выглядеть следующим образом:

private void changeRoom(PlayerSession playerSession, GameRoom room) {
	playerSession.getGameRoom().disconnectSession(playerSession);
	room.connectSession(playerSession);
}

Это все, о чем я хотел рассказать. Полный код можно найти в GitHub.

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


  1. PqDn
    25.04.2018 22:15

    readme.md на гитхабе хромает у вас


    1. taluks Автор
      26.04.2018 12:03

      Совершенно верно, буду исправляться со временем)


  1. ser-mk
    26.04.2018 00:44

    В этой статье я расскажу об основных моментах разработки игрового сервера на фреймворке Nadron

    Nadron сюдя по коммитам, он уже почти как 5 лет не развивается…


    1. taluks Автор
      26.04.2018 12:03

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