Часть 1: Архитектура
Часть 2: Протокол
Часть 4: Переходим в 3D
С третьей частью я немного задержался. Но как говорится лучше поздно чем никогда…
Итак, продолжаем разговор.
В третьей части нашей постановки мы реализуем протокол, напишем сервер и клиент которые будут взаимодействрвать по сети. И (ОМГ!) танки будут ездить!
Под катом то, что вы давно хотели, но боялись спросить…
Для особо придиристых напомню, что весь код в статье не претендует на звание «СуперПуперМегаОфигительноеОхрененноЗашибательское решение всех проблем». Код призван показать основные моменты и только. Он местами некрасив, неоптимален, но надесь основную суть передает.
Со времени последнй статьи произошло много событий. Одно из них это то, что я перешел на разработку для Scala под IDEA. Причина банальна — плагин для NetBeans совсем отстойный… Следовательно проект в bitbucket изменен с NetBeans на IDEA, так что не пугайтесь. И хоть первые впечатления от IDEA не очень положительные, попробую прожевать это кактус.
Часть третья. Действие первое: Что там с архитетурой ?
Вспомним что там с архитетурой…
Она хорошо ложится на Акторы в скале. Получается, что будет один процесс (GameServer) который принимает коннекты и после установки соединения передает канал Актору (ClientHandler) для обработки. Таким образом для каждого клиента будет создан свой актор и он будет отвечать за связь с клиентом. Для отправки сообщения клиенту мы просто отправляем его актору и забываем, актор сам отправит его клиенту и примет ответ. Вообще акторы в скале очень интересная вещь. Их можно создавать десятками тысяч, практически на каждый чих. Есть еще одна реализация акторов на скале, проект Akka. Он гораздо более навороченный. И для реальных проектов имеет смысл присмотреться нему.
Часть третья. Действие второе: Протокол передачи данных.
Для начала создадим класс игрока Player. Он будет хранить id игрока и его координаты.
class Player(idd: Int, xx: Int, yy: Int)
{
var id = idd
var x = xx
var y = yy
}
Протокол у нас самый простой Для его реализации надо создать 2 класса. Класс Packet в котором и бут храниться сообщение.
class Packet ( comm:Int, player: Player )
{
val com = comm // команда
val id = player.id // id клиента и его координаты
val x = player.x
val y = player.y
}
и Класс кодирующий и декодирующий сообщения
object Protocol
{
// кодируем
def encode( packet: Packet ): ByteBuffer =
{
val rez: ByteBuffer = ByteBuffer.allocate(16)
rez.putInt(packet.com)
rez.putInt(packet.id)
rez.putInt(packet.x)
rez.putInt(packet.y)
rez
}
// декодируем
def decode( buffer: ByteBuffer ): Packet =
{
val com = buffer.getInt(0)
val idd = buffer.getInt(4)
val xx = buffer.getInt(8)
val yy = buffer.getInt(12)
val rez: Packet = new Packet( com, new Player(idd, xx, yy) )
rez
}
}
Реализация протокола готова. Как видно, в простейших случаях ничего страшного. Но в реальных проектах для изобретения своего велосипеда должны быть весские основания. Лучше использовать готовые проверенные временем решения.
Часть третья. Действие третье: Сервер, как много в этом слове...
Пока мы создаем каркас игры. Поэтому сервер будет выполнять чисто номинальную работу. Обрабатывать подключение клиентов и обеспечивать связь между клиентами. В дальнейшем мы его будем дорабатывать.
Cоздаем класс GameServer
object GameServer extends Runnable
{
var isActive = true
var selector: Selector = null
var numClients = 0
var port = 7778
// здесь будем хранить сесси содержащие ссылку на актор и канал для определения клиента
var sessions = new HashMap[ SocketChannel, Actor ]
var lock: AnyRef = new Object()
// обрабатвает сообщения от клиента и рассылает их акторам для отправки клиентам
def addPlayerMsg(player: Player)
{
lock.synchronized
{
.....
}
}
// инициализация сервера
def init(portt: Int)
{
port = portt
try
{
selector = Selector.open
println( "Selector opened" )
}
catch
{
case e => println( "Problems during Socket Selector init: " + e )
}
}
override def run()
{
// активируем сокет
bindSocket( "", port)
// цикл обрабатывающий подключения
while ( isActive )
{
Loop()
}
}
def Loop()
{
if ( selector.select > 0 )
{
val it = selector.selectedKeys().iterator()
while ( it.hasNext )
{
val skey = it.next
it.remove()
if ( !skey.isValid )
{
continue()
}
// принимаем
if ( skey.isAcceptable )
{
val socket:ServerSocketChannel = skey.channel().asInstanceOf[ServerSocketChannel]
try
{
numClients = numClients + 1
val channel = socket.accept
channel.configureBlocking(false)
channel.register(selector, SelectionKey.OP_READ)
// создаем игрока и его сессию
val player = new Player(numClients, numClients * 20, numClients * 20)
val actor = new ClientHandler(player, channel)
actor.start()
// скажем игроку где ему появиться
val packet = new Packet( 0, player)
actor.packets += packet
// сохраним сессию в пул
sessions += channel -> actor
println( "Accepted connection from:" + channel.socket().getInetAddress + ":" + channel.socket().getPort )
}
catch
{
case e: Exception => println( "Game Loop Exception: " + e.getMessage )
}
}
// определяем из какой сессии пришло сообщение и отправляем его нужному актору
else
{
val channel:SocketChannel = skey.channel.asInstanceOf[SocketChannel]
val actor = sessions.get(channel).get.asInstanceOf[ClientHandler]
if ( actor.packets.size > 0 )
{
skey.interestOps(SelectionKey.OP_WRITE)
}
actor ! skey
}
}
}
}
def close(remoteAddress:String, channel:SocketChannel)
{
channel.close();
println("Session close: " + remoteAddress);
}
// Открываем сокет
def bindSocket(address: String, port: Int)
{
try
{
//слушаем локальный адрес
val hostAddr: InetAddress = null
val isa = new InetSocketAddress(hostAddr, port)
val serverChannel = ServerSocketChannel.open
serverChannel.configureBlocking(false)
serverChannel.socket.bind(isa)
serverChannel.socket.setReuseAddress(true)
serverChannel.register(selector, SelectionKey.OP_ACCEPT )
println( "Listening game on port: " + port )
}
catch
{
case e: IOException => println("Could not listen on port: " + port + ".")
System.exit(-1)
case e => println("Unknown error " + e)
System.exit(-1)
}
}
}
Сервер получился простой как перпендикуляр. Запускается он в отдельном треде. Хлеба не просит. Здесь показана только сама работа с клиентами. Нет частей обрабатывающих корректное подключение/отключение клиентов. Нет управления сессиями. Но это уже каждый может доработать как ему нравится.
Часть третья. Действие четвертое: Актор еще Актор.
Теперь создадим Actor который будет обрабатывать клиентское соединение.
class ClientHandler(player: Player, chanel:SocketChannel) extends Actor
{
val player_id = player.id
val channel = chanel
val remoteAddress = channel.socket().getRemoteSocketAddress.toString
var packets = new HashSet[ Packet ]
def act()
{
loop
{
receive
{
// принимаем сообщение
case key: SelectionKey =>
{
try
{
// читаем сообщение
if (key.isReadable)
{
val buffer = ByteBuffer.allocate(16)
channel.read(buffer) match
{
case -1 => close(remoteAddress, channel)
case 0 =>
case x => processMessageRead(key, buffer)
}
}
// пишем сообщение
else if (key.isWritable)
{
packets.synchronized
{
for(packet <- packets)
{
processMessageWrite( Protocol.encode(packet) )
packets.remove(packet)
}
}
if(packets.isEmpty)
key.interestOps(SelectionKey.OP_READ)
}
}
catch
{
case e: SocketException => println("ClientHandler SocketException error " + e.getMessage)
case e: IOException => println("ClientHandler IOException error " + e.getMessage)
case e => println("ClientHandler Unknown error " + e.getMessage)
}
}
}
}
}
// обработка чтения сообщения
def processMessageRead(key: SelectionKey, buffer: ByteBuffer)
{
if ( buffer.limit() == 0 )
{
return
}
buffer.flip
val protocol = Protocol.decode( buffer )
println( "Client : " + player.id + " - " + new Date + " - " + "com:" + protocol.com + " x:" + protocol.x + " y:" + protocol.y )
player.x = protocol.x
player.y = protocol.y
buffer.clear
if (protocol.com == 0)
{
key.interestOps(SelectionKey.OP_WRITE)
}
else if (protocol.com == 1)
{
GameServer.addPlayerMsg(player)
}
}
// обработка записи сообщения
def processMessageWrite(buffer: ByteBuffer)
{
if ( buffer.limit() == 0 )
{
return
}
buffer.flip
channel.write(buffer)
println( "Client write: " + player.id + " - " + new Date + " - " + buffer.array().mkString(":") )
buffer.clear
}
def close(remoteAddress: String, channel: SocketChannel)
{
channel.close()
println("Session " + player.id + " close: " + remoteAddress)
}
}
Актор получился простой как палка. Он только принимает сообщения и отсылает их.
Для нагрузочного тестирования я запустил сервер у себя на довольно слабеньком ноутбуке (1.3 Ггц, AMD, WiFi 56Mbit). А в качестве клиента создал консольное java приложение которое запускает указанное количество потоков и в каждом непрерывно, без паузы, отсылает пакеты на сервер. Клиент запускался на десктопе (3.6 Ггц, 4 ядра) в 100 потоков.
В итоге сервер переваривал порядка 6000 сообщений в секнду. Что в общем-то неплохо. В зависимости от вычислительной нагрузки, на реальном серверном железе, он сможет держать несколько тысяч клиентов.
Часть третья. Действие пятое: Клиент… а кто же еще ?
Клиент с прошлой части практически не изменился. Добавилось только граическое отображение игрока в виде танка и реализация протокола.
Добавим класс описывающий игрока
public class Player extends MovieClip
{
public var Name:String = "Player";
public var id:int = 0;
[Embed(source = '../../../../lib/tank.png')]
public var _tank: Class;
public var tank:Bitmap;
public function Player()
{
width = 30;
height = 30;
tank = new _tank();
tank.width = 30;
tank.height = 30;
addChild(tank);
}
}
А также допишем методы реализующие протокол.
//Отправляем сообщение
public function sendMessage(val:int):void
{
if (socket.connected)
{
var bytes:ByteArray = new ByteArray();
bytes.writeInt(val);
bytes.writeInt(player.id);
bytes.writeInt(player.x);
bytes.writeInt(player.y);
socket.writeBytes( bytes, 0, 16);
socket.flush();
}
}
//Пришли данные от сервера
private function dataHandler(e:ProgressEvent):void
{
var bytes:ByteArray = new ByteArray();
socket.readBytes(bytes, 0, 16);
var com:int = bytes.readInt();
var id:int = bytes.readInt();
var x:int = bytes.readInt();
var y:int = bytes.readInt();
switch (com)
{
// иницализация игрока
case 0:
…
// движение
case 1:
...
}
}
Вот и готово базовое взаимодействие между клиентом и сервером.
Не решены вопросы корректного подключения/отключения клиентов, синхронизация клиентов (из-за чего танки при движении дергаются). Это все нас ждет в следующих частях…
P.S. Многовато кода… может убрать часть и оставить только описание методов?
Как всегда все исходники можно посмотреть на Github