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

Unity Http Server или как превратить телефон пользователя в геймпад

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

Всем привет! Меня зовут Григорий Дядиченко, и я технический продюсер. Сегодня хотелось бы поговорить про протокол HTTP, про сервера, и про простенькую реализацию Http сервера вшитого в Unity. Если вам это интересно – добро пожаловать под кат!

Чтож, начнём пожалуй с задачи на примере которой мы будем всё разбирать. В данной статье мы будем разбирать только Unity часть. Предположим вы делаете какой-то интерактивный стенд или на какой-нибудь крупной конференции демонстрируете свою игру скажем издателю. Если игра на пк, можно это обыграть управлением с геймпада и подключить его или же джойстиком. Чтобы человеку было удобно играть, и он не закрывал собой экран ноутбука играя в вашу самую лучшую игру. Но есть любопытная альтернатива. Сделать геймпад из телефона пользователя! В этом случае при наличии какой никакой экспертизе в веб разработке можно там же удобно собирать контакты. Всё что вам понадобится это ноутбук, wi fi роутер, геймпад в виде веб приложения и http сервер зашитый в unity.

Важно: в условиях реальной выставки что с bluetooth геймпадом, что с подобным решением, нужно помнить о том, что там может быть много устройств и шума. Поэтому это может работать нестабильно, но обычно такое бывает, когда таких роутеров очень много. В целом при наличии своего вайфая на выставке проще подключиться к нему, так как это локальная сеть, очень маленькие запросы. И во-первых, это не даст особой нагрузки на сеть, во-вторых, не будет создавать радио шум, в-третьих, потенциальный игрок вполне возможно что будет уже к нему подключён и не нужно будет давать ему логин и пароль. Итак, мы определили “зачем”, хотя кейс не единственный, теперь поговорим про “что”.

Протокол HTTP

В современном мире этот протокол должен знать вообще любой разработчик, в идеале со множеством знаний построенных поверх него. REST API, GraphQL, GRPC и так далее. Я в целом не хочу повторяться и тратить время на описание самого протокола, простым языком отлично это и так сделали в этой статье. Но важно было про него упомянуть.

Http Server на Unity

Если брать на вооружение самую простую и низкоуровневую реализацию HTTP сервера на Unity, то можно сразу же вспомнить про класс HttpListener. И реализовать всё с помощью него. Итак, для начала всё начинается с коннекта и определения порта. Если что в любом браузере любой существующий сайт по умолчанию стучится в 80 порт. То есть если сервер развёрнут на 80 порту, а айпи вашего ПК (можно посмотреть через ipconfg в cmd) 192.168.1.24 к примеру. То ссылка в браузере будет вида http://192.168.1.24, если же развернуть сервер на скажем порте 10021, то тогда уже она будет вида http://192.168.1.24:10021. Для того, чтобы начать “слушать порт”, создаём такой Monobehavior класс:

Пример
public class HttpServer : MonoBehaviour
{
	[SerializeField] private int _Port = 10021;
  private HttpListener _httpListener;
	private void Start()
	{
		_httpListener = new HttpListener();
		_httpListener.Prefixes.Add($"http://*:{_Port}/");
		_httpListener.Start();
		_httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
	}

	private async void OnGetCallback (IAsyncResult result)
	{
	}
}

Важный нюанс. Порт – это условное число в диапазоне от 0 до 65535. Но лучше опасаться портов от 0 до 1023, когда вы не понимаете что вы делаете. Так как эти порты называются системными, и используются многими популярными программами. Соответственно открывать соединение на 80 порту можно, если вы знаете, что на вашем ПК ничего больше не запущено на этом порту. Там часто по умолчанию развёрнут IIS, NGINX и так далее. Если кому интересно подробнее можно посмотреть тут.

Так что если у вас возникает ошибка, что порт уже занят, возможно вы открываете соединение на порту, который уже зарезервировала какая-то программа. То что программы между собой общаются посредством протокола http в целом не такая редкая практика.

Итак соединение мы открыли, теперь нам надо бы что-то возвращать, как ответ сервера и реагировать на запросы. Допишем реализацию нашего метода OnGetCallback:

Пример
public class HttpServer : MonoBehaviour
{
	[SerializeField] private int _Port = 10021;
  private HttpListener _httpListener;
	private void Start()
	{	
		_httpListener = new HttpListener();
		_httpListener.Prefixes.Add($"http://*:{_Port}/");
		_httpListener.Start();
		_httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
	}

	private async void OnGetCallback (IAsyncResult result)
	{
		HttpListenerContext context = _httpListener.EndGetContext(result);
		var response = context.Response;
		var request = context.Request;
		context.Response.Headers.Clear();
		try
		{
			CreateResponse(response, new NetworkAnswer(){ status = 200 });
		}
		catch (Exception e)
		{
			CreateErrorResponse(response, e.Message);
		}
		if (_httpListener.IsListening)
		{
			_httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
		}
	}
	private async void CreateResponse(HttpListenerResponse response, NetworkAnswer data = default)
	{
		response.SendChunked = false;
		response.StatusCode = data.status;
		response.StatusDescription = data.status == 200 ? "OK" : "Internal Server Error";
		using (var writer = new StreamWriter(response.OutputStream, response.ContentEncoding))
		{
			await writer.WriteAsync(JsonConvert.SerializeObject(data));
		}
		response.Close();
	}
	private async void CreateErrorResponse(HttpListenerResponse response, string error)
	{
		response.SendChunked = false;
		response.StatusCode = 500;
		response.StatusDescription = "Internal Server Error";
		using (var writer = new StreamWriter(response.OutputStream, response.ContentEncoding))
		{
			await writer.WriteAsync(JsonConvert.SerializeObject(new NetworkAnswer()
			{
				status = 500,
				errorMessage =  error
			}));
		}
		response.Close();
	}
}

public class NetworkAnswer
{
	public int status;
	public string errorMessage;
	public object data;
}

Теперь можно пойти в браузер и сделать запрос на localhost. И вуаля, It’s Alive! Правда? Наш хттп сервер готов? Ну не совсем.

Вездесущий CORS

На localhost так то оно работает, но вот при попытке сделать запрос скажем с телефона в браузере вылетит ошибка CORS. Что такое CORS и как оно работает? Если коротко, это технология которая ограничивает возможности запросов с одного домена на другой. Она была когда-то сделана во избежание проблем с фишингом, да и в целом это много для чего довольно удобно. Подробнее можно почитать тут.

Нас же интересует, а как это обработать. Так как если его не обработать, то запросы просто не пропустит CORS политика браузера. Сделать это довольно просто. Нужно прописать необходимые хедеры в наш метод OnGetCallback:

Пример
public class HttpServer : MonoBehaviour
{
	[SerializeField] private int _Port = 10021;
  private HttpListener _httpListener;
	private void Start()
	{	
		_httpListener = new HttpListener();
		_httpListener.Prefixes.Add($"http://*:{_Port}/");
		_httpListener.Start();
		_httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
	}

	private async void OnGetCallback (IAsyncResult result)
	{
		HttpListenerContext context = _httpListener.EndGetContext(result);
		var response = context.Response;
		var request = context.Request;
		context.Response.Headers.Clear();
    
		response.AppendHeader("Access-Control-Allow-Origin", "*");
		response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With");
		response.AddHeader("Access-Control-Allow-Methods", "GET, POST");
		response.AddHeader("Access-Control-Max-Age", "1728000");
		
    try
		{
			CreateResponse(response, new NetworkAnswer(){ status = 200 });
		}
		catch (Exception e)
		{
			CreateErrorResponse(response, e.Message);
		}
		if (_httpListener.IsListening)
		{
			_httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
		}
	}
	private async void CreateResponse(HttpListenerResponse response, NetworkAnswer data = default)
	{
		response.SendChunked = false;
		response.StatusCode = data.status;
		response.StatusDescription = data.status == 200 ? "OK" : "Internal Server Error";
		using (var writer = new StreamWriter(response.OutputStream, response.ContentEncoding))
		{
			await writer.WriteAsync(JsonConvert.SerializeObject(data));
		}
		response.Close();
	}
	private async void CreateErrorResponse(HttpListenerResponse response, string error)
	{
		response.SendChunked = false;
		response.StatusCode = 500;
		response.StatusDescription = "Internal Server Error";
		using (var writer = new StreamWriter(response.OutputStream, response.ContentEncoding))
		{
			await writer.WriteAsync(JsonConvert.SerializeObject(new NetworkAnswer()
			{
				status = 500,
				errorMessage =  error
			}));
		}
		response.Close();
	}
}

public class NetworkAnswer
{
	public int status;
	public string errorMessage;
	public object data;
}

Всё, корс пройден? Не совсем. Лучше заранее так же обработать такую штуку, которая называется предварительный запрос. Так как мы не знаем в каком контексте будет использоваться наш простенький Http Server, то мы заранее обработаем тот случай, когда CORS считает запрос сложным. Дело в том, что если современный браузер считает запрос сложным, то перед отправкой основного запроса он посылает предварительный запрос с методом OPTIONS. Например признаки сложных запросов:

  • Использующие методы кроме GET, POST, или HEAD

  • Включающие заголовки кроме Accept, Accept-Language, Content-Type или Content-Language

  • Со значением Content-Type отличнным от application/x-www-form-urlencoded, multipart/form-data, или text/plain

Так как в всё это значит для нас то, что в наш сервер просто придёт лишний запрос с методом OPTIONS, который нам надо обработать и вернуть правильный респонс. Мы просто добавляем в наш метод OnGetCallback:

Пример
public class HttpServer : MonoBehaviour
{
	[SerializeField] private int _Port = 10021;
  private HttpListener _httpListener;
	private void Start()
	{	
		_httpListener = new HttpListener();
		_httpListener.Prefixes.Add($"http://*:{_Port}/");
		_httpListener.Start();
		_httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
	}

	private async void OnGetCallback (IAsyncResult result)
	{
		HttpListenerContext context = _httpListener.EndGetContext(result);
		var response = context.Response;
		var request = context.Request;
		context.Response.Headers.Clear();
		response.AppendHeader("Access-Control-Allow-Origin", "*");
		response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With");
		response.AddHeader("Access-Control-Allow-Methods", "GET, POST");
		response.AddHeader("Access-Control-Max-Age", "1728000");
		if (request.HttpMethod == "OPTIONS")
		{
			CreateResponse(response, new NetworkAnswer(){ status = 200});
			if (_httpListener.IsListening)
			{
				_httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
			}
			return;
		}
		try
		{
			CreateResponse(response, new NetworkAnswer(){ status = 200 });
		}
		catch (Exception e)
		{
			CreateErrorResponse(response, e.Message);
		}
		if (_httpListener.IsListening)
		{
			_httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
		}
	}
	private async void CreateResponse(HttpListenerResponse response, NetworkAnswer data = default)
	{
		response.SendChunked = false;
		response.StatusCode = data.status;
		response.StatusDescription = data.status == 200 ? "OK" : "Internal Server Error";
		using (var writer = new StreamWriter(response.OutputStream, response.ContentEncoding))
		{
			await writer.WriteAsync(JsonConvert.SerializeObject(data));
		}
		response.Close();
	}
	private async void CreateErrorResponse(HttpListenerResponse response, string error)
	{
		response.SendChunked = false;
		response.StatusCode = 500;
		response.StatusDescription = "Internal Server Error";
		using (var writer = new StreamWriter(response.OutputStream, response.ContentEncoding))
		{
			await writer.WriteAsync(JsonConvert.SerializeObject(new NetworkAnswer()
			{
				status = 500,
				errorMessage =  error
			}));
		}
		response.Close();
	}
}

public class NetworkAnswer
{
	public int status;
	public string errorMessage;
	public object data;
}

Вух! CORS прошли, самое сложное можно сказать позади. Теперь добавим немного удобства. Теперь наш http server готов?

Вездесущий фаерволл

Всё, можно слать запросы с телефона? Не тут то было, а точнее не совсем. Если в билде всё уже работает, то в редакторе нужно сделать последний шаг. И это “пройти фаервол”. Если у вас отключен фаервол то “вы ходите по *** тонкому льду”, но ваше право. А если же он всё-таки включен, то в юнити есть небольшая странность. Возможно очень топорная защита от уязвимостей, что странно в инструменте для разработчиков. Но юнити про многое по моему опыту считало в своё время “да вам это не надо”. При установке по умолчанию он прописывает правило запрещающее паблик коннекты к редактору Unity.

Я уже как-то подсвечивал этот момент в этой статье. Но тогда я был молодой неопытный и не мог объяснить всё нормальным языком. Firewall, который многие не заслужено не любят, это ваш хороший друг. Как и вообще любая политика безопасности, так как не дураки его придумывали. И лучше уметь его настраивать, а не отключать. Собственно чтобы редактор начал отвечать нужно изменить в Advanced Settings в Inbound Rules редактора с запрета паблик соединений на разрешение.

Пример картинками

Фух! Справились, теперь наш http server отвечает и в Unity.

Рефлексию в каждый дом

Многие не любят рефлексию. Но не я. Я не люблю лишние, беспощадные и бессмысленные оптимизации. У рефлексии есть свои плюсы и минусы, она не везде работает, не всегда применима, но как же она иногда упрощает жизнь. Рефлексия (или механизм отражения) в С# позволяет творить очень много очень хитрой магии. Но нам понадобится конкретная её часть. Возможность “заглянуть в ассембли” и посмотреть, а какие наследники базового класса есть в нашей программе. Собственно это один из моих любимых паттернов для реализации консольных комманд, собственных скриптовых языков и т.п. Для начала создадим базовый абстрактный класс:

Класс HTTPServerHandler
public abstract class HTTPServerHandler
{
  protected abstract string _route { get; }
  protected string[] _params;
  public bool IsThisRoute (string url)
  {
    return url.ToLower().Contains(_route.ToLower());
  }
  private void ParseParams (string url)
  {
    _params = url.Replace(_route, string.Empty).Split('/');
  }
  public abstract NetworkAnswer GetAnswerData ();
  public virtual void ProcessParams (string url)
  {
    ParseParams(url);   
  }
}

Параметры в http запросе мы будем писать в путь запроса через “/” для простоты реализации. А дальше соберём наших наследников в список хендлеров через рефлексию, дописав метод Start нашего http сервера и введя поле _httpServerHandlers:

Пример
 private List<HTTPServerHandler> _httpServerHandlers;
 private void Start()
 {
   _httpServerHandlers = new List<HTTPServerHandler>();
   var subclassTypes = Assembly
     .Load("Assembly-CSharp")
     .GetTypes()
     .Where(t => t.IsSubclassOf(typeof(HTTPServerHandler)));
   foreach (var subclassType in subclassTypes)
   {
     _httpServerHandlers.Add(Activator.CreateInstance(subclassType) as HTTPServerHandler);
   }

   _httpListener = new HttpListener();
   _httpListener.Prefixes.Add($"http://*:{_Port}/");
   _httpListener.Start();
   _httpListener.BeginGetContext(new AsyncCallback(OnGetCallback), null);
 }

В Unity “Assembly-CSharp” – это основное ассембли проекта. Сделано в данном случае это так, а не запрашивая ассембли класса для того, чтобы не запутаться при использовании в проекте механизма Assebly Definitions. Так как можно создать наследника класса вне ассембли с классом HttpServerHandler и тогда рефлексия его не увидит. Не очень оптимально, так как по сути мы запросили все типы основного ассембли программы, и отфильтровали нужные нам, что может быть достаточно медленно. Но это уже каждый может оптимизировать под себя. И переделать этот блок хоть под регистрацию хенделров в явном виде. Класс же активатор позволяет нам создать объект типа, которые мы нашли. И вуаля наши хендлеры зарегистрированы. Можно сделать более красивый вариант с реализацией через атрибуты, но мы разберём самый простой способ упростить себе жизнь.

Что это позволяет нам сделать? Ну по сути теперь для того, чтобы наш хендлер подключился к системе сервера, нам достаточно реализовать его наследника, и он подтянется автоматически. Напишем например хендлер для нашего “удалённого геймпада”:

Класс GamepadHandler
public class GamepadHandler : HTTPServerHandler
{
	protected override string _route => "/gamepad/";
	private bool IsSuccess = true;
	private string ErrorMessage;
	public override void ProcessParams(string url)
	{
		base.ProcessParams(url);
		if(Enum.TryParse<KeyCode>(_params[1], true, out var key))
		{
			if (_params[0] == "down")
			{
				WebInput.SetKeyDown(key);
			}
			else
			{
				
				WebInput.SetKeyUp(key);
			}
		}
		else
		{
			ErrorMessage = $"Invalid keycode in param 1. There are no keycode {_params[0]}";
		}
	}
	public override NetworkAnswer GetAnswerData()
	{
		return new NetworkAnswer()
		{
			status = IsSuccess ? 200 : 500,
			errorMessage = IsSuccess ? null : ErrorMessage
		};
	}
}
public class WebInput : IKeyInput
{
	private static Dictionary<KeyCode, KeyState> _Keys = new Dictionary<KeyCode, KeyState>();
	public static void SetKeyDown(KeyCode key)
	{
		_Keys[key] = KeyState.Down;
	}
	public static void SetKeyUp(KeyCode key)
	{
		_Keys[key] = KeyState.Up;
	}

	public bool GetKeyDown(KeyCode key)
	{
		if (!_Keys.ContainsKey(key)) return false;
		return _Keys[key] == KeyState.Down;
	}
	public bool GetKeyUp(KeyCode key)
	{
		if (!_Keys.ContainsKey(key)) return false;
		return _Keys[key] == KeyState.Up;
	}

	public bool GetKey(KeyCode key)
	{
		if (!_Keys.ContainsKey(key)) return false;
		return _Keys[key] == KeyState.Pressed;
	}
	
	public void ProcessState()
	{
		foreach (var key in _Keys.Keys.ToList())
		{
			switch (_Keys[key])
			{
				case KeyState.Down:
					_Keys[key] = KeyState.Pressed;
					break;
				case KeyState.Up:
					_Keys[key] = KeyState.None;
					break;
			}
		}
	}


	private enum KeyState
	{
		None = 0,
		Up = 1,
		Down = 2,
		Pressed = 4
	}
}
public interface IKeyInput
{
	bool GetKey(KeyCode key);
	bool GetKeyDown(KeyCode key);
	bool GetKeyUp(KeyCode key);
	void ProcessState();
}

Плюс дополнительно описать обработчик WebInput и интерфейс IKeyInput. Всё. Наш хендлер подключен и работает. Можно проверить в браузере по урлу вида http://localhost:{yourPort}/gamepad/down/A/. (Лучше предварительно в статик методах вывести лог) Поэтому я обожаю этот паттерн для консольных команд, типа читов в игре. Так как не нужно думать “что и куда тут подключить”. Создаёшь наследника класса, прописываешь имя команды и её параметры (парсинг стандартизирован в основном классе) и логику его обработки. И всё, он автоматически подключен в систему.

Потоки и контексты

Но выше был довольно простой пример не учитывающий один небольшой нюанс. Юнити нормально работает только в Main Thread. Допустим задать текст в текстовое поле в интерфейсе. Для этого нам нужно сменить контекст исполнения. В шарпах появился вроде как более элегантный способ, но я предпочитаю по старинке. Создать клаcc ThreadDispatcher:

Класс ThreadDispatcher
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using UnityEngine;

public class ThreadDispatcher : MonoBehaviour
{
    private ConcurrentQueue<Action> _Events;
    public static ThreadDispatcher Instance;
    private void Awake()
    {
        Instance = this;
        _Events = new ConcurrentQueue<Action>();
    }

    private void Update()
    {
        while(_Events.Count > 0)
        {
            if (_Events.TryDequeue(out var action))
            {
                action?.Invoke();
            }
        }
    }

    public void AddEvent(Action action)
    {
        _Events.Enqueue(action);
    }
}

Пример использования:

ThreadDispatcher.Instance.AddEvent(() =>
	{
  	//your require main thread code here
  });

В заключении

А где же геймпад? Собственно для реализации геймпада нам нужно сделать веб приложение. Я сделал его на связке React/Redux + PIXI.js. Может базово разберу его в отдельной статье, а пока пример можно найти тут и просто взять as-is если кому-то будет полезно. Полный разбор, особенно понятный Unity разработчикам, требует погружения в js и его технологии. А не все в них любят погружаться. Хотя чтобы быть настоящим кроссплатформенным разработчиком вы должны знать нюансы всех платформ, и веба в том числе. Спасибо за внимание! 

Теги:
Хабы:
+3
Комментарии18

Публикации

Истории

Работа

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

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