Как стать автором
Обновить

Пьеса «Разработка многопользовательской сетевой игры.» Часть 3: Клиент-серверное взаимодействие

Время на прочтение7 мин
Количество просмотров15K


Часть 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
Теги:
Хабы:
+64
Комментарии41

Публикации

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн