- 프로젝트 목표
- 실시간 웹 기반 상호작용 공간 구현
- 2D 환경에서 고유 캐릭터로 참여
- 실시간 채팅을 통한 사용자 간 커뮤니케이션
- 기술 스택
- Phaser.js: 게임 로직과 캐릭터 움직임, 물리 엔진
- WebSocket: 실시간 채팅 및 캐릭터 위치 동기화
- Spring Boot: 서버 측 WebSocket 관리
- HTML/CSS/JavaScript: UI 및 게임 인터페이스
- 기능 소개
- 사용자 ID 부여: 각 사용자는 고유 6자리 ID를 통해 식별
- 캐릭터 움직임: 화살표 키를 사용해 캐릭터 이동
- 실시간 동기화: WebSocket을 통한 여러 브라우저 간 캐릭터 동기화
- 채팅 기능: 사용자 간 실시간 텍스트 기반 채팅 가능
긴 세션ID를 6자리로 줄인 문자열을 ID 및 캐릭터로 사용했습니다.
검은 화면에 떠있는 6자리 코드는 방향키로 움직일 수 있는 캐릭터입니다.
[ 목차 ]
- 5개의 클래스를 작성합니다.
- game.html을 작성합니다.
- 실행 여부 확인
[ 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는 물리 엔진, 애니메이션, 상호작용 처리 기능을 제공하며, 게임 로직을 구축하는 데 필요한 다양한 도구들을 제공합니다.
[ 실행 결과 ]
이 웹소켓 영역에 접속한 사용자들은 각자 6자리의 고유ID를 가지게 되며, 이 ID를 캐릭터로 사용합니다.
방향키를 사용하여 캐릭터를 움직일 수 있고, 다른 접속자가 움직이면 다른 접속자가 있음을 화면 캐릭터로 알 수 있습니다.
그리고 현재 접속중인 유저끼리 채팅이 가능한 박스도 마련해 두었습니다.
다른 사용자가 움직이면 고유 ID와 함께 좌표 및 채팅 로그가 남습니다.
이 정보를 바탕으로 다른 사용자의 ID모양 캐릭터를 만들고, 좌표에 따라 다른 캐릭터들을 위치시킵니다.
내 캐릭터 또한 다른 사용자들이 보는 모습과 같으며, 나의 좌표 및 채팅 로그도 똑같이 남으므로 타 사용자도 마찬가지로 내가 있음을 확인할 수 있습니다.
캐릭터가 움직이는 공간과 채팅 박스를 구현하였으므로, 고유ID에 이미지를 씌우고 배경화면 이미지를 바꾸고, 채팅박스 UI를 다듬는 등 작업을 거치면 훨씬 더 게임같은 공간을 만들어낼 수 있습니다.
그리고 캐릭터간 상호작용(ex.가위바위보 기능) 등 추가 기능들은 얼마든 확장할 수 있기에 확장 가능성 및 아이디어를 표현하기도 좋을 것 같습니다.
( 이 프로젝트는 ChatGPT 질의응답을 통해 구현하였습니다. )
'스프링부트 구현' 카테고리의 다른 글
2D 웹 온라인 게임 공간 및 채팅을 구현하기 [웹소켓/Phaser.js] (0) | 2024.10.14 |
---|---|
스프링부트로 실시간 채팅을 구현해보자 [웹소켓] (1) | 2024.10.04 |