В данной статье будет описан простой способ создания сетевой онлайн мини игры наподобие небольшой чат комнаты. Игроки могут передвигаться по полю игры, прятаться за деревьями, также есть возможность управлять камерой вида. Для тестирования игры необходимо скачать редактор, зайти в папку collagen_2/games/game_3, ввести в командной строке forever start app.js. Для работы игры требуются модули socket.js и forever(глобальная инсталяция).

Создание сцены игры

Для создания сцены необходимо подготовить все спрайты: фоновой подложки, деревьев, персонажа с покадровой анимацией, затем сохранить их. Далее перейти в редактор сцены, для это нажать кнопку code синего цвета, затем кнопку test. Загрузить фоновое изображение того же размера, что и сцена, загрузить все спрайты. Разместить спрайты бекгроунда, деревьев и персонажа на сцене. Для перемещения камеры вида использовать кнопки - a, w, s, d. Далее добавить спрайты бекгроунда кнопкой add tile bg - синего цвета, спрайты сцены и персонажа кнопкой add tile.

Один и тот же спрайт можно использовать для нескольких одинаковых объектов сцены, например деревьев, для этого переместить спрайт в новое положение и снова нажать кнопку add tile или add tile bg. Id объекта сцены можно изменить, также можно удалить ненужный объект из массивов tile_common и tilt_bg, после чего нажать на кнопку update зеленого цвета. В тестовом редакторе также можно писать код для тестирования анимации непосредственно в броузере. После подготовки сцены - сохранить ее в папке для игр collagen_2/games/game_name в json файл ,нажав кнопку save project - в низу панелей с кнопками.

Создание основной логики игры клиентской части

В файле index.html указываем путь к основному скрипту игры - ../../../collagen_2/games/game_name/game_name.js, в файле game_name.js указываем путь к json файлу сцены в переменной gameUrl, также меняем название папки игры в файле app.js.

Далее создаем логику игры в файле game_name.js:

//////////////переопределяем размер карты смещения камеры вида
 var maxTranslate = [-1000, -0];
//адрес json файла спрайтов
var gameUrl = "http://localhost:3000/collagen_2/games/game_1/game_1.json";
var personageId = "personage";


//анимация сцены
function apply_code(){
	        //обновляем переменные для вколючения анимации
	  			mode = "animation";
				if (modules.animation)modules.animation.isOff = true;
				updateCommonTiles(HM.$props().sprites);
				updateBgTiles(HM.$props().sprites);
				//режим code чтобы обновлять только Tiles
				HM.$$("emiter-operation-with").set("code");
				

///создаем анимацию персонажа				
modules.personage = null;

//находим объект персонажа в массиве tiles_common созданном в test 
for(var i=0; i<tiles_common.length; i++){	
	if(tiles_common[i].id == personageId){
		modules.personage = tiles_common[i];
	}
}

///флаг лика по кнопкам клавиатуры
var click  = false;
///интервал анимации движения персонажа
var interval  = 100;
 ///id таймера для отключения интервала анимации
var timerId  = 0;

///обработка событий нажатия стрелок
modules.keydown = function(key){
if(key == "ArrowUp"){				 
	if (click == true)return;
	click = true;
	clearInterval(timerId);
    timerId = setInterval(move, interval, 11, 15, 12, 0, -10);			
}else if(key == "ArrowDown"){
	if (click == true)return;
	click = true;
	clearInterval(timerId);
    timerId = setInterval(move, interval, -1, 3, 0, 0, 10);
}else if(key == "ArrowRight"){			 
	if (click == true)return;
	click = true;
	clearInterval(timerId);
    timerId = setInterval(move, interval, 7, 11, 8, 10, 0);
		
}else if(key == "ArrowLeft"){
	if (click == true)return;
	click = true;
	clearInterval(timerId);
    timerId = setInterval(move, interval, 3, 7, 4, -10, 0);
}
}
//обработка события отжатия кнопок-стрелок
modules.keyup = function(key){
		clearInterval(timerId);
		click = false;
}

///функция анимации движения персонажа и смены кадров
function move(arg1,arg2,arg3, arg4, arg5) {
	     if(modules.personage.frame_index > arg1 &&  modules.personage.frame_index < arg2){ 
            modules.personage.nextFrame();}else{
            modules.personage.nextFrame(arg3);
		}
		modules.personage.move(arg4, arg5);		
}

//запуск цикла анимации обновления фоновых и основных спрайтов, в том числе персонажа
modules.animation=animationLopLayer(tiles_bg,tiles_common, 40);
				
}


///функция загрузки json файла, создания спрайтов и объектов сцены - Tile
fetch(gameUrl)
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then((json) => {	  
	  //console.log(json)
	    var dataURL = 'data:image/png;base64,' + json.backImg;
	    var context  = HM; //ссылка на корень приложения
                       /// tiles_bg_save, tiles_common_save - временные массивы 
                      ///для хранения промежуточных данных - файл js/games/main_games.js
	                    tiles_common_save = JSON.parse(json.tiles_common_save);				
				        tiles_bg_save = JSON.parse(json.tiles_bg_save); 						
						mainImgScale_x = 1;
						mainImgScale_y = 1; 
						img.src = dataURL;
                        ///обновляем фоновую картинку
						img.onload = function(){ 		
							startImg();	
						}
                        ///создание спрайтов на которые ссылаются объекты сцены и 
                        ///бекгроунда при включении анимации
						for(var key in  json.sprites){
							   var sprite = createFromPC(key, context, false, json.sprites[key]);
							   if(sprite)context.$$("emiter-create-sprite").set(key);									
						}
                //создание бекгроунда               
				tiles_bg = [];		
				for(var i=0; i<tiles_bg_save.length; i++){					
					if(context.$props().sprites[tiles_bg_save[i].parent])tiles_bg.push(new Tile(tiles_bg_save[i].id, context.$props().sprites[tiles_bg_save[i].parent], tiles_bg_save[i].point));					
				}
                ///создание объектов сцены и персонажа
				tiles_common = [];
				for(var i=0; i<tiles_common_save.length; i++){	
						if(context.$props().sprites[tiles_common_save[i].parent])tiles_common.push(new Tile(tiles_common_save[i].id, context.$props().sprites[tiles_common_save[i].parent], tiles_common_save[i].point));					
				}
				///включение анимации
				apply_code();
	  })
  .catch((err) => console.error(`Fetch problem: ${err.message}`));

Создание основной логики анимации сцены и персонажа закончена, протестировать результат можно в папке collagen_2/games/game_1.

Создание серверной составляющей игры

Создание сервера.

Для создания серверной части была использована библиотека socket.js и модуль forever для автоматического запуска сервера.

//при выходе игрока сервер перезагружается 
/// для корректной работы использовать модуль forever.  Запуск сервера:   forever start app.js	

var users = {}; ///координаты и текущий кадр анимации пользователей
var socketjs = require('socket.js'); 

///////связь с другими клиентами
////данные анимации всех игроков
socketjs(server, function(socket, reconnectData) {
	
   console.log(reconnectData);
   
  //подключение нового пользователя
  if (reconnectData === null) {
    console.log('A user connected.');
  } else {
    console.log('A user reconnected with: ', reconnectData);
  }
 
  ///новый игрок обновляем users
   socket.receive('newuser', function(message) {
	users[message.id] = message;
  });
  
  
  // сообщения с клиента
  socket.receive('coord', function(message) {
	users[message.id] = message;  
  });

  //сообщения игрокам каждые 100ms
  var interval = setInterval(function() {
		socket.send('coord', users);
  }, 100);


  socket.close(function(data) {
    console.log('A user disconnected.');
    clearInterval(interval);
	return data;
  });
});

Добавление сокет-составляющей в файл клиента.

//добавляем переменные
//уникальное id для передачи данных на сервер (т.к. персонажи одинаковые для всех)
var userId = "user_" + Math.floor(Math.random() * 1000);
///изначальное количество объектов сцены
var numTiles = 0;
///начальное количество игроков
var numUsers = 1;

///добавляем запуск функций в fetch функцию загрузки json сцены
                ///обновляем количество объектов сцены				
				numTiles = tiles_common.length;
				///включение анимации
				apply_code();
				 createSocket();

///создаем логику сокет соединения
function createSocket(){			
///соединение с другими игроками
  if (socketjs.isSupported()) {
  // connect to the server
  var socket = socketjs.connect();
  
  ///первое сообщение   на сервер с координатами при загрузке 
  socket.send('newuser', {id: userId, point: modules.personage.point, frame_index: modules.personage.frame_index});

  // log a message if we get disconnected
  socket.disconnect(function(data) {
    console.log('Temporarily disconnected.');
  });

  // log a message when we reconnect
  socket.reconnect(function() {
    console.log('Reconnected.');

    // whatever we return here is sent back to the server
    return 'reconnected';
  });
  
 /////////////////////////////////////////////////////////////////////////////////////////// 
    // корординаты игроков с сервера 
  socket.receive('coord', function(data) {
	 //обновляем объекты сцены в случае добавления или выхода игрока 
	 if(Object.keys(data).length != numUsers){
		//удаляем все объекты сцены
		tiles_common.splice(0, tiles_common.length);
		//создаем исходные обекты
		 for(var i=0; i<tiles_common_save.length; i++){
              //HM - ссылка на корень приложения
			 if(HM.$props().sprites[tiles_common_save[i].parent]){  
                   tiles_common.push(new Tile(tiles_common_save[i].id, HM.$props().sprites[tiles_common_save[i].parent], tiles_common_save[i].point));					
			 }
         }
                  
		///создаем новых игроков		
		 for(var key in data){		 
			 if(data[key].id != userId){
				var tile = new Tile(data[key].id, HM.$props().sprites[modules.personage.parent], data[key].point);
				 tiles_common.push(tile);	 
			 }			 
		 }
		 //обновляем ссылку на персонажа
		 for(var i=0; i<tiles_common.length; i++){	
	               if(tiles_common[i].id == personageId){
		           tiles_common[i] = modules.personage;
	            }
         }
    // console.log(tiles_common);
	 numUsers  = Object.keys(data).length;//обновляем количество игроков
	 };
	///включаем анимацию игроков 
	updateUsers(data);  
  });

  //отправляем координаты и текущий кадр на сервер каждые  100ms
  var interval = setInterval(function() {
    socket.send('coord', {id: userId, point: modules.personage.point, frame_index: modules.personage.frame_index});
  }, 100);

  socket.close(function() {
    console.log('Connection closed.');
    clearInterval(interval);
  });
} else {
  console.log('Your browser does not support WebSockets.');
}
///
}
///функция обновления координат и текущего кадра персонажа
 function updateUsers(users){
	
	 for(var i=0; i< tiles_common.length; i++ ){
		 if(users[tiles_common[i].id] && tiles_common[i].id != userId ){
			tiles_common[i].point[0] = users[tiles_common[i].id].point[0];
			tiles_common[i].point[1] = users[tiles_common[i].id].point[1];
			tiles_common[i].nextFrame(users[tiles_common[i].id].frame_index);
		 }		 
	 }	
}


Серверная часть готова, рабочий пример можно протестировать в папке collagen_2/games/game_2.

Создание сообщений чата

Создадим новый спрайт-диалог для отображения сообщений

Назовем его message, далее откроем редактор в тестовом режиме и добавим спрайт в предыдущий проект кнопкой add tile, изменим id на msg, нажмем update, затем сохраним проект в папке иры game_3.

Отредактируем пути к ресурсам проекта в файлах index.htm, app.js, game_3.js. Далее добавим html разметку для формы сообщений в файл index.html.

<!--форма отправки сообщений -->
<div data-user_message="container" class="form-group " style="position: fixed;  top: 2px; right: 2px; z-index:5">
		<input name="user_msg" type="text"  style="width: 270px; padding: 0px; font-size: 14px; margin-top: 2px;"  placeholder="" title="сообщение">
		<button data-hide_panel="container" type="button"  name="user_msg_btn" class="btn btn-info btn-sm"  title="сообщение">message</button>	
</div>	

Добавим javascript код в файл game_3.js

  //отправляем координаты и текущий кадр на сервер каждые  100ms
  ///отредактируем метод:
  var interval = setInterval(function() {
                                                                                                                  
    socket.send('coord', {id: userId, point: modules.personage.point,
                           frame_index: modules.personage.frame_index,
                          ///добавляем сообщение игрокам
                          msg: modules.personage.message});
  }, 100);





///функция обновления координат и текущего кадра персонажа
 function updateUsers(users){

	 for(var i=0; i< tiles_common.length; i++ ){
		 if(users[tiles_common[i].id] && tiles_common[i].id != userId ){
			tiles_common[i].point[0] = users[tiles_common[i].id].point[0];
			tiles_common[i].point[1] = users[tiles_common[i].id].point[1];
			tiles_common[i].nextFrame(users[tiles_common[i].id].frame_index);
			
			///добавляем сообщение от игроков
			tiles_common[i].message = users[tiles_common[i].id].msg;
		 }		 
	 }	
}





/////////////////////////////////////Сообщения игороков
///Переопределяем метод pender из файла /test/tiles.js
Tile.prototype.render_ = function(){
	if(!this.show)return;				
    ctx.drawImage(this.frame, this.point[0], this.point[1], this.width, this.height);
	
	//отображаем спрайт с сообщением
    if(this.message){

		var spiteMsg = HM.$props().sprites["message"];
		spiteMsg.show = true;
		///отображаем сообщение справа вверху от персонажа
		spiteMsg.point[0] = this.point[0]+50; 
		spiteMsg.point[1] = this.point[1]-70;
		///нижняя правая точка спрайта
		spiteMsg.point2[0] = spiteMsg.point[0]+spiteMsg.width;
		spiteMsg.point2[1] = spiteMsg.point[1]+spiteMsg.height;

	    //настраиваем параметры текстового сообщения - отступы, шрифт, размер	
		spiteMsg.textParam = {
			text: this.message, 
			lineHeight: 15, 
			font: "15px Balsamiq Sans",
			fillStyle: "black",
			padding_x_l: 5, 
			padding_x_r: 5,		
			padding_x: false, 
			padding_y: 5, 
			max_width: false, 
			textArr: false,		
		}
	    spiteMsg.render_();
	}	
}
///переопределяем метод render_ спрайта для отрисовки сообщений /js/sprites
///убираем все лишнее для более быстрой работы, добаляем функцию для отображения текста.

CollageSprite.prototype.render_ = function(){
	if(!this.show)return;			
	ctx.drawImage(this.frame, this.point[0], this.point[1], this.width, this.height);
	if(this.textParam)this.fillText(this.point[0], this.point[1]);	
}

///добавляем контейнер с формой в для отправки сообщений в объект StateMap  /js/games/inteface_games.js
 StateMap.user_message = { //канвас	
		container: "user_message",
		props: [["user_msg_btn", "mousedown", "[name='user_msg_btn']"], 
                ["user_msg", "inputvalue", "[name='user_msg']"],				 
		       ],
		methods: {
			user_msg_btn: function(){
			    var text  = this.props("user_msg").getProp(); 
				////тестовое сообщение
                modules.personage.message = text;
            }
		}			
		
 }

Мини-чат готов, рабочий пример можно протестировать в папке collagen_2/games/game_3. Чат работает только на транслите, т.к. socket.js не поддерживает кирилицу.

Добавляем возможность выбора персонажа

Создадим спрайт второго персонажа, добавим его в проект, удалим из массива tiles_common - объект предыдущего персонажа (personage), а также сообщения - msg, персонажи будут добавляться в данный массив в процессе игры, после выбора пользователя, сообщения создаются непосредственно в функции рендер. Также создадим спрайт анимации костра. Сохраним json в папке glitch_forest_2.

Далее создадим папку glitch_forest_2/public и переместим в нее все необходимые файлы, включая стили.

Добавим html разметку для выбора персонажа, контейнер изначально будет скрыт, до загрузки всех файлов - display: none.

<div data-chose_personage="container" class="form-group " style="display: none; padding: 10px; background-color: white; position: fixed;  top: 5px; left: 5px; z-index:5">
		<p>Выберите персонажа:</p>
		<div class="form-check">
			<input  class="form-check-input" type="radio" name="personage_1" id="">
			<label class="form-check-label" for="flexRadioDefault1">
				personage_1
			</label>
		</div>
		<div class="form-check">
    		<input class="form-check-input" type="radio" name="personage_2" id="">
			<label class="form-check-label" for="flexRadioDefault2">
				personage_2
			</label>
		</div>	
</div>

В файл сервера server.js добавим код, для перезагрузки пользователей каждые 20 секунд, чтобы их персонажи не оставались после закрытия броузера.

///обновляем пользователей каждые 20сек в случае закрытия окна
setInterval(function(){users={};}, 20000);

Далее добавим логику выбора персонажа, в файл game_script.js.

///добавляем переменную для выбора типа персонажа
var pesonageType = null;

///функция загрузки json файла
fetch(gameUrl).then((response) => {
                 /*                   
                     код без изменений.....
                 
                 */
  
                 apply_code();
				 ///анимация костра
				 setInterval(function(){modules.fire_1.nextFrame();}, 350);
                
                 ///отображаем скрытое в html разметке окно после загрузки ресурсов
				 HM.state.chose_personage.htmlLink.style.display = "block";

                  maxTranslate = [-1000, -1000];
				
	  })
  .catch((err) => console.error(`Fetch problem: ${err.message}`));


///Добавляем тип персонажа в сообщение на сервер - pType
function createSocket(){			
///соединение с другими игроками
  if (socketjs.isSupported()) {
  // connect to the server
  var socket = socketjs.connect();
  
  ///первое сообщение   на сервер с координатами при загрузке 
  socket.send('newuser', {id: userId, point: modules.personage.point, 
                          frame_index: modules.personage.frame_index, 
                          //тип персонажа
                          pType: pesonageType});

         /* ....код без изменений... */


        /////////////////////////////////////////////////////////////////////////////////////////// 
        // корординаты игроков с сервера 
        /// socket.receive('coord', function(data) {
		///создаем новых игроков		
		 for(var key in data){		 
			 if(data[key].id != userId){
               ///добавляем тип персонажа pType
               
				addTileCommon(data[key].id, HM.$props().sprites[data[key].pType], 
                              data[key].point);                				 
			 }else{
                 //id юзера на сервере и клиенте отличается 
                 //поэтому, для избежания повторных перерисовок
                 //добавляем объект отдельно 
				 addTileCommon(personageId, HM.$props().sprites[data[key].pType],
                               data[key].point);
                 			 
			 }			 
		 }

    
  //отправляем координаты и текущий кадр на сервер каждые  100ms
  var interval = setInterval(function() {
                                                                                                                  ///добавляем сообщение игрокам
    socket.send('coord', {id: userId, point: modules.personage.point, 
	                       frame_index: modules.personage.frame_index, msg: 
						   modules.personage.message,
                          ///добавляем тип ресонажа
						   pType: pesonageType});
  }, 100);

      /* ....код без изменений... */
 }  

}



  ///функция создает и добавляет объект сцены, добавляет новое свойство с текстом сообщения
function addTileCommon(id, sprite, point){	
	//if(id == "msg"){return;}  удаляем лишний код т. к. спрайт сообщения создается 
    //в функции render
	var tile = new Tile(id, sprite, point);
	tile.message = false;	
	tiles_common.push(tile);

    ///добавляем прямую ссылку на анимацию костра
	if(id == "fire_1")modules.fire_1 = tile;
    return tile;	
}



 ///форма выбора типа персонажа
 StateMap.chose_personage = { 	
		container: "chose_personage",
		props: [["personage_1", "mousedown", "[name='personage_1']"], ["personage_2", "mousedown", "[name='personage_2']"],				 
		         ],
		methods: {
			personage_1: function(){
				this.parent.htmlLink.style.display = "none";
                ///создаем персонажа
				modules.personage =  addTileCommon(personageId, this.$props().sprites["personage_1"], [200, 200]);
				pesonageType = "personage_1";
              ///создаем сокет соединение после выбора персонажа
				createSocket();
            },
			personage_2: function(){
				this.parent.htmlLink.style.display = "none";
				modules.personage =  addTileCommon(personageId, this.$props().sprites["personage_2"], [200, 200]);
				pesonageType = "personage_2";
                createSocket();				
            }			
		}			
		
 }  




 ///также добавим проверку шрифта для предотвращения ошибок в сообщениях пользователей
 StateMap.user_message = { 
		container: "user_message",
		props: [["user_msg_btn", "mousedown", "[name='user_msg_btn']"], ["user_msg", "inputvalue", "[name='user_msg']"],				 
		         ],
		methods: {
			user_msg_btn: function(){
			    var text  = this.props("user_msg").getProp();

               //функция проверки шрифта
                var isKyr = function (str) {
                   return /[а-я]/i.test(str);
                 }
                if(isKyr(text)){alert("чат не поддерживает кирилицу"); return};

				////тестовое сообщение
                modules.personage.message = text;
            }
		}			
		
 } 
    

Чат с выбором персонажа готов, протестировать пример можно в папке games/glitch_forest_2 forever start server.js.

Ссылка на глитч демо: https://cloudy-axiomatic-marscapone.glitch.me/

Комментарии (3)


  1. Anton_Kazantsev
    17.01.2024 14:41

    Интересненько посмотреть)


  1. space2pacman
    17.01.2024 14:41

    Просто куски кода?


  1. johnfound
    17.01.2024 14:41

    Чат работает только на транслите, т.к. socket.js не поддерживает кирилицу.

    В смысле? Использует 7 битовый ASCII код, что ли? UTF-8 в принципе поддерживается всегда.