diff --git a/src/main/java/game/multi/ws/MultiWebSocket.java b/src/main/java/game/multi/ws/MultiWebSocket.java index b1907c0..9ef12dc 100644 --- a/src/main/java/game/multi/ws/MultiWebSocket.java +++ b/src/main/java/game/multi/ws/MultiWebSocket.java @@ -26,13 +26,11 @@ public class MultiWebSocket { private static final MultiGameService service = new MultiGameService(); - private static final MultiPlayerDAO multiPlayerDao = new MultiPlayerDAOImpl(); - /* + /* * room 단위 세션/유저/닉/슬롯 캐시 - * - 이모지 브로드캐스트를 해당 room에만 하기 위함 - * - GAME_MULTI_START 메시지를 dispatch에서 감지해 slot을 캐싱함 + * - 이모지/닉네임을 해당 room에만 브로드캐스트하기 위함 */ private static final Map sessionRoomMap = new ConcurrentHashMap<>(); // sessionId -> roomId private static final Map sessionUserMap = new ConcurrentHashMap<>(); // sessionId -> userId @@ -46,30 +44,29 @@ public class MultiWebSocket { @OnOpen public void onOpen(Session session, EndpointConfig config) { try { - // HttpSession에서 userId 가져오기 + /* HttpSession에서 userId 가져오기 */ HttpSession httpSession = (HttpSession)config.getUserProperties().get(HttpSession.class.getName()); String userId = null; if (httpSession != null) { - userId = (String)httpSession.getAttribute("loginUserId"); + userId = (String)httpSession.getAttribute("loginUserId"); // // 개인전이랑 동일 키 } - // URL 쿼리 스트링에서 roomId 파싱 + /* URL 쿼리 스트링에서 roomId 파싱 */ String query = session.getRequestURI().getQuery(); String roomId = getParameterValue(query, "roomId"); - if (roomId == null || roomId.trim().isEmpty()) { - roomId = "default"; // 방 ID가 없을 때 + roomId = "default"; } - // DB로 방 멤버 검증 + /* DB로 방 멤버 검증 */ if (!multiPlayerDao.isMember(roomId, userId)) { session.close(); return; } - /* + /* * room/session 캐시 등록 - * SingleWebSocket과 동일 -> loginNickname 사용 + * - SingleWebSocket과 동일 -> loginNickname 사용 */ sessionRoomMap.put(session.getId(), roomId); if (userId != null) @@ -77,17 +74,16 @@ public void onOpen(Session session, EndpointConfig config) { String nickname = null; if (httpSession != null) { - nickname = (String)httpSession.getAttribute("loginNickname"); + 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); + roomSessions.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>()).put(session.getId(), session); - // Service에 roomId와 userId 함께 전달 + /* Service에 roomId와 userId 함께 전달 */ List jobs = service.handleOpen(session, roomId, userId); dispatch(session, jobs); @@ -96,7 +92,6 @@ public void onOpen(Session session, EndpointConfig config) { } } - // 쿼리 스트링 파싱 헬퍼 메서드 private String getParameterValue(String query, String name) { if (query == null) return null; @@ -112,10 +107,10 @@ private String getParameterValue(String query, String name) { @OnMessage public void onMessage(String msg, Session session) { try { - /* - * 이모지 채팅: "EMOJI_CHAT:smile" 텍스트 프로토콜 + /* + * 이모지 채팅: EMOJI_CHAT:key 텍스트 프로토콜 * - 같은 방에 브로드캐스트 - * - SingleWebSocket과 동일한 JSON 포맷(type/payload) + slot 추가 + * - JSON(type/payload) + slot 추가 */ if (msg != null && msg.startsWith("EMOJI_CHAT:")) { String emojiKey = msg.substring("EMOJI_CHAT:".length()).trim(); @@ -151,7 +146,7 @@ public void onMessage(String msg, Session session) { String json = gson.toJson(root); - // room 내 세션에게만 전송 + /* room 내 세션에게만 전송 */ Map targets = roomSessions.get(roomId); if (targets != null) { for (Session s : targets.values()) { @@ -206,27 +201,25 @@ public void onError(Session session, Throwable t) { } private void dispatch(Session fallback, List jobs) { - // 전체에게 보내야 할 경우를 대비해 현재 열려있는 모든 세션을 가져옴 - // 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()); + /* GAME_MULTI_START를 감지해서 slot 캐싱 + 닉네임 브로드캐스트 */ + Integer slot = cacheSlotIfStart(job.target(), job.text()); + if (slot != null) { + /* 현재 방에 캐시된 유저들 닉네임 전부 보내기 */ + sendAllKnownUsersTo(job.target()); + + /* 방 전체에 이번 target의 slot/nick 알려주기 */ + broadcastMultiUserForTarget(job.target(), slot); + } } - // ================================================== - // 1. 전체 전송 (Broadcast) + /* 1. 전체 전송 (Broadcast) */ if (job.target() == null) { - // 타겟이 없으면(null) => 브로드캐스트 (전체 전송) for (Session s : fallback.getOpenSessions()) { if (s.isOpen()) { try { - // 충돌 방지용 동기화 처리 synchronized (s) { s.getBasicRemote().sendText(job.text()); } @@ -234,7 +227,7 @@ private void dispatch(Session fallback, List jobs) { } } } - // 2. 개별 전송 (Unicast) + /* 2. 개별 전송 (Unicast) */ else { if (job.target().isOpen()) { try { @@ -248,26 +241,114 @@ private void dispatch(Session fallback, List jobs) { } } - /* GAME_MULTI_START 메시지에서 slot을 캐싱 */ - private void cacheSlotIfStart(Session target, String text) { + /* + * GAME_MULTI_START 메시지에서 slot을 캐싱 + * - slot을 캐싱했으면 slot 값을 반환 + */ + private Integer cacheSlotIfStart(Session target, String text) { try { if (text == null) - return; - // 파싱 최소화 + return null; if (!text.contains("\"type\"") || !text.contains("GAME_MULTI_START")) - return; + return null; JsonObject obj = gson.fromJson(text, JsonObject.class); if (obj == null || !obj.has("type")) - return; + return null; String type = obj.get("type").getAsString(); if (!"GAME_MULTI_START".equals(type)) - return; + return null; if (obj.has("slot")) { int slot = obj.get("slot").getAsInt(); sessionSlotMap.put(target.getId(), slot); + return slot; + } + } catch (Exception ignore) {} + return null; + } + + /* + * (추가) MULTI_USER 메시지 생성 + * payload = { slot, userId, nickname } + */ + private String buildMultiUserJson(int slot, String userId, String nickname) { + JsonObject root = new JsonObject(); + root.addProperty("type", "MULTI_USER"); + + JsonObject payload = new JsonObject(); + payload.addProperty("slot", slot); + if (userId != null) + payload.addProperty("userId", userId); + payload.addProperty("nickname", + (nickname == null || nickname.isBlank()) ? (userId != null ? userId : "unknown") : nickname); + + root.add("payload", payload); + return gson.toJson(root); + } + + /* + * 특정 세션(target)에게 현재 room의 캐시된 유저들을 전부 보내기 + * - 늦게 들어온 사람도 기존 3명의 닉네임을 카드에 채울 수 있게 함 + */ + private void sendAllKnownUsersTo(Session target) { + try { + String roomId = sessionRoomMap.get(target.getId()); + if (roomId == null) + return; + + Map targets = roomSessions.get(roomId); + if (targets == null) + return; + + for (Session s : targets.values()) { + if (s == null) + continue; + + Integer slot = sessionSlotMap.get(s.getId()); + if (slot == null) + continue; // // slot 확정된 사람만 + + String uid = sessionUserMap.get(s.getId()); + String nick = sessionNickMap.get(s.getId()); + + String json = buildMultiUserJson(slot, uid, nick); + + if (target.isOpen()) { + synchronized (target) { + target.getBasicRemote().sendText(json); + } + } + } + } catch (Exception ignore) {} + } + + /* + * 방 전체에게 "target의 slot/nick"을 브로드캐스트 + */ + private void broadcastMultiUserForTarget(Session target, int slot) { + try { + String roomId = sessionRoomMap.get(target.getId()); + if (roomId == null) + return; + + String uid = sessionUserMap.get(target.getId()); + String nick = sessionNickMap.get(target.getId()); + String json = buildMultiUserJson(slot, uid, nick); + + Map targets = roomSessions.get(roomId); + if (targets == null) + return; + + for (Session s : targets.values()) { + if (s != null && s.isOpen()) { + try { + synchronized (s) { + s.getBasicRemote().sendText(json); + } + } catch (Exception ignore) {} + } } } catch (Exception ignore) {} } diff --git a/src/main/webapp/WEB-INF/views/game/multi.jsp b/src/main/webapp/WEB-INF/views/game/multi.jsp index eed1740..d2d36f0 100644 --- a/src/main/webapp/WEB-INF/views/game/multi.jsp +++ b/src/main/webapp/WEB-INF/views/game/multi.jsp @@ -8,302 +8,359 @@ 2 vs 2 Team Omok - - + + - -

2:2 팀전 오목 게임

-
대기 중...
- -
- -
+ +
+

2:2 팀전 오목 게임

+
대기 중...
+ +
+
+
+
P1
- -
+
+
+
-
P2
+
P4
+
+
+ +
+ -
+
+
+ +
+
+ + + +
+
EMOJI: 준비
+
+ + +
+ +
+
+
P3
- -
+
+
+
-
P4
+
P2
+
+
+ + - - - + if (data.type === "MULTI_TURN") { + if (gameOver) return; + + isMyTurn = (data.turnIdx === myIdx); + startTimer(data.time, data.color); + + const displayTurnIdx = data.turnIdx + 1; + + if (isMyTurn) { + statusDiv.innerText = "나의 차례입니다!"; + statusDiv.style.color = "red"; + } else if (data.color === myColor) { + statusDiv.innerText = "같은 팀 " + displayTurnIdx + "번의 차례입니다."; + statusDiv.style.color = "blue"; + } else { + statusDiv.innerText = "상대방(" + displayTurnIdx + "번) 차례입니다."; + statusDiv.style.color = "black"; + } + } + + if (data.type === "MULTI_STONE") { + drawStone(data.x, data.y, data.color); + } + + if (data.type === "MULTI_WIN") { + gameOver = true; + clearInterval(timer); + + const msg = (data.color === 1 ? "흑돌 팀 승리!!" : "백돌 팀 승리!!"); + alert(myColor === data.color ? "승리했습니다! 축하합니다." : "패배했습니다."); + log(msg); + statusDiv.innerText = msg; + + goToRoomView(); + } + + if (data.type === "error" || data.type === "GAME_OVER") { + gameOver = true; + clearInterval(timer); + log(data.msg); + alert(data.msg); + + if (data.type === "GAME_OVER") goToRoomView(); + } + + /* 이모지 수신(JSON) */ + if (data.type === "EMOJI_CHAT") { + if (typeof window.onEmojiChat === "function") { + window.onEmojiChat(data.payload || {}); + } + return; + } + + /* 닉네임/슬롯 수신 */ + if (data.type === "MULTI_USER") { + const p = data.payload || {}; + const slot = p.slot; + const nick = p.nickname; + + const card = document.querySelector(`.player-card[data-slot='${slot}']`); + if (card) { + const nameEl = card.querySelector(".name"); + if (nameEl && nick) nameEl.textContent = nick; + } + return; + } + } + + function giveUp() { + if (confirm("정말 기권하시겠습니까?")) { + ws.send(JSON.stringify({ type: "MULTI_GIVEUP" })); + } + } + + canvas.addEventListener("click", (e) => { + if (ws.readyState !== WebSocket.OPEN || gameOver || !isMyTurn) return; + + const rect = canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / size); + const y = Math.floor((e.clientY - rect.top) / size); + + if (x >= 0 && x < 15 && y >= 0 && y < 15) { + ws.send(JSON.stringify({ x: x, y: y })); + } + }); + + function goToRoomView() { + try { ws.close(); } catch (e) {} + + fetch(contextPath + "/room/playersToRoom", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, + body: "roomId=" + encodeURIComponent(roomId) + }) + .then(res => res.json()) + .then(data => { + setTimeout(() => { + location.href = contextPath + "/room?roomId=" + encodeURIComponent(roomId) + "&playType=1"; + }, 3000); + }) + .catch(() => { + setTimeout(() => { + location.href = contextPath + "/room?roomId=" + encodeURIComponent(roomId) + "&playType=1"; + }, 3000); + }); + } + + function startTimer(sec, turnColor) { + clearInterval(timer); + remainsec = sec; + updateTimerText(turnColor, remainsec); + + timer = setInterval(() => { + remainsec--; + updateTimerText(turnColor, remainsec); + if (remainsec <= 0) clearInterval(timer); + }, 1000); + } + + function updateTimerText(color, sec) { + const timerDiv = document.getElementById("timer"); + const colorName = (color === 1 ? "흑돌" : "백돌"); + timerDiv.innerText = colorName + "턴 | 남은 시간: " + sec + "초"; + timerDiv.style.color = (sec <= 5 ? "red" : "black"); + } + + function drawBoard() { + ctx.fillStyle = "#e3c986"; // 바닥 색 + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.beginPath(); + ctx.lineWidth = 1; + ctx.strokeStyle = "#000"; + + for (let i = 0; i < 15; i++) { + ctx.moveTo(size / 2, size * i + size / 2); + ctx.lineTo(size * 14 + size / 2, size * i + size / 2); + ctx.moveTo(size * i + size / 2, size / 2); + ctx.lineTo(size * i + size / 2, size * 14 + size / 2); + } + ctx.stroke(); + } + + function drawStone(x, y, color) { + ctx.beginPath(); + ctx.arc(x * size + size / 2, y * size + size / 2, 12, 0, Math.PI * 2); + ctx.fillStyle = (color === 1 ? "black" : "white"); + ctx.fill(); + if (color === 2) { + ctx.strokeStyle = "black"; + ctx.stroke(); + } + } + + function log(msg) { + const logDiv = document.getElementById("log"); + logDiv.innerHTML += msg + "
"; + logDiv.scrollTop = logDiv.scrollHeight; + } + + + + +
diff --git a/src/main/webapp/static/chat/emojiChatMulti.js b/src/main/webapp/static/chat/emojiChatMulti.js index 8d4a568..47ceb39 100644 --- a/src/main/webapp/static/chat/emojiChatMulti.js +++ b/src/main/webapp/static/chat/emojiChatMulti.js @@ -1,16 +1,19 @@ (() => { const statusEl = document.querySelector("#emoji-ws-status"); + /* 이모지 매핑 */ const EMOJI_MAP = { smile: "🙂", angry: "😡", clap: "👏" }; function setStatus(t) { if (statusEl) statusEl.textContent = t; } + /* slot으로 카드 찾기 */ function getCardBySlot(slot) { return document.querySelector(`.player-card[data-slot='${slot}']`); } + /* slot 카드에 이모지 버블 표시 */ function showBubbleOnSlot(slot, emoji) { const card = getCardBySlot(slot); if (!card) return; @@ -29,22 +32,45 @@ } /* - 서버 → 이모지 수신 - payload = { slot, emoji } - */ + * 서버 → 이모지 수신 + * payload = { slot, emoji, from?, fromNick? } + */ window.onEmojiChat = (payload) => { if (!payload) return; - if (payload.slot === undefined || !payload.emoji) return; - const slot = payload.slot; + const slot = payload.slot; // 서버와 키 일치 const key = payload.emoji; const emoji = EMOJI_MAP[key] || key; + if (typeof slot !== "number") return; + showBubbleOnSlot(slot, emoji); }; + /* + * 서버 → 슬롯/닉네임 수신 + * payload = { slot, userId, nickname } + */ + window.onMultiUser = (payload) => { + if (!payload) return; + + const slot = payload.slot; + const nickname = payload.nickname; + + if (typeof slot !== "number") return; + + const card = getCardBySlot(slot); + if (!card) return; + + const nameEl = card.querySelector(".name"); + if (nameEl && nickname) { + nameEl.textContent = nickname; + } + }; + + /* 이모지 전송 */ function sendEmoji(key) { - const ws = window.singleWs; + const ws = window.singleWs; // multi에서도 동일 소켓 변수 사용 if (!ws || ws.readyState !== WebSocket.OPEN) { setStatus("EMOJI: WS 미연결"); return; @@ -52,16 +78,26 @@ const emoji = EMOJI_MAP[key] || key; - /* 내 슬롯 */ - showBubbleOnSlot(window.mySlot, emoji); + /* 내 슬롯 카드에 즉시 표시 */ + if (typeof window.mySlot === "number") { + 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)); + /* 버튼 바인딩 */ + document + .querySelectorAll(".emoji-buttons button[data-emoji]") + .forEach((b) => { + b.addEventListener("click", () => { + sendEmoji(b.dataset.emoji); + }); + }); setStatus("EMOJI: 준비됨"); })();