GrabDuck

Создание игр без Canvas

:

Однажды мне попалась на глаза карточная игра HeartStone от Blizzard. Играя в нее пришла мысль, что подобные вещи можно создавать используя технологии html5, что позволит им быть кроссплатфорсенными. На мой взгляд, подобные вещи могут делать люди, до сих пор занимающиеся только созданием сайтов.

Итак, что мы имеем:

  • Выделенный сервер с LAMP (без phpDaemon);
  • Желание обкатать WebSockets.

Собственно, все. Этого вполне хватит, чтобы осуществить задуманное.

Для данного примера я использовал готовый WebSockets сервер, написанный на php.

Обмениваться с сервером будем данными в формате json, чтобы передавать имена методов и аргументы.

Самое основное, что нам необходимо — это наладить вменяемое взаимодействие клиента и сервера, то есть тот интерфейс, благодаря которому наше приложение (в данном случае игру) можно будет расширять.

В клиенте это будет выглядеть следующим образом:

	var socketInfo = {};
	socket = new WebSocket('ws://localhost:8000');		
	socket.onopen = function (e){
		socketInfo.method = "connect";
		socket.send(JSON.stringify(socketInfo));	
		console.log('Соединение установлено с ws://localhost:8000');		
	}
	socket.onclose = function (e){
		console.log('Соединение прервано!');
	}
	socket.onmessage = function (e){
		if (typeof e.data === "string"){
			var request = JSON.parse(e.data);
			Actions[request.function](request.args);
		};	
	}

Это стандартное описание функций WebSockets. Остановим внимание на методе onmessage.

В качестве параметра он принимает строку в формате json, которую нам отправил сервер. Строка содержит название имя функции, которое необходимо выполнить на клиенте, а также параметры, которые мы будем в нее передавать.

Для того, чтобы запустить функцию, имя которой содержит некая переменная, эта функция должна являться элементом массива (как вариант — глобальный массив window). В данном примере это массив Actions:

	var Actions = {
		myId: function(id){
			localStorage.setItem("myId", id);
		},				
		log: function(str){
			console.log(str);
		},
                .......
         }			


Естественно, передаем на сервер тоже строку в json формате. У меня она содержит два поля: имя метода и аргумент.
Аналогичным образом сервер разбирает полученную строку:
function websocket_onmessage($keyINsock, $str){
	global $Users;	

	$json = json_decode($str);
	$method = strval($json->{'method'});
	$args = $json->{'args'};

	if (!isset($args)) $args = $keyINsock;
	if (!empty($method)) $Users->$method($keyINsock, $args);
}

Функция websocket_onmessage обрабатывает запросы на наш сервер, в качестве аргумента принимая ID соединения и передаваемую строку соответственно. Далее идет работа с объектом класса Users.

Поясню на примере соединения игроков:

  public function frAccept($myid, $opId){
    $this->opponents[$myid] = $opId;
    $this->opponents[$opId] = $myid;
    $args = array($myid, $opId);
    $arrOut = array('function' => 'frAccept', 'args' => $args);
    $arrOutJSON = json_encode($arrOut);    
    websock_send($opId, $arrOutJSON);   
    websock_send($myid, $arrOutJSON);   
  }

В каждом методе первый параметр — идентификатор соединения, по которому пришел запрос, имено поэтому он и называется $myId. В примере описан метод соединения двух игроков (второй параметр — id игрока, который принял приглашение в сражение). Для простоты я не использую базы данных, поэтому заносим айдишники опоонентов в массив для дальнейшей работы с ними (они хранятся и в клиенте в LocalStorage, но зачем гонять их туда-сюда).

Все, кто чей оппонент записали, отдаем эту информацию обоим игрокам.

		frAccept: function(id){
                       fight_start(id);
                       console.log('Fight starting');                                                  
		}	

         .......
			
	function fight_start(ids){
		$('body').load('fight.html');
		curplayer = ids[0];		
		localStorage.setItem("opponent", ids[0]);
		if (ids[0] == localStorage.getItem("myId")) localStorage.setItem("opponent", ids[1]);
	}


Переменная curplayer хранит данные о том, чей сейчас ход.

Вся игра у нас построена на обычных блоках div.

<meta http-equiv="Cache-Control" content="no-cache" />
	<div id="to_area"></div>
	<div id="wrapper">
		<div id="opHand"></div>
		<div id="area">
			<div id="timer">
				<div class="bg"></div>
			</div>
			<div id="opUnits">
				<div class="nexus card" data-health="30" id="" data-attack="0">
					<div class="health">
						<div class="inner">30</div>
					</div>					
				</div>
				<div class="bg"></div>
			</div>
			<div id="myUnits">
				<div class="nexus card" data-health="30" data-attack="0">
					<div class="health">
						<div class="inner">30</div>
					</div>					
				</div>
			<div class="bg"></div>
			</div>
			<div id="next">Закончить ход</div>			
		</div>
		<div id="myhand"></div>
	</div>	

Имея такой каркас, начинаем работать с элементами игры.

Для начала получим по 4 начальных карты. Как это сделать? Четырежды попросим сервер: «дай карту». Я упростил эту операцию, добавив в качестве аргумента желаемое число карт: get_cards(4);

	function get_cards(num){
		socketInfo.method = "get_cards";
		socketInfo.args = num;
		socket.send(JSON.stringify(socketInfo));			
	}	

Мы уже разобрались, что сервер разбирает полученную json строку на имя метода и аргумент. В результате мы задействуем метод get_cards:
  public function get_cards($myid, $num){
    global $Cards;
    for ($i=0; $i < $num; $i++){ 
      $card = $Cards->getRandom();
      $args = array(
          "player_id" => $myid,
          "card" => $card
        );
      $arrOut = array('function' => 'player_get_card', 'args' => $args);
      $arrOutJSON = json_encode($arrOut);    
      websock_send($myid, $arrOutJSON);   
      websock_send($this->opponents[$myid], $arrOutJSON);       
    }
  }


В цикле запрашиваем случайную карту из колоды, ($Cards->getRandom() возвращает json представление карты: имя, атака, количество жизней, картинка) и отправляем результат обоим игрокам. Не забываем про оппонента, он должен видеть, что мы взяли карту.

Кто из игроков взял текущую карту определяем сравнением айдишников:

		player_get_card: function(args){
			if (!me(args.player_id)) {
				$('#opHand').append('<div class="card" id="'+args.card.id+'" />');
				return;
			}else{
				var card = card_construct(args.card);
				$('#myhand').append(card);
				return;
			}
		}

Соответственно #opHand — рука оппонента, #myhand — наша рука. То есть карты, которые мы держим в руке.

Итак, карты набрали, пора выкладывать их на стол. Для этого напишем элементарную jquery функуию:

	$('#myhand').on('click',".card",function(){ // Клик по карте в руке
		if (!me(curplayer)) return;
		to_area($(this));
	});	

При клике на карту в нашей руке карта отправляется на арену: to_area().
	function to_area(card){ // Выкладывание карты на арену
                card.appendTo('#myUnits');
		socketInfo.method = 'opGetCard';
		socketInfo.args = card.attr("id");
		socket.send(JSON.stringify(socketInfo));		
	}	

Не забываем о том, что противник должен все это видеть.
  public function opGetCard($myid, $card_id){
    global $Cards;    
    $card = $Cards->getById($card_id);
    $arrOut = array('function' => 'opGetCard', 'args' => $card);
    $arrOutJSON = json_encode($arrOut);    
    websock_send($this->opponents[$myid], $arrOutJSON);         
  }

Далее необходимо рассмотреть самое важное — атаку. Одной картой мы атакуем карту противника. Для этого нам надо:

  • Выбрать карту, которой будем ходить;
  • Выбрать карту-жертву.

Для выбора карты-агрессора будем манипулировать классом active:

	$('#area').on('click',"#myUnits .card",function(){ ////////////////// Клик по карте на арене
		if(!me(curplayer) || $(this).hasClass('nexus'))return;
		if ($(this).hasClass('active')) {
			$(this).removeClass('active');
		}else{
			$('#area #myUnits .card.active').removeClass('active');
			$(this).addClass('active');
		}
	});	

Агрессор имеет класс active, то есть карта выбрана. Теперь можно выбрать жертву.
	$('#area').on('click',"#opUnits .card",function(){ ////////////////// Клик по карте ОППОНЕНТА на арене
		$agressor = $('#area #myUnits .card.active');
		if ($agressor.length < 1) {
			return false;
		};
		attack_request($agressor,$(this)); 
	});	

Итак, при клике по карте-жертве передаем в функцию с говорящим названием attack_request два аргумента: агрессора и жертву. То есть кто и кого будет атаковать.

Сама функция является оберткой и лишь отправляет на сервер информацию о том, что будет атака

	function attack_request(agressor, victim){ //////////// Запрос атаки
		socketInfo.method = 'attack_request';
		socketInfo.args = agressor.attr("id")+'-'+victim.attr("id");
		socket.send(JSON.stringify(socketInfo));		
	}		

Функция на сервере работает в эхо-режиме, отправляет информацию обратно мне и моему оппоненту. Сделано это для того, чтобы как можно больше нивелировать разницу в скорости интернета двух игроков (ведь у нас в игре есть таймер, который отсчитывает 2 минуты на ход).
  public function attack_request($myid, $args){
    $arrOut = array('function' => 'attack', 'args' => $args);
    $arrOutJSON = json_encode($arrOut);  
    websock_send($myid, $arrOutJSON);               
    websock_send($this->opponents[$myid], $arrOutJSON);         
  }

Карты у нас четко определены уникальными айдишниками, поэтому имея id агрессора и жертвы мы можем эту атаку отрисовать:
		attack: function (args){ 
			id = args.split('-');
			var alias = $('#'+id[0]).data('alias');
			onBeforeAttack(alias,id);
		}

        ..................

	function onBeforeAttack(alias, id){ // До атаки проверка объектов
		if(Units[alias] == undefined){		
			var uFile = 'js/units/'+alias+'.unit.js';
			var xmlhttp = getXmlHttp();
			xmlhttp.open('GET', uFile, false);
			xmlhttp.send(null);
			if(xmlhttp.status == 200){
				$('body').append('<script src="'+uFile+'"></scipt>');
			}else{
				Units[alias] = new defaultUnit();
			}
		}
		Units[alias].attack(id[0], id[1]);
	}

Обратите внимание, до отрисовки атаки необходимо проверить, есть ли у нас объект, содержащий информацию о данном типе юнита. Я попытался сразу продумать момент расширяемости. Ведь у нас есть как melee юниты, так и range, и хотелось бы, чтобы их атака выглядела по-разному. Но это на перспективу. Пока у нас есть только дефолтный объект, который используется, если отсутствует соответствующий файл с описанием объекта юнита.

Вот как он выглядит:

	function defaultUnit(){}
	defaultUnit.prototype.attack =  function(agressor_id, victim_id){
		var victim = $('#'+victim_id);
		var agressor = $('#'+agressor_id);
		victim.css('z-index',5);
		agressor.css('z-index',10);
		var $ypos = victim.offset().top - agressor.offset().top;
		var $xpos = victim.offset().left - agressor.offset().left;
		agressor.animate({top:$ypos, left:$xpos}, 100).delay(200).animate({top:0, left:0}, 100);

		if (isNaN(victim.data('attack'))) victim.data('attack',0);
		if (isNaN(agressor.data('attack'))) agressor.data('attack',0);
		victim.data('health',victim.data('health')-agressor.data('attack'));
		agressor.data('health',agressor.data('health')-victim.data('attack'));
		refreshCards();
	}

После отрисовки атаки вызываем refreshCards(), которая изменит значения количества жизней у всех карт на арене (именно всех, ведь бывают и массовые заклинания).
	refreshCards = function(){ ////  Обертка обновления карт
		$('#myUnits .card.active').removeClass('active');
		var rest = setTimeout(function(){
			$('#area .card').each(function(){
				refresh_card($(this));
				if ($(this).data('health') < 1) {
					death($(this));				
				};
			});
			clearTimeout(rest);
		},1000);
	}
	function refresh_card(card){ ////////// Обновление карты
		$('.health .inner',card).html(card.data('health'));
		$('.attack .inner',card).html(card.data('attack'));
	}	

Если количество жизней становится меньшим единицы, запускаем «умирание» карты, а если умирает главное здание, которое, по сути игры, надо защищать, то вызываем еще и событие проигрыша одного из игроков:
	function death(unit){ ////////// Умирание
		if (unit.hasClass('nexus')) {
			var lose = localStorage.getItem("opponent");
			if (!me(curplayer)) lose = localStorage.getItem("myId");
			socketInfo.method = 'lose';
			socketInfo.args = lose;
			socket.send(JSON.stringify(socketInfo));		
		};
		unit.addClass('die');
		var dt = setTimeout(function(){
			unit.remove();
			clearTimeout(dt);
		},2000);
	}	

Собственно, я не собирался описывать каждое элементарное действие, которое я в игре создавал, описал концепцию.

Для просмотра демо вам понадобится два браузера/вкладки (на мобильных выглядит тухло, не советую).

141.8.196.181/new-cards