В статье 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.2\lib\
Создаем папку META-INF, в ней должен находиться файл манифеста MANIFEST.MF. Без него платформа отказывается работать с jar-файлом проекта. У меня файл содержит только одну строчку:
Manifest-Version: 1.0
Также в папке META-INF необходимо создать файл app.properties. В этом файле содержатся настройки запуска сервера. Для нашего проекта файл содержит следующие свойства:
Это минимальный необходимый набор опций. При разработке могут быть ещё полезны следующие свойства:
Подробнее о других свойствах можно почитать в документации.
Для игры потребуются следующие сущности.
Server — класс, хранящий список игроков онлайн и занимающийся обработкой их подключения.
Player — представляет собой игрока. Игрок имеет следующие атрибуты: имя (оно же логин) и количество очков. Может участвовать в битве.
Battle — представляет собой битву. В этом объекте происходит ожидание ответов игроков и определение победителя. Хранит в себе ссылки на двух игроков.
Weapon — простое перечисление видов оружия: непосредственно камень, ножницы и бумага.
Если изобразить в виде диаграммы классов, получается вот что:
Все игровые сущности (кроме Weapon) во время работы сервера хранятся во внутренней базе данных, обеспечивающей транзакционность, ссылаются друг на друга, поэтому они должны реализовывать интерфейсы java.io.Serializable и com.sun.sgs.app.ManagedObject.
Класс Server является точкой запуска сервера, поэтому должен реализовывать интерфейс com.sun.sgs.app.AppListener:
Все игроки, подключенные к серверу, будут хранится в специальной коллекции. В Reddwarf для игровых сущностей существует специальная коллекция ScalableHashMap. Достоинства этой коллекции в том, что при изменениях она блокируется (имеется в виду блокировка во внутренней БД) не целиком, а частично. Причем в объекте Server хранить будем не саму коллекцию, а ссылку на нее (ManagedReference).
Переходя от слов к делу, получаем следующий код:
Для работы с базой данных используется DataManager, который позволяет писать в БД, читать из БД и создавать ссылки ManagedReference. Поскольку база данных представляет собой key-value хранилище, то в качестве ключа используется имя игрока с префиксом «player.», в значение же сериализуется объект Player целиком. Напишем функцию загрузки игрока из базы (если игрок не найден в базе, создадим его).
Пришла очередь создать класс Player. Этот класс олицетворяет игрока и получает от платформы уведомления о пришедших сообщениях. А значит, самое время поговорить о протоколе. Reddwarf дает возможность работать с входящими и исходящими сообщениями как с массивом байт, оставляя реализацию протокола на усмотрение разработчика игры. Для игры «Камень-ножницы-бумага» будем использовать простой текстовый протокол.
(сервер --> клиент) SCORE <число> — сервер сообщает игроку количество очков
(клиент --> сервер) PLAY — запрос игрока на начало игры
(сервер --> клиент) BATLE <имя> — началась битва с указанным игроком
(сервер --> клиент) ERROR — игрок для битвы не найден (никого на сервере нет или все в битве)
(клиент --> сервер) ROCK — игрок говорит «Камень»
(клиент --> сервер) SCISSORS — игрок говорит «Ножницы»
(клиент --> сервер) PAPER — игрок говорит «Бумага»
(сервер --> клиент) DRAW — ничья
(сервер --> клиент) WON — игрок победил
(сервер --> клиент) LOST — игрок проиграл
Из протокола можно понять последовательность действий и возможности игрока, поэтому отдельно на этом останавливаться не будем.
Кодировать текст в байты и обратно можно с помощью данного кода:
Теперь переходим к написанию объекта игрока.
Игрок будет хранить у себя следующие поля:
Перечисление Weapon очень простое и комментариев не требует.
Переходим к битве.
Битва имеет уникальный идентификатор, содержит ссылки на двух игроков, данные ими ответы, а также флаг активности.
Как только битва создана, запускается отдельная задача, которая завершит битву через 5 секунд.
По прошествии этого времени подводятся итоги битвы. Если ответ дал только один из игроков, то он считается победителем, если оба — победитель определяется по обычным правилам «Камень-ножницы-бумага».
Задача ставится на исполнение с помощью сервиса TaskManager, который можно получить с помощью AppContext.getTaskManager(). Этот менеджер позволяет запускать задачи, выполняемые в отдельной транзакции либо сразу, либо через заданный промежуток времени, либо периодически. Как и следует ожидать, все задачи также хранятся во внутренней БД, а значит, будут выполняться и после перезапуска сервера.
Итак, код класса Battle.
При чтении данного кода может возникнуть вопрос: «Почему внутренний класс 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.2\dist.
Теперь, находясь в директории sgs-server-dist-0.10.2 выполняем следующую команду:
В результате чего в консоли можно увидеть следующее:
Ура! Сервер запустился! Теперь можно заняться клиентом:
Reddwarf на примере онлайн-игры «Камень-ножницы-бумага»: Клиент
Javadoc по API сервера
Документация, собранная сообществом
Форум проекта
В качестве примера решено было написать онлайн-реализацию игры «Камень-Ножницы-Бумага».
В этой статье мы напишем сервер и попробуем его запустить. В следующей статье напишем для этого сервера небольшой клиент и проверим их работоспособность.
Подготовка к работе
Для начала необходимо закачать сервер 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.2\lib\
Создаем папку 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.2\dist.
Теперь, находясь в директории 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.1\data\dsdb
фев 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 сервера
Документация, собранная сообществом
Форум проекта