Game Development / Reddwarf для создания Java-сервера на примере онлайн-игры «Камень-ножницы-бумага»: Сервер

в 18:12, , рубрики: darkstar, game server, games, java, reddwarf, метки: , , , ,

Game Development / Reddwarf для создания Java сервера на примере онлайн игры «Камень ножницы бумага»: СерверВ статье RedDwarf — cерверная платформа для разработки онлайн-игр на Java я рассказал об особенностях этой платформы для создания игровых серверов. В данной статье я попробую показать на примере, как написать сервер и использованием RedDwarf.
В качестве примера решено было написать онлайн-реализацию игры «Камень-Ножницы-Бумага».
В этой статье мы напишем сервер и попробуем его запустить. В следующей статье напишем для этого сервера небольшой клиент и проверим их работоспособность.

Подготовка к работе

Для начала необходимо закачать сервер Reddwarf в архиве sgs-server-dist-0.10.2.zip отсюда и распаковать содержимое в папку sgs-server-dist-0.10.2.

Создание проекта

Создадим проект в любимой среде разработки.
Проект будет простой, поэтому maven использовать не будем.
Для разработки нужна библиотека sgs-server-api-0.10.2.jar из директории sgs-server-dist-0.10.2lib

Создаем папку META-INF, в ней должен находиться файл манифеста MANIFEST.MF. Без него платформа отказывается работать с jar-файлом проекта. У меня файл содержит только одну строчку:
Manifest-Version: 1.0

Также в папке META-INF необходимо создать файл app.properties. В этом файле содержатся настройки запуска сервера. Для нашего проекта файл содержит следующие свойства:

# Название игры. Служит уникальным идентификатором игры при старте сервера com.sun.sgs.app.name=RockPaperScissors # Класс, реализующий интерфейс AppListener и служащий точкой запуска приложения com.sun.sgs.app.listener=hello.reddwarf.server.Server # Имя директории, в которой будет храниться база данных игры com.sun.sgs.app.root=data 

Это минимальный необходимый набор опций. При разработке могут быть ещё полезны следующие свойства:

  • com.sun.sgs.impl.transport.tcp.listen.port — порт, на котором слушает сервер (по умолчанию 62964)
  • com.sun.sgs.app.authenticators — имена классов, отвечающих за аутентификацию (процесс аутентификации вынесен из игровой логики и может идти независимым модулем)
  • com.sun.sgs.impl.service.session.allow.new.login — позволять ли подключаться уже подключенным игрокам с другого клиента. Если true, то того, кто сейчас в игре выкидывает. Если false, не позволяет подключаться с другого клиента.

Подробнее о других свойствах можно почитать в документации.

Архитектура игры

Для игры потребуются следующие сущности.
Server — класс, хранящий список игроков онлайн и занимающийся обработкой их подключения.
Player — представляет собой игрока. Игрок имеет следующие атрибуты: имя (оно же логин) и количество очков. Может участвовать в битве.
Battle — представляет собой битву. В этом объекте происходит ожидание ответов игроков и определение победителя. Хранит в себе ссылки на двух игроков.
Weapon — простое перечисление видов оружия: непосредственно камень, ножницы и бумага.

Если изобразить в виде диаграммы классов, получается вот что:
Game Development / Reddwarf для создания Java сервера на примере онлайн игры «Камень ножницы бумага»: Сервер

Все игровые сущности (кроме Weapon) во время работы сервера хранятся во внутренней базе данных, обеспечивающей транзакционность, ссылаются друг на друга, поэтому они должны реализовывать интерфейсы java.io.Serializable и com.sun.sgs.app.ManagedObject.

Класс Server. Инициализация и подключение игрока

Класс Server является точкой запуска сервера, поэтому должен реализовывать интерфейс com.sun.sgs.app.AppListener:

void initialize(Properties props) вызывается при первом запуске сервера. Он заполняет внутреннюю базу данных необходимыми для работы начальными значениями. Важная особенность: если сервер остановить (или убить), а потом снова запустить, этот метод вызываться не будет, т.к. внутренняя база данных хранится между запусками сервера и позволяет продолжить работу с момента остановки.

ClientSessionListener loggedIn(ClientSession session) вызывается после успешной аутентификации и должен вернуть объект, олицетворяющий игрока. В нашем примере это будет Player.

Все игроки, подключенные к серверу, будут хранится в специальной коллекции. В Reddwarf для игровых сущностей существует специальная коллекция ScalableHashMap. Достоинства этой коллекции в том, что при изменениях она блокируется (имеется в виду блокировка во внутренней БД) не целиком, а частично. Причем в объекте Server хранить будем не саму коллекцию, а ссылку на нее (ManagedReference).

Переходя от слов к делу, получаем следующий код:

package hello.reddwarf.server;  import java.io.Serializable;  import com.sun.sgs.app.*; import com.sun.sgs.app.util.ScalableHashMap;  import java.util.Properties;  /**  * Сервер игры. Этот класс автоматически загружается платформой,  * инициализируется и его платформа уведомляет о новых подключениях.  */ public class Server implements AppListener, Serializable, ManagedObject {      public ManagedReference<ScalableHashMap<String, Player>> onlinePlayersRef;      @Override     public void initialize(Properties props) {          // Создаем коллекцию для игроков онлайн         ScalableHashMap<String, Player> onlinePlayers = new ScalableHashMap<String, Player>();         onlinePlayersRef = AppContext.getDataManager().createReference(onlinePlayers);      }      @Override     public ClientSessionListener loggedIn(ClientSession session) {          String name = session.getName();          // Подключился пользователь. Необходимо загрузить его из базы данных, либо зарегистрировать нового         Player player = loadOrRegister(name);          // Установим игроку сессию. Сессия - это объект, через который осуществляется         // сетевое взаимодействие - отсылка сообщений на клиент         player.setSession(session);         // Уведомляем игрока о том, что он подключился         player.connected();         // Добавим его в список онлайн-игроков         onlinePlayersRef.get().put(player.name, player);          return player;     } } 

Для работы с базой данных используется DataManager, который позволяет писать в БД, читать из БД и создавать ссылки ManagedReference. Поскольку база данных представляет собой key-value хранилище, то в качестве ключа используется имя игрока с префиксом «player.», в значение же сериализуется объект Player целиком. Напишем функцию загрузки игрока из базы (если игрок не найден в базе, создадим его).

    private Player loadOrRegister(String name) {         try {             return (Player) AppContext.getDataManager().getBindingForUpdate("player." + name);         } catch (NameNotBoundException e) {             // Попытка загрузить объект и перехват исключения -              // единственный способ узнать, есть ли такой объект в базе             Player player = new Player(name, this);             AppContext.getDataManager().setBinding("player." + name, player);             return player;         }     } 
Класс Player и протокол

Пришла очередь создать класс Player. Этот класс олицетворяет игрока и получает от платформы уведомления о пришедших сообщениях. А значит, самое время поговорить о протоколе. Reddwarf дает возможность работать с входящими и исходящими сообщениями как с массивом байт, оставляя реализацию протокола на усмотрение разработчика игры. Для игры «Камень-ножницы-бумага» будем использовать простой текстовый протокол.

(сервер --> клиент) SCORE <число> — сервер сообщает игроку количество очков
(клиент --> сервер) PLAY — запрос игрока на начало игры
(сервер --> клиент) BATLE <имя> — началась битва с указанным игроком
(сервер --> клиент) ERROR — игрок для битвы не найден (никого на сервере нет или все в битве)
(клиент --> сервер) ROCK — игрок говорит «Камень»
(клиент --> сервер) SCISSORS — игрок говорит «Ножницы»
(клиент --> сервер) PAPER — игрок говорит «Бумага»
(сервер --> клиент) DRAW — ничья
(сервер --> клиент) WON — игрок победил
(сервер --> клиент) LOST — игрок проиграл

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

Кодировать текст в байты и обратно можно с помощью данного кода:

package hello.reddwarf.server;  import java.nio.ByteBuffer;  public class Messages {      public static ByteBuffer encodeString(String s) {         return ByteBuffer.wrap(s.getBytes());     }      public static String decodeString(ByteBuffer message) {         byte[] bytes = new byte[message.remaining()];         message.get(bytes);         return new String(bytes);     } } 

Теперь переходим к написанию объекта игрока.
Игрок будет хранить у себя следующие поля:

  • имя
  • количество очков
  • ссылка на сервер (чтобы иметь доступ к списку онлайн-игроков)
  • ссылка на сессия (чтобы отправлять сообщения на клиент)
  • ссылка на битва (если игрок сейчас в битве, иначе null)
package hello.reddwarf.server;  import com.sun.sgs.app.*; import com.sun.sgs.app.util.ScalableHashMap;  import java.io.Serializable; import java.nio.ByteBuffer; import java.util.*;  public class Player implements Serializable, ManagedObject, ClientSessionListener {      private final static Random random = new Random();      public final String name;      private int score;      // Ссылка на сессию, через которую можно отправлять сообщения на клиент     private ManagedReference<ClientSession> sessionRef;      // Ссылка на сервер для доступа к списку онлайн-игроков      private ManagedReference<Server> serverRef;      // Ссылка на текущую битву. Если игрок не в битве - значение этого поля null     private ManagedReference<Battle> battleRef;      public Player(String name, Server server) {         this.name = name;         serverRef = AppContext.getDataManager().createReference(server);         score = 0;     }      @Override     public void receivedMessage(ByteBuffer byteBuffer) {         // При получении сообщения разбираем его и вызываем соответствующий метод         String message = Messages.decodeString(byteBuffer);         if (message.equals("PLAY")) {             play();         } else if (message.equals("ROCK")) {             answer(Weapon.ROCK);         } else if (message.equals("PAPER")) {             answer(Weapon.PAPER);         } else if (message.equals("SCISSORS")) {             answer(Weapon.SCISSORS);         }     }      @Override     public void disconnected(boolean b) {         serverRef.get().disconnect(this);     }      private void answer(Weapon weapon) {         if (battleRef != null) {             battleRef.getForUpdate().answer(this, weapon);         }     }      private void play() {         logger.info("Choosing enemy for "+name);         // Выберем случайного игрока из списка онлайн и начнем битву         Player target = getRandomPlayer();         if (target != null && target.battleRef == null) {             Battle battle = new Battle(this, target);             this.sessionRef.get().send(Messages.encodeString("BATTLE " + target.name));             target.sessionRef.get().send(Messages.encodeString("BATTLE " + this.name));             target.battleRef = AppContext.getDataManager().createReference(battle);             this.battleRef = target.battleRef;             battle.start();         } else {             this.sessionRef.get().send(Messages.encodeString("ERROR"));         }     }      /**      * Поиск случайного соперника (кроме самого игрока)      * Если никого найти не удалось, возвращается null      * @return случайный соперник или null, если не найден      */     private Player getRandomPlayer() {         ScalableHashMap<String,Player> onlineMap = serverRef.get().onlinePlayersRef.get();         Set<String> namesSet = new HashSet<String>(onlineMap.keySet());         namesSet.remove(name);         if (namesSet.isEmpty()) {             return null;         } else {             ArrayList<String> namesList = new ArrayList<String>(namesSet);             String randomName =  namesList.get(random.nextInt(namesList.size()));             return onlineMap.get(randomName);         }     }      public void connected() {         // При подключении к серверу сообщим клиенту, сколько у нас очков         sessionRef.get().send(Messages.encodeString("SCORE " + score));     }      /**      * Бой закончен, игрок уведомляется о результате боя      */     public void battleResult(Battle.Result result) {         switch (result) {             case DRAW:                 score+=1;                 sessionRef.get().send(Messages.encodeString("DRAW"));                 break;             case WON:                 score+=2;                 sessionRef.get().send(Messages.encodeString("WON"));                 break;             case LOST:                 sessionRef.get().send(Messages.encodeString("LOST"));                 break;         }         sessionRef.get().send(Messages.encodeString("SCORE " + score));         battleRef = null;     }      public void setSession(ClientSession session) {         sessionRef = AppContext.getDataManager().createReference(session);     } } 
Классы Weapon и Battle

Перечисление Weapon очень простое и комментариев не требует.

package hello.reddwarf.server;  public enum Weapon {     ROCK,     PAPER,     SCISSORS;     boolean beats(Weapon other) {         return other != null && this != other && this.ordinal() == (other.ordinal() + 1) % values().length;     } } 

Переходим к битве.

Битва имеет уникальный идентификатор, содержит ссылки на двух игроков, данные ими ответы, а также флаг активности.

Как только битва создана, запускается отдельная задача, которая завершит битву через 5 секунд.
По прошествии этого времени подводятся итоги битвы. Если ответ дал только один из игроков, то он считается победителем, если оба — победитель определяется по обычным правилам «Камень-ножницы-бумага».

Задача ставится на исполнение с помощью сервиса TaskManager, который можно получить с помощью AppContext.getTaskManager(). Этот менеджер позволяет запускать задачи, выполняемые в отдельной транзакции либо сразу, либо через заданный промежуток времени, либо периодически. Как и следует ожидать, все задачи также хранятся во внутренней БД, а значит, будут выполняться и после перезапуска сервера.

Итак, код класса Battle.

package hello.reddwarf.server;  import com.sun.sgs.app.AppContext; import com.sun.sgs.app.ManagedObject; import com.sun.sgs.app.ManagedReference; import com.sun.sgs.app.Task;  import java.io.Serializable; import java.util.concurrent.atomic.AtomicInteger;  public class Battle implements ManagedObject, Serializable {      // Битва длится 5 секунд     private static final long BATTLE_TIME_MS = 5000;      enum Result {         DRAW,         WON,         LOST     }      private boolean active;      private ManagedReference<Player> starterPlayerRef;     private ManagedReference<Player> invitedPlayerRef;      private Weapon starterWeapon = null;     private Weapon invitedWeapon = null;      public Battle(Player starterPlayer, Player invitedPlayer) {         starterPlayerRef = AppContext.getDataManager().createReference(starterPlayer);         invitedPlayerRef = AppContext.getDataManager().createReference(invitedPlayer);         active = false;     }      /**      * Начало игры.      * Запускается игра, через BATTLE_TIME_MS мс она будет завершена.      */     public void start(){         active = true;         AppContext.getTaskManager().scheduleTask(new BattleTimeout(this), BATTLE_TIME_MS);     }      /**      * Игрок дал свой ответ.      * Записываем ответ, данный игроком.      * @param player - игрок      * @param weapon - его ответ      */     public void answer(Player player, Weapon weapon){         if (active) {             if (player.name.equals(starterPlayerRef.get().name)) {                 starterWeapon = weapon;             } else {                 invitedWeapon = weapon;             }         }     }      /**      * Битва завершена.      * Подводим итоги.      */     private void finish() {         active = false;         Player starterPlayer = starterPlayerRef.getForUpdate();         Player invitedPlayer = invitedPlayerRef.getForUpdate();          if (starterWeapon != null && starterWeapon.beats(invitedWeapon)) {             starterPlayer.battleResult(Result.WON);             invitedPlayer.battleResult(Result.LOST);         } else if (invitedWeapon != null && invitedWeapon.beats(starterWeapon)) {             invitedPlayer.battleResult(Result.WON);             starterPlayer.battleResult(Result.LOST);         } else {             starterPlayer.battleResult(Result.DRAW);             invitedPlayer.battleResult(Result.DRAW);         }          AppContext.getDataManager().removeObject(this);     }      /**      * Задача, завершаюшая игру по прошествии заданного времени.      */     private static class BattleTimeout implements Serializable, Task {          private ManagedReference<Battle> battleRef;          public BattleTimeout(Battle battle) {             battleRef = AppContext.getDataManager().createReference(battle);         }          @Override         public void run() throws Exception {             battleRef.getForUpdate().finish();         }     } } 

При чтении данного кода может возникнуть вопрос: «Почему внутренний класс BattleTimeout сделан статическим и хранит в себе ссылку на battle в явном виде? Можно же объявить его нестатическим и обращаться к полям Battle напрямую».
Дело в том, что нестатический внутренний класс будет хранить ссылку на родительский Battle в неявном виде и обращаться к Battle через нее. Но особенности платформы Reddwarf (транзакционность) запрещают обращаться к ManagedObject (которым является Battle) из другой транзакции напрямую: в таком случае будет выброшено исключение, т.к. прямая ссылка на объект в другой транзакции некорректна. Именно с этим связана рекомендация создателей платформы использовать только статические внутренние классы.

Отдельно хочется отметить получение managed-объекта по ссылке.
В вышеприведенном коде для ManagedReference используются как метод get(), так и getForUpdate().
В принципе, можно использовать только get(). Использование getForUpdate() позволяет серверу ещё до завершения транзакции знать, какие объекты будут изменены и в случае обнаружения конфликтующих транзакций отменить задачу чуть раньше. Это дает некоторый выигрыш в скорости по сравнению с использованием get().

Наконец наш сервер почти готов.
Добавим немного логирования (для простоты используем java.util.logging) и можно собирать проект.
В результате сборки мы должны получить jar-файл, допустим, deploy.jar.
Если вы не хотите собирать это всё вручную, готовый файл deploy.jar можно взять отсюда.
Этот файл необходимо поместить в sgs-server-dist-0.10.2dist.
Теперь, находясь в директории sgs-server-dist-0.10.2 выполняем следующую команду:

java -jar bin/sgs-boot.jar

В результате чего в консоли можно увидеть следующее:

фев 02, 2012 9:45:19 PM com.sun.sgs.impl.kernel.Kernel <init> INFO: The Kernel is ready, version: 0.10.2.1 фев 02, 2012 9:45:19 PM com.sun.sgs.impl.service.data.store.DataStoreImpl <init> INFO: Creating database directory : C:sgs-server-dist-0.10.2.1datadsdb фев 02, 2012 9:45:19 PM com.sun.sgs.impl.service.watchdog.WatchdogServerImpl registerNode INFO: node:com.sun.sgs.impl.service.watchdog.NodeImpl[1,health:GREEN,backup:(none)]@black registered фев 02, 2012 9:45:19 PM hello.reddwarf.server.Server initialize INFO: Starting new Rock-Paper-Scissors Server. Initialized database. фев 02, 2012 9:45:19 PM com.sun.sgs.impl.kernel.Kernel startApplication INFO: RockPaperScissors: application is ready

Ура! Сервер запустился! Теперь можно заняться клиентом:
Reddwarf на примере онлайн-игры «Камень-ножницы-бумага»: Клиент

Ссылки

Javadoc по API сервера
Документация, собранная сообществом
Форум проекта

Автор: dvb

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js