이 튜토리얼에서는 아주 기본적인 실시간 멀티 플레이어 온라인 게임의 클라이언트와 서버를 프로그래밍하는 방법과 Socket.io를 사용하여 상호 작용하는 방법을 설명합니다. 이 작은 게임에서는 각 플레이어가지도를 클릭하여 캐릭터를 움직이게하고 다른 플레이어의 캐릭터는 화면에서 실시간으로 움직입니다. 이 기본 게임은별로 재미 있지는 않지만, 자신 만의 흥미 진진한 게임을 만들 수있는 기반이 될 것입니다.
대부분의 멀티 플레이어 온라인 게임 (특히 대규모 멀티 플레이어 게임)은 서버 - 클라이언트 아키텍처를 따릅니다. 각 플레이어는 클라이언트라는 소프트웨어를 실행하여 게임을 표시하고 플레이어의 입력을 처리하며, 각 클라이언트는 중앙의 신뢰할 수있는 서버와 데이터를 교환하여 플레이어의 동작을 확인하고 다른 클라이언트에 브로드 캐스트합니다.
우리의 경우 클라이언트는 Javascript로 Phaser로 작성되어 플레이어의 브라우저에서 실행됩니다. 이 튜토리얼은 Phaser (게임 상태 및 관련 함수, 입력 처리 ...)에 대한 기본 지식을 전제로합니다.
서버는 Javascript로 작성되었으며 Node.js와 Express 모듈을 사용합니다. 클라이언트와 서버는 Socket.io를 사용하여 통신합니다.
이 데모에 사용 된 자산은 GitHub 저장소에 있습니다. 지도는 Mozilla의 BrowserQuest지도의 작은 부분입니다. tileset과 플레이어 스프라이트는 BrowserQuest에서도 빌려 왔습니다.
클라이언트와 서버 설정하기
이 첫 번째 부분에서는 파일을 클라이언트에 제공하기 위해 기본 Node.js 서버를 프로그래밍합니다. 또한 기본 게임 파일을 설정합니다. Node.js / Express와 Phaser에 익숙하다면 이 부분을 건너 뛸 수 있습니다. 하지만 코드가 어떻게 구성되어 있는지 느껴지면 유용 할 것입니다. 이 튜토리얼의 초점이 게임 논리이므로 너무 많은 세부 사항을 입력하지 않고도 코드의이 부분이 무엇을하는지 간단히 설명 할 것입니다. 이것이 명확하지 않은 경우 관련 문서를 보거나 의견 섹션에서 설명을 요청하십시오. 명확하게 설명 드리겠습니다.
- 서버 Server.js -
먼저 필요한 모든 Node.js 모듈을 요구합니다. Node.js와 함께 사용하는 환경에 설치해야합니다. npm (예 :`npm install express`)을 사용하여 그렇게 할 수 있습니다. GitHub에서 코드를 실행하면`npm install`을 실행하여 package.json 파일을 읽고 필요한 모든 모듈을 설치합니다.
var express = require('express');
var app = express();
var server = require('http').Server(app);
var io = require('socket.io').listen(server);
Express는 클라이언트에게 파일을 제공하는 데 사용할 모듈입니다. app이라는 새 인스턴스를 만들고이를 http 모듈과 결합하여 Express 응용 프로그램이 http 서버로 작동하도록합니다. 마지막으로 socket.io 모듈이 필요하며 해당 서버에 대한 연결을 수신 대기하게 만듭니다. 이것은 단순한 앱을 시작하기위한 보편적 인 코드입니다.
다음 단계는 요청 된 경로에 따라 파일을 전달하는 것입니다.
app.use('/css',express.static(__dirname + '/css'));
app.use('/js',express.static(__dirname + '/js'));
app.use('/assets',express.static(__dirname + '/assets'));
app.get('/',function(req,res){
res.sendFile(__dirname+'/index.html');
});
이러한 라인은 직접 액세스 할 수 없지만 게임에서 액세스해야하는 CSS 스타일 시트 또는 게임 애셋과 같은 정적 파일을 제공 할 수 있어야합니다. 편의상 app.use ()의 두 번째 인수는 리소스에 대한 실제 경로가 아닌 가상 경로를 지정할 수있게하지만 스크립트에 액세스하는 데 사용되는 경로가됩니다.
서버가 통신해야하는 포트를 지정하고 루트 페이지로 사용할 파일을 지정하여 서버 설정을 완료합니다.
app.get('/',function(req,res){
res.sendFile(__dirname+'/index.html');
});
server.listen(8081,function(){ // Listens to port 8081
console.log('Listening on '+server.address().port);
});
- 클라이언트 index.html -
Index.html은 서버에 연결할 때 표시되는 게이트웨이 페이지 이며 이 경우 게임이 표시 될 페이지입니다. 그러나 당신이 원하는대로 구조화하는 것은 당신에게 달려 있지만 적어도 id라는 게임 요소를 포함해야합니다. 또한 socket.io를 포함하여 게임에 필요한 Javascript 파일을 포함해야합니다. 포함시키는 방법은 다음과 같습니다.
<script src="/socket.io/socket.io.js"></script>
이는 socket.io를 설치할 때 가상 경로 /socket.io/socket.io.js가 자동으로 만들어지기 때문에 작동합니다.
이제 터미널에서 `node server.js`를 입력하여 서버를 실행하고 (예 : http://localhost:8081/ 에서 로컬로 실행하고 기본 포트를 유지하는 경우) 응용 프로그램을 탐색하면 index.html이 제공되는 내용 (여기서는 "Hello World"일 수도 있음)과 함께 이 위치에 표시된 내용을 볼 수 있습니다. 이제 game.js를 포함시켜 게임을 설정해 보겠습니다. 앞으로 이 프로세스를 "게임 실행"이라고합니다.
- js/game.js -
이제 우리는 게임 캔버스를 설정하고 (게임 ID가 'div'인 div 블록이 있다고 가정) 동일한 이름의 Javascript 객체에 해당하는 'Game'이라는 단일 게임 상태를 선언 할 수 있습니다.
var game = new Phaser.Game(16*32, 600, Phaser.AUTO, document.getElementById('game'));
game.state.add('Game',Game);
game.state.start('Game');
var Game = {};
Game.init()에는 설정할 매개 변수가 하나뿐입니다.
Game.init = function(){
game.stage.disableVisibilityChange = true;
};
이것은 필수는 아니지만 유용합니다. 게임 창에 포커스가 없는 경우 (대부분의 게임에서 원하는 동작) 서버가 보낸 메시지에 게임이 계속 반응하게 만듭니다.
Game.preload()에서 타일 맵을 포함한 JSON 형식의 타일을 포함하여 필요한 에셋을 로드합니다. Tile tilemaps를 만들고 다루는 것에 관해서는 여기서는 자세히 다루지 않겠지만, 이 부분을 다루기 위해 튜토리얼을 만들고 싶다면 주저하지 말고 알려주세요.
Game.preload = function() {
game.load.tilemap('map', 'assets/map/example_map.json', null, Phaser.Tilemap.TILED_JSON);
game.load.spritesheet('tileset', 'assets/map/tilesheet.png',32,32);
game.load.image('sprite','assets/sprites/sprite.png'); // this will be the sprite of the players
};
Game.create()에서 지도를 만들고 표시하는 것으로 시작합니다.
Game.create = function(){
var map = game.add.tilemap('map');
map.addTilesetImage('tilesheet', 'tileset'); // tilesheet is the key of the tileset in map's JSON file
var layer;
for(var i = 0; i < map.layers.length; i++) {
layer = map.createLayer(i);
}
layer.inputEnabled = true; // Allows clicking on the map
};
클릭이 지도에서 사용할수 있도록 설정되었지만, 현재 처리중인 코드가 없습니다. 이것은 서버가 가동되어 클라이언트와 서버 간의 통신이 작동하면 시작됩니다.
이 시점에서 게임을 실행할 때, 아무 일도 일어나지 않고 지도가 표시되어야합니다.
- js/client.js -
index.html에는 서버와 게임 자체 사이의 인터페이스 역할을하는 Client 객체를 포함하는 새로운 Javascript 파일인 client.js가 포함됩니다.
var Client = {};
Client.socket = io.connect();
여기에서 중요한 것은 두 번째 줄입니다. 여기서는 서버 (괄호 사이에 달리 지정하지 않으면 localhost)에 대한 연결을 시작합니다. 플레이어가 앱을 탐색 할 때마다 서버와의 연결이 설정됩니다. 이렇게하면 소켓이 생성됩니다. 소켓은 서버와 클라이언트 간의 통신 흐름에서 끝점입니다. socket.io를 사용하면 클라이언트와 서버가 상호 작용할 수있는 기본적인 방법을 구성하는 소켓을 통해 메시지를 보내고받을 수 있습니다. 여기서 클라이언트의 소켓은 나중에 사용하기 위해 Client.socket에 저장됩니다.
실시간 상호 작용
이제 재미있는 부분이 시작됩니다. 우리는 서버가 플레이어의 행동을 인식 할뿐만 아니라 클라이언트가 서버에서 오는 메시지에 반응하도록 해야합니다. 플레이어가 액션 (연결, 연결 해제 또는 이동)을 수행하면 Socket.io API를 사용하여 서버에 메시지를 보내서 이 작업을 알립니다. 그 대가로 서버는 다른 플레이어의 동작을 알릴 필요가 있을 때 동일한 API를 사용하여 클라이언트에게 메시지를 보냅니다. 이 튜토리얼의 나머지 부분에서는 이러한 메시지를 보내고 받는 방법과 Phaser 게임과 통합하는 방법을 설명합니다.
- 연결된 플레이어 표시 -
새 플레이어가 연결되면 새로 연결된 스프라이트를 포함하여 연결된 모든 플레이어에 대해 새로운 스프라이트가 게임에 표시됩니다. 스프라이트의 좌표는 서버에 의해 무작위로 결정됩니다.
- js/game.js -
먼저 game.js에서 Game.create()를 수정하여 클라이언트가 새 플레이어를 만들어야한다는 사실을 서버에 알리도록합시다. 이를 위해 Client.askNewPlayer()를 추가합니다. Game.create()의 처음에 Game.playerMap = {} 을 추가합니다. 이 빈 객체는 나중에 플레이어를 추적하는 데 유용합니다.
Game.create = function(){
Game.playerMap = {};
var map = game.add.tilemap('map');
map.addTilesetImage('tilesheet', 'tileset'); // tilesheet is the key of the tileset in map's JSON file
var layer;
for(var i = 0; i < map.layers.length; i++) {
layer = map.createLayer(i);
}
layer.inputEnabled = true; // Allows clicking on the map
Client.askNewPlayer();
};
- js/client.js -
이제 client.js에서 Client.askNewPlayer() 메서드를 정의해야합니다.Client.askNewPlayer = function(){
Client.socket.emit('newplayer');
};
이 메서드는 소켓 객체를 사용하여 서버에 메시지를 보냅니다. 이 메시지는 'newplayer'라는 레이블을 가지며 이는 자명합니다. 두 번째 인수는 추가 데이터를 전달하기 위해 추가 될 수 있지만이 경우에는 필요하지 않습니다.
- server.js -
server.js에서 클라이언트의 메시지에 반응해야합니다. 다음 코드를 추가하십시오.
server.lastPlayderID = 0; // 새 플레이어에게 할당 된 마지막 ID를 추적합니다.
io.on('connection',function(socket){
socket.on('newplayer',function(){
socket.player = {
id: server.lastPlayderID++,
x: randomInt(100,400),
y: randomInt(100,400)
};
socket.emit('allplayers',getAllPlayers());
socket.broadcast.emit('newplayer',socket.player);
});
});
function getAllPlayers(){
var players = [];
Object.keys(io.sockets.connected).forEach(function(socketID){
var player = io.sockets.connected[socketID].player;
if(player) players.push(player);
});
return players;
}
function randomInt (low, high) {
return Math.floor(Math.random() * (high - low) + low);
}
우리는 Socket.io에게 클라이언트가 (io.connect()를 사용하여) 서버에 연결할 때마다 발생하는 'connection'이벤트를 듣도록 지시합니다. 이 경우 두 번째 인수로 지정된 콜백을 호출해야합니다. 이 콜백은 연결을 설정하는 데 사용 된 소켓을 첫 번째 인수로받습니다.이 소켓은 클라이언트 소켓과 마찬가지로 메시지를 전달하는 데 사용될 수 있습니다.
소켓 객체에서 socket.on() 메소드를 사용하면 콜백을 지정하여 다른 메시지를 처리 할 수 있습니다. 따라서 특정 클라이언트가 자신의 소켓을 통해 특정 메시지를 보낼 때마다 반응에서 특정 콜백이 호출됩니다. 이 경우 'newplayer'메시지에 반응 할 콜백을 정의합니다. 여기서 수행되는 작업을 분해합시다.
socket.on('newplayer',function(){
socket.player = {
id: server.lastPlayderID++,
x: randomInt(100,400),
y: randomInt(100,400)
};
socket.emit('allplayers',getAllPlayers());
socket.broadcast.emit('newplayer',socket.player);
});
먼저 플레이어를 나타내는 데 사용되는 새 사용자 지정 개체를 만들어 소켓 개체에 저장합니다. 보시다시피 소켓 객체에 임의의 클라이언트 특정 속성을 추가하여 액세스하기 쉽게 만들 수 있습니다. 이 객체에서 플레이어에게 고유 한 ID (클라이언트 측에서 사용됨)를 지정하고 임의로 스프라이트의 위치를 결정합니다. 그런 다음 이미 연결된 플레이어 목록을 새 플레이어에게 보내려고합니다.
socket.emit('allplayers',getAllPlayers());
Socket.emit()은 특정 소켓 하나에 메시지를 보낸다. 여기서는 새로 연결된 클라이언트에게 'allplayers'라는 메시지를 보내고 두 번째 인수로 현재 연결된 플레이어의 배열이 될 Client.getAllPlayers()의 리턴값을 보냅니다. 이렇게 하면 새로 연결된 플레이어가 이미 연결된 유저수와 위치를 알 수 있습니다. Client.getAllPlayers()를 간단히 살펴 보겠습니다.
function getAllPlayers(){
var players = [];
Object.keys(io.sockets.connected).forEach(function(socketID){
var player = io.sockets.connected[socketID].player;
if(player) players.push(player);
});
return players;
}
io.sockets.connected는 현재 서버에 연결된 소켓의 Socket.io 내부 배열입니다. 모든 소켓을 반복 처리하고, 추가 한 플레이어 속성 (있는 경우)을 가져 와서 목록에 추가하여 연결된 플레이어를 효과적으로 나열 할 수 있습니다. 그리고 마지막으로:
socket.broadcast.emit('newplayer',socket.player);
socket.emit.broadcast()는 콜백을 트리거 한 소켓을 제외한 모든 연결된 소켓에 메시지를 보냅니다. 클라이언트에서 시작 클라이언트로 이벤트를 반향하지 않고 다른 모든 클라이언트로 이벤트를 브로드 캐스트 할 수 있습니다. 여기서 우리는 'newplayer'메시지를 방송하고 새로운 플레이어 객체를 데이터로 보냅니다.
마지막 몇 단계에서 수행 한 작업을 요약하면 다음과 같습니다.
- 클라이언트로부터의 연결신호를 받고 소켓을 통해 전송 된 메시지를 처리하기위한 콜백을 정의합니다.
- 클라이언트로부터 'newplayer'메시지를 받으면 우리는 클라이언트의 소켓에 저장 한 작은 플레이어 객체를 생성합니다
- 새 클라이언트에게 우리는 모든 다른 플레이어의 목록을 보내서 표시 할 수 있습니다.
- 다른 유저에게 우리는 새로운 유저에 대한 정보를 보냅니다.
지금까지 우리 서버는 클라이언트의 메시지 하나에 반응합니다. 우리는 이제 클라이언트가 서버에서 'allplayers' 및 'newplayer' 메시지를 처리 할 수 있도록 조정해야 하므로 '연결 - 여기에있는 서버에 알림 - 돌아 가기 정보 표시 - 표시' 루프를 완료해야합니다. 클라이언트가 보낸 'newplayer'메시지와 서버가 보낸 메시지는 같지 않습니다. 같은 종류의 정보를 전달하기 때문에 동일한 레이블을 제공하기로했지만, 다른 끝점 (전자는 서버, 후자는 클라이언트)이 있으므로 별도로 처리됩니다.
- js/client.js -
client.js에 다음 코드를 추가하십시오.
Client.socket.on('newplayer',function(data){
Game.addNewPlayer(data.id,data.x,data.y);
});
Client.socket.on('allplayers',function(data){
console.log(data);
for(var i = 0; i < data.length; i++){
Game.addNewPlayer(data[i].id,data[i].x,data[i].y);
}
});
보시다시피 메시지를 처리하는 동일한 구문을 클라이언트 측에서 사용할 수 있습니다. 데이터가 메시지를 따라 전송되면 수신 측에서 콜백의 첫 번째 인수로 검색 할 수 있습니다. 따라서 'newplayer'콜백에 제공되는 'data'객체는 서버가 보낸 socket.player 데이터에 해당합니다. 'allplayers'메시지의 경우 socket.player 객체의 목록입니다. 두 경우 모두이 데이터는 Game.addNewPlayer()를 호출하여 처리됩니다. 이제 game.js에 정의 할 수 있습니다.
- js/game.js -
Game.addNewPlayer = function(id,x,y){
Game.playerMap[id] = game.add.sprite(x,y,'sprite');
};
이 메서드는 지정된 좌표에 새 스프라이트를 만들고 제공된 ID를 키로 사용하여 Game.create()에 선언 된 연관 배열에 해당 Sprite 객체를 저장합니다. 이렇게 하면 특정 플레이어에 해당하는 스프라이트에 쉽게 액세스 할 수 있습니다 (예 : 이동하거나 제거해야하는 경우) (아래 참조).
이 시점에서 서버를 다시 시작하여 마지막 수정 사항을 고려하면 게임으로 이동하면 스프라이트에 해당하는 작은 문자가 표시됩니다. 다른 브라우저와 연결하면 추가 문자가 화면에 나타납니다.
연결 해제 처리
플레이어가 연결을 끊으면 스프라이트가 다른 플레이어의 화면에 남아 있기 때문에 바람직하지 않습니다. 이는 클라이언트가 능동적으로 연결을 끊거나 시간 초과 될 때 서버가 자동으로 수신하는 'disconnet' 메시지를 처리하여 해결할 수 있습니다. 이 메시지는 'newplayer' 처럼 'io.on()'메서드 내에서 콜백을 바인딩하여 다음과 같이 처리 할 수 있습니다.
io.on('connection',function(socket){
socket.on('newplayer',function(){
socket.player = {
id: server.lastPlayderID++,
x: randomInt(100,400),
y: randomInt(100,400)
};
socket.emit('allplayers',getAllPlayers());
socket.broadcast.emit('newplayer',socket.player);
socket.on('disconnect',function(){
io.emit('remove',socket.player.id);
});
});
});
'disconnect' 메시지에 대한 응답으로 io.emit()을 사용하여 연결된 모든 클라이언트에게 메시지를 보냅니다. 'remove'라는 메시지를 보내고 분리 된 플레이어의 ID를 보내 제거합니다.
참고 : 'disconnect' 콜백은 'newplayer' 콜백 내에 등록되어야한다는 Kaundur의 지적에 감사드립니다. 그렇지 않은 경우 'disconnect'가 'newplayer'전에 호출되는 경우 서버가 중단됩니다.
- js/client.js -
client.js에서는 다음과 같이 처리됩니다.
Client.socket.on('remove',function(id){
Game.removePlayer(id);
});
그리고 game.js에서 :
- js/game.js -
Game.removePlayer = function(id){
Game.playerMap[id].destroy();
delete Game.playerMap[id];
};
이것은 Game.playerMap 데이터 구조의 사용법을 보여줍니다. 스프라이트를 반복 할 필요가 없습니다. id를 사용하면 즉시 가져올 수 있습니다. 이제는 플레이어의 움직임을 처리하고 Broadcast 하는 것만 남았습니다.
플레이어의 움직임 처리하기
- js/game.js -
이제 Game.create()를 완료 할 차례입니다. 기본적으로 지도를 클릭하면 좌표가 서버로 전송되므로 클릭 한 플레이어의 위치를 모든 사용자가 업데이트 할 수 있어야 합니다. Game.create()에 다음 행을 추가합니다.
layer.events.onInputUp.add(Game.getCoordinates, this);
이제 지도는 Game.getCoordinates() 메소드를 호출하여 클릭에 반응합니다.이 메소드는 다음과 같이 정의 할 수 있습니다.
Game.getCoordinates = function(layer,pointer){
Client.sendClick(pointer.worldX,pointer.worldY);
};
Phaser의 onInputUp 이벤트 콜백은 해당 포인터 객체를 두 번째 인수로받습니다.이 포인터 객체에는 worldX와 worldY 속성이 포함되어 있으며 게임지도에서 클릭이 발생하는 위치를 파악하는 데 사용할 수 있습니다. 다음 좌표를 client.js의 Client.sendClick()에 전달할 수 있습니다.
- js/client.js -
Client.sendClick = function(x,y){
Client.socket.emit('click',{x:x,y:y});
};
단순히 좌표를 서버에 보내고 레이블은 'click'입니다. 소켓은 클라이언트마다 다르기 때문에 플레이어 ID를 보낼 필요가 없습니다. 단 하나의 플레이어 만 연결할 수 있습니다.
- server.js -
server.js에서 메시지 콜백의 최종 목록은 다음과 같습니다.
io.on('connection',function(socket){
socket.on('newplayer',function(){
socket.player = {
id: server.lastPlayderID++,
x: randomInt(100,400),
y: randomInt(100,400)
};
socket.emit('allplayers',getAllPlayers());
socket.broadcast.emit('newplayer',socket.player);
socket.on('click',function(data){
console.log('click to '+data.x+', '+data.y);
socket.player.x = data.x;
socket.player.y = data.y;
io.emit('move',socket.player);
});
socket.on('disconnect',function(){
io.emit('remove',socket.player.id);
});
});
});
소켓의 플레이어 속성의 x 및 y 필드는 새 좌표로 업데이트 된 다음 변경 사항을 볼 수 있도록 모든 사용자에게 즉시 브로드 캐스트됩니다. 이제는 다른 플레이어가 이동중인 플레이어의 ID를 알아야하기 때문에 완전한 socket.player 객체가 전송됩니다. 화면에 적절한 스프라이트를 이동하려면 (이 미니멀리스트 게임에서는 실제 방법이 없습니다 선수를 구별하기 위해).
- js/client.js -
client.js로 돌아가서, 클라이언트가 움직이는 다른 플레이어에게 반응 할 수 있도록 서버에서 'move'메시지를 처리해야합니다.
Client.socket.on('move',function(data){
Game.movePlayer(data.id,data.x,data.y);
});
이제 프로세스가 익숙해지기 시작합니다. game.js에서 :
- js/game.js -
Game.movePlayer = function(id,x,y){
var player = Game.playerMap[id];
var distance = Phaser.Math.distance(player.x,player.y,x,y);
var duration = distance*10;
var tween = game.add.tween(player);
tween.to({x:x,y:y}, duration);
tween.start();
};
우리는 다시 Game.playerMap 구조를 사용하여 적절한 스프라이트를 검색 한 다음 트위닝하여 점진적으로 움직임을 만듭니다.
결론
이제 게임을 실행하면 다른 클라이언트의 동작에 따라 실시간으로 스프라이트가 이동하고 표시되는지 확인할 수 있습니다. 그리고 많은 개선이 가능합니다. 나는 당신에게 연습 문제로 남겨 둡니다. 몇 가지 예를 들면 다음과 같습니다.
- 스프라이트 시트를 사용하여 스프라이트의 움직임을 애니메이션하십시오.
- "충돌", 즉 플레이어가 이동할 수없는 영역 지정
- 자신 만의 더 큰지도를 사용하고 카메라가 플레이어를 따라 가게하십시오.
- 플레이어가 캐릭터의 이름을 지정하거나 스프라이트를 변경하도록 허용
그것에 대한 수요가 있다면, 나는 이러한 가능한 향상을 위해 튜토리얼을 만드는 경향이있을 것이다. 관심이 있다면 저에게 연락 주시기 바랍니다.
내가했던 것처럼 Heroku에서이 예제 프로젝트를 배포하는 방법을 배우고 싶다면이 튜토리얼을 확인해 보시면 됩니다.
Phaser로 만든 멀티 플레이어 온라인 게임의보다 복잡한 예를 보려면 Phaser Quest를 살펴보십시오!
댓글 없음:
댓글 쓰기