개발 - phaser3 강좌

phaser3 강좌(5) 온라인 멀티플레이

개미v 2024. 10. 15. 18:47

게임에 여러명이 접속을 하면 플레이어 캐릭터가 생성되는 예제입니다.
플레이어 세션관리는 서버쪽에서 하고, 클라이언트와 서버의 통신은 웹소켓으로 통신합니다.

 

클라이언트

Loading Scene에서 '시작하기'를 클릭하면 MyGame Scene으로 이동하고, 
MyGame Scene에서는 웹소켓 연결을 생성하고 서버로부터 JOIN이나 EXIT 같은 메세지를 주고 받으면서 다른 플레이어 정보를 주고 받습니다.

// CANVAS 크기
const WIDTH = 800;
const HEIGHT = 600;

// 채팅 : 클라이언트 --> 서버
const C_JOIN = 1;
const C_EXIT = 2;

// 채팅 : 서버 --> 클라이언트
const S_JOIN = 1;
const S_EXIT = 2;

const SERVER_URL = 'http://localhost:8080/quiz';
<!doctype html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <title>Making your first Phaser 3 Game</title>
    <!-- Phaser 3 라이브러리 불러오기 -->
    <script src="https://cdn.jsdelivr.net/npm/phaser@3.11.0/dist/phaser.js"></script>
    <!-- 웹소켓 라이브러리 불러오기 -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.4.0/sockjs.js"></script>

    <script src="js/define.js"></script>
</head>

<body>
    <script type="text/javascript">
        // 웹소켓
        var webSocket;

        // 접속한 모든 플레이어 저장
        var players = {};

        var loadingScene;
        var myGameScene;

        class Loading extends Phaser.Scene {
            constructor() {
                // 식별자
                super('loading');

                loadingScene = this;
            }

            preload() {
            }

            create() {
                // 클릭 메세지
                this.clickToStart = this.add.text(
                    config.width / 2
                    , config.height / 2
                    , '시작시 클릭'
                )
                    .setFont('고딕')
                    .setFontSize(50)
                    .setOrigin(0.5)

                // 클릭시 게임 화면으로 이동
                this.input.once('pointerdown', () => {
                    // 페이드 아웃
                    loadingScene.cameras.main.fadeOut(1000, 0, 0, 0)
                    // Scene 전환
                    loadingScene.scene.transition({ target: 'myGame', duration: 1000 });
                }, this);
            }

            update() {
            }
        }

        class MyGame extends Phaser.Scene {
            constructor() {
                // 식별자
                super('myGame');

                myGameScene = this;
            }

            preload() {
                this.load.image('sky', 'assets/sky.png');
                this.load.spritesheet('dude', 'assets/dude.png', { frameWidth: 32, frameHeight: 48 });
            }

            create() {
                // SockJS 연결 설정
                webSocket = new SockJS(SERVER_URL);

                // 웹소켓 메시지가 수신되었을 때
                webSocket.onmessage = function (event) {
                    // 서버에서 받은 캐릭터 생성 데이터 처리
                    var data = JSON.parse(event.data);

                    // 입장하면 캐릭터 생성
                    if (data.cmd === S_JOIN) {
                        if (!players[data.sessionId]) {
                            // 화면에 캐릭터 생성
                            let player = myGameScene.physics.add.sprite(data.x, data.y, 'dude');
                            player.setCollideWorldBounds(true);

                            // 각 플레이어를 players 객체에 저장
                            players[data.sessionId] = player;
                        }
                    }
                    // 퇴장하면 캐릭터 삭제
                    else if (data.cmd === S_EXIT) {
                        // 화면에서 캐릭터 삭제
                        let player = players[data.sessionId];
                        player.destroy();

                        // players 객체에서 플레이어 삭제
                        delete players[data.sessionId];
                    }
                };

                // 웹소켓 연결이 열렸을 때
                webSocket.onopen = function () {
                    // url 형식 --> ws://localhost/quiz/636/wt1naem2/websocket
                    webSocket.sessionId = webSocket._transport.url.split('/')[5];

                    // 서버에 사용자 입장 알림
                    var msgData = {
                        sessionId: webSocket.sessionId,
                        cmd: C_JOIN,
                        x: 0,
                        y: 0,
                        msg: ''
                    };
                    var jsonData = JSON.stringify(msgData);
                    webSocket.send(jsonData);
                };

                // 웹소켓 연결이 닫혔을 때
                webSocket.onclose = function () {
                };

                // 페이드 인
                this.cameras.main.fadeIn(1000, 0, 0, 0);

                // 배경
                this.add.image(config.width / 2, config.height / 2, 'sky');

                //  왼쪽
                this.anims.create({
                    key: 'left',
                    frames: this.anims.generateFrameNumbers('dude', { start: 0, end: 3 }),
                    frameRate: 10,
                    repeat: -1
                });

                // 오른쪽
                this.anims.create({
                    key: 'right',
                    frames: this.anims.generateFrameNumbers('dude', { start: 5, end: 8 }),
                    frameRate: 10,
                    repeat: -1
                });

                // 위
                this.anims.create({
                    key: 'up',
                    frames: [{ key: 'dude', frame: 4 }],
                    frameRate: 10,
                    repeat: -1
                });

                // 아래
                this.anims.create({
                    key: 'down',
                    frames: [{ key: 'dude', frame: 4 }],
                    frameRate: 10,
                    repeat: -1
                });

                // 정지상태
                this.anims.create({
                    key: 'turn',
                    frames: [{ key: 'dude', frame: 4 }],
                    frameRate: 120
                });

                //  Input Events
                this.cursors = this.input.keyboard.createCursorKeys();
            }

            update() {
                // 현재 플레이어 캐릭터
                let player = players[webSocket.sessionId];

                // JOIN 시간차 처리
                if (player === undefined) {
                    return;
                }

                // 왼쪽 이동
                if (this.cursors.left.isDown) {
                    player.setVelocityX(-160);

                    player.anims.play('left', true);
                }
                // 오른쪽 이동
                else if (this.cursors.right.isDown) {
                    player.setVelocityX(160);

                    player.anims.play('right', true);
                }
                // 위 이동
                else if (this.cursors.up.isDown) {
                    player.setVelocityY(-160);

                    player.anims.play('up', true);
                }
                // 아래 이동
                else if (this.cursors.down.isDown) {
                    player.setVelocityY(160);

                    player.anims.play('down', true);
                }
                // 정지 상태
                else {
                    player.setVelocityX(0);
                    player.setVelocityY(0);

                    player.anims.play('turn');
                }
            }
        }

        // 게임 설정 구성
        var config = {
            type: Phaser.AUTO,
            width: WIDTH,
            height: HEIGHT,
            physics: {
                default: 'arcade'
            },
            scene: [Loading, MyGame]
        };

        var game = new Phaser.Game(config);
    </script>

</body>

</html>

 

서버

서버는 자바 스프링프레임워크로 구성되어 있습니다.
웹소켓 Handler 부분만 올립니다.

@Component
public class WebSocketHandler extends TextWebSocketHandler {
	
	// <세션ID, <x, y>>
	public static Map<String, Map<String, Object>> sessionMap = new HashMap<String, Map<String, Object>>();
	
	// <세션-세션-세션-세션...>
	public static List<WebSocketSession> sessionList = new LinkedList<WebSocketSession>();
	
	// 클라이언트가 웹소켓서버로 메시지를 전송했을 때 실행되는 메소드
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) {
		try {
			super.handleTextMessage(session, message);

			// message JSON 파싱
			ObjectMapper objectMapper = new ObjectMapper();
			QuizMsgDto quizMsgDto = objectMapper.readValue(message.getPayload(), QuizMsgDto.class);
			
			switch (quizMsgDto.getCmd()) {
			case Code.C_JOIN:
				// 게임 입장
				C_JOIN(session, quizMsgDto);
				break;
			}
			
		} catch (Exception e) {
			System.out.println("***** handleTextMessage Exception *****");
			e.printStackTrace();
		}
	}

	// 클라이언트가 연결을 했을 때 실행되는 메소드
	@Override
	public void afterConnectionEstablished(WebSocketSession session) {
		System.out.println("***** WebSocket Connection Established *****");
		try {
			super.afterConnectionEstablished(session);
		} catch (Exception e) {
			System.out.println("***** afterConnectionEstablished Exception *****");
			e.printStackTrace();
		}
	}

	// 클라이언트가 연결을 끊었을 때 실행되는 메소드
	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
		try {
			super.afterConnectionClosed(session, status);
			
			ObjectMapper objectMapper = new ObjectMapper();
						
			// 세션 관리 삭제
			sessionList.remove(session);
			sessionMap.remove(session.getId());
			
			// 다른 플레이어에게 퇴장한 플레이어 정보 전송
			for (WebSocketSession sess : sessionList) {
				try {				
					QuizMsgDto dtoChatServerMsg = new QuizMsgDto(session.getId(), Code.S_EXIT, 0, 0, "퇴장");
					String jsonStr = objectMapper.writeValueAsString(dtoChatServerMsg);
					if(sess != null && sess.isOpen()) {
						sess.sendMessage(new TextMessage(jsonStr));
					}
				} catch(Exception ex) {
					// nothing todo
				}
			}
			
		} catch (Exception e) {
			System.out.println("***** afterConnectionClosed Exception *****");
			e.printStackTrace();
		}
	}

	@Override
	public void handleTransportError(WebSocketSession session, Throwable exception) {
		System.out.println("***** handleTransportError Exception *****");
	}
	
	// 게임 입장
	private void C_JOIN(WebSocketSession session, QuizMsgDto quizMsgDto) throws Exception {
		
		ObjectMapper objectMapper = new ObjectMapper();
		
		// 좌표 랜덤 생성
		int newPlayerX = Util.getRandomNumber(200, Define.WIDTH - 200);
		int newPlayerY = Util.getRandomNumber(150, Define.HEIGHT - 150);
		
		// 새로 입장한 플레이어 세션 관리에 추가
		// <세션-세션-세션-세션...>
		sessionList.add(session);
		
		// <세션ID, <x, y>>
		Map<String, Object> newPlayerMap = new HashMap<String, Object>();
		newPlayerMap.put("x", newPlayerX);
		newPlayerMap.put("y", newPlayerY);
		sessionMap.put(session.getId(), newPlayerMap);
		
		// 다른 플레이어에게 새로 입장한 플레이어 정보 전송
		for (WebSocketSession sess : sessionList) {
			try {				
				QuizMsgDto dtoChatServerMsg = new QuizMsgDto(session.getId(), Code.S_JOIN, newPlayerX, newPlayerY, "입장");
				String jsonStr = objectMapper.writeValueAsString(dtoChatServerMsg);
				if(sess != null && sess.isOpen()) {
					sess.sendMessage(new TextMessage(jsonStr));
				}
			} catch(Exception ex) {
				// nothing todo
			}
		}
		
		// 새로 입장한 플레이어에게 기존 다른 플레이어 정보 전송
		for (WebSocketSession sess : sessionList) {
			try {				
				Map<String, Object> otherPlayer = sessionMap.get(sess.getId());
				int otherPlayerX = (int) otherPlayer.get("x");
				int otherPlayerY = (int) otherPlayer.get("y");
				
				QuizMsgDto dtoChatServerMsg = new QuizMsgDto(sess.getId(), Code.S_JOIN, otherPlayerX, otherPlayerY, "입장");
				String jsonStr = objectMapper.writeValueAsString(dtoChatServerMsg);
				if(session != null && session.isOpen()) {
					session.sendMessage(new TextMessage(jsonStr));
				}
			} catch(Exception ex) {
				// nothing todo
			}
		}
	}
}

 

 

결과화면

다른 플레이어가 접속할 때마다 캐릭터가 추가로 생기고, 다른 플레이어가 창을 닫으면 캐릭터가 사라집니다.

 

 

 

다음 강좌는 온라인 상에서 각 플레이어 이동에 대해서 구현해보겠습니다.