Nadronでのゲームサーバー開発

この記事では、 Nadronフレームワークでゲームサーバーを開発する主なポイント、開発で使用するテクノロジーのスタックについて説明し、ブラウザーゲームのプロジェクト構造の例を示します。



なぜ正確にナドロンなのか?



まず、データ転送用のすべての一般的なプロトコルを実装し、1つのゲーム内で任意の数のプロトコルを使用でき、さらに独自のオプションを作成および追加できます。 このエンジンにより、ゲームルーム内で線形コードを記述し、基本的なコマンドを実装できます。 クライアントの場合、Nadronを使用するための既製のライブラリは、js、as3、javaまたはdartで記述されています。



何してるの?



ほとんどのお客様の実装に適したブランクを作成します

アイデアと他の皆のために何であるかを把握する機会を提供します。 特に、VKontakteソーシャルネットワークの既製の承認方法、データスキームの作成、ゲームエンティティのロード、ロビー、シンプルなゲームルームなど。



この記事の例はWebSocket向けに開発されましたが、他のプロトコルとの違いは最小限であるため、実際には問題ではありません。 しかし、最初に、著者Nadronのを読むことをお勧めします。



構成



最初に依存関係について。 Gradleプロジェクトの場合、Nadron、Spring、Flyway、c3p0が必要です。これは次のようになります。



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の既製のソリューションは簡単です。デフォルトでは、ユーザー認証のために、接続する部屋のユーザー名、パスワード、およびコードが転送されます。 この例では、ログインハンドラーを再定義する必要があります。このハンドラーにVKユーザーデータを転送し、ロビー内のすべてのプレーヤーの自動検出を追加します。 サーバー上のメインBeanをクラスで説明し、再定義が必要なBeanは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); }
      
      





古いデータ配列の代わりに構成オブジェクトが表示される場所。この配列に、ライブラリを変更せずに将来データを追加できます。 次のようになります。



 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と呼ばれます)も、標準のプレーヤーから継承する必要があります。 ソーシャルキーを確認します。 ネットワークにはセキュリティキーとアプリケーション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; } }
      
      







ロビーは、ほとんどのゲームで必要とされる基本的なメカニズムの1つです。

ここでは、公式の例のように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); } }
      
      







ロビーとチームワーク



ロビーの実装はおそらくそれほど簡単な作業ではありません(いずれにせよ、公式フォーラムでこれについて質問があります)。 この例では、ロビーは独自の状態(状態)を持ち、ゲームのコピーが1つある通常の部屋です。 ロビーに入ると、プレイヤーのデータを全員に送信します。 明確にするために、クラス全体のコードを提供します。



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には2種類のハンドラーがあります。1つはプレイヤーのセッション用、もう1つは部屋自体のコマンドを処理するためのものです。



ルーム内のクライアントからの着信コマンドを処理するには、プレーヤーのセッションハンドラーからイベントを送信する必要があります。 新しいメソッドは次のようになります。



 @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); } }
      
      







ゲーム室



すべての質問のうち、重要なことが1つ残っていました。新しい部屋の作成と、そこへのプレイヤーの移行です。 問題は、同じプロトコルの部屋に入ることができないことです。そうしないと、プロトコルが重複します。 この見落としを修正するには、ハンドラーの存在のチェックを追加する必要があります。



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にあります



All Articles