diff --git a/src/main/java/game/multi/ws/MultiWebSocket.java b/src/main/java/game/multi/ws/MultiWebSocket.java index 8a013f4..b1907c0 100644 --- a/src/main/java/game/multi/ws/MultiWebSocket.java +++ b/src/main/java/game/multi/ws/MultiWebSocket.java @@ -1,6 +1,8 @@ package game.multi.ws; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import javax.servlet.http.HttpSession; import javax.websocket.EndpointConfig; @@ -11,6 +13,9 @@ import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; +import com.google.gson.Gson; +import com.google.gson.JsonObject; + import config.WebSocketConfig; import game.multi.dao.MultiPlayerDAO; import game.multi.dao.MultiPlayerDAOImpl; @@ -21,58 +26,147 @@ public class MultiWebSocket { private static final MultiGameService service = new MultiGameService(); - + private static final MultiPlayerDAO multiPlayerDao = new MultiPlayerDAOImpl(); + /* + * room 단위 세션/유저/닉/슬롯 캐시 + * - 이모지 브로드캐스트를 해당 room에만 하기 위함 + * - GAME_MULTI_START 메시지를 dispatch에서 감지해 slot을 캐싱함 + */ + private static final Map sessionRoomMap = new ConcurrentHashMap<>(); // sessionId -> roomId + private static final Map sessionUserMap = new ConcurrentHashMap<>(); // sessionId -> userId + private static final Map sessionNickMap = new ConcurrentHashMap<>(); // sessionId -> nickname + private static final Map sessionSlotMap = new ConcurrentHashMap<>(); // sessionId -> slot(0~3) + + private static final Map> roomSessions = new ConcurrentHashMap<>(); // roomId -> (sessionId -> Session) + + private static final Gson gson = new Gson(); + @OnOpen - public void onOpen(Session session, EndpointConfig config) { - try { - // HttpSession에서 userId 가져오기 - HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName()); - String userId = null; - if (httpSession != null) { - userId = (String) httpSession.getAttribute("loginUserId"); - } - - // URL 쿼리 스트링에서 roomId 파싱 - String query = session.getRequestURI().getQuery(); - String roomId = getParameterValue(query, "roomId"); - - if (roomId == null || roomId.trim().isEmpty()) { - // 방 ID가 없으면 에러 처리 혹은 기본방("default") - roomId = "default"; - } - - // DB로 방 멤버 검증 - if (!multiPlayerDao.isMember(roomId, userId)) { - session.close(); - return; - } - - // Service에 roomId와 userId 함께 전달 - List jobs = service.handleOpen(session, roomId, userId); - dispatch(session, jobs); - - } catch (Exception e) { - e.printStackTrace(); - } - } - + public void onOpen(Session session, EndpointConfig config) { + try { + // HttpSession에서 userId 가져오기 + HttpSession httpSession = (HttpSession)config.getUserProperties().get(HttpSession.class.getName()); + String userId = null; + if (httpSession != null) { + userId = (String)httpSession.getAttribute("loginUserId"); + } + + // URL 쿼리 스트링에서 roomId 파싱 + String query = session.getRequestURI().getQuery(); + String roomId = getParameterValue(query, "roomId"); + + if (roomId == null || roomId.trim().isEmpty()) { + roomId = "default"; // 방 ID가 없을 때 + } + + // DB로 방 멤버 검증 + if (!multiPlayerDao.isMember(roomId, userId)) { + session.close(); + return; + } + + /* + * room/session 캐시 등록 + * SingleWebSocket과 동일 -> loginNickname 사용 + */ + sessionRoomMap.put(session.getId(), roomId); + if (userId != null) + sessionUserMap.put(session.getId(), userId); + + String nickname = null; + if (httpSession != null) { + nickname = (String)httpSession.getAttribute("loginNickname"); + } + if (nickname == null || nickname.isBlank()) { + nickname = (userId != null ? userId : "unknown"); + } + sessionNickMap.put(session.getId(), nickname); + + roomSessions.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>()) + .put(session.getId(), session); + + // Service에 roomId와 userId 함께 전달 + List jobs = service.handleOpen(session, roomId, userId); + dispatch(session, jobs); + + } catch (Exception e) { + e.printStackTrace(); + } + } + // 쿼리 스트링 파싱 헬퍼 메서드 - private String getParameterValue(String query, String name) { - if (query == null) return null; - for (String param : query.split("&")) { - String[] pair = param.split("="); - if (pair.length > 1 && pair[0].equals(name)) { - return pair[1]; - } - } - return null; - } + private String getParameterValue(String query, String name) { + if (query == null) + return null; + for (String param : query.split("&")) { + String[] pair = param.split("="); + if (pair.length > 1 && pair[0].equals(name)) { + return pair[1]; + } + } + return null; + } @OnMessage public void onMessage(String msg, Session session) { try { + /* + * 이모지 채팅: "EMOJI_CHAT:smile" 텍스트 프로토콜 + * - 같은 방에 브로드캐스트 + * - SingleWebSocket과 동일한 JSON 포맷(type/payload) + slot 추가 + */ + if (msg != null && msg.startsWith("EMOJI_CHAT:")) { + String emojiKey = msg.substring("EMOJI_CHAT:".length()).trim(); + if (emojiKey.isEmpty()) + return; + + String roomId = sessionRoomMap.get(session.getId()); + if (roomId == null) + return; + + String userId = sessionUserMap.get(session.getId()); + String nickname = sessionNickMap.get(session.getId()); + if (nickname == null || nickname.isBlank()) + nickname = (userId != null ? userId : "unknown"); + + int slot = -1; + Integer cachedSlot = sessionSlotMap.get(session.getId()); + if (cachedSlot != null) + slot = cachedSlot; + + JsonObject root = new JsonObject(); + root.addProperty("type", "EMOJI_CHAT"); + + JsonObject payload = new JsonObject(); + if (userId != null) + payload.addProperty("from", userId); + payload.addProperty("fromNick", nickname); + payload.addProperty("emoji", emojiKey); + if (slot >= 0) + payload.addProperty("slot", slot); + + root.add("payload", payload); + + String json = gson.toJson(root); + + // room 내 세션에게만 전송 + Map targets = roomSessions.get(roomId); + if (targets != null) { + for (Session s : targets.values()) { + if (s != null && s.isOpen()) { + try { + synchronized (s) { + s.getBasicRemote().sendText(json); + } + } catch (Exception ignore) {} + } + } + } + return; + } + List jobs = service.handleMessage(session, msg); dispatch(session, jobs); } catch (Exception e) { @@ -83,6 +177,22 @@ public void onMessage(String msg, Session session) { @OnClose public void onClose(Session session) { try { + /* room/session 캐시 정리 */ + String roomId = sessionRoomMap.remove(session.getId()); + sessionUserMap.remove(session.getId()); + sessionNickMap.remove(session.getId()); + sessionSlotMap.remove(session.getId()); + + if (roomId != null) { + Map targets = roomSessions.get(roomId); + if (targets != null) { + targets.remove(session.getId()); + if (targets.isEmpty()) { + roomSessions.remove(roomId); + } + } + } + List jobs = service.handleClose(session); dispatch(session, jobs); } catch (Exception e) { @@ -97,24 +207,33 @@ public void onError(Session session, Throwable t) { private void dispatch(Session fallback, List jobs) { // 전체에게 보내야 할 경우를 대비해 현재 열려있는 모든 세션을 가져옴 - // Service가 target=null을 주면 "모두에게" 보냄 + // Service가 target=null을 주면 모두에게 보냄 for (SendJob job : jobs) { try { + /* + * GAME_MULTI_START를 감지해서 slot 캐싱 + * GameRoom이 보내는 {"type":"GAME_MULTI_START","slot":i,...}를 여기서 읽어둠 + */ + if (job.target() != null) { + cacheSlotIfStart(job.target(), job.text()); + } + // ================================================== + // 1. 전체 전송 (Broadcast) if (job.target() == null) { // 타겟이 없으면(null) => 브로드캐스트 (전체 전송) for (Session s : fallback.getOpenSessions()) { if (s.isOpen()) { try { - // 동기화 처리로 충돌 방지 + // 충돌 방지용 동기화 처리 synchronized (s) { s.getBasicRemote().sendText(job.text()); } - } catch (Exception e) { } + } catch (Exception e) {} } } - } + } // 2. 개별 전송 (Unicast) else { if (job.target().isOpen()) { @@ -122,10 +241,34 @@ private void dispatch(Session fallback, List jobs) { synchronized (job.target()) { job.target().getBasicRemote().sendText(job.text()); } - } catch (Exception e) { } + } catch (Exception e) {} } } - } catch (Exception ignore) { } + } catch (Exception ignore) {} } } -} \ No newline at end of file + + /* GAME_MULTI_START 메시지에서 slot을 캐싱 */ + private void cacheSlotIfStart(Session target, String text) { + try { + if (text == null) + return; + // 파싱 최소화 + if (!text.contains("\"type\"") || !text.contains("GAME_MULTI_START")) + return; + + JsonObject obj = gson.fromJson(text, JsonObject.class); + if (obj == null || !obj.has("type")) + return; + + String type = obj.get("type").getAsString(); + if (!"GAME_MULTI_START".equals(type)) + return; + + if (obj.has("slot")) { + int slot = obj.get("slot").getAsInt(); + sessionSlotMap.put(target.getId(), slot); + } + } catch (Exception ignore) {} + } +} diff --git a/src/main/webapp/WEB-INF/views/chat/gameEmoji.jsp b/src/main/webapp/WEB-INF/views/chat/gameEmoji.jsp index aab9212..c3fd765 100644 --- a/src/main/webapp/WEB-INF/views/chat/gameEmoji.jsp +++ b/src/main/webapp/WEB-INF/views/chat/gameEmoji.jsp @@ -1,6 +1,6 @@ <%@ page contentType="text/html; charset=UTF-8" %> - +
diff --git a/src/main/webapp/WEB-INF/views/game/multi.jsp b/src/main/webapp/WEB-INF/views/game/multi.jsp index ce34d06..eed1740 100644 --- a/src/main/webapp/WEB-INF/views/game/multi.jsp +++ b/src/main/webapp/WEB-INF/views/game/multi.jsp @@ -6,19 +6,69 @@ 2 vs 2 Team Omok + + + +

2:2 팀전 오목 게임

대기 중...
- + +
+ + +
+
+
P1
+
+
+ +
+
+
P2
+
+
+ +
+
+
P3
+
+
+ +
+
+
P4
+
+
+
+
- + +
+
+ + + +
+
EMOJI: 준비
+
+ + + + - \ No newline at end of file + diff --git a/src/main/webapp/static/chat/emojiChat.js b/src/main/webapp/static/chat/emojiChat.js index 9f77ef8..f210355 100644 --- a/src/main/webapp/static/chat/emojiChat.js +++ b/src/main/webapp/static/chat/emojiChat.js @@ -1,14 +1,13 @@ (() => { const statusEl = document.querySelector("#emoji-ws-status"); - const leftCard = document.querySelector("#p1"); - const rightCard = document.querySelector("#p2"); + const p1 = document.querySelector("#p1"); // 상대 + const p2 = document.querySelector("#p2"); // 나 - const leftNameEl = leftCard?.querySelector(".name"); - const rightNameEl = rightCard?.querySelector(".name"); - - const leftBubble = leftCard?.querySelector(".bubble"); - const rightBubble = rightCard?.querySelector(".bubble"); + const leftNameEl = p1 ? p1.querySelector(".name") : null; + const rightNameEl = p2 ? p2.querySelector(".name") : null; + const leftBubble = p1 ? p1.querySelector(".bubble") : null; + const rightBubble = p2 ? p2.querySelector(".bubble") : null; const btns = document.querySelectorAll(".emoji-buttons button[data-emoji]"); @@ -37,11 +36,11 @@ function setMyName(name) { if (rightNameEl && name) rightNameEl.textContent = name; } function setOppName(name) { if (leftNameEl && name) leftNameEl.textContent = name; } - /* 초기 내 닉네임(세션) */ + /* 초기 내 닉네임 */ setMyName(window.loginNickname || "나"); /* 서버가 접속자 정보 보내면 닉네임 반영 - payload = { userId, nickname } */ + payload = { userId, nickname } */ window.onSingleUser = (payload) => { if (!payload) return; @@ -58,8 +57,8 @@ } }; - /*서버가 이모지 보내면: 보낸 사람 카드 위에만 띄우기 - payload = { from, fromNick, emoji } */ + /* 서버가 이모지 보내면: 보낸 사람 카드 위에만 띄우기 + payload = { from, fromNick, emoji } */ window.onEmojiChat = (payload) => { if (!payload) return; @@ -70,14 +69,12 @@ const emoji = EMOJI_MAP[key] || key; - /* 혹시 닉네임이 같이 오면 즉시 반영(상대가 먼저 이모지 보내도 이름 뜨게) */ if (fromNick) { if (meId && from === meId) setMyName(fromNick); else setOppName(fromNick); } if (!meId) { - /* 로그인 정보가 없으면 디버깅용으로 둘 다 띄우지 말고 오른쪽만 */ showBubble(myBubble(), emoji); return; } @@ -93,16 +90,16 @@ return; } - /* 오른쪽 플레이어 카드 = 나 */ const emoji = EMOJI_MAP[key] || key; + + /* 오른쪽 플레이어 카드 = 나 */ showBubble(myBubble(), emoji); /* 서버로 전송 */ ws.send("EMOJI_CHAT:" + key); } - - /* 외부에서 호출 가능하도록 전역 노출 */ - window.sendEmoji = sendEmoji; + + window.sendEmoji = sendEmoji; btns.forEach((b) => { b.addEventListener("click", () => sendEmoji(b.dataset.emoji)); diff --git a/src/main/webapp/static/chat/emojiChatMulti.js b/src/main/webapp/static/chat/emojiChatMulti.js new file mode 100644 index 0000000..8d4a568 --- /dev/null +++ b/src/main/webapp/static/chat/emojiChatMulti.js @@ -0,0 +1,67 @@ +(() => { + const statusEl = document.querySelector("#emoji-ws-status"); + + const EMOJI_MAP = { smile: "🙂", angry: "😡", clap: "👏" }; + + function setStatus(t) { + if (statusEl) statusEl.textContent = t; + } + + function getCardBySlot(slot) { + return document.querySelector(`.player-card[data-slot='${slot}']`); + } + + function showBubbleOnSlot(slot, emoji) { + const card = getCardBySlot(slot); + if (!card) return; + + const bubble = card.querySelector(".bubble"); + if (!bubble) return; + + bubble.textContent = emoji; + bubble.style.display = "block"; + + clearTimeout(bubble._t); + bubble._t = setTimeout(() => { + bubble.style.display = "none"; + bubble.textContent = ""; + }, 1200); + } + + /* + 서버 → 이모지 수신 + payload = { slot, emoji } + */ + window.onEmojiChat = (payload) => { + if (!payload) return; + if (payload.slot === undefined || !payload.emoji) return; + + const slot = payload.slot; + const key = payload.emoji; + const emoji = EMOJI_MAP[key] || key; + + showBubbleOnSlot(slot, emoji); + }; + + function sendEmoji(key) { + const ws = window.singleWs; + if (!ws || ws.readyState !== WebSocket.OPEN) { + setStatus("EMOJI: WS 미연결"); + return; + } + + const emoji = EMOJI_MAP[key] || key; + + /* 내 슬롯 */ + showBubbleOnSlot(window.mySlot, emoji); + + ws.send("EMOJI_CHAT:" + key); + } + + window.sendEmoji = sendEmoji; + + document.querySelectorAll(".emoji-buttons button[data-emoji]") + .forEach(b => b.onclick = () => sendEmoji(b.dataset.emoji)); + + setStatus("EMOJI: 준비됨"); +})();