
Часть 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
