개발 - phaser3 강좌

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

개미v 2024. 10. 24. 21:03

게임에 여러명이 접속을 해서 플레이어 캐릭터가 이동하는 예제입니다.
실시간으로 웹소켓을 통해서 다른 플레이어와 이동 정보가 공유 됩니다.

 

클라이언트

Loading Scene에서 '시작하기'를 클릭하면 MyGame Scene으로 이동하고,
MyGame Scene에서는 웹소켓 연결을 생성하고 서버로부터 MOVE 메세지를 주고 받으면서 다른 플레이어 위치 정보를 주고 받습니다.
네트워크 트래픽을 줄이기 위해서 이동중일 때에만 위치 정보를 서버로 전송하고, 정지상태일 때에는 위치 정보를 서버로 전송하지 않습니다.

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

// 웹소켓 : 클라이언트 --> 서버
const C_JOIN = 1;
const C_EXIT = 2;
const C_MOVE = 3;

// 웹소켓 : 서버 --> 클라이언트
const S_JOIN = 1;
const S_EXIT = 2;
const S_MOVE = 3;

// 서버 URL
const SERVER_URL = 'http://localhost:8080/quiz';

// 애니메이션 프레임 10ms
const FRAME_RATE = 10;

// 이동속도
const SPEED = 16;

 

<!doctype html>
<html lang="ko">

<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">
        // Scene
        let loadingScene;
        let 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', function () {
                    // 페이드 아웃
                    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;

                // 웹소켓
                this.webSocket = null;

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

                // 정지 상태 타이머
                this.stopTimer = 0;

                // 서버로 이동 정보를 보낼지 여부
                this.isSendingMovement = false;
            }

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

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

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

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

                            // 각 플레이어를 players 객체에 저장
                            this.players[data.sessionId] = player;

                            // 본인 플레이어일 때 애니메이션 완료 이벤트 추가
                            if (data.sessionId == this.webSocket.sessionId) {
                                // 애니메이션이 정지 상태로 전환되었을 때의 처리
                                player.on('animationcomplete', function (animation, frame) {
                                    if (animation.key === 'turn') {
                                        // 정지 상태로 표시
                                        player.isMoving = false;
                                    }
                                }, this);
                            }
                        }
                    }
                    // 퇴장
                    else if (data.cmd === S_EXIT) {
                        let player = this.players[data.sessionId];

                        // 플레이어 삭제
                        if (player) {
                            // 화면에서 플레이어 삭제
                            player.destroy();

                            // players 객체에서 플레이어 삭제
                            delete this.players[data.sessionId];
                        }
                    }
                    // 이동
                    else if (data.cmd === S_MOVE) {
                        let player = this.players[data.sessionId];

                        // 본인 정보는 처리하지 않음
                        if (data.sessionId == this.webSocket.sessionId) {
                            return;
                        }

                        // 플레이어 이동
                        if (player) {
                            // 플레이어 애니메이션 처리
                            switch (data.direction) {
                                case 'left':
                                    player.anims.play('left', true);
                                    break;
                                case 'right':
                                    player.anims.play('right', true);
                                    break;
                                case 'up':
                                    player.anims.play('up', true);
                                    break;
                                case 'down':
                                    player.anims.play('down', true);
                                    break;
                                case 'turn':
                                default:
                                    player.anims.play('turn');
                                    break;
                            }

                            // Tween을 사용하여 부드럽게 이동
                            myGameScene.tweens.add({
                                targets: player,
                                x: data.x,
                                y: data.y,
                                duration: data.duration,
                                ease: 'Linear'
                            });
                        }
                    }
                }.bind(this);

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

                    // 서버에 사용자 입장 알림
                    let msgData = {
                        sessionId: this.webSocket.sessionId,
                        cmd: C_JOIN,
                        x: 0,
                        y: 0,
                        duration: 0,
                        direction: '',
                        timestamp: Date.now()
                    };
                    let jsonData = JSON.stringify(msgData);
                    this.webSocket.send(jsonData);
                }.bind(this);

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

                // 페이드 인
                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: FRAME_RATE,
                    repeat: -1
                });

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

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

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

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


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

            update(time, delta) {
                // 본인 플레이어
                let player = this.players[this.webSocket.sessionId];

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

                // 이동 속도
                let speed = SPEED;

                // 이동 좌표
                let targetX = player.x;
                let targetY = player.y;

                // 이동 방향
                let targetDirection = '';

                // 키 입력 확인
                if (this.cursors.left.isDown || this.cursors.right.isDown || this.cursors.up.isDown || this.cursors.down.isDown) {
                    // 이동 상태
                    player.isMoving = true;

                    // 서버로 이동 정보를 보냄
                    this.isSendingMovement = true;

                    // 이동 중이므로 정지 타이머 초기화
                    this.stopTimer = 0;
                } else {
                    // 정지 상태
                    player.isMoving = false;

                    // 이동하지 않으면 정지 타이머 증가
                    this.stopTimer += delta;

                    // 정지 상태가 200ms 이상인 경우
                    if (this.stopTimer > (FRAME_RATE * 20)) {
                        // 더 이상 서버로 이동 정보를 보내지 않음
                        this.isSendingMovement = false;
                    }
                }

                // 왼쪽 이동
                if (this.cursors.left.isDown) {
                    targetX = player.x - speed;
                    targetDirection = 'left';
                }
                // 오른쪽 이동
                else if (this.cursors.right.isDown) {
                    targetX = player.x + speed;
                    targetDirection = 'right';
                }
                // 위 이동
                else if (this.cursors.up.isDown) {
                    targetY = player.y - speed;
                    targetDirection = 'up';
                }
                // 아래 이동
                else if (this.cursors.down.isDown) {
                    targetY = player.y + speed;
                    targetDirection = 'down';
                }
                // 정지 상태
                else {
                    targetDirection = 'turn';
                }

                // 애니메이션 처리
                if(targetDirection != 'turn') {
                    player.anims.play(targetDirection, true);
                }
                else {
                    player.anims.play(targetDirection);
                }

                // tween을 사용해 이동
                this.tweens.add({
                    targets: player,
                    x: targetX,
                    y: targetY,
                    duration: FRAME_RATE * 10,
                    ease: 'Linear'
                });

                // 서버에 이동 정보 전송
                // 이동 중 또는 정지 후 200ms 이내에는 전송
                if (player.isMoving || this.isSendingMovement) {
                    let msgData = {
                        sessionId: this.webSocket.sessionId,
                        cmd: C_MOVE,
                        x: targetX,
                        y: targetY,
                        duration: FRAME_RATE * 10,
                        direction: targetDirection,
                        timestamp: Date.now()
                    };
                    let jsonData = JSON.stringify(msgData);
                    this.webSocket.send(jsonData);
                }
            }
        }

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

        let 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;
			case Code.C_MOVE:
				// 이동
				C_MOVE(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) {
		System.out.println("***** WebSocket Connection Closed *****");
		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, 0, "", System.currentTimeMillis());
					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, 0, "", System.currentTimeMillis());
				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 {
				// 본인한테는 본인정보 전송하지 않음
				if (session.getId().equals(sess.getId()))
					continue;

				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, 0, "", System.currentTimeMillis());
				String jsonStr = objectMapper.writeValueAsString(dtoChatServerMsg);
				if (session != null && session.isOpen()) {
					session.sendMessage(new TextMessage(jsonStr));
				}
			} catch (Exception ex) {
				// nothing todo
			}
		}
	}

	// 이동
	private void C_MOVE(WebSocketSession session, QuizMsgDto quizMsgDto) throws Exception {

		ObjectMapper objectMapper = new ObjectMapper();

		// 이동 좌표
		int targetX = quizMsgDto.getX();
		int targetY = quizMsgDto.getY();

		// 이동 시간
		int duration = quizMsgDto.getDuration();
		
		// 이동 방향
		String direction = quizMsgDto.getDirection();
		
		// 타임스탬프
		long timestamp = quizMsgDto.getTimestamp();

		// 플레이어 세션 관리
		// <세션ID, <x, y>>
		Map<String, Object> playerMap = new HashMap<String, Object>();
		playerMap.put("x", targetX);
		playerMap.put("y", targetY);
		sessionMap.put(session.getId(), playerMap);

		// 다른 플레이어에게 플레이어 이동 정보 전송
		for (WebSocketSession sess : sessionList) {
			try {
				// 본인한테는 본인정보 전송하지 않음
				if (session.getId().equals(sess.getId()))
					continue;

				QuizMsgDto dtoChatServerMsg = new QuizMsgDto(session.getId(), Code.S_MOVE, targetX, targetY, duration, direction, timestamp);
				String jsonStr = objectMapper.writeValueAsString(dtoChatServerMsg);
				if (sess != null && sess.isOpen()) {
					sess.sendMessage(new TextMessage(jsonStr));
				}
			} catch (Exception ex) {
				// nothing todo
			}
		}
	}
}

 

결과화면

다른 플레이어가 접속할 때마다 캐릭터가 추가로 생기고, 플레이어가 이동을 하면 다른 화면에서도 실시간으로 똑같이 움직입니다.