스프링부트 구현

2D 캐릭터 및 채팅 상호작용 공간을 구현해보자 [웹소켓/Phaser.js]

rexondex 2024. 10. 6. 00:00

 

  • 프로젝트 목표
    • 실시간 웹 기반 상호작용 공간 구현
    • 2D 환경에서 고유 캐릭터로 참여
    • 실시간 채팅을 통한 사용자 간 커뮤니케이션
  • 기술 스택
    • Phaser.js: 게임 로직과 캐릭터 움직임, 물리 엔진
    • WebSocket: 실시간 채팅 및 캐릭터 위치 동기화
    • Spring Boot: 서버 측 WebSocket 관리
    • HTML/CSS/JavaScript: UI 및 게임 인터페이스
  • 기능 소개
    • 사용자 ID 부여: 각 사용자는 고유 6자리 ID를 통해 식별
    • 캐릭터 움직임: 화살표 키를 사용해 캐릭터 이동
    • 실시간 동기화: WebSocket을 통한 여러 브라우저 간 캐릭터 동기화
    • 채팅 기능: 사용자 간 실시간 텍스트 기반 채팅 가능

r3uox1의 채팅
9puxyc의 채팅

 

 

긴 세션ID를 6자리로 줄인 문자열을 ID 및 캐릭터로 사용했습니다.

검은 화면에 떠있는 6자리 코드는 방향키로 움직일 수 있는 캐릭터입니다.

 


 

프로젝트 구조

 

[ 목차 ]

  1. 5개의 클래스를 작성합니다.
  2. game.html을 작성합니다.
  3. 실행 여부 확인

 


 

[ GameController ]

package com.example.demo.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Slf4j
@Controller
public class GameController {

    @GetMapping("/game")
    public String game() {
        return "game";
    }
}

 


 

[ GameWebSocketHandler ]

package com.example.demo.websocket;

import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.CloseStatus;

import java.util.ArrayList;
import java.util.List;

@RestController
public class GameWebSocketHandler extends TextWebSocketHandler {

    private List<WebSocketSession> sessions = new ArrayList<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        sessions.add(session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        for (WebSocketSession webSocketSession : sessions) {
            webSocketSession.sendMessage(new TextMessage(message.getPayload()));
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        sessions.remove(session);
    }
}

 


 

[ MyWebSocketHandler ]

package com.example.demo.websocket;

import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

public class MyWebSocketHandler extends TextWebSocketHandler {

    private static final Set<WebSocketSession> sessions = Collections.synchronizedSet(new HashSet<>());

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        sessions.add(session);
        session.sendMessage(new TextMessage("Welcome! You are connected with session ID: " + session.getId()));
    }

    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 사용자의 메시지와 세션 ID를 함께 전송
        String msgPayload = session.getId() + ": " + message.getPayload();

        // 모든 클라이언트에게 메시지를 전송
        for (WebSocketSession s : sessions) {
            if (s.isOpen()) {
                s.sendMessage(new TextMessage(msgPayload));
            }
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        sessions.remove(session);
    }
}

 


 

[ WebSocketConfig ]

package com.example.demo.websocket;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new MyWebSocketHandler(), "/websocket")
                .setAllowedOrigins("https://localhost:8443", "http://localhost:8443"); // CORS 설정
    }
}

 


 

[ WebSocketController ]

package com.example.demo.websocket;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;

@Controller
public class WebSocketController {

    @MessageMapping("/sendMessage") // 클라이언트에서 보낸 메시지 처리
    @SendTo("/topic/messages") // 모든 구독자에게 메시지 전송
    public String sendMessage(String message) {
        return message; // 클라이언트에게 메시지 전송
    }
}

 


 

[ game.html ] 채팅 및 캐릭터 공간을 구현할 핵심 html입니다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Phaser Key Input Example</title>
    <script src="https://cdn.jsdelivr.net/npm/phaser@3/dist/phaser.js"></script>
    <style>
        body {
            margin: 0;
            overflow: hidden; /* 스크롤 방지 */
        }
        #chatBox {
            position: absolute;
            bottom: 10px;
            left: 10px;
            width: 300px;
            height: 200px;
            background: rgba(0, 0, 0, 0.7);
            color: white;
            overflow-y: scroll; /* 스크롤 가능 */
            padding: 10px;
            border-radius: 5px;
        }
        #chatInput {
            position: absolute;
            bottom: 220px;
            left: 10px;
            width: 300px;
        }
    </style>
</head>
<body>
<div id="chatBox"></div>
<input type="text" id="chatInput" placeholder="채팅 입력..." />

<script>
    // 각 사용자를 위한 고유 6자리 ID 생성
    function generateId() {
        return Math.random().toString(36).substring(2, 8);
    }

    const sessionId = generateId(); // 고유 세션 ID 생성
    console.log("사용자 ID:", sessionId);

    // 웹소켓 연결
    const socket = new WebSocket("ws://localhost:8080/websocket");

    socket.onopen = function() {
        console.log("웹소켓 연결이 설정되었습니다.");
        // 사용자 ID를 서버에 전송
        socket.send(JSON.stringify({ id: sessionId }));
        addChatMessage(`Welcome! You are connected with session ID: ${sessionId}`); // 환영 메시지 추가
    };

    socket.onmessage = function(event) {
        console.log(event.data);  // 받은 메시지를 로그에 출력

        // 수신한 데이터에서 JSON 부분만 추출
        const jsonStartIndex = event.data.indexOf(':') + 1; // ':' 이후의 인덱스부터 시작
        const jsonString = event.data.substring(jsonStartIndex).trim(); // JSON 문자열 추출 및 공백 제거

        // JSON 데이터인지 확인하기 위한 조건문
        if (jsonString.startsWith('{') && jsonString.endsWith('}')) {
            try {
                const data = JSON.parse(jsonString); // JSON 파싱

                if (data.message) {
                    // 채팅 메시지 처리
                    addChatMessage(`${data.id}: ${data.message}`); // 채팅 박스에 추가
                } else {
                    updateOtherPlayers(data); // 다른 플레이어 업데이트
                }
            } catch (error) {
                console.error("JSON 파싱 오류:", error);
                console.log("받은 데이터:", event.data);
            }
        } else {
            console.log("JSON이 아닌 데이터 수신:", event.data);
        }
    };

    socket.onerror = function(error) {
        console.error("웹소켓 오류:", error);
    };

    function sendPosition(x, y) {
        if (socket.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify({ id: sessionId, x: x, y: y }));
        }
    }

    function sendChatMessage(message) {
        if (socket.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify({ id: sessionId, message: message }));
        }
    }

    const config = {
        type: Phaser.AUTO,
        width: 800,
        height: 600,
        physics: {
            default: 'arcade',
            arcade: {
                gravity: { y: 0 },
                debug: false
            }
        },
        scene: {
            preload: preload,
            create: create,
            update: update
        }
    };

    const game = new Phaser.Game(config);

    let player; // 플레이어 변수
    let cursors; // 방향키 변수
    let otherPlayers = {}; // 다른 플레이어들 정보 저장 객체

    let lastSentPosition = { x: null, y: null };  // 마지막으로 보낸 위치 저장

    function preload() {
        // 로드할 이미지가 없으므로 비워둡니다.
    }

    function create() {
        // 플레이어 텍스트 생성
        player = this.add.text(400, 300, sessionId, { fontSize: '32px', fill: '#ffffff' });
        player.setOrigin(0.5, 0.5); // 텍스트 중앙 정렬
        this.physics.add.existing(player); // 텍스트 객체에 물리 추가
        player.body.setCollideWorldBounds(true); // 월드 경계에서 벗어나는 것을 방지

        // 방향키 설정
        cursors = this.input.keyboard.createCursorKeys();

        // 채팅 입력 이벤트
        document.getElementById("chatInput").addEventListener("keypress", function(event) {
            if (event.key === "Enter") {
                const message = this.value.trim();
                if (message) {
                    sendChatMessage(message); // 채팅 메시지 전송
                    this.value = ''; // 입력창 비우기
                }
            }
        });
    }

    function update() {
        player.body.setVelocity(0);

        let moved = false;

        if (cursors.left.isDown) {
            player.body.setVelocityX(-160);
            moved = true;
        } else if (cursors.right.isDown) {
            player.body.setVelocityX(160);
            moved = true;
        }

        if (cursors.up.isDown) {
            player.body.setVelocityY(-160);
            moved = true;
        } else if (cursors.down.isDown) {
            player.body.setVelocityY(160);
            moved = true;
        }

        if (moved && (player.x !== lastSentPosition.x || player.y !== lastSentPosition.y)) {
            sendPosition(player.x, player.y);
            lastSentPosition = { x: player.x, y: player.y };  // 마지막 전송된 위치 업데이트
        }
    }

    function updateOtherPlayers(data) {
        if (data.id !== sessionId) { // 자신이 보낸 메시지는 무시
            if (!otherPlayers[data.id]) {
                // 다른 플레이어가 없으면 새로 생성
                otherPlayers[data.id] = createPlayer(data.x, data.y, data.id);
            } else {
                // 기존 플레이어가 있으면 위치 업데이트
                otherPlayers[data.id].setPosition(data.x, data.y);
            }
        }
    }

    function createPlayer(x, y, id) {
        let newPlayerText = game.scene.scenes[0].add.text(x, y, id, { fontSize: '32px', fill: '#ffffff' });
        newPlayerText.setOrigin(0.5, 0.5); // 텍스트 중앙 정렬
        return newPlayerText;
    }

    function addChatMessage(message) {
        const chatBox = document.getElementById("chatBox");
        const messageElement = document.createElement("div");
        messageElement.textContent = message;
        chatBox.appendChild(messageElement);
        chatBox.scrollTop = chatBox.scrollHeight; // 스크롤을 아래로
    }

</script>
</body>
</html>

 

여기서 HTML5 게임 개발을 위해 Phaser.js 오픈 소스 프레임워크를 사용했습니다.

 

Phaser.js는 HTML5 게임 개발을 위한 오픈 소스 프레임워크로, 2D 게임을 빠르게 개발할 수 있게 도와줍니다. Phaser는 물리 엔진, 애니메이션, 상호작용 처리 기능을 제공하며, 게임 로직을 구축하는 데 필요한 다양한 도구들을 제공합니다.

 


 

[ 실행 결과 ]

방향키로 조작하는 고유ID모양 캐릭터와 채팅박스

 

이 웹소켓 영역에 접속한 사용자들은 각자 6자리의 고유ID를 가지게 되며, 이 ID를 캐릭터로 사용합니다.

방향키를 사용하여 캐릭터를 움직일 수 있고, 다른 접속자가 움직이면 다른 접속자가 있음을 화면 캐릭터로 알 수 있습니다.

그리고 현재 접속중인 유저끼리 채팅이 가능한 박스도 마련해 두었습니다.

브라우저 콘솔에 찍힌 위치 및 채팅 로그

 

다른 사용자가 움직이면 고유 ID와 함께 좌표 및 채팅 로그가 남습니다.

이 정보를 바탕으로 다른 사용자의 ID모양 캐릭터를 만들고, 좌표에 따라 다른 캐릭터들을 위치시킵니다.

 

내 캐릭터 또한 다른 사용자들이 보는 모습과 같으며, 나의 좌표 및 채팅 로그도 똑같이 남으므로 타 사용자도 마찬가지로 내가 있음을 확인할 수 있습니다.

 

캐릭터가 움직이는 공간과 채팅 박스를 구현하였으므로, 고유ID에 이미지를 씌우고 배경화면 이미지를 바꾸고, 채팅박스 UI를 다듬는 등 작업을 거치면 훨씬 더 게임같은 공간을 만들어낼 수 있습니다.

 

그리고 캐릭터간 상호작용(ex.가위바위보 기능) 등 추가 기능들은 얼마든 확장할 수 있기에 확장 가능성 및 아이디어를 표현하기도 좋을 것 같습니다.

 

 

( 이 프로젝트는 ChatGPT 질의응답을 통해 구현하였습니다. )