В статье 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 — простое перечисление видов оружия: непосредственно камень, ножницы и бумага.
Если изобразить в виде диаграммы классов, получается вот что:
Все игровые сущности (кроме 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